From ad0b88c7d07b576a607911f4bccdce70f480c15f Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 09:19:11 -0400 Subject: [PATCH 01/16] feat(storage): postgres backend, 51-method trait surface and 37 sqlx migrations --- Cargo.lock | 673 ++++++++++++++- Cargo.toml | 3 + crates/nostream-storage/Cargo.toml | 12 +- .../20220524152512_enable_uuid_ossp_ext.sql | 1 + .../20220524153400_create_events_table.sql | 16 + ...0220524221800_add_events_table_indexes.sql | 1 + ...00_add_replaceable_events_unique_index.sql | 1 + ...25204900_add_delegator_to_events_table.sql | 3 + ...7212200_add_is_deleted_to_events_table.sql | 1 + ...4400_add_deduplication_to_events_table.sql | 1 + ...ion_to_replaceable_events_unique_index.sql | 11 + ...plication_field_for_replaceable_events.sql | 5 + ...hat_to_replaceable_events_unique_index.sql | 33 + ...120900_add_pubkey_delegator_kind_index.sql | 1 + .../20230107230900_create_invoices_table.sql | 16 + .../20230107230901_create_users_table.sql | 8 + .../20230118190000_confirm_invoice_func.sql | 35 + ...9170900_add_kind_tags_created_at_index.sql | 4 + .../20230120161800_charge_user_func.sql | 18 + ...000_add_remote_address_to_events_table.sql | 1 + ...3185700_add_expires_at_to_events_table.sql | 3 + ...0205004400_change_invoice_id_to_string.sql | 27 + ...03904_add_verify_url_to_invoices_table.sql | 1 + ...35600_scale_balance_addition_with_unit.sql | 30 + ...20002700_fix_unit_confirm_invoice_func.sql | 34 + .../20240108130100_add_event_tags_table.sql | 52 ++ ...900_remove_delegator_from_events_table.sql | 3 + ...20000_create_nip05_verifications_table.sql | 15 + .../20260409201624_admit_user_func.sql | 18 + ...0230000_add_is_vanished_to_users_table.sql | 10 + ..._normalize_parameterized_deduplication.sql | 21 + .../20260420120000_add_hot_path_indexes.sql | 18 + .../20260427000000_add_nip50_fts_index.sql | 2 + ...120000_create_nip43_invite_codes_table.sql | 13 + .../20260812150000_create_dvm_jobs_table.sql | 18 + ...829120000_backfill_null_invoice_expiry.sql | 4 + .../20260910120000_create_reports_table.sql | 18 + ...20000_create_notification_outbox_table.sql | 16 + ...create_notification_delivery_log_table.sql | 18 + ...6120000_add_actionable_reports_indexes.sql | 12 + crates/nostream-storage/src/error.rs | 76 ++ crates/nostream-storage/src/lib.rs | 21 +- .../nostream-storage/src/postgres/events.rs | 451 ++++++++++ .../nostream-storage/src/postgres/invoices.rs | 146 ++++ .../nostream-storage/src/postgres/metadata.rs | 621 ++++++++++++++ crates/nostream-storage/src/postgres/mod.rs | 542 ++++++++++++ .../src/postgres/notifications.rs | 386 +++++++++ .../src/postgres/repository.rs | 587 +++++++++++++ crates/nostream-storage/src/postgres/rows.rs | 313 +++++++ crates/nostream-storage/src/postgres/sql.rs | 724 ++++++++++++++++ crates/nostream-storage/src/postgres/users.rs | 173 ++++ crates/nostream-storage/src/records.rs | 381 +++++++++ crates/nostream-storage/src/repositories.rs | 808 ++++++++++++------ crates/nostream-storage/tests/storage.rs | 403 +++++++-- 54 files changed, 6448 insertions(+), 361 deletions(-) create mode 100644 crates/nostream-storage/migrations/20220524152512_enable_uuid_ossp_ext.sql create mode 100644 crates/nostream-storage/migrations/20220524153400_create_events_table.sql create mode 100644 crates/nostream-storage/migrations/20220524221800_add_events_table_indexes.sql create mode 100644 crates/nostream-storage/migrations/20220809190400_add_replaceable_events_unique_index.sql create mode 100644 crates/nostream-storage/migrations/20220825204900_add_delegator_to_events_table.sql create mode 100644 crates/nostream-storage/migrations/20220827212200_add_is_deleted_to_events_table.sql create mode 100644 crates/nostream-storage/migrations/20221030134400_add_deduplication_to_events_table.sql create mode 100644 crates/nostream-storage/migrations/20221030134500_add_dedulication_to_replaceable_events_unique_index.sql create mode 100644 crates/nostream-storage/migrations/20221108201600_set_deduplication_field_for_replaceable_events.sql create mode 100644 crates/nostream-storage/migrations/20221224164100_add_group_chat_to_replaceable_events_unique_index.sql create mode 100644 crates/nostream-storage/migrations/20230105120900_add_pubkey_delegator_kind_index.sql create mode 100644 crates/nostream-storage/migrations/20230107230900_create_invoices_table.sql create mode 100644 crates/nostream-storage/migrations/20230107230901_create_users_table.sql create mode 100644 crates/nostream-storage/migrations/20230118190000_confirm_invoice_func.sql create mode 100644 crates/nostream-storage/migrations/20230119170900_add_kind_tags_created_at_index.sql create mode 100644 crates/nostream-storage/migrations/20230120161800_charge_user_func.sql create mode 100644 crates/nostream-storage/migrations/20230126230000_add_remote_address_to_events_table.sql create mode 100644 crates/nostream-storage/migrations/20230203185700_add_expires_at_to_events_table.sql create mode 100644 crates/nostream-storage/migrations/20230205004400_change_invoice_id_to_string.sql create mode 100644 crates/nostream-storage/migrations/20230213103904_add_verify_url_to_invoices_table.sql create mode 100644 crates/nostream-storage/migrations/20230217235600_scale_balance_addition_with_unit.sql create mode 100644 crates/nostream-storage/migrations/20230220002700_fix_unit_confirm_invoice_func.sql create mode 100644 crates/nostream-storage/migrations/20240108130100_add_event_tags_table.sql create mode 100644 crates/nostream-storage/migrations/20240111204900_remove_delegator_from_events_table.sql create mode 100644 crates/nostream-storage/migrations/20260409120000_create_nip05_verifications_table.sql create mode 100644 crates/nostream-storage/migrations/20260409201624_admit_user_func.sql create mode 100644 crates/nostream-storage/migrations/20260410230000_add_is_vanished_to_users_table.sql create mode 100644 crates/nostream-storage/migrations/20260419140000_normalize_parameterized_deduplication.sql create mode 100644 crates/nostream-storage/migrations/20260420120000_add_hot_path_indexes.sql create mode 100644 crates/nostream-storage/migrations/20260427000000_add_nip50_fts_index.sql create mode 100644 crates/nostream-storage/migrations/20260624120000_create_nip43_invite_codes_table.sql create mode 100644 crates/nostream-storage/migrations/20260812150000_create_dvm_jobs_table.sql create mode 100644 crates/nostream-storage/migrations/20260829120000_backfill_null_invoice_expiry.sql create mode 100644 crates/nostream-storage/migrations/20260910120000_create_reports_table.sql create mode 100644 crates/nostream-storage/migrations/20260919120000_create_notification_outbox_table.sql create mode 100644 crates/nostream-storage/migrations/20260919130000_create_notification_delivery_log_table.sql create mode 100644 crates/nostream-storage/migrations/20260926120000_add_actionable_reports_indexes.sql create mode 100644 crates/nostream-storage/src/postgres/events.rs create mode 100644 crates/nostream-storage/src/postgres/invoices.rs create mode 100644 crates/nostream-storage/src/postgres/metadata.rs create mode 100644 crates/nostream-storage/src/postgres/mod.rs create mode 100644 crates/nostream-storage/src/postgres/notifications.rs create mode 100644 crates/nostream-storage/src/postgres/repository.rs create mode 100644 crates/nostream-storage/src/postgres/rows.rs create mode 100644 crates/nostream-storage/src/postgres/sql.rs create mode 100644 crates/nostream-storage/src/postgres/users.rs create mode 100644 crates/nostream-storage/src/records.rs diff --git a/Cargo.lock b/Cargo.lock index 14b39ade..a214cc74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -78,12 +93,27 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" @@ -108,6 +138,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -159,7 +198,20 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", ] [[package]] @@ -168,8 +220,8 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.12.1", + "crypto-common 0.2.2", "inout", ] @@ -231,6 +283,21 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -240,6 +307,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crossbeam-queue" version = "0.3.14" @@ -255,6 +337,16 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -273,15 +365,25 @@ dependencies = [ "cmov", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "const-oid", - "crypto-common", + "crypto-common 0.2.2", "ctutils", ] @@ -296,6 +398,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "doxygen-rs" version = "0.4.2" @@ -310,6 +418,9 @@ name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] [[package]] name = "equivalent" @@ -327,6 +438,26 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -339,12 +470,29 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -361,6 +509,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -380,6 +529,23 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + [[package]] name = "futures-macro" version = "0.3.34" @@ -410,13 +576,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -438,6 +616,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -459,12 +638,32 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -530,7 +729,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -636,6 +835,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -747,7 +970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -803,6 +1026,16 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -826,6 +1059,15 @@ dependencies = [ "libc", ] +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -841,6 +1083,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" @@ -914,7 +1166,7 @@ dependencies = [ "secp256k1", "serde", "serde_json", - "sha2", + "sha2 0.11.1", "thiserror", "tokio", ] @@ -943,9 +1195,15 @@ dependencies = [ name = "nostream-storage" version = "0.1.0" dependencies = [ + "chrono", + "futures-core", "heed", "nostream-config", "nostream-core", + "serde", + "serde_json", + "serde_yaml", + "sqlx", "tempfile", "thiserror", "tokio", @@ -980,6 +1238,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1062,6 +1329,35 @@ dependencies = [ "winapi", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1136,6 +1432,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "portable-atomic" version = "1.15.0" @@ -1241,6 +1543,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1266,6 +1579,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -1308,6 +1636,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "secp256k1" version = "0.33.1" @@ -1383,6 +1717,28 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.1" @@ -1390,8 +1746,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1436,6 +1792,9 @@ name = "smallvec" version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -1447,12 +1806,207 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1", + "sha2 0.11.1", + "sqlx-core", + "thiserror", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.3", + "serde", + "serde_json", + "sha2 0.11.1", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1559,6 +2113,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.3" @@ -1696,6 +2256,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1779,12 +2340,33 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -1821,6 +2403,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.2" @@ -1900,6 +2494,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + [[package]] name = "winapi" version = "0.3.9" @@ -1922,12 +2522,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index f1617a30..4b1a9901 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,9 @@ repository = "https://github.com/cameri/nostream" anyhow = "1" base64 = "0.22.1" chacha20 = "0.10.2" +chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] } clap = { version = "4", features = ["derive"] } +futures-core = "0.3" heed = "0.20" hex = "0.4.3" hkdf = "0.13.0" @@ -35,6 +37,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" sha2 = "0.11.1" +sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "postgres", "migrate", "macros", "chrono", "json", "tls-none"] } tempfile = "3" thiserror = "2" tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } diff --git a/crates/nostream-storage/Cargo.toml b/crates/nostream-storage/Cargo.toml index ad75bd7e..1106ba3c 100644 --- a/crates/nostream-storage/Cargo.toml +++ b/crates/nostream-storage/Cargo.toml @@ -5,17 +5,23 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "Repository traits, backend selection and the LMDB environment slice" +description = "Repository traits, backend selection and the Postgres storage backend" [dependencies] +chrono.workspace = true +futures-core.workspace = true +heed.workspace = true nostream-config.workspace = true nostream-core.workspace = true -heed.workspace = true +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true thiserror.workspace = true +tokio.workspace = true [dev-dependencies] +serde_yaml.workspace = true tempfile.workspace = true -tokio.workspace = true [lints] workspace = true diff --git a/crates/nostream-storage/migrations/20220524152512_enable_uuid_ossp_ext.sql b/crates/nostream-storage/migrations/20220524152512_enable_uuid_ossp_ext.sql new file mode 100644 index 00000000..d2a0f39a --- /dev/null +++ b/crates/nostream-storage/migrations/20220524152512_enable_uuid_ossp_ext.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA public version "1.1"; diff --git a/crates/nostream-storage/migrations/20220524153400_create_events_table.sql b/crates/nostream-storage/migrations/20220524153400_create_events_table.sql new file mode 100644 index 00000000..286103f2 --- /dev/null +++ b/crates/nostream-storage/migrations/20220524153400_create_events_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE events ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + event_id bytea NOT NULL UNIQUE, + event_pubkey bytea NOT NULL, + event_kind integer NOT NULL, + event_created_at integer NOT NULL, + event_content text NOT NULL, + event_tags jsonb, + event_signature bytea NOT NULL, + first_seen timestamp DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX events_event_id_index ON events (event_id); +CREATE INDEX events_event_pubkey_index ON events (event_pubkey); +CREATE INDEX events_event_kind_index ON events (event_kind); +CREATE INDEX events_event_created_at_index ON events (event_created_at); diff --git a/crates/nostream-storage/migrations/20220524221800_add_events_table_indexes.sql b/crates/nostream-storage/migrations/20220524221800_add_events_table_indexes.sql new file mode 100644 index 00000000..3399137d --- /dev/null +++ b/crates/nostream-storage/migrations/20220524221800_add_events_table_indexes.sql @@ -0,0 +1 @@ +CREATE INDEX event_tags_idx ON events USING GIN ( event_tags ); diff --git a/crates/nostream-storage/migrations/20220809190400_add_replaceable_events_unique_index.sql b/crates/nostream-storage/migrations/20220809190400_add_replaceable_events_unique_index.sql new file mode 100644 index 00000000..891a37f8 --- /dev/null +++ b/crates/nostream-storage/migrations/20220809190400_add_replaceable_events_unique_index.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX replaceable_events_idx ON events ( event_pubkey, event_kind ) WHERE event_kind = 0 OR event_kind = 3 OR event_kind >= 10000 AND event_kind < 20000; diff --git a/crates/nostream-storage/migrations/20220825204900_add_delegator_to_events_table.sql b/crates/nostream-storage/migrations/20220825204900_add_delegator_to_events_table.sql new file mode 100644 index 00000000..471316dc --- /dev/null +++ b/crates/nostream-storage/migrations/20220825204900_add_delegator_to_events_table.sql @@ -0,0 +1,3 @@ +ALTER TABLE events ADD COLUMN event_delegator bytea NULL; + +CREATE INDEX events_event_delegator_index ON events (event_delegator); diff --git a/crates/nostream-storage/migrations/20220827212200_add_is_deleted_to_events_table.sql b/crates/nostream-storage/migrations/20220827212200_add_is_deleted_to_events_table.sql new file mode 100644 index 00000000..39f7f1e7 --- /dev/null +++ b/crates/nostream-storage/migrations/20220827212200_add_is_deleted_to_events_table.sql @@ -0,0 +1 @@ +ALTER TABLE events ADD COLUMN deleted_at timestamp NULL; diff --git a/crates/nostream-storage/migrations/20221030134400_add_deduplication_to_events_table.sql b/crates/nostream-storage/migrations/20221030134400_add_deduplication_to_events_table.sql new file mode 100644 index 00000000..7576aac7 --- /dev/null +++ b/crates/nostream-storage/migrations/20221030134400_add_deduplication_to_events_table.sql @@ -0,0 +1 @@ +ALTER TABLE events ADD COLUMN event_deduplication jsonb NULL; diff --git a/crates/nostream-storage/migrations/20221030134500_add_dedulication_to_replaceable_events_unique_index.sql b/crates/nostream-storage/migrations/20221030134500_add_dedulication_to_replaceable_events_unique_index.sql new file mode 100644 index 00000000..28865d9f --- /dev/null +++ b/crates/nostream-storage/migrations/20221030134500_add_dedulication_to_replaceable_events_unique_index.sql @@ -0,0 +1,11 @@ +DROP INDEX IF EXISTS replaceable_events_idx; + +CREATE UNIQUE INDEX replaceable_events_idx +ON events ( event_pubkey, event_kind, event_deduplication ) +WHERE + ( + event_kind = 0 + OR event_kind = 3 + OR (event_kind >= 10000 AND event_kind < 20000) + ) + OR (event_kind >= 30000 AND event_kind < 40000); diff --git a/crates/nostream-storage/migrations/20221108201600_set_deduplication_field_for_replaceable_events.sql b/crates/nostream-storage/migrations/20221108201600_set_deduplication_field_for_replaceable_events.sql new file mode 100644 index 00000000..4ea69ad0 --- /dev/null +++ b/crates/nostream-storage/migrations/20221108201600_set_deduplication_field_for_replaceable_events.sql @@ -0,0 +1,5 @@ +UPDATE events +SET event_deduplication = jsonb_build_array(right(event_pubkey::text, 64), event_kind) +WHERE event_kind IN (0, 3) + OR event_kind BETWEEN 10000 AND 19999 + AND event_deduplication IS NULL; diff --git a/crates/nostream-storage/migrations/20221224164100_add_group_chat_to_replaceable_events_unique_index.sql b/crates/nostream-storage/migrations/20221224164100_add_group_chat_to_replaceable_events_unique_index.sql new file mode 100644 index 00000000..abff5f26 --- /dev/null +++ b/crates/nostream-storage/migrations/20221224164100_add_group_chat_to_replaceable_events_unique_index.sql @@ -0,0 +1,33 @@ +DROP INDEX IF EXISTS replaceable_events_idx; + +DELETE FROM events a +WHERE a.event_kind = 41 +AND a.event_created_at < ( + SELECT max(event_created_at) + FROM events b + WHERE a.event_pubkey = b.event_pubkey + AND b.event_kind = 41 + AND a.event_tags = b.event_tags +); + +UPDATE events a +SET event_deduplication = ('["' || encode(a.event_pubkey, 'hex') || '",' || a.event_kind || ']')::jsonb +WHERE a.event_kind = 41 +AND a.event_created_at = ( + SELECT max(event_created_at) + FROM events b + WHERE a.event_pubkey = b.event_pubkey + AND b.event_kind = 41 + AND a.event_tags = b.event_tags +); + +CREATE UNIQUE INDEX replaceable_events_idx +ON events ( event_pubkey, event_kind, event_deduplication ) +WHERE + ( + event_kind = 0 + OR event_kind = 3 + OR event_kind = 41 + OR (event_kind >= 10000 AND event_kind < 20000) + ) + OR (event_kind >= 30000 AND event_kind < 40000); diff --git a/crates/nostream-storage/migrations/20230105120900_add_pubkey_delegator_kind_index.sql b/crates/nostream-storage/migrations/20230105120900_add_pubkey_delegator_kind_index.sql new file mode 100644 index 00000000..5157d7df --- /dev/null +++ b/crates/nostream-storage/migrations/20230105120900_add_pubkey_delegator_kind_index.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX pubkey_delegator_kind_idx ON events ( event_pubkey, event_delegator, event_kind ); diff --git a/crates/nostream-storage/migrations/20230107230900_create_invoices_table.sql b/crates/nostream-storage/migrations/20230107230900_create_invoices_table.sql new file mode 100644 index 00000000..d432c09d --- /dev/null +++ b/crates/nostream-storage/migrations/20230107230900_create_invoices_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE invoices ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + pubkey bytea NOT NULL, + bolt11 text NOT NULL, + amount_requested bigint NOT NULL, + amount_paid bigint, + unit text CHECK (unit IN ('msats', 'sats', 'btc')), + status text CHECK (status IN ('pending', 'completed', 'expired')), + description text, + confirmed_at timestamp(3), + expires_at timestamp(3), + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX invoices_pubkey_index ON invoices (pubkey); diff --git a/crates/nostream-storage/migrations/20230107230901_create_users_table.sql b/crates/nostream-storage/migrations/20230107230901_create_users_table.sql new file mode 100644 index 00000000..5d6ed6fa --- /dev/null +++ b/crates/nostream-storage/migrations/20230107230901_create_users_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE users ( + pubkey bytea PRIMARY KEY, + is_admitted boolean DEFAULT false, + balance bigint DEFAULT 0, + tos_accepted_at timestamp(3), + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/crates/nostream-storage/migrations/20230118190000_confirm_invoice_func.sql b/crates/nostream-storage/migrations/20230118190000_confirm_invoice_func.sql new file mode 100644 index 00000000..34144a86 --- /dev/null +++ b/crates/nostream-storage/migrations/20230118190000_confirm_invoice_func.sql @@ -0,0 +1,35 @@ +create function now_utc() returns timestamp as $$ + select now() at time zone 'utc'; +$$ language sql; + +create function ASSERT_SERIALIZED() returns void as $$ +BEGIN + IF (select current_setting('transaction_isolation') <> 'serializable') THEN + RAISE EXCEPTION 'SN_NOT_SERIALIZABLE'; + END IF; +END; +$$ language plpgsql; + +CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id UUID, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + payee BYTEA; + confirmed_date TIMESTAMP WITHOUT TIME ZONE; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT "pubkey", "confirmed_at" INTO payee, confirmed_date FROM "invoices" WHERE id = invoice_id; + IF confirmed_date IS NULL THEN + UPDATE invoices + SET + "confirmed_at" = confirmation_date, + "amount_paid" = amount_received, + "updated_at" = now_utc() + WHERE id = invoice_id; + UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee; + END IF; + RETURN 0; +END; +$$; diff --git a/crates/nostream-storage/migrations/20230119170900_add_kind_tags_created_at_index.sql b/crates/nostream-storage/migrations/20230119170900_add_kind_tags_created_at_index.sql new file mode 100644 index 00000000..b6786d12 --- /dev/null +++ b/crates/nostream-storage/migrations/20230119170900_add_kind_tags_created_at_index.sql @@ -0,0 +1,4 @@ +CREATE EXTENSION btree_gin; + +CREATE INDEX kind_tags_created_at_idx +ON events USING GIN ( event_kind, event_tags, event_created_at ); diff --git a/crates/nostream-storage/migrations/20230120161800_charge_user_func.sql b/crates/nostream-storage/migrations/20230120161800_charge_user_func.sql new file mode 100644 index 00000000..648fd545 --- /dev/null +++ b/crates/nostream-storage/migrations/20230120161800_charge_user_func.sql @@ -0,0 +1,18 @@ +CREATE OR REPLACE FUNCTION charge_user(charged_user BYTEA, amount BIGINT) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + current_balance BIGINT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT "balance" INTO current_balance FROM "users" WHERE "pubkey" = charged_user; + IF current_balance - amount >= 0 THEN + UPDATE "users" SET balance = balance - amount WHERE "pubkey" = charged_user; + RETURN 1; + ELSE + RETURN 0; + END IF; +END; +$$; diff --git a/crates/nostream-storage/migrations/20230126230000_add_remote_address_to_events_table.sql b/crates/nostream-storage/migrations/20230126230000_add_remote_address_to_events_table.sql new file mode 100644 index 00000000..93146168 --- /dev/null +++ b/crates/nostream-storage/migrations/20230126230000_add_remote_address_to_events_table.sql @@ -0,0 +1 @@ +ALTER TABLE events ADD remote_address inet NULL; diff --git a/crates/nostream-storage/migrations/20230203185700_add_expires_at_to_events_table.sql b/crates/nostream-storage/migrations/20230203185700_add_expires_at_to_events_table.sql new file mode 100644 index 00000000..a20ca291 --- /dev/null +++ b/crates/nostream-storage/migrations/20230203185700_add_expires_at_to_events_table.sql @@ -0,0 +1,3 @@ +ALTER TABLE events ADD COLUMN expires_at integer NULL; + +CREATE INDEX events_expires_at_index ON events (expires_at); diff --git a/crates/nostream-storage/migrations/20230205004400_change_invoice_id_to_string.sql b/crates/nostream-storage/migrations/20230205004400_change_invoice_id_to_string.sql new file mode 100644 index 00000000..1a2819f3 --- /dev/null +++ b/crates/nostream-storage/migrations/20230205004400_change_invoice_id_to_string.sql @@ -0,0 +1,27 @@ +ALTER TABLE invoices ALTER COLUMN id TYPE text USING id::text; + +ALTER TABLE invoices ALTER COLUMN id DROP DEFAULT; + +CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + payee BYTEA; + confirmed_date TIMESTAMP WITHOUT TIME ZONE; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT "pubkey", "confirmed_at" INTO payee, confirmed_date FROM "invoices" WHERE id = invoice_id; + IF confirmed_date IS NULL THEN + UPDATE invoices + SET + "confirmed_at" = confirmation_date, + "amount_paid" = amount_received, + "updated_at" = now_utc() + WHERE id = invoice_id; + UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee; + END IF; + RETURN 0; +END; +$$; diff --git a/crates/nostream-storage/migrations/20230213103904_add_verify_url_to_invoices_table.sql b/crates/nostream-storage/migrations/20230213103904_add_verify_url_to_invoices_table.sql new file mode 100644 index 00000000..17f965f5 --- /dev/null +++ b/crates/nostream-storage/migrations/20230213103904_add_verify_url_to_invoices_table.sql @@ -0,0 +1 @@ +ALTER TABLE invoices ADD verify_url TEXT; diff --git a/crates/nostream-storage/migrations/20230217235600_scale_balance_addition_with_unit.sql b/crates/nostream-storage/migrations/20230217235600_scale_balance_addition_with_unit.sql new file mode 100644 index 00000000..27c716f6 --- /dev/null +++ b/crates/nostream-storage/migrations/20230217235600_scale_balance_addition_with_unit.sql @@ -0,0 +1,30 @@ +CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + payee BYTEA; + confirmed_date TIMESTAMP WITHOUT TIME ZONE; + unit TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT "pubkey", "confirmed_at", "unit" INTO payee, confirmed_date, unit FROM "invoices" WHERE id = invoice_id; + IF confirmed_date IS NULL THEN + UPDATE invoices + SET + "confirmed_at" = confirmation_date, + "amount_paid" = amount_received, + "updated_at" = now_utc() + WHERE id = invoice_id; + IF unit = 'sats' THEN + UPDATE users SET balance = balance + amount_received * 1000 WHERE "pubkey" = payee; + ELSIF unit = 'msats' THEN + UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee; + ELSIF unit = 'btc' THEN + UPDATE users SET balance = balance + amount_received * 100000000 * 1000 WHERE "pubkey" = payee; + END IF; + END IF; + RETURN 0; +END; +$$; diff --git a/crates/nostream-storage/migrations/20230220002700_fix_unit_confirm_invoice_func.sql b/crates/nostream-storage/migrations/20230220002700_fix_unit_confirm_invoice_func.sql new file mode 100644 index 00000000..9674d25e --- /dev/null +++ b/crates/nostream-storage/migrations/20230220002700_fix_unit_confirm_invoice_func.sql @@ -0,0 +1,34 @@ +DROP FUNCTION confirm_invoice(invoice_id UUID, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE); + +DROP FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE); + +CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + payee BYTEA; + confirmed_date TIMESTAMP WITHOUT TIME ZONE; + invoice_unit TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT "pubkey", "confirmed_at", "unit" INTO payee, confirmed_date, invoice_unit FROM "invoices" WHERE id = invoice_id; + IF confirmed_date IS NULL THEN + UPDATE invoices + SET + "confirmed_at" = confirmation_date, + "amount_paid" = amount_received, + "updated_at" = now_utc() + WHERE id = invoice_id; + IF invoice_unit = 'sats' THEN + UPDATE users SET balance = balance + amount_received * 1000 WHERE "pubkey" = payee; + ELSIF invoice_unit = 'msats' THEN + UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee; + ELSIF invoice_unit = 'btc' THEN + UPDATE users SET balance = balance + amount_received * 100000000 * 1000 WHERE "pubkey" = payee; + END IF; + END IF; + RETURN 0; +END; +$$; diff --git a/crates/nostream-storage/migrations/20240108130100_add_event_tags_table.sql b/crates/nostream-storage/migrations/20240108130100_add_event_tags_table.sql new file mode 100644 index 00000000..490d2bcd --- /dev/null +++ b/crates/nostream-storage/migrations/20240108130100_add_event_tags_table.sql @@ -0,0 +1,52 @@ +CREATE TABLE event_tags ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + event_id bytea NOT NULL, + tag_name text NOT NULL, + tag_value text NOT NULL +); + +CREATE INDEX event_tags_event_id_index ON event_tags (event_id); +CREATE INDEX event_tags_tag_name_tag_value_index ON event_tags (tag_name, tag_value); + +CREATE OR REPLACE FUNCTION process_event_tags() RETURNS TRIGGER AS $$ + DECLARE + tag_element jsonb; + tag_name text; + tag_value text; + BEGIN + DELETE FROM event_tags WHERE event_id = OLD.event_id; + + IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN + FOR tag_element IN SELECT jsonb_array_elements(NEW.event_tags) + LOOP + tag_name := trim((tag_element->0)::text, '"'); + tag_value := trim((tag_element->1)::text, '"'); + IF length(tag_name) = 1 AND tag_value IS NOT NULL AND tag_value <> '' THEN + INSERT INTO event_tags (event_id, tag_name, tag_value) VALUES (NEW.event_id, tag_name, tag_value); + END IF; + END LOOP; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + +CREATE TRIGGER insert_event_tags +AFTER INSERT OR UPDATE OR DELETE ON events +FOR EACH ROW +EXECUTE FUNCTION process_event_tags(); + +-- The set-based form of the capture's per-event loop: one row per one-character tag name +-- with a non-empty value, for every event that has no event_tags row yet. +INSERT INTO event_tags (event_id, tag_name, tag_value) +SELECT events.event_id, + (tag_element->>0), + (tag_element->>1) +FROM events +CROSS JOIN LATERAL jsonb_array_elements(events.event_tags) AS tag_element +WHERE NOT EXISTS ( + SELECT 1 FROM event_tags WHERE event_tags.event_id = events.event_id +) +AND length(tag_element->>0) = 1 +AND (tag_element->>1) IS NOT NULL +AND (tag_element->>1) <> ''; diff --git a/crates/nostream-storage/migrations/20240111204900_remove_delegator_from_events_table.sql b/crates/nostream-storage/migrations/20240111204900_remove_delegator_from_events_table.sql new file mode 100644 index 00000000..d6a15a0c --- /dev/null +++ b/crates/nostream-storage/migrations/20240111204900_remove_delegator_from_events_table.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS pubkey_delegator_kind_idx; + +ALTER TABLE events DROP COLUMN event_delegator; diff --git a/crates/nostream-storage/migrations/20260409120000_create_nip05_verifications_table.sql b/crates/nostream-storage/migrations/20260409120000_create_nip05_verifications_table.sql new file mode 100644 index 00000000..58818e9d --- /dev/null +++ b/crates/nostream-storage/migrations/20260409120000_create_nip05_verifications_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE nip05_verifications ( + pubkey bytea NOT NULL PRIMARY KEY, + nip05 text NOT NULL, + domain text NOT NULL, + is_verified boolean NOT NULL DEFAULT false, + last_verified_at timestamptz, + last_checked_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + failure_count integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_nip05_verifications_domain ON nip05_verifications (domain); +CREATE INDEX idx_nip05_verifications_is_verified ON nip05_verifications (is_verified); +CREATE INDEX idx_nip05_verifications_last_checked_at ON nip05_verifications (last_checked_at); diff --git a/crates/nostream-storage/migrations/20260409201624_admit_user_func.sql b/crates/nostream-storage/migrations/20260409201624_admit_user_func.sql new file mode 100644 index 00000000..01c6d394 --- /dev/null +++ b/crates/nostream-storage/migrations/20260409201624_admit_user_func.sql @@ -0,0 +1,18 @@ +CREATE OR REPLACE FUNCTION admit_user(user_pubkey BYTEA, tos_accepted TIMESTAMP WITHOUT TIME ZONE) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM ASSERT_SERIALIZED(); + + INSERT INTO "users" ("pubkey", "is_admitted", "tos_accepted_at", "created_at", "updated_at") + VALUES (user_pubkey, true, tos_accepted, now_utc(), now_utc()) + ON CONFLICT ("pubkey") + DO UPDATE SET + "is_admitted" = true, + "tos_accepted_at" = tos_accepted, + "updated_at" = now_utc(); + + RETURN 0; +END; +$$; diff --git a/crates/nostream-storage/migrations/20260410230000_add_is_vanished_to_users_table.sql b/crates/nostream-storage/migrations/20260410230000_add_is_vanished_to_users_table.sql new file mode 100644 index 00000000..142538a7 --- /dev/null +++ b/crates/nostream-storage/migrations/20260410230000_add_is_vanished_to_users_table.sql @@ -0,0 +1,10 @@ +ALTER TABLE users ADD COLUMN is_vanished boolean NOT NULL DEFAULT false; + +UPDATE users u +SET is_vanished = true +WHERE EXISTS ( + SELECT 1 FROM events e + WHERE e.event_pubkey = u.pubkey + AND e.event_kind = 62 + AND e.deleted_at IS NULL +); diff --git a/crates/nostream-storage/migrations/20260419140000_normalize_parameterized_deduplication.sql b/crates/nostream-storage/migrations/20260419140000_normalize_parameterized_deduplication.sql new file mode 100644 index 00000000..2f0fab08 --- /dev/null +++ b/crates/nostream-storage/migrations/20260419140000_normalize_parameterized_deduplication.sql @@ -0,0 +1,21 @@ +WITH ranked AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY event_pubkey, event_kind, jsonb_build_array(COALESCE(event_deduplication->>0, '')) + ORDER BY event_created_at DESC, event_id ASC + ) AS row_rank + FROM events + WHERE event_kind >= 30000 + AND event_kind < 40000 +) +DELETE FROM events AS e +USING ranked AS r +WHERE e.id = r.id + AND r.row_rank > 1; + +UPDATE events +SET event_deduplication = jsonb_build_array(COALESCE(event_deduplication->>0, '')) +WHERE event_kind >= 30000 + AND event_kind < 40000 + AND event_deduplication IS DISTINCT FROM jsonb_build_array(COALESCE(event_deduplication->>0, '')); diff --git a/crates/nostream-storage/migrations/20260420120000_add_hot_path_indexes.sql b/crates/nostream-storage/migrations/20260420120000_add_hot_path_indexes.sql new file mode 100644 index 00000000..cbf6589c --- /dev/null +++ b/crates/nostream-storage/migrations/20260420120000_add_hot_path_indexes.sql @@ -0,0 +1,18 @@ +-- no-transaction +-- The knex source runs this file outside a transaction because it builds its indexes with +-- CREATE INDEX CONCURRENTLY, which PostgreSQL refuses inside a transaction block. The +-- rewrite's migrator applies migrations to a fresh database, and a multi-statement simple +-- query is itself an implicit transaction block, so the three indexes are built without +-- CONCURRENTLY here; the resulting index definitions are identical (the SQL text is +-- recorded in the ADR). + +CREATE INDEX IF NOT EXISTS events_active_pubkey_kind_created_at_idx +ON events (event_pubkey, event_kind, event_created_at DESC, event_id); + +CREATE INDEX IF NOT EXISTS events_deleted_at_partial_idx +ON events (deleted_at) +WHERE deleted_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS invoices_pending_created_at_idx +ON invoices (created_at) +WHERE status = 'pending'; diff --git a/crates/nostream-storage/migrations/20260427000000_add_nip50_fts_index.sql b/crates/nostream-storage/migrations/20260427000000_add_nip50_fts_index.sql new file mode 100644 index 00000000..d534ea63 --- /dev/null +++ b/crates/nostream-storage/migrations/20260427000000_add_nip50_fts_index.sql @@ -0,0 +1,2 @@ +-- no-transaction +CREATE INDEX CONCURRENTLY IF NOT EXISTS events_content_fts_idx ON events USING gin (to_tsvector('simple', event_content)); diff --git a/crates/nostream-storage/migrations/20260624120000_create_nip43_invite_codes_table.sql b/crates/nostream-storage/migrations/20260624120000_create_nip43_invite_codes_table.sql new file mode 100644 index 00000000..a01a6d26 --- /dev/null +++ b/crates/nostream-storage/migrations/20260624120000_create_nip43_invite_codes_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE invite_codes ( + code varchar(64) PRIMARY KEY, + created_by bytea, + claimed_by bytea, + expires_at timestamptz, + remaining_uses integer DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE invite_codes ADD CONSTRAINT chk_remaining_uses_non_negative CHECK (remaining_uses >= 0); + +CREATE INDEX idx_invite_codes_expires_at ON invite_codes(expires_at) WHERE expires_at IS NOT NULL; diff --git a/crates/nostream-storage/migrations/20260812150000_create_dvm_jobs_table.sql b/crates/nostream-storage/migrations/20260812150000_create_dvm_jobs_table.sql new file mode 100644 index 00000000..d6717f1a --- /dev/null +++ b/crates/nostream-storage/migrations/20260812150000_create_dvm_jobs_table.sql @@ -0,0 +1,18 @@ +CREATE TABLE dvm_jobs ( + id bytea PRIMARY KEY, + requester_pubkey bytea NOT NULL, + kind integer NOT NULL, + worker_index integer, + status text NOT NULL DEFAULT 'submitted' + CHECK (status IN ('submitted', 'picked_up', 'completed', 'failed', 'timed_out')), + result_event_id bytea, + error text, + picked_up_at timestamptz, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_dvm_jobs_requester_pubkey ON dvm_jobs (requester_pubkey); +CREATE INDEX idx_dvm_jobs_status_created_at ON dvm_jobs (status, created_at); +CREATE INDEX idx_dvm_jobs_kind ON dvm_jobs (kind); diff --git a/crates/nostream-storage/migrations/20260829120000_backfill_null_invoice_expiry.sql b/crates/nostream-storage/migrations/20260829120000_backfill_null_invoice_expiry.sql new file mode 100644 index 00000000..f4020a3f --- /dev/null +++ b/crates/nostream-storage/migrations/20260829120000_backfill_null_invoice_expiry.sql @@ -0,0 +1,4 @@ +UPDATE invoices +SET expires_at = (created_at AT TIME ZONE 'UTC') + interval '86400 seconds' +WHERE expires_at IS NULL + AND status = 'pending'; diff --git a/crates/nostream-storage/migrations/20260910120000_create_reports_table.sql b/crates/nostream-storage/migrations/20260910120000_create_reports_table.sql new file mode 100644 index 00000000..beb2119a --- /dev/null +++ b/crates/nostream-storage/migrations/20260910120000_create_reports_table.sql @@ -0,0 +1,18 @@ +CREATE TABLE reports ( + id serial PRIMARY KEY, + event_id bytea NOT NULL, + reporter_pubkey bytea NOT NULL, + reported_pubkey bytea, + reported_event_id bytea, + report_type text NOT NULL + CHECK (report_type IN ('nudity', 'malware', 'profanity', 'illegal', 'spam', 'impersonation', 'other')), + weight real NOT NULL, + actionable boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_reports_event_id ON reports (event_id); +CREATE INDEX idx_reports_reported_pubkey ON reports (reported_pubkey); +CREATE INDEX idx_reports_reported_event_id ON reports (reported_event_id); +CREATE INDEX idx_reports_reporter_pubkey ON reports (reporter_pubkey); +CREATE INDEX idx_reports_actionable_created_at ON reports (actionable, created_at); diff --git a/crates/nostream-storage/migrations/20260919120000_create_notification_outbox_table.sql b/crates/nostream-storage/migrations/20260919120000_create_notification_outbox_table.sql new file mode 100644 index 00000000..44b0f45e --- /dev/null +++ b/crates/nostream-storage/migrations/20260919120000_create_notification_outbox_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE notification_outbox ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + event_type text NOT NULL, + payload jsonb NOT NULL, + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'processing', 'delivered', 'dead')), + attempt_count integer NOT NULL DEFAULT 0, + available_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_error text, + delivered_at timestamptz, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_notification_outbox_dispatch ON notification_outbox (status, available_at, created_at); +CREATE INDEX idx_notification_outbox_event_type_created_at ON notification_outbox (event_type, created_at); diff --git a/crates/nostream-storage/migrations/20260919130000_create_notification_delivery_log_table.sql b/crates/nostream-storage/migrations/20260919130000_create_notification_delivery_log_table.sql new file mode 100644 index 00000000..d11368ad --- /dev/null +++ b/crates/nostream-storage/migrations/20260919130000_create_notification_delivery_log_table.sql @@ -0,0 +1,18 @@ +CREATE TABLE notification_delivery_log ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + outbox_id uuid, + event_type text NOT NULL, + target_id text NOT NULL, + target_type text NOT NULL CHECK (target_type IN ('http', 'discord', 'slack', 'telegram')), + status text NOT NULL CHECK (status IN ('success', 'failed')), + attempt_number integer NOT NULL, + error_snippet text, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_notification_delivery_log_created_at ON notification_delivery_log (created_at); +CREATE INDEX idx_notification_delivery_log_event_created_at ON notification_delivery_log (event_type, created_at); + +ALTER TABLE notification_delivery_log +ADD CONSTRAINT notification_delivery_log_outbox_id_foreign +FOREIGN KEY (outbox_id) REFERENCES notification_outbox(id) ON DELETE SET NULL; diff --git a/crates/nostream-storage/migrations/20260926120000_add_actionable_reports_indexes.sql b/crates/nostream-storage/migrations/20260926120000_add_actionable_reports_indexes.sql new file mode 100644 index 00000000..be06d004 --- /dev/null +++ b/crates/nostream-storage/migrations/20260926120000_add_actionable_reports_indexes.sql @@ -0,0 +1,12 @@ +-- The knex source runs this file outside a transaction because it builds both indexes with +-- CREATE INDEX CONCURRENTLY. The rewrite's migrator applies the whole schema to a fresh +-- database, where the lock mode has no effect, so the two statements run inside the +-- migration's transaction and produce identical index definitions. + +CREATE INDEX IF NOT EXISTS reports_actionable_reported_event_id_idx +ON reports (reported_event_id) +WHERE actionable = true AND reported_event_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS reports_actionable_reported_pubkey_idx +ON reports (reported_pubkey) +WHERE actionable = true AND reported_pubkey IS NOT NULL; diff --git a/crates/nostream-storage/src/error.rs b/crates/nostream-storage/src/error.rs index 4cdffeda..10c68883 100644 --- a/crates/nostream-storage/src/error.rs +++ b/crates/nostream-storage/src/error.rs @@ -9,6 +9,33 @@ pub enum StorageError { #[error("not implemented in Phase 1 (job 005): {module}")] NotImplemented { module: &'static str }, + /// A database operation failed. The driver's message names the statement class for an + /// operator; callers must not match on its text. + #[error("database operation failed: {0}")] + Database(#[from] sqlx::Error), + + /// The embedded migrator could not apply the schema. + #[error("database migration failed: {0}")] + Migration(#[from] sqlx::migrate::MigrateError), + + /// A caller violated a repository precondition. + #[error("invalid argument: {message}")] + InvalidArgument { message: &'static str }, + + /// A column held a value the row type cannot represent, such as an unknown enum + /// spelling or a malformed tag document. + #[error("{table}.{column} holds a value the row type cannot represent: {value}")] + InvalidRow { + table: &'static str, + column: &'static str, + value: String, + }, + + /// An integer column held a value outside its Rust range (a negative kind, an id that + /// is not 32 bytes). + #[error("{column} is out of range: {value}")] + ValueOutOfRange { column: &'static str, value: String }, + /// An LMDB environment or transaction failed. #[error("lmdb operation failed: {0}")] Lmdb(#[from] heed::Error), @@ -38,3 +65,52 @@ pub enum StorageError { #[error("Unsupported {env} \"{value}\". Supported backends: postgres, lmdb")] UnsupportedBackend { value: String, env: &'static str }, } + +#[cfg(test)] +mod tests { + #![allow(clippy::panic)] + + use super::*; + + #[test] + fn an_invalid_row_names_its_column_and_value() { + let error = StorageError::InvalidRow { + table: "invoices", + column: "status", + value: "refunded".to_owned(), + }; + assert_eq!( + error.to_string(), + "invoices.status holds a value the row type cannot represent: refunded" + ); + } + + #[test] + fn an_out_of_range_value_names_the_column() { + let error = StorageError::ValueOutOfRange { + column: "event_kind", + value: "-1".to_owned(), + }; + assert_eq!(error.to_string(), "event_kind is out of range: -1"); + } + + #[test] + fn an_invalid_argument_carries_the_capture_message() { + let error = StorageError::InvalidArgument { + message: "Filters cannot be empty", + }; + assert_eq!( + error.to_string(), + "invalid argument: Filters cannot be empty" + ); + } + + #[test] + fn a_database_error_keeps_the_drivers_message() { + let error = StorageError::from(sqlx::Error::RowNotFound); + assert_eq!( + error.to_string(), + "database operation failed: no rows returned by a query that expected to return at least one row" + ); + } +} diff --git a/crates/nostream-storage/src/lib.rs b/crates/nostream-storage/src/lib.rs index ab30c01e..47ce0bb2 100644 --- a/crates/nostream-storage/src/lib.rs +++ b/crates/nostream-storage/src/lib.rs @@ -1,30 +1,31 @@ -//! `nostream-storage` owns the persistence seam: the repository traits, the backend selection, +//! `nostream-storage` owns the persistence seam: the nine repository traits, the streaming +//! query surface, the Postgres backend with its embedded migrations, the backend selection, //! the cache client seam and the LMDB environment. //! //! It owns these TypeScript paths from the capture commit //! (`242a3001d77149e4d70151ec03060c4ef44a1547`): //! -//! - `src/repositories/` (9 files; `src/repositories/event-repository.ts`'s 622-line body is -//! **not** ported — Phase 2 fills the implementations behind the traits) -//! - `src/database/client.ts`, `src/database/transaction.ts` +//! - `src/repositories/` (9 files, ported to `src/postgres/`) +//! - `src/database/client.ts`, `src/database/transaction.ts` (`src/postgres/mod.rs`) +//! - the schema: `migrations/` (37 knex migrations, ported to `migrations/*.sql`) //! - `src/cache/client.ts`, `src/adapters/redis-adapter.ts` //! - `src/utils/event-store.ts`, `src/utils/hidden-content-cache.ts` //! - `src/@types/{adapters,cache,database,repositories,invite-code,invoice,nip05,report, //! notification-outbox,user}.ts` //! - `src/factories/event-repository-factory.ts` //! -//! Phase 1 implements exactly one real slice: `backend::lmdb` opens an LMDB environment with -//! `heed` and puts/gets values, retiring the C-toolchain build risk while the crate is empty. -//! The Postgres backend and the Redis cache are labelled stubs — choosing `sqlx` versus -//! `tokio-postgres` versus a pool crate is a Phase 2 decision that would otherwise be made -//! for every repository port at once. +//! What it deliberately does **not** own: the relay handlers that call these traits, the +//! payments service, and the LMDB backend. The Redis cache, `LmdbEnv`'s environment slice +//! and `backend::data_dir` remain labelled stubs; the Postgres backend is complete. pub mod backend; pub mod cache; pub mod error; pub mod event_store; +pub mod postgres; +pub mod records; pub mod repositories; pub use backend::{Backend, LmdbEnv}; - pub use error::StorageError; +pub use postgres::{PostgresBackend, PostgresTransaction}; diff --git a/crates/nostream-storage/src/postgres/events.rs b/crates/nostream-storage/src/postgres/events.rs new file mode 100644 index 00000000..31d1150a --- /dev/null +++ b/crates/nostream-storage/src/postgres/events.rs @@ -0,0 +1,451 @@ +//! `EventRepository` against Postgres. Mirrors `src/repositories/event-repository.ts`. + +use std::pin::Pin; +use std::task::{Context, Poll}; + +use chrono::Utc; +use futures_core::Stream; +use nostream_core::{EventId, Filter, Kind, KindSelector, Pubkey}; +use sqlx::postgres::{PgRow, Postgres}; +use sqlx::{Executor, Row}; + +use super::sql::{self, hex_bytes}; +use super::{Arg, Args, EventQuery, Gates, RETENTION_BATCH_SIZE}; +use crate::error::StorageError; +use crate::repositories::{ + EventPurgeCounts, EventRecord, EventRetentionOptions, QueryStream, ReconciliationItem, +}; + +/// The conflict target of every replaceable write, exactly as the capture spells it. +const REPLACEABLE_CONFLICT: &str = "(event_pubkey, event_kind, event_deduplication) WHERE \ +(event_kind = 0 OR event_kind = 3 OR event_kind = 41 OR (event_kind >= 10000 AND event_kind < 20000)) \ +OR (event_kind >= 30000 AND event_kind < 40000)"; + +/// The merge predicate: the incoming row wins only when it is newer, or equal and smaller. +const REPLACEABLE_MERGE: &str = "WHERE (events.event_created_at < excluded.event_created_at OR \ +(events.event_created_at = excluded.event_created_at AND events.event_id > excluded.event_id))"; + +const INSERT_COLUMNS: &str = "(event_id, event_pubkey, event_created_at, event_kind, event_tags, \ +event_content, event_signature, remote_address, expires_at)"; + +const UPSERT_COLUMNS: &str = "(event_id, event_pubkey, event_created_at, event_kind, event_tags, \ +event_content, event_signature, remote_address, expires_at, event_deduplication, deleted_at)"; + +/// The kind of the capture's `REQUEST_TO_VANISH`, which every retention whitelist keeps. +const REQUEST_TO_VANISH: i32 = 62; + +/// `create`: `INSERT ... ON CONFLICT (event_id) DO NOTHING`, returning the row count. +pub(crate) async fn create<'e, E>(executor: E, record: &EventRecord) -> Result +where + E: Executor<'e, Database = Postgres> + Copy, +{ + create_many(executor, std::slice::from_ref(record)).await +} + +/// `create_many`: one multi-row insert, in batches the parameter limit allows. +pub(crate) async fn create_many<'e, E>( + executor: E, + records: &[EventRecord], +) -> Result +where + E: Executor<'e, Database = Postgres> + Copy, +{ + if records.is_empty() { + return Ok(0); + } + + let mut affected = 0; + for batch in records.chunks(1000) { + let mut args = Args::default(); + let values = batch + .iter() + .map(|record| push_insert_values(&mut args, record)) + .collect::, _>>()?; + let sql = format!( + "INSERT INTO events {INSERT_COLUMNS} VALUES {} ON CONFLICT (event_id) DO NOTHING", + values.join(", ") + ); + affected += EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected(); + } + + Ok(affected) +} + +/// `upsert`: one replaceable insert, merging only when the incoming row wins. +pub(crate) async fn upsert<'e, E>(executor: E, record: &EventRecord) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let values = push_upsert_values(&mut args, record)?; + let sql = format!( + "INSERT INTO events {UPSERT_COLUMNS} VALUES {values} ON CONFLICT {REPLACEABLE_CONFLICT} \ + DO UPDATE SET deleted_at = NULL, event_content = excluded.event_content, \ + event_created_at = excluded.event_created_at, event_id = excluded.event_id, \ + event_signature = excluded.event_signature, event_tags = excluded.event_tags, \ + remote_address = excluded.remote_address, expires_at = excluded.expires_at {REPLACEABLE_MERGE}" + ); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected()) +} + +/// `upsert_many`: one multi-row replaceable insert. The capture's explicit merge list omits +/// `remote_address`, and so does this one. +pub(crate) async fn upsert_many<'e, E>( + executor: E, + records: &[EventRecord], +) -> Result +where + E: Executor<'e, Database = Postgres> + Copy, +{ + if records.is_empty() { + return Ok(0); + } + + let mut affected = 0; + for batch in records.chunks(1000) { + let mut args = Args::default(); + let values = batch + .iter() + .map(|record| push_upsert_values(&mut args, record)) + .collect::, _>>()?; + let sql = format!( + "INSERT INTO events {UPSERT_COLUMNS} VALUES {} ON CONFLICT {REPLACEABLE_CONFLICT} \ + DO UPDATE SET deleted_at = excluded.deleted_at, event_content = excluded.event_content, \ + event_created_at = excluded.event_created_at, event_id = excluded.event_id, \ + event_signature = excluded.event_signature, event_tags = excluded.event_tags, \ + expires_at = excluded.expires_at {REPLACEABLE_MERGE}", + values.join(", ") + ); + affected += EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected(); + } + + Ok(affected) +} +/// connection for its life. +pub(crate) fn find_by_filters<'a>( + reader: &'a sqlx::PgPool, + filters: &[Filter], + gates: &Gates, +) -> Result, StorageError> { + let query = sql::find_by_filters(filters, gates)?; + Ok(Box::pin(EventStream { + inner: query.into_query().fetch(reader), + })) +} + +/// `find_reconciliation_items`: the live `(created_at, event_id)` pairs, limit ignored. +pub(crate) fn find_reconciliation_items<'a>( + reader: &'a sqlx::PgPool, + filter: &Filter, + gates: &Gates, +) -> Result, StorageError> { + let query = sql::find_reconciliation_items(filter, gates, Utc::now().timestamp())?; + Ok(Box::pin(ReconciliationStream { + inner: query.into_query().fetch(reader), + })) +} + +/// `count_by_filters`: the count of the union of the filters' live ids. +pub(crate) async fn count_by_filters<'e, E>( + executor: E, + filters: &[Filter], + gates: &Gates, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let query = sql::count_by_filters(filters, gates, Utc::now().timestamp())?; + let row = query.into_query().fetch_one(executor).await?; + let count: i64 = row.try_get("count")?; + u64::try_from(count).map_err(|_| StorageError::ValueOutOfRange { + column: "count", + value: count.to_string(), + }) +} + +/// `delete_by_pubkey_and_ids`: a soft delete, never touching kind 62 or a deleted row. +pub(crate) async fn delete_by_pubkey_and_ids<'e, E>( + executor: E, + pubkey: &Pubkey, + ids: &[EventId], +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let pubkey_slot = args.push(Arg::Bytes(pubkey.as_bytes().to_vec())); + let ids_slot = args.push(Arg::BytesList( + ids.iter().map(|id| id.as_bytes().to_vec()).collect(), + )); + let sql = format!( + "UPDATE events SET deleted_at = now() WHERE event_pubkey = {pubkey_slot} \ + AND event_id = ANY({ids_slot}) AND event_kind <> {REQUEST_TO_VANISH} AND deleted_at IS NULL" + ); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected()) +} + +/// `delete_by_pubkey_except_kinds`: a soft delete of every other live kind. +pub(crate) async fn delete_by_pubkey_except_kinds<'e, E>( + executor: E, + pubkey: &Pubkey, + excluded_kinds: &[Kind], +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let pubkey_slot = args.push(Arg::Bytes(pubkey.as_bytes().to_vec())); + let kinds_clause = if excluded_kinds.is_empty() { + String::new() + } else { + let kinds: Vec = excluded_kinds + .iter() + .map(|kind| i32::try_from(kind.0).unwrap_or(i32::MAX)) + .collect(); + let kinds_slot = args.push(Arg::IntList(kinds)); + format!(" AND event_kind <> ALL({kinds_slot})") + }; + let sql = format!( + "UPDATE events SET deleted_at = now() WHERE event_pubkey = {pubkey_slot} {kinds_clause} \ + AND deleted_at IS NULL" + ); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected()) +} + +/// `has_active_request_to_vanish`: a live kind-62 row exists. +pub(crate) async fn has_active_request_to_vanish<'e, E>( + executor: E, + pubkey: &Pubkey, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let pubkey_slot = args.push(Arg::Bytes(pubkey.as_bytes().to_vec())); + let sql = format!( + "SELECT event_id FROM events WHERE event_pubkey = {pubkey_slot} AND event_kind = \ + {REQUEST_TO_VANISH} AND deleted_at IS NULL LIMIT 1" + ); + + Ok(EventQuery { sql, args } + .into_query() + .fetch_optional(executor) + .await? + .is_some()) +} + +/// `delete_expired_and_retained`: one hard-delete batch of at most 1000 rows, classified in +/// the capture's order (deleted, then expired, then retained). +pub(crate) async fn delete_expired_and_retained<'e, E>( + executor: E, + options: &EventRetentionOptions, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let Some(max_days) = options.max_days.filter(|days| *days > 0) else { + return Ok(EventPurgeCounts::default()); + }; + + let now = Utc::now().timestamp(); + let retention_limit = now - max_days * 86_400; + + let mut args = Args::default(); + let now_slot = args.push(Arg::BigInt(now)); + let limit_slot = args.push(Arg::BigInt(retention_limit)); + let whitelist = retention_kinds(options.kind_whitelist.as_deref()); + let whitelist_sql = push_retention_kinds(&mut args, &whitelist); + + let mut pubkey_clause = String::new(); + if let Some(whitelist) = &options.pubkey_whitelist + && !whitelist.is_empty() + { + let pubkeys: Vec> = whitelist + .iter() + .map(|pubkey| hex_bytes(&pubkey.0)) + .collect(); + let slot = args.push(Arg::BytesList(pubkeys)); + pubkey_clause = format!(" AND event_pubkey <> ALL({slot})"); + } + + let sql = format!( + "DELETE FROM events WHERE event_id IN (SELECT event_id FROM events WHERE \ + (expires_at < {now_slot} OR deleted_at IS NOT NULL OR event_created_at < {limit_slot}) \ + AND NOT ({whitelist_sql}){pubkey_clause} LIMIT {RETENTION_BATCH_SIZE}) \ + RETURNING deleted_at, expires_at, event_created_at" + ); + + let rows = EventQuery { sql, args } + .into_query() + .fetch_all(executor) + .await?; + + let mut counts = EventPurgeCounts::default(); + for row in rows { + let deleted_at: Option = row.try_get("deleted_at")?; + let expires_at: Option = row.try_get("expires_at")?; + let created_at: i32 = row.try_get("event_created_at")?; + if deleted_at.is_some() { + counts.deleted += 1; + } else if expires_at.is_some_and(|expires_at| i64::from(expires_at) < now) { + counts.expired += 1; + } else if i64::from(created_at) < retention_limit { + counts.retained += 1; + } + } + + Ok(counts) +} + +/// The capture's deduplicated whitelist, always including kind 62. +fn retention_kinds(configured: Option<&[KindSelector]>) -> Vec { + let mut seen = Vec::new(); + let mut whitelist = Vec::new(); + let mut push = |selector: KindSelector| { + if !seen.contains(&selector) { + seen.push(selector); + whitelist.push(selector); + } + }; + for selector in configured.unwrap_or_default() { + push(*selector); + } + push(KindSelector::Single(Kind(REQUEST_TO_VANISH as u64))); + whitelist +} + +fn push_retention_kinds(args: &mut Args, whitelist: &[KindSelector]) -> String { + let mut parts = Vec::new(); + for selector in whitelist { + match selector { + KindSelector::Single(kind) => { + let slot = args.push(Arg::Int(i32::try_from(kind.0).unwrap_or(i32::MAX))); + parts.push(format!("event_kind = {slot}")); + } + KindSelector::Range([start, end]) => { + let start_slot = args.push(Arg::Int(i32::try_from(start.0).unwrap_or(i32::MAX))); + let end_slot = args.push(Arg::Int(i32::try_from(end.0).unwrap_or(i32::MAX))); + parts.push(format!("event_kind BETWEEN {start_slot} AND {end_slot}")); + } + } + } + parts.join(" OR ") +} + +fn push_insert_values(args: &mut Args, record: &EventRecord) -> Result { + let id = args.push(Arg::Bytes(record.event.id.as_bytes().to_vec())); + let pubkey = args.push(Arg::Bytes(record.event.pubkey.as_bytes().to_vec())); + let created_at = args.push(Arg::Int(int_column( + "event_created_at", + record.event.created_at, + )?)); + let kind = args.push(Arg::Int(int_column( + "event_kind", + record.event.kind.0 as i64, + )?)); + let tags = args.push(Arg::Text(tags_json(record)?)); + let content = args.push(Arg::Text(record.event.content.clone())); + let signature = args.push(Arg::Bytes(record.event.sig.as_bytes().to_vec())); + let remote_address = match record.remote_address { + Some(address) => args.push(Arg::Text(address.to_string())), + None => args.push(Arg::NullText), + }; + let expires_at = match record.expires_at { + Some(expires_at) => args.push(Arg::Int(int_column("expires_at", expires_at)?)), + None => args.push(Arg::NullInt), + }; + + Ok(format!( + "({id}, {pubkey}, {created_at}, {kind}, {tags}::jsonb, {content}, {signature}, \ + {remote_address}::text::inet, {expires_at})" + )) +} + +fn push_upsert_values(args: &mut Args, record: &EventRecord) -> Result { + let insert = push_insert_values(args, record)?; + let deduplication = + serde_json::to_string(&record.deduplication()).map_err(|_| StorageError::InvalidRow { + table: "events", + column: "event_deduplication", + value: "the deduplication value is not JSON".to_owned(), + })?; + let deduplication = args.push(Arg::Text(deduplication)); + + Ok(format!("{insert}, {deduplication}::jsonb, NULL")) +} + +fn tags_json(record: &EventRecord) -> Result { + serde_json::to_string(&record.event.tags).map_err(|_| StorageError::InvalidRow { + table: "events", + column: "event_tags", + value: "the tags are not a JSON array".to_owned(), + }) +} + +fn int_column(column: &'static str, value: i64) -> Result { + i32::try_from(value).map_err(|_| StorageError::ValueOutOfRange { + column, + value: value.to_string(), + }) +} + +/// A stream of stored events, mapping each driver row. +struct EventStream<'a> { + inner: Pin> + Send + 'a>>, +} + +impl Stream for EventStream<'_> { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + match this.inner.as_mut().poll_next(context) { + Poll::Ready(Some(Ok(row))) => Poll::Ready(Some(super::rows::event_record(&row))), + Poll::Ready(Some(Err(error))) => Poll::Ready(Some(Err(StorageError::Database(error)))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +/// A stream of reconciliation items, mapping each driver row. +struct ReconciliationStream<'a> { + inner: Pin> + Send + 'a>>, +} + +impl Stream for ReconciliationStream<'_> { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + match this.inner.as_mut().poll_next(context) { + Poll::Ready(Some(Ok(row))) => Poll::Ready(Some(super::rows::reconciliation_item(&row))), + Poll::Ready(Some(Err(error))) => Poll::Ready(Some(Err(StorageError::Database(error)))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} diff --git a/crates/nostream-storage/src/postgres/invoices.rs b/crates/nostream-storage/src/postgres/invoices.rs new file mode 100644 index 00000000..6a5dc7f7 --- /dev/null +++ b/crates/nostream-storage/src/postgres/invoices.rs @@ -0,0 +1,146 @@ +//! `InvoiceRepository` against Postgres. Mirrors `src/repositories/invoice-repository.ts`. + +use chrono::{DateTime, Utc}; +use sqlx::Executor; +use sqlx::postgres::Postgres; + +use super::{Arg, Args, EventQuery}; +use crate::error::StorageError; +use crate::repositories::{Invoice, InvoiceStatus}; + +const INVOICE_COLUMNS: &str = "id, pubkey, bolt11, amount_requested, amount_paid, unit, status, \ +description, confirmed_at, expires_at, updated_at, created_at, verify_url"; + +/// `findById`: one row by primary key. +pub(crate) async fn find_by_id<'e, E>( + executor: E, + id: &str, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let id_slot = args.push(Arg::Text(id.to_owned())); + let sql = format!("SELECT {INVOICE_COLUMNS} FROM invoices WHERE id = {id_slot}"); + let row = EventQuery { sql, args } + .into_query() + .fetch_optional(executor) + .await?; + + row.as_ref().map(super::rows::invoice).transpose() +} + +/// `upsert`: inserts the row, or updates only `status` and `updated_at` — the capture's +/// merge list, which never carries `amount_paid` or `confirmed_at`. +pub(crate) async fn upsert<'e, E>(executor: E, invoice: &Invoice) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let mut args = Args::default(); + let id_slot = args.push(Arg::Text(invoice.id.clone())); + let pubkey_slot = args.push(Arg::Bytes(invoice.pubkey.as_bytes().to_vec())); + let bolt11_slot = args.push(Arg::Text(invoice.bolt11.clone())); + let requested_slot = args.push(Arg::BigInt(invoice.amount_requested)); + let unit_slot = args.push(Arg::Text(invoice.unit.as_str().to_owned())); + let status_slot = args.push(Arg::Text(invoice.status.as_str().to_owned())); + let description_slot = args.push(Arg::Text(invoice.description.clone())); + let expires_slot = match invoice.expires_at { + Some(expires_at) => args.push(Arg::NaiveTimestamp(expires_at.naive_utc())), + None => args.push(Arg::NullNaiveTimestamp), + }; + let updated_slot = args.push(Arg::Timestamp(now)); + let created_slot = args.push(Arg::Timestamp(invoice.created_at)); + let verify_slot = match &invoice.verify_url { + Some(url) => args.push(Arg::Text(url.clone())), + None => args.push(Arg::NullText), + }; + + let sql = format!( + "INSERT INTO invoices (id, pubkey, bolt11, amount_requested, unit, status, description, \ + expires_at, updated_at, created_at, verify_url) VALUES ({id_slot}, {pubkey_slot}, \ + {bolt11_slot}, {requested_slot}, {unit_slot}, {status_slot}, {description_slot}, \ + {expires_slot}, {updated_slot}, {created_slot}, {verify_slot}) \ + ON CONFLICT (id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at" + ); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected()) +} + +/// `updateStatus`: updates one row and returns it. +pub(crate) async fn update_status<'e, E>( + executor: E, + id: &str, + status: InvoiceStatus, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let id_slot = args.push(Arg::Text(id.to_owned())); + let status_slot = args.push(Arg::Text(status.as_str().to_owned())); + let updated_slot = args.push(Arg::Timestamp(Utc::now())); + let sql = format!( + "UPDATE invoices SET status = {status_slot}, updated_at = {updated_slot} \ + WHERE id = {id_slot} RETURNING {INVOICE_COLUMNS}" + ); + let row = EventQuery { sql, args } + .into_query() + .fetch_optional(executor) + .await?; + + row.as_ref().map(super::rows::invoice).transpose() +} + +/// `confirmInvoice`: the schema function, which asserts a serializable transaction and +/// scales the credit by the invoice's unit. +pub(crate) async fn confirm_invoice<'e, E>( + executor: E, + invoice_id: &str, + amount_received: i64, + confirmed_at: DateTime, +) -> Result<(), StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let id_slot = args.push(Arg::Text(invoice_id.to_owned())); + let amount_slot = args.push(Arg::BigInt(amount_received)); + let confirmed_slot = args.push(Arg::NaiveTimestamp(confirmed_at.naive_utc())); + let sql = format!("SELECT confirm_invoice({id_slot}, {amount_slot}, {confirmed_slot})"); + + EventQuery { sql, args } + .into_query() + .execute(executor) + .await?; + Ok(()) +} + +/// `findPendingInvoices`: pending rows, oldest first, paged. +pub(crate) async fn find_pending_invoices<'e, E>( + executor: E, + offset: u64, + limit: u64, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let status_slot = args.push(Arg::Text(InvoiceStatus::Pending.as_str().to_owned())); + let offset_slot = args.push(Arg::BigInt(offset as i64)); + let limit_slot = args.push(Arg::BigInt(limit as i64)); + let sql = format!( + "SELECT {INVOICE_COLUMNS} FROM invoices WHERE status = {status_slot} \ + ORDER BY created_at ASC OFFSET {offset_slot} LIMIT {limit_slot}" + ); + let rows = EventQuery { sql, args } + .into_query() + .fetch_all(executor) + .await?; + + rows.iter().map(super::rows::invoice).collect() +} diff --git a/crates/nostream-storage/src/postgres/metadata.rs b/crates/nostream-storage/src/postgres/metadata.rs new file mode 100644 index 00000000..50ce6751 --- /dev/null +++ b/crates/nostream-storage/src/postgres/metadata.rs @@ -0,0 +1,621 @@ +//! `Nip05VerificationRepository`, `InviteCodeRepository`, `DvmJobRepository` and +//! `ReportRepository` against Postgres. Mirrors the four `src/repositories/*` files. + +use chrono::{Duration, Utc}; +use nostream_core::{EventId, Kind, Pubkey}; +use sqlx::postgres::Postgres; +use sqlx::{Executor, Row}; + +use super::{Arg, Args, EventQuery}; +use crate::error::StorageError; +use crate::repositories::{ + ActionableTarget, CreateInviteCodeOptions, DvmJob, DvmJobStatus, DvmJobUpdate, FieldUpdate, + InviteCode, NewReport, Nip05Verification, Report, +}; + +const NIP05_COLUMNS: &str = "pubkey, nip05, domain, is_verified, last_verified_at, \ +last_checked_at, failure_count, created_at, updated_at"; + +const INVITE_COLUMNS: &str = + "code, created_by, claimed_by, expires_at, remaining_uses, created_at, updated_at"; + +const DVM_COLUMNS: &str = "id, requester_pubkey, kind, worker_index, status, result_event_id, \ +error, picked_up_at, completed_at, created_at, updated_at"; + +const REPORT_COLUMNS: &str = "id, event_id, reporter_pubkey, reported_pubkey, \ +reported_event_id, report_type, weight, actionable, created_at"; + +/// `Nip05VerificationRepository::find_by_pubkey`. +pub(crate) async fn nip05_find_by_pubkey<'e, E>( + executor: E, + pubkey: &Pubkey, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let pubkey_slot = args.push(Arg::Bytes(pubkey.as_bytes().to_vec())); + let sql = + format!("SELECT {NIP05_COLUMNS} FROM nip05_verifications WHERE pubkey = {pubkey_slot}"); + let row = EventQuery { sql, args } + .into_query() + .fetch_optional(executor) + .await?; + + row.as_ref() + .map(super::rows::nip05_verification) + .transpose() +} + +/// `Nip05VerificationRepository::upsert`: every column but `pubkey` and `created_at`. +pub(crate) async fn nip05_upsert<'e, E>( + executor: E, + verification: &Nip05Verification, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let mut args = Args::default(); + let pubkey_slot = args.push(Arg::Bytes(verification.pubkey.as_bytes().to_vec())); + let nip05_slot = args.push(Arg::Text(verification.nip05.clone())); + let domain_slot = args.push(Arg::Text(verification.domain.clone())); + let verified_slot = args.push(Arg::Bool(verification.is_verified)); + let last_verified_slot = match verification.last_verified_at { + Some(value) => args.push(Arg::Timestamp(value)), + None => args.push(Arg::NullTimestamp), + }; + let last_checked_slot = args.push(Arg::Timestamp(verification.last_checked_at)); + let failures_slot = args.push(Arg::Int(verification.failure_count)); + let created_slot = args.push(Arg::Timestamp(now)); + let updated_slot = args.push(Arg::Timestamp(now)); + let sql = format!( + "INSERT INTO nip05_verifications ({NIP05_COLUMNS}) VALUES ({pubkey_slot}, {nip05_slot}, \ + {domain_slot}, {verified_slot}, {last_verified_slot}, {last_checked_slot}, \ + {failures_slot}, {created_slot}, {updated_slot}) ON CONFLICT (pubkey) DO UPDATE SET \ + nip05 = excluded.nip05, domain = excluded.domain, is_verified = excluded.is_verified, \ + last_verified_at = excluded.last_verified_at, \ + last_checked_at = excluded.last_checked_at, \ + failure_count = excluded.failure_count, updated_at = excluded.updated_at" + ); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected()) +} + +/// `Nip05VerificationRepository::find_pending_verifications`: rows last checked before the +/// cutoff, ordered by that check. +pub(crate) async fn nip05_find_pending<'e, E>( + executor: E, + update_frequency_ms: i64, + max_failures: i32, + limit: u64, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let cutoff = Utc::now() - Duration::milliseconds(update_frequency_ms); + let mut args = Args::default(); + let cutoff_slot = args.push(Arg::Timestamp(cutoff)); + let failures_slot = args.push(Arg::Int(max_failures)); + let limit_slot = args.push(Arg::BigInt(limit as i64)); + let sql = format!( + "SELECT {NIP05_COLUMNS} FROM nip05_verifications WHERE last_checked_at < {cutoff_slot} \ + AND failure_count < {failures_slot} ORDER BY last_checked_at ASC LIMIT {limit_slot}" + ); + let rows = EventQuery { sql, args } + .into_query() + .fetch_all(executor) + .await?; + + rows.iter().map(super::rows::nip05_verification).collect() +} + +/// `Nip05VerificationRepository::delete_by_pubkey`. +pub(crate) async fn nip05_delete_by_pubkey<'e, E>( + executor: E, + pubkey: &Pubkey, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let pubkey_slot = args.push(Arg::Bytes(pubkey.as_bytes().to_vec())); + let sql = format!("DELETE FROM nip05_verifications WHERE pubkey = {pubkey_slot}"); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected()) +} + +/// `InviteCodeRepository::create`: the capture returns the row it built, so this does too. +pub(crate) async fn invite_create<'e, E>( + executor: E, + code: &str, + options: &CreateInviteCodeOptions, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let mut args = Args::default(); + let code_slot = args.push(Arg::Text(code.to_owned())); + let created_by_slot = match &options.created_by { + Some(pubkey) => args.push(Arg::Bytes(pubkey.as_bytes().to_vec())), + None => args.push(Arg::NullBytes), + }; + let claimed_by_slot = args.push(Arg::NullBytes); + let expires_slot = match options.expires_at { + Some(expires_at) => args.push(Arg::Timestamp(expires_at)), + None => args.push(Arg::NullTimestamp), + }; + let remaining_uses = match options.remaining_uses { + FieldUpdate::Absent => 1, + FieldUpdate::Clear => -1, + FieldUpdate::Set(uses) => uses, + }; + let remaining_slot = if remaining_uses < 0 { + args.push(Arg::NullInt) + } else { + args.push(Arg::Int(remaining_uses)) + }; + let created_slot = args.push(Arg::Timestamp(now)); + let updated_slot = args.push(Arg::Timestamp(now)); + let sql = format!( + "INSERT INTO invite_codes ({INVITE_COLUMNS}) VALUES ({code_slot}, {created_by_slot}, \ + {claimed_by_slot}, {expires_slot}, {remaining_slot}, {created_slot}, {updated_slot})" + ); + + EventQuery { sql, args } + .into_query() + .execute(executor) + .await?; + + Ok(InviteCode { + code: code.to_owned(), + created_by: options.created_by, + claimed_by: None, + expires_at: options.expires_at, + remaining_uses: if remaining_uses < 0 { + None + } else { + Some(remaining_uses) + }, + created_at: now, + updated_at: now, + }) +} + +/// `InviteCodeRepository::find_by_code`. +pub(crate) async fn invite_find_by_code<'e, E>( + executor: E, + code: &str, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let code_slot = args.push(Arg::Text(code.to_owned())); + let sql = format!("SELECT {INVITE_COLUMNS} FROM invite_codes WHERE code = {code_slot}"); + let row = EventQuery { sql, args } + .into_query() + .fetch_optional(executor) + .await?; + + row.as_ref().map(super::rows::invite_code).transpose() +} + +/// `InviteCodeRepository::claim_code`: one conditional update, so exactly one caller wins. +pub(crate) async fn invite_claim_code<'e, E>( + executor: E, + code: &str, + pubkey: &Pubkey, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let mut args = Args::default(); + let code_slot = args.push(Arg::Text(code.to_owned())); + let pubkey_slot = args.push(Arg::Bytes(pubkey.as_bytes().to_vec())); + let now_slot = args.push(Arg::Timestamp(now)); + let sql = format!( + "UPDATE invite_codes SET remaining_uses = remaining_uses - 1, \ + claimed_by = COALESCE(claimed_by, {pubkey_slot}), updated_at = {now_slot} \ + WHERE code = {code_slot} AND (remaining_uses IS NULL OR remaining_uses > 0) \ + AND (expires_at IS NULL OR expires_at > {now_slot})" + ); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected() + > 0) +} + +/// `InviteCodeRepository::find_active_codes`: unexpired and claimable, newest first. +pub(crate) async fn invite_find_active_codes<'e, E>( + executor: E, + limit: u64, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let mut args = Args::default(); + let now_slot = args.push(Arg::Timestamp(now)); + let limit_slot = args.push(Arg::BigInt(limit as i64)); + let sql = format!( + "SELECT {INVITE_COLUMNS} FROM invite_codes \ + WHERE (expires_at IS NULL OR expires_at > {now_slot}) \ + AND (remaining_uses IS NULL OR remaining_uses > 0) \ + ORDER BY created_at DESC LIMIT {limit_slot}" + ); + let rows = EventQuery { sql, args } + .into_query() + .fetch_all(executor) + .await?; + + rows.iter().map(super::rows::invite_code).collect() +} + +/// `InviteCodeRepository::delete_expired_codes`. +pub(crate) async fn invite_delete_expired<'e, E>(executor: E) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let now_slot = args.push(Arg::Timestamp(Utc::now())); + let sql = format!( + "DELETE FROM invite_codes WHERE expires_at IS NOT NULL AND expires_at <= {now_slot}" + ); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected()) +} + +/// `DvmJobRepository::create`: a submitted job with every optional column empty. +pub(crate) async fn dvm_create<'e, E>( + executor: E, + id: &EventId, + requester: &Pubkey, + kind: Kind, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let mut args = Args::default(); + let id_slot = args.push(Arg::Bytes(id.as_bytes().to_vec())); + let requester_slot = args.push(Arg::Bytes(requester.as_bytes().to_vec())); + let kind_slot = args.push(Arg::Int(int32(kind.0 as i64, "kind")?)); + let status_slot = args.push(Arg::Text(DvmJobStatus::Submitted.as_str().to_owned())); + let created_slot = args.push(Arg::Timestamp(now)); + let updated_slot = args.push(Arg::Timestamp(now)); + let sql = format!( + "INSERT INTO dvm_jobs (id, requester_pubkey, kind, worker_index, status, \ + result_event_id, error, picked_up_at, completed_at, created_at, updated_at) \ + VALUES ({id_slot}, {requester_slot}, {kind_slot}, NULL, {status_slot}, NULL, NULL, \ + NULL, NULL, {created_slot}, {updated_slot})" + ); + + EventQuery { sql, args } + .into_query() + .execute(executor) + .await?; + + Ok(DvmJob { + id: *id, + requester_pubkey: *requester, + kind, + worker_index: None, + status: DvmJobStatus::Submitted, + result_event_id: None, + error: None, + picked_up_at: None, + completed_at: None, + created_at: now, + updated_at: now, + }) +} + +/// `DvmJobRepository::find_by_id`. +pub(crate) async fn dvm_find_by_id<'e, E>( + executor: E, + id: &EventId, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let id_slot = args.push(Arg::Bytes(id.as_bytes().to_vec())); + let sql = format!("SELECT {DVM_COLUMNS} FROM dvm_jobs WHERE id = {id_slot}"); + let row = EventQuery { sql, args } + .into_query() + .fetch_optional(executor) + .await?; + + row.as_ref().map(super::rows::dvm_job).transpose() +} + +/// `DvmJobRepository::assign_worker`: a conditional update from `submitted`. +pub(crate) async fn dvm_assign_worker<'e, E>( + executor: E, + id: &EventId, + worker_index: i32, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let mut args = Args::default(); + let id_slot = args.push(Arg::Bytes(id.as_bytes().to_vec())); + let worker_slot = args.push(Arg::Int(worker_index)); + let picked_slot = args.push(Arg::Timestamp(now)); + let updated_slot = args.push(Arg::Timestamp(now)); + let submitted_slot = args.push(Arg::Text(DvmJobStatus::Submitted.as_str().to_owned())); + let picked_up_slot = args.push(Arg::Text(DvmJobStatus::PickedUp.as_str().to_owned())); + let sql = format!( + "UPDATE dvm_jobs SET worker_index = {worker_slot}, status = {picked_up_slot}, \ + picked_up_at = {picked_slot}, updated_at = {updated_slot} \ + WHERE id = {id_slot} AND status = {submitted_slot}" + ); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected() + > 0) +} + +/// `DvmJobRepository::update_status`: the `FieldUpdate` fields are applied by key presence. +pub(crate) async fn dvm_update_status<'e, E>( + executor: E, + update: &DvmJobUpdate, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let mut args = Args::default(); + let mut assignments = Vec::new(); + + let status_slot = args.push(Arg::Text(update.status.as_str().to_owned())); + assignments.push(format!("status = {status_slot}")); + let updated_slot = args.push(Arg::Timestamp(now)); + assignments.push(format!("updated_at = {updated_slot}")); + if update.status.is_terminal() { + let completed_slot = args.push(Arg::Timestamp(now)); + assignments.push(format!("completed_at = {completed_slot}")); + } + match &update.result_event_id { + FieldUpdate::Absent => {} + FieldUpdate::Clear => assignments.push("result_event_id = NULL".to_owned()), + FieldUpdate::Set(id) => { + let slot = args.push(Arg::Bytes(id.as_bytes().to_vec())); + assignments.push(format!("result_event_id = {slot}")); + } + } + match &update.error { + FieldUpdate::Absent => {} + FieldUpdate::Clear => assignments.push("error = NULL".to_owned()), + FieldUpdate::Set(error) => { + let slot = args.push(Arg::Text(error.clone())); + assignments.push(format!("error = {slot}")); + } + } + + let id_slot = args.push(Arg::Bytes(update.id.as_bytes().to_vec())); + let sql = format!( + "UPDATE dvm_jobs SET {} WHERE id = {id_slot} RETURNING {DVM_COLUMNS}", + assignments.join(", ") + ); + let row = EventQuery { sql, args } + .into_query() + .fetch_optional(executor) + .await?; + + row.as_ref().map(super::rows::dvm_job).transpose() +} + +/// `DvmJobRepository::find_pending_jobs`: submitted and picked-up jobs, oldest first. +pub(crate) async fn dvm_find_pending_jobs<'e, E>( + executor: E, + limit: u64, + kinds: Option<&[Kind]>, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let statuses = args.push(Arg::TextList(vec![ + DvmJobStatus::Submitted.as_str().to_owned(), + DvmJobStatus::PickedUp.as_str().to_owned(), + ])); + let mut kinds_clause = String::new(); + if let Some(kinds) = kinds.filter(|kinds| !kinds.is_empty()) { + let kinds: Vec = kinds + .iter() + .map(|kind| int32(kind.0 as i64, "kind")) + .collect::>()?; + let kinds_slot = args.push(Arg::IntList(kinds)); + kinds_clause = format!(" AND kind = ANY({kinds_slot})"); + } + let limit_slot = args.push(Arg::BigInt(limit as i64)); + let sql = format!( + "SELECT {DVM_COLUMNS} FROM dvm_jobs WHERE status = ANY({statuses}){kinds_clause} \ + ORDER BY created_at ASC LIMIT {limit_slot}" + ); + let rows = EventQuery { sql, args } + .into_query() + .fetch_all(executor) + .await?; + + rows.iter().map(super::rows::dvm_job).collect() +} + +/// `ReportRepository::create`: inserts the row and returns it with its generated id. +pub(crate) async fn report_create<'e, E>( + executor: E, + report: &NewReport, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let mut args = Args::default(); + let event_slot = args.push(Arg::Bytes(report.event_id.as_bytes().to_vec())); + let reporter_slot = args.push(Arg::Bytes(report.reporter_pubkey.as_bytes().to_vec())); + let reported_pubkey_slot = match &report.reported_pubkey { + Some(pubkey) => args.push(Arg::Bytes(pubkey.as_bytes().to_vec())), + None => args.push(Arg::NullBytes), + }; + let reported_event_slot = match &report.reported_event_id { + Some(id) => args.push(Arg::Bytes(id.as_bytes().to_vec())), + None => args.push(Arg::NullBytes), + }; + let type_slot = args.push(Arg::Text(report.report_type.as_str().to_owned())); + let weight_slot = args.push(Arg::Real(report.weight)); + let actionable_slot = args.push(Arg::Bool(report.actionable)); + let created_slot = args.push(Arg::Timestamp(now)); + let sql = format!( + "INSERT INTO reports (event_id, reporter_pubkey, reported_pubkey, reported_event_id, \ + report_type, weight, actionable, created_at) VALUES ({event_slot}, {reporter_slot}, \ + {reported_pubkey_slot}, {reported_event_slot}, {type_slot}, {weight_slot}, \ + {actionable_slot}, {created_slot}) RETURNING id" + ); + let row = EventQuery { sql, args } + .into_query() + .fetch_one(executor) + .await?; + + Ok(Report { + id: row.try_get("id")?, + event_id: report.event_id, + reporter_pubkey: report.reporter_pubkey, + reported_pubkey: report.reported_pubkey, + reported_event_id: report.reported_event_id, + report_type: report.report_type, + weight: report.weight, + actionable: report.actionable, + created_at: now, + }) +} + +/// `ReportRepository::find_by_event_id`. +pub(crate) async fn report_find_by_event_id<'e, E>( + executor: E, + event_id: &EventId, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let event_slot = args.push(Arg::Bytes(event_id.as_bytes().to_vec())); + let sql = format!("SELECT {REPORT_COLUMNS} FROM reports WHERE event_id = {event_slot}"); + let rows = EventQuery { sql, args } + .into_query() + .fetch_all(executor) + .await?; + + rows.iter().map(super::rows::report).collect() +} + +/// `ReportRepository::find_actionable`: actionable rows, newest first. +pub(crate) async fn report_find_actionable<'e, E>( + executor: E, + limit: u64, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let limit_slot = args.push(Arg::BigInt(limit as i64)); + let sql = format!( + "SELECT {REPORT_COLUMNS} FROM reports WHERE actionable = true \ + ORDER BY created_at DESC LIMIT {limit_slot}" + ); + let rows = EventQuery { sql, args } + .into_query() + .fetch_all(executor) + .await?; + + rows.iter().map(super::rows::report).collect() +} + +/// `ReportRepository::find_actionable_targets`: every distinct target, unpaginated. +pub(crate) async fn report_find_actionable_targets<'e, E>( + executor: E, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let sql = "SELECT DISTINCT reported_pubkey, reported_event_id FROM reports \ + WHERE actionable = true"; + let rows = EventQuery { + sql: sql.to_owned(), + args: Args::default(), + } + .into_query() + .fetch_all(executor) + .await?; + + rows.iter() + .map(|row| { + let reported_pubkey = optional_bytes(row, "reported_pubkey")? + .map(|bytes| bytes_to_pubkey(bytes, "reported_pubkey")) + .transpose()?; + let reported_event_id = optional_bytes(row, "reported_event_id")? + .map(|bytes| bytes_to_event_id(bytes, "reported_event_id")) + .transpose()?; + Ok(ActionableTarget { + reported_pubkey, + reported_event_id, + }) + }) + .collect() +} + +fn int32(value: i64, column: &'static str) -> Result { + i32::try_from(value).map_err(|_| StorageError::ValueOutOfRange { + column, + value: value.to_string(), + }) +} + +fn optional_bytes( + row: &sqlx::postgres::PgRow, + column: &'static str, +) -> Result>, StorageError> { + Ok(row.try_get::>, _>(column)?) +} + +fn bytes_to_pubkey(bytes: Vec, column: &'static str) -> Result { + bytes + .try_into() + .map(Pubkey::from_byte_array) + .map_err(|bytes: Vec| StorageError::ValueOutOfRange { + column, + value: format!("{} bytes", bytes.len()), + }) +} + +fn bytes_to_event_id(bytes: Vec, column: &'static str) -> Result { + bytes + .try_into() + .map(EventId::from_byte_array) + .map_err(|bytes: Vec| StorageError::ValueOutOfRange { + column, + value: format!("{} bytes", bytes.len()), + }) +} diff --git a/crates/nostream-storage/src/postgres/mod.rs b/crates/nostream-storage/src/postgres/mod.rs new file mode 100644 index 00000000..f2bb972c --- /dev/null +++ b/crates/nostream-storage/src/postgres/mod.rs @@ -0,0 +1,542 @@ +//! The Postgres backend: a pooled implementation of the nine repository traits, the +//! serializable transaction that spans users, invoices and the outbox, and the embedded +//! migrator. Mirrors `src/database/client.ts`, `src/database/transaction.ts` and the nine +//! files under `src/repositories/`. +//! +//! Queries are composed at runtime (`sqlx::query` with explicit `Row::try_get` +//! conversions), never through `query!`/`query_as!`: CI has no database to check the +//! macros against, and the event queries are dynamically composed. + +mod events; +mod invoices; +mod metadata; +mod notifications; +mod repository; +mod rows; +mod sql; +mod users; + +use std::env; +use std::sync::Arc; +use std::time::Duration; + +use nostream_config::Settings; +use sqlx::postgres::{PgConnectOptions, PgPool, PgPoolOptions}; +use sqlx::{Postgres, Transaction}; + +use crate::error::StorageError; + +pub(crate) use sql::{Arg, Args, EventQuery, Gates, Nip50Gate}; + +/// The default `nip50.language` when the settings leave it out (`simple`, as the capture's +/// FTS index is built). +pub(crate) const DEFAULT_TS_CONFIG: &str = "simple"; +/// The default `nip50.maxQueryLength` (`src/repositories/event-repository.ts:50-52`). +pub(crate) const DEFAULT_MAX_SEARCH_QUERY_LENGTH: u64 = 256; +/// The capture's default page size (`DEFAULT_FILTER_LIMIT`). +pub(crate) const DEFAULT_FILTER_LIMIT: u64 = 500; +/// The retention batch size (`RETENTION_BATCH_SIZE`). +pub(crate) const RETENTION_BATCH_SIZE: u64 = 1000; + +/// A pooled Postgres backend with the master pool and, when configured, a read replica. +#[derive(Debug, Clone)] +pub struct PostgresBackend { + master: PgPool, + reader: PgPool, + settings: Option>, +} + +/// The connection settings one pool is built from, resolved from the environment exactly +/// as `src/database/client.ts:31-92` resolves them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConnectParts { + pub host: String, + pub port: u16, + pub username: String, + pub password: Option, + pub database: String, +} + +impl ConnectParts { + fn options(&self) -> PgConnectOptions { + let mut options = PgConnectOptions::new() + .host(&self.host) + .port(self.port) + .username(&self.username) + .database(&self.database); + if let Some(password) = &self.password { + options = options.password(password); + } + options + } +} + +/// The pool bounds one backend is built with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PoolBounds { + pub min_connections: u32, + pub max_connections: u32, + pub acquire_timeout_ms: u64, +} + +impl Default for PoolBounds { + fn default() -> Self { + Self { + min_connections: 0, + max_connections: 3, + acquire_timeout_ms: 60_000, + } + } +} + +impl PostgresBackend { + /// Connects a master pool and, when `READ_REPLICA_ENABLED=true`, the replica pool this + /// worker reads from. Both pools use the capture's `DB_*` names and defaults. + pub async fn from_env() -> Result { + Self::from_lookup(&|name| env::var(name).ok()).await + } + + /// Connects master and reader pools from `url`; the harness and the tests use it. + pub async fn connect(url: &str) -> Result { + let options = url.parse::()?; + Self::with_pools( + options.clone(), + PoolBounds::default(), + options, + PoolBounds::default(), + ) + .await + } + + /// Builds the backend without dialling. The dyn-compatibility test and the migration + /// target use it, because neither needs a live connection to prove the trait objects. + pub fn connect_lazy(url: &str) -> Result { + let options = url.parse::()?; + let master = lazy_pool(options, &PoolBounds::default()); + let reader = master.clone(); + Ok(Self { + master, + reader, + settings: None, + }) + } + + /// Supplies the `nip50`/`nip56` gates. Absent settings mean both features are off, + /// matching the capture's optional settings getter. + pub fn with_settings(mut self, settings: Arc) -> Self { + self.settings = Some(settings); + self + } + + /// The master pool, for the migrator and for callers that own their own transactions. + pub fn master(&self) -> &PgPool { + &self.master + } + + /// Applies the embedded schema migrations. + pub async fn migrate(&self) -> Result<(), StorageError> { + migrate_pool(&self.master).await + } + + /// Opens a serializable transaction (`src/database/transaction.ts:12`). + pub fn begin(&self) -> crate::repositories::StorageFuture<'_, PostgresTransaction> { + Box::pin(async move { + let transaction = self + .master + .begin_with("BEGIN ISOLATION LEVEL SERIALIZABLE") + .await?; + Ok(PostgresTransaction { + transaction: tokio::sync::Mutex::new(Some(transaction)), + }) + }) + } + + pub(crate) fn gates(&self) -> Gates { + let Some(settings) = &self.settings else { + return Gates::default(); + }; + + let nip50 = settings.nip50.as_ref().and_then(|nip50| { + (nip50.enabled == Some(true)).then(|| Nip50Gate { + language: nip50 + .language + .clone() + .unwrap_or_else(|| DEFAULT_TS_CONFIG.to_owned()), + max_query_length: nip50 + .max_query_length + .unwrap_or(DEFAULT_MAX_SEARCH_QUERY_LENGTH), + }) + }); + + let nip56 = settings + .nip56 + .as_ref() + .is_some_and(|nip56| nip56.enabled && nip56.hide_actionable_reports); + + Gates { nip50, nip56 } + } + + async fn from_lookup(lookup: &F) -> Result + where + F: Fn(&str) -> Option + Sync, + { + let master = master_options(lookup)?; + let bounds = pool_bounds(lookup, ""); + let reader = if lookup("READ_REPLICA_ENABLED").as_deref() == Some("true") { + let index = replica_index(lookup)?; + ( + replica_parts(lookup, index)?.options(), + pool_bounds(lookup, &format!("RR{index}_")), + ) + } else { + (master.clone(), bounds.clone()) + }; + + Self::with_pools( + master.options([("timezone", "UTC")]), + bounds, + reader.0.options([("timezone", "UTC")]), + reader.1, + ) + .await + } + + async fn with_pools( + master: PgConnectOptions, + master_bounds: PoolBounds, + reader: PgConnectOptions, + reader_bounds: PoolBounds, + ) -> Result { + let master_pool = connected_pool(master, &master_bounds).await?; + let reader_pool = connected_pool(reader, &reader_bounds).await?; + Ok(Self { + master: master_pool, + reader: reader_pool, + settings: None, + }) + } +} + +/// A serializable transaction over the master pool. It implements exactly the three +/// repositories the capture's payments service spans; a streaming trait is deliberately +/// absent, because a stream would hold the connection for its whole life. +pub struct PostgresTransaction { + transaction: tokio::sync::Mutex>>, +} + +impl PostgresTransaction { + /// Commits the transaction; dropping it rolls back. + pub async fn commit(self) -> Result<(), StorageError> { + let Some(transaction) = self.transaction.into_inner() else { + return Err(StorageError::InvalidArgument { + message: "the transaction is already closed", + }); + }; + transaction.commit().await?; + Ok(()) + } +} + +/// Applies the embedded migrations to one pool. Running it twice is a no-op. +pub async fn migrate_pool(pool: &PgPool) -> Result<(), StorageError> { + sqlx::migrate!("./migrations").run(pool).await?; + Ok(()) +} + +fn pool_options(bounds: &PoolBounds) -> PgPoolOptions { + PgPoolOptions::new() + .min_connections(bounds.min_connections) + .max_connections(bounds.max_connections) + .acquire_timeout(Duration::from_millis(bounds.acquire_timeout_ms)) +} + +fn lazy_pool(options: PgConnectOptions, bounds: &PoolBounds) -> PgPool { + pool_options(bounds).connect_lazy_with(options) +} + +/// Opens a pool and waits for its first connection, so an unreachable database is reported +/// at construction rather than at the first query. +async fn connected_pool( + options: PgConnectOptions, + bounds: &PoolBounds, +) -> Result { + Ok(pool_options(bounds).connect_with(options).await?) +} + +/// The master connection options: `DB_URI` when set, else the capture's `DB_*` parts. +pub(crate) fn master_options(lookup: &F) -> Result +where + F: Fn(&str) -> Option + Sync, +{ + if let Some(uri) = lookup("DB_URI") { + return Ok(uri.parse::()?); + } + + Ok(master_parts(lookup)?.options()) +} + +/// The capture's `DB_*` connection parts. +pub(crate) fn master_parts(lookup: &F) -> Result +where + F: Fn(&str) -> Option + Sync, +{ + Ok(ConnectParts { + host: lookup("DB_HOST").unwrap_or_else(|| "localhost".to_owned()), + port: parse_port(lookup, "DB_PORT")?.unwrap_or(5432), + username: lookup("DB_USER").unwrap_or_else(|| "postgres".to_owned()), + password: lookup("DB_PASSWORD"), + database: lookup("DB_NAME").unwrap_or_else(|| "postgres".to_owned()), + }) +} + +/// The read-replica index the capture computes: `WORKER_INDEX % READ_REPLICAS`. +pub(crate) fn replica_index(lookup: &F) -> Result +where + F: Fn(&str) -> Option, +{ + let worker_index = lookup("WORKER_INDEX") + .map(|value| value.parse::()) + .transpose() + .map_err(|_| StorageError::InvalidArgument { + message: "WORKER_INDEX must be a non-negative integer", + })? + .unwrap_or(0); + let replicas = lookup("READ_REPLICAS") + .map(|value| value.parse::()) + .transpose() + .map_err(|_| StorageError::InvalidArgument { + message: "READ_REPLICAS must be a non-negative integer", + })? + .unwrap_or(1); + + if replicas == 0 { + return Err(StorageError::InvalidArgument { + message: "READ_REPLICAS must be at least 1", + }); + } + + Ok(worker_index % replicas) +} + +/// The `RR{index}_DB_*` parts of one read replica. +pub(crate) fn replica_parts(lookup: &F, index: u32) -> Result +where + F: Fn(&str) -> Option, +{ + let prefix = format!("RR{index}_DB_"); + let name = |suffix: &str| lookup(&format!("{prefix}{suffix}")); + + Ok(ConnectParts { + host: name("HOST").unwrap_or_else(|| "localhost".to_owned()), + port: parse_port_by(lookup, &format!("{prefix}PORT"))?.unwrap_or(5432), + username: name("USER").unwrap_or_else(|| "postgres".to_owned()), + password: name("PASSWORD"), + database: name("NAME").unwrap_or_else(|| "postgres".to_owned()), + }) +} + +/// The pool bounds the capture reads: `DB_MIN_POOL_SIZE`, `DB_MAX_POOL_SIZE` and +/// `DB_ACQUIRE_CONNECTION_TIMEOUT`, or their `RR{n}_` variants. +pub(crate) fn pool_bounds(lookup: &F, prefix: &str) -> PoolBounds +where + F: Fn(&str) -> Option, +{ + let number = + |name: &str| lookup(&format!("{prefix}{name}")).and_then(|value| value.parse::().ok()); + + PoolBounds { + min_connections: number("DB_MIN_POOL_SIZE").unwrap_or(0) as u32, + max_connections: number("DB_MAX_POOL_SIZE").unwrap_or(3) as u32, + acquire_timeout_ms: number("DB_ACQUIRE_CONNECTION_TIMEOUT").unwrap_or(60_000), + } +} + +fn parse_port(lookup: &F, name: &str) -> Result, StorageError> +where + F: Fn(&str) -> Option, +{ + parse_port_by(lookup, name) +} + +fn parse_port_by(lookup: &F, name: &str) -> Result, StorageError> +where + F: Fn(&str) -> Option, +{ + lookup(name) + .map(|value| { + value + .parse::() + .map_err(|_| StorageError::InvalidArgument { + message: "a database port must be a valid TCP port", + }) + }) + .transpose() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + + fn lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option + Sync { + let pairs: Vec<(String, String)> = pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(); + move |name| { + pairs + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.clone()) + } + } + + #[test] + fn a_db_uri_wins_over_the_parts() { + let env = lookup(&[ + ("DB_URI", "postgres://alice:secret@db.internal:6543/relay"), + ("DB_HOST", "ignored"), + ]); + let options = master_options(&env).expect("a parseable uri"); + assert_eq!(options.get_host(), "db.internal"); + assert_eq!(options.get_port(), 6543); + assert_eq!(options.get_username(), "alice"); + assert_eq!(options.get_database(), Some("relay")); + } + #[test] + fn the_parts_carry_the_capture_defaults() { + let parts = master_parts(&lookup(&[])).expect("defaults"); + assert_eq!( + parts, + ConnectParts { + host: "localhost".to_owned(), + port: 5432, + username: "postgres".to_owned(), + password: None, + database: "postgres".to_owned(), + } + ); + } + + #[test] + fn the_parts_are_read_one_by_one() { + let env = lookup(&[ + ("DB_HOST", "10.0.0.5"), + ("DB_PORT", "6432"), + ("DB_USER", "relay"), + ("DB_PASSWORD", "hunter2"), + ("DB_NAME", "nostream"), + ]); + let parts = master_parts(&env).expect("parts"); + assert_eq!(parts.host, "10.0.0.5"); + assert_eq!(parts.port, 6432); + assert_eq!(parts.username, "relay"); + assert_eq!(parts.password.as_deref(), Some("hunter2")); + assert_eq!(parts.database, "nostream"); + } + + #[test] + fn a_bad_port_is_an_invalid_argument() { + let error = master_parts(&lookup(&[("DB_PORT", "eighty")])).expect_err("a bad port"); + assert!(matches!(error, StorageError::InvalidArgument { .. })); + } + + #[test] + fn the_replica_index_is_the_worker_index_modulo_the_replica_count() { + assert_eq!( + replica_index(&lookup(&[("WORKER_INDEX", "5"), ("READ_REPLICAS", "2")])).unwrap(), + 1 + ); + assert_eq!(replica_index(&lookup(&[])).unwrap(), 0); + } + + #[test] + fn a_zero_replica_count_is_rejected() { + let error = replica_index(&lookup(&[("READ_REPLICAS", "0")])).expect_err("zero replicas"); + assert!(matches!(error, StorageError::InvalidArgument { .. })); + } + + #[test] + fn the_replica_parts_use_their_own_prefix() { + let env = lookup(&[ + ("RR1_DB_HOST", "replica.internal"), + ("RR1_DB_PORT", "7432"), + ("RR1_DB_USER", "reader"), + ("RR1_DB_NAME", "nostream"), + ]); + let parts = replica_parts(&env, 1).expect("parts"); + assert_eq!(parts.host, "replica.internal"); + assert_eq!(parts.port, 7432); + assert_eq!(parts.username, "reader"); + assert_eq!(parts.database, "nostream"); + } + + #[test] + fn the_pool_bounds_carry_the_capture_defaults_and_prefixes() { + assert_eq!( + pool_bounds(&lookup(&[]), ""), + PoolBounds { + min_connections: 0, + max_connections: 3, + acquire_timeout_ms: 60_000, + } + ); + assert_eq!( + pool_bounds( + &lookup(&[ + ("RR2_DB_MIN_POOL_SIZE", "1"), + ("RR2_DB_MAX_POOL_SIZE", "9"), + ("RR2_DB_ACQUIRE_CONNECTION_TIMEOUT", "1500"), + ]), + "RR2_" + ), + PoolBounds { + min_connections: 1, + max_connections: 9, + acquire_timeout_ms: 1500, + } + ); + } + + #[tokio::test] + async fn absent_settings_leave_both_gates_off() { + let backend = + PostgresBackend::connect_lazy("postgres://localhost/postgres").expect("a lazy backend"); + let gates = backend.gates(); + assert!(gates.nip50.is_none()); + assert!(!gates.nip56); + } + + #[tokio::test] + async fn settings_supply_the_search_language_and_the_exclusion_gate() { + let raw = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/default-settings.yaml" + )) + .expect("the shipped defaults are readable"); + let mut document: serde_yaml::Value = + serde_yaml::from_str(&raw).expect("the shipped defaults are valid YAML"); + for (path, value) in [ + ("nip50.enabled", serde_yaml::Value::Bool(true)), + ( + "nip50.language", + serde_yaml::Value::String("english".to_owned()), + ), + ("nip56.enabled", serde_yaml::Value::Bool(true)), + ("nip56.hideActionableReports", serde_yaml::Value::Bool(true)), + ] { + document = nostream_config::path::set_by_path(&document, path, value) + .expect("the path is writable"); + } + let settings: Settings = serde_yaml::from_value(document).expect("the defaults fit"); + let backend = PostgresBackend::connect_lazy("postgres://localhost/postgres") + .expect("a lazy backend") + .with_settings(Arc::new(settings)); + let gates = backend.gates(); + let nip50 = gates.nip50.expect("nip50 is on"); + assert_eq!(nip50.language, "english"); + assert_eq!(nip50.max_query_length, DEFAULT_MAX_SEARCH_QUERY_LENGTH); + assert!(gates.nip56); + } +} diff --git a/crates/nostream-storage/src/postgres/notifications.rs b/crates/nostream-storage/src/postgres/notifications.rs new file mode 100644 index 00000000..bd017e4a --- /dev/null +++ b/crates/nostream-storage/src/postgres/notifications.rs @@ -0,0 +1,386 @@ +//! `NotificationDeliveryLogRepository` and `NotificationOutboxRepository` against Postgres. +//! Mirrors `src/repositories/notification-delivery-log-repository.ts` and +//! `src/repositories/notification-outbox-repository.ts`. + +use chrono::{DateTime, Duration, Utc}; +use sqlx::postgres::Postgres; +use sqlx::{Executor, PgPool}; + +use super::{Arg, Args, EventQuery}; +use crate::error::StorageError; +use crate::repositories::{ + DeliveryLogEntry, DeliveryLogFilters, NOTIFICATION_OUTBOX_PROCESSING_LEASE_MS, + NewDeliveryLogEntry, OutboxMessage, OutboxPayload, OutboxStatus, +}; + +const OUTBOX_COLUMNS: &str = "id::text AS id, event_type, payload, status, attempt_count, \ +available_at, last_error, delivered_at, created_at, updated_at"; + +const DELIVERY_LOG_COLUMNS: &str = "id::text AS id, outbox_id::text AS outbox_id, event_type, \ +target_id, target_type, status, attempt_number, error_snippet, created_at"; + +/// `append`: one delivery attempt, timestamped now. +pub(crate) async fn append<'e, E>( + executor: E, + entry: &NewDeliveryLogEntry, +) -> Result<(), StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let outbox_slot = match &entry.outbox_id { + Some(outbox_id) => args.push(Arg::Text(outbox_id.clone())), + None => args.push(Arg::NullText), + }; + let event_type_slot = args.push(Arg::Text(entry.event_type.clone())); + let target_id_slot = args.push(Arg::Text(entry.target_id.clone())); + let target_type_slot = args.push(Arg::Text(entry.target_type.as_str().to_owned())); + let status_slot = args.push(Arg::Text(entry.status.as_str().to_owned())); + let attempt_slot = args.push(Arg::Int(entry.attempt_number)); + let error_slot = match &entry.error_snippet { + Some(snippet) => args.push(Arg::Text(snippet.clone())), + None => args.push(Arg::NullText), + }; + let created_slot = args.push(Arg::Timestamp(Utc::now())); + let sql = format!( + "INSERT INTO notification_delivery_log (outbox_id, event_type, target_id, target_type, \ + status, attempt_number, error_snippet, created_at) VALUES ({outbox_slot}::uuid, \ + {event_type_slot}, {target_id_slot}, {target_type_slot}, {status_slot}, \ + {attempt_slot}, {error_slot}, {created_slot})" + ); + + EventQuery { sql, args } + .into_query() + .execute(executor) + .await?; + Ok(()) +} + +/// `find_recent`: newest first, optionally filtered by status and event type. +pub(crate) async fn find_recent<'e, E>( + executor: E, + limit: u64, + filters: &DeliveryLogFilters, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let mut conditions = String::new(); + if let Some(status) = filters.status { + let slot = args.push(Arg::Text(status.as_str().to_owned())); + conditions.push_str(&format!(" AND status = {slot}")); + } + if let Some(event_type) = &filters.event_type { + let slot = args.push(Arg::Text(event_type.clone())); + conditions.push_str(&format!(" AND event_type = {slot}")); + } + let limit_slot = args.push(Arg::BigInt(limit as i64)); + let sql = format!( + "SELECT {DELIVERY_LOG_COLUMNS} FROM notification_delivery_log WHERE 1 = 1{conditions} \ + ORDER BY created_at DESC LIMIT {limit_slot}" + ); + let rows = EventQuery { sql, args } + .into_query() + .fetch_all(executor) + .await?; + + rows.iter().map(super::rows::delivery_log_entry).collect() +} + +/// `findSuccessfulTargetIds`: the distinct targets that succeeded for one message. +pub(crate) async fn find_successful_target_ids<'e, E>( + executor: E, + outbox_id: &str, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let outbox_slot = args.push(Arg::Text(outbox_id.to_owned())); + let status_slot = args.push(Arg::Text( + crate::repositories::NotificationDeliveryStatus::Success + .as_str() + .to_owned(), + )); + let sql = format!( + "SELECT DISTINCT target_id FROM notification_delivery_log \ + WHERE outbox_id = {outbox_slot}::uuid AND status = {status_slot}" + ); + let rows = EventQuery { sql, args } + .into_query() + .fetch_all(executor) + .await?; + + rows.iter() + .map(|row| Ok(sqlx::Row::try_get::(row, "target_id")?)) + .collect() +} + +/// `deleteOlderThan`: prunes the log by creation time. +pub(crate) async fn delete_older_than<'e, E>( + executor: E, + cutoff: DateTime, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let cutoff_slot = args.push(Arg::Timestamp(cutoff)); + let sql = format!("DELETE FROM notification_delivery_log WHERE created_at < {cutoff_slot}"); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected()) +} + +/// `enqueue`: inserts a pending message and returns the stored row. +pub(crate) async fn enqueue<'e, E>( + executor: E, + event_type: &str, + payload: &OutboxPayload, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let mut args = Args::default(); + let event_type_slot = args.push(Arg::Text(event_type.to_owned())); + let payload_slot = args.push(Arg::Text(payload.to_string())); + let status_slot = args.push(Arg::Text(OutboxStatus::Pending.as_str().to_owned())); + let available_slot = args.push(Arg::Timestamp(now)); + let created_slot = args.push(Arg::Timestamp(now)); + let updated_slot = args.push(Arg::Timestamp(now)); + let sql = format!( + "INSERT INTO notification_outbox (event_type, payload, status, attempt_count, \ + available_at, created_at, updated_at) VALUES ({event_type_slot}, {payload_slot}::jsonb, \ + {status_slot}, 0, {available_slot}, {created_slot}, {updated_slot}) \ + RETURNING {OUTBOX_COLUMNS}" + ); + let row = EventQuery { sql, args } + .into_query() + .fetch_one(executor) + .await?; + + super::rows::outbox_message(&row) +} + +/// `claimBatch`: one transaction claims pending-or-stale rows and marks them processing. It +/// opens its own transaction, exactly as the capture does. +pub(crate) async fn claim_batch( + pool: &PgPool, + limit: u64, +) -> Result, StorageError> { + let mut transaction = pool.begin().await?; + let claimed = claim_batch_in(&mut transaction, limit).await?; + transaction.commit().await?; + Ok(claimed) +} + +/// The claim itself, inside a transaction the caller owns. +pub(crate) async fn claim_batch_in( + connection: &mut sqlx::PgConnection, + limit: u64, +) -> Result, StorageError> { + let now = Utc::now(); + let stale = now - Duration::milliseconds(NOTIFICATION_OUTBOX_PROCESSING_LEASE_MS); + + let mut args = Args::default(); + let pending_slot = args.push(Arg::Text(OutboxStatus::Pending.as_str().to_owned())); + let processing_slot = args.push(Arg::Text(OutboxStatus::Processing.as_str().to_owned())); + let now_slot = args.push(Arg::Timestamp(now)); + let stale_slot = args.push(Arg::Timestamp(stale)); + let limit_slot = args.push(Arg::BigInt(limit as i64)); + let sql = format!( + "SELECT {OUTBOX_COLUMNS} FROM notification_outbox \ + WHERE (status = {pending_slot} AND available_at <= {now_slot}) \ + OR (status = {processing_slot} AND updated_at < {stale_slot}) \ + ORDER BY created_at ASC LIMIT {limit_slot} FOR UPDATE SKIP LOCKED" + ); + let rows = EventQuery { sql, args } + .into_query() + .fetch_all(&mut *connection) + .await?; + + if rows.is_empty() { + return Ok(Vec::new()); + } + + let mut claimed = Vec::with_capacity(rows.len()); + let mut ids = Vec::with_capacity(rows.len()); + for row in &rows { + let id: String = sqlx::Row::try_get(row, "id")?; + ids.push(id.clone()); + let mut message = super::rows::outbox_message(row)?; + message.status = OutboxStatus::Processing; + message.updated_at = now; + claimed.push(message); + } + + let mut args = Args::default(); + let processing_slot = args.push(Arg::Text(OutboxStatus::Processing.as_str().to_owned())); + let now_slot = args.push(Arg::Timestamp(now)); + let ids_slot = args.push(Arg::TextList(ids)); + let sql = format!( + "UPDATE notification_outbox SET status = {processing_slot}, updated_at = {now_slot} \ + WHERE id = ANY({ids_slot}::uuid[])" + ); + EventQuery { sql, args } + .into_query() + .execute(&mut *connection) + .await?; + + Ok(claimed) +} + +/// `markDelivered`: delivered, timestamped, error cleared. +pub(crate) async fn mark_delivered<'e, E>(executor: E, id: &str) -> Result<(), StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let mut args = Args::default(); + let delivered_slot = args.push(Arg::Text(OutboxStatus::Delivered.as_str().to_owned())); + let now_slot = args.push(Arg::Timestamp(now)); + let updated_slot = args.push(Arg::Timestamp(now)); + let id_slot = args.push(Arg::Text(id.to_owned())); + let sql = format!( + "UPDATE notification_outbox SET status = {delivered_slot}, delivered_at = {now_slot}, \ + last_error = NULL, updated_at = {updated_slot} WHERE id = {id_slot}::uuid" + ); + + EventQuery { sql, args } + .into_query() + .execute(executor) + .await?; + Ok(()) +} + +/// `markFailed`: a retry with capped exponential backoff, or the dead transition. +pub(crate) async fn mark_failed<'e, E>( + executor: E, + id: &str, + error: &str, + attempt_count: i32, + max_attempts: i32, + base_delay_ms: i64, +) -> Result<(), StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let is_dead = attempt_count >= max_attempts; + let backoff = backoff_ms(base_delay_ms, attempt_count); + let available_at = if is_dead { + now + } else { + now + Duration::milliseconds(backoff) + }; + + let mut args = Args::default(); + let status = if is_dead { + OutboxStatus::Dead + } else { + OutboxStatus::Pending + }; + let status_slot = args.push(Arg::Text(status.as_str().to_owned())); + let attempts_slot = args.push(Arg::Int(attempt_count)); + let available_slot = args.push(Arg::Timestamp(available_at)); + let error_slot = args.push(Arg::Text(truncate_bytes(error, 2000))); + let updated_slot = args.push(Arg::Timestamp(now)); + let id_slot = args.push(Arg::Text(id.to_owned())); + let sql = format!( + "UPDATE notification_outbox SET status = {status_slot}, attempt_count = {attempts_slot}, \ + available_at = {available_slot}, last_error = {error_slot}, updated_at = {updated_slot} \ + WHERE id = {id_slot}::uuid" + ); + + EventQuery { sql, args } + .into_query() + .execute(executor) + .await?; + Ok(()) +} + +/// `deleteTerminalOlderThan`: prunes delivered and dead messages. +pub(crate) async fn delete_terminal_older_than<'e, E>( + executor: E, + cutoff: DateTime, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let statuses = args.push(Arg::TextList(vec![ + OutboxStatus::Delivered.as_str().to_owned(), + OutboxStatus::Dead.as_str().to_owned(), + ])); + let cutoff_slot = args.push(Arg::Timestamp(cutoff)); + let sql = format!( + "DELETE FROM notification_outbox WHERE status = ANY({statuses}) \ + AND updated_at < {cutoff_slot}" + ); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected()) +} + +/// `min(60_000, max(0, base_delay_ms) * 2^max(0, attempt_count - 1))`, computed without +/// overflowing for any attempt count. +fn backoff_ms(base_delay_ms: i64, attempt_count: i32) -> i64 { + let base = i128::from(base_delay_ms.max(0)); + let exponent = u32::try_from(attempt_count.saturating_sub(1).max(0)).unwrap_or(u32::MAX); + let factor = if exponent >= 62 { + i128::MAX + } else { + 1i128 << exponent + }; + i64::try_from(base.saturating_mul(factor).min(60_000)).unwrap_or(60_000) +} + +/// Truncates at a character boundary, keeping at most `limit` bytes. +fn truncate_bytes(value: &str, limit: usize) -> String { + if value.len() <= limit { + return value.to_owned(); + } + let mut end = limit; + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_owned() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + + #[test] + fn the_backoff_doubles_and_caps_at_a_minute() { + assert_eq!(backoff_ms(1000, 1), 1000); + assert_eq!(backoff_ms(1000, 2), 2000); + assert_eq!(backoff_ms(1000, 3), 4000); + assert_eq!(backoff_ms(100_000, 2), 60_000); + assert_eq!(backoff_ms(-5, 1), 0); + } + + #[test] + fn the_backoff_does_not_overflow_at_a_large_attempt_count() { + assert_eq!(backoff_ms(1000, i32::MAX), 60_000); + assert_eq!(backoff_ms(i64::MAX, 2), 60_000); + } + + #[test] + fn an_error_snippet_is_truncated_at_a_character_boundary() { + assert_eq!(truncate_bytes("abcd", 10), "abcd"); + assert_eq!(truncate_bytes("abcdef", 4), "abcd"); + // "é" is two bytes, so cutting at 3 would split it. + assert_eq!(truncate_bytes("aéé", 3), "aé"); + } +} diff --git a/crates/nostream-storage/src/postgres/repository.rs b/crates/nostream-storage/src/postgres/repository.rs new file mode 100644 index 00000000..7da2054d --- /dev/null +++ b/crates/nostream-storage/src/postgres/repository.rs @@ -0,0 +1,587 @@ +//! The nine trait implementations on [`PostgresBackend`], and the three the serializable +//! [`PostgresTransaction`] carries. + +use nostream_core::{EventId, Filter, Kind, Pubkey}; + +use super::{ + PostgresBackend, PostgresTransaction, events, invoices, metadata, notifications, users, +}; +use crate::error::StorageError; +use crate::repositories::{ + ActionableTarget, CreateInviteCodeOptions, DeliveryLogEntry, DeliveryLogFilters, DvmJob, + DvmJobRepository, DvmJobUpdate, EventPurgeCounts, EventRecord, EventRepository, + EventRetentionOptions, InviteCode, InviteCodeRepository, Invoice, InvoiceRepository, + InvoiceStatus, NewDeliveryLogEntry, NewReport, Nip05Verification, Nip05VerificationRepository, + NotificationDeliveryLogRepository, NotificationOutboxRepository, OutboxMessage, OutboxPayload, + QueryStream, ReconciliationItem, Report, ReportRepository, StorageFuture, User, UserPatch, + UserRepository, +}; + +impl EventRepository for PostgresBackend { + fn create<'a>(&'a self, record: &'a EventRecord) -> StorageFuture<'a, u64> { + Box::pin(async move { events::create(&self.master, record).await }) + } + + fn create_many<'a>(&'a self, records: &'a [EventRecord]) -> StorageFuture<'a, u64> { + Box::pin(async move { events::create_many(&self.master, records).await }) + } + + fn upsert<'a>(&'a self, record: &'a EventRecord) -> StorageFuture<'a, u64> { + Box::pin(async move { events::upsert(&self.master, record).await }) + } + + fn upsert_many<'a>(&'a self, records: &'a [EventRecord]) -> StorageFuture<'a, u64> { + Box::pin(async move { events::upsert_many(&self.master, records).await }) + } + + fn find_by_filters<'a>( + &'a self, + filters: &'a [Filter], + ) -> StorageFuture<'a, QueryStream<'a, EventRecord>> { + Box::pin(async move { + let gates = self.gates(); + events::find_by_filters(&self.reader, filters, &gates) + }) + } + + fn count_by_filters<'a>(&'a self, filters: &'a [Filter]) -> StorageFuture<'a, u64> { + Box::pin(async move { + let gates = self.gates(); + events::count_by_filters(&self.reader, filters, &gates).await + }) + } + + fn find_reconciliation_items<'a>( + &'a self, + filter: &'a Filter, + ) -> StorageFuture<'a, QueryStream<'a, ReconciliationItem>> { + Box::pin(async move { + let gates = self.gates(); + events::find_reconciliation_items(&self.reader, filter, &gates) + }) + } + + fn delete_by_pubkey_and_ids<'a>( + &'a self, + pubkey: &'a Pubkey, + ids: &'a [EventId], + ) -> StorageFuture<'a, u64> { + Box::pin(async move { events::delete_by_pubkey_and_ids(&self.master, pubkey, ids).await }) + } + + fn delete_by_pubkey_except_kinds<'a>( + &'a self, + pubkey: &'a Pubkey, + excluded_kinds: &'a [Kind], + ) -> StorageFuture<'a, u64> { + Box::pin(async move { + events::delete_by_pubkey_except_kinds(&self.master, pubkey, excluded_kinds).await + }) + } + + fn has_active_request_to_vanish<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, bool> { + Box::pin(async move { events::has_active_request_to_vanish(&self.reader, pubkey).await }) + } + + fn delete_expired_and_retained<'a>( + &'a self, + options: &'a EventRetentionOptions, + ) -> StorageFuture<'a, EventPurgeCounts> { + Box::pin(async move { events::delete_expired_and_retained(&self.master, options).await }) + } +} + +impl InvoiceRepository for PostgresBackend { + fn find_by_id<'a>(&'a self, id: &'a str) -> StorageFuture<'a, Option> { + Box::pin(async move { invoices::find_by_id(&self.master, id).await }) + } + + fn upsert<'a>(&'a self, invoice: &'a Invoice) -> StorageFuture<'a, u64> { + Box::pin(async move { invoices::upsert(&self.master, invoice).await }) + } + + fn update_status<'a>( + &'a self, + id: &'a str, + status: InvoiceStatus, + ) -> StorageFuture<'a, Option> { + Box::pin(async move { invoices::update_status(&self.master, id, status).await }) + } + + fn confirm_invoice<'a>( + &'a self, + invoice_id: &'a str, + amount_received: i64, + confirmed_at: chrono::DateTime, + ) -> StorageFuture<'a, ()> { + Box::pin(async move { + let mut transaction = self + .master + .begin_with("BEGIN ISOLATION LEVEL SERIALIZABLE") + .await?; + invoices::confirm_invoice(&mut *transaction, invoice_id, amount_received, confirmed_at) + .await?; + transaction.commit().await?; + Ok(()) + }) + } + + fn find_pending_invoices<'a>( + &'a self, + offset: u64, + limit: u64, + ) -> StorageFuture<'a, Vec> { + Box::pin(async move { invoices::find_pending_invoices(&self.master, offset, limit).await }) + } +} + +impl UserRepository for PostgresBackend { + fn find_by_pubkey<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, Option> { + Box::pin(async move { users::find_by_pubkey(&self.master, pubkey).await }) + } + + fn upsert<'a>(&'a self, user: &'a UserPatch) -> StorageFuture<'a, u64> { + Box::pin(async move { users::upsert(&self.master, user).await }) + } + + fn get_balance_by_pubkey<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, i64> { + Box::pin(async move { users::get_balance_by_pubkey(&self.master, pubkey).await }) + } + + fn is_vanished<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, bool> { + Box::pin(async move { + if let Some(user) = users::find_by_pubkey(&self.master, pubkey).await? { + return Ok(user.is_vanished); + } + let vanished = events::has_active_request_to_vanish(&self.master, pubkey).await?; + users::upsert_vanish_state(&self.master, pubkey, vanished).await?; + Ok(vanished) + }) + } + + fn set_vanished<'a>(&'a self, pubkey: &'a Pubkey, vanished: bool) -> StorageFuture<'a, u64> { + Box::pin(async move { users::upsert_vanish_state(&self.master, pubkey, vanished).await }) + } + + fn admit_user<'a>( + &'a self, + pubkey: &'a Pubkey, + admitted_at: chrono::DateTime, + ) -> StorageFuture<'a, ()> { + Box::pin(async move { + let mut transaction = self + .master + .begin_with("BEGIN ISOLATION LEVEL SERIALIZABLE") + .await?; + users::admit_user(&mut *transaction, pubkey, admitted_at).await?; + transaction.commit().await?; + Ok(()) + }) + } + + fn revoke_admission<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, u64> { + Box::pin(async move { users::revoke_admission(&self.master, pubkey).await }) + } +} + +impl Nip05VerificationRepository for PostgresBackend { + fn find_by_pubkey<'a>( + &'a self, + pubkey: &'a Pubkey, + ) -> StorageFuture<'a, Option> { + Box::pin(async move { metadata::nip05_find_by_pubkey(&self.master, pubkey).await }) + } + + fn upsert<'a>(&'a self, verification: &'a Nip05Verification) -> StorageFuture<'a, u64> { + Box::pin(async move { metadata::nip05_upsert(&self.master, verification).await }) + } + + fn find_pending_verifications<'a>( + &'a self, + update_frequency_ms: i64, + max_failures: i32, + limit: u64, + ) -> StorageFuture<'a, Vec> { + Box::pin(async move { + metadata::nip05_find_pending(&self.master, update_frequency_ms, max_failures, limit) + .await + }) + } + + fn delete_by_pubkey<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, u64> { + Box::pin(async move { metadata::nip05_delete_by_pubkey(&self.master, pubkey).await }) + } +} + +impl InviteCodeRepository for PostgresBackend { + fn create<'a>( + &'a self, + code: &'a str, + options: &'a CreateInviteCodeOptions, + ) -> StorageFuture<'a, InviteCode> { + Box::pin(async move { metadata::invite_create(&self.master, code, options).await }) + } + + fn find_by_code<'a>(&'a self, code: &'a str) -> StorageFuture<'a, Option> { + Box::pin(async move { metadata::invite_find_by_code(&self.master, code).await }) + } + + fn claim_code<'a>(&'a self, code: &'a str, pubkey: &'a Pubkey) -> StorageFuture<'a, bool> { + Box::pin(async move { metadata::invite_claim_code(&self.master, code, pubkey).await }) + } + + fn find_active_codes<'a>(&'a self, limit: u64) -> StorageFuture<'a, Vec> { + Box::pin(async move { metadata::invite_find_active_codes(&self.master, limit).await }) + } + + fn delete_expired_codes<'a>(&'a self) -> StorageFuture<'a, u64> { + Box::pin(async move { metadata::invite_delete_expired(&self.master).await }) + } +} + +impl DvmJobRepository for PostgresBackend { + fn create<'a>( + &'a self, + id: &'a EventId, + requester: &'a Pubkey, + kind: Kind, + ) -> StorageFuture<'a, DvmJob> { + Box::pin(async move { metadata::dvm_create(&self.master, id, requester, kind).await }) + } + + fn find_by_id<'a>(&'a self, id: &'a EventId) -> StorageFuture<'a, Option> { + Box::pin(async move { metadata::dvm_find_by_id(&self.master, id).await }) + } + + fn assign_worker<'a>(&'a self, id: &'a EventId, worker_index: i32) -> StorageFuture<'a, bool> { + Box::pin(async move { metadata::dvm_assign_worker(&self.master, id, worker_index).await }) + } + + fn update_status<'a>(&'a self, update: &'a DvmJobUpdate) -> StorageFuture<'a, Option> { + Box::pin(async move { metadata::dvm_update_status(&self.master, update).await }) + } + + fn find_pending_jobs<'a>( + &'a self, + limit: u64, + kinds: Option<&'a [Kind]>, + ) -> StorageFuture<'a, Vec> { + Box::pin(async move { metadata::dvm_find_pending_jobs(&self.master, limit, kinds).await }) + } +} + +impl ReportRepository for PostgresBackend { + fn create<'a>(&'a self, report: &'a NewReport) -> StorageFuture<'a, Report> { + Box::pin(async move { metadata::report_create(&self.master, report).await }) + } + + fn create_many<'a>(&'a self, reports: &'a [NewReport]) -> StorageFuture<'a, Vec> { + Box::pin(async move { + if reports.is_empty() { + return Ok(Vec::new()); + } + let mut transaction = self.master.begin().await?; + let mut created = Vec::with_capacity(reports.len()); + for report in reports { + created.push(metadata::report_create(&mut *transaction, report).await?); + } + transaction.commit().await?; + Ok(created) + }) + } + + fn find_by_event_id<'a>(&'a self, event_id: &'a EventId) -> StorageFuture<'a, Vec> { + Box::pin(async move { metadata::report_find_by_event_id(&self.master, event_id).await }) + } + + fn find_actionable<'a>(&'a self, limit: u64) -> StorageFuture<'a, Vec> { + Box::pin(async move { metadata::report_find_actionable(&self.master, limit).await }) + } + + fn find_actionable_targets<'a>(&'a self) -> StorageFuture<'a, Vec> { + Box::pin(async move { metadata::report_find_actionable_targets(&self.master).await }) + } +} + +impl NotificationDeliveryLogRepository for PostgresBackend { + fn append<'a>(&'a self, entry: &'a NewDeliveryLogEntry) -> StorageFuture<'a, ()> { + Box::pin(async move { notifications::append(&self.master, entry).await }) + } + + fn find_recent<'a>( + &'a self, + limit: u64, + filters: &'a DeliveryLogFilters, + ) -> StorageFuture<'a, Vec> { + Box::pin(async move { notifications::find_recent(&self.master, limit, filters).await }) + } + + fn find_successful_target_ids<'a>( + &'a self, + outbox_id: &'a str, + ) -> StorageFuture<'a, Vec> { + Box::pin( + async move { notifications::find_successful_target_ids(&self.master, outbox_id).await }, + ) + } + + fn delete_older_than<'a>( + &'a self, + cutoff: chrono::DateTime, + ) -> StorageFuture<'a, u64> { + Box::pin(async move { notifications::delete_older_than(&self.master, cutoff).await }) + } +} + +impl NotificationOutboxRepository for PostgresBackend { + fn enqueue<'a>( + &'a self, + event_type: &'a str, + payload: &'a OutboxPayload, + ) -> StorageFuture<'a, OutboxMessage> { + Box::pin(async move { notifications::enqueue(&self.master, event_type, payload).await }) + } + + fn claim_batch<'a>(&'a self, limit: u64) -> StorageFuture<'a, Vec> { + Box::pin(async move { notifications::claim_batch(&self.master, limit).await }) + } + + fn mark_delivered<'a>(&'a self, id: &'a str) -> StorageFuture<'a, ()> { + Box::pin(async move { notifications::mark_delivered(&self.master, id).await }) + } + + fn mark_failed<'a>( + &'a self, + id: &'a str, + error: &'a str, + attempt_count: i32, + max_attempts: i32, + base_delay_ms: i64, + ) -> StorageFuture<'a, ()> { + Box::pin(async move { + notifications::mark_failed( + &self.master, + id, + error, + attempt_count, + max_attempts, + base_delay_ms, + ) + .await + }) + } + + fn delete_terminal_older_than<'a>( + &'a self, + cutoff: chrono::DateTime, + ) -> StorageFuture<'a, u64> { + Box::pin( + async move { notifications::delete_terminal_older_than(&self.master, cutoff).await }, + ) + } +} + +/// The three repositories the payments service spans inside one serializable transaction. +macro_rules! with_transaction { + ($self:ident, |$transaction:ident| $body:expr) => {{ + let mut guard = $self.transaction.lock().await; + let Some($transaction) = guard.as_mut() else { + return Err(StorageError::InvalidArgument { + message: "the transaction is already closed", + }); + }; + $body + }}; +} + +impl UserRepository for PostgresTransaction { + fn find_by_pubkey<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, Option> { + Box::pin(async move { + with_transaction!(self, |transaction| { + users::find_by_pubkey(&mut **transaction, pubkey).await + }) + }) + } + + fn upsert<'a>(&'a self, user: &'a UserPatch) -> StorageFuture<'a, u64> { + Box::pin(async move { + with_transaction!(self, |transaction| { + users::upsert(&mut **transaction, user).await + }) + }) + } + + fn get_balance_by_pubkey<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, i64> { + Box::pin(async move { + with_transaction!(self, |transaction| { + users::get_balance_by_pubkey(&mut **transaction, pubkey).await + }) + }) + } + + fn is_vanished<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, bool> { + Box::pin(async move { + with_transaction!(self, |transaction| { + if let Some(user) = users::find_by_pubkey(&mut **transaction, pubkey).await? { + return Ok(user.is_vanished); + } + let vanished = + events::has_active_request_to_vanish(&mut **transaction, pubkey).await?; + users::upsert_vanish_state(&mut **transaction, pubkey, vanished).await?; + Ok(vanished) + }) + }) + } + + fn set_vanished<'a>(&'a self, pubkey: &'a Pubkey, vanished: bool) -> StorageFuture<'a, u64> { + Box::pin(async move { + with_transaction!(self, |transaction| { + users::upsert_vanish_state(&mut **transaction, pubkey, vanished).await + }) + }) + } + + fn admit_user<'a>( + &'a self, + pubkey: &'a Pubkey, + admitted_at: chrono::DateTime, + ) -> StorageFuture<'a, ()> { + Box::pin(async move { + with_transaction!(self, |transaction| { + users::admit_user(&mut **transaction, pubkey, admitted_at).await + }) + }) + } + + fn revoke_admission<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, u64> { + Box::pin(async move { + with_transaction!(self, |transaction| { + users::revoke_admission(&mut **transaction, pubkey).await + }) + }) + } +} + +impl InvoiceRepository for PostgresTransaction { + fn find_by_id<'a>(&'a self, id: &'a str) -> StorageFuture<'a, Option> { + Box::pin(async move { + with_transaction!(self, |transaction| { + invoices::find_by_id(&mut **transaction, id).await + }) + }) + } + + fn upsert<'a>(&'a self, invoice: &'a Invoice) -> StorageFuture<'a, u64> { + Box::pin(async move { + with_transaction!(self, |transaction| { + invoices::upsert(&mut **transaction, invoice).await + }) + }) + } + + fn update_status<'a>( + &'a self, + id: &'a str, + status: InvoiceStatus, + ) -> StorageFuture<'a, Option> { + Box::pin(async move { + with_transaction!(self, |transaction| { + invoices::update_status(&mut **transaction, id, status).await + }) + }) + } + + fn confirm_invoice<'a>( + &'a self, + invoice_id: &'a str, + amount_received: i64, + confirmed_at: chrono::DateTime, + ) -> StorageFuture<'a, ()> { + Box::pin(async move { + with_transaction!(self, |transaction| { + invoices::confirm_invoice( + &mut **transaction, + invoice_id, + amount_received, + confirmed_at, + ) + .await + }) + }) + } + + fn find_pending_invoices<'a>( + &'a self, + offset: u64, + limit: u64, + ) -> StorageFuture<'a, Vec> { + Box::pin(async move { + with_transaction!(self, |transaction| { + invoices::find_pending_invoices(&mut **transaction, offset, limit).await + }) + }) + } +} + +impl NotificationOutboxRepository for PostgresTransaction { + fn enqueue<'a>( + &'a self, + event_type: &'a str, + payload: &'a OutboxPayload, + ) -> StorageFuture<'a, OutboxMessage> { + Box::pin(async move { + with_transaction!(self, |transaction| { + notifications::enqueue(&mut **transaction, event_type, payload).await + }) + }) + } + + fn claim_batch<'a>(&'a self, limit: u64) -> StorageFuture<'a, Vec> { + Box::pin(async move { + with_transaction!(self, |transaction| { + notifications::claim_batch_in(&mut **transaction, limit).await + }) + }) + } + fn mark_delivered<'a>(&'a self, id: &'a str) -> StorageFuture<'a, ()> { + Box::pin(async move { + with_transaction!(self, |transaction| { + notifications::mark_delivered(&mut **transaction, id).await + }) + }) + } + + fn mark_failed<'a>( + &'a self, + id: &'a str, + error: &'a str, + attempt_count: i32, + max_attempts: i32, + base_delay_ms: i64, + ) -> StorageFuture<'a, ()> { + Box::pin(async move { + with_transaction!(self, |transaction| { + notifications::mark_failed( + &mut **transaction, + id, + error, + attempt_count, + max_attempts, + base_delay_ms, + ) + .await + }) + }) + } + + fn delete_terminal_older_than<'a>( + &'a self, + cutoff: chrono::DateTime, + ) -> StorageFuture<'a, u64> { + Box::pin(async move { + with_transaction!(self, |transaction| { + notifications::delete_terminal_older_than(&mut **transaction, cutoff).await + }) + }) + } +} diff --git a/crates/nostream-storage/src/postgres/rows.rs b/crates/nostream-storage/src/postgres/rows.rs new file mode 100644 index 00000000..402b31d4 --- /dev/null +++ b/crates/nostream-storage/src/postgres/rows.rs @@ -0,0 +1,313 @@ +//! Row-to-record conversion. Every decode is explicit: the bytea columns become the core +//! hex newtypes, the jsonb columns become tags and deduplication values, `inet` is read as +//! text, `timestamp` as UTC, and the integer columns are range-checked. + +use std::net::IpAddr; +use std::str::FromStr; + +use chrono::{DateTime, NaiveDateTime, Utc}; +use nostream_core::{Event, EventId, Pubkey, Signature, Tag}; +use sqlx::Row; +use sqlx::postgres::PgRow; + +use crate::error::StorageError; +use crate::records::{kind_from_i32, utc_from_naive}; +use crate::repositories::{ + DeliveryLogEntry, DvmJob, InviteCode, Invoice, Nip05Verification, OutboxMessage, + ReconciliationItem, Report, User, +}; + +/// One stored event row. +pub(crate) fn event_record(row: &PgRow) -> Result { + let event = Event { + id: event_id(row, "event_id")?, + pubkey: pubkey(row, "event_pubkey")?, + created_at: i64::from(event_time(row, "event_created_at")?), + kind: kind_from_i32(row.try_get::("event_kind")?)?, + tags: tags(row)?, + content: row.try_get::("event_content")?, + sig: signature(row, "event_signature")?, + }; + + Ok(crate::repositories::EventRecord { + row_id: Some(row.try_get::("id")?), + event, + first_seen: optional_timestamp(row, "first_seen")?, + deleted_at: optional_timestamp(row, "deleted_at")?, + expires_at: optional_int(row, "expires_at")?.map(i64::from), + event_deduplication: optional_json_array(row, "event_deduplication")?, + remote_address: optional_address(row, "remote_address")?, + }) +} + +/// One reconciliation row. +pub(crate) fn reconciliation_item(row: &PgRow) -> Result { + Ok(ReconciliationItem { + created_at: i64::from(event_time(row, "event_created_at")?), + event_id: event_id(row, "event_id")?, + }) +} + +/// One user row. +pub(crate) fn user(row: &PgRow) -> Result { + Ok(User { + pubkey: pubkey(row, "pubkey")?, + is_admitted: row.try_get::("is_admitted")?, + is_vanished: row.try_get::("is_vanished")?, + balance: row.try_get::("balance")?, + created_at: timestamptz(row, "created_at")?, + updated_at: timestamptz(row, "updated_at")?, + }) +} + +/// One invoice row. +pub(crate) fn invoice(row: &PgRow) -> Result { + Ok(Invoice { + id: row.try_get::("id")?, + pubkey: pubkey(row, "pubkey")?, + bolt11: row.try_get::("bolt11")?, + amount_requested: row.try_get::("amount_requested")?, + amount_paid: row.try_get::, _>("amount_paid")?, + unit: enum_column(row, "unit")?, + status: enum_column(row, "status")?, + description: row + .try_get::, _>("description")? + .unwrap_or_default(), + confirmed_at: optional_timestamp(row, "confirmed_at")?, + expires_at: optional_timestamp(row, "expires_at")?, + updated_at: timestamptz(row, "updated_at")?, + created_at: timestamptz(row, "created_at")?, + verify_url: row.try_get::, _>("verify_url")?, + }) +} + +/// One NIP-05 verification row. +pub(crate) fn nip05_verification(row: &PgRow) -> Result { + Ok(Nip05Verification { + pubkey: pubkey(row, "pubkey")?, + nip05: row.try_get::("nip05")?, + domain: row.try_get::("domain")?, + is_verified: row.try_get::("is_verified")?, + last_verified_at: optional_timestamptz(row, "last_verified_at")?, + last_checked_at: timestamptz(row, "last_checked_at")?, + failure_count: row.try_get::("failure_count")?, + created_at: timestamptz(row, "created_at")?, + updated_at: timestamptz(row, "updated_at")?, + }) +} + +/// One invite code. +pub(crate) fn invite_code(row: &PgRow) -> Result { + Ok(InviteCode { + code: row.try_get::("code")?, + created_by: optional_pubkey(row, "created_by")?, + claimed_by: optional_pubkey(row, "claimed_by")?, + expires_at: optional_timestamptz(row, "expires_at")?, + remaining_uses: row.try_get::, _>("remaining_uses")?, + created_at: timestamptz(row, "created_at")?, + updated_at: timestamptz(row, "updated_at")?, + }) +} + +/// One DVM job row. +pub(crate) fn dvm_job(row: &PgRow) -> Result { + Ok(DvmJob { + id: event_id(row, "id")?, + requester_pubkey: pubkey(row, "requester_pubkey")?, + kind: kind_from_i32(row.try_get::("kind")?)?, + worker_index: row.try_get::, _>("worker_index")?, + status: enum_column(row, "status")?, + result_event_id: optional_event_id(row, "result_event_id")?, + error: row.try_get::, _>("error")?, + picked_up_at: optional_timestamptz(row, "picked_up_at")?, + completed_at: optional_timestamptz(row, "completed_at")?, + created_at: timestamptz(row, "created_at")?, + updated_at: timestamptz(row, "updated_at")?, + }) +} + +/// One report row. +pub(crate) fn report(row: &PgRow) -> Result { + Ok(Report { + id: row.try_get::("id")?, + event_id: event_id(row, "event_id")?, + reporter_pubkey: pubkey(row, "reporter_pubkey")?, + reported_pubkey: optional_pubkey(row, "reported_pubkey")?, + reported_event_id: optional_event_id(row, "reported_event_id")?, + report_type: enum_column(row, "report_type")?, + weight: row.try_get::("weight")?, + actionable: row.try_get::("actionable")?, + created_at: timestamptz(row, "created_at")?, + }) +} + +/// One delivery-log row. +pub(crate) fn delivery_log_entry(row: &PgRow) -> Result { + Ok(DeliveryLogEntry { + id: row.try_get::("id")?, + outbox_id: row.try_get::, _>("outbox_id")?, + event_type: row.try_get::("event_type")?, + target_id: row.try_get::("target_id")?, + target_type: enum_column(row, "target_type")?, + status: enum_column(row, "status")?, + attempt_number: row.try_get::("attempt_number")?, + error_snippet: row.try_get::, _>("error_snippet")?, + created_at: timestamptz(row, "created_at")?, + }) +} + +/// One outbox message. +pub(crate) fn outbox_message(row: &PgRow) -> Result { + Ok(OutboxMessage { + id: row.try_get::("id")?, + event_type: row.try_get::("event_type")?, + payload: row.try_get::("payload")?, + status: enum_column(row, "status")?, + attempt_count: row.try_get::("attempt_count")?, + available_at: timestamptz(row, "available_at")?, + last_error: row.try_get::, _>("last_error")?, + delivered_at: optional_timestamptz(row, "delivered_at")?, + created_at: timestamptz(row, "created_at")?, + updated_at: timestamptz(row, "updated_at")?, + }) +} + +fn event_time(row: &PgRow, column: &'static str) -> Result { + let value = row.try_get::(column)?; + if value < 0 { + return Err(StorageError::ValueOutOfRange { + column, + value: value.to_string(), + }); + } + Ok(value) +} + +fn optional_int(row: &PgRow, column: &'static str) -> Result, StorageError> { + Ok(row.try_get::, _>(column)?) +} + +fn event_id(row: &PgRow, column: &'static str) -> Result { + Ok(EventId::from_byte_array(fixed::<32>(row, column)?)) +} + +fn pubkey(row: &PgRow, column: &'static str) -> Result { + Ok(Pubkey::from_byte_array(fixed::<32>(row, column)?)) +} + +fn optional_pubkey(row: &PgRow, column: &'static str) -> Result, StorageError> { + Ok(optional_fixed::<32>(row, column)?.map(Pubkey::from_byte_array)) +} + +fn optional_event_id(row: &PgRow, column: &'static str) -> Result, StorageError> { + Ok(optional_fixed::<32>(row, column)?.map(EventId::from_byte_array)) +} + +fn signature(row: &PgRow, column: &'static str) -> Result { + Ok(Signature::from_byte_array(fixed::<64>(row, column)?)) +} + +fn fixed(row: &PgRow, column: &'static str) -> Result<[u8; N], StorageError> { + let bytes: Vec = row.try_get(column)?; + bytes + .try_into() + .map_err(|bytes: Vec| StorageError::ValueOutOfRange { + column, + value: format!("{} bytes", bytes.len()), + }) +} + +fn optional_fixed( + row: &PgRow, + column: &'static str, +) -> Result, StorageError> { + let bytes: Option> = row.try_get(column)?; + match bytes { + None => Ok(None), + Some(bytes) => { + bytes + .try_into() + .map(Some) + .map_err(|bytes: Vec| StorageError::ValueOutOfRange { + column, + value: format!("{} bytes", bytes.len()), + }) + } + } +} + +fn tags(row: &PgRow) -> Result, StorageError> { + let value: Option = row.try_get("event_tags")?; + match value { + None => Ok(Vec::new()), + Some(value) => { + let display = value.to_string(); + serde_json::from_value(value).map_err(|_| StorageError::InvalidRow { + table: "events", + column: "event_tags", + value: display, + }) + } + } +} + +fn optional_json_array( + row: &PgRow, + column: &'static str, +) -> Result>, StorageError> { + let value: Option = row.try_get(column)?; + match value { + None => Ok(None), + Some(value) => { + let display = value.to_string(); + serde_json::from_value(value) + .map(Some) + .map_err(|_| StorageError::InvalidRow { + table: "events", + column, + value: display, + }) + } + } +} + +fn optional_address(row: &PgRow, column: &'static str) -> Result, StorageError> { + let value: Option = row.try_get(column)?; + match value { + None => Ok(None), + Some(value) => IpAddr::from_str(&value) + .map(Some) + .map_err(|_| StorageError::InvalidRow { + table: "events", + column, + value, + }), + } +} + +fn optional_timestamp( + row: &PgRow, + column: &'static str, +) -> Result>, StorageError> { + let value: Option = row.try_get(column)?; + Ok(value.map(utc_from_naive)) +} + +fn timestamptz(row: &PgRow, column: &'static str) -> Result, StorageError> { + Ok(row.try_get::, _>(column)?) +} + +fn optional_timestamptz( + row: &PgRow, + column: &'static str, +) -> Result>, StorageError> { + Ok(row.try_get::>, _>(column)?) +} + +fn enum_column(row: &PgRow, column: &'static str) -> Result +where + T: FromStr, +{ + let value: String = row.try_get(column)?; + value.parse() +} diff --git a/crates/nostream-storage/src/postgres/sql.rs b/crates/nostream-storage/src/postgres/sql.rs new file mode 100644 index 00000000..250d18b6 --- /dev/null +++ b/crates/nostream-storage/src/postgres/sql.rs @@ -0,0 +1,724 @@ +//! Event-query composition. Reproduces the SQL semantics of +//! `src/repositories/event-repository.ts` (measured against its unit-test expectations): +//! hex prefix groups, kind/time criteria, NIP-50 search, NIP-56 exclusion, one `EXISTS` per +//! tag name, `UNION` across filters, and the capture's per-branch `ORDER BY`/`LIMIT`. + +use nostream_core::Filter; +use sqlx::postgres::PgArguments; +use sqlx::{AssertSqlSafe, Postgres}; + +use super::{DEFAULT_FILTER_LIMIT, DEFAULT_TS_CONFIG}; +use crate::error::StorageError; + +/// The columns one stored event row is read from. +const EVENT_COLUMNS: &str = "events.id::text AS id, events.event_id, events.event_pubkey, \ +events.event_kind, events.event_created_at, events.event_content, events.event_tags, \ +events.event_signature, events.first_seen, events.deleted_at, events.event_deduplication, \ +events.remote_address::text AS remote_address, events.expires_at"; + +/// One bind value. Values are owned by the query, so the stream that drains it borrows only +/// the pool. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Arg { + Bytes(Vec), + BytesList(Vec>), + Text(String), + TextList(Vec), + Int(i32), + BigInt(i64), + IntList(Vec), + Bool(bool), + Timestamp(chrono::DateTime), + NaiveTimestamp(chrono::NaiveDateTime), + Real(f32), + NullText, + NullInt, + NullNaiveTimestamp, + NullTimestamp, + NullBytes, +} + +impl Arg { + fn bind( + self, + query: sqlx::query::Query<'static, Postgres, PgArguments>, + ) -> sqlx::query::Query<'static, Postgres, PgArguments> { + match self { + Self::Bytes(value) => query.bind(value), + Self::BytesList(values) => query.bind(values), + Self::Text(value) => query.bind(value), + Self::TextList(values) => query.bind(values), + Self::Int(value) => query.bind(value), + Self::BigInt(value) => query.bind(value), + Self::IntList(values) => query.bind(values), + Self::Bool(value) => query.bind(value), + Self::Timestamp(value) => query.bind(value), + Self::NaiveTimestamp(value) => query.bind(value), + Self::Real(value) => query.bind(value), + Self::NullText => query.bind(None::), + Self::NullInt => query.bind(None::), + Self::NullNaiveTimestamp => query.bind(None::), + Self::NullTimestamp => query.bind(None::>), + Self::NullBytes => query.bind(None::>), + } + } +} + +/// The placeholder list one statement's binds are written as `$1..$n`. +#[derive(Debug, Default, Clone, PartialEq)] +pub(crate) struct Args { + items: Vec, +} + +impl Args { + pub(crate) fn push(&mut self, arg: Arg) -> String { + self.items.push(arg); + format!("${}", self.items.len()) + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.items.len() + } +} + +/// One composed event statement: SQL text plus its bind values, in placeholder order. +#[derive(Debug)] +pub(crate) struct EventQuery { + pub(crate) sql: String, + pub(crate) args: Args, +} + +impl EventQuery { + /// Materialises the statement with its binds, ready to fetch. + pub(crate) fn into_query(self) -> sqlx::query::Query<'static, Postgres, PgArguments> { + let mut query = sqlx::query(AssertSqlSafe(self.sql)); + for arg in self.args.items { + query = arg.bind(query); + } + query + } +} + +/// The settings gates the capture reads per query. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct Gates { + pub(crate) nip50: Option, + pub(crate) nip56: bool, +} + +/// The resolved NIP-50 configuration (`nip50.{language,maxQueryLength}`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Nip50Gate { + pub(crate) language: String, + pub(crate) max_query_length: u64, +} + +/// Composes `findByFilters`: one branch per filter, `UNION`ed, each keeping its own +/// `ORDER BY`/`LIMIT`, with the first filter's order applied to the union. +pub(crate) fn find_by_filters( + filters: &[Filter], + gates: &Gates, +) -> Result { + if filters.is_empty() { + return Err(StorageError::InvalidArgument { + message: "Filters cannot be empty", + }); + } + + let mut builder = Builder::default(); + if filters.len() == 1 { + push_event_branch(&mut builder, &filters[0], gates); + } else { + for (index, filter) in filters.iter().enumerate() { + if index > 0 { + builder.push(" UNION "); + } + builder.push("("); + push_event_branch(&mut builder, filter, gates); + builder.push(")"); + } + push_order(&mut builder, &filters[0], is_search(&filters[0], gates)); + } + + Ok(builder.finish()) +} + +/// Composes `countByFilters`: the union of the filters' live ids, counted. +pub(crate) fn count_by_filters( + filters: &[Filter], + gates: &Gates, + now: i64, +) -> Result { + if filters.is_empty() { + return Err(StorageError::InvalidArgument { + message: "Filters cannot be empty", + }); + } + + let mut builder = Builder::default(); + builder.push("SELECT count(*) AS count FROM ("); + if filters.len() == 1 { + push_id_branch(&mut builder, &filters[0], gates, now); + } else { + for (index, filter) in filters.iter().enumerate() { + if index > 0 { + builder.push(" UNION "); + } + builder.push("("); + push_id_branch(&mut builder, filter, gates, now); + builder.push(")"); + } + } + builder.push(") AS matching_events"); + + Ok(builder.finish()) +} + +/// Composes `findReconciliationItems`: the filter's live `(created_at, event_id)` pairs, +/// oldest first, with the filter's limit ignored. +pub(crate) fn find_reconciliation_items( + filter: &Filter, + gates: &Gates, + now: i64, +) -> Result { + let mut builder = Builder::default(); + builder.push("SELECT events.event_created_at, events.event_id FROM events WHERE 1 = 1"); + push_conditions(&mut builder, filter, gates); + push_live_only(&mut builder, now); + builder.push(" ORDER BY event_created_at ASC, event_id ASC"); + + Ok(builder.finish()) +} + +#[derive(Debug, Default)] +struct Builder { + sql: String, + args: Args, +} + +impl Builder { + fn push(&mut self, text: &str) { + self.sql.push_str(text); + } + + fn arg(&mut self, arg: Arg) -> String { + self.args.push(arg) + } + + fn finish(self) -> EventQuery { + EventQuery { + sql: self.sql, + args: self.args, + } + } +} + +fn push_event_branch(builder: &mut Builder, filter: &Filter, gates: &Gates) { + builder.push("SELECT "); + builder.push(EVENT_COLUMNS); + + let search = is_search(filter, gates); + if search { + let (language, query) = search_binding(filter, gates); + let language_slot = builder.arg(Arg::Text(language.clone())); + let query_language_slot = builder.arg(Arg::Text(language)); + let query_slot = builder.arg(Arg::Text(query)); + builder.push(&format!( + ", ts_rank(to_tsvector({language_slot}::regconfig, events.event_content), \ +plainto_tsquery({query_language_slot}::regconfig, {query_slot})) AS search_rank" + )); + } + + builder.push(" FROM events WHERE 1 = 1"); + push_conditions(builder, filter, gates); + + let limit = filter.limit.unwrap_or(DEFAULT_FILTER_LIMIT); + push_order(builder, filter, search); + let limit_slot = builder.arg(Arg::BigInt(limit as i64)); + builder.push(&format!(" LIMIT {limit_slot}")); +} + +fn push_id_branch(builder: &mut Builder, filter: &Filter, gates: &Gates, now: i64) { + builder.push("SELECT events.event_id FROM events WHERE 1 = 1"); + push_conditions(builder, filter, gates); + push_live_only(builder, now); + if filter.limit.is_some() { + push_order(builder, filter, false); + let limit_slot = builder.arg(Arg::BigInt(filter.limit.unwrap_or_default() as i64)); + builder.push(&format!(" LIMIT {limit_slot}")); + } +} + +fn push_live_only(builder: &mut Builder, now: i64) { + let now_slot = builder.arg(Arg::BigInt(now)); + builder.push(&format!( + " AND (events.deleted_at IS NULL AND (events.expires_at IS NULL OR events.expires_at > {now_slot}))" + )); +} + +fn push_order(builder: &mut Builder, filter: &Filter, search: bool) { + if search { + builder.push(" ORDER BY search_rank DESC, event_id ASC"); + } else if filter.limit.is_some() { + builder.push(" ORDER BY event_created_at DESC, event_id ASC"); + } else { + builder.push(" ORDER BY event_created_at ASC, event_id ASC"); + } +} + +fn push_conditions(builder: &mut Builder, filter: &Filter, gates: &Gates) { + if let Some(authors) = &filter.authors { + push_hex_criteria(builder, "events.event_pubkey", authors); + } + if let Some(ids) = &filter.ids { + push_hex_criteria(builder, "events.event_id", ids); + } + + if gates.nip56 { + builder.push( + " AND NOT EXISTS (SELECT 1 FROM reports WHERE reports.actionable = true AND \ +(reports.reported_event_id = events.event_id OR reports.reported_pubkey = events.event_pubkey))", + ); + } + + if let Some(kinds) = &filter.kinds { + let kinds: Vec = kinds + .iter() + .map(|kind| i32::try_from(kind.0).unwrap_or(i32::MAX)) + .collect(); + let slot = builder.arg(Arg::IntList(kinds)); + builder.push(&format!(" AND events.event_kind = ANY({slot})")); + } + + if let Some(since) = filter.since { + let slot = builder.arg(Arg::BigInt(since)); + builder.push(&format!(" AND events.event_created_at >= {slot}")); + } + + if let Some(until) = filter.until { + let slot = builder.arg(Arg::BigInt(until)); + builder.push(&format!(" AND events.event_created_at <= {slot}")); + } + + if is_search(filter, gates) { + let (language, query) = search_binding(filter, gates); + let language_slot = builder.arg(Arg::Text(language.clone())); + let query_language_slot = builder.arg(Arg::Text(language)); + let query_slot = builder.arg(Arg::Text(query)); + builder.push(&format!( + " AND (to_tsvector({language_slot}::regconfig, events.event_content) @@ \ +plainto_tsquery({query_language_slot}::regconfig, {query_slot}))" + )); + } + + for (key, criteria) in &filter.tags { + let Some(name) = generic_tag_name(key) else { + continue; + }; + + if criteria.is_empty() { + builder.push(" AND 1 = 0"); + continue; + } + + let mut values = Vec::new(); + let mut prefixes = Vec::new(); + for criterion in criteria { + match strip_geohash_prefix(key, criterion) { + Some(prefix) => prefixes.push(prefix.to_owned()), + None => values.push(criterion.clone()), + } + } + + let name_slot = builder.arg(Arg::Text(name.to_owned())); + builder.push(&format!( + " AND EXISTS (SELECT 1 FROM event_tags WHERE event_tags.event_id = events.event_id \ +AND event_tags.tag_name = {name_slot} AND (" + )); + + let mut parts = Vec::new(); + if !values.is_empty() { + let values_slot = builder.arg(Arg::TextList(values)); + parts.push(format!("event_tags.tag_value = ANY({values_slot})")); + } + for prefix in prefixes { + let prefix_slot = builder.arg(Arg::Text(format!("{prefix}%"))); + parts.push(format!("event_tags.tag_value LIKE {prefix_slot}")); + } + builder.push(&parts.join(" OR ")); + builder.push("))"); + } +} + +/// One hex criterion family: 64 characters compare exactly, an even-length prefix compares +/// its bytes, an odd-length one compares a byte range. Groups are OR-ed; an empty list +/// matches nothing. +fn push_hex_criteria(builder: &mut Builder, column: &str, criteria: &[String]) { + if criteria.is_empty() { + builder.push(" AND 1 = 0"); + return; + } + + let mut exact = Vec::new(); + let mut even = Vec::new(); + let mut odd = Vec::new(); + for criterion in criteria { + match criterion.len() { + 64 => exact.push(criterion.clone()), + length if length % 2 == 0 => even.push(criterion.clone()), + _ => odd.push(criterion.clone()), + } + } + + let mut parts = Vec::new(); + if !exact.is_empty() { + let bytes: Vec> = exact.iter().map(|value| hex_bytes(value)).collect(); + let slot = builder.arg(Arg::BytesList(bytes)); + parts.push(format!("{column} = ANY({slot})")); + } + for prefix in even { + let length_slot = builder.arg(Arg::Int((prefix.len() >> 1) as i32)); + let bytes = builder.arg(Arg::Bytes(hex_bytes(&prefix))); + parts.push(format!( + "substring({column} from 1 for {length_slot}) = {bytes}" + )); + } + for prefix in odd { + let length_slot = builder.arg(Arg::Int(((prefix.len() >> 1) + 1) as i32)); + let low = builder.arg(Arg::Bytes(hex_bytes(&format!("{prefix}0")))); + let high = builder.arg(Arg::Bytes(hex_bytes(&format!("{prefix}f")))); + parts.push(format!( + "substring({column} from 1 for {length_slot}) BETWEEN {low} AND {high}" + )); + } + + builder.push(&format!(" AND ({})", parts.join(" OR "))); +} + +/// The capture's `isGenericTagQuery`: `#` plus one ASCII letter. +fn generic_tag_name(key: &str) -> Option<&str> { + let mut characters = key.chars(); + if characters.next() != Some('#') { + return None; + } + let name = characters.next()?; + if characters.next().is_some() || !name.is_ascii_alphabetic() { + return None; + } + Some(&key[1..2]) +} + +/// `isGeohashPrefixCriterion`: only `#g` criteria may end in `*`, and only those are +/// prefixes. Returns the prefix without the wildcard. +fn strip_geohash_prefix<'a>(key: &str, criterion: &'a str) -> Option<&'a str> { + if key == "#g" && criterion.ends_with('*') { + criterion.strip_suffix('*') + } else { + None + } +} + +fn is_search(filter: &Filter, gates: &Gates) -> bool { + gates.nip50.is_some() + && filter + .search + .as_deref() + .is_some_and(|search| !search.trim().is_empty()) +} + +fn search_binding(filter: &Filter, gates: &Gates) -> (String, String) { + let Some(gate) = &gates.nip50 else { + return (DEFAULT_TS_CONFIG.to_owned(), String::new()); + }; + let query = filter.search.as_deref().unwrap_or_default().trim(); + let query = query + .chars() + .take(gate.max_query_length as usize) + .collect::(); + (gate.language.clone(), query) +} + +/// Hex helpers, kept local so the query composer needs no hex crate dependency. +pub(crate) fn hex_bytes(value: &str) -> Vec { + let mut bytes = Vec::with_capacity(value.len() / 2); + let digits = value.as_bytes(); + let mut index = 0; + while index + 1 < digits.len() { + let high = hex_digit(digits[index]); + let low = hex_digit(digits[index + 1]); + bytes.push((high << 4) | low); + index += 2; + } + bytes +} + +fn hex_digit(digit: u8) -> u8 { + match digit { + b'0'..=b'9' => digit - b'0', + b'a'..=b'f' => digit - b'a' + 10, + b'A'..=b'F' => digit - b'A' + 10, + _ => 0, + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + use nostream_core::Kind; + + fn filter() -> Filter { + Filter::default() + } + + fn gates(nip50: bool, nip56: bool) -> Gates { + Gates { + nip50: nip50.then(|| Nip50Gate { + language: "simple".to_owned(), + max_query_length: 256, + }), + nip56, + } + } + + fn sql(query: &EventQuery) -> &str { + &query.sql + } + + #[test] + fn no_filters_is_the_capture_error() { + let error = find_by_filters(&[], &gates(false, false)).expect_err("empty filters"); + assert!(matches!( + error, + StorageError::InvalidArgument { + message: "Filters cannot be empty" + } + )); + let error = count_by_filters(&[], &gates(false, false), 0).expect_err("empty filters"); + assert!(matches!(error, StorageError::InvalidArgument { .. })); + } + + #[test] + fn one_filter_defaults_to_500_rows_oldest_first() { + let query = find_by_filters(&[filter()], &gates(false, false)).unwrap(); + assert!(sql(&query).contains("ORDER BY event_created_at ASC, event_id ASC LIMIT $1")); + assert_eq!(query.args.len(), 1); + } + + #[test] + fn a_limited_filter_orders_newest_first() { + let mut filter = filter(); + filter.limit = Some(10); + let query = find_by_filters(&[filter], &gates(false, false)).unwrap(); + assert!(sql(&query).contains("ORDER BY event_created_at DESC, event_id ASC LIMIT $1")); + assert_eq!( + query.args, + Args { + items: vec![Arg::BigInt(10)] + } + ); + } + + #[test] + fn exact_even_and_odd_author_prefixes_become_one_or_group() { + let mut filter = filter(); + filter.authors = Some(vec!["a".repeat(64), "abcd".to_owned(), "abcde".to_owned()]); + let query = find_by_filters(&[filter], &gates(false, false)).unwrap(); + let text = sql(&query); + assert!(text.contains("events.event_pubkey = ANY($1)")); + assert!(text.contains("substring(events.event_pubkey from 1 for $2) = $3")); + assert!(text.contains("substring(events.event_pubkey from 1 for $4) BETWEEN $5 AND $6")); + assert_eq!(query.args.len(), 7); + } + + #[test] + fn an_empty_id_list_matches_nothing() { + let mut filter = filter(); + filter.ids = Some(Vec::new()); + let query = find_by_filters(&[filter], &gates(false, false)).unwrap(); + assert!(sql(&query).contains("AND 1 = 0")); + } + + #[test] + fn kinds_and_times_are_inclusive() { + let mut filter = filter(); + filter.kinds = Some(vec![Kind::TEXT_NOTE, Kind(30_023)]); + filter.since = Some(100); + filter.until = Some(200); + let query = find_by_filters(&[filter], &gates(false, false)).unwrap(); + let text = sql(&query); + assert!(text.contains("events.event_kind = ANY($1)")); + assert!(text.contains("events.event_created_at >= $2")); + assert!(text.contains("events.event_created_at <= $3")); + assert_eq!( + query.args, + Args { + items: vec![ + Arg::IntList(vec![1, 30_023]), + Arg::BigInt(100), + Arg::BigInt(200), + Arg::BigInt(500) + ] + } + ); + } + + #[test] + fn search_is_gated_and_changes_the_order() { + let mut filter = filter(); + filter.search = Some(" bitcoin lightning ".to_owned()); + let without = find_by_filters(&[filter.clone()], &gates(false, false)).unwrap(); + assert!(!sql(&without).contains("ts_rank")); + + let with = find_by_filters(&[filter], &gates(true, false)).unwrap(); + let text = sql(&with); + assert!(text.contains("ts_rank(to_tsvector($1::regconfig, events.event_content)")); + assert!(text.contains("to_tsvector($4::regconfig, events.event_content) @@")); + assert!(text.contains("ORDER BY search_rank DESC, event_id ASC")); + assert_eq!( + with.args, + Args { + items: vec![ + Arg::Text("simple".to_owned()), + Arg::Text("simple".to_owned()), + Arg::Text("bitcoin lightning".to_owned()), + Arg::Text("simple".to_owned()), + Arg::Text("simple".to_owned()), + Arg::Text("bitcoin lightning".to_owned()), + Arg::BigInt(500), + ] + } + ); + } + + #[test] + fn a_search_query_is_truncated_to_the_configured_length() { + let mut filter = filter(); + filter.search = Some("a".repeat(300)); + let gate = Gates { + nip50: Some(Nip50Gate { + language: "english".to_owned(), + max_query_length: 10, + }), + nip56: false, + }; + let query = find_by_filters(&[filter], &gate).unwrap(); + assert!( + query + .args + .items + .iter() + .any(|arg| { arg == &Arg::Text("a".repeat(10)) }) + ); + assert!( + query + .args + .items + .iter() + .any(|arg| arg == &Arg::Text("english".to_owned())) + ); + } + + #[test] + fn tag_criteria_become_one_exists_per_tag_name() { + let mut filter = filter(); + filter + .tags + .insert("#e".to_owned(), vec!["aa".to_owned(), "bb".to_owned()]); + filter.tags.insert("#p".to_owned(), vec!["cc".to_owned()]); + let query = find_by_filters(&[filter], &gates(false, false)).unwrap(); + assert_eq!( + sql(&query) + .matches("EXISTS (SELECT 1 FROM event_tags") + .count(), + 2 + ); + assert!(sql(&query).contains("event_tags.tag_name = $1")); + assert!(sql(&query).contains("event_tags.tag_value = ANY($2)")); + assert!(sql(&query).contains("event_tags.tag_name = $3")); + assert!(sql(&query).contains("event_tags.tag_value = ANY($4)")); + } + + #[test] + fn a_geohash_wildcard_becomes_a_prefix_match() { + let mut filter = filter(); + filter.tags.insert( + "#g".to_owned(), + vec!["u4pru*".to_owned(), "u4pruydqqvj".to_owned()], + ); + let query = find_by_filters(&[filter], &gates(false, false)).unwrap(); + let text = sql(&query); + assert!(text.contains("event_tags.tag_name = $1")); + assert!(text.contains("event_tags.tag_value = ANY($2)")); + assert!(text.contains("event_tags.tag_value LIKE $3")); + assert_eq!(query.args.items[2], Arg::Text("u4pru%".to_owned())); + } + + #[test] + fn a_non_hex_tag_key_is_not_a_tag_query() { + let mut filter = filter(); + filter.tags.insert("#em".to_owned(), vec!["x".to_owned()]); + let query = find_by_filters(&[filter], &gates(false, false)).unwrap(); + assert!(!sql(&query).contains("FROM event_tags")); + } + + #[test] + fn nip56_excludes_actionable_reports() { + let query = find_by_filters(&[filter()], &gates(false, true)).unwrap(); + assert!(sql(&query).contains("NOT EXISTS (SELECT 1 FROM reports")); + let without = find_by_filters(&[filter()], &gates(false, false)).unwrap(); + assert!(!sql(&without).contains("reports")); + } + + #[test] + fn several_filters_are_union_ed_with_the_first_order_on_the_outside() { + let mut first = filter(); + first.limit = Some(2); + let mut second = filter(); + second.kinds = Some(vec![Kind::TEXT_NOTE]); + let query = find_by_filters(&[first, second], &gates(false, false)).unwrap(); + let text = sql(&query); + assert_eq!(text.matches(" UNION ").count(), 1); + assert!(text.starts_with("(SELECT ")); + assert!(text.ends_with(") ORDER BY event_created_at DESC, event_id ASC")); + } + + #[test] + fn counting_wraps_the_union_in_a_count() { + let mut first = filter(); + first.limit = Some(3); + let mut second = filter(); + second.since = Some(5); + let query = count_by_filters(&[first, second], &gates(false, false), 42).unwrap(); + let text = sql(&query); + assert!(text.starts_with("SELECT count(*) AS count FROM (")); + assert!(text.ends_with(") AS matching_events")); + assert!(text.contains("events.deleted_at IS NULL")); + assert!(text.contains("events.expires_at > $1")); + assert_eq!( + text.matches("ORDER BY event_created_at DESC, event_id ASC LIMIT") + .count(), + 1 + ); + assert!(text.contains("events.event_created_at >= $3")); + } + + #[test] + fn reconciliation_ignores_the_limit_and_excludes_dead_rows() { + let mut filter = filter(); + filter.limit = Some(7); + let query = find_reconciliation_items(&filter, &gates(false, false), 42).unwrap(); + let text = sql(&query); + assert!(!text.contains("LIMIT")); + assert!(text.contains("events.deleted_at IS NULL")); + assert!(text.contains("ORDER BY event_created_at ASC, event_id ASC")); + } + + #[test] + fn hex_helper_decodes_the_wire_form() { + assert_eq!(hex_bytes("00ff10"), vec![0x00, 0xff, 0x10]); + assert_eq!(hex_bytes("ab"), vec![0xab]); + } +} diff --git a/crates/nostream-storage/src/postgres/users.rs b/crates/nostream-storage/src/postgres/users.rs new file mode 100644 index 00000000..c1269d35 --- /dev/null +++ b/crates/nostream-storage/src/postgres/users.rs @@ -0,0 +1,173 @@ +//! `UserRepository` against Postgres. Mirrors `src/repositories/user-repository.ts`. + +use chrono::{DateTime, Utc}; +use nostream_core::Pubkey; +use sqlx::postgres::Postgres; +use sqlx::{Executor, Row}; + +use super::{Arg, Args, EventQuery}; +use crate::error::StorageError; +use crate::repositories::{FieldUpdate, User, UserPatch}; + +/// `findByPubkey`: the full row as the capture's `fromDBUser` maps it. +pub(crate) async fn find_by_pubkey<'e, E>( + executor: E, + pubkey: &Pubkey, +) -> Result, StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let pubkey_slot = args.push(Arg::Bytes(pubkey.as_bytes().to_vec())); + let sql = format!( + "SELECT pubkey, COALESCE(is_admitted, false) AS is_admitted, \ + COALESCE(is_vanished, false) AS is_vanished, COALESCE(balance, 0) AS balance, \ + created_at, updated_at FROM users WHERE pubkey = {pubkey_slot}" + ); + let row = EventQuery { sql, args } + .into_query() + .fetch_optional(executor) + .await?; + + row.as_ref().map(super::rows::user).transpose() +} + +/// `upsert`: inserts the row or updates admission, the vanished flag and `updated_at`, and +/// `tos_accepted_at` only when the patch carries it. `balance` and `created_at` are never +/// touched. +pub(crate) async fn upsert<'e, E>(executor: E, patch: &UserPatch) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let mut args = Args::default(); + let mut columns = vec![ + "pubkey", + "is_admitted", + "is_vanished", + "updated_at", + "created_at", + ]; + let mut values = vec![ + args.push(Arg::Bytes(patch.pubkey.as_bytes().to_vec())), + args.push(Arg::Bool(patch.is_admitted)), + args.push(Arg::Bool(patch.is_vanished)), + args.push(Arg::Timestamp(now)), + args.push(Arg::Timestamp(now)), + ]; + let mut merge = String::from( + "is_admitted = excluded.is_admitted, is_vanished = excluded.is_vanished, \ + updated_at = excluded.updated_at", + ); + if let FieldUpdate::Set(tos_accepted_at) = patch.tos_accepted_at { + columns.push("tos_accepted_at"); + values.push(args.push(Arg::NaiveTimestamp(tos_accepted_at.naive_utc()))); + merge.push_str(", tos_accepted_at = excluded.tos_accepted_at"); + } + + let sql = format!( + "INSERT INTO users ({}) VALUES ({}) ON CONFLICT (pubkey) DO UPDATE SET {merge}", + columns.join(", "), + values.join(", ") + ); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected()) +} + +/// `getBalanceByPubkey`: the stored balance, `0` when no row exists. +pub(crate) async fn get_balance_by_pubkey<'e, E>( + executor: E, + pubkey: &Pubkey, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let pubkey_slot = args.push(Arg::Bytes(pubkey.as_bytes().to_vec())); + let sql = format!("SELECT balance FROM users WHERE pubkey = {pubkey_slot} LIMIT 1"); + let row = EventQuery { sql, args } + .into_query() + .fetch_optional(executor) + .await?; + + match row { + None => Ok(0), + Some(row) => Ok(row.try_get::("balance")?), + } +} + +/// `upsertVanishState`: inserts a minimal row or updates only the vanished flag. +pub(crate) async fn upsert_vanish_state<'e, E>( + executor: E, + pubkey: &Pubkey, + vanished: bool, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let now = Utc::now(); + let mut args = Args::default(); + let pubkey_slot = args.push(Arg::Bytes(pubkey.as_bytes().to_vec())); + let vanished_slot = args.push(Arg::Bool(vanished)); + let now_slot = args.push(Arg::Timestamp(now)); + let created_slot = args.push(Arg::Timestamp(now)); + let sql = format!( + "INSERT INTO users (pubkey, is_admitted, balance, is_vanished, created_at, updated_at) \ + VALUES ({pubkey_slot}, false, 0, {vanished_slot}, {now_slot}, {created_slot}) \ + ON CONFLICT (pubkey) DO UPDATE SET is_vanished = excluded.is_vanished, \ + updated_at = excluded.updated_at" + ); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected()) +} + +/// `admitUser`: the schema function, which asserts a serializable transaction. +pub(crate) async fn admit_user<'e, E>( + executor: E, + pubkey: &Pubkey, + admitted_at: DateTime, +) -> Result<(), StorageError> +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let pubkey_slot = args.push(Arg::Bytes(pubkey.as_bytes().to_vec())); + let admitted_slot = args.push(Arg::NaiveTimestamp(admitted_at.naive_utc())); + let sql = format!("SELECT admit_user({pubkey_slot}, {admitted_slot})"); + + EventQuery { sql, args } + .into_query() + .execute(executor) + .await?; + Ok(()) +} + +/// `revokeAdmission`: a targeted update so unrelated columns are never clobbered. +pub(crate) async fn revoke_admission<'e, E>( + executor: E, + pubkey: &Pubkey, +) -> Result +where + E: Executor<'e, Database = Postgres>, +{ + let mut args = Args::default(); + let pubkey_slot = args.push(Arg::Bytes(pubkey.as_bytes().to_vec())); + let now_slot = args.push(Arg::Timestamp(Utc::now())); + let sql = format!( + "UPDATE users SET is_admitted = false, updated_at = {now_slot} WHERE pubkey = {pubkey_slot}" + ); + + Ok(EventQuery { sql, args } + .into_query() + .execute(executor) + .await? + .rows_affected()) +} diff --git a/crates/nostream-storage/src/records.rs b/crates/nostream-storage/src/records.rs new file mode 100644 index 00000000..c915dc6b --- /dev/null +++ b/crates/nostream-storage/src/records.rs @@ -0,0 +1,381 @@ +//! Implementations for the row types declared in [`crate::repositories`]: constructors, +//! the enum spellings their columns hold, and the conversions the Postgres backend uses. +//! `repositories.rs` declares, this module implements. + +use std::fmt; +use std::net::IpAddr; +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use nostream_core::{Event, Kind}; + +use crate::error::StorageError; +use crate::repositories::{ + DvmJobStatus, EventRecord, FieldUpdate, InvoiceStatus, InvoiceUnit, NotificationDeliveryStatus, + NotificationTargetType, OutboxStatus, ReportType, +}; + +// `#[derive(Default)]` would require `T: Default`, which a `FieldUpdate>` is +// not; the absent variant is the default for every `T`. +#[allow(clippy::derivable_impls)] +impl Default for FieldUpdate { + fn default() -> Self { + Self::Absent + } +} + +impl EventRecord { + /// Wraps a wire event as a row to write: the four storage-only fields are empty and the + /// backend applies its column defaults. + pub fn from_event(event: &Event) -> Self { + Self { + row_id: None, + event: event.clone(), + first_seen: None, + deleted_at: None, + expires_at: None, + event_deduplication: None, + remote_address: None, + } + } + + /// Sets `events.remote_address` (the capture's `ContextMetadataKey`). + pub fn with_remote_address(mut self, address: IpAddr) -> Self { + self.remote_address = Some(address); + self + } + + /// Sets `events.expires_at` (the capture's `EventExpirationTimeMetadataKey`). + pub fn with_expires_at(mut self, expires_at: i64) -> Self { + self.expires_at = Some(expires_at); + self + } + + /// Sets `events.event_deduplication` (the capture's `EventDeduplicationMetadataKey`). + pub fn with_deduplication(mut self, deduplication: Vec) -> Self { + self.event_deduplication = Some(deduplication); + self + } + + /// The `event_deduplication` value an insert writes: the record's own, or the capture's + /// default of `[pubkey, kind]` with the kind as a JSON number. + pub fn deduplication(&self) -> Vec { + self.event_deduplication + .clone() + .unwrap_or_else(|| default_deduplication(&self.event)) + } +} + +/// `jsonb_build_array(pubkey, kind)` as `src/repositories/event-repository.ts:390-396` +/// builds it: the pubkey hex string and the kind as a JSON number. +pub fn default_deduplication(event: &Event) -> Vec { + vec![ + serde_json::Value::String(event.pubkey.to_string()), + serde_json::Value::Number(serde_json::Number::from(event.kind.0)), + ] +} + +/// Implements the one lowercase spelling a column holds, its parser (an unknown value is +/// [`StorageError::InvalidRow`], never a default) and `Display`. +macro_rules! column_enum { + ($type:ident { $($variant:ident => $spelling:literal),+ $(,)? } table $table:literal, column $column:literal) => { + impl $type { + /// The value the column stores. + pub const fn as_str(self) -> &'static str { + match self { + $(Self::$variant => $spelling),+ + } + } + + /// Parses a column value; an unknown spelling is an invalid row, never a default. + pub fn parse(value: &str) -> Result { + match value { + $($spelling => Ok(Self::$variant),)+ + other => Err(StorageError::InvalidRow { + table: $table, + column: $column, + value: other.to_owned(), + }), + } + } + } + + impl fmt::Display for $type { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } + } + + impl FromStr for $type { + type Err = StorageError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } + } + }; +} + +column_enum!(InvoiceUnit { + Msats => "msats", + Sats => "sats", + Btc => "btc", +} table "invoices", column "unit"); + +column_enum!(InvoiceStatus { + Pending => "pending", + Completed => "completed", + Expired => "expired", +} table "invoices", column "status"); + +column_enum!(DvmJobStatus { + Submitted => "submitted", + PickedUp => "picked_up", + Completed => "completed", + Failed => "failed", + TimedOut => "timed_out", +} table "dvm_jobs", column "status"); + +column_enum!(ReportType { + Nudity => "nudity", + Malware => "malware", + Profanity => "profanity", + Illegal => "illegal", + Spam => "spam", + Impersonation => "impersonation", + Other => "other", +} table "reports", column "report_type"); + +column_enum!(NotificationTargetType { + Http => "http", + Discord => "discord", + Slack => "slack", + Telegram => "telegram", +} table "notification_delivery_log", column "target_type"); + +column_enum!(NotificationDeliveryStatus { + Success => "success", + Failed => "failed", +} table "notification_delivery_log", column "status"); + +column_enum!(OutboxStatus { + Pending => "pending", + Processing => "processing", + Delivered => "delivered", + Dead => "dead", +} table "notification_outbox", column "status"); + +impl DvmJobStatus { + /// Whether the status closes the job (`src/repositories/dvm-job-repository.ts` + /// `isTerminal`), which sets `completed_at`. + pub const fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::TimedOut) + } + + /// Whether the job is still waiting for or running on a worker (`findPendingJobs`). + pub const fn is_pending(self) -> bool { + matches!(self, Self::Submitted | Self::PickedUp) + } +} + +/// The integer columns are `integer`; a value outside `i32` cannot come from them, and a +/// negative one cannot name a kind or a creation time. +pub fn kind_from_i32(value: i32) -> Result { + u64::try_from(value) + .map(Kind) + .map_err(|_| StorageError::ValueOutOfRange { + column: "event_kind", + value: value.to_string(), + }) +} + +/// A `timestamp` column decoded as UTC. Every connection sets `timezone=UTC`, and the +/// capture's writes go through `now()` on a UTC session, so the value is a UTC instant. +pub fn utc_from_naive(value: chrono::NaiveDateTime) -> DateTime { + value.and_utc() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + use nostream_core::Event; + + fn event() -> Event { + serde_json::from_value(serde_json::json!({ + "id": "0d684e8ec2431de586aa3cafbee2f6d308d19b28805e53deabcac3220e9136a5", + "pubkey": "f43c1f9bff677b8f27b602725ea0ad51af221344f69a6b352a74991a4479bac3", + "created_at": 1660407625i64, + "kind": 3, + "tags": [["p", "b34417513f66497d7b0e1a8406b6689ac32afb184027717e57d281ea19186315"]], + "content": "hello", + "sig": "ab".repeat(64) + })) + .expect("the fixture event is a wire event") + } + + #[test] + fn a_record_from_an_event_has_no_storage_state() { + let record = EventRecord::from_event(&event()); + assert_eq!(record.event, event()); + assert!(record.row_id.is_none()); + assert!(record.first_seen.is_none()); + assert!(record.deleted_at.is_none()); + assert!(record.expires_at.is_none()); + assert!(record.event_deduplication.is_none()); + assert!(record.remote_address.is_none()); + } + + #[test] + fn the_builders_set_the_four_storage_only_fields() { + let record = EventRecord::from_event(&event()) + .with_remote_address("203.0.113.7".parse().expect("an address")) + .with_expires_at(1_700_000_000) + .with_deduplication(vec![serde_json::json!("tag-value")]); + assert_eq!( + record.remote_address, + Some("203.0.113.7".parse().expect("an address")) + ); + assert_eq!(record.expires_at, Some(1_700_000_000)); + assert_eq!( + record.event_deduplication, + Some(vec![serde_json::json!("tag-value")]) + ); + } + + #[test] + fn the_default_deduplication_is_the_pubkey_and_the_kind() { + let deduplication = default_deduplication(&event()); + assert_eq!( + deduplication, + vec![ + serde_json::json!( + "f43c1f9bff677b8f27b602725ea0ad51af221344f69a6b352a74991a4479bac3" + ), + serde_json::json!(3), + ] + ); + assert!(deduplication[1].is_number()); + } + + #[test] + fn a_record_without_deduplication_reports_the_default() { + let record = EventRecord::from_event(&event()); + assert_eq!(record.deduplication(), default_deduplication(&event())); + } + + #[test] + fn a_record_with_deduplication_reports_its_own() { + let record = EventRecord::from_event(&event()) + .with_deduplication(vec![serde_json::json!("0d684e8ec2431de5")]); + assert_eq!( + record.deduplication(), + vec![serde_json::json!("0d684e8ec2431de5")] + ); + } + + #[test] + fn every_column_enum_round_trips_its_spelling() { + let cases: Vec<(&str, &str)> = vec![ + (InvoiceUnit::Msats.as_str(), "msats"), + (InvoiceUnit::Sats.as_str(), "sats"), + (InvoiceUnit::Btc.as_str(), "btc"), + (InvoiceStatus::Pending.as_str(), "pending"), + (InvoiceStatus::Completed.as_str(), "completed"), + (InvoiceStatus::Expired.as_str(), "expired"), + (DvmJobStatus::Submitted.as_str(), "submitted"), + (DvmJobStatus::PickedUp.as_str(), "picked_up"), + (DvmJobStatus::Completed.as_str(), "completed"), + (DvmJobStatus::Failed.as_str(), "failed"), + (DvmJobStatus::TimedOut.as_str(), "timed_out"), + (ReportType::Nudity.as_str(), "nudity"), + (ReportType::Malware.as_str(), "malware"), + (ReportType::Profanity.as_str(), "profanity"), + (ReportType::Illegal.as_str(), "illegal"), + (ReportType::Spam.as_str(), "spam"), + (ReportType::Impersonation.as_str(), "impersonation"), + (ReportType::Other.as_str(), "other"), + (NotificationTargetType::Http.as_str(), "http"), + (NotificationTargetType::Discord.as_str(), "discord"), + (NotificationTargetType::Slack.as_str(), "slack"), + (NotificationTargetType::Telegram.as_str(), "telegram"), + (NotificationDeliveryStatus::Success.as_str(), "success"), + (NotificationDeliveryStatus::Failed.as_str(), "failed"), + (OutboxStatus::Pending.as_str(), "pending"), + (OutboxStatus::Processing.as_str(), "processing"), + (OutboxStatus::Delivered.as_str(), "delivered"), + (OutboxStatus::Dead.as_str(), "dead"), + ]; + for (spelling, expected) in cases { + assert_eq!(spelling, expected); + } + + assert_eq!( + "msats".parse::().expect("msats"), + InvoiceUnit::Msats + ); + assert_eq!( + "timed_out".parse::().expect("timed_out"), + DvmJobStatus::TimedOut + ); + assert_eq!( + "impersonation" + .parse::() + .expect("impersonation"), + ReportType::Impersonation + ); + assert_eq!( + "telegram" + .parse::() + .expect("telegram"), + NotificationTargetType::Telegram + ); + assert_eq!( + "dead".parse::().expect("dead"), + OutboxStatus::Dead + ); + assert_eq!(InvoiceStatus::Expired.to_string(), "expired"); + } + + #[test] + fn an_unknown_column_value_is_an_invalid_row() { + let error = "Telegram" + .parse::() + .expect_err("case matters"); + match error { + StorageError::InvalidRow { + table, + column, + value, + } => { + assert_eq!(table, "notification_delivery_log"); + assert_eq!(column, "target_type"); + assert_eq!(value, "Telegram"); + } + other => panic!("expected InvalidRow, got {other:?}"), + } + } + + #[test] + fn a_terminal_dvm_status_closes_the_job() { + assert!(DvmJobStatus::Completed.is_terminal()); + assert!(DvmJobStatus::Failed.is_terminal()); + assert!(DvmJobStatus::TimedOut.is_terminal()); + assert!(!DvmJobStatus::PickedUp.is_terminal()); + assert!(DvmJobStatus::PickedUp.is_pending()); + assert!(!DvmJobStatus::Submitted.is_terminal()); + } + + #[test] + fn a_kind_comes_from_a_non_negative_integer() { + assert_eq!(kind_from_i32(1).expect("kind 1"), Kind::TEXT_NOTE); + let error = kind_from_i32(-1).expect_err("negative kinds do not exist"); + assert!(matches!(error, StorageError::ValueOutOfRange { .. })); + } + + #[test] + fn field_update_defaults_to_absent() { + assert_eq!(FieldUpdate::::default(), FieldUpdate::Absent); + } +} diff --git a/crates/nostream-storage/src/repositories.rs b/crates/nostream-storage/src/repositories.rs index 113b2f00..9725f117 100644 --- a/crates/nostream-storage/src/repositories.rs +++ b/crates/nostream-storage/src/repositories.rs @@ -1,18 +1,32 @@ -//! The nine repository traits. Mirrors `src/@types/repositories.ts` at the capture commit. +//! The nine repository traits and the rows they move. Mirrors `src/@types/repositories.ts` +//! and the `src/@types/*` domain types at the capture commit. //! -//! Phase 1 carries each trait's representative method set (the capture's interfaces declare -//! five to eleven methods; the full surface is a Phase-2 port), and every implementation is a -//! labelled stub — the 622-line `src/repositories/event-repository.ts` body is **not** ported -//! here. Methods are async and return `Result<_, StorageError>`, which is the one part of the -//! TS boundary (`IQueryResult` extends `Promise`) that ports cleanly to tokio. - -use nostream_core::{EventId, Kind, Pubkey, SettingsPubkey}; +//! Every method is declared here and implemented in `crate::records` (constructors, enum +//! spellings, conversions) or `crate::postgres` (the backend), so the method count is +//! greppable and no implementation hides in this file. The capture returns `IQueryResult`, +//! a thenable with `.stream()`; here the two query methods return [`QueryStream`], which +//! borrows the store for the stream's life. use std::future::Future; use std::pin::Pin; +use chrono::{DateTime, Utc}; +use futures_core::Stream; +use nostream_core::{EventId, Kind, KindSelector, Pubkey, SettingsPubkey}; + use crate::error::StorageError; +/// One boxed repository future. The lifetime is the borrow of the store, exactly as in +/// `src/@types/repositories.ts`, whose methods take the ambient `DatabaseClient`. +pub type StorageFuture<'a, T> = Pin> + Send + 'a>>; + +/// One row per item, in exactly the query's `ORDER BY`. The borrow of the store is held for +/// the stream's life, which is what both backends need: Postgres holds a portal and a +/// pooled connection; LMDB holds a read transaction. A channel would force a background +/// task and a buffer policy LMDB does not need, and a `Vec` would materialise a 10M-row +/// NIP-77 reconciliation. +pub type QueryStream<'a, T> = Pin> + Send + 'a>>; + /// The rows a retention pass deleted, expired and kept (`src/@types/repositories.ts` /// `EventPurgeCounts`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -23,337 +37,571 @@ pub struct EventPurgeCounts { } /// The options a retention pass takes (`src/@types/repositories.ts` `EventRetentionOptions`). -#[derive(Debug, Clone, Default)] +/// +/// `kind_whitelist` holds [`KindSelector`]s, not bare kinds: the capture's +/// `(EventKinds | EventKindsRange)[]` carries `[start, end]` ranges, which a `Vec` +/// silently dropped. +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct EventRetentionOptions { pub max_days: Option, - pub kind_whitelist: Option>, + pub kind_whitelist: Option>, pub pubkey_whitelist: Option>, } -/// Event storage (`src/@types/repositories.ts` `IEventRepository`). -pub trait EventRepository: Send + Sync { - /// Inserts one event, returning its row id. - /// - /// Phase 1 stub — job 005; will insert a canonicalised event row; see `src/repositories/event-repository.ts`. - fn create( - &self, - _event: &nostream_core::Event, - ) -> Pin> + Send>>; - - /// Counts events matching the filters. - /// - /// Phase 1 stub — job 005; will count rows through the query builder; see `src/repositories/event-repository.ts`. - fn count_by_filters( - &self, - _filters: &[nostream_core::Filter], - ) -> Pin> + Send>>; - - /// Deletes events by pubkey and ids. - /// - /// Phase 1 stub — job 005; will delete NIP-09 deletions; see `src/repositories/event-repository.ts`. - fn delete_by_pubkey_and_ids( - &self, - _pubkey: &Pubkey, - _ids: &[EventId], - ) -> Pin> + Send>>; - - /// Runs the retention pass. - /// - /// Phase 1 stub — job 005; will delete expired and over-retention rows; see `src/repositories/event-repository.ts`. - fn delete_expired_and_retained( - &self, - _options: &EventRetentionOptions, - ) -> Pin> + Send>>; +/// One stored event row: the seven wire fields plus the five columns that are storage +/// state rather than wire data (`src/@types/event.ts` `DBEvent`, and the two insert-time +/// Symbols in `src/constants/base.ts`). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct EventRecord { + /// `events.id` (uuid, DB-generated). `None` on a write; populated on every read. + pub row_id: Option, + /// The seven wire fields (`nostream_core::Event`), unchanged. + pub event: nostream_core::Event, + /// `events.first_seen` (DB default `now()`); `None` on a write. + pub first_seen: Option>, + /// `events.deleted_at`; soft-delete state, `None` on a write. + pub deleted_at: Option>, + /// `events.expires_at`, unix seconds (the insert-time NIP-40 metadata). + pub expires_at: Option, + /// `events.event_deduplication`, the jsonb array: `[pubkey, kind]` by default, + /// `[d-tag]` for kinds 30000..=39999. `None` means "the capture's default". + pub event_deduplication: Option>, + /// `events.remote_address` (the `ContextMetadataKey` insert metadata). + pub remote_address: Option, } -/// Invoice storage (`src/@types/repositories.ts` `IInvoiceRepository`). -pub trait InvoiceRepository: Send + Sync { - /// Loads one invoice by id. - /// - /// Phase 1 stub — job 005; will load an invoice row; see `src/repositories/invoice-repository.ts`. - fn find_by_id( - &self, - _id: &str, - ) -> Pin, StorageError>> + Send>>; - - /// Upserts an invoice, returning its row id. - /// - /// Phase 1 stub — job 005; will insert or update an invoice; see `src/repositories/invoice-repository.ts`. - fn upsert( - &self, - _invoice: &Invoice, - ) -> Pin> + Send>>; - - /// Loads pending invoices with pagination. - /// - /// Phase 1 stub — job 005; will page pending invoices; see `src/repositories/invoice-repository.ts`. - fn find_pending( - &self, - _offset: u64, - _limit: u64, - ) -> Pin, StorageError>> + Send>>; -} - -/// One payment invoice row (`src/@types/invoice.ts`). +/// One NIP-77 reconciliation item (`src/@types/repositories.ts` `ReconciliationItem`). #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Invoice { - pub id: String, - pub pubkey: Pubkey, - pub amount: i64, - pub status: String, +pub struct ReconciliationItem { + pub created_at: i64, + pub event_id: EventId, } -/// User and admission storage (`src/@types/repositories.ts` `IUserRepository`). -pub trait UserRepository: Send + Sync { - /// Loads one user by pubkey. - /// - /// Phase 1 stub — job 005; will load a user row; see `src/repositories/user-repository.ts`. - fn find_by_pubkey( - &self, - _pubkey: &Pubkey, - ) -> Pin, StorageError>> + Send>>; - - /// Reads the balance of a pubkey. - /// - /// Phase 1 stub — job 005; will read the credited balance; see `src/repositories/user-repository.ts`. - fn get_balance_by_pubkey( - &self, - _pubkey: &Pubkey, - ) -> Pin> + Send>>; - - /// Marks a pubkey admitted. - /// - /// Phase 1 stub — job 005; will write the admission timestamp; see `src/repositories/user-repository.ts`. - fn admit_user( - &self, - _pubkey: &Pubkey, - _admitted_at: i64, - ) -> Pin> + Send>>; -} - -/// One user row (`src/@types/user.ts`). +/// One user row (`src/@types/user.ts` `User`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct User { pub pubkey: Pubkey, pub is_admitted: bool, + pub is_vanished: bool, + /// TS `bigint` over a PG `int8`. pub balance: i64, + pub created_at: DateTime, + pub updated_at: DateTime, + // Deliberately no `tos_accepted_at`: the capture's `fromDBUser` omits it + // (`src/utils/transform.ts:137-144`). Writes carry it in [`UserPatch`]. } -/// NIP-05 verification storage (`src/@types/repositories.ts` `INip05VerificationRepository`). -pub trait Nip05VerificationRepository: Send + Sync { - /// Loads the verification record for a pubkey. - /// - /// Phase 1 stub — job 005; will load the verification row; see `src/repositories/nip05-verification-repository.ts`. - fn find_by_pubkey( - &self, - _pubkey: &Pubkey, - ) -> Pin, StorageError>> + Send>>; - - /// Upserts a verification record. - /// - /// Phase 1 stub — job 005; will write the verification row; see `src/repositories/nip05-verification-repository.ts`. - fn upsert( - &self, - _verification: &Nip05Verification, - ) -> Pin> + Send>>; -} - -/// One NIP-05 verification row (`src/@types/nip05.ts`). +/// The user fields a write carries (`src/repositories/user-repository.ts` `upsert`). #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Nip05Verification { +pub struct UserPatch { pub pubkey: Pubkey, - pub identifier: String, - pub verified_at: Option, + /// TS default `false`. + pub is_admitted: bool, + /// TS default `false`. + pub is_vanished: bool, + /// Absent = leave the column alone (knex skips `undefined`); `Clear` = NULL; + /// `Set(t)` = t (the capture passes `null` explicitly to clear). + pub tos_accepted_at: FieldUpdate>, } -/// Invite-code storage (`src/@types/repositories.ts` `IInviteCodeRepository`). -pub trait InviteCodeRepository: Send + Sync { - /// Creates an invite code with its mint options. - /// - /// Phase 1 stub — job 005; will persist a minted code; see `src/repositories/invite-code-repository.ts`. - fn create( - &self, - _code: &str, - _max_uses: u32, - _expiry_seconds: u64, - ) -> Pin> + Send>>; - - /// Claims a code for a pubkey, returning whether the claim succeeded. - /// - /// Phase 1 stub — job 005; will atomically claim one use of a code; see `src/repositories/invite-code-repository.ts`. - fn claim_code( - &self, - _code: &str, - _pubkey: &Pubkey, - ) -> Pin> + Send>>; +/// `invoices.unit` (`src/@types/invoice.ts` `InvoiceUnit`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum InvoiceUnit { + Msats, + Sats, + Btc, +} - /// Deletes expired codes, returning the count. - /// - /// Phase 1 stub — job 005; will delete expired codes; see `src/repositories/invite-code-repository.ts`. - fn delete_expired_codes( - &self, - ) -> Pin> + Send>>; +/// `invoices.status` (`src/@types/invoice.ts` `InvoiceStatus`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum InvoiceStatus { + Pending, + Completed, + Expired, +} + +/// One payment invoice row (`src/@types/invoice.ts` `Invoice`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Invoice { + pub id: String, + pub pubkey: Pubkey, + pub bolt11: String, + pub amount_requested: i64, + pub amount_paid: Option, + pub unit: InvoiceUnit, + pub status: InvoiceStatus, + pub description: String, + pub confirmed_at: Option>, + pub expires_at: Option>, + pub updated_at: DateTime, + pub created_at: DateTime, + pub verify_url: Option, +} + +/// One NIP-05 verification row (`src/@types/nip05.ts` `Nip05Verification`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Nip05Verification { + pub pubkey: Pubkey, + pub nip05: String, + pub domain: String, + pub is_verified: bool, + pub last_verified_at: Option>, + pub last_checked_at: DateTime, + pub failure_count: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// The options a minted invite code takes (`src/@types/invite-code.ts` +/// `CreateInviteCodeOptions`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CreateInviteCodeOptions { + pub expires_at: Option>, + /// Absent = the capture's default of one use; `Clear` = unlimited; `Set(n)` = n. + pub remaining_uses: FieldUpdate, + pub created_by: Option, } -/// One invite code (`src/@types/invite-code.ts`). +/// One invite code (`src/@types/invite-code.ts` `InviteCode`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct InviteCode { pub code: String, - pub max_uses: u32, - pub expires_at: Option, + pub created_by: Option, + pub claimed_by: Option, + pub expires_at: Option>, + pub remaining_uses: Option, + pub created_at: DateTime, + pub updated_at: DateTime, } -/// DVM job storage (`src/@types/repositories.ts` `IDvmJobRepository`). -pub trait DvmJobRepository: Send + Sync { - /// Creates a job row for a NIP-90 request. - /// - /// Phase 1 stub — job 005; will persist a job request; see `src/repositories/dvm-job-repository.ts`. - fn create( - &self, - _id: &EventId, - _requester: &Pubkey, - _kind: Kind, - ) -> Pin> + Send>>; - - /// Assigns a worker to a job. - /// - /// Phase 1 stub — job 005; will assign an idle worker; see `src/repositories/dvm-job-repository.ts`. - fn assign_worker( - &self, - _id: &EventId, - _worker_index: u32, - ) -> Pin> + Send>>; - - /// Loads pending jobs, optionally by kind. - /// - /// Phase 1 stub — job 005; will page pending jobs; see `src/repositories/dvm-job-repository.ts`. - fn find_pending( - &self, - _limit: u64, - _kinds: &[Kind], - ) -> Pin, StorageError>> + Send>>; -} - -/// One DVM job row (`src/@types/dvm.ts`). +/// `dvm_jobs.status` (`src/@types/dvm.ts` `DvmJobStatus`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DvmJobStatus { + Submitted, + PickedUp, + Completed, + Failed, + TimedOut, +} + +/// One DVM job row (`src/@types/dvm.ts` `DvmJob`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct DvmJob { pub id: EventId, - pub requester: Pubkey, + pub requester_pubkey: Pubkey, pub kind: Kind, - pub status: String, - pub worker_index: Option, + pub worker_index: Option, + pub status: DvmJobStatus, + pub result_event_id: Option, + pub error: Option, + pub picked_up_at: Option>, + pub completed_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, } -/// Report storage (`src/@types/repositories.ts` `IReportRepository`). -pub trait ReportRepository: Send + Sync { - /// Creates one report row. - /// - /// Phase 1 stub — job 005; will persist a kind-1984 report; see `src/repositories/report-repository.ts`. - fn create( - &self, - _report: &Report, - ) -> Pin> + Send>>; +/// Key presence, which the capture checks with `'resultEventId' in job`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FieldUpdate { + Absent, + Clear, + Set(T), +} - /// Loads the reports against one event. - /// - /// Phase 1 stub — job 005; will load reports by event id; see `src/repositories/report-repository.ts`. - fn find_by_event_id( - &self, - _event_id: &EventId, - ) -> Pin, StorageError>> + Send>>; - - /// Loads actionable reports, up to `limit`. - /// - /// Phase 1 stub — job 005; will page actionable reports; see `src/repositories/report-repository.ts`. - fn find_actionable( - &self, - _limit: u64, - ) -> Pin, StorageError>> + Send>>; -} - -/// One report row (`src/@types/report.ts`). +/// One DVM status update (`src/repositories/dvm-job-repository.ts` `updateStatus`). #[derive(Debug, Clone, PartialEq, Eq)] +pub struct DvmJobUpdate { + pub id: EventId, + pub status: DvmJobStatus, + pub result_event_id: FieldUpdate, + pub error: FieldUpdate, +} + +/// `reports.report_type` (`src/@types/report.ts` `ReportType`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ReportType { + Nudity, + Malware, + Profanity, + Illegal, + Spam, + Impersonation, + Other, +} + +/// The fields a report write carries (`src/@types/report.ts`, minus `id`/`createdAt`). +#[derive(Debug, Clone, PartialEq)] +pub struct NewReport { + pub event_id: EventId, + pub reporter_pubkey: Pubkey, + pub reported_pubkey: Option, + pub reported_event_id: Option, + pub report_type: ReportType, + /// `real` in the schema (knex `float`), so `f32`. + pub weight: f32, + pub actionable: bool, +} + +/// One report row (`src/@types/report.ts` `Report`). +#[derive(Debug, Clone, PartialEq)] pub struct Report { - pub reporter: Pubkey, + pub id: i32, + pub event_id: EventId, + pub reporter_pubkey: Pubkey, pub reported_pubkey: Option, pub reported_event_id: Option, - pub report_type: String, + pub report_type: ReportType, + pub weight: f32, pub actionable: bool, + pub created_at: DateTime, } -/// Notification delivery-log storage (`src/@types/repositories.ts` -/// `INotificationDeliveryLogRepository`). -pub trait NotificationDeliveryLogRepository: Send + Sync { - /// Appends one delivery attempt. - /// - /// Phase 1 stub — job 005; will append a delivery log row; see `src/repositories/notification-delivery-log-repository.ts`. - fn append( - &self, - _entry: &DeliveryLogEntry, - ) -> Pin> + Send>>; - - /// Loads the recent log, newest first. - /// - /// Phase 1 stub — job 005; will page the delivery log; see `src/repositories/notification-delivery-log-repository.ts`. - fn find_recent( - &self, - _limit: u64, - ) -> Pin, StorageError>> + Send>>; +/// One actionable report target (`src/@types/repositories.ts` `findActionableTargets`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionableTarget { + pub reported_pubkey: Option, + pub reported_event_id: Option, +} - /// Deletes entries older than the cutoff, returning the count. - /// - /// Phase 1 stub — job 005; will prune old log rows; see `src/repositories/notification-delivery-log-repository.ts`. - fn delete_older_than( - &self, - _cutoff: i64, - ) -> Pin> + Send>>; +/// `notification_delivery_log.target_type` (`src/@types/operator-notifications.ts` +/// `OperatorNotificationChannelType`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NotificationTargetType { + Http, + Discord, + Slack, + Telegram, +} + +/// `notification_delivery_log.status` (`src/@types/operator-notifications.ts` +/// `NotificationDeliveryStatus`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NotificationDeliveryStatus { + Success, + Failed, +} + +/// One delivery attempt to append (`src/@types/operator-notifications.ts`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewDeliveryLogEntry { + pub outbox_id: Option, + pub event_type: String, + pub target_id: String, + pub target_type: NotificationTargetType, + pub status: NotificationDeliveryStatus, + pub attempt_number: i32, + pub error_snippet: Option, } -/// One delivery attempt (`src/@types/operator-notifications.ts`). +/// One stored delivery attempt (`src/@types/operator-notifications.ts` +/// `NotificationDeliveryLogEntry`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeliveryLogEntry { + pub id: String, pub outbox_id: Option, pub event_type: String, pub target_id: String, - pub status: String, - pub attempt_number: u32, + pub target_type: NotificationTargetType, + pub status: NotificationDeliveryStatus, + pub attempt_number: i32, pub error_snippet: Option, - pub created_at: i64, + pub created_at: DateTime, } -/// Notification outbox storage (`src/@types/repositories.ts` `INotificationOutboxRepository`). -pub trait NotificationOutboxRepository: Send + Sync { - /// Enqueues one notification for delivery. - /// - /// Phase 1 stub — job 005; will insert an outbox message; see `src/repositories/notification-outbox-repository.ts`. - fn enqueue( - &self, - _event_type: &str, - _payload: &str, - ) -> Pin> + Send>>; - - /// Claims a batch for delivery. - /// - /// Phase 1 stub — job 005; will claim unclaimed messages; see `src/repositories/notification-outbox-repository.ts`. - fn claim_batch( - &self, - _limit: u64, - ) -> Pin, StorageError>> + Send>>; +/// The filters `findRecent` accepts (no filters by default). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DeliveryLogFilters { + pub status: Option, + pub event_type: Option, +} - /// Marks a message delivered. - /// - /// Phase 1 stub — job 005; will mark delivery success; see `src/repositories/notification-outbox-repository.ts`. - fn mark_delivered( - &self, - _id: &str, - ) -> Pin> + Send>>; +/// `notification_outbox.status` (`src/@types/notification-outbox.ts` +/// `NotificationOutboxStatus`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum OutboxStatus { + Pending, + Processing, + Delivered, + Dead, } +/// The JSON document an outbox message carries. +pub type OutboxPayload = serde_json::Value; + /// One outbox message (`src/@types/notification-outbox.ts`). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct OutboxMessage { pub id: String, pub event_type: String, - pub payload: String, - pub attempt_count: u32, + pub payload: OutboxPayload, + pub status: OutboxStatus, + pub attempt_count: i32, + pub available_at: DateTime, + pub last_error: Option, + pub delivered_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// How long a `processing` row is left alone before another worker may reclaim it +/// (`src/repositories/notification-outbox-repository.ts`). +pub const NOTIFICATION_OUTBOX_PROCESSING_LEASE_MS: i64 = 5 * 60 * 1000; + +/// Event storage (`src/@types/repositories.ts` `IEventRepository`). +pub trait EventRepository: Send + Sync { + /// Inserts one event, returning the affected row count (0 on a duplicate `event_id`). + fn create<'a>(&'a self, record: &'a EventRecord) -> StorageFuture<'a, u64>; + + /// Inserts every event in one statement, returning the affected row count. + fn create_many<'a>(&'a self, records: &'a [EventRecord]) -> StorageFuture<'a, u64>; + + /// Inserts or replaces a replaceable event, returning the affected row count. + fn upsert<'a>(&'a self, record: &'a EventRecord) -> StorageFuture<'a, u64>; + + /// Inserts or replaces every event in one statement, returning the affected row count. + fn upsert_many<'a>(&'a self, records: &'a [EventRecord]) -> StorageFuture<'a, u64>; + + /// Streams the events matching the filters, in the query's order. + fn find_by_filters<'a>( + &'a self, + filters: &'a [nostream_core::Filter], + ) -> StorageFuture<'a, QueryStream<'a, EventRecord>>; + + /// Counts the events matching the filters. + fn count_by_filters<'a>( + &'a self, + filters: &'a [nostream_core::Filter], + ) -> StorageFuture<'a, u64>; + + /// Streams the live `(created_at, event_id)` pairs a NIP-77 reconciliation compares. + fn find_reconciliation_items<'a>( + &'a self, + filter: &'a nostream_core::Filter, + ) -> StorageFuture<'a, QueryStream<'a, ReconciliationItem>>; + + /// Soft-deletes the given ids of one pubkey, returning the count. + fn delete_by_pubkey_and_ids<'a>( + &'a self, + pubkey: &'a Pubkey, + ids: &'a [EventId], + ) -> StorageFuture<'a, u64>; + + /// Soft-deletes every event of one pubkey except the given kinds. + fn delete_by_pubkey_except_kinds<'a>( + &'a self, + pubkey: &'a Pubkey, + excluded_kinds: &'a [Kind], + ) -> StorageFuture<'a, u64>; + + /// Whether the pubkey has a live kind-62 request to vanish. + fn has_active_request_to_vanish<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, bool>; + + /// Hard-deletes one batch of expired, deleted and over-retention rows. + fn delete_expired_and_retained<'a>( + &'a self, + options: &'a EventRetentionOptions, + ) -> StorageFuture<'a, EventPurgeCounts>; +} + +/// Invoice storage (`src/@types/repositories.ts` `IInvoiceRepository`). +pub trait InvoiceRepository: Send + Sync { + /// Loads one invoice by id. + fn find_by_id<'a>(&'a self, id: &'a str) -> StorageFuture<'a, Option>; + + /// Inserts an invoice or updates its status, returning the row count. + fn upsert<'a>(&'a self, invoice: &'a Invoice) -> StorageFuture<'a, u64>; + + /// Updates one invoice's status and returns the row, `None` when no row matched. + fn update_status<'a>( + &'a self, + id: &'a str, + status: InvoiceStatus, + ) -> StorageFuture<'a, Option>; + + /// Confirms a payment through the schema's `confirm_invoice` function. + fn confirm_invoice<'a>( + &'a self, + invoice_id: &'a str, + amount_received: i64, + confirmed_at: DateTime, + ) -> StorageFuture<'a, ()>; + + /// Loads pending invoices, oldest first. + fn find_pending_invoices<'a>( + &'a self, + offset: u64, + limit: u64, + ) -> StorageFuture<'a, Vec>; +} + +/// User and admission storage (`src/@types/repositories.ts` `IUserRepository`). +pub trait UserRepository: Send + Sync { + /// Loads one user by pubkey. + fn find_by_pubkey<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, Option>; + + /// Inserts or updates one user, returning the row count. + fn upsert<'a>(&'a self, user: &'a UserPatch) -> StorageFuture<'a, u64>; + + /// Reads the balance of a pubkey, `0` when no row exists. + fn get_balance_by_pubkey<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, i64>; + + /// Whether the pubkey is vanished, hydrating the flag from events once. + fn is_vanished<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, bool>; + + /// Writes the vanished flag, returning the row count. + fn set_vanished<'a>(&'a self, pubkey: &'a Pubkey, vanished: bool) -> StorageFuture<'a, u64>; + + /// Admits a pubkey through the schema's `admit_user` function. + fn admit_user<'a>( + &'a self, + pubkey: &'a Pubkey, + admitted_at: DateTime, + ) -> StorageFuture<'a, ()>; + + /// Revokes admission, returning the row count. + fn revoke_admission<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, u64>; +} + +/// NIP-05 verification storage (`src/@types/repositories.ts` +/// `INip05VerificationRepository`). +pub trait Nip05VerificationRepository: Send + Sync { + /// Loads the verification record for a pubkey. + fn find_by_pubkey<'a>( + &'a self, + pubkey: &'a Pubkey, + ) -> StorageFuture<'a, Option>; + + /// Inserts or updates a verification record, returning the row count. + fn upsert<'a>(&'a self, verification: &'a Nip05Verification) -> StorageFuture<'a, u64>; + + /// Loads the verifications that are due for a check, oldest check first. + fn find_pending_verifications<'a>( + &'a self, + update_frequency_ms: i64, + max_failures: i32, + limit: u64, + ) -> StorageFuture<'a, Vec>; + + /// Deletes the verification record of one pubkey, returning the row count. + fn delete_by_pubkey<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, u64>; +} + +/// Invite-code storage (`src/@types/repositories.ts` `IInviteCodeRepository`). +pub trait InviteCodeRepository: Send + Sync { + /// Mints an invite code with its options. + fn create<'a>( + &'a self, + code: &'a str, + options: &'a CreateInviteCodeOptions, + ) -> StorageFuture<'a, InviteCode>; + + /// Loads one invite code. + fn find_by_code<'a>(&'a self, code: &'a str) -> StorageFuture<'a, Option>; + + /// Claims one use of a code, returning whether a row changed. + fn claim_code<'a>(&'a self, code: &'a str, pubkey: &'a Pubkey) -> StorageFuture<'a, bool>; + + /// Loads the codes that are still claimable, newest first. + fn find_active_codes<'a>(&'a self, limit: u64) -> StorageFuture<'a, Vec>; + + /// Deletes expired codes, returning the count. + fn delete_expired_codes<'a>(&'a self) -> StorageFuture<'a, u64>; +} + +/// DVM job storage (`src/@types/repositories.ts` `IDvmJobRepository`). +pub trait DvmJobRepository: Send + Sync { + /// Creates a job row for a NIP-90 request. + fn create<'a>( + &'a self, + id: &'a EventId, + requester: &'a Pubkey, + kind: Kind, + ) -> StorageFuture<'a, DvmJob>; + + /// Loads one job. + fn find_by_id<'a>(&'a self, id: &'a EventId) -> StorageFuture<'a, Option>; + + /// Assigns a worker to a submitted job, returning whether a row changed. + fn assign_worker<'a>(&'a self, id: &'a EventId, worker_index: i32) -> StorageFuture<'a, bool>; + + /// Applies a status update and returns the updated row. + fn update_status<'a>(&'a self, update: &'a DvmJobUpdate) -> StorageFuture<'a, Option>; + + /// Loads the pending jobs, oldest first, optionally by kind. + fn find_pending_jobs<'a>( + &'a self, + limit: u64, + kinds: Option<&'a [Kind]>, + ) -> StorageFuture<'a, Vec>; +} + +/// Report storage (`src/@types/repositories.ts` `IReportRepository`). +pub trait ReportRepository: Send + Sync { + /// Creates one report row. + fn create<'a>(&'a self, report: &'a NewReport) -> StorageFuture<'a, Report>; + + /// Creates every report in one transaction, in input order. + fn create_many<'a>(&'a self, reports: &'a [NewReport]) -> StorageFuture<'a, Vec>; + + /// Loads the reports against one event. + fn find_by_event_id<'a>(&'a self, event_id: &'a EventId) -> StorageFuture<'a, Vec>; + + /// Loads actionable reports, newest first. + fn find_actionable<'a>(&'a self, limit: u64) -> StorageFuture<'a, Vec>; + + /// Loads every distinct actionable report target. + fn find_actionable_targets<'a>(&'a self) -> StorageFuture<'a, Vec>; +} + +/// Notification delivery-log storage (`src/@types/repositories.ts` +/// `INotificationDeliveryLogRepository`). +pub trait NotificationDeliveryLogRepository: Send + Sync { + /// Appends one delivery attempt. + fn append<'a>(&'a self, entry: &'a NewDeliveryLogEntry) -> StorageFuture<'a, ()>; + + /// Loads the recent log, newest first, optionally filtered. + fn find_recent<'a>( + &'a self, + limit: u64, + filters: &'a DeliveryLogFilters, + ) -> StorageFuture<'a, Vec>; + + /// Loads the distinct target ids that succeeded for one outbox message. + fn find_successful_target_ids<'a>( + &'a self, + outbox_id: &'a str, + ) -> StorageFuture<'a, Vec>; + + /// Deletes entries older than the cutoff, returning the count. + fn delete_older_than<'a>(&'a self, cutoff: DateTime) -> StorageFuture<'a, u64>; +} + +/// Notification outbox storage (`src/@types/repositories.ts` +/// `INotificationOutboxRepository`). +pub trait NotificationOutboxRepository: Send + Sync { + /// Enqueues one notification for delivery. + fn enqueue<'a>( + &'a self, + event_type: &'a str, + payload: &'a OutboxPayload, + ) -> StorageFuture<'a, OutboxMessage>; + + /// Claims a batch for delivery, oldest first. + fn claim_batch<'a>(&'a self, limit: u64) -> StorageFuture<'a, Vec>; + + /// Marks a message delivered. + fn mark_delivered<'a>(&'a self, id: &'a str) -> StorageFuture<'a, ()>; + + /// Marks a failed attempt, scheduling a retry or the dead transition. + fn mark_failed<'a>( + &'a self, + id: &'a str, + error: &'a str, + attempt_count: i32, + max_attempts: i32, + base_delay_ms: i64, + ) -> StorageFuture<'a, ()>; + + /// Deletes delivered and dead messages older than the cutoff. + fn delete_terminal_older_than<'a>(&'a self, cutoff: DateTime) -> StorageFuture<'a, u64>; } diff --git a/crates/nostream-storage/tests/storage.rs b/crates/nostream-storage/tests/storage.rs index 4a55378b..18d0b113 100644 --- a/crates/nostream-storage/tests/storage.rs +++ b/crates/nostream-storage/tests/storage.rs @@ -1,4 +1,4 @@ -//! The LMDB environment slice and the backend stubs. +//! The LMDB environment slice, the trait surface, and the backend's dyn compatibility. // This integration-test target may unwrap: a failed assertion here is a failed test, // which is the point. The allowance names this file rather than exempting test targets @@ -7,7 +7,7 @@ use std::path::Path; -use nostream_storage::{Backend, LmdbEnv, StorageError}; +use nostream_storage::{Backend, LmdbEnv}; #[tokio::test] async fn the_lmdb_environment_round_trips_a_value() { @@ -29,87 +29,374 @@ fn the_backend_parses_the_settings_spelling() { } #[tokio::test] -async fn the_postgres_backend_is_a_labelled_stub() { - let error = nostream_storage::backend::PostgresBackend::connect("postgres://nostream") - .await - .expect_err("the Postgres backend is a stub"); - assert!( - error - .to_string() - .contains("not implemented in Phase 1 (job 005)") - ); - assert!(matches!(error, StorageError::NotImplemented { .. })); -} - -#[test] -fn the_repository_traits_are_dyn_compatible() { +async fn the_repository_traits_are_dyn_compatible() { // The seam exists so a backend can be picked at runtime: the trait objects must compile. - // A boxed stub proves the vtable exists without touching a database. - use std::future::Future; - use std::pin::Pin; + // The noop stub proves every vtable exists without touching a database. + use std::sync::Arc; - use nostream_core::{Event, EventId, Pubkey}; + use nostream_core::{EventId, Filter, Kind, Pubkey}; + use nostream_storage::PostgresBackend; use nostream_storage::StorageError; use nostream_storage::repositories::{ - EventPurgeCounts, EventRepository, Report, ReportRepository, + ActionableTarget, CreateInviteCodeOptions, DeliveryLogEntry, DeliveryLogFilters, DvmJob, + DvmJobRepository, DvmJobUpdate, EventPurgeCounts, EventRecord, EventRepository, + EventRetentionOptions, InviteCode, InviteCodeRepository, Invoice, InvoiceRepository, + InvoiceStatus, NewDeliveryLogEntry, NewReport, Nip05Verification, + Nip05VerificationRepository, NotificationDeliveryLogRepository, + NotificationOutboxRepository, OutboxMessage, OutboxPayload, QueryStream, + ReconciliationItem, Report, ReportRepository, StorageFuture, User, UserPatch, + UserRepository, }; struct Noop; + fn unsupported(module: &'static str) -> StorageFuture<'static, T> { + Box::pin(async move { Err(StorageError::NotImplemented { module }) }) + } + impl EventRepository for Noop { - fn create( - &self, - _event: &Event, - ) -> Pin> + Send>> { - Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + fn create<'a>(&'a self, _record: &'a EventRecord) -> StorageFuture<'a, u64> { + unsupported("noop::events::create") + } + + fn create_many<'a>(&'a self, _records: &'a [EventRecord]) -> StorageFuture<'a, u64> { + unsupported("noop::events::create_many") + } + + fn upsert<'a>(&'a self, _record: &'a EventRecord) -> StorageFuture<'a, u64> { + unsupported("noop::events::upsert") + } + + fn upsert_many<'a>(&'a self, _records: &'a [EventRecord]) -> StorageFuture<'a, u64> { + unsupported("noop::events::upsert_many") + } + + fn find_by_filters<'a>( + &'a self, + _filters: &'a [Filter], + ) -> StorageFuture<'a, QueryStream<'a, EventRecord>> { + unsupported("noop::events::find_by_filters") + } + + fn count_by_filters<'a>(&'a self, _filters: &'a [Filter]) -> StorageFuture<'a, u64> { + unsupported("noop::events::count_by_filters") + } + + fn find_reconciliation_items<'a>( + &'a self, + _filter: &'a Filter, + ) -> StorageFuture<'a, QueryStream<'a, ReconciliationItem>> { + unsupported("noop::events::find_reconciliation_items") + } + + fn delete_by_pubkey_and_ids<'a>( + &'a self, + _pubkey: &'a Pubkey, + _ids: &'a [EventId], + ) -> StorageFuture<'a, u64> { + unsupported("noop::events::delete_by_pubkey_and_ids") + } + + fn delete_by_pubkey_except_kinds<'a>( + &'a self, + _pubkey: &'a Pubkey, + _excluded_kinds: &'a [Kind], + ) -> StorageFuture<'a, u64> { + unsupported("noop::events::delete_by_pubkey_except_kinds") + } + + fn has_active_request_to_vanish<'a>( + &'a self, + _pubkey: &'a Pubkey, + ) -> StorageFuture<'a, bool> { + unsupported("noop::events::has_active_request_to_vanish") + } + + fn delete_expired_and_retained<'a>( + &'a self, + _options: &'a EventRetentionOptions, + ) -> StorageFuture<'a, EventPurgeCounts> { + unsupported("noop::events::delete_expired_and_retained") + } + } + + impl InvoiceRepository for Noop { + fn find_by_id<'a>(&'a self, _id: &'a str) -> StorageFuture<'a, Option> { + unsupported("noop::invoices::find_by_id") + } + + fn upsert<'a>(&'a self, _invoice: &'a Invoice) -> StorageFuture<'a, u64> { + unsupported("noop::invoices::upsert") + } + + fn update_status<'a>( + &'a self, + _id: &'a str, + _status: InvoiceStatus, + ) -> StorageFuture<'a, Option> { + unsupported("noop::invoices::update_status") + } + + fn confirm_invoice<'a>( + &'a self, + _invoice_id: &'a str, + _amount_received: i64, + _confirmed_at: chrono::DateTime, + ) -> StorageFuture<'a, ()> { + unsupported("noop::invoices::confirm_invoice") + } + + fn find_pending_invoices<'a>( + &'a self, + _offset: u64, + _limit: u64, + ) -> StorageFuture<'a, Vec> { + unsupported("noop::invoices::find_pending_invoices") + } + } + + impl UserRepository for Noop { + fn find_by_pubkey<'a>(&'a self, _pubkey: &'a Pubkey) -> StorageFuture<'a, Option> { + unsupported("noop::users::find_by_pubkey") + } + + fn upsert<'a>(&'a self, _user: &'a UserPatch) -> StorageFuture<'a, u64> { + unsupported("noop::users::upsert") + } + + fn get_balance_by_pubkey<'a>(&'a self, _pubkey: &'a Pubkey) -> StorageFuture<'a, i64> { + unsupported("noop::users::get_balance_by_pubkey") + } + + fn is_vanished<'a>(&'a self, _pubkey: &'a Pubkey) -> StorageFuture<'a, bool> { + unsupported("noop::users::is_vanished") + } + + fn set_vanished<'a>( + &'a self, + _pubkey: &'a Pubkey, + _vanished: bool, + ) -> StorageFuture<'a, u64> { + unsupported("noop::users::set_vanished") + } + + fn admit_user<'a>( + &'a self, + _pubkey: &'a Pubkey, + _admitted_at: chrono::DateTime, + ) -> StorageFuture<'a, ()> { + unsupported("noop::users::admit_user") + } + + fn revoke_admission<'a>(&'a self, _pubkey: &'a Pubkey) -> StorageFuture<'a, u64> { + unsupported("noop::users::revoke_admission") + } + } + + impl Nip05VerificationRepository for Noop { + fn find_by_pubkey<'a>( + &'a self, + _pubkey: &'a Pubkey, + ) -> StorageFuture<'a, Option> { + unsupported("noop::nip05::find_by_pubkey") + } + + fn upsert<'a>(&'a self, _verification: &'a Nip05Verification) -> StorageFuture<'a, u64> { + unsupported("noop::nip05::upsert") + } + + fn find_pending_verifications<'a>( + &'a self, + _update_frequency_ms: i64, + _max_failures: i32, + _limit: u64, + ) -> StorageFuture<'a, Vec> { + unsupported("noop::nip05::find_pending_verifications") + } + + fn delete_by_pubkey<'a>(&'a self, _pubkey: &'a Pubkey) -> StorageFuture<'a, u64> { + unsupported("noop::nip05::delete_by_pubkey") + } + } + + impl InviteCodeRepository for Noop { + fn create<'a>( + &'a self, + _code: &'a str, + _options: &'a CreateInviteCodeOptions, + ) -> StorageFuture<'a, InviteCode> { + unsupported("noop::invites::create") + } + + fn find_by_code<'a>(&'a self, _code: &'a str) -> StorageFuture<'a, Option> { + unsupported("noop::invites::find_by_code") + } + + fn claim_code<'a>( + &'a self, + _code: &'a str, + _pubkey: &'a Pubkey, + ) -> StorageFuture<'a, bool> { + unsupported("noop::invites::claim_code") + } + + fn find_active_codes<'a>(&'a self, _limit: u64) -> StorageFuture<'a, Vec> { + unsupported("noop::invites::find_active_codes") + } + + fn delete_expired_codes<'a>(&'a self) -> StorageFuture<'a, u64> { + unsupported("noop::invites::delete_expired_codes") + } + } + + impl DvmJobRepository for Noop { + fn create<'a>( + &'a self, + _id: &'a EventId, + _requester: &'a Pubkey, + _kind: Kind, + ) -> StorageFuture<'a, DvmJob> { + unsupported("noop::dvm::create") + } + + fn find_by_id<'a>(&'a self, _id: &'a EventId) -> StorageFuture<'a, Option> { + unsupported("noop::dvm::find_by_id") } - fn count_by_filters( - &self, - _filters: &[nostream_core::Filter], - ) -> Pin> + Send>> { - Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + fn assign_worker<'a>( + &'a self, + _id: &'a EventId, + _worker_index: i32, + ) -> StorageFuture<'a, bool> { + unsupported("noop::dvm::assign_worker") } - fn delete_by_pubkey_and_ids( - &self, - _pubkey: &Pubkey, - _ids: &[EventId], - ) -> Pin> + Send>> { - Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + fn update_status<'a>( + &'a self, + _update: &'a DvmJobUpdate, + ) -> StorageFuture<'a, Option> { + unsupported("noop::dvm::update_status") } - fn delete_expired_and_retained( - &self, - _options: &nostream_storage::repositories::EventRetentionOptions, - ) -> Pin> + Send>> { - Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + fn find_pending_jobs<'a>( + &'a self, + _limit: u64, + _kinds: Option<&'a [Kind]>, + ) -> StorageFuture<'a, Vec> { + unsupported("noop::dvm::find_pending_jobs") } } impl ReportRepository for Noop { - fn create( - &self, - _report: &Report, - ) -> Pin> + Send>> { - Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + fn create<'a>(&'a self, _report: &'a NewReport) -> StorageFuture<'a, Report> { + unsupported("noop::reports::create") + } + + fn create_many<'a>(&'a self, _reports: &'a [NewReport]) -> StorageFuture<'a, Vec> { + unsupported("noop::reports::create_many") } - fn find_by_event_id( - &self, - _event_id: &EventId, - ) -> Pin, StorageError>> + Send>> { - Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + fn find_by_event_id<'a>( + &'a self, + _event_id: &'a EventId, + ) -> StorageFuture<'a, Vec> { + unsupported("noop::reports::find_by_event_id") } - fn find_actionable( - &self, + fn find_actionable<'a>(&'a self, _limit: u64) -> StorageFuture<'a, Vec> { + unsupported("noop::reports::find_actionable") + } + + fn find_actionable_targets<'a>(&'a self) -> StorageFuture<'a, Vec> { + unsupported("noop::reports::find_actionable_targets") + } + } + + impl NotificationDeliveryLogRepository for Noop { + fn append<'a>(&'a self, _entry: &'a NewDeliveryLogEntry) -> StorageFuture<'a, ()> { + unsupported("noop::delivery_log::append") + } + + fn find_recent<'a>( + &'a self, _limit: u64, - ) -> Pin, StorageError>> + Send>> { - Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + _filters: &'a DeliveryLogFilters, + ) -> StorageFuture<'a, Vec> { + unsupported("noop::delivery_log::find_recent") + } + + fn find_successful_target_ids<'a>( + &'a self, + _outbox_id: &'a str, + ) -> StorageFuture<'a, Vec> { + unsupported("noop::delivery_log::find_successful_target_ids") + } + + fn delete_older_than<'a>( + &'a self, + _cutoff: chrono::DateTime, + ) -> StorageFuture<'a, u64> { + unsupported("noop::delivery_log::delete_older_than") } } - let _event_repository: std::sync::Arc = std::sync::Arc::new(Noop); - let _report_repository: Box = Box::new(Noop); + impl NotificationOutboxRepository for Noop { + fn enqueue<'a>( + &'a self, + _event_type: &'a str, + _payload: &'a OutboxPayload, + ) -> StorageFuture<'a, OutboxMessage> { + unsupported("noop::outbox::enqueue") + } + + fn claim_batch<'a>(&'a self, _limit: u64) -> StorageFuture<'a, Vec> { + unsupported("noop::outbox::claim_batch") + } + + fn mark_delivered<'a>(&'a self, _id: &'a str) -> StorageFuture<'a, ()> { + unsupported("noop::outbox::mark_delivered") + } + + fn mark_failed<'a>( + &'a self, + _id: &'a str, + _error: &'a str, + _attempt_count: i32, + _max_attempts: i32, + _base_delay_ms: i64, + ) -> StorageFuture<'a, ()> { + unsupported("noop::outbox::mark_failed") + } + + fn delete_terminal_older_than<'a>( + &'a self, + _cutoff: chrono::DateTime, + ) -> StorageFuture<'a, u64> { + unsupported("noop::outbox::delete_terminal_older_than") + } + } + + let noop = Arc::new(Noop); + let _: Arc = noop.clone(); + let _: Arc = noop.clone(); + let _: Arc = noop.clone(); + let _: Arc = noop.clone(); + let _: Arc = noop.clone(); + let _: Arc = noop.clone(); + let _: Arc = noop.clone(); + let _: Arc = noop.clone(); + let _: Arc = noop; + + // The real backend coerces into the same nine forms without a database. + let backend = PostgresBackend::connect_lazy("postgres://postgres:postgres@127.0.0.1/postgres") + .expect("a lazy backend"); + let _: &dyn EventRepository = &backend; + let _: &dyn InvoiceRepository = &backend; + let _: &dyn UserRepository = &backend; + let _: &dyn Nip05VerificationRepository = &backend; + let _: &dyn InviteCodeRepository = &backend; + let _: &dyn DvmJobRepository = &backend; + let _: &dyn ReportRepository = &backend; + let _: &dyn NotificationDeliveryLogRepository = &backend; + let _: &dyn NotificationOutboxRepository = &backend; } From 7f90f8863e2198b97e1380a355ff88d176455ab7 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 09:31:06 -0400 Subject: [PATCH 02/16] test(storage): differential parity harness, schema contract and fixtures --- .../20220524153400_create_events_table.sql | 2 +- .../20230107230901_create_users_table.sql | 2 +- .../nostream-storage/src/postgres/events.rs | 4 +- .../src/postgres/repository.rs | 2 +- crates/nostream-storage/src/postgres/sql.rs | 2 +- .../tests/capture/gen-storage-parity.mjs | 1412 ++ .../tests/fixtures/repository-parity.json | 11567 ++++++++++++++++ .../tests/fixtures/schema.json | 1588 +++ crates/nostream-storage/tests/migrations.rs | 346 + crates/nostream-storage/tests/parity.rs | 1027 ++ 10 files changed, 15947 insertions(+), 5 deletions(-) create mode 100644 crates/nostream-storage/tests/capture/gen-storage-parity.mjs create mode 100644 crates/nostream-storage/tests/fixtures/repository-parity.json create mode 100644 crates/nostream-storage/tests/fixtures/schema.json create mode 100644 crates/nostream-storage/tests/migrations.rs create mode 100644 crates/nostream-storage/tests/parity.rs diff --git a/crates/nostream-storage/migrations/20220524153400_create_events_table.sql b/crates/nostream-storage/migrations/20220524153400_create_events_table.sql index 286103f2..fbf3be86 100644 --- a/crates/nostream-storage/migrations/20220524153400_create_events_table.sql +++ b/crates/nostream-storage/migrations/20220524153400_create_events_table.sql @@ -1,6 +1,6 @@ CREATE TABLE events ( id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), - event_id bytea NOT NULL UNIQUE, + event_id bytea NOT NULL CONSTRAINT events_event_id_unique UNIQUE, event_pubkey bytea NOT NULL, event_kind integer NOT NULL, event_created_at integer NOT NULL, diff --git a/crates/nostream-storage/migrations/20230107230901_create_users_table.sql b/crates/nostream-storage/migrations/20230107230901_create_users_table.sql index 5d6ed6fa..2580fff7 100644 --- a/crates/nostream-storage/migrations/20230107230901_create_users_table.sql +++ b/crates/nostream-storage/migrations/20230107230901_create_users_table.sql @@ -1,7 +1,7 @@ CREATE TABLE users ( pubkey bytea PRIMARY KEY, is_admitted boolean DEFAULT false, - balance bigint DEFAULT 0, + balance bigint DEFAULT '0'::bigint, tos_accepted_at timestamp(3), created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP diff --git a/crates/nostream-storage/src/postgres/events.rs b/crates/nostream-storage/src/postgres/events.rs index 31d1150a..459fb7ab 100644 --- a/crates/nostream-storage/src/postgres/events.rs +++ b/crates/nostream-storage/src/postgres/events.rs @@ -394,7 +394,9 @@ fn push_upsert_values(args: &mut Args, record: &EventRecord) -> Result Result { diff --git a/crates/nostream-storage/src/postgres/repository.rs b/crates/nostream-storage/src/postgres/repository.rs index 7da2054d..5743eb67 100644 --- a/crates/nostream-storage/src/postgres/repository.rs +++ b/crates/nostream-storage/src/postgres/repository.rs @@ -539,7 +539,7 @@ impl NotificationOutboxRepository for PostgresTransaction { fn claim_batch<'a>(&'a self, limit: u64) -> StorageFuture<'a, Vec> { Box::pin(async move { with_transaction!(self, |transaction| { - notifications::claim_batch_in(&mut **transaction, limit).await + notifications::claim_batch_in(transaction, limit).await }) }) } diff --git a/crates/nostream-storage/src/postgres/sql.rs b/crates/nostream-storage/src/postgres/sql.rs index 250d18b6..1ae6128d 100644 --- a/crates/nostream-storage/src/postgres/sql.rs +++ b/crates/nostream-storage/src/postgres/sql.rs @@ -14,7 +14,7 @@ use crate::error::StorageError; const EVENT_COLUMNS: &str = "events.id::text AS id, events.event_id, events.event_pubkey, \ events.event_kind, events.event_created_at, events.event_content, events.event_tags, \ events.event_signature, events.first_seen, events.deleted_at, events.event_deduplication, \ -events.remote_address::text AS remote_address, events.expires_at"; +host(events.remote_address) AS remote_address, events.expires_at"; /// One bind value. Values are owned by the query, so the stream that drains it borrows only /// the pool. diff --git a/crates/nostream-storage/tests/capture/gen-storage-parity.mjs b/crates/nostream-storage/tests/capture/gen-storage-parity.mjs new file mode 100644 index 00000000..20cde744 --- /dev/null +++ b/crates/nostream-storage/tests/capture/gen-storage-parity.mjs @@ -0,0 +1,1412 @@ +// Regenerates ../fixtures/schema.json and ../fixtures/repository-parity.json from the +// TypeScript implementation at the capture commit. +// +// Subject: cameri/nostream at 242a3001d77149e4d70151ec03060c4ef44a1547 — the capture commit +// this branch is cut from. The harness MUST run with that tree as its working directory, +// because the repositories and resources/default-settings.yaml are read from . +// +// One-time setup, in a scratch directory (never in the working tree): +// git archive 242a3001 | tar -x -C /tmp/nostream-capture +// ln -s /node_modules /tmp/nostream-capture/node_modules +// +// Run, from the capture tree, against a PostgreSQL 16 server with an empty `public` schema: +// TZ=UTC DATABASE_URI=postgres://postgres:postgres@host:port/postgres \ +// node --out-dir /crates/nostream-storage/tests/fixtures +// +// TZ=UTC is required: node-postgres parses a `timestamp without time zone` column in the +// process's local zone, so any other TZ would record shifted `confirmed_at`/`expires_at` +// values for the Rust side to disagree with. +// +// The generator applies the capture's 37 knex migrations itself, loads the shipped defaults +// and overrides the two settings gates, inserts the corpus, runs the script below through the +// TypeScript repositories, and writes both fixtures. The committed fixtures are the output; +// the routine harness never needs this tree or knex. +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' + +const cwd = process.cwd() +const settingsModule = path.join(cwd, 'src/utils/settings-config.ts') +const isCaptureTree = + fs.existsSync(settingsModule) && + fs.existsSync(path.join(cwd, 'resources/default-settings.yaml')) && + !fs.existsSync(path.join(cwd, 'crates')) +if (!isCaptureTree) { + console.error( + `run this from the capture tree (cwd=${cwd}); src/utils/settings-config.ts and ` + + `resources/default-settings.yaml must exist, and crates/ must not`, + ) + process.exit(2) +} +if (process.env.TZ !== 'UTC') { + console.error('run with TZ=UTC so timestamp columns are read as UTC') + process.exit(2) +} +if (!process.env.DATABASE_URI) { + console.error('DATABASE_URI must name an empty PostgreSQL 16 database') + process.exit(2) +} + +const outDir = (() => { + const index = process.argv.indexOf('--out-dir') + return index >= 0 ? process.argv[index + 1] : path.join(cwd, 'fixtures') +})() + +const require = createRequire(path.join(cwd, 'package.json')) +require('ts-node/register/transpile-only') + +const knex = require('knex') +const { EventRepository } = require(path.join(cwd, 'src/repositories/event-repository.ts')) +const { InvoiceRepository } = require(path.join(cwd, 'src/repositories/invoice-repository.ts')) +const { UserRepository } = require(path.join(cwd, 'src/repositories/user-repository.ts')) +const { + Nip05VerificationRepository, +} = require(path.join(cwd, 'src/repositories/nip05-verification-repository.ts')) +const { + InviteCodeRepository, +} = require(path.join(cwd, 'src/repositories/invite-code-repository.ts')) +const { DvmJobRepository } = require(path.join(cwd, 'src/repositories/dvm-job-repository.ts')) +const { ReportRepository } = require(path.join(cwd, 'src/repositories/report-repository.ts')) +const { + NotificationDeliveryLogRepository, +} = require(path.join(cwd, 'src/repositories/notification-delivery-log-repository.ts')) +const { + NotificationOutboxRepository, +} = require(path.join(cwd, 'src/repositories/notification-outbox-repository.ts')) +const { loadDefaults, setByPath } = require(settingsModule) +const { + ContextMetadataKey, + EventDeduplicationMetadataKey, + EventExpirationTimeMetadataKey, +} = require(path.join(cwd, 'src/constants/base.ts')) + +// --------------------------------------------------------------------------------------- +// Settings: the shipped defaults with the two query-shape gates switched on. +// --------------------------------------------------------------------------------------- + +const SETTINGS_OVERRIDES = [ + ['nip50.enabled', true], + ['nip50.language', 'simple'], + ['nip50.maxQueryLength', 256], + ['nip56.enabled', true], + ['nip56.hideActionableReports', true], +] +const settings = SETTINGS_OVERRIDES.reduce( + (document, [dotted, value]) => setByPath(document, dotted, value), + loadDefaults(), +) +const getSettings = () => settings + +// --------------------------------------------------------------------------------------- +// Corpus: the 463 shipped seed events plus deterministic synthetic edge cases. +// --------------------------------------------------------------------------------------- + +const seedEvents = JSON.parse(fs.readFileSync(path.join(cwd, 'seeds/events.json'), 'utf8')) + +const pad64 = (prefix) => prefix + '0'.repeat(64 - prefix.length) +const PK_A = pad64('a1') +const PK_B = pad64('a2') +const PK_C = pad64('b3') +const PK_D = pad64('c4') +const syntheticId = (index) => pad64(`c${index.toString(16).padStart(3, '0')}`) +const syntheticPubkey = (index) => pad64(`d${index.toString(16).padStart(3, '0')}`) +const SIG = 'ab'.repeat(64) + +const makeEvent = (id, pubkey, created_at, kind, tags, content) => ({ + id, + pubkey, + created_at, + kind, + tags, + content, + sig: SIG, +}) + +const synthetic = [ + makeEvent(syntheticId(1), PK_A, 1700000100, 0, [], 'profile one'), + makeEvent(syntheticId(2), PK_A, 1700000200, 0, [], 'profile two'), + makeEvent(syntheticId(3), PK_A, 1700000200, 0, [], 'profile tie winner'), + makeEvent(syntheticId(4), PK_A, 1700000300, 1, [['t', 'nostr']], 'bitcoin lightning network'), + makeEvent(syntheticId(5), PK_B, 1700000400, 1, [['t', 'bitcoin'], ['g', 'u4pruydqqvj']], 'lightning strikes twice'), + makeEvent(syntheticId(6), PK_B, 1700000500, 1, [], 'unrelated text'), + makeEvent(syntheticId(7), PK_B, 1700000600, 7, [['e', 'c000000000000000000000000000000000000000000000000000000000000001']], 'reaction'), + makeEvent(syntheticId(8), PK_C, 1700000700, 1, [['e', syntheticId(4)], ['p', PK_A]], 'reply to note'), + makeEvent(syntheticId(9), PK_C, 1700000800, 30023, [['d', 'article-one']], 'long form article'), + makeEvent(syntheticId(10), PK_C, 1700000801, 30023, [['d', 'article-one']], 'long form article v2'), + makeEvent(syntheticId(11), PK_C, 1700000802, 30023, [], 'parameterized without d tag'), + makeEvent(syntheticId(12), PK_D, 1700000900, 41, [], 'group chat'), + makeEvent(syntheticId(13), PK_D, 1700000901, 41, [], 'group chat later'), + makeEvent(syntheticId(14), PK_D, 1700001000, 1, [['g', 'u4pruydqqvj']], 'geohash note'), + makeEvent(syntheticId(15), PK_D, 1700001100, 62, [], 'request to vanish'), + makeEvent(syntheticId(16), PK_D, 1700001200, 1, [], 'expired note'), + makeEvent(syntheticId(17), PK_A, 1700001300, 1, [['a', '30023:' + PK_C + ':article-one']], 'addressable reference'), + makeEvent(syntheticId(18), syntheticPubkey(1), 1700001400, 1, [], 'deletable by kind'), + makeEvent(syntheticId(19), syntheticPubkey(1), 1700001401, 2, [], 'relay recommendation'), + makeEvent(syntheticId(20), syntheticPubkey(2), 1700001500, 1, [], 'target for a report'), +] + +const eventExpiry = new Map([ + [syntheticId(16), 1600000000], // long expired + [syntheticId(6), 2000000000], // far future +]) + +const corpus = [ + ...seedEvents.map((event) => ({ source: 'seeds/events.json', event })), + ...synthetic.map((event) => ({ + source: 'synthetic', + event, + ...(eventExpiry.has(event.id) ? { expires_at: eventExpiry.get(event.id) } : {}), + ...(event.id === syntheticId(7) ? { remote_address: '203.0.113.7' } : {}), + ...(event.kind === 30023 && event.tags.length > 0 + ? { deduplication: [event.tags[0][1]] } + : {}), + })), +] + +// --------------------------------------------------------------------------------------- +// Encoders shared with the Rust replay. +// --------------------------------------------------------------------------------------- + +const hex = (value) => (value === null || value === undefined ? null : Buffer.from(value).toString('hex')) +const iso = (value) => (value === null || value === undefined ? null : new Date(value).toISOString()) +const eventRow = (row) => [ + hex(row.event_id), + row.event_created_at, + row.expires_at ?? null, + row.deleted_at !== null && row.deleted_at !== undefined, + row.event_deduplication ?? null, +] +const invoiceView = (invoice) => { + if (!invoice) return null + return { + id: invoice.id, + pubkey: invoice.pubkey, + bolt11: invoice.bolt11, + amount_requested: Number(invoice.amountRequested), + amount_paid: + invoice.amountPaid === undefined || invoice.amountPaid === null + ? null + : Number(invoice.amountPaid), + unit: invoice.unit, + status: invoice.status, + description: invoice.description, + confirmed_at: iso(invoice.confirmedAt), + expires_at: iso(invoice.expiresAt), + verify_url: invoice.verifyURL ?? null, + } +} +const userView = (user) => + user === undefined + ? null + : { + pubkey: user.pubkey, + is_admitted: user.isAdmitted, + is_vanished: user.isVanished, + balance: Number(user.balance), + } +const nip05View = (row) => + row === undefined + ? null + : { + pubkey: row.pubkey, + nip05: row.nip05, + domain: row.domain, + is_verified: row.isVerified, + last_verified_at: iso(row.lastVerifiedAt), + last_checked_at: iso(row.lastCheckedAt), + failure_count: row.failureCount, + } +const inviteView = (row) => ({ + code: row.code, + created_by: row.createdBy, + claimed_by: row.claimedBy, + expires_at: iso(row.expiresAt), + remaining_uses: row.remainingUses, +}) +const dvmView = (row) => + row === undefined + ? null + : { + id: row.id, + requester_pubkey: row.requesterPubkey, + kind: row.kind, + worker_index: row.workerIndex, + status: row.status, + result_event_id: row.resultEventId, + error: row.error, + } +const reportView = (row) => ({ + id: row.id, + event_id: row.eventId, + reporter_pubkey: row.reporterPubkey, + reported_pubkey: row.reportedPubkey, + reported_event_id: row.reportedEventId, + report_type: row.reportType, + weight: row.weight, + actionable: row.actionable, +}) +const deliveryView = (row) => ({ + outbox_id: row.outboxId, + event_type: row.eventType, + target_id: row.targetId, + target_type: row.targetType, + status: row.status, + attempt_number: row.attemptNumber, + error_snippet: row.errorSnippet, +}) +const outboxView = (row) => ({ + event_type: row.eventType, + payload: row.payload, + status: row.status, + attempt_count: row.attemptCount, + last_error: row.lastError, +}) + +// Some lists are ordered by a timestamp the call generated; both sides sort them by a +// deterministic key before comparing, as the fixture's `projection` records. +const byKey = (key) => (left, right) => (key(left) < key(right) ? -1 : key(left) > key(right) ? 1 : 0) +const byReportId = byKey((row) => row.id) +const byDvmId = byKey((row) => row.id) +const byInviteCode = byKey((row) => row.code) +const byDelivery = (left, right) => + byKey((row) => `${row.target_id}/${row.attempt_number}`)(left, right) +const byOutboxType = byKey((row) => row.event_type) + +// --------------------------------------------------------------------------------------- +// Request decoding: JSON -> the TypeScript types, including the Symbol metadata. +// --------------------------------------------------------------------------------------- + +const eventFromJson = (record) => { + const event = { ...record.event } + if (record.remote_address !== undefined) { + event[ContextMetadataKey] = { remoteAddress: { address: record.remote_address } } + } + if (record.expires_at !== undefined) { + event[EventExpirationTimeMetadataKey] = record.expires_at + } + if (record.deduplication !== undefined) { + event[EventDeduplicationMetadataKey] = record.deduplication + } + return event +} + +const date = (value) => (value === null ? null : new Date(value)) + +const fieldUpdateIn = (update) => { + switch (update.kind) { + case 'absent': + return undefined + case 'clear': + return null + default: + return update.value + } +} + +// --------------------------------------------------------------------------------------- +// The script. Every op runs through the TypeScript repositories and records its result. +// --------------------------------------------------------------------------------------- + +const repoTarget = syntheticId(20) +const reportEventId = syntheticId(19) + +const script = [ + // ---- events: writes ------------------------------------------------------------- + { + op: 'events.create', + request: { record: { event: makeEvent(syntheticId(21), PK_A, 1700001600, 1, [], 'created once') } }, + }, + { + op: 'events.create', + request: { record: { event: makeEvent(syntheticId(21), PK_A, 1700001600, 1, [], 'created once') } }, + }, + { op: 'events.create_many', request: { records: [] } }, + { + op: 'events.upsert', + request: { record: { event: makeEvent(syntheticId(22), PK_A, 1700001700, 0, [], 'profile upsert') } }, + }, + { + op: 'events.upsert_many', + request: { + records: [ + { event: makeEvent(syntheticId(23), PK_B, 1700001800, 1, [], 'upsert many one') }, + { + event: makeEvent(syntheticId(24), PK_B, 1700001801, 1, [], 'upsert many two'), + expires_at: 2000000000, + remote_address: '198.51.100.9', + }, + ], + }, + // upsert_many's merge list omits remote_address on both sides. + }, + { + op: 'events.upsert', + request: { record: { event: makeEvent(syntheticId(25), PK_A, 1700001600, 0, [], 'profile older') } }, + }, + { + op: 'events.upsert', + request: { record: { event: makeEvent(syntheticId(26), PK_A, 1700001800, 0, [], 'profile newer') } }, + }, + { + op: 'events.upsert', + request: { + record: { + event: makeEvent(syntheticId(27), PK_C, 1700001900, 30023, [['d', 'article-two']], 'article two'), + deduplication: ['article-two'], + }, + }, + }, + { + op: 'events.upsert', + request: { + record: { + event: makeEvent(syntheticId(28), PK_C, 1700001900, 30023, [['d', 'article-two']], 'article two tie'), + deduplication: ['article-two'], + }, + }, + }, + { + op: 'events.upsert_many', + request: { + records: [ + { + event: makeEvent(syntheticId(29), PK_D, 1700002000, 30000, [['d', 'many']], 'many one'), + deduplication: ['many'], + remote_address: '198.51.100.1', + }, + { + event: makeEvent(syntheticId(30), PK_D, 1700002001, 30000, [['d', 'many-2']], 'many two'), + deduplication: ['many-2'], + remote_address: '198.51.100.2', + }, + ], + }, + }, + + // ---- events: reads -------------------------------------------------------------- + { + op: 'events.find_by_filters', + request: { filters: [{ authors: [PK_A], kinds: [1] }] }, + }, + { + op: 'events.find_by_filters', + request: { filters: [{ authors: [PK_A.slice(0, 8)] }] }, + }, + { + op: 'events.find_by_filters', + request: { filters: [{ authors: [PK_A.slice(0, 7)] }] }, + }, + { + op: 'events.find_by_filters', + request: { filters: [{ ids: [syntheticId(4).slice(0, 9)] }] }, + }, + { + op: 'events.find_by_filters', + request: { filters: [{ ids: [syntheticId(5), syntheticId(6)] }] }, + }, + { + op: 'events.find_by_filters', + request: { filters: [{ authors: [], kinds: [1] }] }, + }, + { + op: 'events.find_by_filters', + request: { filters: [{ kinds: [1], since: 1700000400, until: 1700000600 }] }, + }, + { + op: 'events.find_by_filters', + request: { filters: [{ kinds: [1], limit: 3 }] }, + }, + { + op: 'events.find_by_filters', + request: { filters: [{ '#e': [syntheticId(4)] }] }, + }, + { + op: 'events.find_by_filters', + request: { filters: [{ '#g': ['u4pru*'] }] }, + }, + { + op: 'events.find_by_filters', + request: { filters: [{ '#e': [] }] }, + }, + { + op: 'events.find_by_filters', + request: { filters: [{ search: 'bitcoin lightning' }] }, + }, + { + op: 'events.find_by_filters', + request: { filters: [{ kinds: [1], limit: 2 }, { authors: [PK_C] }] }, + }, + { op: 'events.find_by_filters', request: { filters: [] } }, + { + op: 'events.count_by_filters', + request: { filters: [{ authors: [PK_A] }] }, + }, + { + op: 'events.count_by_filters', + request: { filters: [{ kinds: [1] }, { kinds: [7] }] }, + }, + { + op: 'events.count_by_filters', + request: { filters: [{ kinds: [1], limit: 2 }] }, + }, + { + op: 'events.find_reconciliation_items', + request: { filter: { authors: [PK_B] } }, + }, + { + op: 'events.has_active_request_to_vanish', + request: { pubkey: PK_D }, + }, + { + op: 'events.has_active_request_to_vanish', + request: { pubkey: PK_A }, + }, + + // ---- events: soft deletes, then NIP-56 ------------------------------------------------- + { + op: 'events.delete_by_pubkey_and_ids', + request: { pubkey: syntheticPubkey(1), ids: [syntheticId(18)] }, + }, + { + op: 'events.delete_by_pubkey_except_kinds', + request: { pubkey: PK_B, kinds: [1] }, + }, + { + op: 'report.create', + request: { + report: { + event_id: reportEventId, + reporter_pubkey: PK_A, + reported_pubkey: syntheticPubkey(2), + reported_event_id: repoTarget, + report_type: 'spam', + weight: 0.5, + actionable: true, + }, + }, + }, + { + op: 'events.find_by_filters', + request: { filters: [{ authors: [syntheticPubkey(2)] }] }, + }, + { + op: 'events.count_by_filters', + request: { filters: [{ authors: [syntheticPubkey(2)] }] }, + }, + + // ---- events: retention (last, because it removes rows) --------------------------- + { + op: 'events.delete_expired_and_retained', + request: { options: { max_days: 1 } }, + }, + { + op: 'events.delete_expired_and_retained', + request: { options: { max_days: 0 } }, + }, + { + op: 'events.delete_expired_and_retained', + request: { + options: { max_days: 1, kind_whitelist: [[1, 1]], pubkey_whitelist: [PK_A] }, + }, + }, + + // ---- users ---------------------------------------------------------------------- + { + op: 'user.upsert', + request: { + patch: { + pubkey: PK_A, + is_admitted: true, + is_vanished: false, + tos_accepted_at: { kind: 'set', value: '2024-01-02T03:04:05Z' }, + }, + }, + }, + { op: 'user.find_by_pubkey', request: { pubkey: PK_A } }, + { op: 'user.find_by_pubkey', request: { pubkey: PK_B } }, + { op: 'user.get_balance_by_pubkey', request: { pubkey: PK_A } }, + { op: 'user.get_balance_by_pubkey', request: { pubkey: PK_D } }, + { + op: 'user.is_vanished', + request: { pubkey: PK_D }, + }, + { op: 'user.set_vanished', request: { pubkey: PK_A, vanished: true } }, + { op: 'user.is_vanished', request: { pubkey: PK_A } }, + { + op: 'user.upsert', + request: { patch: { pubkey: PK_B, is_admitted: false, is_vanished: true, tos_accepted_at: { kind: 'absent' } } }, + }, + { + op: 'user.upsert', + request: { patch: { pubkey: PK_B, is_admitted: true, is_vanished: false, tos_accepted_at: { kind: 'clear' } } }, + }, + { op: 'user.revoke_admission', request: { pubkey: PK_B } }, + { op: 'user.find_by_pubkey', request: { pubkey: PK_C } }, + + // ---- invoices ------------------------------------------------------------------- + { + op: 'invoice.upsert', + request: { + invoice: { + id: 'inv-1', + pubkey: PK_A, + bolt11: 'lnbc1invoiceone', + amount_requested: 1000, + amount_paid: null, + unit: 'sats', + status: 'pending', + description: 'first invoice', + confirmed_at: null, + expires_at: '2024-03-01T00:00:00Z', + created_at: '2024-02-29T00:00:00Z', + verify_url: 'https://example.test/verify/1', + }, + }, + }, + { + op: 'invoice.upsert', + request: { + invoice: { + id: 'inv-2', + pubkey: PK_B, + bolt11: 'lnbc1invoicetwo', + amount_requested: 2000, + amount_paid: null, + unit: 'msats', + status: 'pending', + description: 'second invoice', + confirmed_at: null, + expires_at: null, + created_at: '2024-02-28T00:00:00Z', + }, + }, + }, + { op: 'invoice.find_by_id', request: { id: 'inv-1' } }, + { op: 'invoice.find_by_id', request: { id: 'missing' } }, + { op: 'invoice.find_pending_invoices', request: { offset: 0, limit: 10 } }, + { op: 'invoice.update_status', request: { id: 'inv-2', status: 'expired' } }, + { op: 'invoice.update_status', request: { id: 'missing', status: 'expired' } }, + { + op: 'invoice.confirm_invoice', + request: { invoice_id: 'inv-1', amount_received: 1000, confirmed_at: '2024-03-02T00:00:00Z' }, + }, + { op: 'user.get_balance_by_pubkey', request: { pubkey: PK_A } }, + { op: 'invoice.find_by_id', request: { id: 'inv-1' } }, + + // ---- nip05 ---------------------------------------------------------------------- + { + op: 'nip05.upsert', + request: { + verification: { + pubkey: PK_A, + nip05: '_@example.test', + domain: 'example.test', + is_verified: true, + last_verified_at: '2024-04-01T00:00:00Z', + last_checked_at: '2024-04-02T00:00:00Z', + failure_count: 0, + }, + }, + }, + { + op: 'nip05.upsert', + request: { + verification: { + pubkey: PK_B, + nip05: 'bob@example.test', + domain: 'example.test', + is_verified: false, + last_verified_at: null, + last_checked_at: '2024-04-03T00:00:00Z', + failure_count: 2, + }, + }, + }, + { op: 'nip05.find_by_pubkey', request: { pubkey: PK_A } }, + { op: 'nip05.find_by_pubkey', request: { pubkey: PK_C } }, + { + op: 'nip05.find_pending_verifications', + request: { update_frequency_ms: 315360000000, max_failures: 5, limit: 10 }, + }, + { op: 'nip05.delete_by_pubkey', request: { pubkey: PK_B } }, + { op: 'nip05.find_by_pubkey', request: { pubkey: PK_B } }, + + // ---- invite codes --------------------------------------------------------------- + { + op: 'invite.create', + request: { + code: 'invite-one', + options: { expires_at: '2100-01-01T00:00:00Z', created_by: PK_A }, + }, + }, + { + op: 'invite.create', + request: { code: 'invite-two', options: { remaining_uses: { kind: 'clear' } } }, + }, + { + op: 'invite.create', + request: { + code: 'invite-expired', + options: { expires_at: '2020-01-01T00:00:00Z', remaining_uses: { kind: 'set', value: 3 } }, + }, + }, + { op: 'invite.find_by_code', request: { code: 'invite-one' } }, + { op: 'invite.claim_code', request: { code: 'invite-one', pubkey: PK_B } }, + { op: 'invite.claim_code', request: { code: 'invite-expired', pubkey: PK_B } }, + { op: 'invite.claim_code', request: { code: 'invite-two', pubkey: PK_C } }, + { op: 'invite.find_active_codes', request: { limit: 10 } }, + { op: 'invite.delete_expired_codes', request: {} }, + { op: 'invite.find_by_code', request: { code: 'invite-expired' } }, + + // ---- dvm jobs ------------------------------------------------------------------- + { + op: 'dvm.create', + request: { id: syntheticId(30), requester: PK_A, kind: 5100 }, + }, + { + op: 'dvm.create', + request: { id: syntheticId(31), requester: PK_B, kind: 5200 }, + }, + { op: 'dvm.find_by_id', request: { id: syntheticId(30) } }, + { op: 'dvm.find_pending_jobs', request: { limit: 10 } }, + { op: 'dvm.assign_worker', request: { id: syntheticId(30), worker_index: 2 } }, + { op: 'dvm.assign_worker', request: { id: syntheticId(30), worker_index: 3 } }, + { + op: 'dvm.update_status', + request: { + update: { + id: syntheticId(30), + status: 'picked_up', + result_event_id: { kind: 'absent' }, + error: { kind: 'absent' }, + }, + }, + }, + { + op: 'dvm.update_status', + request: { + update: { + id: syntheticId(31), + status: 'completed', + result_event_id: { kind: 'set', value: syntheticId(32) }, + error: { kind: 'clear' }, + }, + }, + }, + { op: 'dvm.find_pending_jobs', request: { limit: 10, kinds: [5100] } }, + { op: 'dvm.find_by_id', request: { id: syntheticId(31) } }, + + // ---- reports -------------------------------------------------------------------- + { + op: 'report.create_many', + request: { + reports: [ + { + event_id: reportEventId, + reporter_pubkey: PK_B, + reported_pubkey: null, + reported_event_id: syntheticId(20), + report_type: 'nudity', + weight: 0.25, + actionable: true, + }, + { + event_id: reportEventId, + reporter_pubkey: PK_C, + reported_pubkey: syntheticPubkey(2), + reported_event_id: null, + report_type: 'other', + weight: 0.125, + actionable: false, + }, + ], + }, + }, + { op: 'report.create_many', request: { reports: [] } }, + { op: 'report.find_by_event_id', request: { event_id: reportEventId } }, + { op: 'report.find_actionable', request: { limit: 10 } }, + { op: 'report.find_actionable_targets', request: {} }, + + // ---- delivery log --------------------------------------------------------------- + { + op: 'delivery_log.append', + request: { + entry: { + outbox_id: null, + event_type: 'settings.changed', + target_id: 'target-http', + target_type: 'http', + status: 'success', + attempt_number: 1, + error_snippet: null, + }, + }, + }, + { + op: 'delivery_log.append', + request: { + entry: { + outbox_id: null, + event_type: 'relay.restarted', + target_id: 'target-discord', + target_type: 'discord', + status: 'failed', + attempt_number: 2, + error_snippet: 'connection refused', + }, + }, + }, + { op: 'delivery_log.find_recent', request: { limit: 10, filters: {} } }, + { + op: 'delivery_log.find_recent', + request: { limit: 10, filters: { status: 'success' } }, + }, + { + op: 'delivery_log.find_recent', + request: { limit: 10, filters: { event_type: 'relay.restarted' } }, + }, + { op: 'delivery_log.delete_older_than', request: { cutoff: '1970-01-02T00:00:00Z' } }, + + // ---- outbox --------------------------------------------------------------------- + { + op: 'outbox.enqueue', + request: { + event_type: 'admission.invoice.paid', + payload: { invoiceId: 'inv-1', amount: 1000 }, + }, + }, + { + op: 'outbox.enqueue', + request: { event_type: 'settings.changed', payload: { changed: ['nip50.enabled'] } }, + }, + { op: 'outbox.mark_failed', request: { enqueue_event_type: 'admission.invoice.paid', error: 'boom', attempt_count: 2, max_attempts: 5, base_delay_ms: 0 } }, + { op: 'outbox.claim_batch', request: { limit: 10 } }, + { op: 'outbox.mark_delivered', request: { enqueue_event_type: 'admission.invoice.paid' } }, + { op: 'outbox.claim_batch', request: { limit: 10 } }, + { op: 'outbox.delete_terminal_older_than', request: { cutoff: '2100-01-01T00:00:00Z' } }, + { op: 'outbox.claim_batch', request: { limit: 10 } }, + + // ---- serializable transaction groups -------------------------------------------- + { + op: 'transaction', + request: { + ops: [ + { op: 'user.upsert', request: { patch: { pubkey: PK_B, is_admitted: true, is_vanished: false, tos_accepted_at: { kind: 'set', value: '2024-05-01T00:00:00Z' } } } }, + { op: 'user.admit_user', request: { pubkey: PK_D, admitted_at: '2024-05-02T00:00:00Z' } }, + { op: 'invoice.find_by_id', request: { id: 'inv-2' } }, + { op: 'outbox.enqueue', request: { event_type: 'admission.invoice.created', payload: { invoiceId: 'inv-2' } } }, + ], + }, + }, + { + op: 'transaction', + request: { + ops: [ + { op: 'invoice.upsert', request: { invoice: { id: 'inv-3', pubkey: PK_C, bolt11: 'lnbc1invoicethree', amount_requested: 500, amount_paid: null, unit: 'btc', status: 'pending', description: 'third', confirmed_at: null, expires_at: null, created_at: '2024-05-03T00:00:00Z' } } }, + { op: 'user.upsert', request: { patch: { pubkey: PK_C, is_admitted: true, is_vanished: false, tos_accepted_at: { kind: 'clear' } } } }, + { op: 'invoice.confirm_invoice', request: { invoice_id: 'inv-3', amount_received: 1000000000, confirmed_at: '2024-05-04T00:00:00Z' } }, + ], + }, + }, + { op: 'invoice.find_by_id', request: { id: 'inv-3' } }, + { op: 'user.find_by_pubkey', request: { pubkey: PK_C } }, +] + +// --------------------------------------------------------------------------------------- +// Execution +// --------------------------------------------------------------------------------------- + +const collect = async (stream) => { + const rows = [] + for await (const row of stream) rows.push(row) + return rows +} + +const run = async (op, client, inTransaction = false) => { + const repository = { + master: client, + event: new EventRepository(client, client, getSettings), + user: new UserRepository(client, new EventRepository(client, client, getSettings)), + invoice: new InvoiceRepository(client), + nip05: new Nip05VerificationRepository(client), + invite: new InviteCodeRepository(client), + dvm: new DvmJobRepository(client), + report: new ReportRepository(client), + delivery: new NotificationDeliveryLogRepository(client), + outbox: new NotificationOutboxRepository(client), + } + const request = op.request + switch (op.op) { + case 'events.create': + return { compare: { count: await repository.event.create(eventFromJson(request.record)) } } + case 'events.create_many': + return { + compare: { count: await repository.event.createMany(request.records.map(eventFromJson)) }, + } + case 'events.upsert': + return { compare: { count: await repository.event.upsert(eventFromJson(request.record)) } } + case 'events.upsert_many': + return { + compare: { count: await repository.event.upsertMany(request.records.map(eventFromJson)) }, + } + case 'events.find_by_filters': { + if (request.filters.length === 0) { + let failed = false + try { + await repository.event.findByFilters([]) + } catch { + failed = true + } + return { compare: { error: failed } } + } + const rows = await collect(repository.event.findByFilters(request.filters).stream()) + return { compare: { rows: rows.map(eventRow) } } + } + case 'events.count_by_filters': + return { compare: { count: await repository.event.countByFilters(request.filters) } } + case 'events.find_reconciliation_items': { + const rows = await collect( + repository.event.findReconciliationItems(request.filter).stream(), + ) + return { compare: { items: rows.map((row) => [row.event_created_at, hex(row.event_id)]) } } + } + case 'events.delete_by_pubkey_and_ids': + return { + compare: { + count: await repository.event.deleteByPubkeyAndIds(request.pubkey, request.ids), + }, + } + case 'events.delete_by_pubkey_except_kinds': + return { + compare: { + count: await repository.event.deleteByPubkeyExceptKinds( + request.pubkey, + request.kinds, + ), + }, + } + case 'events.has_active_request_to_vanish': + return { + compare: { value: await repository.event.hasActiveRequestToVanish(request.pubkey) }, + } + case 'events.delete_expired_and_retained': { + const options = {} + if (request.options?.max_days !== undefined) options.maxDays = request.options.max_days + if (request.options?.kind_whitelist !== undefined) { + options.kindWhitelist = request.options.kind_whitelist + } + if (request.options?.pubkey_whitelist !== undefined) { + options.pubkeyWhitelist = request.options.pubkey_whitelist + } + const counts = await repository.event.deleteExpiredAndRetained(options) + return { compare: { ...counts } } + } + case 'user.find_by_pubkey': + return { compare: { user: userView(await repository.user.findByPubkey(request.pubkey)) } } + case 'user.upsert': { + const patch = { + pubkey: request.patch.pubkey, + isAdmitted: request.patch.is_admitted, + isVanished: request.patch.is_vanished, + } + const tos = fieldUpdateIn(request.patch.tos_accepted_at) + if (tos !== undefined) patch.tosAcceptedAt = tos === null ? null : date(tos) + return { compare: { count: await repository.user.upsert(patch) } } + } + case 'user.get_balance_by_pubkey': + return { + compare: { + balance: Number(await repository.user.getBalanceByPubkey(request.pubkey)), + }, + } + case 'user.is_vanished': + return { compare: { value: await repository.user.isVanished(request.pubkey) } } + case 'user.set_vanished': + return { + compare: { count: await repository.user.setVanished(request.pubkey, request.vanished) }, + } + case 'user.admit_user': + return { + compare: { + ok: await repository.user + .admitUser(request.pubkey, date(request.admitted_at)) + .then(() => true), + }, + } + case 'user.revoke_admission': + return { compare: { count: await repository.user.revokeAdmission(request.pubkey) } } + case 'invoice.find_by_id': + return { compare: { invoice: invoiceView(await repository.invoice.findById(request.id)) } } + case 'invoice.upsert': { + const invoice = { + id: request.invoice.id, + pubkey: request.invoice.pubkey, + bolt11: request.invoice.bolt11, + amountRequested: BigInt(request.invoice.amount_requested), + amountPaid: + request.invoice.amount_paid === null + ? undefined + : BigInt(request.invoice.amount_paid), + unit: request.invoice.unit, + status: request.invoice.status, + description: request.invoice.description, + confirmedAt: request.invoice.confirmed_at === null ? null : date(request.invoice.confirmed_at), + expiresAt: request.invoice.expires_at === null ? null : date(request.invoice.expires_at), + createdAt: date(request.invoice.created_at), + verifyURL: request.invoice.verify_url ?? undefined, + } + return { compare: { count: await repository.invoice.upsert(invoice) } } + } + case 'invoice.update_status': + return { + compare: { + invoice: invoiceView( + await repository.invoice.updateStatus({ id: request.id, status: request.status }), + ), + }, + } + case 'invoice.confirm_invoice': { + // The schema function asserts a serializable transaction; the capture's call sites + // always supply one, so a standalone replay opens its own. + const call = (trx) => + repository.invoice.confirmInvoice( + request.invoice_id, + BigInt(request.amount_received), + date(request.confirmed_at), + trx, + ) + if (inTransaction) { + await call(repository.master) + } else { + await db.transaction(call, { isolationLevel: 'serializable' }) + } + return { compare: { ok: true } } + } + case 'invoice.find_pending_invoices': + return { + compare: { + invoices: (await repository.invoice.findPendingInvoices(request.offset, request.limit)) + .map(invoiceView), + }, + } + case 'nip05.find_by_pubkey': + return { + compare: { verification: nip05View(await repository.nip05.findByPubkey(request.pubkey)) }, + } + case 'nip05.upsert': { + const verification = { + pubkey: request.verification.pubkey, + nip05: request.verification.nip05, + domain: request.verification.domain, + isVerified: request.verification.is_verified, + lastVerifiedAt: + request.verification.last_verified_at === null + ? null + : date(request.verification.last_verified_at), + lastCheckedAt: date(request.verification.last_checked_at), + failureCount: request.verification.failure_count, + createdAt: new Date(0), + updatedAt: new Date(0), + } + return { compare: { count: await repository.nip05.upsert(verification) } } + } + case 'nip05.find_pending_verifications': + return { + compare: { + verifications: ( + await repository.nip05.findPendingVerifications( + request.update_frequency_ms, + request.max_failures, + request.limit, + ) + ).map(nip05View), + }, + } + case 'nip05.delete_by_pubkey': + return { compare: { count: await repository.nip05.deleteByPubkey(request.pubkey) } } + case 'invite.create': { + const options = { + expiresAt: request.options.expires_at == null ? null : date(request.options.expires_at), + remainingUses: fieldUpdateIn(request.options.remaining_uses ?? { kind: 'absent' }), + createdBy: request.options.created_by ?? null, + } + return { compare: { invite: inviteView(await repository.invite.create(request.code, options)) } } + } + case 'invite.find_by_code': { + const row = await repository.invite.findByCode(request.code) + return { compare: { invite: row === undefined ? null : inviteView(row) } } + } + case 'invite.claim_code': + return { + compare: { value: await repository.invite.claimCode(request.code, request.pubkey) }, + } + case 'invite.find_active_codes': + return { + compare: { + invites: (await repository.invite.findActiveCodes(request.limit)) + .map(inviteView) + .sort(byInviteCode), + }, + } + case 'invite.delete_expired_codes': + return { compare: { count: await repository.invite.deleteExpiredCodes() } } + case 'dvm.create': + return { + compare: { + job: dvmView(await repository.dvm.create(request.id, request.requester, request.kind)), + }, + } + case 'dvm.find_by_id': { + const row = await repository.dvm.findById(request.id) + return { compare: { job: row === undefined ? null : dvmView(row) } } + } + case 'dvm.assign_worker': + return { + compare: { + value: await repository.dvm.assignWorker(request.id, request.worker_index), + }, + } + case 'dvm.update_status': { + const update = { id: request.update.id, status: request.update.status } + const resultEventId = fieldUpdateIn(request.update.result_event_id) + if (resultEventId !== undefined) update.resultEventId = resultEventId + const error = fieldUpdateIn(request.update.error) + if (error !== undefined) update.error = error + const row = await repository.dvm.updateStatus(update) + return { compare: { job: row === undefined ? null : dvmView(row) } } + } + case 'dvm.find_pending_jobs': { + const rows = await repository.dvm.findPendingJobs(request.limit, request.kinds) + return { compare: { jobs: rows.map(dvmView).sort(byDvmId) } } + } + case 'report.create': { + const report = { + eventId: request.report.event_id, + reporterPubkey: request.report.reporter_pubkey, + reportedPubkey: request.report.reported_pubkey, + reportedEventId: request.report.reported_event_id, + reportType: request.report.report_type, + weight: request.report.weight, + actionable: request.report.actionable, + } + return { compare: { report: reportView(await repository.report.create(report)) } } + } + case 'report.create_many': { + const reports = request.reports.map((report) => ({ + eventId: report.event_id, + reporterPubkey: report.reporter_pubkey, + reportedPubkey: report.reported_pubkey, + reportedEventId: report.reported_event_id, + reportType: report.report_type, + weight: report.weight, + actionable: report.actionable, + })) + return { + compare: { + reports: (await repository.report.createMany(reports)).map(reportView).sort(byReportId), + }, + } + } + case 'report.find_by_event_id': + return { + compare: { + reports: (await repository.report.findByEventId(request.event_id)) + .map(reportView) + .sort(byReportId), + }, + } + case 'report.find_actionable': + return { + compare: { + reports: (await repository.report.findActionable(request.limit)) + .map(reportView) + .sort(byReportId), + }, + } + case 'report.find_actionable_targets': + return { + compare: { + targets: (await repository.report.findActionableTargets()) + .map((target) => ({ + reported_pubkey: target.reportedPubkey, + reported_event_id: target.reportedEventId, + })) + .sort(byKey((target) => `${target.reported_pubkey}/${target.reported_event_id}`)), + }, + } + case 'delivery_log.append': + return { + compare: { + ok: await repository.delivery + .append({ + outboxId: request.entry.outbox_id, + eventType: request.entry.event_type, + targetId: request.entry.target_id, + targetType: request.entry.target_type, + status: request.entry.status, + attemptNumber: request.entry.attempt_number, + errorSnippet: request.entry.error_snippet, + }) + .then(() => true), + }, + } + case 'delivery_log.find_recent': { + const filters = {} + if (request.filters.status) filters.status = request.filters.status + if (request.filters.event_type) filters.eventType = request.filters.event_type + return { + compare: { + entries: (await repository.delivery.findRecent(request.limit, filters)) + .map(deliveryView) + .sort(byDelivery), + }, + } + } + case 'delivery_log.find_successful_target_ids': + return { + compare: { + target_ids: ( + await repository.delivery.findSuccessfulTargetIds(request.outbox_id) + ).sort(), + }, + } + case 'delivery_log.delete_older_than': + return { + compare: { + count: await repository.delivery.deleteOlderThan(date(request.cutoff)), + }, + } + case 'outbox.enqueue': + return { + compare: { message: outboxView(await repository.outbox.enqueue(request.event_type, request.payload)) }, + ref: null, // filled below from the returned row + } + case 'outbox.claim_batch': + return { + compare: { + messages: (await repository.outbox.claimBatch(request.limit)).map(outboxView).sort(byOutboxType), + }, + } + case 'outbox.mark_delivered': + return { + compare: { + ok: await repository.outbox.markDelivered(resolveId(request)).then(() => true), + }, + } + case 'outbox.mark_failed': + return { + compare: { + ok: await repository.outbox + .markFailed( + resolveId(request), + request.error, + request.attempt_count, + request.max_attempts, + request.base_delay_ms, + ) + .then(() => true), + }, + } + case 'outbox.delete_terminal_older_than': + return { + compare: { + count: await repository.outbox.deleteTerminalOlderThan(date(request.cutoff)), + }, + } + default: + throw new Error(`unknown op ${op.op}`) + } +} + +// `enqueue_event_type` addresses the row an earlier enqueue created; both sides keep the id +// in a side channel, so the compared projection stays deterministic. +const refs = new Map() +const resolveId = (request) => { + const value = refs.get(request.enqueue_event_type) + if (!value) throw new Error(`no id ref for ${request.enqueue_event_type}`) + return value +} + +const runOp = async (op, client) => { + const result = await run(op, client) + if (op.op === 'outbox.enqueue') { + const message = result.compare.message + // The id is not part of the comparison; it is needed to address the row later. + const row = await client('notification_outbox') + .where({ event_type: message.event_type }) + .orderBy('created_at', 'desc') + .first() + result.ref = { id: row.id } + refs.set(message.event_type, row.id) + } + return result +} + +const runTransaction = async (op) => { + const executed = [] + let failedAt = null + try { + await db.transaction(async (trx) => { + for (const [index, nested] of op.request.ops.entries()) { + try { + executed.push(await run(nested, trx, true)) + } catch (error) { + failedAt = index + throw error + } + } + }, { isolationLevel: 'serializable' }) + } catch (error) { + if (failedAt === null) throw error + } + return failedAt === null + ? { compare: { failed: false, results: executed.map((entry) => entry.compare) } } + : { compare: { failed: true, at: failedAt } } +} + +// --------------------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------------------- + +const db = knex({ client: 'pg', connection: process.env.DATABASE_URI }) + +const main = async () => { + const [, applied] = await db.migrate.latest({ directory: path.join(cwd, 'migrations') }) + + // The corpus goes in through createMany, exactly as the Rust side inserts it. + const corpusRecords = corpus.map((entry) => { + const record = { event: entry.event } + if (entry.remote_address) record.remote_address = entry.remote_address + if (entry.expires_at) record.expires_at = entry.expires_at + if (entry.deduplication) record.deduplication = entry.deduplication + return record + }) + await new EventRepository(db, db, getSettings).createMany(corpusRecords.map(eventFromJson)) + + for (const op of script) { + op.result = op.op === 'transaction' ? await runTransaction(op) : await runOp(op, db) + } + + const excluded = ['knex_migrations', 'knex_migrations_lock', '_sqlx_migrations'] + const notIn = excluded.map((name) => `'${name}'`).join(',') + const queries = { + columns: ` + SELECT table_name, column_name, ordinal_position::int AS position, data_type, + udt_name, is_nullable, column_default, + character_maximum_length::int AS max_length, + numeric_precision::int AS numeric_precision, + numeric_scale::int AS numeric_scale, + datetime_precision::int AS datetime_precision + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name NOT IN (${notIn}) + ORDER BY table_name, ordinal_position`, + indexes: ` + SELECT tablename AS table_name, indexname AS index_name, indexdef AS definition + FROM pg_indexes + WHERE schemaname = 'public' AND tablename NOT IN (${notIn}) + ORDER BY tablename, indexname`, + constraints: ` + SELECT c.relname AS table_name, con.conname AS constraint_name, + pg_get_constraintdef(con.oid) AS definition + FROM pg_constraint con + JOIN pg_class c ON c.oid = con.conrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relname NOT IN (${notIn}) + ORDER BY c.relname, con.conname`, + functions: ` + SELECT p.proname AS name, pg_get_function_identity_arguments(p.oid) AS identity_arguments, + pg_get_functiondef(p.oid) AS definition + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e') + ORDER BY p.proname, pg_get_function_identity_arguments(p.oid)`, + triggers: ` + SELECT c.relname AS table_name, t.tgname AS trigger_name, pg_get_triggerdef(t.oid) AS definition + FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE NOT t.tgisinternal AND n.nspname = 'public' + ORDER BY c.relname, t.tgname`, + extensions: ` + SELECT extname AS name, extversion AS version FROM pg_extension ORDER BY extname`, + sequences: ` + SELECT sequence_name AS name, data_type, start_value::text, minimum_value::text, + maximum_value::text, increment::text, cycle_option + FROM information_schema.sequences + WHERE sequence_schema = 'public' + AND sequence_name NOT LIKE 'knex_migrations%' + ORDER BY sequence_name`, + } + + // Each entry is an array in the same field order the Rust test emits, so the fixture is a + // representation the replay can compare structurally. + const shapes = { + columns: [ + 'table_name', 'column_name', 'position', 'data_type', 'udt_name', 'is_nullable', + 'column_default', 'max_length', 'numeric_precision', 'numeric_scale', 'datetime_precision', + ], + indexes: ['table_name', 'index_name', 'definition'], + constraints: ['table_name', 'constraint_name', 'definition'], + functions: ['name', 'identity_arguments', 'definition'], + triggers: ['table_name', 'trigger_name', 'definition'], + extensions: ['name', 'version'], + sequences: [ + 'name', 'data_type', 'start_value', 'minimum_value', 'maximum_value', 'increment', + 'cycle_option', + ], + } + const schema = {} + for (const [key, sql] of Object.entries(queries)) { + schema[key] = (await db.raw(sql)).rows.map((row) => + shapes[key].map((field) => (row[field] === undefined ? null : row[field])), + ) + } + + const fixture = { + subject: { + repo: 'cameri/nostream', + capture_commit: '242a3001d77149e4d70151ec03060c4ef44a1547', + generator: 'crates/nostream-storage/tests/capture/gen-storage-parity.mjs', + generated_on: new Date().toISOString().slice(0, 10), + server_image: 'postgres:16-alpine (PostgreSQL 16.15)', + timezone: 'UTC', + migration_command: 'knex migrate:latest', + migration_count: applied.length, + }, + settings: { + overrides: Object.fromEntries(SETTINGS_OVERRIDES), + source: 'resources/default-settings.yaml', + }, + corpus, + script: script.map((op) => ({ op: op.op, request: op.request, result: op.result })), + projection: { + event_row: '[event_id, created_at, expires_at, deleted_at.is_some(), event_deduplication]', + dropped_timestamps: + 'first_seen, updated_at, created_at of rows whose created_at the call generated, ' + + 'delivered_at, available_at, picked_up_at, completed_at', + sorted_lists: + 'reports (by id), dvm jobs (by id), invite codes (by code), delivery log ' + + '(by target_id/attempt_number), outbox messages (by event_type)', + transaction: + 'a group result is {failed, at} or {failed, results}; the failing op is ' + + 'confirm_invoice with an amount that overflows int8', + }, + } + + fs.mkdirSync(outDir, { recursive: true }) + fs.writeFileSync(path.join(outDir, 'schema.json'), JSON.stringify(schema, null, 2) + '\n') + fs.writeFileSync( + path.join(outDir, 'repository-parity.json'), + JSON.stringify(fixture, null, 2) + '\n', + ) + console.log( + `wrote schema.json and repository-parity.json (${script.length} ops, ` + + `${corpus.length} corpus events, ${applied.length} migrations)`, + ) + await db.destroy() +} + +main().catch(async (error) => { + console.error(error) + await db.destroy() + process.exit(1) +}) diff --git a/crates/nostream-storage/tests/fixtures/repository-parity.json b/crates/nostream-storage/tests/fixtures/repository-parity.json new file mode 100644 index 00000000..81effc0b --- /dev/null +++ b/crates/nostream-storage/tests/fixtures/repository-parity.json @@ -0,0 +1,11567 @@ +{ + "subject": { + "repo": "cameri/nostream", + "capture_commit": "242a3001d77149e4d70151ec03060c4ef44a1547", + "generator": "crates/nostream-storage/tests/capture/gen-storage-parity.mjs", + "generated_on": "2026-10-19", + "server_image": "postgres:16-alpine (PostgreSQL 16.15)", + "timezone": "UTC", + "migration_command": "knex migrate:latest", + "migration_count": 37 + }, + "settings": { + "overrides": { + "nip50.enabled": true, + "nip50.language": "simple", + "nip50.maxQueryLength": 256, + "nip56.enabled": true, + "nip56.hideActionableReports": true + }, + "source": "resources/default-settings.yaml" + }, + "corpus": [ + { + "source": "seeds/events.json", + "event": { + "id": "0d684e8ec2431de586aa3cafbee2f6d308d19b28805e53deabcac3220e9136a5", + "pubkey": "f43c1f9bff677b8f27b602725ea0ad51af221344f69a6b352a74991a4479bac3", + "created_at": 1660407625, + "kind": 3, + "tags": [ + [ + "p", + "b34417513f66497d7b0e1a8406b6689ac32afb184027717e57d281ea19186315", + "wss://nostr-relay.untethr.me" + ], + [ + "p", + "2ef93f01cd2493e04235a6b87b10d3c4a74e2a7eb7c3caf168268f6af73314b5", + "wss://nostr.rocks" + ], + [ + "p", + "13e7f234ef71ffd63fdf3fec4eaec6fdea9bb850a37ba1a854a62b934c97855e", + "wss://nostr-relay.untethr.me" + ], + [ + "p", + "40e162e0a8d139c9ef1d1bcba5265d1953be1381fb4acd227d8f3c391f9b9486", + "wss://nostr-relay.untethr.me" + ], + [ + "p", + "42a0825e980b9f97943d2501d99c3a3859d4e68cd6028c02afe58f96ba661a9d", + "wss://nostr-relay.untethr.me" + ], + [ + "p", + "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "ed04f9c719af697ac1c045bfff5f841cdf61a0b0d2170c9970f0ce0a04f708bf", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "76f5960d381e7146b7f374a4a65afa403038441b46933840c71e436facb82ae7", + "wss://nostr.bitcoiner.social" + ], + [ + "p", + "c697f7f5f59de8ddb93c6b74fdd759ab2dc654bc36315f39770c214607fcd65e", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "d3fe840f672c191849f8500762d81af8a258e673b7ff07cf9ce1211c2d0f493d", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "14347702b99786cc0ee644620a5f71bc6a88e2882491f57c372f1deaed198701", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "cbc5ef6b01cbd1ffa2cb95a954f04c385a936c1a86e1bb9ccdf2cf0f4ebeaccb", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "8ff7a6132ffe1bb3600aa20496ab648f1daf6b50ceaa8054a37e6a0b1f7ee491", + "wss://nostr.bitcoiner.social" + ], + [ + "p", + "1f7dfb1b51bd4fb5d15245b28d86fab670a677580e2a0633a2cf76509d02471c", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "f5424d002fd0d48fadd6e54879387714c54bfa46535976ff2b385843aaddf8e5", + "wss://nostr.rocks" + ], + [ + "p", + "9aeb3bb495f09be3799048c3ef76649917efc46a8c8a69fefc31a7d012f6eccb", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "c181af1aca3a13243a9ef9c302d5e988eaec25caa60c9923e5faed097e52cd69", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "a4cb51f4618cfcd16b2d3171c466179bed8e197c43b8598823b04de266cef110", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "1221fd0054a6c8ebd07b39c5eeea388f7f0244409f8cd8649ac22fcd668d02f6", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "f61abb9886e1f4cd5d20419c197d5d7f3649addab24b6a32a2367124ca3194b4", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "b175db709771d32bbe7d8599e0c41f3f8768cc3a8333603d93c6d72d41c42f76", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "57225e0adcbad1fddf8d9ba1f5f36d657f134b7e0ea7aed6c0eb7013e4ef45f1", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "6446d04ecf9e0bb72c5ae218df9fc6c0a273149d9ecbfbe42519c53667b4405a", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "e0d05a5b8c7789eb83f87672f4eb0dca78f99292ab038e5c66f84d97d77b95ae", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "4d5ce768123563bc583697db5e84841fb528f7b708d966f2e546286ce3c72077" + ], + [ + "p", + "8d233d8babe9f40f170c5b0706fd4832869e07d040cfcd6b702d57e070aad1cb", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "566516663d91d4fef824eaeccbf9c2631a8d8a2efee8048ca5ee6095e6e5c843", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "0000a0fa65fcccd99e6fd32fc7870339af40f4a94703ea30999fc5c091daa222", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "d987084c48390a290f5d2a34603ae64f55137d9b4affced8c0eae030eb222a25" + ], + [ + "p", + "3878d95db7b854c3a0d3b2d6b7bf9bf28b36162be64326f5521ba71cf3b45a69", + "wss://nostr.rocks" + ], + [ + "p", + "7f0be893dc501f391260aa2088de28b35280dfd4ae8f8bfa9bdbb7319952755b" + ], + [ + "p", + "44c39a01cbdeb70905aaa9cbd614a1ef39d0f4386d0dee9d7493e6e680548eb9", + "wss://nostr-relay.untethr.me" + ], + [ + "p", + "484712e818a8373182c64e53c0d1fb9cec5de96daa2d39424b42d7b0dcd8e6c9" + ], + [ + "p", + "b2222fc7844fef7b002440b3216213d9b01dcf5e412a604ddfa50967db4d8bd6", + "wss://nostr-relay.untethr.me" + ], + [ + "p", + "78aa0c9a0fe2d2476469db25f19a293a6606c113fe2e87e17b8ab51cb120dbb7" + ], + [ + "p", + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "wss://nostr.rocks" + ], + [ + "p", + "57f03c1604d109be088dbac71371b6939833dd24fdcf2886d3382a0479c0d4de" + ], + [ + "p", + "778fdd199044a2e8dc3cfac3c274f5577ed78c22fb3b5ccb13df6956980eff4c" + ], + [ + "p", + "e76e705283775febf3d5f4f97662648582d42ff822435924f21a47c8d46c5921", + "wss://nostr-relay.untethr.me" + ], + [ + "p", + "e794d71b8f7426a291004f592b758438a25d0012e5bb969e53307b3785fd5211", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "88a2c3b420b4a027706a98600d1fd744ac6cfd12e201b74189be5ef4b2b3aa45", + "wss://nostr-relay.untethr.me" + ], + [ + "p", + "004db7605cfeba09b15625deb77c9369029f370591d68231b7c4dfd43f8f6f4f" + ], + [ + "p", + "b238e136091cb01cd21606dac1a2f503f504e7e8e7c75d98fcefd30aed084a1c", + "wss://nostr-relay.untethr.me" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a" + ], + [ + "p", + "dd81a8bacbab0b5c3007d1672fb8301383b4e9583d431835985057223eb298a5" + ], + [ + "p", + "ed1d0e1f743a7d19aa2dfb0162df73bacdbc699f67cc55bb91a98c35f7deac69" + ], + [ + "p", + "b2d1d0fc5b771a7041054ebded57bc3bf20f69ccbb9dc9b8ef432801d247df7c" + ], + [ + "p", + "d947d8f1be338c5cff194a6630453fa43c924eb9f58c339c68b26b2193efa276" + ], + [ + "p", + "6112a73a50518ed631dc6804a238525acdf10f26343199bc25ed7c9f5a0685c5" + ], + [ + "p", + "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793" + ], + [ + "p", + "1bbb8324577ac089607e45813bac499ebdab4621d029f8c02b2c82b4410fd3f4" + ], + [ + "p", + "e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216" + ], + [ + "p", + "2508ed2c2ab3f6728a880fafbc0895a2afeacbb74eb69847255fb60564af0d85" + ], + [ + "p", + "c2bb5d6529095edbfbdbe3f136175c146c6706526325b32da881c7c34c7b1ab8" + ], + [ + "p", + "8f87ac34eb27a86fc917866fbc9016429bd89cf1d0d27a038a8eaac4c62c63e5" + ], + [ + "p", + "52cb4b34775fa781b6a964bda0432dbcdfede7a59bf8dfc279cbff0ad8fb09ff" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ], + [ + "p", + "7e88f589d2677ea4a863c72af5d0e85fbe1d3db111667c50d33fa42196a1afc0" + ], + [ + "p", + "f0bed2e11260f0f77f781db928f40a34c18713fda1918d3be996f91d0776e985" + ], + [ + "p", + "565152b2d1793a253cba282588a4b287b0ab2acbe7faa7021ea0dced39d33716" + ], + [ + "p", + "d9c8c00017a2a345c2f32132436a26e1c72cb7a57e7b6b316f62dee2f8bcf8dd" + ], + [ + "p", + "b28a0714f86fd344a7ecad9566c2e33f8485ef560a702e15c3f537914abc152d" + ], + [ + "p", + "7e7272c475d920ad408e7a6faf9a123aa7b882cba7151e6105a0fc9d212fb240" + ], + [ + "p", + "ea42658e9a1291a32d1b74793edaef3d8757589a32b16931cacd85ba5470ea7c" + ], + [ + "p", + "aff9a9f017f32b2e8b60754a4102db9d9cf9ff2b967804b50e070780aa45c9a8" + ], + [ + "p", + "b74848fa6f8975f00b04ce12ccbe18673ad1f4511f66d4e5a3a151720fdce62a" + ], + [ + "p", + "7e3b8e221023e92c297cb35937d88e495de780ac3190c23e1e2e1e6274f43f59" + ], + [ + "p", + "547fcc5c7e655fe7c83da5a812e6332f0a4779c87bf540d8e75a4edbbf36fe4a" + ], + [ + "p", + "a12535e8bf4f712211b68f7fe7303d03c3c5cfe8155116d553fe6b8adba85d41" + ], + [ + "p", + "772405d14585d9d8fe481cef6ce560b83f03c24f0efc179415530d54eee97534" + ], + [ + "p", + "2163edbd81fa58e64c7e38bf968dda1b2f42811b78ea06accd32007bbb8a018b" + ], + [ + "p", + "e37d948a0eee45e6cd113faaad934fcf17a97de2236c655b70650d4252daa9d3" + ], + [ + "p", + "e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2" + ], + [ + "p", + "4557aab9aae76a892e01568064a9e262e613690421a79e584b8cc4c5ca9afb7e" + ], + [ + "p", + "9630f464cca6a5147aa8a35f0bcdd3ce485324e732fd39e09233b1d848238f31" + ], + [ + "p", + "1265c1c3d41f0f05bf306224ec40628231a5086a2eaa36643b3982a4eba19c9f" + ], + [ + "p", + "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9" + ], + [ + "p", + "d3646691ba5b1d796c1e1b3430df00fe1189ec9c232877adde18c8f656af18f0" + ], + [ + "p", + "b7c66ce6f7bbe034e96be54c2ffc0adf631a889abc0834ba1431171b67c489aa" + ], + [ + "p", + "8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9" + ], + [ + "p", + "06fca9f06f74cf86a16fe4c2feec508700643e2b105b519fd93d35332c51ad53" + ], + [ + "p", + "6b0d4c8d9dc59e110d380b0429a02891f1341a0fa2ba1b1cf83a3db4d47e3964" + ], + [ + "p", + "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f" + ], + [ + "p", + "dcecb5c4c228e15a1f04305c34b39b7ff67675544cb7dc74dd5c715cf62ada74" + ], + [ + "p", + "b2c61317687060b2b7e9cb7f7fde04f30bab23e12bf471f8d356000ca2b12b4a" + ], + [ + "p", + "51fc7209201b1414f721c3d2d2b3430699b1e6317716c5182cc1d7945072e358" + ], + [ + "p", + "ce5061bfcc16476b9bde3f1d5b3ec7730c4361cf8c827fbd9c14eb8c7003a1de" + ], + [ + "p", + "0810b5bc4cddc3e7624a1f6acbdccdc95c6e9409c144ce83365ee04a3a63314e" + ], + [ + "p", + "975bbd239f0b7e25a080675d3db5892492ea9e9c7705c819ba3dafd8de95f3d9" + ], + [ + "p", + "76f928b303b095a6f17784151acd9a5127d183cb5f989a173b00bd0c12d07e83" + ], + [ + "p", + "d4d4fdde8ab4924b1e452e896709a3bd236da4c0576274b52af5992d4d34762c" + ], + [ + "p", + "ac9ec020170155f0feb347f0d777ee5fc38dd1f36353093046323646cff5169f" + ], + [ + "p", + "d91191e30e00444b942c0e82cad470b32af171764c2275bee0bd99377efd4075" + ], + [ + "p", + "ea75802dd1c86933c1e20c582541bb283d44c88e3445ed90d4375fc3d973f3a0" + ], + [ + "p", + "9682c33f9024dadb1bffdf762c3156e26b4aa340de8d06c91ca537fcc0fdb3a9" + ], + [ + "p", + "a8f14f05c64f9e62bdada89c21a52f09aa5d7948b47ccf52da1be16b0de9efac" + ], + [ + "p", + "80482e60178c2ce996da6d67577f56a2b2c47ccb1c84c81f2b7960637cb71b78" + ], + [ + "p", + "b10c0000079a83cf26815dc7538818d8d56a2983e374e30a4143e50060978457" + ], + [ + "p", + "ae683cd251952448ad0d7b8ed6c2e0f8ab451578250cb35f0c977275b56b056e" + ], + [ + "p", + "954aaf69c2e7c9fb3f9998f61944ab8ab08ce3c8679ecd985e4486a6eb696217" + ], + [ + "p", + "d7f0e3917c466f1e2233e9624fbd6d4bd1392dbcfcaf3574f457569d496cb731" + ], + [ + "p", + "104749bc9151a0e54b9845ee50fc4b559439dd1ada006e36a6c49ad3ea16a55c" + ], + [ + "p", + "cf9413eb6bbe55c8a3c10119ec0635e134fa266f2c50f825d7225da9b92ecc4e" + ], + [ + "p", + "bae77874946ec111f94be59aef282de092dc4baf213f8ecb8c9e15cb7ed7304e" + ], + [ + "p", + "44bb2dd1615ed2a527946c41d854995f18866a8feffa88eb375728c20aeea30c" + ], + [ + "p", + "62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49" + ], + [ + "p", + "9a29ee8c3771573e5306bb7701182e970b188ce3552713ca68a157ebc3c0bf75" + ], + [ + "p", + "e3f0c72e7b653f395f64e03519bae3efeac184bcf0b3f38bdccb62a4d2aa5d30" + ], + [ + "p", + "9b9f5f1ec13105c8d1c2ea16aa952e98640b170b871420980ea11b18eb1f1e03" + ], + [ + "p", + "2b36fb6ae1022d0d4eac2a9f13fc2638f3350acc9b07bdca1de43a7c63429644" + ], + [ + "p", + "f00c952da33c06e02c930f76aba1085021b98075657daaff8ad119edcfde691e" + ], + [ + "p", + "8837f562e064282e4fb9902ae6062ee436a53236909a68c6d19564df6c208fbe" + ], + [ + "p", + "f43c1f9bff677b8f27b602725ea0ad51af221344f69a6b352a74991a4479bac3" + ], + [ + "p", + "66e346dfe3a4e572359519f086bf45771a19224343183aa1c86b9f9e31b78ac9" + ], + [ + "p", + "8c24f2bf7df33aea0f05706162176343f34389d95ca5696dba1c2768887f586f" + ], + [ + "p", + "343558f07b07ffcb24b27b73812d74d4ff8f46e81ea903f1e7f37d30d907bcfc" + ], + [ + "p", + "57400e5b11c8b52ed04765df605fe9c30aa50abdeacff49d3de6b58359c907ed" + ], + [ + "p", + "4535551a40271b059ab92b71e7ab7e8700061a2d91b0d20f313ef82f052eb085" + ], + [ + "p", + "8431af1a305fd23b869a12ad87118f78d87bec6e2a431e38fd1fabdac281ff45" + ], + [ + "p", + "4b12f6132a5ba813bdf55bcbf9d1acfefb02dabf67191dad71b455668c429b36" + ], + [ + "p", + "747adf8e9036ed78b47eca762bf80bc41af34df6da7bd44876cf2d27e6b7dd64" + ], + [ + "p", + "b832d7fdcf4f6fed87ccfc6e10426710b968d6c260206fecb24aa096879c44ce" + ], + [ + "p", + "09e935f7c01fda340051a4700cfb9dde533202bdf56808f68cafef6bae07a5bd" + ], + [ + "p", + "2b26251002f9bdd990da1990bcc378ac5c816f1446e82167819ab60c4b9a6ca9" + ], + [ + "p", + "2183e94758481d0f124fbd93c56ccaa45e7e545ceeb8d52848f98253f497b975" + ], + [ + "p", + "2bee8a0f48dcc76df4385df95ee184331e41fbde0731164c6627512b9b34f005" + ], + [ + "p", + "d0cb47a354003467a3a7cbc50ddc0c29250851f9040656bad9d0ab7adb5b7382" + ], + [ + "p", + "47bae3a008414e24b4d91c8c170f7fce777dedc6780a462d010761dca6482327" + ], + [ + "p", + "38b07a31f3b23dbeb9f59deb7bec5b993173fb4022206980f3809d0b68abf959" + ], + [ + "p", + "e6a92d8b6c20426f78bba8510ccdc73df5122814a3bac1d553adebac67a92b27" + ], + [ + "p", + "ad5aab5be883a571ea37b231cd996d37522e77d0f121cedfd6787b91d848268e" + ], + [ + "p", + "6d334336f9ba6c35fdc3b87950721b123f56f0d686fe9a5b4c95d2568b2398d8" + ], + [ + "p", + "c8b430569a2c95aa8d6eceea67f40c16e17f1ac10755fcf17f2ba772f3febd96" + ], + [ + "p", + "3b6a202702bc8c236ff2900aa564575fe56ae5a9e5b8386d3307c79b392674ab" + ], + [ + "p", + "d97cd1bcc21e393e5a8b053fba9db385ace78710ba68a6bc7828d57ad82e88bd" + ], + [ + "p", + "3235036bd0957dfb27ccda02d452d7c763be40c91a1ac082ba6983b25238388c" + ], + [ + "p", + "b99b149370e4f8533ce53d143af3f39e1f2628a39847f7fdd7544c9585da9299" + ], + [ + "p", + "e4c47aedea8ea54255f5ba07a77053b24553e9b975435e56da343da19aec7881" + ], + [ + "p", + "552b4d02f9db02f11bda4b4c1cdefe8852c6c6b6ca0e03b7013f182c854413b7" + ], + [ + "p", + "3f8e32d654fbc0da5fd570d70381a3e59843b208c5574a74a2305527bce8382b" + ], + [ + "p", + "84620a7b6a3d42b96b3e8a392fabca1e476e9049188808b0ecf3d64d36efffd1" + ], + [ + "p", + "047f497e13073d4303383c7abcc296a3b5b5956d243eafa6423c675a831a5cc1" + ], + [ + "p", + "b875065f96ff58c82e951f543857515798f5e50c6903d9602b425e2cd957f1ce" + ], + [ + "p", + "edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29" + ], + [ + "p", + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" + ], + [ + "p", + "bc1f8b83991f46f6f2f2b4569314d50b229e9f2761716ca56d4572a190801a44" + ], + [ + "p", + "84fe3febc748470ff1a363db8a375ffa1ff86603f2653d1c3c311ad0a70b5d0c" + ], + [ + "p", + "d543c820050efd6d2c1536b0990111ac293a4431e6a12929432366e0aa8001e7" + ], + [ + "p", + "7cf68b47a2b243d06322bfdb6a1c2422fb8b3a18d18a5c90c27b59e8f612553e" + ], + [ + "p", + "f0c864cf573de171053bef4df3b31c6593337a097fbbd9f20d78506e490c6b64" + ], + [ + "p", + "3702743c98430ba152e635b081637716a3c949c13ad3ad1e6c80e6e7d41fbc8a" + ], + [ + "p", + "2a043132d98c2457fb3581fdeddab380a8eda3760b2605f676be5059ed260066" + ], + [ + "p", + "c5072866b41d6b88ab2ffee16ad7cb648f940867371a7808aaa94cf7d01f4188" + ], + [ + "p", + "51535ad9f0e13a810f73ea8829a79b3733bd1fffb767c4885990b02f59103a13" + ], + [ + "p", + "3707f1efc7515524dce41d3bf50bfd9fdaed3494620b5f94fcf16d2766da4ec2" + ], + [ + "p", + "dbab9040bc1f0c436b0f92f517702498358edc1fde2c7884d0e1036c739d44f3" + ], + [ + "p", + "904ea00a4a245559d6184be5c6e2cf2c66ea7fc91eb5f1eb5349506d19d63a11" + ], + [ + "p", + "9ac12013d20fae4f8829ba4e5ba6343e410288d3a0752d6143386d2c1af1f57e" + ], + [ + "p", + "7bc0ff3de7b2205ed8bc366f7657138eacb5164d43d9580b8f5b47b7e6a7c235" + ], + [ + "p", + "c5cfda98d01f152b3493d995eed4cdb4d9e55a973925f6f9ea24769a5a21e778" + ], + [ + "p", + "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072" + ], + [ + "p", + "88a502f72f216c93eb840fa805c1a215b97e0800ab2dfa017450d38cb4b60a03" + ], + [ + "p", + "3f152ab665d1079108529ff6bf0ba48809b6788b22ab8a3d76f7a3f63bec19a0" + ], + [ + "p", + "27da3f032e0fea007947b0da12f1183630c5a2da79d7202b96f35f16ef6ce48e" + ], + [ + "p", + "de29897a4a9086a1c5e8f6c7d06691afeda77103eea35eabecbfda21189fa995" + ], + [ + "p", + "0a2bfced3f7c8a08d88a697da80d7d85f12e69260cf308de27da1f5b6f65bf00" + ], + [ + "p", + "95405f16211a88c869ec87b684cb450136b7bf2420e236f9ec793385893d01e8" + ], + [ + "p", + "f9e24c0a9544d119b4f0e31ceac53d1b650c763e378541e1dfde402e350f5792" + ], + [ + "p", + "7f3bd39154ce2994d67bc89b782c12871bcd7a30093b4700b07c438fb7b906db" + ], + [ + "p", + "1d914450975db68d850f13a8950abda9dc6a1b140de6460634f839c49f5de958" + ], + [ + "p", + "545320c902a7c7de8f44c6c3c0e7870b72e8ddfdd203139db18b5d518f6771c1" + ], + [ + "p", + "e740b0275f467618fdebf8ad54cb597deabbca2a0490d314e509730c50118499" + ], + [ + "p", + "179744407ac4fda143a8635e7ae9c9eabf3ab107a818a4f740a9e46b39412a42" + ], + [ + "p", + "2d11d3a3123287b478e19e9ef011bceb48e8f14a0d58e22bd156f35a839c5640" + ], + [ + "p", + "ce5a47f6328beab97310a27269c4725988ced2aec93fcd3ab01282f667d696c3" + ], + [ + "p", + "c7eda660a6bc8270530e82b4a7712acdea2e31dc0a56f8dc955ac009efd97c86" + ], + [ + "p", + "e8caa2028a7090ffa85f1afee67451b309ba2f9dee655ec8f7e0a02c29388180" + ], + [ + "p", + "9c8e6bcf8438812fe44ccd32ba4208b3c72193a944d7e6f68ff311b48a28523e" + ], + [ + "p", + "7215b2db8754494fd3452b7f2d28b56e23863b95446bf68d79f980a7ad5ec7cd" + ] + ], + "content": "{\"wss://rsslay.fiatjaf.com\":{\"read\":true,\"write\":false},\"wss://nostr-pub.wellorder.net\":{\"read\":true,\"write\":true},\"wss://expensive-relay.fiatjaf.com\":{\"read\":true,\"write\":true},\"wss://nostr.rocks\":{\"read\":true,\"write\":true},\"wss://nostr-relay.untethr.me\\t\":{\"read\":true,\"write\":true},\"wss://relayer.fiatjaf.com\":{\"read\":true,\"write\":true},\"wss://nostr-relay.untethr.me\":{\"read\":true,\"write\":true},\"wss://nostr-relay.wlvs.space\":{\"read\":true,\"write\":true},\"wss://nostr.openchain.fr\":{\"read\":true,\"write\":true},\"wss://relay.futohq.com\":{\"read\":true,\"write\":true}}", + "sig": "f5935788cf7a5a402b14f3199f2ecb2f181f710a475693f2866fe3cd8bdaf900ec9edb9f831d23783023e0aa9011fe403fbaa4e4c93562d56ac8f463fd201e3d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2e6dcaa6f7767b2f0ad7756e5bb19145dcd9817beb078ca7478154ad4fad54cd", + "pubkey": "42a0825e980b9f97943d2501d99c3a3859d4e68cd6028c02afe58f96ba661a9d", + "created_at": 1660400296, + "kind": 3, + "tags": [ + [ + "p", + "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681" + ], + [ + "p", + "42a0825e980b9f97943d2501d99c3a3859d4e68cd6028c02afe58f96ba661a9d" + ], + [ + "p", + "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + ], + [ + "p", + "2ef93f01cd2493e04235a6b87b10d3c4a74e2a7eb7c3caf168268f6af73314b5" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "2a043132d98c2457fb3581fdeddab380a8eda3760b2605f676be5059ed260066" + ], + [ + "p", + "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f" + ], + [ + "p", + "aff9a9f017f32b2e8b60754a4102db9d9cf9ff2b967804b50e070780aa45c9a8" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ], + [ + "p", + "9ac12013d20fae4f8829ba4e5ba6343e410288d3a0752d6143386d2c1af1f57e" + ], + [ + "p", + "d7f0e3917c466f1e2233e9624fbd6d4bd1392dbcfcaf3574f457569d496cb731" + ], + [ + "p", + "47bae3a008414e24b4d91c8c170f7fce777dedc6780a462d010761dca6482327" + ], + [ + "p", + "dd81a8bacbab0b5c3007d1672fb8301383b4e9583d431835985057223eb298a5" + ], + [ + "p", + "c5072866b41d6b88ab2ffee16ad7cb648f940867371a7808aaa94cf7d01f4188" + ], + [ + "p", + "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd" + ], + [ + "p", + "80482e60178c2ce996da6d67577f56a2b2c47ccb1c84c81f2b7960637cb71b78" + ], + [ + "p", + "e37d948a0eee45e6cd113faaad934fcf17a97de2236c655b70650d4252daa9d3" + ], + [ + "p", + "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793" + ], + [ + "p", + "4570d7a0b49b5524797120810116a2a5c18281423b173a557056f08f15c5382d" + ], + [ + "p", + "4535551a40271b059ab92b71e7ab7e8700061a2d91b0d20f313ef82f052eb085" + ], + [ + "p", + "edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29" + ], + [ + "p", + "4b12f6132a5ba813bdf55bcbf9d1acfefb02dabf67191dad71b455668c429b36" + ], + [ + "p", + "f5424d002fd0d48fadd6e54879387714c54bfa46535976ff2b385843aaddf8e5" + ], + [ + "p", + "047f497e13073d4303383c7abcc296a3b5b5956d243eafa6423c675a831a5cc1" + ], + [ + "p", + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" + ], + [ + "p", + "f43c1f9bff677b8f27b602725ea0ad51af221344f69a6b352a74991a4479bac3" + ], + [ + "p", + "d987084c48390a290f5d2a34603ae64f55137d9b4affced8c0eae030eb222a25" + ], + [ + "p", + "c7eda660a6bc8270530e82b4a7712acdea2e31dc0a56f8dc955ac009efd97c86" + ] + ], + "content": "{\"wss:\\/\\/nostr.bitcoiner.social\":{\"write\":true,\"read\":true},\"wss:\\/\\/relay.damus.io\":{\"write\":true,\"read\":true},\"wss:\\/\\/nostr.onsats.org\":{\"write\":true,\"read\":true},\"wss:\\/\\/relay.zerosequioso.com\":{\"write\":true,\"read\":true},\"wss:\\/\\/relay.nostr.info\":{\"write\":true,\"read\":true}}", + "sig": "8d97d1027d28c0492c0d4733405e9680b8eea332418a3f5e65c13ba6d2c4e2db38c4d405cf06d144316a788cb9a7c6805e28aef286c775981872c922e6d2cbfd" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "92242fb2c2d2c8228fad83d54caeaea3b7b596bd2413cbc840c91763e276edcb", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1659850395, + "kind": 3, + "tags": [ + [ + "p", + "6cad545430904b84a8101c5783b65043f19ae29d2da1076b8fc3e64892736f03", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "2ef93f01cd2493e04235a6b87b10d3c4a74e2a7eb7c3caf168268f6af73314b5", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "ed1d0e1f743a7d19aa2dfb0162df73bacdbc699f67cc55bb91a98c35f7deac69", + "wss://nostr.rocks" + ], + [ + "p", + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "wss://nostr.bitcoiner.social" + ], + [ + "p", + "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a", + "wss://nostr-relay.untethr.me" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + "wss://nostr-relay.freeberty.net" + ], + [ + "p", + "e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "7927bc6e25892729a9c02a1332c409a69b285e143b9d845c54fd9c1fe829e25e", + "wss://nostr-relay.freeberty.net" + ], + [ + "p", + "3185f1d041ce283e0eb055eae5218552864fd8d66662594180d4beb439062d59", + "wss://nostr-relay.freeberty.net" + ], + [ + "p", + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "7e7272c475d920ad408e7a6faf9a123aa7b882cba7151e6105a0fc9d212fb240", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "b238e136091cb01cd21606dac1a2f503f504e7e8e7c75d98fcefd30aed084a1c", + "wss://nostr-relay.freeberty.net" + ], + [ + "p", + "4557aab9aae76a892e01568064a9e262e613690421a79e584b8cc4c5ca9afb7e", + "wss://nostr-relay.freeberty.net" + ], + [ + "p", + "21763c71793764a2661eb10ede32a8f2312c9f8db08bc539c888bafa38dcf368" + ], + [ + "p", + "9630f464cca6a5147aa8a35f0bcdd3ce485324e732fd39e09233b1d848238f31" + ], + [ + "p", + "b457120b6cfb2589d48718f2ab71362dd0db43e13266771725129d35cc602dbe", + "wss://nostr-relay.freeberty.net" + ], + [ + "p", + "b1dd5e8ed19644671e8693ca2445c68729249f6d4f2d2d8f072d5e1399ba7ecb", + "wss://nostr-relay.freeberty.net" + ], + [ + "p", + "1265c1c3d41f0f05bf306224ec40628231a5086a2eaa36643b3982a4eba19c9f", + "wss://nostr.bitcoiner.social" + ], + [ + "p", + "4570d7a0b49b5524797120810116a2a5c18281423b173a557056f08f15c5382d", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", + "wss://nostr-relay.freeberty.net" + ], + [ + "p", + "13fd7010166391fa485f077e57d84153613039ed9d2319b555b50458e55beff0", + "wss://nostr-relay.untethr.me" + ], + [ + "p", + "d3646691ba5b1d796c1e1b3430df00fe1189ec9c232877adde18c8f656af18f0" + ], + [ + "p", + "07c8e6897131165bb55155377b37d5dd7e0ed77f12890d250161a0ba0aabedfb" + ], + [ + "p", + "34af16f1787b261331a67fa519e9edb11f0a851c0b10a15901720b0fe0a06d1d" + ], + [ + "p", + "b7c66ce6f7bbe034e96be54c2ffc0adf631a889abc0834ba1431171b67c489aa" + ], + [ + "p", + "2183e94758481d0f124fbd93c56ccaa45e7e545ceeb8d52848f98253f497b975" + ], + [ + "p", + "8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9" + ], + [ + "p", + "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681" + ], + [ + "p", + "308a1a0d38f3f5ed18cfeaa8e8f9227d9ac5f4b46141cfea78e0d1dc37171500" + ], + [ + "p", + "6b0d4c8d9dc59e110d380b0429a02891f1341a0fa2ba1b1cf83a3db4d47e3964" + ], + [ + "p", + "06fca9f06f74cf86a16fe4c2feec508700643e2b105b519fd93d35332c51ad53" + ], + [ + "p", + "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f" + ], + [ + "p", + "dcecb5c4c228e15a1f04305c34b39b7ff67675544cb7dc74dd5c715cf62ada74" + ], + [ + "p", + "b2c61317687060b2b7e9cb7f7fde04f30bab23e12bf471f8d356000ca2b12b4a" + ], + [ + "p", + "0810b5bc4cddc3e7624a1f6acbdccdc95c6e9409c144ce83365ee04a3a63314e" + ], + [ + "p", + "51fc7209201b1414f721c3d2d2b3430699b1e6317716c5182cc1d7945072e358" + ], + [ + "p", + "e6a92d8b6c20426f78bba8510ccdc73df5122814a3bac1d553adebac67a92b27" + ], + [ + "p", + "ce5061bfcc16476b9bde3f1d5b3ec7730c4361cf8c827fbd9c14eb8c7003a1de" + ], + [ + "p", + "619d0c0483a311a16767b0a7999ce9f28e58e79eff66f0edafae9ca2d9054f08" + ], + [ + "p", + "d7b76d02c758a62a505e03bd5f5049aaee4e7e36283d273c7f6798912692df2b" + ], + [ + "p", + "8ba2a6b558eeb7fccd1862b905ae9d9408cfbc208f1680d1262733246e92d4da" + ], + [ + "p", + "975bbd239f0b7e25a080675d3db5892492ea9e9c7705c819ba3dafd8de95f3d9" + ], + [ + "p", + "abf12c3fad52ab1a5313fea429a5021ecb652b4e801d516c1c1f203f0c575983" + ], + [ + "p", + "76f928b303b095a6f17784151acd9a5127d183cb5f989a173b00bd0c12d07e83" + ], + [ + "p", + "d4d4fdde8ab4924b1e452e896709a3bd236da4c0576274b52af5992d4d34762c" + ], + [ + "p", + "7e88f589d2677ea4a863c72af5d0e85fbe1d3db111667c50d33fa42196a1afc0" + ], + [ + "p", + "3878d95db7b854c3a0d3b2d6b7bf9bf28b36162be64326f5521ba71cf3b45a69" + ], + [ + "p", + "ed04f9c719af697ac1c045bfff5f841cdf61a0b0d2170c9970f0ce0a04f708bf" + ], + [ + "p", + "d9c8c00017a2a345c2f32132436a26e1c72cb7a57e7b6b316f62dee2f8bcf8dd" + ], + [ + "p", + "ea42658e9a1291a32d1b74793edaef3d8757589a32b16931cacd85ba5470ea7c" + ], + [ + "p", + "aff9a9f017f32b2e8b60754a4102db9d9cf9ff2b967804b50e070780aa45c9a8" + ], + [ + "p", + "ac9ec020170155f0feb347f0d777ee5fc38dd1f36353093046323646cff5169f" + ], + [ + "p", + "d91191e30e00444b942c0e82cad470b32af171764c2275bee0bd99377efd4075" + ], + [ + "p", + "146bda4ec6932830503ee4f8e8b626bd7b3a5784232b8240ba15c8cbff9a07cd" + ], + [ + "p", + "8f6ddf42ebe9486308975de3f27cc783bed2d93d1f7b0804d5e7090c5fad93b6" + ], + [ + "p", + "ea75802dd1c86933c1e20c582541bb283d44c88e3445ed90d4375fc3d973f3a0" + ], + [ + "p", + "bc6f08b3630d1afbef42db40c6650ebc666de01312ddfe7c6443dc23fe9905c4" + ], + [ + "p", + "41108126409bf99cb77ff16fd53f4da2e53010b0dca04b0a53ebdf46eade37aa" + ], + [ + "p", + "778fdd199044a2e8dc3cfac3c274f5577ed78c22fb3b5ccb13df6956980eff4c" + ], + [ + "p", + "9682c33f9024dadb1bffdf762c3156e26b4aa340de8d06c91ca537fcc0fdb3a9" + ], + [ + "p", + "a8f14f05c64f9e62bdada89c21a52f09aa5d7948b47ccf52da1be16b0de9efac" + ], + [ + "p", + "80482e60178c2ce996da6d67577f56a2b2c47ccb1c84c81f2b7960637cb71b78" + ], + [ + "p", + "b10c0000079a83cf26815dc7538818d8d56a2983e374e30a4143e50060978457" + ], + [ + "p", + "547fcc5c7e655fe7c83da5a812e6332f0a4779c87bf540d8e75a4edbbf36fe4a" + ], + [ + "p", + "48bd69736fa9e0a322aa132ec7613313b84fd064319c4f0ef4fdb8a55a66ad09" + ], + [ + "p", + "dd81a8bacbab0b5c3007d1672fb8301383b4e9583d431835985057223eb298a5" + ], + [ + "p", + "bb1cf5250435ff475cd8b32acb23e3ee7bbe8fc38f6951704b4798513947672c" + ], + [ + "p", + "1fa91680ebfc68069ec13423fc8b9b0a746e9265584e16cf7d80be7ad721de6e" + ], + [ + "p", + "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd" + ], + [ + "p", + "b832d7fdcf4f6fed87ccfc6e10426710b968d6c260206fecb24aa096879c44ce" + ], + [ + "p", + "f6c3ea7afe17b41f48c0a8e509f9be3c5cdbb449d73efcc1d22e02c6f3ebf484" + ], + [ + "p", + "808ef58c7b30118467399a15d7c1dda5411c8b738c37070bbb14d95339743ae3" + ], + [ + "p", + "d8139f130cc1dec5d92e3a4dc49fb11f064bf5b32c65d96da107ba2389547dc7" + ], + [ + "p", + "e794d71b8f7426a291004f592b758438a25d0012e5bb969e53307b3785fd5211" + ], + [ + "p", + "9ac12013d20fae4f8829ba4e5ba6343e410288d3a0752d6143386d2c1af1f57e" + ], + [ + "p", + "ae683cd251952448ad0d7b8ed6c2e0f8ab451578250cb35f0c977275b56b056e" + ], + [ + "p", + "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + ], + [ + "p", + "b370efe1aca99ad03dee35668fcb5da5b6027778cfd22383d293a2cb07dcd748" + ], + [ + "p", + "a63655cea2abd24ef8523dc84ecb106ab39061cfc834dfbfe31575f605240c71" + ], + [ + "p", + "4b0572ab1f9a575415326b599f1179c20134bc23040d207156e71800b4ed33fe" + ], + [ + "p", + "2fa4b9ba71b6dab17c4723745bb7850dfdafcb6ae1a8642f76f9c64fa5f43436" + ], + [ + "p", + "bfc6af8244dc2859efdfd0e81a6a79f4ee395bc78acb3202b6f287a1ca3a27b3" + ], + [ + "p", + "898420028069bfdf1ad1ef5512dfc8fa71a2a8528028bf816c54169770cbf99b" + ], + [ + "p", + "428107e3b4b05df1e13c42b3bacb3fddf54c7ed12630e91870d5d8d0b4a091de" + ], + [ + "p", + "3235036bd0957dfb27ccda02d452d7c763be40c91a1ac082ba6983b25238388c" + ], + [ + "p", + "954aaf69c2e7c9fb3f9998f61944ab8ab08ce3c8679ecd985e4486a6eb696217" + ], + [ + "p", + "d7f0e3917c466f1e2233e9624fbd6d4bd1392dbcfcaf3574f457569d496cb731" + ], + [ + "p", + "84fe3febc748470ff1a363db8a375ffa1ff86603f2653d1c3c311ad0a70b5d0c" + ], + [ + "p", + "08b80da85ba68ac031885ea555ab42bb42231fde9b690bbd0f48c128dfbf8009" + ], + [ + "p", + "104749bc9151a0e54b9845ee50fc4b559439dd1ada006e36a6c49ad3ea16a55c" + ], + [ + "p", + "c47daa0cd21a70797fe9404f8fe0c3f679c2b46148788d1295e6424232064f1d" + ], + [ + "p", + "d987084c48390a290f5d2a34603ae64f55137d9b4affced8c0eae030eb222a25" + ], + [ + "p", + "e1ff3bfdd4e40315959b08b4fcc8245eaa514637e1d4ec2ae166b743341be1af" + ], + [ + "p", + "8f9c45787f179b2949608ffcaae7a73b5a7d12a4ad309594c2a20273601ae70f" + ], + [ + "p", + "cf9413eb6bbe55c8a3c10119ec0635e134fa266f2c50f825d7225da9b92ecc4e" + ], + [ + "p", + "09e935f7c01fda340051a4700cfb9dde533202bdf56808f68cafef6bae07a5bd" + ], + [ + "p", + "9c3e7a87c357f9d698e4d7167ae08e269b1b1b1433311f8cf0dfdb8c0faf52c3" + ], + [ + "p", + "af4f5ea6086182e8b411368fa50d92d66ceb91a54fab6e3f7e29094ce7fbb426" + ], + [ + "p", + "179744407ac4fda143a8635e7ae9c9eabf3ab107a818a4f740a9e46b39412a42" + ], + [ + "p", + "bae77874946ec111f94be59aef282de092dc4baf213f8ecb8c9e15cb7ed7304e" + ], + [ + "p", + "9211af4fe742043111e923a6235065b1df69acb34df4d894b50f10e5ba57de8b" + ], + [ + "p", + "44bb2dd1615ed2a527946c41d854995f18866a8feffa88eb375728c20aeea30c" + ], + [ + "p", + "cd6b2f16c7afb47570ab242e0cbe0b9da64e1e7c6978a23c5ef33d4bb4a1cf57" + ], + [ + "p", + "6398e15e3416de093b963ca38783d2a66a9657cb08cbba4f02546cdd55b6f1a4" + ], + [ + "p", + "f24ab1e41a924b40a285064c5294541b575191a0af32ded8c84fd5af4215dfaf" + ], + [ + "p", + "40e162e0a8d139c9ef1d1bcba5265d1953be1381fb4acd227d8f3c391f9b9486" + ], + [ + "p", + "ba67bf89761da5ed59966077c4d514e94bb11195ce81d252572e3bac14e6803b" + ], + [ + "p", + "57dd4ca641c95019151e957861929a175268a604810628fdacfb831776d12d29" + ], + [ + "p", + "c7eda660a6bc8270530e82b4a7712acdea2e31dc0a56f8dc955ac009efd97c86" + ], + [ + "p", + "f031876048ebdb65054a46d10c988bdfa2b53fb11d8f527c7c1a9c4d67c23c67" + ], + [ + "p", + "6bb28e797d075bd0a822769b0173f8d0fc876fedeba4168f238709631be41273" + ], + [ + "p", + "2590201e2919a8aa6568c88900192aa54ef00e6c0974a5b0432f52614a841ec8" + ], + [ + "p", + "38b07a31f3b23dbeb9f59deb7bec5b993173fb4022206980f3809d0b68abf959" + ], + [ + "p", + "d474da30f6cd727889743fcdce790fbdfc6376b1984206dbf50bd0d36d066351" + ], + [ + "p", + "23a2cf63ec81e65561acafc655898d2fd0ef190084653fa452413f75e5a3d5bc" + ], + [ + "p", + "e9c0a9c12e3a04edd79afc77d89b6c6413cc942ef9e61c51e51283cbe9db0c8f" + ], + [ + "p", + "1c6b3be353041dd9e09bb568a4a92344e240b39ef5eb390f5e9e821273f0ae6f" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "47bae3a008414e24b4d91c8c170f7fce777dedc6780a462d010761dca6482327" + ], + [ + "p", + "f6d31b8781b7a6a98a87c1ebf390460399cca1046d58a1790428a401184297b5" + ], + [ + "p", + "0fe4703dc4851c7843d5d07bd5263f743611f45d49949c407992333abdb1c7cc" + ], + [ + "p", + "6c9580ff66ac28ddabb19f0238c4db75b350ef50a5567beb5a955da4fd5f65b4" + ], + [ + "p", + "0000a0fa65fcccd99e6fd32fc7870339af40f4a94703ea30999fc5c091daa222" + ], + [ + "p", + "0d6c8388dcb049b8dd4fc8d3d8c3bb93de3da90ba828e4f09c8ad0f346488a33" + ], + [ + "p", + "e4c47aedea8ea54255f5ba07a77053b24553e9b975435e56da343da19aec7881" + ], + [ + "p", + "fed5ca89be2b53e6234d9a3656bfd39777057ab71179a8b4632bad6e20d5e079" + ], + [ + "p", + "566516663d91d4fef824eaeccbf9c2631a8d8a2efee8048ca5ee6095e6e5c843" + ], + [ + "p", + "11b9a89404dbf3034e7e1886ba9dc4c6d376f239a118271bd2ec567a889850ce" + ], + [ + "p", + "8d233d8babe9f40f170c5b0706fd4832869e07d040cfcd6b702d57e070aad1cb" + ], + [ + "p", + "651dece809a1dbf9b9eb8a3e630ea674b8ea5996dc0a7726329c0cef4efc686b" + ], + [ + "p", + "0ba7c7f4a32c25bcc644ef65bf9c0d75e99c83c7eadfdac7214ff426b0af64c6" + ], + [ + "p", + "4a146fa1d1cb4c93fbe4594b4f6ad770dfb9d6f461f91b67f69c8731d3aa6b74" + ], + [ + "p", + "cdb55b719d18e264364d53b7826422daf05cf11af0ef6fa4076a7e2724b722a8" + ], + [ + "p", + "8600f1e35c28d941b8e3303142f17960a034e10457742d2b8c03c967a4e26934" + ], + [ + "p", + "ff72534233d23c9f9880c5c99a1fe978697caeea08c439b9d4b5e3778e7c05f9" + ], + [ + "p", + "ab6bf7466fa6b9f355d31335b001ea33ef3127349caaf5587334a083607f92d5" + ], + [ + "p", + "b99b149370e4f8533ce53d143af3f39e1f2628a39847f7fdd7544c9585da9299" + ], + [ + "p", + "ac4e18391f45932c0067e28203d5083a356ce301ab60867de094c94b98358666" + ], + [ + "p", + "f45efc1451e13ba933c34300fd2e017f14eadf4b14b16a60c5d5049455b09ad8" + ], + [ + "p", + "a869ca735271275671864337122a99386b5630d30dfcf5a46d62c271d9113c0b" + ], + [ + "p", + "0d7df18a42d41e1ba747199490764d17c80956afb0d402b2f7c5ce620bd4d7a7" + ], + [ + "p", + "70fc0e65e2239286a0318d6ee5ae849af7b79154d1e3dfb45c334d1b261f2257" + ], + [ + "p", + "9577439173a22c446e3cd94669abae148af638e5d27970f3b2fa44d2938ddd0e" + ], + [ + "p", + "e938040374dbf27726c62a5b8b4437defdb8c363c4795f478d9ebed68bf9d22b" + ], + [ + "p", + "cbf781e3b1eb3b167728cfa25bfe135562ca9993e626be6b326ff5e10d689be8" + ], + [ + "p", + "4f33aefaa8e5eac5f29799bce192be267826d1d2e226737fd3a3029b9f979d6e" + ], + [ + "p", + "d34a2a9a302816601b64db8f8262883c5646744427f1b218cc34003cba506690" + ], + [ + "p", + "9d12fc75d4cd3ffa89e3276e060415ce7fa10ef07af552d03ec7dec5cc52aca7" + ], + [ + "p", + "81ab5b5c5c36bbc3960beeddd5c0e5966fa28672498fd960ed648794e7f6db15" + ], + [ + "p", + "68b9ea7860f262482dee6c32615e00c88ce09ff16b6e971d3ab9721c405d3353" + ], + [ + "p", + "fc6bf3e9c044981d3cdbf146c92472716b3592712310d86fc51130dca7b52923" + ], + [ + "p", + "62610847e87afb99f40c949cd67f490ac6dd539689957c0b315ed67d1d13220f" + ], + [ + "p", + "4d5ce768123563bc583697db5e84841fb528f7b708d966f2e546286ce3c72077" + ], + [ + "p", + "edc0120f7c50760a6a39438a187432183653f695d4595a60983dc6faaa7f94b9" + ], + [ + "p", + "e0d05a5b8c7789eb83f87672f4eb0dca78f99292ab038e5c66f84d97d77b95ae" + ], + [ + "p", + "86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e" + ], + [ + "p", + "62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49" + ], + [ + "p", + "6446d04ecf9e0bb72c5ae218df9fc6c0a273149d9ecbfbe42519c53667b4405a" + ], + [ + "p", + "9a29ee8c3771573e5306bb7701182e970b188ce3552713ca68a157ebc3c0bf75" + ], + [ + "p", + "57225e0adcbad1fddf8d9ba1f5f36d657f134b7e0ea7aed6c0eb7013e4ef45f1" + ], + [ + "p", + "e3f0c72e7b653f395f64e03519bae3efeac184bcf0b3f38bdccb62a4d2aa5d30" + ], + [ + "p", + "b175db709771d32bbe7d8599e0c41f3f8768cc3a8333603d93c6d72d41c42f76" + ], + [ + "p", + "9b9f5f1ec13105c8d1c2ea16aa952e98640b170b871420980ea11b18eb1f1e03" + ], + [ + "p", + "f61abb9886e1f4cd5d20419c197d5d7f3649addab24b6a32a2367124ca3194b4" + ], + [ + "p", + "3ed35796636aa19b7327de00b1192fbb985e8bf05d71604237bcd9df9b8bc73c" + ], + [ + "p", + "1221fd0054a6c8ebd07b39c5eeea388f7f0244409f8cd8649ac22fcd668d02f6" + ], + [ + "p", + "234d39919c1bd120766c4d874e8f34df4c80981236d76cdd95e246b1d01ae10b" + ], + [ + "p", + "a4cb51f4618cfcd16b2d3171c466179bed8e197c43b8598823b04de266cef110" + ], + [ + "p", + "c181af1aca3a13243a9ef9c302d5e988eaec25caa60c9923e5faed097e52cd69" + ], + [ + "p", + "fd66e2e83d7ac4548724e2a9d56d46cb00244a8573775a8c97e5eee4f474e99c" + ], + [ + "p", + "2b36fb6ae1022d0d4eac2a9f13fc2638f3350acc9b07bdca1de43a7c63429644" + ], + [ + "p", + "472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e" + ], + [ + "p", + "9aeb3bb495f09be3799048c3ef76649917efc46a8c8a69fefc31a7d012f6eccb" + ], + [ + "p", + "f5424d002fd0d48fadd6e54879387714c54bfa46535976ff2b385843aaddf8e5" + ], + [ + "p", + "152311509d52ef1fd3397b2bc297f206401ce00daabb69ab9a72e08d1547b8d4" + ], + [ + "p", + "2bb514ba0acd6c9e6db7a76f53fd92016f893c62053acb7ef9a9f61042ade52e" + ], + [ + "p", + "1f7dfb1b51bd4fb5d15245b28d86fab670a677580e2a0633a2cf76509d02471c" + ], + [ + "p", + "3707f1efc7515524dce41d3bf50bfd9fdaed3494620b5f94fcf16d2766da4ec2" + ], + [ + "p", + "4d960b819ff5c4f417431e73e7bf70ad41f181136d1baef47c25d1c8b23b4de2" + ], + [ + "p", + "3fde182cc7e6efa69a393b16ef41b10c03928df3b96acf4f0eb03f9fca63a09a" + ], + [ + "p", + "8ff7a6132ffe1bb3600aa20496ab648f1daf6b50ceaa8054a37e6a0b1f7ee491" + ], + [ + "p", + "cbc5ef6b01cbd1ffa2cb95a954f04c385a936c1a86e1bb9ccdf2cf0f4ebeaccb" + ], + [ + "p", + "647fcbfef88dd2347a4f69a296f0fd6a470f96eb1cd294066e2594e95fc9480d" + ], + [ + "p", + "b1576eb99a4774158a32fc5e190afa3ded4da19f51fbfa0b1a1bf6421ea5733a" + ], + [ + "p", + "14347702b99786cc0ee644620a5f71bc6a88e2882491f57c372f1deaed198701" + ], + [ + "p", + "e37d948a0eee45e6cd113faaad934fcf17a97de2236c655b70650d4252daa9d3" + ], + [ + "p", + "42a0825e980b9f97943d2501d99c3a3859d4e68cd6028c02afe58f96ba661a9d" + ], + [ + "p", + "464b1e81a178e597314b0793ee51b4db2a1f1dca6fab4895309306faab0ec4d7" + ], + [ + "p", + "f00c952da33c06e02c930f76aba1085021b98075657daaff8ad119edcfde691e" + ], + [ + "p", + "c697f7f5f59de8ddb93c6b74fdd759ab2dc654bc36315f39770c214607fcd65e" + ], + [ + "p", + "76f5960d381e7146b7f374a4a65afa403038441b46933840c71e436facb82ae7" + ], + [ + "p", + "8837f562e064282e4fb9902ae6062ee436a53236909a68c6d19564df6c208fbe" + ], + [ + "p", + "f43c1f9bff677b8f27b602725ea0ad51af221344f69a6b352a74991a4479bac3" + ], + [ + "p", + "3dc473414bea4de1df1f172c3196a820719dbb58baaf8764b9edaa07c6d5e9dc" + ], + [ + "p", + "66e346dfe3a4e572359519f086bf45771a19224343183aa1c86b9f9e31b78ac9" + ], + [ + "p", + "8c24f2bf7df33aea0f05706162176343f34389d95ca5696dba1c2768887f586f" + ], + [ + "p", + "b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc" + ], + [ + "p", + "c47c24c8f4000a83f5efcd2f7eadaeafb8f95437027df3354fd1c01d7f852583" + ], + [ + "p", + "88a2c3b420b4a027706a98600d1fd744ac6cfd12e201b74189be5ef4b2b3aa45" + ], + [ + "p", + "c3557fd9f7771fc9db60d2708715996a047d0ddd1e9cb0868010e8004b21a808" + ], + [ + "p", + "1f5cd0b7618dcd0b4040e0daa1e6719ae9e4b5c0822fc5f47ed55725e08b6564" + ], + [ + "p", + "343558f07b07ffcb24b27b73812d74d4ff8f46e81ea903f1e7f37d30d907bcfc" + ], + [ + "p", + "1d643df20fb811b33c3cfb04c72db04c6ae031dbf68f613ba45407cbdd9446b6" + ], + [ + "p", + "2ab80bb67da174e8de71d2747a2c205c09fd31b8ecfeaf5825c58bd574cbf75d" + ] + ], + "content": "{\"wss:\\/\\/nostr.drss.io\":{\"write\":true,\"read\":true},\"wss:\\/\\/relay.damus.io\":{\"write\":true,\"read\":true},\"ws:\\/\\/monad.jb55.com:8080\":{\"write\":true,\"read\":true},\"wss:\\/\\/nostr.zaprite.io\":{\"write\":true,\"read\":true},\"wss:\\/\\/relay.nostr.info\":{\"write\":true,\"read\":true},\"wss:\\/\\/expensive-relay.fiatjaf.com\":{\"write\":true,\"read\":true},\"wss:\\/\\/nostr-relay.wlvs.space\":{\"write\":true,\"read\":true},\"wss:\\/\\/nostr-pub.wellorder.net\":{\"write\":true,\"read\":true}}", + "sig": "d532da78b22e128535320ce6547d33b17876eef8a9546741a32726fbcbdceee7cc8595ce46028cd02bd17e390f59033afe8c914a8c6b3132b404fed2b65b5e4f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "cf8de9db67a1d7203512d1d81e6190f5e53abfdc0ac90275f67172b65a5b09a0", + "pubkey": "e8b487c079b0f67c695ae6c4c2552a47f38adfa2533cc5926bd2c102942fdcb7", + "created_at": 1645030752, + "kind": 1, + "tags": [ + [ + "r", + "https://fiatjaf.com" + ] + ], + "content": "r", + "sig": "53d12018d036092794366283eca36df4e0cabd014b6e91bbf684c8bb9bbbe9dedafa77b6b928587e11e05e036227598dded8713e8da17d55076e12242b361542" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "91503a45bca4631ce768b1ba806a4526c2a953d9fa60a8c7afa65341776d85a2", + "pubkey": "e8b487c079b0f67c695ae6c4c2552a47f38adfa2533cc5926bd2c102942fdcb7", + "created_at": 1645030912, + "kind": 1, + "tags": [ + [ + "r", + "https://fiatjaf.com" + ] + ], + "content": "testing a comment here", + "sig": "f3c2acfb971920f822e8f2c6a61b60a897ac349b6dbbd906f7e21781fbd58517fce4f9b5b93270236875861e09336cedbbc498b84ec3d5b65dea212546114c9d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "423a19c9f81fb295101fe2ae491b928b327832a3346b9929e55777b9a97364ad", + "pubkey": "e8b487c079b0f67c695ae6c4c2552a47f38adfa2533cc5926bd2c102942fdcb7", + "created_at": 1645049616, + "kind": 1, + "tags": [ + [ + "r", + "https://fiatjaf.com" + ] + ], + "content": "what", + "sig": "3ee017e2090ad8c1d1ac14bf68692620aa91bdf7b8b1f76f3bf7192ff52f0e9e15c57ba1c69132c0864e88774c265dfde8727dd81cda444b5cd973fd5bc44bca" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "00391038e0d23e69d30289ede976ef6663313df85b479a4b59895e5245428f0c", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1652272332, + "kind": 1, + "tags": [ + [ + "nonce", + "606" + ] + ], + "content": "“I want to wax the car today.”", + "sig": "64db161fd7b1f3738b9662600000ac3fec015bdc6433c29eb7ef87b6551e7400ba0cd87261eefc02c8a9b7d8daafb22b69cd4ba0f53aeade607b9f1bae658708" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0017d3def959fd2b3a9d08a4e4726b8dcb496d3eefe5f4eaf0a16d0f3c075859", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1652272353, + "kind": 1, + "tags": [ + [ + "e", + "00391038e0d23e69d30289ede976ef6663313df85b479a4b59895e5245428f0c" + ], + [ + "nonce", + "2056" + ] + ], + "content": "“Oops, the hose is still broken from the winter. I’ll need to buy a new one at Home Depot.”", + "sig": "a7cf4f246c2e60f56ab01ada17ae08dacb904bbf064a1cc35f793814f02de6ecdafdaac93c935078fa08ca8529bf9744347cd7a4f610d9d35dfbe5077a735b02" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "003dee6eb42837d083375ab7d0fe6b18af3a8fe27baf39b56905dd56e9272869", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1652272387, + "kind": 1, + "tags": [ + [ + "e", + "00391038e0d23e69d30289ede976ef6663313df85b479a4b59895e5245428f0c" + ], + [ + "e", + "0017d3def959fd2b3a9d08a4e4726b8dcb496d3eefe5f4eaf0a16d0f3c075859" + ], + [ + "nonce", + "46" + ] + ], + "content": "“But Home Depot is on the other side of the Tappan Zee bridge and getting there without my EZPass is miserable because of the tolls.”", + "sig": "eda8683c3b115cad7589017566c3dcef52184dfea57db56f1462dbdec54e5a274d35dd11f7b9fc0e7b856a57e62d666646e3bb3e708f0c297d45caaea4a1a3b2" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0021f50ca06c3a226ce589567b319fc360803d8fce3867b984c5020e80a5beec", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1652272396, + "kind": 1, + "tags": [ + [ + "e", + "00391038e0d23e69d30289ede976ef6663313df85b479a4b59895e5245428f0c" + ], + [ + "e", + "003dee6eb42837d083375ab7d0fe6b18af3a8fe27baf39b56905dd56e9272869" + ], + [ + "nonce", + "3891" + ] + ], + "content": "“But, wait! I could borrow my neighbor’s EZPass…”", + "sig": "093dc3d4d278b6d81bde7bfbcfbc87e4f52d561f29ba715d4a96acc4e5b614d715250705ada63c99cd4e505f2e6843d6cc87d9416b83766a0a4383f188580f67" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0003ba56dbb707b9f3131656c67c6eca6c69e9bd48ada4aa8be0c38b78dbf471", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1652272479, + "kind": 1, + "tags": [ + [ + "e", + "00391038e0d23e69d30289ede976ef6663313df85b479a4b59895e5245428f0c" + ], + [ + "e", + "0021f50ca06c3a226ce589567b319fc360803d8fce3867b984c5020e80a5beec" + ], + [ + "nonce", + "835" + ] + ], + "content": "“Bob won’t lend me his EZPass until I return the mooshi pillow my son borrowed, though.”", + "sig": "fe4bba3d1e6b100fd6910b8df1b673f4fbb491d2f4b5ceb20692b348a04d1db7e9e3ca7209a9612e39bde95f47d1358790d77a859db62b4402367af0a49badb7" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0010f416ad17ae80965ce267a9dded56cae1c21def2caf42f4b30c462050a3ca", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1652272557, + "kind": 1, + "tags": [ + [ + "e", + "00391038e0d23e69d30289ede976ef6663313df85b479a4b59895e5245428f0c" + ], + [ + "e", + "0003ba56dbb707b9f3131656c67c6eca6c69e9bd48ada4aa8be0c38b78dbf471" + ], + [ + "nonce", + "725" + ] + ], + "content": "“And we haven’t returned it because some of the stuffing fell out and we need to get some yak hair to restuff it.”", + "sig": "88a5e938df547139f5275b867284189e0d9b63f79aee22f61d52cbc802f22a515d6c0a72ae874fcb47627b4fd3f9463e5223274481b76041f2204740841a15f7" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0033d2c0bc23118f886405ea60fdac672c1f8c10e11f5707f80d7def352ff141", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1652273176, + "kind": 1, + "tags": [ + [ + "e", + "00391038e0d23e69d30289ede976ef6663313df85b479a4b59895e5245428f0c" + ], + [ + "e", + "0010f416ad17ae80965ce267a9dded56cae1c21def2caf42f4b30c462050a3ca" + ], + [ + "nonce", + "19" + ] + ], + "content": "And the next thing you know, you’re at the zoo, shaving a yak, all so you can wax your car.\n\n- Seth Godin", + "sig": "ebc0cd3bfb6a5a0fb498f98594e278f0d674b9a9572375207b664b978c3cd1903d6ab69d397a1bb3577640492c17d0a1c62cc1685ce53ad9be0918429cf9657b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2855921ed8f27980540d3c21c49fa9d1ba49299a675d339a85462f6dd0fae65e", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1648324761, + "kind": 1, + "tags": [ + [ + "p", + "b238e136091cb01cd21606dac1a2f503f504e7e8e7c75d98fcefd30aed084a1c", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "d7e747f60a16bf0081c0a88184a34086cc13e6edb0662d4e55202531b47be026", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "5c6c25b7ef18d8633e97512159954e1aa22809c6b763e94b9f91071836d00217", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "61e9a1f2bc9206aaa306a579ee37493aeba75be31ea8aaa3bd52d6192eebda30" + ], + [ + "e", + "8aec8effa876c911f058973d69001a4ece139986916ae4b520b287e117da92d9" + ] + ], + "content": "it can only wash my own dishes sorry :P", + "sig": "443fd22c1815569b630e4a1af522056adc9d016b1e105d28676ae6327531154db2f382dc9540b415a997c976ec5d3fec0a7d5a45e656310c002e766e8aed3900" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b1601d26958e6508b7b9df0af609c652346c09392b6534d93aead9819a51b4ef", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1648339664, + "kind": 1, + "tags": [], + "content": "learning terraform rn!", + "sig": "ec8b2bc640c8c7e92fbc0e0a6f539da2635068a99809186f15106174d727456132977c78f3371d0ab01c108173df75750f33d8e04c4d7980bbb3fb70ba1e3848" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5e69702b8297035e31760e7de2d5f33b62b1549f160cbba26ec1217c3de36d69", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1648351291, + "kind": 1, + "tags": [ + [ + "p", + "8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "d7e747f60a16bf0081c0a88184a34086cc13e6edb0662d4e55202531b47be026", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "5c6c25b7ef18d8633e97512159954e1aa22809c6b763e94b9f91071836d00217", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "dc57356cfcd8d3881856d97fd7361288d06ddf15cae770b256a6bd79cf03cfde", + "wss://nostr-relay.wlvs.space" + ] + ], + "content": "checkout how im using it with IoT #[2] #[3] #[4]", + "sig": "482a1b548cd07d2bb8f50a7c6f13088b444430a9a6a40822dc785b5838822dcd429aa822b724a72a15b8cf7ee604d4f621bfc32bb2a1ab69f4dafd58931131e5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "6b3cdd0302ded8068ad3f0269c74423ca4fee460f800f3d90103b63f14400407", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1648351380, + "kind": 1, + "tags": [ + [ + "p", + "8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96", + "wss://nostr-relay.untethr.me" + ] + ], + "content": "I've set up mirroring between relays: https://i.imgur.com/HxCDipB.png", + "sig": "b37adfed0e6398546d623536f9ddc92b95b7dc71927e1123266332659253ecd0ffa91ddf2c0a82a8426c5b363139d28534d6cac893b8a810149557a3f6d36768" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d965cf7c34af42e9b68225ea94d8b6fd08a1bb556cf16ac39855fe20702e71e1", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1648351595, + "kind": 1, + "tags": [ + [ + "p", + "8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96", + "wss://nostr-relay.untethr.me" + ], + [ + "e", + "6b3cdd0302ded8068ad3f0269c74423ca4fee460f800f3d90103b63f14400407", + "wss://nostr-pub.wellorder.net" + ] + ], + "content": "this one without the expletives: https://i.imgur.com/DjC56cn.png", + "sig": "b3bb5d6b689d838705bc4cda0458564a1918d6ba229d78daa7c238948d1f1686da0d992688198d80041a5655a048b0ff35b2a4ba76f4665017edbf7844ea338a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "fec49e19eb2069f2e64348ea48c6a5d4b7ee9bb9a18c8d72bf05c2ae501040b4", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1648503655, + "kind": 1, + "tags": [], + "content": "it's supposed to be spring... but there's snow everywhere wtf", + "sig": "e9481e11fe76caddf8b1c548f4385ff1c49fa20cc7c58878cb0ca3f0c6df39b0ba35ed9b5df7ed1be53eac6dbfd7cd0a3c6d2915fd5819d688b1b9223f4bf4f5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7d786553cd5c55e9dfcb18d823f8e3c58e83bee89856fc606e9747953038d766", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1648504073, + "kind": 1, + "tags": [ + [ + "p", + "8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "d7e747f60a16bf0081c0a88184a34086cc13e6edb0662d4e55202531b47be026", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "5c6c25b7ef18d8633e97512159954e1aa22809c6b763e94b9f91071836d00217", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "ad5aab5be883a571ea37b231cd996d37522e77d0f121cedfd6787b91d848268e", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "9cd7f75bde4249f28cd0ee6bc4bf7394bff11d608b54b520e9aa95ac09f47c36", + "wss://nostr-relay.wlvs.space" + ] + ], + "content": "it's got a smart switch that provides power, voltage, current in real-time connected to Home Assistant, I then have a Node-Red flow that watches the power and detects whether it is in use.\nthe washer/dryer here are smart appliances, I could probably hook them up to Nostr too", + "sig": "bef32d8b3fea0ef5fc46f3e560edb2c2d324c19d12bf2525ff0f86aadfb9714e1ddab2fa1cb77d39844205ebb6955452746fb9b1c12ec41880fbd7d53aeab0fa" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "94df1af2e5160cc74dcae8cf371fa0bbca70a0fae0877ccba396407b4e9c44c2", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1648907147, + "kind": 1, + "tags": [ + [ + "p", + "41108126409bf99cb77ff16fd53f4da2e53010b0dca04b0a53ebdf46eade37aa", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "046ed1a1f6d4fd023b13b80b0b20a8c5e80d1b23eff892cf7f1eda9281cefd3c", + "wss://relayer.fiatjaf.com" + ], + [ + "e", + "68743c031fe03c69a08fad47832c45c6134a934e9022594a036bdc2d77a38ee5", + "wss://nostr-relay.wlvs.space" + ] + ], + "content": "[Object object]", + "sig": "1467f090b9564b58b8891247794f5e2680f5696e69e54092ad92ddd4fc15ca1c93400c59f93577518d5d99794efcab77fda0cb085fe08aaba0e211b537c9e88f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "fd4200b43064e97099959eb4858c8548277e6079a0807e8789072a0f44138216", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649110580, + "kind": 1, + "tags": [ + [ + "p", + "fd3fdb0d0d8d6f9a7667b53211de8ae3c5246b79bdaf64ebac849d5148b5615f", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "acd9456eff3eef7b156799a68f07323ae550bd878792340e19f7e4226026c981", + "wss://nostr.rocks" + ] + ], + "content": "exciting!", + "sig": "7fc92b4914c8f493d2e7d629cdd4777b1e94c537186faa0196f8c9b7c9b456e10d4d9ee416e2ddb907530e12f9725ea9eeac4ac27223676009ec900bc87ad39b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7ed629c6ee58a52837e8304cebecf10d0e0bae09428d12ff146710701dadf458", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649129063, + "kind": 1, + "tags": [ + [ + "p", + "0fb7cfd1c99aab982d182d134960f54795c96575ed0206800e06ce0c7953f20e", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "b1601d26958e6508b7b9df0af609c652346c09392b6534d93aead9819a51b4ef", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "a60138f84e22981fa055180cefa6140e6cf4e0e9888b8dd4b3c4de5bec601be1", + "wss://nostr-relay.wlvs.space" + ] + ], + "content": "I'm reading Terraform: Up & Going. So far so good", + "sig": "6292fdd0d378a7d7ee1a7945532c9051ed73c19b40ce128de5396205b962c9bb31661e16ab838a22d7e74da6f968d4842dd8c3a06fcf5e92d0b2481606412033" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "963f79c982c75f3e385ea72fabeaa2534d839a6d1ec8a2f3cba77dfeb2f4d91b", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649163739, + "kind": 1, + "tags": [ + [ + "p", + "b238e136091cb01cd21606dac1a2f503f504e7e8e7c75d98fcefd30aed084a1c", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "e12ab56fe908e173bf941c0ebe1598f336d240699d6a07703ff25a61a8d2f30a", + "wss://nostr-relay.untethr.me" + ], + [ + "e", + "0e73143861e14d5a5377406d711ea55a2cf7c55b15eb3485f87dd9d2af309af6", + "wss://nostr.rocks" + ] + ], + "content": "where is that? is that photo recent?", + "sig": "cdde25b02179bf4f6ff9ea68dc962298c79d554d10844b825de3a5582820d5dfd7fc7b9e743192c4956078595ac9dde1a5d5b3513936be791b50ae8cfbb31990" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "75cebfc6cbfd0ff8654579d63cb4a115246be924baff64d640edb79cfad440a5", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649163786, + "kind": 1, + "tags": [], + "content": "late night playing around with proof of work and Nostr! https://observablehq.com/@cameri/nostr-playground", + "sig": "00d3b4a3095e424d46c6908dbfc6c2f517905b109422a9be386ed6cab42205b644ff207ec30dd4aad18475a4e0a60ce89ab6f3de916eb1ee842f72c37ed47288" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "79972c28d22a426b6448fc066960ea1383ee1b23706fb9377c642f3b87fcaf2c", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649198139, + "kind": 1, + "tags": [ + [ + "p", + "ed1d0e1f743a7d19aa2dfb0162df73bacdbc699f67cc55bb91a98c35f7deac69", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "75cebfc6cbfd0ff8654579d63cb4a115246be924baff64d640edb79cfad440a5" + ], + [ + "e", + "3301d04238a7d1c0d18c7e1bd17040ae9461a6de91dbd9cb40833d68f80a8470", + "wss://nostr.rocks" + ] + ], + "content": "sweet! observablehq throttles es6 generators so they \"render\" nicely", + "sig": "23f9147a13a0870b6e91c2f4016a90f3c60d9f58f3c7cb8ccf16c2fe386802297b237e9d874781407d7ccb319f8e4362bc2e44bbf120dddc7136ce67079d31b9" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "633e394cda2445aadd35eca7fe5404f927c7cc751e3adb25ffab2e9c88ce39af", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649198211, + "kind": 1, + "tags": [ + [ + "p", + "ed1d0e1f743a7d19aa2dfb0162df73bacdbc699f67cc55bb91a98c35f7deac69", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "bdf5b7c494fdca5417c48b34781c2fca33a47a53dc7573e725a75d9e9adc7c28", + "wss://nostr-relay.wlvs.space" + ] + ], + "content": "pretty neat!", + "sig": "32acf14e1f16da46d93a8f93039818b71559a8f43c37ebd5aa0635a1ab1b40f1dc4b509c892903ac7a6e59db4ace45c72362556f906719bd6046931d9e7cf188" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "858a58696f7d54c2ea3aa1e5fbbdb0fab65077ff2129ee55bdb9c8dc80b67b72", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649205273, + "kind": 1, + "tags": [ + [ + "p", + "7927bc6e25892729a9c02a1332c409a69b285e143b9d845c54fd9c1fe829e25e", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "41a3905e0dcd61739d369e07a558e1dabc22fdf45a9c6c5f08f3ef8b4b6c6543" + ] + ], + "content": "I'll add yours forever to wlvs.space for 500 sats, if I ever have to take it down you get the 500 sats back", + "sig": "9aa06a46927180ab422cab6e56ddc3dd55a9e77bd0328cd288f3c77e9127e3e69b6846ef0bf8f8ecb4249762ff76cc028c5ae3387ef0764db318467e4ca44781" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "fcdc19f406f4fad7d70bb5c6166d8bb281e071efb7a76938470e1be1a417e0c5", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649205373, + "kind": 1, + "tags": [ + [ + "p", + "8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "ad5aab5be883a571ea37b231cd996d37522e77d0f121cedfd6787b91d848268e", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96", + "wss://nostr-relay.untethr.me" + ], + [ + "e", + "cd41772c5d138eed0fa3642396da60417d3d71c4285557b85eba32aecbbf1508", + "wss://nostr.rocks" + ] + ], + "content": "on the second post I did remove it :)", + "sig": "87a062d73f9019e100a5a4fa8f9fa65900142527a641e992efc4f84b378777d91637ade3914306ce8ecb84ed538300af311e0289fb85db6037480be34d36a3c9" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "52a6e8fbc42a39a61eb7d59ad7523085811b54bc3bf1cdd815d7aad09949015d", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649288699, + "kind": 1, + "tags": [], + "content": "redoing nip-15 today... let's see what comes out", + "sig": "0da43d0013d07de3e620ea7c374d8e7967c281900013233f3e081c9bbcd10812e1e4d2f021bb9ab716bcb1d67fd09c1691625a4af57c3fcf4829d4eefd3882e7" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9e5c389757b1adab5dec3e120242569303077c800aee1b2248d3e4cedfb9330c", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649306719, + "kind": 1, + "tags": [ + [ + "p", + "7927bc6e25892729a9c02a1332c409a69b285e143b9d845c54fd9c1fe829e25e" + ], + [ + "p", + "f9e24c0a9544d119b4f0e31ceac53d1b650c763e378541e1dfde402e350f5792", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "41a3905e0dcd61739d369e07a558e1dabc22fdf45a9c6c5f08f3ef8b4b6c6543" + ], + [ + "e", + "f863bbd237ea503a15e8c8c07b893e9c86a18e0ea75d949090c45ca40e485471" + ] + ], + "content": "right on! what's the exact name you want listed? it must only contain the following characters: a-z0-9-_. as explained here: https://github.com/fiatjaf/nostr/blob/master/nips/05.md", + "sig": "ab796166278fc63fd00979eb99a47aae7587cd5ea5e96473109600abf90a35a2ac29a8cd026b8b07d974ec03b7fdc03175929a4120f0a3f6235188a20841ed80" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "bdf48f9a1d26f04dbde51eb2075e10b3947badc6fea164c2bd4894a9b974a98a", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649306970, + "kind": 1, + "tags": [ + [ + "p", + "7927bc6e25892729a9c02a1332c409a69b285e143b9d845c54fd9c1fe829e25e" + ], + [ + "p", + "f9e24c0a9544d119b4f0e31ceac53d1b650c763e378541e1dfde402e350f5792", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "41a3905e0dcd61739d369e07a558e1dabc22fdf45a9c6c5f08f3ef8b4b6c6543" + ], + [ + "e", + "610dc5a103c5b566d6733dfbf38d3cad05e291e300980e7f8f43065ff1ed0815" + ] + ], + "content": "go to your profile and add `ogw_yuya@wlvs.space` on the nip-05 field, it should work. refresh the browser if you dont see it right away", + "sig": "3e3fc4bf1a2f38d4fa304c0da8f005ae3e8c1d68ada169c8a6e51d537bc1db58bcf2fe43a59b7ea9e8effc9f7a781c86c77f9261a096a48417f420805b251c15" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "3b02cdc396210425cd25d6edc3f74458314ec29ab845d908166c1877e41edf8e", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649307425, + "kind": 1, + "tags": [ + [ + "p", + "dcf29d97037654e8bc37818e20120ef44c56fd4266cccbaa13b02637dd3e49f4" + ], + [ + "e", + "7ac91971bf2e23b83fa090939132362108fe7e53b194c2d1f62d7a81872bc253" + ] + ], + "content": "I see you!", + "sig": "95ae1044ec36dec114b4fff90dd68e0039d511a3a851896b411c0de0df5d50e81759aec6b523a0d91d0cb883dfd827413ddf487653920e6a2239b7724e0f98a6" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c383f30193e00b793b7be14973935704d197527cd5bc018f5338357a104f5b97", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649307535, + "kind": 1, + "tags": [ + [ + "p", + "fd3fdb0d0d8d6f9a7667b53211de8ae3c5246b79bdaf64ebac849d5148b5615f", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "87816efbed5f4734a3f75c90dae23778eefa476c43f920a6cda7e534da4599ff" + ], + [ + "e", + "1e6da67f9d50ab68f6b2979511971921a96da4df5415c17e405f6513baab3293" + ], + [ + "e", + "87ce475b565ac3642c467172ee06ff82371219480f1a588d7ae851581803e161", + "wss://nostr-relay.wlvs.space" + ] + ], + "content": "the nostr protocol allows you to subscribe to everything", + "sig": "5930e94471087baa314278e35d090f6f81bf6df5855f885380d18d202b84783fb06e2b0d97cfbf75a459441635b5bb6b38d813c07dad789db598c6d7c2c8a76c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d6a2c69e1b78f1d8cadc3288dbc80b8365e4ee03ef72317bb74172305db8d384", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649336506, + "kind": 1, + "tags": [ + [ + "p", + "fd3fdb0d0d8d6f9a7667b53211de8ae3c5246b79bdaf64ebac849d5148b5615f", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "87816efbed5f4734a3f75c90dae23778eefa476c43f920a6cda7e534da4599ff", + "wss://nostr.rocks" + ], + [ + "e", + "1e6da67f9d50ab68f6b2979511971921a96da4df5415c17e405f6513baab3293" + ], + [ + "e", + "9215b04d3f86de2d66e63471dba670174683f600ebb4a338f8103a009292be75", + "wss://nostr.rocks" + ] + ], + "content": "maybe Branle could have a Explore section where you can see it all?", + "sig": "2f45f258c6fbbfede1c12cdf1acf2324258bc62516aa6286b92f34cbaefb74ac17699d380f9d786aac591522312f6482ffd331e8b1b6abe1a5a45f564703b76f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4e91937410a470f25e400f8caa8c6dccce1a3d8e1dd88acb47dae27177814aef", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649369962, + "kind": 1, + "tags": [ + [ + "p", + "04b359506f44be3419c81e634c5a6c604280c6033ca77053101b8b0328cc9e52" + ], + [ + "e", + "6d344ca40ba7c6b7c8e4ebfca440b6538bd168df7a5a9b1e30e8d2fe60fafde7" + ] + ], + "content": "Hola Mundo!", + "sig": "ba3c04a8815a89162b53dd0912a52578b84698c719b00e1f31fb5fa20c5ce5127584a6fae10f1f8e4342e50705e7af1e38ac4f4022961b060cd4999e75b37c97" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "95238f5a4ef2cc029ccee26939492548646d075a86042273973cbc97eff51732", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649370034, + "kind": 1, + "tags": [ + [ + "p", + "b238e136091cb01cd21606dac1a2f503f504e7e8e7c75d98fcefd30aed084a1c", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "4921420c8255d348ed324218bcf047960c11c8b81198174db7072750ee84bc41", + "wss://nostr-relay.wlvs.space" + ] + ], + "content": "the video cannot be played it says", + "sig": "127490727bbecf44d9c113eff03cd201041916b9d54004a62436b0ac5c15d253e4a3879e15670ccd443ce7393f0e4dcf1c430ce449b9ae33f874a9f61346d4af" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "82736d7d01da411de563574672b65870f7908bb4832f1d1fb1b5756773780822", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649374159, + "kind": 1, + "tags": [], + "content": "Relating events to resources: https://github.com/fiatjaf/nostr/pull/74", + "sig": "28991909200c46383569a5d4be4b0680b6f66b337ce18803345693b78257c30728f72cb0c0cd78c6c31de515bddefabe28c1722e3c442dcfce8de38ad1d98339" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0008fbb0d1f94033d4d1a090da9d04f90a257f08a2da4afeb95d5c8ae255912c", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649562784, + "kind": 1, + "tags": [ + [ + "nonce", + "166" + ] + ], + "content": "need ppl to use https://branle.wlvs.space/ and help me test the proof of work feature.. basically all events are \"mined\" to get event IDs with a minimum of 10 leading zeroes. let me know if there's issues please", + "sig": "e239fb10c0c3ac3e0274355688b3adb6e4a73947035a35d70004fdc316b5ed810a4259a4dcbdc0a5e1cf22baeb3a09f2e033fe575e773490f1ef21bdd71fb8fc" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "00084497012671b21fa51a873d9242028a02de884304e78f0fca3ffead06a06b", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649595213, + "kind": 1, + "tags": [ + [ + "p", + "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a", + "wss://nostr-relay.untethr.me" + ], + [ + "e", + "0008fbb0d1f94033d4d1a090da9d04f90a257f08a2da4afeb95d5c8ae255912c" + ], + [ + "e", + "003ac98777cece877c134036c73089c70557942f55ab37cde3fb9a5cc868b1e6", + "wss://nostr-relay.wlvs.space" + ], + [ + "nonce", + "103" + ] + ], + "content": "I predict most quality relays will require a minimum proof of work to accept incoming events in order to combat spam. Currently it is very cheap to create users (key pairs) and very cheap to send events.", + "sig": "48a96c8d9787a78640a8be5480f49e6e16aff4abc42445e4025964f86fd8c8777109cfda04e3383379e8e75f65e618df533fb378c5da8a00425bf69b462450c5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0015bf5dbcaff2091f59f21f7f019c50f6c2528a8fd0e6116997b2a4347cd8f5", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649618846, + "kind": 1, + "tags": [ + [ + "p", + "b238e136091cb01cd21606dac1a2f503f504e7e8e7c75d98fcefd30aed084a1c", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "0008fbb0d1f94033d4d1a090da9d04f90a257f08a2da4afeb95d5c8ae255912c" + ], + [ + "e", + "0011f87fd3ea99167b5dd4f0b26a865cf271b2f6282dc2fb28794202a87da111", + "wss://nostr-relay.wlvs.space" + ], + [ + "nonce", + "257" + ] + ], + "content": "awesome! thanks", + "sig": "5ae15d4406adb4a891f13791efa4fe4fe63b2870197bac5415bc61c480e88c3831e3a712777cb4ac49f8354d8c95fa7c9ec3a83423050c0efa0048cb7ab12d58" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0032933473088d6ece1ea7212ed04f91870602ab80048f085b1da46527584711", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649696493, + "kind": 1, + "tags": [ + [ + "p", + "87816efbed5f4734a3f75c90dae23778eefa476c43f920a6cda7e534da4599ff" + ], + [ + "p", + "fd3fdb0d0d8d6f9a7667b53211de8ae3c5246b79bdaf64ebac849d5148b5615f", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "1e6da67f9d50ab68f6b2979511971921a96da4df5415c17e405f6513baab3293" + ], + [ + "e", + "c208214c131b98fce8b9e9246e49a4f1aff7ce820252655eb0928ed7975e91b7", + "wss://nostr-relay.wlvs.space" + ], + [ + "nonce", + "1121" + ] + ], + "content": "perhaps we should extend `REQ` to support filtering by `id` using a bit mask", + "sig": "b7ea0fdc9aa5b3b510e2ab566b764fe1d6bea0a34349a432e5667d86a734ecd30e8d1daacd6e32a6d8ae6ce2447c790f64dfdc02547f951fab2ec6cf4daceba1" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0013e29971de4686bd90cc5f56ab3c84b848f602df3556ab4d62f32e7124d4b0", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649697410, + "kind": 1, + "tags": [ + [ + "p", + "87816efbed5f4734a3f75c90dae23778eefa476c43f920a6cda7e534da4599ff" + ], + [ + "p", + "fd3fdb0d0d8d6f9a7667b53211de8ae3c5246b79bdaf64ebac849d5148b5615f", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "1e6da67f9d50ab68f6b2979511971921a96da4df5415c17e405f6513baab3293" + ], + [ + "e", + "aa147157c1a5e291310a40c82e0a2a1f086e075625ac3c3a738eaeff397f9737", + "wss://nostr.rocks" + ], + [ + "nonce", + "631" + ] + ], + "content": "that's one option to let the relays be the curators, but as a user I might have a different idea of what spam is ", + "sig": "697d37d36ca4f9d978fcc8e00622323c15f6dc4bc239141f41de31cbd3861de7e3f6539477fb361c6407759e8c84db67d50c2dc7236af5efedb33d1e370f1ebc" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0000935cfcbd8f3fa0a706ae147348b0b75f9fa85f04278683cd38e8eff91912", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649707813, + "kind": 4, + "tags": [ + [ + "p", + "7927bc6e25892729a9c02a1332c409a69b285e143b9d845c54fd9c1fe829e25e" + ], + [ + "nonce", + "2186" + ] + ], + "content": "NVnU0u2NQG+GEiebk/axj6fYFrbmemJ1VIlqjV7YepkzpnrK0wBsc4Hm1pH6XkGw9cN7Isd5ewXiGglDYxddnpQ6Hfy0cxw4UloXwbDu/EecPF2FiQvCpY6IrbTIJFMRrxGtpmyG66el3hUkWMlQ32PrSQ0NtemZ+uyqX6PQPynhbHW4skL5ruCvMrC/H4g9gR9LNuf98TW3gD7EZ+svEQ==?iv=MA+w8O16ZiymmxefvfGiMg==", + "sig": "6c86165600a7562de6e76047c26101565772461e12078c12054ad86b1c3153da883386cd793dfd3da8e49e9674548db3bcebcaa192ca2d8048f694f9fdf56433" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "003c9f4bfa50acf4089a6c8dd88d5aed82e65c414161daecbb810696ad621add", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649708456, + "kind": 4, + "tags": [ + [ + "p", + "7927bc6e25892729a9c02a1332c409a69b285e143b9d845c54fd9c1fe829e25e" + ], + [ + "nonce", + "235" + ] + ], + "content": "mQLa9k3VP0Bhn1F8d84EZcfalLCKXfC9uocNJnuT6xLA2+jEtNTEMm8evrMPFUrZs6TFwa9dNAj43n5K0WYAAwdhXXLmC1b86DNFgj3CmPXBXdV2+v4Zx8aQPPSOShpVxC2RHaqfNO0aGxqT+8IyGM9F7PaEMKDnvXRX886MWlQCUGDtgIzjsye2aeCe48pZ0PuXc0mMdXh9c007RJSwyd6Iso5WvbbODmb1Xr9cj/sycybcbV/pc5xlREnJ1jMxcxCeQsie9/7eXrEDkmgkBkSsevk/JB8hz9GKkTnGsl8/nH6FHaoLseE4+6eig7/uIrbgDR56AlSGj/f1kfLIjkC/dun3n/WCsplmPFQOJes88LgkcmQOUNcuRaMdSx5hJnGZN39rljacugK6q/RMdtiybUw3rTvv5yBEmhidffbB6rtMxjk+2o8QIisbdqr/?iv=8lHRpPzyIXd685EdT56qqw==", + "sig": "f7587d29c0988b57a1e893b8a0262a90f9e6db0653292a3feb20fa9dae8097de118abae6e5b79012a6760c57f9617dd6d1b19c0e5cb44094cd180baf88ddf2a0" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "00335edc3873fdc8afdeb2bee9ced035ea8f19f38ea3b06477d007b381d95d4d", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649709043, + "kind": 1, + "tags": [ + [ + "p", + "f9e24c0a9544d119b4f0e31ceac53d1b650c763e378541e1dfde402e350f5792", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "d60a7380600f7bee4c0f3da47bb7b3be4a4eb9cf048dca3e568312d64535f678", + "wss://nostr-relay.wlvs.space" + ], + [ + "nonce", + "1836" + ] + ], + "content": "it's not possible yet", + "sig": "264112a339bb4e3efe54a0f3ed3f2209d61a442322db0ee400d04b1318a5c4833671b34d62e8af90b0b8165231c500ea2933fbea0214726ca8e6e2d05723f327" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "002d100a476a7e952ab020b4b4f3a0455ff47c0e4edd2e87fac25a581e06482a", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649709525, + "kind": 1, + "tags": [ + [ + "p", + "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "000e713f0c7f601ffc34ceee19abe845afd30c215999163cb6ff4f71dd5dbcf8", + "wss://nostr-relay.wlvs.space" + ], + [ + "nonce", + "149" + ] + ], + "content": "super!", + "sig": "017ae0d1299e6cc28435e5787a32eff4ced3ec72c02305ccf45e4f8513f0f57c6dadbb9a57a1ddf7dc2ed69c70bc545a7688edffa7caa27bc6560188bc2ac6a8" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "002ce2865825eb3c5afe26254b29ccb15d0ed482e378f42d14229212e6bcb2a9", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649709560, + "kind": 1, + "tags": [ + [ + "p", + "b44b0193849f60587f0a17cccd3ade26668a7e36eb453acb1a94e6c1f45b4af3", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "c2776c15ca28828934446679842f1752ec31c0d6033dee0fc8b023d12127779b" + ], + [ + "nonce", + "18" + ] + ], + "content": "that website looks down for me", + "sig": "8fb5410be9690119c173178861b138b9be89e2f98831c5308a66e7b919bb8e597b323ff78669ea2f6c99e1dc635589ad055de759bb74795c57723833b431cd7a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0037cfa67026df39d97c52c219e6333e13efd5262aefdd3b52266596196c64ab", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649709930, + "kind": 4, + "tags": [ + [ + "p", + "7927bc6e25892729a9c02a1332c409a69b285e143b9d845c54fd9c1fe829e25e" + ], + [ + "nonce", + "58" + ] + ], + "content": "wxOfIYXoUR6708qnRjKJWM3KpFKxJsvo8cvK9zGaFWi6FFSUfR+Ne0QWw5nj+9EmuaLRdBRl/2N6ndbmbszU2IFWOLUqKapJM3pD6FtupQ+jclQQFq+fXzK+mey2jeAXlh/1BJgWyzVYx61PH8Mz9n1GAwKAzmh5YgAJcWWrljw=?iv=VYFMK47cORbGju+wG9A85A==", + "sig": "4dffc5319d23250ec80d919823519c684dc7f854b6d40e66b4c20e8613b72c8e82a077e7f294de8aa35aa572aa8c76f8cf6e4e14c63c980b676226718e911b88" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "003ab85e335c43ab18aa981287ffa3ace0e7ea84bf1d2c615c315410db088638", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649710251, + "kind": 4, + "tags": [ + [ + "p", + "7927bc6e25892729a9c02a1332c409a69b285e143b9d845c54fd9c1fe829e25e" + ], + [ + "nonce", + "263" + ] + ], + "content": "MTSZsBoGFbTrpJQr6WTvY5Lwk6fBIupVnOJ9xMtKQdhJTlM7SX5jZ9efR35bkfI5OTvDMqCkJnxV9iwxtUITlA==?iv=XCbBpzzrx8VB2zr4blMZPg==", + "sig": "8276444c0f5938122151eafc3cbbe556841b21b0f60a797f2ab9876e1d9a1acfb73dff3a71234c87a0e6937ae2760bf8d235b0f2f87fd491703e039dff1322d1" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0013cee55dee8f38ff52ea9ba24fba741f2e0959de62e936750f0c85ae8f0878", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649710300, + "kind": 4, + "tags": [ + [ + "p", + "7927bc6e25892729a9c02a1332c409a69b285e143b9d845c54fd9c1fe829e25e" + ], + [ + "nonce", + "92" + ] + ], + "content": "38QlqtS77C0s03JvGWdv7Jw2+um4hfjqy882yrtVfyWmSJRLJ1mi/ud2i4v9wMEEH3/jsi23mR3/dUBz++HonXohg+nInr99XyDmU+5pHLGuarchefNXpODpFUcT/vMSduQWB5z1cet2U/xfIG2DBAzpL13CjrKFrbkorir3IlkWea8RIGlmq9IvcRWOttXwxep/pDHYzo30jN7qAcOhGA==?iv=YcoNYV5tbhLTyX96Hnsmtw==", + "sig": "99baa6806925078c455cb6c41e93d6b3b93db6c4965005e2d1649b1e9af35869a34873d7c82d97949e935e1d2fd2f7e6f463212763b5a54b330c30fc2bb19c3d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "002f1241d28caf602cd6cf6ae68865da9b5074851e5fdb561388905a680d23ec", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649710432, + "kind": 1, + "tags": [ + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "aff9a9f017f32b2e8b60754a4102db9d9cf9ff2b967804b50e070780aa45c9a8", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "7927bc6e25892729a9c02a1332c409a69b285e143b9d845c54fd9c1fe829e25e", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "41a3905e0dcd61739d369e07a558e1dabc22fdf45a9c6c5f08f3ef8b4b6c6543", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "2ee2b0a6a5416278dc8bb6659fef166841d0fc1842f17895a3125f962d4bd3d8", + "wss://nostr.rocks" + ], + [ + "nonce", + "1166" + ] + ], + "content": "I gotchu!", + "sig": "2982f24ec508ac0a36436be41164e5f7fd268a9014bab3b06ad486cb837c184882044f19c1206dba110bd759716771bcaf931440052bc6b8aac5054c01a55586" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0009191cd3ae4bcb35e33c0a35c75cf4e95b063770c7e9b6825a8a85a0700f22", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649714249, + "kind": 4, + "tags": [ + [ + "p", + "7927bc6e25892729a9c02a1332c409a69b285e143b9d845c54fd9c1fe829e25e" + ], + [ + "nonce", + "721" + ] + ], + "content": "ljbkKwBYVfc8HTQb3cDyVONgt5kJHZ51F2VkBjb0TtfG9RW4UYMYy5wypRLbA4qHFUaq3gO6CBRkrhI+qRKt0Mlxa4iWJUEBkt4ei7w0pB/2ZzwCEuqD49ZVO6ENQKLhqKzZ8aEN12Fi7EADg6e7NQ==?iv=RYcyr9rnpzIOclytBxHX5w==", + "sig": "8210abf546b38af1a4d75eccfe0bd45f4808e7eb33d9888035bb467a73360673508dcffe50a616b87b0776643d939a5c8cd5747a3e2bdc92e9702b95a77a47de" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0024eba40184a7c516a3e0d17f05ba5cdaf94e13d60552f7132aa2182cf74b9f", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649714770, + "kind": 4, + "tags": [ + [ + "p", + "7927bc6e25892729a9c02a1332c409a69b285e143b9d845c54fd9c1fe829e25e" + ], + [ + "nonce", + "1379" + ] + ], + "content": "w1VH47Myo+FpZO2go+OScg==?iv=2m5qmpMxpY3sqGdmu9ATzQ==", + "sig": "984f634a631f1efa70e994da08605417929aaaac0860fa6a5feb1495ab359d28eac23e79554cd0389667fd8d29e4312e83e55d87627e3311fe0a643603311db1" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "002ab727f5e7da11ebf628a96b08d560b12a03926804a447406b9352cd347d7f", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649771475, + "kind": 1, + "tags": [ + [ + "p", + "fd3fdb0d0d8d6f9a7667b53211de8ae3c5246b79bdaf64ebac849d5148b5615f", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "2d75526292ad7a0c5994ba7d6244fda4b4c1e879107c7938d85762e0a1216b99", + "wss://nostr-relay.wlvs.space" + ], + [ + "nonce", + "223" + ] + ], + "content": "it did! fuck yeah :D", + "sig": "a3ce1bd0981ca67092506082b8ee293f045d6e938b40930e95bc40913f2e61d349d1ef6b0f06ee24665b9574ef7ccf1f719013aa91f4f8c0ffaac67dfabc95b3" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "003b9560b3288cbfae261a755495e29f1721849446ff78a2fc703c9eb5eaf913", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1649786901, + "kind": 1, + "tags": [ + [ + "p", + "fd3fdb0d0d8d6f9a7667b53211de8ae3c5246b79bdaf64ebac849d5148b5615f", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "9db7ee96898d2aac62e7f1a9e9bea6801319dfa153891cbfb0970e144bf0dfb9", + "wss://nostr-relay.wlvs.space" + ], + [ + "nonce", + "147" + ] + ], + "content": " Branle does not use filters efficiently for one, that's perhaps part of the issue. I think it will be good for the ecosystem to have more clients that are platform-specific but I don't have the time to write a full client myself.", + "sig": "daf5c1114adf20afbbbb1b62ec0e41e93d487ca3547978d9474f84d9f4fb4bb1643e61b14a4fcb43a3df9e577f50029baa5fa095051c136ba6326aa109a512f9" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5638df7f2d271f4aa4eb142a4809a0a4265e7608090457c7a6fec1dea4ee2a0c", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650885386, + "kind": 1, + "tags": [ + [ + "e", + "9ddde5f120cf6c4d2ff89dd291453eb5a9febeee7ac7eb95e3e0a4fe187950ef" + ], + [ + "e", + "bf772aacf241301331f91abe1ef5bb6cb33ba8a33a3047b18a6ae7779cdcec26" + ], + [ + "p", + "9630f464cca6a5147aa8a35f0bcdd3ce485324e732fd39e09233b1d848238f31" + ], + [ + "p", + "b238e136091cb01cd21606dac1a2f503f504e7e8e7c75d98fcefd30aed084a1c" + ] + ], + "content": "yes lightning nodes do this for you \n. You don't have to do anything. The more channels you have and the larger the channels, the more you can earn.", + "sig": "ee3c0c54ca5587e0305beed99526a8b6adaa1e924cbc95a640cb28a56a8d69cda93d7d863b8a2b981137a4b125ae7cac01b1619bc05ef4f2311174f205b28a1b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c4b6c5d4e756be16fc559ba8e0c2762a562e755625064611d87d7f827f617bab", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650886049, + "kind": 1, + "tags": [], + "content": "gm", + "sig": "f781b2d3cde5526d1c76f0c0d5c052945aab11e4ff41b47507293dbde053c0c3cea256f4a1bccd1dbc80abda873a091eccbea8c30022b316afdae862303b4198" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0b3fce904df3356e1e9a4c6bd97d9f25f43ca1b20e36d769a555d5c40709962c", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650888192, + "kind": 1, + "tags": [], + "content": "relay registration idea: you pay with sats like expensive-relay or you do at least one post that has at least 30 bits of PoW", + "sig": "ca712c6e7378639337f4b2b692144e510dd4a7581014bb58eb8a9e5261051fbc72bb9022a4017bc0f43144002c8b13d0377438ec7fd2fc9c3c6b76ab40733288" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "335268e2d533ac5bb21222deb237b0fa2f2c354292a810d2c5fdc9f44d33f844", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650888309, + "kind": 1, + "tags": [ + [ + "e", + "0b3fce904df3356e1e9a4c6bd97d9f25f43ca1b20e36d769a555d5c40709962c" + ] + ], + "content": "This would prevent outright spamming of relays with random keys. But I guess attackers could still spam afterwards. Maybe they should get put in timeout if they post too fast after signup.", + "sig": "45650fa8416e082b0441a362e1f79c9e2f8e33ecb33ab5ef0e7c1bd3ff39c5ddcc9ecd54dce315820e31b52719452c9c06b53c16aa5d7403fa95514ef19ddf63" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "ff18d9a1dd7b60593e744c11d1b6250087d8dffe79e608b33d4beec1422299af", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650891745, + "kind": 1, + "tags": [], + "content": "I'm doing a podcast with fiatjaf and MrKukks tomorrow talking about nostr!\n\nRT @ODELL\n\n👀 new @citadeldispatch at 2P ET / 1800 UTC tomorrow:\n\nNostr: an open source censorship resistant alternative to twitter that is not controlled by a single individual or company with @fiatjaf, @jb55, and @MrKukks. \n\nPodcast: podpage.com/citadeldispatch\n\nVideo: twitch.tv/citadeldispatch", + "sig": "29a5111085a1c94061911a72d9e876a29c471704576ff69347da1d1de7b601dd3b45c9ae3b54468e31c8b5c5bb9ea05dff8a7644917a8b28ae86fe30d0068e39" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a11ad975d17cde363292b48ce1765721a1a37eb2291fc6b791fae169c14e7132", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650892818, + "kind": 1, + "tags": [ + [ + "e", + "ff18d9a1dd7b60593e744c11d1b6250087d8dffe79e608b33d4beec1422299af", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "619d4d92c439a5dfb9d5380ef0a3eef9410fc06a6c05b5a1c606da826ce8ac79" + ], + [ + "p", + "b238e136091cb01cd21606dac1a2f503f504e7e8e7c75d98fcefd30aed084a1c" + ] + ], + "content": "I have renostrs almost implemented :) soooooon. Please let's not call it that though lol", + "sig": "43d2eabf909553fb906667406ff363d8ad151eb67cbd96d59e99ad8cbb303a200b7a4f4757b310fe74108cc03a6e4b43a7b492a3646984cf0be83fe833c9983b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f01a58589d1d45eaf7d5ab94ab48705b209a41034546892e91e832855113bf71", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650893365, + "kind": 1, + "tags": [ + [ + "e", + "a6577ba5e51664af42942e6d06befdf9cc5e6779efe6b68da388a34886cc79e1" + ] + ], + "content": "#[0]", + "sig": "f62690076da0921e8cb69a5686377d289e12ebc3dbe016f7e0abc581f810a91fc14f3136b76f124d8ebe297be2eea85bb244b6dfded20ef8066bd0556587ed19" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7cccf4cc8e3390850b915a62bae6527b64f5853a5d10897c577b490137db4a62", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650894289, + "kind": 1, + "tags": [ + [ + "e", + "e5888f0ba62859c72c26090142417b554cb54ddb1f38ce9f099d2a143d340f99" + ], + [ + "p", + "b238e136091cb01cd21606dac1a2f503f504e7e8e7c75d98fcefd30aed084a1c" + ] + ], + "content": "gn", + "sig": "e239d6e22bb79efcc10d79976031bf0e6ec052f61ff6f0df26c2424ca229d140fde97764f26546c812f1bed0576538e779b4e7a4387eac2ec8e9cd8896684efb" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "001f04204c63769a28c9c637ab773562c7888f0007f5c3a6dd602632055cf8d3", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1650898616, + "kind": 1, + "tags": [ + [ + "p", + "b238e136091cb01cd21606dac1a2f503f504e7e8e7c75d98fcefd30aed084a1c", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "ff18d9a1dd7b60593e744c11d1b6250087d8dffe79e608b33d4beec1422299af", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "a11ad975d17cde363292b48ce1765721a1a37eb2291fc6b791fae169c14e7132", + "wss://nostr-relay.wlvs.space" + ], + [ + "nonce", + "1431" + ] + ], + "content": "renostrils!", + "sig": "f0c8299f6b97b811d0b58faddab1acd400381f886e71f0fa79df0b1740fd4b4147d043143fc19376ac9f63294850d6ec35506e2516fa497250eafcfaaa8a751a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7e2a74163c31cdc10847803c45dc7c57ff62c8b9b116bded2b0fef4716bd87a3", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650899112, + "kind": 1, + "tags": [ + [ + "e", + "ff18d9a1dd7b60593e744c11d1b6250087d8dffe79e608b33d4beec1422299af", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "001f04204c63769a28c9c637ab773562c7888f0007f5c3a6dd602632055cf8d3" + ], + [ + "p", + "b238e136091cb01cd21606dac1a2f503f504e7e8e7c75d98fcefd30aed084a1c", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793" + ] + ], + "content": "This was my first boost note congrats lol", + "sig": "2ddb46c1235254d607750452309566cfd992db450a9e3cd79baafac3baeaca94491413312c8a7909feef7f2c9461440b7947b10cbce545ac0875002bd627456f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7f326c254dcaf636cb41aeb1c7f75238b54bdba82a3b7c68eba163effed3298d", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650900145, + "kind": 1, + "tags": [ + [ + "e", + "21d1192ce57aa55af84494c495f21cb81814cf53bdec756578a0f49afda05a2b" + ], + [ + "p", + "d7b76d02c758a62a505e03bd5f5049aaee4e7e36283d273c7f6798912692df2b" + ] + ], + "content": "The web sucks", + "sig": "b443623e574f91094e14e83ea70f9579369e00e085e6b24f39966eabca3a03c50d928f73760d7cacf6a920f06a552ab476ac56874121aa1eb366b1b89c935a62" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "e80a5584f721c88000888485536928b489831a93baec88e85505f196eb5077b7", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650900277, + "kind": 1, + "tags": [ + [ + "e", + "3f826f7660575676e003ba67e7c2c6bdb668dc43d5296365d3a876f4676b3548" + ], + [ + "p", + "d7b76d02c758a62a505e03bd5f5049aaee4e7e36283d273c7f6798912692df2b" + ] + ], + "content": "1. Could be handled by marking the contact list as revoked or by created a revoked note. Clients and/or relays can ignore all further notes from that key if there is a revoke note.", + "sig": "b431c3fb670cc8a5186ab24810279b695c4f0232b795e60e6f7520e2c958dbc1fd0fadd781017f3452d1764b987761ca365f0e5fc042fd77b634d0078c6ff346" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "bc3d6f358b5da52b1b836d208ad26f488a47bcadc9e170dc353409942620e751", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650900780, + "kind": 1, + "tags": [ + [ + "e", + "3f826f7660575676e003ba67e7c2c6bdb668dc43d5296365d3a876f4676b3548" + ], + [ + "e", + "3c7b7a0a6c75f26b6f8f5572ab3d56e2e23549e48d2d46d7b1a1bcf6714e6441" + ], + [ + "p", + "d7b76d02c758a62a505e03bd5f5049aaee4e7e36283d273c7f6798912692df2b" + ] + ], + "content": "Please do! I'll implement it in my client.", + "sig": "9ca4bb8a5db86fcd93c9a4fb3d5adebf270d27b3cb003e3536118d065363b09b7a5bbc08bb97b0e6e5004b5f82f5d845f54bf64976347921667667bd607ade35" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c0add11441aea457279b38004e53af84bfaa8d4272e7307a882f480b97d9e71f", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650908959, + "kind": 1, + "tags": [ + [ + "e", + "cc44cda0fdcc7a1fd8c96b179adb3d7b99bc19cf36a4f1920d65c0e3a80ae5be" + ], + [ + "e", + "9143a323e2c26c82c990b7c42fa8a6dfdbcbf134baae1e782a5ee4db0e7da223" + ], + [ + "e", + "39c94f62989c228971c9d629e8a0b5198d30f1e5cde89a31f06ac030f192a32a" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ] + ], + "content": "yeah fiatjaf man I paid my pubkey lnurl like 3 times and I still can't post to expensive-relay. scammed!", + "sig": "dd67c867dc7b6ac6f9796646124495d9efe92a1e9a6a3593215cb883167fac5358e06844d4153bf0f68ec626ce73c75e0f496ee3172bd589a1ed0b26d705544c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f2103664f69a390e9385a66290ea2ecb093f95aa1b706029d7318714e8576720", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650909264, + "kind": 1, + "tags": [ + [ + "e", + "3f826f7660575676e003ba67e7c2c6bdb668dc43d5296365d3a876f4676b3548" + ], + [ + "e", + "21d1192ce57aa55af84494c495f21cb81814cf53bdec756578a0f49afda05a2b" + ] + ], + "content": "test merging two threads", + "sig": "040c5b9f94d015ed88f720e305b2762ab3e3ada8033035ef26ae598db74e4b441cbacfe71920741e1bb8b2d605e85e2d24d0d074749f9f53305badbd3cc1e57f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b9e093556362497f888a436ef418ab5d688638476a8cfb84dab691b3d6ce1fa9", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650909705, + "kind": 1, + "tags": [ + [ + "e", + "c4b6c5d4e756be16fc559ba8e0c2762a562e755625064611d87d7f827f617bab" + ], + [ + "e", + "21d1192ce57aa55af84494c495f21cb81814cf53bdec756578a0f49afda05a2b" + ] + ], + "content": "test merging gm thread and broken avatar thread", + "sig": "f6ce41d3ca9fefb663a8ba2bc8f6e8ccf0d35550daa382ce7f961376075d75615665ddf18ba03c78f0848114a4f387c9fca151b790ca1eb47c70af5a56b46bdd" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "35327c5245f87ab058b643e6958addcce3c300482f025b8c6846f32bbcd5b279", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650913274, + "kind": 1, + "tags": [], + "content": "Elon fucking did it. What a madman. When twitter readonly nostr relay.", + "sig": "f93f31604b9e8691c80b572d35ee453392827175b5c55e0a7edac88eadbd6983aacf89047bb0176b2399811f565adb28fce2def113d204b0e54780c1bb3f5584" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "00f9004270aee69644280680ad46bf4371567e2a752dbaaf858aa93a877bf956", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650914290, + "kind": 1, + "tags": [ + [ + "e", + "35327c5245f87ab058b643e6958addcce3c300482f025b8c6846f32bbcd5b279", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "4bfde64d5873a525a41460a6c32da9d626c0476f2cce0244106a5bb2dc6128a1" + ], + [ + "p", + "d7b76d02c758a62a505e03bd5f5049aaee4e7e36283d273c7f6798912692df2b" + ] + ], + "content": "I almost have it implemented in damus. Sooon.", + "sig": "ebc2373e2f685515ad3190de31b3fabf4f41389239512cd289718d6c98d4aee88e569dc4f7c58b4ea619fdf7e75b4add8e7b6a4f32dbc3754c6268a75a536e67" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "93c8990e252b285580ac5eed3a3abe9def058ac0187bb8b25229348ee01b56f8", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650914411, + "kind": 1, + "tags": [ + [ + "e", + "21d1192ce57aa55af84494c495f21cb81814cf53bdec756578a0f49afda05a2b", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "bc6261775beab005bf4231de964d01f38246a9ed9da51a9d6cbf24585dae8ef3" + ], + [ + "p", + "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a", + "wss://nostr.bitcoiner.social" + ], + [ + "p", + "d7b76d02c758a62a505e03bd5f5049aaee4e7e36283d273c7f6798912692df2b" + ] + ], + "content": "Weird, i wonder if it's using the HSTS config for the root domain or something?", + "sig": "69669b1e096aa40792e0399a59b386698357473307fc07e8f38e0210d75444ca3945673640492ae616fe5c3b55ed2399b9179eab9c123e61644b1b38ba5577ca" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9c58408eb966def80ddf5afd21c87df1f0650b17214517e86a280528fcda087c", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650915068, + "kind": 1, + "tags": [ + [ + "e", + "2cc874a6502ff9bf85be00b46b0a4f0fd9cbd3d7afa09b75a4e1d7b6ad89903d" + ], + [ + "p", + "9630f464cca6a5147aa8a35f0bcdd3ce485324e732fd39e09233b1d848238f31" + ] + ], + "content": "wow that's not bad at all", + "sig": "0deef12c66011d0c9e57c6a9f7b0a7b8bf43ed24d496d0b40dcc2b56e6bc794ae6ba0f24d05253052bcd94b30b974c8acb35b2332900e3592c0b31503bcdc4e2" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f068dbf3cd89c62b2f59437a14d48dd851e23d3369ac964617fff68ffde3b1f9", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650916115, + "kind": 1, + "tags": [ + [ + "e", + "ff18d9a1dd7b60593e744c11d1b6250087d8dffe79e608b33d4beec1422299af" + ], + [ + "e", + "5536125386ff45c508f70bf31170b9eb58817eb4a7547f4b43131e35a84b10f2" + ], + [ + "p", + "8ba2a6b558eeb7fccd1862b905ae9d9408cfbc208f1680d1262733246e92d4da" + ] + ], + "content": "Thanks and welcome to nostr!", + "sig": "f3ae4234e3439bb18e30a4e6c4ed30bcc29f2ae0728b8704f7cd4f3f47eea21f18de4eb2b7ed68e30e82974f46e53911e3cbe4c94becffdcd0b6ef594cba5a72" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "20b5afbb3a9e102eb8a20ed704945105e696bd63c5ef94ef7c9a13ab5a772b83", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650916284, + "kind": 1, + "tags": [ + [ + "e", + "598e5b8ae5e384843a2bb65bff32de5a244151c5683e100b7c987daf94e637fa" + ], + [ + "p", + "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9" + ] + ], + "content": "Welcome!", + "sig": "5fda291e76acffffab14ad1ea4a7dbce23fd559f7e53861ba38b354b1fcf42915f17189aab6d40120d5dd669e35c904066d4205a8d5cc687dc3d7bb36e068a44" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f5c8f93a7613185dbf52fd1fa3bdad3a0e4a371052a337932aff09a41042d32f", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650921158, + "kind": 1, + "tags": [ + [ + "e", + "598e5b8ae5e384843a2bb65bff32de5a244151c5683e100b7c987daf94e637fa" + ], + [ + "e", + "2397ecc533f5144f98b7a180a66d21725c867ff4ebb1293dedd4c122418e91b2" + ], + [ + "p", + "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9" + ], + [ + "p", + "abf12c3fad52ab1a5313fea429a5021ecb652b4e801d516c1c1f203f0c575983" + ] + ], + "content": "A lot more than two lol", + "sig": "3a1a4b6032f08d3ea2a0e950d11121fda9ac9536d792519d66a414c60c849dbad15acb4ed06c072e03f7b252c1b942f333fcd6cfcb6107c4bf3a61810873c4da" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "bb32b8d3771f7158d1534094dd5885f3ed887af81440503d316c7eab539ebc1e", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1650921177, + "kind": 1, + "tags": [ + [ + "e", + "f541c0e8c34e43946e9a1d66a65c5ae9129880df8d03b495d7b3f490a8f09291" + ], + [ + "p", + "5509170e5deba872446850bc289be2cc0f98f4ebf97d0b6fb6eea742be9d6eca" + ] + ], + "content": "Smart!", + "sig": "4724b1dd5d87c603769dc3abc56b2cd6d611100977a0e5daa5f2040b6fb5f5d41aaf7549eeded7674baff88fc6be9267f2d6ca9c7fc5cb1f0b70142d9aa5ad1a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "000115edb9b70a5497035927690add73f1009dd09ea3309119453302c6f05f89", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1650931361, + "kind": 1, + "tags": [ + [ + "p", + "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a", + "wss://nostr-relay.wlvs.space" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "8ba2a6b558eeb7fccd1862b905ae9d9408cfbc208f1680d1262733246e92d4da", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "ff18d9a1dd7b60593e744c11d1b6250087d8dffe79e608b33d4beec1422299af" + ], + [ + "e", + "85135222be8f3e63154ee63d13d1a9a36e5965d2657f38c93d19948cb9a36bbf" + ], + [ + "nonce", + "78" + ] + ], + "content": "Welcome!", + "sig": "1dfa7e2347a8508608737e8a769968894025c5be9128e6e30ef888a4e3918ce2fe5ab186808bedd36a8fdafc4b050c5a7f9535f7537fb351baa4844adecd8a14" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "000fc9e5f6fe9285e6da085dc91fd6f82ec5c4ad75d2cca02a33f09c6c75d37c", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1650931420, + "kind": 1, + "tags": [ + [ + "p", + "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9" + ], + [ + "e", + "598e5b8ae5e384843a2bb65bff32de5a244151c5683e100b7c987daf94e637fa" + ], + [ + "nonce", + "185" + ] + ], + "content": "welcome!", + "sig": "878d001c69a70d764d44bc3e92efcd514a4e74524916a565c68bb4693a97a482b8536b2ab0305953f212df6a889df338bc533e076c117a30194cb2deb64b05a7" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0008e172f30406cd02d9a24adda8337d9fc57d68f210d768bf71108f0b1c3cc2", + "pubkey": "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "created_at": 1650931525, + "kind": 1, + "tags": [ + [ + "p", + "d7b76d02c758a62a505e03bd5f5049aaee4e7e36283d273c7f6798912692df2b", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "43f3c38e7e621a65bb11833175d618c678c7560a0734d30bc014462968e1018f" + ], + [ + "nonce", + "1044" + ] + ], + "content": "looks like it works!", + "sig": "c7eeff3a19c6c4b26eeb5c80a84ab276a08e44ec0e4ba84d98003076e690f5ff2fa62e16017fd113a019499fadedf1801a39eaf841d29d5a6f1b26e56d52623f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2e29f66297c0d09b0885d2d595bb8bb9878791921c0a68520aa779de1917ded7", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652428501, + "kind": 1, + "tags": [], + "content": "A guide to getting started with embedded systems https://yinka.dev/blog/a-guide-to-getting-started-with-embedded-systems/", + "sig": "0c4fdc3f77ff81992aebf45f33e067ff2bf88381b200baa9857f4c4d1bea7341af701ef1b55931520ab50aa188c05e4c418c38107473b4553294f4b2f501199e" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "bee2e5af1427adbec423f758648679329954b3168cdf683651054966319acce1", + "pubkey": "c21bf1e3b2988a1d7cd80348efc936db19cb51ae0115fa66d50d282ec5d9c63b", + "created_at": 1652428844, + "kind": 4, + "tags": [ + [ + "p", + "3702743c98430ba152e635b081637716a3c949c13ad3ad1e6c80e6e7d41fbc8a" + ] + ], + "content": "rSvyM5/e5mdsUAcCT6FmYQ==?iv=YgXEZzDRZO3VSdNLlkUF9A==", + "sig": "1e858d53671c8c322cd72231623f15226dc72b2e45f01e89257d261abfd44e2c196ae20a9a30b9000e61312e5861e18fb1fd9f9f6d0a37a89300dbacbf224efe" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7983975d0e6ac6bce805188e587dba276042d5aa3ead94b809fd2a8f125d7c65", + "pubkey": "c21bf1e3b2988a1d7cd80348efc936db19cb51ae0115fa66d50d282ec5d9c63b", + "created_at": 1652428847, + "kind": 3, + "tags": [ + [ + "p", + "7cf68b47a2b243d06322bfdb6a1c2422fb8b3a18d18a5c90c27b59e8f612553e" + ], + [ + "p", + "763c78643bc52d86b7fc82fbe76c29b634e83d237ce8dc61d3a56e6ded4cc8eb" + ], + [ + "p", + "2c35ccc5ea21fa62ab12bf350399ed1aab206529c62d3c270b9f87d572966878" + ], + [ + "p", + "b090261be17626b3e64d944aa2a0985caf61fef90361aaf6a76dbfeb26f2f782" + ], + [ + "p", + "3702743c98430ba152e635b081637716a3c949c13ad3ad1e6c80e6e7d41fbc8a" + ] + ], + "content": "{\"wss://rsslay.fiatjaf.com\":{\"read\":true,\"write\":false},\"wss://nostr-pub.wellorder.net\":{\"read\":true,\"write\":true},\"wss://expensive-relay.fiatjaf.com\":{\"read\":true,\"write\":false},\"wss://nostr-relay.wlvs.space\":{\"read\":true,\"write\":true},\"wss://nostr-relay.freeberty.net\":{\"read\":true,\"write\":true},\"wss://nostr.bitcoiner.social\":{\"read\":true,\"write\":true},\" wss://nostr.bitcoiner.social\":{\"read\":true,\"write\":true}}", + "sig": "870d4f0e9e1e213cbf52d307dbe9070e0c563f1eb56a6796eae0097aaf1c9a592709dd95a797d475842355df79ec15c7e7ea3445c22609ac752e33fdd670a4ae" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d884dc5c0dcb9e5d0c6dfbe41c2327598500138be6268250673b2ad9bd3e2a87", + "pubkey": "7cf68b47a2b243d06322bfdb6a1c2422fb8b3a18d18a5c90c27b59e8f612553e", + "created_at": 1652429524, + "kind": 1, + "tags": [], + "content": "一个人不该过分自省,这会使他变得软弱。理智的做法,只有在做很小的决定时才有效。至于改变人生的事情,你必须冒险。意义非凡的事情,总是碰巧发生的。只有不重要的事,才有周全的计划。--珍妮特·温森特", + "sig": "3d232d83d8802992d2e844d6232697d34f5aaef78139aed616b08644e07e0117dd6b8f24ec60016bfd8c171b505373a19c7f9d443b2ea1dbf884121a3546936d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4fef06a3d6f5d92134a2633c1df33f13a5581f3d3bcef5379e454873230a7405", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652430901, + "kind": 1, + "tags": [], + "content": "Show HN: A Visual IDE for React https://dev.aspect.app", + "sig": "418157e798675abbb482337f0a3a39ff92c720d51384521468843cf835cfb813573e785af4ba0474298d8c35a2bc3f32857966e1b8e11bd0f47c3f26302143b7" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4d915ef7cf49e0ea49ac9d1c3d7e67743da15ddaa02286f2597a1fef1eb3264c", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652433001, + "kind": 1, + "tags": [], + "content": "Butyrylcholinesterase is a potential biomarker for Sudden Infant Death Syndrome https://pubmed.ncbi.nlm.nih.gov/35533499/", + "sig": "9672ae089190fa4e7fce7b0e8ca4a28d40b2f2d3ba17ba2789c58a0fac9196911de9013db93cb4e9c1d7e9dc04dd8b2e29df2f7bbedba0c8ff9cd1e3e5b2b609" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d049e559ed581569e110e5666ff861dbbca7912a0c9c42443b5b82603be5a7a3", + "pubkey": "7cf68b47a2b243d06322bfdb6a1c2422fb8b3a18d18a5c90c27b59e8f612553e", + "created_at": 1652433250, + "kind": 1, + "tags": [], + "content": "人们如何能更好地治理自己?", + "sig": "24c5cfc2a0accc579113011e28f746e1c418d0c99857686097bf9eeff520d2d861eefb32dcd8ffd7102999c742d5f94fcfc96f944a1cc2300df80734bf900402" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a92db0d000956cedb6b5a47c36ea0ffeb259a94ef642852e5b706061174d8947", + "pubkey": "7cf68b47a2b243d06322bfdb6a1c2422fb8b3a18d18a5c90c27b59e8f612553e", + "created_at": 1652433685, + "kind": 2, + "tags": [], + "content": "WSS://NOSTR-PUB.WELLORDER.NET/", + "sig": "246b18ea4f2531dab363ad814ee1cc63a69b65d598c0bce6b9def4ace97434f19d3536e139c9611a4f57b6bd8ed8f1dfe3a886c36ae96e0140357e449cd056b9" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "22c149d0284683dd1ef19a9cb8687c862ee51693d43559b9039a30c53c03af78", + "pubkey": "763c78643bc52d86b7fc82fbe76c29b634e83d237ce8dc61d3a56e6ded4cc8eb", + "created_at": 1652433981, + "kind": 1, + "tags": [ + [ + "p", + "4416e97154fee968dffa2aa49d0042dde925dbc78261396f83f3e9d754e12827" + ], + [ + "e", + "65d43c096a61c541bb4ea8766900669d55624167de049b8a32fab790da4fd19f" + ], + [ + "e", + "d2e57eab09537a026cec752d7e0f359e4003fd1884fa8057d61de97b6eceda20", + "wss://nostr-pub.wellorder.net" + ] + ], + "content": "还有应用场景的扩展。这个有无限的空间", + "sig": "1510039378fad28458afe0a1015382d50099687cd9e1493bb1d88403c6a59c9ddadf190366dcd4cf855b096b256641a98adba92ee6e279abf02a3649bf824e97" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "48fe6485e2b71654b686bc9724b9d984b6216c7edba8c354586a05b2ebf8adaf", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652434801, + "kind": 1, + "tags": [], + "content": "Ask HN: Asynchronous FPGAs and flowchart programming https://news.ycombinator.com/item?id=31361858", + "sig": "c336c897af8532b84eaf788d3b40b179f37d9e9182526739320eb458f0fbccbef5dfad4a9b77ce14f96651acea884a2cf139f2b5e5e4d79751a71f777e910ac2" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "834565c777ac2d22f5e0734f397070a7ec25eeb8dd647095d7216b38bdfa3d30", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652435401, + "kind": 1, + "tags": [], + "content": "I fixed my broken monitor with a hair dryer https://johnnys.news/2022/05/How-i-fixed-my-broken-monitor-with-a-hair-dryer/", + "sig": "6cc50d74d8ef91e06db96233b755985e9bb98fd084075bef5e481e8b8903db984d0574db7f23941fb525b8aa425cdfaa617b3f43c662d822415760be4d287f4f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9294410747cd10d19b46276909643b06202a9738a72760b928bae180c0e96f04", + "pubkey": "0000a0fa65fcccd99e6fd32fc7870339af40f4a94703ea30999fc5c091daa222", + "created_at": 1652435738, + "kind": 4, + "tags": [ + [ + "p", + "7cf68b47a2b243d06322bfdb6a1c2422fb8b3a18d18a5c90c27b59e8f612553e" + ] + ], + "content": "HcHrEz2RX5NIYoyawVKqe73eIbLGL2nyVIJsXbKehl5+Nf0OIK7x19mNiMTTlwmH?iv=yR95m9fg8CwBFHsVJWc8Eg==", + "sig": "0b8d77c39e04ec1fb221f631ff76e86f0494ffebe45a0c868c8544eeed9bd52fee6e08984eb75827d7da4ef3895a92980f527f46bf9b4a1dee60ac728612846f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5789fd3f2b673c39817bb79fb95671826a668c1b70dd1969315d8020da52eea7", + "pubkey": "0000a0fa65fcccd99e6fd32fc7870339af40f4a94703ea30999fc5c091daa222", + "created_at": 1652435763, + "kind": 2, + "tags": [], + "content": "wss://rsslay.fiatjaf.com", + "sig": "8d864a439d57e25edde739cb23ef026c40fc7d6a457551cf3bc4f1d6eb6f03216a5e9fe8f9f5753bcd6ad2841afd4cc0bbedc517712cac102e7956d890148e1b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b56d2b51f44e13af31a50e07f371364f2aff6e2c2b9b9774664b42a80841cb25", + "pubkey": "7cf68b47a2b243d06322bfdb6a1c2422fb8b3a18d18a5c90c27b59e8f612553e", + "created_at": 1652435845, + "kind": 4, + "tags": [ + [ + "p", + "0000a0fa65fcccd99e6fd32fc7870339af40f4a94703ea30999fc5c091daa222" + ] + ], + "content": "fglFG1lyRftJO0hFkvU5dg==?iv=pBGdNc9pTvoFs+v1a9dThQ==", + "sig": "62a559fa8f9157d622749f400fa5d0a109c6cac63344325d4895a512872850fc5d4ebec8ea8ba166178cb945b0caf8758b9f984ed5890d6a576a355f8b338770" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f3e556f70da460c11e59f07e4450806acd318a8e259f2193664ee91425810b24", + "pubkey": "0000a0fa65fcccd99e6fd32fc7870339af40f4a94703ea30999fc5c091daa222", + "created_at": 1652435927, + "kind": 4, + "tags": [ + [ + "p", + "7cf68b47a2b243d06322bfdb6a1c2422fb8b3a18d18a5c90c27b59e8f612553e" + ] + ], + "content": "j7GUkVMb8Cezsq+IIpiyhx7AXitIQfjVaI0wJ55Dt/3m6BTb04eWuFl/gg37tpdBza9GDdNXzY/YljAj74KgKn4pZhtwsGcKOByEKN5REzXE3SCCstodtrTLOAA54IT6dOaFaXo1ivwr/NX7tNUnD0WM2lLQwmpIfqfKBQax0tSTn2/stEIa7XvVQYPnwFFdfJjIZHanm1hFd4e00LLCqLzO49jSdeSr6kpbIB/niRDJsIUd17HHN8vw8429EwZPKRAHqvuXUJ+lDgQgfGfYBMlYKLP13QzUuo7sAhKt7oz+GkOjB5r8zQjFwQCIIKbJ?iv=tkZ8jWB0RYoQVDiWcEh1tA==", + "sig": "f9f14009ce37f041cfc3b90b995b9b4bc57ff96a52b5830e469f50a65f67af2b6e59d04999acb8df910aa37a41e39762d3493d8d7a5108d942dade7a9a7ac9cc" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f1534ce2569cb3bd82d8e5916a81b8a832fe30337fd383645ece47e59ef18b7d", + "pubkey": "0000a0fa65fcccd99e6fd32fc7870339af40f4a94703ea30999fc5c091daa222", + "created_at": 1652435950, + "kind": 4, + "tags": [ + [ + "p", + "7cf68b47a2b243d06322bfdb6a1c2422fb8b3a18d18a5c90c27b59e8f612553e" + ] + ], + "content": "zI726sqyJ8twFbqPgWX/uDHEb9G9127JAECz3n4/EfNA1r/VuhVfObqkbC2Cxkix?iv=CuBbCpQj0mdXLh4sOmbQeA==", + "sig": "521b2ad6d655903668c1ed9a219fa596bdf008a3dd5b08a73b11074c4ec8aab6015eac67ef4ded90cb33b115b1e4fa415cfdca0647f118ad43cd40926fb581db" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "e79955e57e677e4a6fc884b13ae1630e714d7e35212574684c6b2b2fd40cde68", + "pubkey": "0000a0fa65fcccd99e6fd32fc7870339af40f4a94703ea30999fc5c091daa222", + "created_at": 1652435984, + "kind": 4, + "tags": [ + [ + "p", + "7cf68b47a2b243d06322bfdb6a1c2422fb8b3a18d18a5c90c27b59e8f612553e" + ] + ], + "content": "qBlS1D0UJydKE/tm3ytUJ+wqK4aOQoQMb46T1ng4xsY5Y5q1kOi0tJBur6ehxIM7j9loAZlbsiuhjtbrtbi8Sw==?iv=NjbfatZxwlMcwDlDq377kQ==", + "sig": "fa880d4fc0a068df8b46561496a269c1e85e312cf7e43c5d71485b233cdb996d1e492189640ed1a61eb983c019657ce053192eda2c34da5eb8426934ea516eb1" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "e9fd05dc639238219b559458a9d05a38170336fe8e7c43cb1df68890715013d7", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652436001, + "kind": 1, + "tags": [], + "content": "Luna Founder Requests Police Protection as Investors Are Visiting His House https://news.mt.co.kr/mtview.php?no=2022051308574726920", + "sig": "3688d69ba53f7078cf3b399235cb0631da7da25083c9b1a4ebf5c92667b55d8cef5bf50f2a5feb5130bba850e8adc29fcf6d41aac9b4eaeae7e20dda26e3b944" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f01a91b65a27183fd08e9ba25833678598b8d33db067d4aa03582930674e70f0", + "pubkey": "0000a0fa65fcccd99e6fd32fc7870339af40f4a94703ea30999fc5c091daa222", + "created_at": 1652436097, + "kind": 1, + "tags": [], + "content": "I am a lucky man. see my public key", + "sig": "622dd32b93880b499483c17eef14ec60cf69ad5047168d44b4062cdf9c0141fa6da24113f430531caae5d979fa356e63353d0c30efb040bce919f675c4ccacad" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c56b2a7d32a8d19af9a9e2f96ae4a757d97405ad868e2683accdf72444836a1e", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652436601, + "kind": 1, + "tags": [], + "content": "Police claims to have fingerprinted computer based on printed document https://www.nrk.no/norge/nye-opplysninger-om-trusselbrevet-i-forsvinningssaken_-_-vet-vi-sa-a-si-alt-om-dette-dokumentet-1.15965322", + "sig": "774f50d47d6ac37c03f30fd775894b84cfc58fd2b34bc01bb5d7b766601047a20a64362d9b81a3ecf9165cc8cff198f99cf24b3829e1d924256b6c455fa4c499" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "faed92884bdd263f0446e08904f8a2d598b4829b9970b55f14a654da28350823", + "pubkey": "7cf68b47a2b243d06322bfdb6a1c2422fb8b3a18d18a5c90c27b59e8f612553e", + "created_at": 1652436619, + "kind": 4, + "tags": [ + [ + "p", + "0000a0fa65fcccd99e6fd32fc7870339af40f4a94703ea30999fc5c091daa222" + ] + ], + "content": "/dmBZpJOIBd0vgPAC4D9qwV608DraajQqa/W63QnO+g=?iv=kH74k9I+yJSSDbqQt1DaJQ==", + "sig": "83435fc83cc12eff7805a30b012e8ec2272f9e2c445f11d3d8bcedf516eefe3fa3901829ca9516376fbff05337fad4c9e997e98e961607f65262d4f6d187caac" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0c0908baa1f7d60161d2eb800ffd12cbd6695b86440d897e726cabd59b8d7086", + "pubkey": "7cf68b47a2b243d06322bfdb6a1c2422fb8b3a18d18a5c90c27b59e8f612553e", + "created_at": 1652436635, + "kind": 4, + "tags": [ + [ + "p", + "0000a0fa65fcccd99e6fd32fc7870339af40f4a94703ea30999fc5c091daa222" + ] + ], + "content": "Fige0GHFJjBAeE9bxO6XNg==?iv=ADtNAOmpgTSnETn8V7/S4w==", + "sig": "e5e004b5d7356626b6fa898dece5bbcad84f70d95d3921c5eb49ee217e1aa5501af168e20d8ee0137f587d9bbaa1569123c00e967eb45ae8a68e1d9824b2e889" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "e46c1c647fc973218f998f295fd51e5fbde5a3ac1fc56aa54afb19623974ac45", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652436901, + "kind": 1, + "tags": [], + "content": "The Washington Post's open-source design system https://build.washingtonpost.com/", + "sig": "45412589c1041168af7205b62f6d671c0458e04ee9fe22202fc6fc3026884c380bc61a114eeecb449d699d32dc02427f3a08a1e3567208369c90d8d676396170" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b96eea9c6eebb410b26317fac716afb2df3806b31540167c9f6e79d57b22cdd9", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652438102, + "kind": 1, + "tags": [], + "content": "Twitter Deal Temporarily on Hold https://twitter.com/elonmusk/status/1525049369552048129", + "sig": "1938ca31bba9d12aa441432bfe77139fcbc020267ecd93c5805715836dbeadf16f710004ab4b7f4806b68868b5b704a438d67e57d6affbf3093035a6eda983d2" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "61c16a2d45e348aad3695fbd533f60638f6b6c7ee772bce49c606e947a3490b7", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652438701, + "kind": 1, + "tags": [], + "content": "Show HN: Fruits – sell digital products in less than 2 minutes https://fruits.de/en", + "sig": "6189a3c5c112b802122d67b2b194a73a9133a0b8425202e2c3fdf1f57caa1bad7a3098b97a066a635079010ed36ac3e67fbad648a363b837b31391b90502f53a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "ba34fd1cb223cf1545af5e3a8d66dfe1d385070d8dfcb2371ae328d0694ef786", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652439901, + "kind": 1, + "tags": [], + "content": "SQLite in Go, with and Without Cgo https://datastation.multiprocess.io/blog/2022-05-12-sqlite-in-go-with-and-without-cgo.html", + "sig": "c7635047869bcd0fd490184b58dc92c1f66ec5244e7adf6d75cd6dae946f6605d2fae07042a2cc0b2bfc6308fe7c355009d9266f6dea94045fe5680e4165ab17" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "ba67d61bef0b8e3f08b2aec677e2f79539df2d829b89f62beb4785682e1da955", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652444401, + "kind": 1, + "tags": [], + "content": "The Other Kind of Staff Software Engineer https://earthly.dev/blog/line-staff/", + "sig": "fab37805a8eb775d2fad6fbfa32e57d31ac54a0373f1c50575a405077a89898f5a5b821298dd9dc255e56546894b808b41e906409b4a1abba6771f3c3f30b702" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "05e90ded18a7bf5fda8565b2b6f95bf0ab2aad7e6c30f29ed9560571f049bb5d", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652444401, + "kind": 1, + "tags": [], + "content": "Employers’ Use of AI Tools Can Violate the Americans with Disabilities Act https://www.justice.gov/opa/pr/justice-department-and-eeoc-warn-against-disability-discrimination", + "sig": "3c45353887ca87ac4923b59c83c6643b27d1cd101cb02ab0464d6f64ab26b734d41eeb7fc9b84d767686672e9b2b080d35e8a2e1831f7626d92438fdd141c9d9" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "38f80f6a9c4cb79016b93dfd95fa1bc96e6f3ade7434fd5fb37497cc3459f709", + "pubkey": "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "created_at": 1652445240, + "kind": 1, + "tags": [], + "content": "I'm implementing the actual nip05 verification (it's not really verifying currently), and there's two ways to deal with \"root\" domain users. this is what my follows with nip05 have: https://res.cloudinary.com/eskema/image/upload/v1652445099/Screenshot_2022-05-13_at_13.31.26_e0dtul.png\n", + "sig": "590f7d8ff92609fe2369529247a1bee7a31de23261359d7ea42008fccaa6721f4b5b0cdecd47a1831947eaf43928ccc67636bdb2696190c3cc98f91ac0eaf6e1" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "17b11cf269365a96c58f319635f1844cd77ba7237b48e92f1dd2f784be06de5e", + "pubkey": "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "created_at": 1652445299, + "kind": 1, + "tags": [ + [ + "e", + "38f80f6a9c4cb79016b93dfd95fa1bc96e6f3ade7434fd5fb37497cc3459f709" + ] + ], + "content": "some are not using the _@, and just expect the client to interpret what they mean because branle does that\n", + "sig": "fe9ed9c7b1d0ec9e1972ebfe1caecedb3f952a7ba643c510c15b4d2319c5f330df97f45c5ffc5c23e33ff76b7f8757416c02227130c183739f149cee55979eec" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d0852e65622eecd97b7c98c3d2ce6b43e693e68ed6c58cbccec021c05a97dc99", + "pubkey": "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "created_at": 1652445416, + "kind": 1, + "tags": [ + [ + "e", + "38f80f6a9c4cb79016b93dfd95fa1bc96e6f3ade7434fd5fb37497cc3459f709" + ] + ], + "content": "I'm leaning towards requiring \"_@\"\n", + "sig": "c0fd342e1c24bde83f5cfefb7ccaa2828398bc4b3ceab51f162d85f274bc6e1e277292612e136252ff996ed7b53c992add53f2d74a7cde3194d11c4db5625417" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2b636d65dfd4b161d55b7be5da02bc452b9cdc4f3ab6c70e266258f4a02dbc5d", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652446202, + "kind": 1, + "tags": [], + "content": "Germany: Crypto held for more than a year will not get taxed https://www.vanticatrading.com/post/germany-crypto-held-for-more-than-a-year-will-not-get-taxed", + "sig": "a630758bf9d7f974f95882e4e3de78f91f4f8352571bb374c4695e9c2f9a3592dbc741eb9a5601819c14c104f4c6754ff72db762bbb05f1a4b03a79dbc1295c2" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "449820f757d5d58d7c6abb4e842c0d31c8d2d55565edc569a0d8f93f0e0f24f9", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652446501, + "kind": 1, + "tags": [], + "content": "Ex-eBay exec pleads guilty to terrorizing couple with spiders, funeral wreaths https://www.theguardian.com/technology/2022/may/12/ebay-executive-guilty-boston-couple-harassment", + "sig": "91c571c05a84e0bcab13a548bf29f211375883f4f8ac13474c201f5b93598293f1ae7d810e46e349b78cda6d0e6475be91e6ae5b8d6709161787317f4e9f9bb0" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "bb828b354855d2d5486deb1ea78479cc223546c42d07ac7dc5dde725b4f0c78d", + "pubkey": "b495402b0162d7f3a61940e7a26963e1859ac81fd00cb5876bb5cc632c43be41", + "created_at": 1652447612, + "kind": 1, + "tags": [], + "content": "PENDING BOOST https://feat-2311-nostr.minds.io/newsfeed/1371837773052383243", + "sig": "36626a00d2c969ea39ae454cb080605795862549f95b987a6fd4ad0f4a1af6039e8350b3b64c5df5b8fb9d11989296b4b0003670aede8d0c780b34f4f1ab589a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "55fcedaa0578ea310d4aba204b96929394e6c09b7d830c72f2bb6275c094e09e", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652448001, + "kind": 1, + "tags": [], + "content": "Show HN: React Routing in 120 lines (including comments) https://github.com/ashok-khanna/react-snippets/blob/main/Router.js", + "sig": "b1da535a9167e6a33ebe1a756b8532255cba4e6bd122b2d09ea51d4fbc4669576c1b491724c961ad9fefbc0baad39fc13e5accb5a2c8b21b1935e9b094c9b967" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d6db3dc0299ffc09f4da8e9f39846f12e5e6bba5cc17214b93a32ed722bbb543", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1652448947, + "kind": 1, + "tags": [ + [ + "e", + "f01a91b65a27183fd08e9ba25833678598b8d33db067d4aa03582930674e70f0" + ], + [ + "p", + "0000a0fa65fcccd99e6fd32fc7870339af40f4a94703ea30999fc5c091daa222" + ] + ], + "content": "Nice. You are very blue in damus.", + "sig": "022f4d326ca485618829ea7a2e6caa2fb79bbea16c82eb2c3feca1347a91a017a2e2d2f4efde9ed4eebe328ac52f009a12ad45e2f5071366fa57eed8568f304b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "cbccebd2f86fd4c0d4ec6e492ad5abfcfd090d54ac900d61c0dbd0cd68ea53af", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652449801, + "kind": 1, + "tags": [], + "content": "Building a musical instrument with the Web Audio API https://www.taniarascia.com/musical-instrument-web-audio-api/", + "sig": "0bf552bd3b6f247acd16972d50757ce3f5a128050ed4a8532710269e74b99ce93dcff88ae25c2e7afe5d7e80a406cc8bdc465eadedcb628ab932a09b7d1facb0" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f6bc5eaad677fad9d93f5a6e82ebc917f002a59b3867e339001ef7bc0cbd6f08", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652451302, + "kind": 1, + "tags": [], + "content": "The Apple GPU and the impossible bug https://rosenzweig.io/blog/asahi-gpu-part-5.html", + "sig": "230d0427f2163617056118d37c69d5ca5d97224b9eb8e66f7efb01dac71c114d06f11695c26ad57a0cea15dc2cb2c9eb48b664b44ec5a85d9b5b34f6158e0649" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9122664549ae6492ba7201fe630b0c51b206821688a1b6f415a0f0b654093e81", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652452202, + "kind": 1, + "tags": [], + "content": "Notes on Virtual Threads and Clojure https://ales.rocks/notes-on-virtual-threads-and-clojure", + "sig": "d9b79abfad8481c6bae5290f42a0a6c54ed13906e95104bf499ca89d25ab1b60182812d40d0a1dc58fc9a056385be6e995e49d8009d6bc76691f6913f1e26457" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d92a271b8a79c7bc8a689deeca7849a6f397be5dfde610091f044559b7ac86de", + "pubkey": "d543c820050efd6d2c1536b0990111ac293a4431e6a12929432366e0aa8001e7", + "created_at": 1652452896, + "kind": 1, + "tags": [], + "content": "突然想到,有传染力的病毒对专制政体来说是不是有点像叛乱,一旦多处散发,就将变得不可收拾", + "sig": "3199ba887132f2c085011c087f034e8bee3b5ec8c47a1c672042622902fe06729aa9dcda22733ee5d76fca7d4d1ec950ea717c0fbfc351604d57f1507f5de62a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "002eae0500afc54834c4feba7e8b2da5a79bfd45cc102808beb15f8c84e8d159", + "pubkey": "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", + "created_at": 1652453584, + "kind": 1, + "tags": [ + [ + "p", + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "wss://nostr.onsats.org" + ], + [ + "e", + "38f80f6a9c4cb79016b93dfd95fa1bc96e6f3ade7434fd5fb37497cc3459f709" + ], + [ + "e", + "d0852e65622eecd97b7c98c3d2ce6b43e693e68ed6c58cbccec021c05a97dc99", + "wss://nostr-pub.wellorder.net" + ], + [ + "nonce", + "80" + ] + ], + "content": "will it show domain or _@domain ", + "sig": "603b20db4b1a47b0e2bce6f1b117b28571a470fa76e15b4fc44d132c3817a42eb7d38c9cec3091609a990d190aaba2d088e4f20d66e49b32273bbe239a71cc37" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "192de7050237e795b4c2994ac6b18586fcdc9b3f7cc2659c1e4be3f3c618a968", + "pubkey": "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "created_at": 1652453876, + "kind": 1, + "tags": [ + [ + "p", + "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd" + ], + [ + "e", + "38f80f6a9c4cb79016b93dfd95fa1bc96e6f3ade7434fd5fb37497cc3459f709" + ], + [ + "e", + "002eae0500afc54834c4feba7e8b2da5a79bfd45cc102808beb15f8c84e8d159" + ] + ], + "content": "that's irrelevant, but branle displays it as domain. alphaama will not verify if there's no \"_@\". how it will be displayed is still being decided\n", + "sig": "915670f1b7e424af8a6d68948b13ac3e566297e4ef3170a87097186d252586d494599cc855531d258176ac0722f739337edde6e77fbbfbb6df4e492761e1d822" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9a43122ffc62066c648795ac7003e695d3608bb0dfaf1a425cc5b5cc60475f4d", + "pubkey": "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "created_at": 1652453891, + "kind": 1, + "tags": [ + [ + "p", + "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd" + ], + [ + "e", + "38f80f6a9c4cb79016b93dfd95fa1bc96e6f3ade7434fd5fb37497cc3459f709" + ], + [ + "e", + "002eae0500afc54834c4feba7e8b2da5a79bfd45cc102808beb15f8c84e8d159" + ] + ], + "content": "--x\n", + "sig": "e99b89c60ee3057a2010b2974c21593cd08d43e91647b7249a446be7958ce061ab186f4b4ed716fcdc1528b59bd37de6a372caebf48790d666e6363467b65424" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "6462ee088f89235703ccc9d4c21e8e4914e04b51d4f8a2c03faba21e65a0083a", + "pubkey": "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "created_at": 1652453919, + "kind": 1, + "tags": [ + [ + "e", + "38f80f6a9c4cb79016b93dfd95fa1bc96e6f3ade7434fd5fb37497cc3459f709" + ], + [ + "e", + "9a43122ffc62066c648795ac7003e695d3608bb0dfaf1a425cc5b5cc60475f4d" + ] + ], + "content": "dev version bug :P\n", + "sig": "1e4f308e8c65e870e13ef94b684a54a25c91c149a4097ce0523b82cd0565f150c0e0b0976f6d2b12a64981cf645f59143596b62364af57400edf042d0fef19e6" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "00062237c4c048538f5660d9d2c90681993da3ac77941d9fda0c31f61b5148f6", + "pubkey": "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", + "created_at": 1652454126, + "kind": 1, + "tags": [ + [ + "p", + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "wss://nostr.onsats.org" + ], + [ + "e", + "38f80f6a9c4cb79016b93dfd95fa1bc96e6f3ade7434fd5fb37497cc3459f709" + ], + [ + "e", + "192de7050237e795b4c2994ac6b18586fcdc9b3f7cc2659c1e4be3f3c618a968", + "wss://nostr.onsats.org" + ], + [ + "nonce", + "1054" + ] + ], + "content": "i'd advise domain. domain looks cleaner than \"_@domain\" and i would rather use \"semisol@semisol.dev\" then.\nalso, why not verify without \"_@\"? it's just a few more lines to add \"_@\" if there isn't any I guess", + "sig": "37a40a2c399e228cfd745d187999fd39e1aa673a3a74e09ce594241c961597cdd4178d854609c1a8137f82254e0b9eafd3512a59281ea5ae9368e6714f218b5c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "002fe7b8071c0e66ab0c732538cd44cc2a0e8e3b2dbbbe6e59780d400cfac80a", + "pubkey": "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", + "created_at": 1652454151, + "kind": 1, + "tags": [ + [ + "p", + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "wss://nostr.onsats.org" + ], + [ + "e", + "38f80f6a9c4cb79016b93dfd95fa1bc96e6f3ade7434fd5fb37497cc3459f709" + ], + [ + "e", + "6462ee088f89235703ccc9d4c21e8e4914e04b51d4f8a2c03faba21e65a0083a", + "wss://nostr.bitcoiner.social" + ], + [ + "nonce", + "407" + ] + ], + "content": "picture?", + "sig": "4f122e1b9e7318be54e18b8228ae425342ac165916629fb3844aedf6ebf2636ed4e41f17f438764fdabbe1b63aadbaad35f55003b770c550bad6f25683afe3a2" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f0155ad49da34652c59f3022237388dd436d012e0197e4940b31ca8c9beecfdb", + "pubkey": "6885c7df0f7dc551d67669e274f97e49f9cc98043a4c3d6f575c65dab32e06b2", + "created_at": 1652454313, + "kind": 1, + "tags": [], + "content": "test", + "sig": "b056def47f39b1c758ed69684c091f8ef7d1ac32b6cf73b0aa012747751355583dc93cbbb422810b85e8b65a2559ce3dbb6b228b109189fa8bba9fb61e77679f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0f9fde49184217fab8462a9298f0caf292d82ce6e17059f1c3c95677bcd8ec82", + "pubkey": "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "created_at": 1652454455, + "kind": 1, + "tags": [ + [ + "p", + "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd" + ], + [ + "e", + "38f80f6a9c4cb79016b93dfd95fa1bc96e6f3ade7434fd5fb37497cc3459f709" + ], + [ + "e", + "002fe7b8071c0e66ab0c732538cd44cc2a0e8e3b2dbbbe6e59780d400cfac80a" + ] + ], + "content": "of what?\n", + "sig": "d072752c5ce42f8c87b2e8ba8841d75d8c1e7b441a87bac3f0f05dbb44de3370076a3e8a8d7da9c12de9c620277f61b86613f31529c616f9a1d2385fbf768784" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0fb5a9c888542483cb0d32cfb59fc580910f400117778db73a94802897dc7d0e", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1652454835, + "kind": 1, + "tags": [ + [ + "p", + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437" + ] + ], + "content": "#[0] btw whatever your posting with is adding linebreaks at the end of each post", + "sig": "14ac48d15db96cf8ece940da7afad66783e5fb8c58a2b85bbc5dc44b339fc35912f64a8514a4bc6732696315a6d04682a98f0a687ac31464a113a3174529a9ad" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "6d0e1b1a8e815f99de5cad224ad54ed0a613c4ab8f873b71c2bc51e0d0ce4b83", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652454901, + "kind": 1, + "tags": [], + "content": "The Biggest Potential Water Disaster in the United States https://www.newyorker.com/news/dispatch/the-biggest-potential-water-disaster-in-the-united-states", + "sig": "3f21c93c848db77fdbd31d68d23c72d0c0df0e106cc1c82f8c042da93647d93e84d04f8983c8c9345be2ce15a2b0c0d20f6022d2a6e041439917cc234624fb85" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "00077914cd54341f9f1ebdcdcba4ef85a642f7914d1c1cba52b16c77e4cd4dc9", + "pubkey": "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", + "created_at": 1652455039, + "kind": 1, + "tags": [ + [ + "p", + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "wss://nostr.onsats.org" + ], + [ + "e", + "38f80f6a9c4cb79016b93dfd95fa1bc96e6f3ade7434fd5fb37497cc3459f709" + ], + [ + "e", + "0f9fde49184217fab8462a9298f0caf292d82ce6e17059f1c3c95677bcd8ec82", + "wss://nostr.onsats.org" + ], + [ + "nonce", + "1348" + ] + ], + "content": "the bug", + "sig": "2f6dce7b3870f5d75e31d1e7965fbfc170d7dc2c70bcc7efc830bafbe8f21c5b6d8d08d355080ed80d7ebc34976122b2cbba834281c9ded3844d90970c786bd2" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2707c7d67a4508aa2fb60d13b40c9e80a1b3e427995cc1140908bd9c9d770753", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1652455115, + "kind": 1, + "tags": [ + [ + "e", + "0fb5a9c888542483cb0d32cfb59fc580910f400117778db73a94802897dc7d0e" + ], + [ + "p", + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437" + ] + ], + "content": "Maybe I should trim whitespace on posts when displaying...", + "sig": "c65fe681a7bff9232402849181e644b5495e5fd98a096fe2b049508a4f20ced145446aeba6bfdf3851ad13c6dc41bb9b57027a566012536969667c0f57e9254a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a2447739c927ca66970589ba890a327a3f67deb3677348bce3b87257acb8ad42", + "pubkey": "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "created_at": 1652455351, + "kind": 1, + "tags": [ + [ + "p", + "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd" + ], + [ + "e", + "38f80f6a9c4cb79016b93dfd95fa1bc96e6f3ade7434fd5fb37497cc3459f709" + ], + [ + "e", + "00077914cd54341f9f1ebdcdcba4ef85a642f7914d1c1cba52b16c77e4cd4dc9" + ] + ], + "content": "it should've had cleared the \"log\" with the command \"--x\", it went as a new post instead... need to make a confirmation workflow :)\n", + "sig": "d23e54fe2c9ad4cad2907db8df1521aa8e420f4b597513449c98e7ca4d28208ae08b508c73544cccac32ee76fd4952b70d8a874a4433018309dc6a75c0cdf2ad" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a9e3c1319e825d2e46927903ade6906a478926840738e0815bf7c3c6c0ab9937", + "pubkey": "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "created_at": 1652455590, + "kind": 1, + "tags": [ + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "e", + "0fb5a9c888542483cb0d32cfb59fc580910f400117778db73a94802897dc7d0e" + ] + ], + "content": "ah! that must be the bug then. switched to a textarea instead of input, that's where the linebreaks are coming for sure and throwing off the command detection", + "sig": "b4ca4bb048f0e4ab8beddb25720c44f6afe41723eb0cd0e2383f6eb9a32e6cded22215d18b2cdb325a882624018df588c9d4a1abf5474e05f177363cbf837e66" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9c4f924b7a2dd9b294d66a078af24dd6ae891dbff71b5c03b64dc14aeaa7aeca", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652455801, + "kind": 1, + "tags": [], + "content": "FormKit: Form building framework for Vue 3 https://formkit.com", + "sig": "469feb06107271c5658b3799fa9259b7e2a1652556806bac3b004c8a2343b69516a1cc3857c7a12fc4f810076e90576c792c2bc2b4b9e7a5816e2f1bc20108f3" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "10f4bb5c48de891e28f603db8e21d73f0da9934bba85ccccbbb9484372ff01aa", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652458501, + "kind": 1, + "tags": [], + "content": "The scourge of Rec dot gov (2021) https://pmags.com/the-scourge-of-rec-dot-gov", + "sig": "1124cdaef701221d572432dfa54c9c6076c3daf6b956d751cdaa4fe6b6dcde070ac902a92317d31f5ae40976423f75748604eab0635b556fbeb0645ba65d1d83" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "6f732538d1e33e4cbc22d79e8c3ac136da775ee2e1aa9938a13c65cfab9aa49e", + "pubkey": "ccebf2fc058a0f5ce5c1c47b186a90ea657f8c21fd82f6a84280d396e6faaf03", + "created_at": 1652459062, + "kind": 3, + "tags": [ + [ + "p", + "8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9", + "wss://nostr-relay.wlvs.space" + ] + ], + "content": "{\"wss://rsslay.fiatjaf.com\":{\"read\":true,\"write\":false},\"wss://nostr-pub.wellorder.net\":{\"read\":true,\"write\":true},\"wss://expensive-relay.fiatjaf.com\":{\"read\":true,\"write\":false},\"wss://nostr.rocks\":{\"read\":true,\"write\":true},\"wss://nostr-relay.wlvs.space\":{\"read\":true,\"write\":true},\"wss://relayer.fiatjaf.com\":{\"read\":true,\"write\":true}}", + "sig": "041b633f32e76369b6a3f18f3727aeb30f874a3a6623b7814a60411b07536af58c12dcc413a0d74b49a557e323f1b89cfc81e268a1ca6ed42be558e0761dd4db" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "03a962645bfacd28a7d6d10a0be5b087ccb037c8ea9fe550c1748f1d71f6678f", + "pubkey": "8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9", + "created_at": 1652459112, + "kind": 4, + "tags": [ + [ + "p", + "ccebf2fc058a0f5ce5c1c47b186a90ea657f8c21fd82f6a84280d396e6faaf03" + ] + ], + "content": "hH6PSlFKcOuKLRWnW7y+jw==?iv=wlv/5CxloYG/YmruNohzdQ==", + "sig": "210fbb3e833d42c6e156b0d138cd1f130de927e38cd39a8695e0a68a558d55875170de7242c7acab5cde846ce13c9c79e1f878596421e9a603a48392fbbf2d67" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b5ad6bd141ca16c2b8a1318a9d72ec82e5ab629bd1dd6b1b3e6b6047705905b8", + "pubkey": "ccebf2fc058a0f5ce5c1c47b186a90ea657f8c21fd82f6a84280d396e6faaf03", + "created_at": 1652459188, + "kind": 4, + "tags": [ + [ + "p", + "8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9" + ] + ], + "content": "/1CD6cP1gYC6kkPuZaJ9PA==?iv=vmXIiODv7mwmpzx8cxryow==", + "sig": "1350d748b265ec1ce06fe0afc420be6a96da1a84a2355dbdb26a736364420253045d31b38360a6daf81b953d206815c2fa673d45d0d1111a680059cf71c8d6a3" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "98ea1312091d274a4a44617cb3b4caf9e7f771cd9c5b1b96ea10e2902b912210", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652459401, + "kind": 1, + "tags": [], + "content": "Tech bubbles are bursting all over the place https://www.economist.com/business/2022/05/14/tech-bubbles-are-bursting-all-over-the-place", + "sig": "f02b853292c088ebfeaaa668a59fc169e6d5d126a69fd467eeb4d613946436cf5b68b430f21975b82758b45220b68c64d61fafb19c6f32ca1e01c07c9a6d9aca" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "fc00ac4f931149c849e67410fd4d0eca369ea6cd0fb48c344566d045ce72040e", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652459701, + "kind": 1, + "tags": [], + "content": "Chainless electric drive system “Free Drive” for bicycles (2021) https://www.schaeffler.de/en/news_media/press_releases/press_releases_detail.jsp?id=87716736", + "sig": "19a9145e9db4a80df06ce81e1f82de64ab9c870e3e6c378dec680c06032802abbcb5819911f77532d27e38f428b290335d774ba54d35c780c63c9de548e01637" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "1763aa00db77f864e0eafcc5e6d679d7802e48201c2e7c9c8fe6a7ecf4d0ef80", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652460301, + "kind": 1, + "tags": [], + "content": "23k Devices Shipped. Quick Start Guide for Flipper Zero https://blog.flipperzero.one/quick-start-guide-and-shipping-update/", + "sig": "cf3cb9bd9c6fe0d998bb5d636da132571b3fc7e7209295051a558625375fc8c4125082b915307ccdafee7df3adef6ca9819ec7424f72e7fede302859823fd8e0" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a9f73ef13264e267583af4b1dae7734e72df53f5cfb7b1e7c52f52aa4967e4dd", + "pubkey": "2183e94758481d0f124fbd93c56ccaa45e7e545ceeb8d52848f98253f497b975", + "created_at": 1652461510, + "kind": 1, + "tags": [ + [ + "p", + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "wss://nostr.bitcoiner.social" + ], + [ + "e", + "4d2087d1f90571ef9830d29562a9272cc0f4ed79546d03f137f9bdcfcdc531a6" + ] + ], + "content": "Do you mean from the pubkey? If not, how do users know what your color is? They can't see your private key.", + "sig": "b17d6b67f8b28b5b073f2c90270caa98fd0bb50b70d9ee062d12d1ca6e2eab569bdd4d73d16896cd9c307a61fc3c4f5b7b7588023a93af9bb3ea82e03cba5a15" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2a5782678366814fc9ffcd50c26d7e2890f28be6770c315cdd11feeacad44e1c", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1652462510, + "kind": 1, + "tags": [ + [ + "e", + "4d2087d1f90571ef9830d29562a9272cc0f4ed79546d03f137f9bdcfcdc531a6" + ], + [ + "e", + "a9f73ef13264e267583af4b1dae7734e72df53f5cfb7b1e7c52f52aa4967e4dd" + ], + [ + "p", + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "wss://nostr.bitcoiner.social" + ], + [ + "p", + "2183e94758481d0f124fbd93c56ccaa45e7e545ceeb8d52848f98253f497b975" + ] + ], + "content": "Yes he meant pubkey", + "sig": "46759ba6d8f2b2d4b19a558c4f8969ad3f8aa5fcd2b14f93036f89169e83a4c95be73bf18dfb8be3c04d40e5e0ad07be58866142cb9d5c489aa486f333e34b6b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d6cacad021cfddfab60550d4cdc235a0e496b9332f0fcc7a0d644fe653460296", + "pubkey": "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", + "created_at": 1652462910, + "kind": 1, + "tags": [ + [ + "p", + "9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437", + "wss://nostr.bitcoiner.social" + ], + [ + "e", + "38f80f6a9c4cb79016b93dfd95fa1bc96e6f3ade7434fd5fb37497cc3459f709" + ], + [ + "e", + "17b11cf269365a96c58f319635f1844cd77ba7237b48e92f1dd2f784be06de5e", + "wss://nostr-pub.wellorder.net" + ] + ], + "content": "branle also uses _@ for NIP05 (it just obscures the _@ when displaying the name to the user). for example the astral.ninja account uses _@astral.ninja", + "sig": "b78f73d3ebf0d52d76059251a75f45d26f2a7faf80a4429a6cc7b3f5ebdbbd2be1869cbf8add0dee9996528bacab1ab8c3b0a5ada690053d43b2318a7380086b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7919209d6405e10513403ed52a5bfb8207c51ed6d3899ca8325d7d3e912a5d7a", + "pubkey": "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a", + "created_at": 1652463240, + "kind": 1, + "tags": [], + "content": "RoboSats has reached 2 BTC of total contracted volume! \n\n![Robosats statistics](https://pbs.twimg.com/media/FSoxytrXsAAHqhu?format=png&name=small)", + "sig": "96e22716fec2284aa8c8ae0b2bb4dd71b745e3ab9a3e5e4110654a18e1f5f5b44802f5248d611f0bf985a6be6033143d16766a382a5ff099f2e4652f5b212a43" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "68c82f620e5b956e47b34c83f524bd3d6afc96a1276ee6ef44cb4ac483547874", + "pubkey": "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a", + "created_at": 1652463282, + "kind": 1, + "tags": [ + [ + "e", + "7919209d6405e10513403ed52a5bfb8207c51ed6d3899ca8325d7d3e912a5d7a", + "wss://nostr-pub.wellorder.net" + ] + ], + "content": "![volmestats](https://pbs.twimg.com/media/FSoxytrXsAAHqhu?format=png&name=large)", + "sig": "e4c93c998e79ab386552019712bf59e2dc306825610c10b3ac3df9ffe2d4f6a38491ec891074a238098ccddd4eaf56763e8729b5fc27a696f95f74ab1bfb7003" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c4be6e6e32bd7e9eca2350d0e6fb82260587bdf4c18f26522c494fac680a5060", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652463302, + "kind": 1, + "tags": [], + "content": "Safari Technology Preview Release Notes https://developer.apple.com/safari/technology-preview/release-notes/", + "sig": "69e69d569ae940f189b42b39aaf79163e29f5923dee0872a9f71952f0ecd163ae98bf229bc45b3245d8b547afc982e4c93937b4ea05a5c71b95d1cca1396923f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "3762791fbd822df347c0c33f1dcfd67ae5581b06be18923e8465ed8c6fc76058", + "pubkey": "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a", + "created_at": 1652463388, + "kind": 1, + "tags": [ + [ + "e", + "7919209d6405e10513403ed52a5bfb8207c51ed6d3899ca8325d7d3e912a5d7a" + ], + [ + "e", + "68c82f620e5b956e47b34c83f524bd3d6afc96a1276ee6ef44cb4ac483547874", + "wss://nostr-relay.wlvs.space" + ] + ], + "content": "Fuck it, me and md formatted images is just not meant to be. Here is the tweet https://twitter.com/RoboSats/status/1525086318866452483/photo/1", + "sig": "493c7b489b025a086fb04eb795ab0e58c44707fd67bd422fa1a3fa7aecb50a49eedded112405bbd7fb3912d88c11a5eeb19a20d6794da54efb423d6856a3d1a4" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "47959e2f738f78ca1fea0dcd3d3b117934ab13e823183c482f5cd0ba9e3268f9", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652464201, + "kind": 1, + "tags": [], + "content": "Sliderland – Creative coding with 64 HTML sliders https://sliderland.blinry.org/", + "sig": "2cb9e3c8eba97a8a52e655d9a9182ff1b7d4a3db37d85bdae103f185a4fd421743a3ffcb332f2148114c4a2ff490b265e438aa71c47600ab0ca681a12ff2fdc6" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4f3f921d0d35e55ac4fac083e8d30021a273b390716fa19cb3fbe95081ce4a85", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652464201, + "kind": 1, + "tags": [], + "content": "Jonesforth – A sometimes minimal Forth compiler and tutorial (2007) https://github.com/nornagon/jonesforth/blob/master/jonesforth.S", + "sig": "87790926a99c6f0c428546f9b5ee4f5989b5bbbad508a34e731c7839cc4628cfaa4aa8d167eeec386e83953c1cf493a33b56871273ca6e8fcd2d3fed3ae2a115" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d53d16ac647720d6ae077fa70c5d9c4ad61293e7d44684241b4a2e08a7caf8a5", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1652464783, + "kind": 1, + "tags": [ + [ + "e", + "7919209d6405e10513403ed52a5bfb8207c51ed6d3899ca8325d7d3e912a5d7a" + ], + [ + "p", + "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a" + ] + ], + "content": "I still need to try this... looks awesome", + "sig": "f3ec0a328eda570fddfa9831dee7613bab9b9de771336de2088a2a13f2da8774344841d643a049ed24c65dbcc946ec58e6809b92f1a3a3de0d50cf6be3b14113" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "6d4f2c24e32fa605d83a1a0d5dd247a3922821d62b0ce33636c4128a9ca7598e", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1652464883, + "kind": 1, + "tags": [], + "content": "damus.io relay coming up soon 👀", + "sig": "fb9bd0c10ca49b68da0459382c6c7c9d28240636741bb34733420ff0783dfd4fd29c1a5c4be2389d415cb85c80bb69658c02c7c1b011de944db4a5d394b292cb" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4851c183164569991d1f5a43b4d17d58cf8e86d842b0c34bffc59ddd7c76b291", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652466001, + "kind": 1, + "tags": [], + "content": "Talking with the Moon: Inside Apollo's premodulation processor http://www.righto.com/2022/05/talking-with-moon-inside-apollos.html", + "sig": "43ebc72b7127a604de4fd70eb18fb1fa61b09bf29acf51a6ea5caabe9313ef8311a22be6f257a6379ffc70c7652d7c4b98ebf8c145f9311d897f6bafa21b900c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9c9410d4f720b0207dae7b8adea25aa2015395665536cabe09f510e5f4a74a93", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652466301, + "kind": 1, + "tags": [], + "content": "Nanobots Can Swim Around a Wound and Kill Bacteria https://www.wired.com/story/these-nanobots-can-swim-around-a-wound-and-kill-bacteria/", + "sig": "cf824c724341c9929819737a01ea164a110db9de221db01e9a117331c8a7830ead107f15432db4b1a104709ddae4081a9385e92021a6c4099c6009bc4e8a4076" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "36e4866e49eb1872ade68471f918ddf9e44e71a7b0cccebb8b02aa1d6865a134", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652466602, + "kind": 1, + "tags": [], + "content": "Algae-Powered Computing https://www.cam.ac.uk/research/news/scientists-create-reliable-biological-photovoltaic-cell-using-algae", + "sig": "5bd9828b54b83bafaec6fc348458b07704d5f0d73b33e714156ce40041118c2c5c5b1d6a7eeb58351c4c89acadc12f439d5f4db74bf7b02219ea7bcedfcb2e4a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "1fa2e592798a8a4e418002cb83be10480f249f1a697ad5208ae8b6be12aa003c", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1652468431, + "kind": 1, + "tags": [ + [ + "e", + "6d4f2c24e32fa605d83a1a0d5dd247a3922821d62b0ce33636c4128a9ca7598e" + ] + ], + "content": "It's up: \n\nwss://relay.damus.io", + "sig": "216f22f3defd2fdcf299a4a9186f1ad806cdcdb3eb53fb8c4d19bd56bceacbc179a04fc013a8304efe198c746f2974ece9f942301ee966f49bb918d2b1c85e56" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a128a151d092c2bd19508477c680acafb93fd3c344c44502184738ffdc37f43c", + "pubkey": "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a", + "created_at": 1652468521, + "kind": 1, + "tags": [ + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "wss://nostr-relay.wlvs.space" + ], + [ + "e", + "7919209d6405e10513403ed52a5bfb8207c51ed6d3899ca8325d7d3e912a5d7a" + ], + [ + "e", + "d53d16ac647720d6ae077fa70c5d9c4ad61293e7d44684241b4a2e08a7caf8a5", + "wss://nostr-pub.wellorder.net" + ] + ], + "content": "It's so simple and it makes it really easy to buy sats in a non-KYC manner. Took me 3 minutes to buy 500k sats. It's all lightning too so that's definitely a UX improvement compared to Bisq v1", + "sig": "cf221fdf68f9bf0856aa64c7c6bd9d5818de83388f1d9f1d30d13600f30b7a463af1b9bc661bf433eec31e8f1ec90578b88869820b7cd05c3e7ff4b488a1021e" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "46e56d60ea46ea239dced5c9b2e1719e70b8f052bcdcf1ff4f4a64e753ab8b3a", + "pubkey": "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a", + "created_at": 1652468596, + "kind": 1, + "tags": [ + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "wss://nostr.bitcoiner.social" + ], + [ + "e", + "6d4f2c24e32fa605d83a1a0d5dd247a3922821d62b0ce33636c4128a9ca7598e", + "wss://nostr-pub.wellorder.net" + ] + ], + "content": "Love the website design. Do you have any UI design experience from before or are you just a designer wizard?", + "sig": "2a3508ea395c2c5e24b2e6587d66def870e4d0130a2eac74c2967c339616e8f2ec686a0d568861509222d0878e5e3d0cbf90476e29ab92d685e8b516887a03b6" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7e17a41f4d70b44850e792483cb3e5ee22aaea6238070abe5e5c83f37f5f2454", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1652468833, + "kind": 1, + "tags": [ + [ + "e", + "6d4f2c24e32fa605d83a1a0d5dd247a3922821d62b0ce33636c4128a9ca7598e", + "wss://nostr-pub.wellorder.net" + ], + [ + "e", + "46e56d60ea46ea239dced5c9b2e1719e70b8f052bcdcf1ff4f4a64e753ab8b3a" + ], + [ + "p", + "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a" + ] + ], + "content": "No experience just winging it", + "sig": "dba45bbd3a8d2e749947adcbf33ba17930f6e95db610b723d6bfb148027568a46c24bc856538f5a0d197dcc630a00c0d1538fc877550a2210ae0bdb0a96639d9" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "994588fd9fb4854143e84c69fc580cf9b834a14e1b3fb488f1bf49aaa735dcd1", + "pubkey": "308a1a0d38f3f5ed18cfeaa8e8f9227d9ac5f4b46141cfea78e0d1dc37171500", + "created_at": 1652470126, + "kind": 1, + "tags": [], + "content": "Total lunar eclipse on Sunday!\n\nhttps://www.timeanddate.com/eclipse/lunar/2022-may-16", + "sig": "e93eeb749607739d20c2641a9f97f2268373864f46501959739d81daabc7a262fc48abe484d4e92cfbba395fe048f0fa3d766e0a8ee9ff686ed53a7a172331f0" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f3dbdc0a6242aaeca66b1c7570e0c1fdcc8b726422a60b61dd40ea69d0978136", + "pubkey": "308a1a0d38f3f5ed18cfeaa8e8f9227d9ac5f4b46141cfea78e0d1dc37171500", + "created_at": 1652470144, + "kind": 3, + "tags": [ + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "wss://nostr.bitcoiner.social" + ], + [ + "p", + "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a", + "wss://nostr.bitcoiner.social" + ] + ], + "content": "{\"wss://rsslay.fiatjaf.com\":{\"read\":true,\"write\":false},\"wss://nostr-pub.wellorder.net\":{\"read\":true,\"write\":true},\"wss://expensive-relay.fiatjaf.com\":{\"read\":true,\"write\":false},\"wss://nostr-relay.untethr.me\\t\":{\"read\":true,\"write\":true},\"wss://nostr.bitcoiner.social\":{\"read\":true,\"write\":true},\"wss://relayer.fiatjaf.com\":{\"read\":true,\"write\":true}}", + "sig": "8649bae9d336aab83e0554b0f702434699a0fd2113952dab89a4bb9e6b02cc2e608008603d9d227774b3d8ac04be0b897d3fbd9529b67439da0ad64089f88947" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0fbf90a1b6ff4846736f0e4802a3db8e2fcdeee07f69e2a7aafb1f7f5f9e2403", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652470801, + "kind": 1, + "tags": [], + "content": "Open DNS resolvers, from bad to worse https://blog.apnic.net/2022/05/13/open-dns-resolvers-from-bad-to-worse/", + "sig": "9fabe1f307426c765f6ddca0562f3d9f7b62c67ce1a027551ab4d4ee55ae2aca715d325d4865d73cc9f5fd7357fbef749df5cff20e5298ad28b40d29c4bc2d6c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c867a20280f8fd965cce74fa5579a34528f04d0d0fd89a428006598782b4baeb", + "pubkey": "308a1a0d38f3f5ed18cfeaa8e8f9227d9ac5f4b46141cfea78e0d1dc37171500", + "created_at": 1652470884, + "kind": 4, + "tags": [ + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ] + ], + "content": "McAg5e/uBsKpMfDu6t8iFdv2jpxnYXaWOGxXbqBL0Mxrzn8M1U0D7pn56F6p1WpWpONpd+TTjxf+BUDZSTN8gGRtjbxkv4HUKNMNbmCrfG8=?iv=dcaGj8WPz9Tf8cVZFIHwXg==", + "sig": "1e9eb3c8d1db008073beb28b04ae2d1d2f3b7704258088b4267d41e828ff610b5508181d913f791e838bea679ab61ed84a43ff3d7fafc6aa5c8281bd8e3dd9aa" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "444b1e4cf4eea42d35c7f1be58ab9cf6a942153593251d66e0471084a3430dae", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1652470951, + "kind": 2, + "tags": [], + "content": "wss://relay.damus.io", + "sig": "1d8625765364edffa42f83fa1e53bf3486e7fb94eec065dd0a00b48dd777702fafbfa1063ef27f1dd27b3892132e4d1703fb0da2bfb98b70045f826ee76d5526" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7a7fe37e75781e8ea251eec3eb675df07532833bb4d3ce402a42bbd980769ed2", + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "created_at": 1652471332, + "kind": 4, + "tags": [ + [ + "p", + "4416e97154fee968dffa2aa49d0042dde925dbc78261396f83f3e9d754e12827" + ] + ], + "content": "4jk/4jUX1H1Bow1Pzlg03gdbDaoqOApT+JdSQPQ1bxcqnnUL/ngy/N/XKRXpDTpZl+MBe12GAZov7ebrUXy5eh/iVt8DmPa15gVmsrTxvFGkfE3Wr4Q3bnyMm/RKfzWuFeMPIgPgqRkBS5l6m5dp7df5gpCbejTaQec19vDRh+W1NaeVvC1Ep3jzPPBp4zhR05pb284IEr6vTMGYehAVCg==?iv=gNKyH9sGYZe/rgdappy9rA==", + "sig": "52b282e00f80259f74844f5f972062613be8f29cd800009fa2bc3d54ed0337c77e7c2a907b7c9546cd2670e5b6362adbc79178b247558aa199756338dd12b318" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "04286d11a23919fd0e699a5e6215e7b12f425c05bb5cd5f6b154fb9c2329440a", + "pubkey": "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", + "created_at": 1652472883, + "kind": 4, + "tags": [ + [ + "p", + "2df69cd0c6ab95e08f466abe7b39bb64e744ee31ffc3041f270bdfec2a37ec06" + ], + [ + "e", + "0e42637afa7de0e71876303b1edd08fa2f0cbcba6eabacc1ee124b91ea8efc5f" + ] + ], + "content": "SPa7eid4wqO6PkdarKZcZcVaKAxeGX3uId1XipypfXg=?iv=BXtncG6+txwNJ+TokDA0GA==", + "sig": "d5b3feafb93ac3043355c070eb9fd02afbdd70b81c34e0c4301f3ca76e2e721854c41b4b472c2fe961b790354cf21468002ec3889e219b514a57455c83fda42b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d96dbf96e4f609a549c341079168064e4f9753e4d7d28286713ac930374fd2be", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652472901, + "kind": 1, + "tags": [], + "content": "The Nature of the Firm (1937) https://en.wikipedia.org/wiki/The_Nature_of_the_Firm", + "sig": "0a8c91bde3ea83c050effce9a2d4847a23dd00711ea3e2b2d823a12771ac49ff8fece3ab3f2670ca5255a9244a355bf1b63f03fcf9d354cab3f6d1231ad39b5e" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "86d54073db25fe5b77fd28deeb4c9ee58df65bfa7c89f205e956face8dd934be", + "pubkey": "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", + "created_at": 1652473025, + "kind": 4, + "tags": [ + [ + "p", + "2df69cd0c6ab95e08f466abe7b39bb64e744ee31ffc3041f270bdfec2a37ec06" + ], + [ + "e", + "33af29a6e2fe13318bb3ee6770605842aea84b6bd4d75ad4e531a5651ad0b07a" + ] + ], + "content": "PQKiqqm+CPPEq6VpMyU5+A==?iv=/PpSlU9gRd8jZ2Ht1Xvj6w==", + "sig": "91531b027794f77c7571b56a5b88f2b1464e01f36097042a6c4d130dd5fea0d3d775ff6ef30785466b8372f3276495812844dc2524b27ef6b9897102ac13caa5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "bf7c0a8b0a3dd6d31917871a8d2b4fd540f435f219d91916b51b8e5cc3a0a39c", + "pubkey": "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", + "created_at": 1652473145, + "kind": 4, + "tags": [ + [ + "p", + "2df69cd0c6ab95e08f466abe7b39bb64e744ee31ffc3041f270bdfec2a37ec06" + ], + [ + "e", + "33af29a6e2fe13318bb3ee6770605842aea84b6bd4d75ad4e531a5651ad0b07a" + ] + ], + "content": "v9LLEtUDdw/XGx36sv77Lg==?iv=6wNK3Epqujy3oYAzP2K2iw==", + "sig": "317dbf8b695e8250ceada5261b27807106e74948f2498e692e3cf4357d541d4b715c170b5bf784e915a820576fef9951993940156c264c9bb825f7eeabaf77ff" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "210fd4ae8feecbed9228b5b3104e9ba7f400b23690849312cfaa1c237537c645", + "pubkey": "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", + "created_at": 1652473288, + "kind": 4, + "tags": [ + [ + "p", + "2df69cd0c6ab95e08f466abe7b39bb64e744ee31ffc3041f270bdfec2a37ec06" + ], + [ + "e", + "33af29a6e2fe13318bb3ee6770605842aea84b6bd4d75ad4e531a5651ad0b07a" + ] + ], + "content": "gAyh2s8yKir+RPK3v/eCJfq4+EO8ns+bn5COHIrMOo8DK5L6BBMh/O6dnzph58gD?iv=J5ATNanEajljVrqeX9jzEQ==", + "sig": "0887ea9e8ba2de36d9afb107a2121e5ae7b00a07bd29569ee190ceebb4d9cab3a249c6c2270bb28d0fe1c591d7a4c7d169ca512662f1d5d3dfc76a72056ac787" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "3fee251d457bd06a7e20b465ae9f54ba5af5891b2bf47ffd0e09c379232f0196", + "pubkey": "b7c66ce6f7bbe034e96be54c2ffc0adf631a889abc0834ba1431171b67c489aa", + "created_at": 1652473475, + "kind": 3, + "tags": [ + [ + "p", + "e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2", + "wss://nostr-relay.freeberty.net" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "wss://nostr-relay.freeberty.net" + ], + [ + "p", + "13fd7010166391fa485f077e57d84153613039ed9d2319b555b50458e55beff0", + "wss://nostr-pub.wellorder.net" + ], + [ + "p", + "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793", + "wss://nostr-relay.freeberty.net" + ], + [ + "p", + "2ef93f01cd2493e04235a6b87b10d3c4a74e2a7eb7c3caf168268f6af73314b5", + "wss://nostr-relay.freeberty.net" + ], + [ + "p", + "b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a", + "wss://nostr-pub.wellorder.net" + ] + ], + "content": "{\"wss://rsslay.fiatjaf.com\":{\"read\":true,\"write\":false},\"wss://nostr-pub.wellorder.net\":{\"read\":true,\"write\":true},\"wss://expensive-relay.fiatjaf.com\":{\"read\":true,\"write\":false},\"wss://nostr-relay.wlvs.space\":{\"read\":true,\"write\":true},\"wss://nostr-relay.untethr.me\\t\":{\"read\":true,\"write\":true},\"wss://nostr-relay.freeberty.net\":{\"read\":true,\"write\":true},\"wss://relay.damus.io\":{\"read\":true,\"write\":true}}", + "sig": "4fe49bd88c405818114438ef58f4106fe4f32cf96719f5cb41425e328a63a7d6672a7a5d4078b7e7f010b31c60b2dfa4f23936ff59bc6c14a93f32a9cd87a3d8" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "fc4eba3b6e01919dc97a53c04b0b9cfd79d3b790aecbe96cd7d31f1b59aa4a04", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652473501, + "kind": 1, + "tags": [], + "content": "Show HN: A Full-Stack Web Framework for Go https://github.com/livebud/bud", + "sig": "22ac4277ad988a616fd9f8706a03e0aedd7aa6aca412553ab0fa64b5dac9a7a71a41e481c021490ce98e221d41e50e3120d18e623e05d485761433669b6c96af" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7e2e76d3c81a4614ea59040d5bc852589dc6258298aed335bf15542f1c7f1688", + "pubkey": "b7c66ce6f7bbe034e96be54c2ffc0adf631a889abc0834ba1431171b67c489aa", + "created_at": 1652473503, + "kind": 1, + "tags": [ + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "wss://nostr-relay.freeberty.net" + ], + [ + "e", + "6d4f2c24e32fa605d83a1a0d5dd247a3922821d62b0ce33636c4128a9ca7598e" + ], + [ + "e", + "1fa2e592798a8a4e418002cb83be10480f249f1a697ad5208ae8b6be12aa003c", + "wss://nostr-relay.wlvs.space" + ] + ], + "content": "Huzzah!", + "sig": "35fe36ec1c9c68fd195fa9318aab510b60588254f918941b08cbfbd3cd93dd58beb68d7b87fde6f2a9cdcbc55d304fb96df09f1914c7c8c05e292cc2b0617626" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "cf9a389cefe3f8dba47c4dfad2b03e17c2ac376aa57e7fae4e2e6f9c5695da78", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652474102, + "kind": 1, + "tags": [], + "content": "Show HN: Watermelon – open-source VSC ext. to document code and view Git blame https://github.com/watermelontools/wm-extension", + "sig": "450181cdf9ee64e01722c97e324019e7b19c164ab0232bc1d25c46c8476ce18fd2f971e179f4bcb891e7ab942f38f27df9541e6b91c1764b98d5b561e986677c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "04bdbb62b114e7033c941f4a33a9eb5eabdc11772df55af6d350fbd342f20ddb", + "pubkey": "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", + "created_at": 1652478601, + "kind": 1, + "tags": [], + "content": "Ideas That Created the Future: Classic Papers of Computer Science https://direct.mit.edu/books/book/5003/Ideas-That-Created-the-FutureClassic-Papers-of", + "sig": "9b10a1728735fb46449d9da272c9b9ca5150d5ab994eebcfaff96095c2d43fc1bb88c4433f9256857d9ce85acfb8bf635bbc7592d2883a7421307f0be8613f2f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a", + "pubkey": "55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503", + "created_at": 1564498626, + "kind": 0, + "tags": [], + "content": "{\"name\":\"ottman@minds.io\",\"about\":\"\",\"picture\":\"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539\"}", + "sig": "d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b54b683d4e5cd8bc45cf5d751d7210488fa89c703e1f1b5eb0c87174d41f61d8", + "pubkey": "c37febad818bf7dc1d521dfdbd78561b98286f7d523e445e7b687b009a31b15c", + "created_at": 1584740435, + "kind": 0, + "tags": [], + "content": "{\"name\":\"minds@minds.io\",\"about\":\"\",\"picture\":\"https://feat-2311-nostr.minds.io/icon/991441275603390467/medium/1561754020/1584740435/1653380403\"}", + "sig": "2c14c925b81b451a591dd818be1789c94f76cba9c94c58231953595940c8c6978d8181a2045dae9adfdfeb248895f6da3969fea258592fd8f55360b4c1f481bd" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "ce5fa5ac7e944dd325b48392dc18744d4498be2ba7389cdf6a9e53bf4272b91b", + "pubkey": "6446d04ecf9e0bb72c5ae218df9fc6c0a273149d9ecbfbe42519c53667b4405a", + "created_at": 1607350182, + "kind": 0, + "tags": [], + "content": "{\"name\":\"mark@minds.io\",\"about\":\"hello world\",\"picture\":\"https://feat-2311-nostr.minds.io/icon/991438404115763219/medium/1561753335/1607350182/1653379625\"}", + "sig": "89abfa4ce20727c4fbd4a37eaafa0f81b0e750fc62e689fd00b8cb58af9ebb2464e6cc26c1884291ec406d08e20475a5eb571da30d418d39f266536ebd369011" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d46118d11b32fa62e57a58380da80a52f9196aa8423724004d57d690848e53f9", + "pubkey": "656e4f70242342cd143295f2c53c47646bb6bc19f26ca27cc989945bc2928d14", + "created_at": 1639442928, + "kind": 0, + "tags": [], + "content": "{\"name\":\"girafa\",\"picture\":\"https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Flive.staticflickr.com%2F8100%2F8644548577_ec932a124c_b.jpg&f=1&nofb=1\",\"about\":\"um animal pantaneiro\"}", + "sig": "089089003c190c1ed500edcf4c27c464e27981289e8fd3c838f5ba052d401cafcb831637af7ff6a1902581093cb6a1c59cbd3cc8a2c301cbc396200ee3763005" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "730f84bfc97f8c8bb738dc95301b3b860d16a5cea7c2fd297fcedef8e68f658d", + "pubkey": "ab5d1908507df1ccc8597ecb3153bf471a93215be3acfa1f7625c9d0b17a84ba", + "created_at": 1639940326, + "kind": 0, + "tags": [], + "content": "{\"name\":\"name6\",\"picture\":\"http://localhost:3001/bird.png\",\"about\":\"hello frends\"}", + "sig": "7655e431e1c8f62b46b40ce84fe4ec14b8304a862d11cec96c117f664cdf631b6c5ee7987b7920ac2359719f9d06bf26891d14c89343a4fe1f70465ee3babe4c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9fda436da264356ceb8a02c202e339975fdbfeb03c2d56b657aff8fc8dc8fcbc", + "pubkey": "d95c0dfad8eefaf0a3aeb94004a20fe538c3181cd1226066b9d83a3af8d3c264", + "created_at": 1640300735, + "kind": 0, + "tags": [], + "content": "{\"name\":\"fiatjaf\",\"picture\":\"http://fiatjaf.com/static/favicon.jpg\",\"about\":\"https://fiatjaf.com/\"}", + "sig": "b13fc77de05ff3d3fa71045ff08c889c364d40bf2b49669e98bfb5209c4a462a43aa5fc400bee6eda76f4de7c81cb3a312f4cbb6173fd85ce52228de41a2a95a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0dc89d83f8c9172947e7b14c8892ffebb183bb4b4e4552ffb52468edfe36ae3d", + "pubkey": "3b6e234032d9b523db195e3fdfdab769a61c1e801db11364cbdfa2c00dc6fa2c", + "created_at": 1640302209, + "kind": 0, + "tags": [], + "content": "{\"name\":\"fiatjaf\",\"picture\":\"http://fiatjaf.com/static/favicon.jpg\"}", + "sig": "8ceb1c02ede4555e27fe562f3d99f5299a019e71ae14c114d1886edaed693399ccac7a0cef992a198af1c7f73a03beaa617fb86c43fdfa1dfb6766a9d682f134" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "44714ee4a056c6b690c071bb08ccdd9f57c0e4c53032b1f4a3801565ee77850d", + "pubkey": "5a462fa6044b4b8da318528a6987a45e3adf832bd1c64bd6910eacfecdf07541", + "created_at": 1640305953, + "kind": 0, + "tags": [], + "content": "{\"name\":\"fiatjaf\",\"picture\":\"http://fiatjaf.com/static/favicon.jpg\",\"about\":\"https://fiatjaf.com/\"}", + "sig": "2e60ec12c169778220eb3a51449733951b6c88d6c57480e23559d1cd82e332069cc393b6e33cf3118271589547cced7ab93d8f49c0ecfc50ab7345528e98deea" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a906081ef4cf2a9856c69d531e9e2dc9a747fd162f972486f59eb9d046b5de76", + "pubkey": "0e8305730d53799a5e640e82b38bb890212e834e947d8758541d7501f1392ed0", + "created_at": 1640307360, + "kind": 0, + "tags": [], + "content": "{\"name\":\"bitpaint\",\"picture\":\"https://bitpaint.club/img/animlight.gif\",\"about\":\"bitpaint.club\"}", + "sig": "e1a553335b5fddd2c00b14ef37b1a8fd416417f186f4918b4c205e548ff7665b9d57b20fff0d9219c120f6f3026f2ffe049f17d7fb4bd2219fe052a27abe7964" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "48087d76f150718c54f504c41b5e47d6442eee375b77f72ff29faf3803fbc10c", + "pubkey": "77f08e258715896aa92b870633b47795d32e18d06c0d55dd0f044b6fb99d2f5c", + "created_at": 1640315488, + "kind": 0, + "tags": [], + "content": "{\"name\":\"spectivert\",\"picture\":\"https://i.pinimg.com/564x/1b/14/34/1b1434c7d78bca9e24bcb89e5126903c.jpg\",\"about\":\"just a test account, don't mind me.\"}", + "sig": "2750bd6e48f65771b29e44df8abaaef94cf314d43b82587268ef9251f79ceaaa4ea5b4e764e6b871b01fe2597bf7c91dee45711e13975c58d854f0383e8e2e53" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4939bbe431151cbfc61f75e4b4dc308f99166e0f011af255435cada6d41b2d7c", + "pubkey": "24c4f81e36fa3c5bf66ac23cbeb62687002e9fd4e8a218f14fc8ec7c5a2c0790", + "created_at": 1640337801, + "kind": 0, + "tags": [], + "content": "{\"name\":\"afalol\",\"about\":\"lnmarkets.com\"}", + "sig": "f91b2c6b30550efa6a129eff3acc718dec354682730c185989eba1d81bc7234fbe8734edcb46798760ac20615a3def75ecce993b3cd0a2d146bef3890d5b61f8" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2fcad8e64fb3a3909a3bd42abdf4c883637dbd914a5605fbe0b1201947167741", + "pubkey": "d94ed159c13b4cc187076ac0b8b4f6c093588df3f1739069069c6b99f33344ca", + "created_at": 1640339088, + "kind": 0, + "tags": [], + "content": "{\"name\":\"vmd\",\"about\":\"Buzz\"}", + "sig": "003b05e1f88069e8a606f9f0995251a7fc3787e69e73ca5c805fe8d394ea551e1a4485466169660b14c1ea2785f4b2486364f196955b8b9bb987ff95042fd671" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "04207dfdfcabc47356a3156ef9e3f1d7706da8b6e3c2dbaaea261d2b3180165a", + "pubkey": "9d925fa77ada61630af5be544846c08e2048b428f3586c8ef145e1f59f95f7a1", + "created_at": 1640340274, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Blurr\",\"about\":\"Golf equipment \"}", + "sig": "e771194605951bf44f66bede1fd8edc531f0dfd4480240e42f309f17910df90ec58e7bfaf4551cb186b7c1da5ec547a35c2bad67cf5ef5096ba255836c5e626f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "44f7e23477b60528d4afbd20cdbfce485aa0820891f4a4bb24a51fdc9e4acc01", + "pubkey": "056ccc33d638633ecc60ee28db5f226ad3acfdfe69a73aa9670cc8beb0d9dc74", + "created_at": 1640340351, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Colby\",\"picture\":\"https://a57.foxnews.com/static.foxbusiness.com/foxbusiness.com/content/uploads/2020/12/0/0/Bitcoin-Gold.jpg?ve=1&tl=1\",\"about\":\"Testing\"}", + "sig": "3af85fcfdcb36f183191554e255f4afc06b6a72eed260d330e68d13920784a7723980729dcf06ed9c9869cdc3fad060938f7f8e5d8483ae4f71b98add1a38e72" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "02bbdba7fa43fc2b6404ed7197cc04d00d93553818ef907b44fd98ae64d80e18", + "pubkey": "1909839609346b08e86e4cf40e8d2ae783b88c5c0dffd39a3ce65d19e77ebbdd", + "created_at": 1640341880, + "kind": 0, + "tags": [], + "content": "{\"name\":\"redshark\",\"picture\":\"https://bengalhalalfood.com/wp-content/uploads/2018/04/duck.0.jpg\"}", + "sig": "9b0ea8d87b18f75635d85c3dace713e692a17b1a88fea745244994219223593f16da0e1c6a4e6a013275bc10003ac57470fbe90b6d4f0148235bdb67b78549f8" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "ffae032f820308e3a8e45ee6fbf963bd216f4d99afebb9a3574dd32bf35b36ae", + "pubkey": "8ca347663a83a667ec865a8b063f0889c351b7a2536f6c682c9f960605e6cd33", + "created_at": 1640342826, + "kind": 0, + "tags": [], + "content": "{\"name\":\"tucuman\",\"about\":\"How do you know it's me ?\"}", + "sig": "3dec9f91423693dc70c4ccc4ff19784133049fb80e437135aad275d32c00f137db99e621c9c8a89ae0d9b30e751337745a3ffe6cbaf2340f230b017f7bc5c516" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "910b1e3b4f99d49a45d0d8ddc3bdcc9ae95ab7cfbff7d4d4baa72cd1d49e68f0", + "pubkey": "f97107c59afa6fb09de6a916267093f7e53dc7cff5957b8dc8231f432fbad732", + "created_at": 1640371695, + "kind": 0, + "tags": [], + "content": "{\"name\":\"rohan\",\"picture\":\"https://i.imgur.com/PCEauiu.png\",\"about\":\"i'm the nostr intern\"}", + "sig": "f3bba0509465143f85204bcb070c1441b8531cd4b29f04d0367aefb46b022a3e7ea24d5dc4d4578231d22f52bff1d17457dde00d4d3de3aba5dc821aab68ccb2" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "995dab491047c5ad6de7f7114e07dfe79943d9be2804e2257277329a89dfe9e0", + "pubkey": "5064ad3804d0d89534102d282fc0cd0c30d9924086bb1e4fdc3e26ad01574a05", + "created_at": 1640374427, + "kind": 0, + "tags": [], + "content": "{\"name\":\"lukapercic\",\"picture\":\"https://scontent-vie1-1.xx.fbcdn.net/v/t39.30808-6/269761300_473337230816057_6451041514872596313_n.jpg?_nc_cat=101&_nc_rgb565=1&ccb=1-5&_nc_sid=8bfeb9\",\"about\":\"I like turtles\"}", + "sig": "d92a32cd008388872c6e9fe0aead78816735a6e4f5847e3fcb7288aeb142a8f69d01703801e70a04bf3c8fbb4bfb3e125804f99e0c9ec6859f5df5d4a4a182af" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5d5d550e98fb2d51baab0fe18c4536ff2ac1cff9c15cb1b76528f8d5cc8205be", + "pubkey": "49c6c102a0e3b0aa2670771840bd61343986ec8249428ba30cc917c2bc7e457b", + "created_at": 1640377986, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Branle Enjoyer\",\"picture\":\"https://cdn.shopify.com/s/files/1/2209/1363/products/additional_megachess-9_d4e6bb9b-a79a-405f-80ae-f1c6fd196ec0_500x.png?v=1535653313\",\"about\":\"just testing things out, my lightning address is 3716125234@ln.tips\"}", + "sig": "dafe3aef793290b9e69d1d6ef2f7b35367af57bb18abff93921dc95caac755a9ddde03fdc63102147415f87d5d123dd6a0538212227d5d511f6041d89ba58f7b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "8008474cc611b627ec945c57a61312ffbd166f2f0b9e76d9635ed2d754e61093", + "pubkey": "e5256168ee6d01d9ec94becd4275a2505cc7f71c4471bcb71e1e1dbae29e3d35", + "created_at": 1640387581, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Dutch_Goat\",\"picture\":\"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAoHCBYVFRgWFhUYGBUaGBoYHBgYGBgSFRgYGRgaGRgZGBgcIS4lHB4rHxgYJjgmKy8xNTU1GiQ7QDs0Py40NTQBDAwMEA8\",\"about\":\"Helloo\"}", + "sig": "23aa6f1a25784a314254b0d5897434835fca6519cf484598a42f814fe67b195a510910e2fc4a24fd18d4c5b40b0fcbed1de939bdd6ddd745cad27e1b3a388c82" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "1e6c2fb0aa71f3a16fa5d05c173b0b515d406214707737017bacf6de6f5fc35f", + "pubkey": "6d690a2fe00bdf04772fced392ac9e2a7680363f38a71c27bc5448d3e1a1c2b9", + "created_at": 1640397504, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Rockstar\"}", + "sig": "870f71c42ec5c22ce0b42cb202383fc17ff935319ba38376cf9596161937fa5bd58bf55cba17c50f7f176ff2f05231aebe4c17917793aaa0997735957d98c4bb" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f361c7a85eac0cbbd87eb30084dd15bc3c7a1f4248da91465147d13ba0d517da", + "pubkey": "b832b35e7e4d19b46b3e60c8f1c65bebdc31f449c5d68bcc6e6c2b3db03f3df4", + "created_at": 1640398354, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Benis\",\"about\":\"Long story\"}", + "sig": "a9e5f736f23c05fd4d18fd027ff857beb82f4b136ea848ada8834e5217ea7b66b9e919c0875fff6314de54e344524e7c2b02390b1776415fca72b8e44c5ffbcb" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "cf9f8d40ee96f018b707babc9b966c4377c474a4728275a00544c92b8e4ffec5", + "pubkey": "13ad5025016aa5740b83212583cfa747976b4df31246963ac23473c6c00bc87f", + "created_at": 1640398875, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Ravi Patel\",\"about\":\"Building end to end encrypted social network. Cypherpost.io\\n\"}", + "sig": "d139ea979c7f05803804f598bf8eca78cc0894723fd93d788ec72e8885f426995c53a70e6aef71e5e9c05e5dbc5e355c98272ae86dd85186f6e45902ff8b39e3" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c44197bd931b6cdc7d7abffe0907789821ebba6d9bae02857706959a6dcf2820", + "pubkey": "194fc1c4cec9b29b9c60b9ab98166eba546edba1e5f9adfb7102107d60bf7dc1", + "created_at": 1640411653, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Happybear_btc\",\"about\":\"Just a happy bear who loves bitcoin\"}", + "sig": "bda03d1cb7d73af683105bef0717494e9e22dd04f2cd0cca2a016e8ca8382c0a34e72816eace36a49c123c8242296a30f533e8a98eea82e08719ed3b36ae3112" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d427d831094b3bee037490acf5126c6748de187b8c8b4f515979ac8dbc9d203e", + "pubkey": "770e8b4b72cc53a0c9953de3eec8ff2b2b314933ac0826608af4a40514cbfc03", + "created_at": 1640464924, + "kind": 0, + "tags": [], + "content": "{\"name\":\"fiatjaf2\"}", + "sig": "91633828d6659ee4d69f9f7fd264951ac04d990648ad8081e18bacd0e677b093a4e52990c6babe21eb8e2f4860c3937eb27893a8b5a6f02b1f410f4495549390" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5a6a79e360f053d235cbc3e72efc8b74f3269b69b93b5606ce2487ba5b56b111", + "pubkey": "b5e9dd72fa95e1b9655c9d65d7438f6ee161f4c664583c19f5d0297b80d58572", + "created_at": 1640646761, + "kind": 0, + "tags": [], + "content": "{\"name\":\"asoltys\",\"picture\":\"https://adamsoltys.com/adam.jpg\"}", + "sig": "06a0d7e60e9dc51dd2e2b829c15a682f639fe535269531c6b86ab0cc775b5b43826d718b2685cb8f66edd8f363e4721967504d174ad9c7d5a4701164ce2ecfb4" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b7b0032fef0f6a30ebba1170da25f7d01affc677c062224456387dd379bbc7a7", + "pubkey": "c9217e2f26a5b9986dd46340a40cbd8687daa51a56b1b47f4ed3f47adb154b6f", + "created_at": 1640646896, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Garbage\"}", + "sig": "b60d4b159eb5e2fa4bd807e4664455d4fd8a092567c50477583eacb3dea8a43d68668f2d30afb626833bafcf5b189d6d1e52cf032623f58f2e5213898be01702" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "678f90eb3706aaec2ab841ea700e089e3b142200d103d5fb7f3483c1b9f4495c", + "pubkey": "bebe06cf128be9b207eae489348693775299c5032fbb17cf12dbaff88c519c82", + "created_at": 1640647342, + "kind": 0, + "tags": [], + "content": "{\"name\":\"SamSamskies\",\"picture\":\"https://pbs.twimg.com/profile_images/1457132530310725637/udb53cQX_400x400.jpg\",\"about\":\"Frontend Engineer at Strike | ⚡️ samsamskies@zbd.gg\"}", + "sig": "fc9717cfba92bba088a6816a1ae6f525f604381ec28b8e2ad9d04c1fe96e4ea468a0e6ba23089fd1dc1d62c40185db16920e202b189b36b555dbada1d0dacc16" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f92cd57a78c6e454e406ddb36b577a0138a2e3f894b5598fbffc9d8511e4f209", + "pubkey": "95a70d7f7bfc4deba1ce464a20f3c15ddf0774aa3fe2897affa80b04dfad94d5", + "created_at": 1640647523, + "kind": 0, + "tags": [], + "content": "{\"name\":\"test\"}", + "sig": "e6b7039b6d4046acad2870d6a8a093dc7822c57a728e48ed86d78b9122cb1b1ce404f9e55f0b0b2f0cb7050aeb7de6f7f62a78401ab52f0bffb1de1bbfa7b94c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "47a0e4a4b78b98f6cc9796ff11309e709bd899b90e9fcf588f77fe78c0533cd4", + "pubkey": "c3e53b0af51c078bfa0e54deb615fca84742cdb91372cf8775693e2704d612cc", + "created_at": 1640648378, + "kind": 0, + "tags": [], + "content": "{\"name\":\"crawf\"}", + "sig": "1271feb63f58cc84a0c1f519c2292a2ab026889e896dacdad265f12764d5891c68ba85ec2d9dfe8113aed2e5e002b9f9c49ecb2ce6268676a0e71e04eef6d829" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5b5890973d7f401e8855c7bd78bde6048996b2cf745e5fc54b800f9315e6d7e4", + "pubkey": "d34232d360c7dc249d8960040887c02041c1ff734f1cc1f68d3550c72429c695", + "created_at": 1640651146, + "kind": 0, + "tags": [], + "content": "{\"name\":\"pw\",\"about\":\"Hello happy to chat\"}", + "sig": "f4c5b1a134739050dfd3f829a09425efe66c657e37258faa2d9d7fa4a3ca0ba121ada1588dffe20b0e4d84f429520e2e560a2fc70fc84e13a7960c747db0f94f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "e5da1eaa921ab693f9dbdbbe813e882391f339bf1b02c0658271976e633f0c18", + "pubkey": "3c559080f647074a638e9cc6d4c487a06bfab810402aab9a6794fb49a053153e", + "created_at": 1640653866, + "kind": 0, + "tags": [], + "content": "{\"name\":\"False_Faucet\",\"picture\":\"https://pbs.twimg.com/profile_images/1447307970702987271/Cqj_AmHk_400x400.jpg\",\"about\":\"bitcoin is for enemies | im a satsman\"}", + "sig": "fd7bdbe63fc1115dd8600789ea21de533a3592362606273d41f56d0414057a07e16917136a16bc6e4075a4a012f3ddedd251f1b1f9f10acc4ecd1167a1e9e264" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "25a7d6eaf9d3ec038ab098a55aa8992baee51b3052491983689a4190a8b97063", + "pubkey": "c697f7f5f59de8ddb93c6b74fdd759ab2dc654bc36315f39770c214607fcd65e", + "created_at": 1640658677, + "kind": 0, + "tags": [], + "content": "{\"name\":\"johndavies24\",\"picture\":\"https://johndavies24.keybase.pub/990AAABE-5640-4A54-AE46-8491C883B2B2.jpg\",\"about\":\"I like more things than jaf does\"}", + "sig": "43a2559a3f2f07114b7a37d3f3c1f76676d3b4be353913c822a6e33cb5b59d5ca11e6c6b970a246541bcec7ff6e43131891eb46ee7da09ee879de0d3954366c4" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c7827c900a124b1a29369aabb4fdb3a49f5cae8e57a95b68d5f269e0a6b499b5", + "pubkey": "574a212913bd1ed46274d14734d95c0f7c7499eff5f3a84a5b9e64a4759dffe7", + "created_at": 1640660364, + "kind": 0, + "tags": [], + "content": "{\"name\":\"bitcoin\"}", + "sig": "20437d5cebd4a495b3c021d07859e340b34d8a43026c3e2913bf444754465f390a98e1bba7b00f1c77ba614413494e8743764d0270a8158692b56ea1e512ae48" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c55676f48d19b7020364dda11a3d7146517110b70021e636a2a08f2a4ce603db", + "pubkey": "53f487c2287fd217089b22ac440c0f97993214b0c09e4ca963c18145bf82fbfa", + "created_at": 1640661076, + "kind": 0, + "tags": [], + "content": "{\"name\":\"justinmoon\"}", + "sig": "aac00a4c422a304448381ed7e17aa331252f2d4a19aa643f3ede4b6b8b8a5cde932783ce7c1f1dfc1756261bc8d462682be26f3125be49aab2ed4ddffae946fd" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f6e510abbda7deb22ee170d193fb554f30882d27595fbc1fc7c2f4fe3873ada5", + "pubkey": "ea561307bec47d416fa013d0eb552413c2b01014b31d15da210ea28d51ac1a17", + "created_at": 1640661866, + "kind": 0, + "tags": [], + "content": "{\"name\":\"gerson\",\"picture\":\"https://www.twitter.com/gersonmartinez\",\"about\":\"tick tock next block\"}", + "sig": "edee57d256434aeab73815d58c34f1e92691a9493975ff9bc9103c39d9dc04d26c0f290abfe4293c198e5c7f0a6779296c881eb8ee6a2b77f0bb62afe3a0ee5f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "10f027ad66c1e177a05a735d7db558b051a75f6f26d671ce543ab36fe8a58628", + "pubkey": "afe56dc7cd68b64b7bd5279f84b774bb40d7405cac168bc3253bca979ab43bad", + "created_at": 1640666605, + "kind": 0, + "tags": [], + "content": "{\"name\":\"pseudozach\"}", + "sig": "e8f20b525870c761b38ee60714661c4c9f457db3f70004ce1b1977bb8d54e10a3f60539d12b67c1cfb9974b54a19870f2848a13a58b5dab0b18adbead99eedc7" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7a7ba02e3fcb4046facfcc0b296d6613a0c81e3bdbdfbcd9f7688fdf1a03dd4f", + "pubkey": "a948bfd82f480b4dd24882ece60b9997854af58f2b5b6518ea25677b5524599a", + "created_at": 1640669647, + "kind": 0, + "tags": [], + "content": "{\"name\":\"elonmusk\"}", + "sig": "7597f6f1c9b79679f283523357c9fb5cc9dc5b523d81073e646aa54c211b006c8564e237b44c9f369762a30cc40e02149c593e53d0b1d1c695bf6520daf6f048" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "66aa8e4942807541cc4673d8ddb08531387c1241386d3c22b002800e0a0005cd", + "pubkey": "539005c815bae57e3ac2ad2f91ac10ca8c04f2f05dd0a69c8dc95323f2d8416a", + "created_at": 1640673050, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Cryptopath\"}", + "sig": "63aa0423117a050713e17748fff36c467fde4598655d32bc505d19888eac706733c42b7adaa7df2bb2f8b25601bd1020f6a25c12ff8738a8553ec14470a6fd43" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "1923b1b37cd94ae388b6c45b1e019ccb1ba1551ac8728f063403f6537262ea16", + "pubkey": "a0aff150f19fbfd473f46617aede5f954a4d4c92d51a14a8871e64cc7279e711", + "created_at": 1640680216, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Thales\"}", + "sig": "dbd036c4313e95d76452cb781bd828d1cfed240697266f9d16994d89603a446b72b295564ac8a5f969ab71226fec67172f68fd4a2b2a4ad1bc4a6c1111d630b5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "62d4a6716742d55a5031fde220a13a0f45e4656fca281a11ffc0dbc0975191b3", + "pubkey": "4a0b101e9540781eb15ace759daeb152989990c9d05651b910601dffeade786a", + "created_at": 1640680282, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Thales\"}", + "sig": "9960b84cb02d8db51563528d0464a7a80a109427982b8a9f523f4c3e080bcdb1eae66357dd213843becd47a82de5d0647e62b0033c2f378c4e80d86907b6f4ae" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b15b3dfc46b31db300e6fc6e43ec126a890b1121189e0e858a9b446234a43d7c", + "pubkey": "d30e37f03478debb713bce7270667efe993bf9ee9dd8bf381d16461220c52925", + "created_at": 1640685803, + "kind": 0, + "tags": [], + "content": "{\"name\":\"escapee\"}", + "sig": "0fdf693751e03c728dcd18551c33ec7bd6e2cf5685e3ea42f214437df03b091a0c6c140cb3473d0302ca5d350ea08b70f07c18f887c06d9a71739ed283a63422" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "dab205db2ea18b3b9db4a61dfbf87c1cf583741bfccb7d9284bd3d5fe15675c9", + "pubkey": "88c3c4078feefdbcbe6adde097c615683e745a9df20cd62951c5e6ee6202de1e", + "created_at": 1640689249, + "kind": 0, + "tags": [], + "content": "{\"name\":\"gs\"}", + "sig": "586e7db1d537884261a90226a5fe2052e29b82bc65f3c800b2ad575c7de18dc7b176469c960cf2593bd9eff7520e7351e80f94f8e5c4c00a2ae6c9196eff8c3a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "6835ec9da57b7694ad7f47567ec68d18024477b4e54afec6139abd7c8e3b0514", + "pubkey": "51535ad9f0e13a810f73ea8829a79b3733bd1fffb767c4885990b02f59103a13", + "created_at": 1640695352, + "kind": 0, + "tags": [], + "content": "{\"name\":\"AnyoneOReally1\",\"about\":\"Running #Bitcoin nodes only. Smile.\"}", + "sig": "44e5881b71fa14c6b26f9175c667bf9e1bbad321c2e019970a72e7b81b37b75c4c605a7ef4800762d8b1de6e2bc8c9058fd009e059b4a322c8d41603c48fc4b6" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "ebfbb22e6c45478e9e51efc3c8f58c39d81ff9043f1ed7746b2e9fa880b6fd98", + "pubkey": "f36a8d2a207094661e58ccd7d101f0e193ffe9f64a4933478c18cbd886e8ff0e", + "created_at": 1640695545, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Yyyooo\",\"about\":\"Dguyegurffh hbffunfdykc\"}", + "sig": "b300d07841f49a154fdf23487b1b38b41d648a643af9856eaaf37f92e23d38bb0f14add45694601ede74600fb74bc5f5b5c26cf94a958e8d0e48c4813ef28692" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "be7c4db067e44fc8e3763fe15be263e6d11340514e76912d7d3c6c342b129357", + "pubkey": "6c4a4a9a6bec87de1fa56a444e38cf4a1f4c630ab9b8ccb7352054eef24c76f5", + "created_at": 1640695893, + "kind": 0, + "tags": [], + "content": "{\"name\":\"JohnOnChain\"}", + "sig": "b98234cc13bf46b0831bfde8b18c7eecadb895efd96ff4a027876ef6c3278b54545f5b4a9551e5cf9054570ae2fe7202341481801d9697ebcc75d738b58c10db" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "996d9c6fb93a0fd254ad72b2a5bcb55e5aebe7a6a55be3035e034b522fa689bd", + "pubkey": "f42b61d54fe18ab433eb4f127bd41ce4c001c6cd6852bf2f6a74ad7ffaf05636", + "created_at": 1640695933, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Nux\"}", + "sig": "3106068c04c802b8e2657445c01f684d66b086725bfc834406f2f91b35c00eef715fdc1181c6cc2aa43b43cc5e0bf40f3df2086380e7b73e8f151feeb26e443a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2243dc612deeb218e30c9b80e4a9c94c814f105334ac3cd1244ba263ed53474d", + "pubkey": "8302acc7a1f123666109e7dc5387fcc222f27f761e8add16ca801cff250c0277", + "created_at": 1640696436, + "kind": 0, + "tags": [], + "content": "{\"name\":\"jack\"}", + "sig": "68f07e3d28af1a75c25cb2961ad8c65c07f3040f7df1b82411b7285fcb6aac4ee3f0f6bf71723a57fc854428b0f94916a8a37980b6c8b5a003061a88dda14e4d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5752b6fd7fea69b130f99391ac4d83161d63fbf1c0ce0174b2ec89000c279498", + "pubkey": "e76450df94f84c1c0b71677a45d75b7918f0b786113c2d038e6ab8841b99f276", + "created_at": 1640696652, + "kind": 0, + "tags": [], + "content": "{\"name\":\"josibake\",\"picture\":\"https://i.ibb.co/TKnSwPn/C5-A6-C8-D9-7-A3-A-4-E2-D-AD56-F45-AD40-DBFF9.jpg\"}", + "sig": "7fa8a75f38cd66d03f6abfa4e53c78ed299e728491ea22b4ce4c170ee16a0f663acb4e3aef982fd4f935febf88f929afffc77d3c3072481d92497f5ea8c71669" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4240b1ffb9af3dbcf4b394cfd750fb62251762aa9c263addd4858e120a94b817", + "pubkey": "b4a25d0ce8e8e4185bb33ff3d4d6ba3a558569a796d8fc992769e27528a59945", + "created_at": 1640696693, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Leseko\"}", + "sig": "3a9c958308de1631ec312c00d0af5c6014e82f262ac5fd661047d904896af40281fb3150138e9211eb8d369c840c69c253cf4cfd4dd845d4419a63ce89696fb5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "acedd787fe4efe47485d5133a2303b981ca8d478a1c77aaf2428bfdba962fd80", + "pubkey": "0e83900b0e6af859dc411151e18ffeb1169cb73e7958271f0c5fa15189496130", + "created_at": 1640696968, + "kind": 0, + "tags": [], + "content": "{\"name\":\"ranod_tester\",\"about\":\"ha ha\"}", + "sig": "394cbcee48fedb2eadb6fe3f2badafae411a00325266d4e8882fbfce50396035c8f837891f5ff8437a80901564500c428065041cdea755d768b3b4417d281d5d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "dcbb34978684f85eb6dadcb177e7b6f6d698d7668237ba8732a87f644ccac452", + "pubkey": "73c7ff2146aa79cac93d949763761f953a3d337700ce67c3895282d4066ae796", + "created_at": 1640697176, + "kind": 0, + "tags": [], + "content": "{\"name\":\"linyanam\"}", + "sig": "5e4b8b9dffd6044c01265b1f3e514c51a9afe3d3c2cefd35e4f62783a8ad9e0342350cf8ad2ab6a78027828688fdadd671c0595aaa63371941245ce799dbf601" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5b49557342ec855ed4ac56199ec8cbad7a73047730220da1757425b312d8b7e5", + "pubkey": "228f7d088b85e56c9854370ec17ea58ed271fa79e63712ff9c1e30dc403314d2", + "created_at": 1640697318, + "kind": 0, + "tags": [], + "content": "{\"name\":\"webdesign29\"}", + "sig": "4487a1664796a951ba2bd25cb3ebc3e4c1cbd19cf0f6516225997d5e4ef47304857703c278aac104a066fab5f5750b9981f6ffb897898c213f7dd370da3bed94" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "85a2485025be22c64fe36d568a92b411929733845870b0cf9d84fa824b04cdad", + "pubkey": "752f199472ec7efa37dc7cd98d6f6332b7cf08ce391ffc6645d24b6f6b9cde59", + "created_at": 1640697857, + "kind": 0, + "tags": [], + "content": "{\"name\":\"ok\"}", + "sig": "d2313f19205188e8b76fdb24c501c6e95c1b63593dab3eae55cf55a14024c6fc3c84eeb38c471238c6e01b2dc41320baab0be2a7139f8c01f2d3ac2d4f26d80b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "1b216ff949cdfa0e62cf17a9a273b02a607659a7d1bd153a697dc0a24589f321", + "pubkey": "fafa9669c63286a5ec1d876e8d6bbe0028521ddd63fa9442f9ab11c9bdf3bbda", + "created_at": 1640698229, + "kind": 0, + "tags": [], + "content": "{\"name\":\"hugh_janus\",\"picture\":\"https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Flive.staticflickr.com%2F3621%2F3637304600_4a7c5497eb.jpg&f=1&nofb=1\",\"about\":\"Just poking around here.\"}", + "sig": "1705166a267753b8f3b5a1804017659af779e5acd1c3054e41418c955fb3e947dbd29b165ed7fecc6be6a82e7f0ed48fdb208edce9e281f418b09cad5d3f0629" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "6851055370c3be7b52d55a582f462b933faec9d6d30325f0980b30d755e1b8ec", + "pubkey": "d558bc2aaa4d39a3c0c445c84434f02104e8e869238e6a0718b540f5e18a72e7", + "created_at": 1640698379, + "kind": 0, + "tags": [], + "content": "{\"name\":\"hugh_janus\",\"picture\":\"https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fuproxx.com%2Fwp-content%2Fuploads%2F2016%2F02%2Fblackphillip.jpg%3Fw%3D1024&f=1&nofb=1\",\"about\":\"Poking around here.\"}", + "sig": "ff8d631dc09f636bc5bd7ef121a0ff91cd9d7f6a98870f896f71a9609cfafe4aa943835dc3f1b78b8159ad1f90ec5a27124eb3ecc7328b93fd62228b394a946f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "906d7e2c4a16fb0b26ae2f7cc9d175897e6a5cff08ca30c9a4abd0fa97d28169", + "pubkey": "0e78b5cea5c67749b427b9d1f2d800bbfeff7b0dfa23261abcb55004bb7a2d89", + "created_at": 1640698918, + "kind": 0, + "tags": [], + "content": "{\"name\":\"sebdev\"}", + "sig": "3e896719bc3cb1d4c8538e55f567f308bd803d4ea07fcd2cd1fe3f62b4d53373cf9396fa36dfa0c6d048123cf57542ffa52d9b4d57d015b754eb729317cd94f2" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "20e376fd169a924c9c60894901e9ff9e6017074ebf4013644bde3619fc7dcb73", + "pubkey": "3e528dd6406bbb1d09abb0c1b45267aa052edec4a632d38c1cf0285a1cf654e6", + "created_at": 1640699148, + "kind": 0, + "tags": [], + "content": "{\"name\":\"kouloumos\"}", + "sig": "6cce5d13721bdb3472cf07a9dbc438f9417adafe7512ab9123bba91a23e9d34f170fffdfab1a6f5f051099c2090841483f3dfcb28586487c86a8a5bc720b7ac3" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "feb90264d32f4fecd80cbe3a0e4a5bee61c0980313912fb9fa3017cec44141f4", + "pubkey": "e623d95e63af002b22262b4a568ca2780d64a3ae6bfab104595e4a3fd97f91b1", + "created_at": 1640701431, + "kind": 0, + "tags": [], + "content": "{\"name\":\"vitoshi\"}", + "sig": "19cc1a8c61343a799ad4b6955b43947b294e8cbbb0b8954b2a8368e604ba098c927f398e4ddb06d70bf46083fd19a6cc6296821beedd877d2721984f5a10232f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "ce3333177a6f67ab789c58cd524f0de64491d16d471586d76f19529d5d86c491", + "pubkey": "53304c50e0c8f3d87c80a13becfe5a908c21c198aadfb3ca8033a02bcf8717ec", + "created_at": 1640701454, + "kind": 0, + "tags": [], + "content": "{\"name\":\"a\"}", + "sig": "08605c609e8a6ecf8d8b4e97f93b11860052152a650821544bba281996fbe042baafe0b0c434da1762c60ef75d055078f803096aa65253b6aca836e1e702ba3b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2ebd802f17c776a826eb4dfc41fce8e2236b80d04cefcb6d0972b33b537ceb48", + "pubkey": "c7a270eee7e549736d2d9df3c21f87bd4f052568a8e4313b8071428f180830b3", + "created_at": 1640703389, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Thales\"}", + "sig": "adee1759b3bc672b9505edaa28282e036c09f594a6304aa4c75f080e3573acc6540f15471dc3fa95a60ce2ff84125bee4ebecf905cc6eff48e6908078b3f179f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2fc9f2d9abeca8163c998662a145318174ee16e4a962058bb827835b3832594f", + "pubkey": "cdf23c7dc01cbf7bc2f9506aac950ea54e0039b4f274505e9c712717eff4b219", + "created_at": 1640704174, + "kind": 0, + "tags": [], + "content": "{\"name\":\"bitcoin\",\"about\":\"it's me\"}", + "sig": "4642891220b2bf460a8991e5be801c8cc561531e1b74e4f8f96210e760755652b360f868f2f2729fb20fe3b368b6961333fb767f8731ca3276d5c0584a1bdede" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "27b0381930b10ae1c1b036b1a245cc6cadca032f745bd1da43634b54e03f18bc", + "pubkey": "100059bfaba1c0408f79889ff80f05013051c35ffab1fd4b06e3340dec96049a", + "created_at": 1640704563, + "kind": 0, + "tags": [], + "content": "{\"name\":\"anthonyyaco\"}", + "sig": "f33e8005b09ce3583a9752301f579a6e76e1185cdbb3c3e82340bcce0c9b1d456e4111c336a1006a10a55e4cd15b8ef7ed28d6f97019ce469d95fd881c17c468" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "3be88be0b8c975febc24b444c584ef319358ddfb48fde79ff1343abe97bdb51f", + "pubkey": "17053e74d2c863a16cc7027c8032f49f72c956927bc433a9ee96a1933f4870ac", + "created_at": 1640704582, + "kind": 0, + "tags": [], + "content": "{\"name\":\"unkown \",\"about\":\"bitcoin\"}", + "sig": "b0ffe5df33f39cc37ecab0c2ffcd3ab815b85e0598355d88f2c68fa1bcbf1f534ff3043d276e43bca3052d3c79e3c66c22486ed51fd4141a47cc5a93ba2d6f97" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5a47516bbbfb9ed7d96b1216651cff174916402916a7c482d7659ac5af837918", + "pubkey": "e6a1418e1b9043b01ab1d3ff6ff3143cdec31d4dc28a7e08dbacbf7cf00f9e0d", + "created_at": 1640705325, + "kind": 0, + "tags": [], + "content": "{\"name\":\"btcslovnik\"}", + "sig": "27111cc54b2da66b5d61bcb29cb5c0cc2b88dcc106586f8ea181790c890f4c2c5d44c7e862d6cd4c9545b67bb3f67c7154e907ef3209c943cdac11dc1e20fa85" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "ef4e6ed86ed84c73350dc782d07fb1334fc5d6723f4282b2e90c0eb32b6b5ced", + "pubkey": "32484d99d4b5111a8159be2206d98f5b879a7f12bae4090221436cae1e2cf1b3", + "created_at": 1640706132, + "kind": 0, + "tags": [], + "content": "{\"name\":\"bitcoin3us\"}", + "sig": "3745a4b990f409794dbf1feb6c311e9fc33652afd96bc22e440c81a4cdb87a60cf6833de1a21eaa738049dd2031c90e77b603ec74d0a8d7423e154cac552eb38" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "981646f896c584b9e12a7c03edab8edec357bd951dd16cf9772b79b16b318a31", + "pubkey": "c65cb37c2ca211693f98a2b4efd8ad20271b0d064704a9d6ebae60dcbc645371", + "created_at": 1640706662, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Houdini\"}", + "sig": "9a1e43fa7622fc27c67747f762f43d636987c62bce557d04c2326b39727c430023888c79e5e5b2256d5fb8ecc22faaeac0eed0852d044bc4912a2fd4400713a1" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "249ddb37233ddb6e7cae571081dfc406782039d69a88a94bb12216220b26bca6", + "pubkey": "3f0f31f8f5e63bc54365a32bbd3aa49ffc4efbde5808fbe275234a00e4707b6d", + "created_at": 1640706706, + "kind": 0, + "tags": [], + "content": "{\"name\":\"vincent2darc\"}", + "sig": "f1155a0c49936132d4d051878c8c988a5f8309e50ab28198cf9ff14c1e38c65b87d6863f5ad8d43e39a9b21902dc12023acdec9428040014b032940c89287db6" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b9a0bec36495340129ebff0a1d731dcbbf728ffcfbffc7c33254b976ef4fd97d", + "pubkey": "92f2bc079cad2e8be10cd27e5791ab78ce234092f4651bbb7d119b549311e9cc", + "created_at": 1640707224, + "kind": 0, + "tags": [], + "content": "{\"name\":\"HODL_U_K\",\"about\":\"#bitcoin\"}", + "sig": "9a2c4190ced9d564c27a8751a575a395d5140584858773d2fa1bf3fc86aae07a5ab055cf4c7e3f53e2ed7cb5d7f450d205dd8b4aa57dc48e9cae2bde905963ed" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "3b7e6e9bbcbcd645a7610a8560545e34c0e82a61b9d4b6e59bc9f8c8f4cc18ce", + "pubkey": "9ca4689be90d8bcae45b9e52236bb5fec75cc0f1c5aa57f6917517f5988c2793", + "created_at": 1640708509, + "kind": 0, + "tags": [], + "content": "{\"name\":\"futurepaul\",\"picture\":\"https://external-preview.redd.it/9RAWh-caAHPZkghiMMjB4jJ_ubODJd-XO7iNLJbCHfA.jpg?auto=webp&s=ca0e864fbd153675d9a2800b7ac77b8d5af19327\",\"about\":\"hey everybody\"}", + "sig": "fc724a682865754c0be1231920a0cef418d3a662f214a555187e8e5ff7ab5833c25ca7f25dd52c5b4336b819fb471dabeff2624005d90c47b415e50ba7cbb60a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "1be56e21b33906844b1cab82e900f92e12be7ebaa597d127c9c110ac13ac0c72", + "pubkey": "4891541bdc5f6038ee4b81e57ccb75452529c768bbcc52ce5ba0e61b3e8aadc4", + "created_at": 1640709224, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Dawood\",\"picture\":\"https://www.instagram.com/p/CJSUVazAcYS/?utm_medium=copy_link\",\"about\":\"Salafi #Bitcoin Tanzania Natural Perfumer & Abstract Artist\"}", + "sig": "dd0e5c51dcbb1c07ab76a83c6ecf69d83a97e8a49a391cd3d6d98e5f346db36597b58df4cd54cfb945fe554d2131a69572b78681132ae834ac90058b712e0eba" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f8a4208aacd7279f67ac9c791221320b2d8ed72fe132f9f2099b40eb6b2343ba", + "pubkey": "e6b4799b2c22ec3864d753a54a2d3952ae71097b4a7fc3f3db793d8eea3e2869", + "created_at": 1640710287, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Piet\",\"about\":\"Bitcoin focused.\"}", + "sig": "d1ca6dc9bf7f20edb3b33666040f4bd394de947ad5a9233df7e893af140a17df929ad7b7bd89b5b79c25631cd4cd36508fb5fdb72af41af333fe539ccd24bb10" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "e31071cf4791ddd4c65c3d5437692a2bf18c1369fa240db6d420bf6b80a1a4e3", + "pubkey": "359223185c5abf0119e6d8df091bfa20b8bd3ec20d1e3a763842963fe95d035b", + "created_at": 1640714410, + "kind": 0, + "tags": [], + "content": "{\"name\":\"robbya\"}", + "sig": "8b26ed440758e833bc255ff86c6e563d69fe260df928e1e88565c52a66881037d2095a739a503405b058cb5e86cac156c6522e564a46688fc3052466fa6324b5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d51042182dbaa2698c3253cc919c69481f5d0cc48f6e443b1a3a675e5c2537dc", + "pubkey": "30637f9d64cbc9e3b51fbd1060e4de9974bf1b49cb03c483e4043a78b6b07521", + "created_at": 1640714728, + "kind": 0, + "tags": [], + "content": "{\"name\":\"kr\"}", + "sig": "73d62dae04db7fb96f784e5969556eaa220764fc9b472ee027dc5b8938d97ff0a5089c30c00c5c0c3af2d25dd71e60d5589d3225054d29eac76410c3881f8e8c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "ed860c3687f6848304347da80a4f630617199c2b0ae572a2213ffca4ed9174f8", + "pubkey": "c265be4c654bae350dcfbd17da048c9c17aac8ff00641c2797e6d4b2a8c274c7", + "created_at": 1640716844, + "kind": 0, + "tags": [], + "content": "{\"name\":\"VzxPLnHqr\"}", + "sig": "9c5756d4933c0bd0038b758a03862ba08a67e78f2fb3594128f76dc8d5f59b8033e73b48a7c131210e2904991373d42c17cbe439d4da926f263a6d3e97a7db04" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b18571929341b791a50ce28c1a6ecbc4e2ff4556b9af619fe000d77d52064648", + "pubkey": "2bc792402a59571536cd87871cc10dcfdcee95eff05064868c843f875ac60269", + "created_at": 1640717224, + "kind": 0, + "tags": [], + "content": "{\"name\":\"kr\"}", + "sig": "ea7c325bd804f25ad0a8cd7ca8875e1d0f5645339fdaff0a6241724e8287af33efd3234e8665ab37ad6ab75ae2977c418b7b71e97e3ab6ce0e36731e4def85e0" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4c6f89f4549ce3b019c67c99880b8b9a7ce5b9ee9a76426b20fd4debdd1633d2", + "pubkey": "73f1e549837b5de96f49411bd002896a40bebfebe44881f8a467450e561cb800", + "created_at": 1640717505, + "kind": 0, + "tags": [], + "content": "{\"about\":\"\"}", + "sig": "effb07433087f1ea97618e66c917872e2c77f4191f850d6098051841af8272b90726d95997b3f0ba5126bdae565cc4f018fbd8a00ad2fb0134a7dd572160dc66" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "fe0d8cd60866782acc4480eaf3edbe0be063cdaa73a698e1652573fc1f35054a", + "pubkey": "a5a4e1dc3cfef667fc5cfc5ed5ed97597b43386e91e1a05fe28f2fc573e03767", + "created_at": 1640718519, + "kind": 0, + "tags": [], + "content": "{\"name\":\"BTCTranslator\",\"picture\":\"https://pbs.twimg.com/profile_images/1431870093164793857/vrZvW1SC_400x400.jpg\",\"about\":\"Translator of Gradually then Suddenly, 21 Lessons, and more articles/books on Bitcoin.\"}", + "sig": "058bf7702da2aeee28873a81936f14fead5f717fb2d60f282c1a8af818201c68ecb79a3571784aa896836700b31f6a6251e8dcd161aec43cae1136dc04e78f81" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4261c049796f9b4723c381e2c792a0b8641dca8ecd30705a29e63b7abd8cefae", + "pubkey": "a2e6db73a135a9bd2ccc149513eed9677f508e8fab7610849c1e88fdd56a0063", + "created_at": 1640718693, + "kind": 0, + "tags": [], + "content": "{\"name\":\"johnOnChain\"}", + "sig": "e7b789f12a0bd2e21881aec9ef46857eb31e9d9647554f8dfaf38cef60b2c2e734ae7bfc63861abe74a8f3feee38f47038d7dfef66b919c093e432130557b46d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a9c363af77d7015096c816626c51d78555183b68d04f387debb63b17a206ab49", + "pubkey": "6fb11d4483679af36ee169c54984d8bfd4419332bff111105178ed0b3701a89d", + "created_at": 1640723939, + "kind": 0, + "tags": [], + "content": "{\"name\":\"btcradio\",\"picture\":\"test\",\"about\":\"btcradio\"}", + "sig": "54af1703291ca77094778f317d6db3f98afd4011baef34cc334031936a54d8d17a37d319baff16f2a2a7f241e66731f82c1189efcf72d717e423c4d6a6dafb77" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5eacbb1990b6925db97efeb0a4d1b9a9235a916febd650ba4727ede289ced293", + "pubkey": "e850179164e46ce60e59fd228ddd7b7561db9f77809ad9db3c4ad7ce807bdc07", + "created_at": 1640725032, + "kind": 0, + "tags": [], + "content": "{\"name\":\"NieKo\"}", + "sig": "646a56ae6bb58bb608bfa54ccd695758d2bc73e6afe398f6323ae9feccd09f911fc573aa670443692fa746099bb3ce6d4d91a0c2adb93046e612b786c2432f63" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "bc00559f433c707798429a28858ad8c571b06ff160895537f378577fdba390c4", + "pubkey": "c68966e8b4bd787408edab410bad239d48d8f8f9480be1b2a36b699e0b13bd22", + "created_at": 1640725831, + "kind": 0, + "tags": [], + "content": "{\"name\":\"jir\",\"picture\":\"https://jireva.org/face.png\",\"about\":\"jireva.org\"}", + "sig": "97e070a228dff5c27cf3a6cf8c08ce529b6a9e1c3875b505335f83aee2bffab019b100df8c7849dba0ec8f51b9432fe9a2a80435159def811a4ec1a2cab74dcb" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f1a6c350ed8cbc2c2b3c7c1373363b54b4cbedcf3977694d8fd029890a844cc4", + "pubkey": "c3c7216ddf8fb27a7ffde661aa289dcbc948c7bc55cf19ef2ed0685c075f2ea2", + "created_at": 1640733648, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Psklqc\"}", + "sig": "3f027596459f8743dca9737efb8fd806c4a19157d19850cad25f92f2370fb6188fcb6ca3c506d4f4bd7f5fd4051a3e84c8258464aa5e42444788a7dab0669423" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5454369c2445e12b001b51e5e48cf6722072ecdb187453c028cac8a12040633a", + "pubkey": "01ae01fa8da90d81cc3c369d2d1b8e9d8fe2d839d3b584222f85f2f5843e162a", + "created_at": 1640740934, + "kind": 0, + "tags": [], + "content": "{\"name\":\"microlancer\",\"picture\":\"https://microlancer.io/media/images/profile/55a6ccfc4dec78e2331dbbe36af8e235.png\",\"about\":\"Microlancer.io is a freelancing marketplace designed with Bitcoin Lightning Network. Check us out @ https://microlancer.io\"}", + "sig": "0af12e4f43161a2b7d671da208924ffc453d30c040884a400dad9bf01a57604bd8a06020185e8921ac7ee11ece4be7885cda40ef16705cfcdf58bd9e452c5155" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5f474f4ae9883dc4ea91526eac0f393344d2f61c42456c0af4514a557d291bd0", + "pubkey": "e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2", + "created_at": 1640741847, + "kind": 0, + "tags": [], + "content": "{\"name\":\"benarc\",\"picture\":\"https://pbs.twimg.com/profile_images/1363601520458166273/jEqtVFYX_400x400.jpg\",\"about\":\"#bitcoin FOSS hobbyist, advocate\\n\\\"From each according to their ability, to each according to their needs\\\" \\nLN tips ben@lnurl.com\\nhttp://github.com/arc\"}", + "sig": "112b302b5a1951ba8326c3d6534d7bf42a50e902b8f13a70dff18298b076f27cbf69aa162182ce553cd54d6e0d56ee3978da70754000ff2040d7174b32dd6bef" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "233a45a4cdd405296ddbc048367366f43eb2ff9889023f6ee2ab3e3089a3174e", + "pubkey": "830cd6f753685cff0dc338799eea8dfcde79271db1c09204e6c1045bda0e0ba9", + "created_at": 1640742561, + "kind": 0, + "tags": [], + "content": "{\"name\":\"NodeHello\",\"about\":\"HelloJessica LN Node\"}", + "sig": "6fd5613a23d9664ff13d0afab976fd8d90742a8300b573e9badaae53f709e641031b45716697326b88eac4277df57f89561f1cef08597b813b475ed8455cee77" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "92df4cb270d7dd3ed131b83fe1d94c4bcb7ad9a2efc6b7a796dd3a82791c1ab6", + "pubkey": "791d515054bc56a400b9c477990eaca9f976eb74c10b39b64a7afc5dbb693bb4", + "created_at": 1640743167, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Hesam\",\"picture\":\"https://pbs.twimg.com/profile_images/1362917335477157888/urZWd_KZ_400x400.jpg\",\"about\":\"Veg696, greenhead696 and other696s \"}", + "sig": "a8449be9ac0fc6975597907cc1d7e7f0d9f0fed48a8517eeab38effdaaff911082214f9eb82da100f624c63f940b338a6c9e8b1a91bf5b45f865296fdd25ea1f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "6686d9bfcfa4e32c215d4f0dab9510a86c9b2c64d77315ec46912bb496c4d6c0", + "pubkey": "1785d15fe4a8dda26509ec784787665b4f232ba06a56290831dd561b2540871f", + "created_at": 1640743424, + "kind": 0, + "tags": [], + "content": "{\"name\":\"test123\",\"about\":\"test\"}", + "sig": "5ca30acd295459f9fc850c222ba993d96e23ae644196e413a5122eccfa25de5f04bfefe9eb8855b97ce77386f19ac7ff27bc2b9cbbcb6237c7063b3008e53098" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "999c5ae9e4fce3a2d4a721d533843bfbf92a7dfba7fe494f8d8a557f47ed4fd8", + "pubkey": "8c58c4743904e8e93817e8ba070ee8e6db9b302e7046914a9c89fb457bc22c7d", + "created_at": 1640743428, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Will\",\"picture\":\".\",\"about\":\".\"}", + "sig": "e1b520af1efd3c2f5fbd6733b249c643381840817123d1ac20163dc0fda17800399605d5c9946f419899b5c4486bb256ea13826880b7d23dccb1c35d46e7cfca" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "de4f7b799e22375fc38ba7c9af7ac3e00db11b0cfda31e46532006e96e1195aa", + "pubkey": "03dd65b0f4a0c1aef27c46e50ca0f117a142589d334921c7295e2a153d6e91f2", + "created_at": 1640743501, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Maxtavarz\",\"about\":\"😎\"}", + "sig": "535fb15c69eb0d8a9d00d2d4dc7d55308a7df6ad610fbaadb87ce92d02c4bfa66330903d935e2690f63f4575b9b58670f9352136ddba121a10758ee53d711d29" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b7631a0a0adc1292a1a00133a6e1512bdd2a40ed86eb6d5f405edd81ea17b02f", + "pubkey": "e5e85917ef9279d6755607ae4cf347f1b6531b1c6437ec4d8892146c80a805e9", + "created_at": 1640743534, + "kind": 0, + "tags": [], + "content": "{\"name\":\"kps\",\"picture\":\"https://en.m.wikipedia.org/wiki/File:The_Child_aka_Baby_Yoda_(Star_Wars).jpg\",\"about\":\"Hello world\"}", + "sig": "41f5ca137420e5aa54506cdda13bc2d5272ecdc7fadb6dc54552c509d311d9c8b280e655010222f4dd29d958b10ad297c037d8997954587d81fe94ed8dfe6a0f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "44011dc018a3c0b0fb6324f38a3fa2620642d9c1df7dc960ad2304dd4ee9031c", + "pubkey": "9c543f69ae0c5b6349aa9e43a5cd6c32c999de3a33c7b374dcc674e834af7daf", + "created_at": 1640744273, + "kind": 0, + "tags": [], + "content": "{\"name\":\"kps\",\"picture\":\"https://en.m.wikipedia.org/wiki/File:The_Child_aka_Baby_Yoda_(Star_Wars).jpg\",\"about\":\"tbd\"}", + "sig": "8d3b21c6b957c104ed6c57555560290a9561145131321b733baa29c2a47c63069337fcf9f15b90b769358c51f18d87d2473319252cdc2f0bea8f7ba2528f733f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2eaec6f97b5a45d25cadee487cede1cb392e54da27a9e93841ded4c16d5b6e74", + "pubkey": "eab43adf8fecee8fc71ac703864987cd9fa9c01b4be35c090a1a21eebe8f2d1d", + "created_at": 1640744753, + "kind": 0, + "tags": [], + "content": "{\"name\":\"seha\",\"picture\":\"https://pbs.twimg.com/profile_images/1404851462631460876/hWuq_yq7_400x400.jpg\",\"about\":\"Hello Nostr\"}", + "sig": "ddb18a42762552f128682e17868cce62b2b43217897043402ed588f395cdffc012d20913c0adbc1933f32ae511538f242d035a13c694e76eff65ff8758c38401" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a154c85c7d6c6e4d1cb9ea0e68c2e14b01857135e354bdc94177e98da808ccf2", + "pubkey": "618b7bcd1ed2d1ac93587729752de3b3d0394408fe61e1450603e537b278fe72", + "created_at": 1640744904, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Perlover\"}", + "sig": "b738bd8c153ac35db6cff08a2db72fb654d59dbf82b7a69d0cef8a09225fe472fdb564f5e71e7ccc6dd46ff672892485c96365d733f109b52961d233b1c3e1a9" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9dee1a68872603d4270444ca80cfbf8978a3c7e1e2ea5ae9e76f00099ea70a40", + "pubkey": "78ecf53ffc5aec786c70eba9c606c8a8581a3877593d412df830a5669cd6f51e", + "created_at": 1640746796, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Willi\"}", + "sig": "c7357e7840a24370bb525e1fec5ef9aa1c6c257b5fca662ad9b77cc4cd503f7829e9b8256b60002803361a406d9dca58743d021b2b8ad9c4475452a7fd452449" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "796d138d3fab65b6e31fcf39dd9760d8361d6950cfa0faf928ccace16d43e701", + "pubkey": "6280018841ce2409a770b82ce62e90e1770138e76c788b40c631d7721a85546d", + "created_at": 1640747390, + "kind": 0, + "tags": [], + "content": "{\"name\":\"citadel\",\"about\":\"The citadel is in your mind.\"}", + "sig": "4f9d95f02584b9f431287fd68681fabd8d4c0c666145a1eec500f90838ba9b9cc1eb3b420a65471d1bb32e40d0d29f8c47d5f8aa14e22350503491ae40fdcedb" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "ff13590df66930083cc9b0269d5a54403ff01cd33ae95ac33b9090c76a372753", + "pubkey": "aeb1f1018441825108032c30d37d9212eaa7185120a1d14f0695ffa29188d549", + "created_at": 1640748396, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Giszmo\"}", + "sig": "12f34fa8955a56f7e8563b060efd15f302d2a5408d83bf8826572912fd52c3742a68b37d3633042d80c56432b71277a108e1ca4c32e866deb8c2fae0f0299c68" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5ccc416574ef5bce60cd6495b2e49e6771f6eecd120f41c3158f51abb5af39ce", + "pubkey": "aed01006bb7fadcd74a334cdd7f3eddb08c2ffc91841d399494ce0695d95d577", + "created_at": 1640750141, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Satoshi\"}", + "sig": "6f47be0bfad3053490cc361403cb4307f8068a522c89e4481b9207a9f48d23ff072e0c28aadbcce2a615d51d719b6b1cccbdc69359286757f88fc614781db993" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "8cba4488f1e9850d0288eaa3bc2dc11b794e15e4248279f203fcb7b37d99fb49", + "pubkey": "94659cf34bba7a4bb82c60ac987c55524cfb6f858851e760e28c786e065f6796", + "created_at": 1640756329, + "kind": 0, + "tags": [], + "content": "{\"name\":\"fangschuss\",\"about\":\"I just stumbled across here\"}", + "sig": "6f38d3910c97384036079f883ffd0ac6b73a5b0785c0477a2506d2ec70eb11b4de3dd9793846a8dd7aaaca91580655152455fb7f906b8a21df3e1c248df3933f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "87c6346a5ef5b94b0878a72c83c2f36fe0e7aa6137cd773a1e078df95a8ae8ad", + "pubkey": "48a6c290f6bbce3a9dd64b0c71e8564913c908d9c4ad1983e76f776660165f0a", + "created_at": 1640756364, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Nintendo\"}", + "sig": "188d565e369ecebc21b4c070cdc039f59b538e7cee9e766e6d3d96a21b3a1ea9bf9539489c75b6b0e23e0667a34c67f78cfc307c41ecd2170e37c75bf7bc239b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "640ea678484bfe478890eae832924aaf5a9d04d0397110c32eec7cdad12c1bc9", + "pubkey": "a29c9a67fb542c2c64c7e58a4b0876aa363a4c66475517d3db118f7530eab20d", + "created_at": 1640756923, + "kind": 0, + "tags": [], + "content": "{\"name\":\"musdom\",\"picture\":\"https://i.imgur.com/iCwiGRs.jpg\"}", + "sig": "a5d0f6011854bc81b99d27f231ab0534f52b5642b356332d9fee2ed2e97c13138f23f38b35ad5e01c0ff8e1a82420ff40ba2891bb69e87350364058cc1ab53ea" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "ba53e00122e3dc223c47a5b7003d3df705855bd879ed730bd39575a802315b4d", + "pubkey": "d56e0e50d494df0d93c95a5a2eb60c8cf0c2ec9d2533148408c1fbec9a603aec", + "created_at": 1640757033, + "kind": 0, + "tags": [], + "content": "{\"name\":\"musdom\",\"picture\":\"https://i.imgur.com/iCwiGRs.jpg\"}", + "sig": "2292e4efe44edb074990a6978b49408e5f88b856b2f286619a4d3f933defeac23c50118080ab97512eb9100e14dbd195f325c4882fea97498397de9a2422baf5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "df661a6e56f8ff35850ead3de1ceef09dbc26049b74225e3a6ecf36b8a18f2ba", + "pubkey": "55005b36843e1be743862980c330dfed90e21604e3f9519ba932426a06cf771b", + "created_at": 1640757308, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Rick\"}", + "sig": "a0b3a9f44511720878ac9fefbe32ea1c5b93db3551a8eed125468cd4a08d4b16d1d805a516a7dfeda0136c52f2d58da6122aafc52741dec7a598c8e1535b4f78" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "90a78eae00854b9a31eb448c3c33da4820d52d5209b9bc2403c936d0805dc9b8", + "pubkey": "2d2a37cc940fd00b687b7c40ca8fb6b3c3f89ebd86ed71b49b54c76b30de0139", + "created_at": 1640758183, + "kind": 0, + "tags": [], + "content": "{\"name\":\"arifeee\"}", + "sig": "b2363d612fabca8bce3fc1d3a9a9510a3fb67eecb3e0e5803f56a59f0f24aa18f0019fb033762625f1ffb85a4ae1515a0ee08c59495e187b3f9d0e558de5f375" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2ec255eb9d29223f9bbc11be5d10ff15a66c61d72541043cf402a710a14e3365", + "pubkey": "78825288676676626cc1c6cdd70c4f35d081347856c24c5227eba79d43dc5ba2", + "created_at": 1640758763, + "kind": 0, + "tags": [], + "content": "{\"name\":\"akifrabbani\"}", + "sig": "3b4a574605a03118e3187904f0cf9208529fe451ab0066ec38e293086e33bc510bc5d81ca48cbf4d61530aea580d829f4fcab4cb3382f89ef4ac8a061977733a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "cbb356b3affb0f3c4209c36eeec8aa9a638812f42fbdf17095f768d673ad9435", + "pubkey": "1995332d0ed92271a0d57482c987cd9ef63060c8a947206dbbc7d2364f4ae67b", + "created_at": 1640759623, + "kind": 0, + "tags": [], + "content": "{\"name\":\"nescagraff\"}", + "sig": "fe03c4aa6e0d05ecfeac106e6eb3c5ccca3a75b133a1dff87f6f17a2dc10cee41b823490a0e7d8725dba724cb0551955a090f653c9b963958c265353537c0e5b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9ee99931d4828ed5f8d37d5cf1373a0307132cda720062c53845f8db53128b02", + "pubkey": "e28cac2a7f47a99a2cf1fe60ea23916fb7a241a0b62b278bf28158abb72f34b0", + "created_at": 1640760631, + "kind": 0, + "tags": [], + "content": "{\"name\":\"fusion44\"}", + "sig": "27f8ea3a60bef57050ed90ec5655c7b1dc9d4d7e7da7212e08dd3fdf47eeb228e1da71751c1094c8565031393e7d481d12393927c33a4072741106db5c9b7727" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "21d67fb216a01599b29228f2b13d11504a3470281053e87a999f1105f90c5a13", + "pubkey": "dfb51800a94847a75f18ada0474426c28aacb2f9bc4b16bed40c6244a5850aed", + "created_at": 1640764862, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Everybody\"}", + "sig": "de4b670a7522af9afcb53e5c640d5803733e708cf386491a5c5bac28283bfdf134e665deba8a834cb7e9daafb2c105b0cc88f54361fa659d929a7b19f1663194" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f8ef979358ca737fcff947314572c83bb9725dc2695590575390c8b42a5c4939", + "pubkey": "1f9254e3e34b4aa4fc551ddd2d26fafdbbaa1189143e41d5965d45a16dba9f8d", + "created_at": 1640766811, + "kind": 0, + "tags": [], + "content": "{\"name\":\"bitcoininveniice\",\"picture\":\"https://pbs.twimg.com/profile_images/1362934927113809923/yQfpvGnE_400x400.jpg\",\"about\":\"Project aimed to push #Bitcoin & #LightningNetwork adoption in #Venice island #bitcoinisland ⚡️ bitcoininvenice@ln.coinkit.de\\ntallyco.in/s/pi4exj\"}", + "sig": "fbcc8810b0c0ebcf26c469743a3a4fd57f346d764d42641de387267592134579091d06c25bc7c632cee9de9c175b9a0034aa0dae82bcfd92111a5bd9f1cf51ca" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2439da9346c9a25793f50bd6740f8c63fc66c92c8430867fbe12e08b9540ea83", + "pubkey": "43366abc9d219f1386cb9b566acb176e8a980d2f88020cac4fa0104a408afd2f", + "created_at": 1640767663, + "kind": 0, + "tags": [], + "content": "{\"name\":\"mrcrown\"}", + "sig": "fe4d4d3679fa0be5e694a5d3e09f7652316bca6c78b706aba302d86f5a5ab4cfde6246d7a305632394168f924910a1242a5e2e1820a449f30cb34c1ecc56d034" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "885f0724fe351fbef6eba6aad69f88e2f1fae696668d3bdd0a6159f5ebe9bddd", + "pubkey": "15fde65f2530f53104058db4a34906d9449b85be62b8a0db13285837e73d0168", + "created_at": 1640770144, + "kind": 0, + "tags": [], + "content": "{\"name\":\"ziya\"}", + "sig": "a90755b8d2aa5ec30ca7c11dd3383b0de2316d97fc63e0b6653daa3599ebb58889f4e3a67f0c98c3dd472d70eebef310270b3487ddab570cf8d93a2987566794" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "028cae35760d51c98e7b17f30159fcca8648b3b1b88840fac1040357c42421f1", + "pubkey": "49b3ee952b1e3fe09f79282ad0612d8e4674a138e66a308d82e35ca775a6cd14", + "created_at": 1640770727, + "kind": 0, + "tags": [], + "content": "{\"name\":\"bitcoin\"}", + "sig": "d327ae5be498d7bb146fa43d0f8b04c92ab069414c7c138aac8de25d719995229cc236dc9da6f6f17378693d29fdd0e1e15d57a31e6aa966f9e44ade61407657" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "1263c31010df39efd2cb2e7b01b9c9879c3d911542e79206b7e2cc643cc5916a", + "pubkey": "af59b8ab0cd097b23858ec9b1917b9c52612537c8379d6611c89aeca0a01f83f", + "created_at": 1640771143, + "kind": 0, + "tags": [], + "content": "{\"name\":\"joa\",\"about\":\"Hey\"}", + "sig": "20221c2affaf62775d1464d7369b5a4d0690de35d0deed6d2f5ab9083299df403c5b3a39e713e119b5651fe48457d3d03dfe0cc89dcfef2b18c661118b39b05c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9ae1fef4857bb0174e8fb1221f8da1da6705ebef42e143de7a8f8fe965ba9ee3", + "pubkey": "8aa6797a37eb6da083ceaf0f203bcbdfc894a91dae2fbfe6db5eba0ed7a8b905", + "created_at": 1640771785, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Silexperience \",\"picture\":\"https://twitter.com/Silexperience?t=vkXc6qs4JulB0qbk-BOf_w&s=09\",\"about\":\"Bitcoin 3d Printer & Lightning Node Builder \"}", + "sig": "537ded0459ea2920ebb8400550e037b4ea679ca921005a73f706415c4401468b7bc111224eaa30b2265a9767b870920061e4c715ecd27a756da3a08225988bc2" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d33fd90e472d413037e5f9c1fb863a0aafd242bc8426a1fee52098181a11e713", + "pubkey": "85daf9c9708fe5f914fff6aad72f33abb831f361fee7d2a8831f6026f5d1c23c", + "created_at": 1640772090, + "kind": 0, + "tags": [], + "content": "{\"name\":\"jesper\"}", + "sig": "87c699deb27641442859c47a842df92d20d352bd03e71655bc2a5b63f28326187e9cdf4744d4c3f6223bd0d3074bd45187c467c2a3ca1304835b1da97b67b6c4" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2839dd931c0714435f97d310c0f436163d0afbd2f6d868f6829260d74c576b44", + "pubkey": "3c80b87b5658f7ac70cc7674dd86bf25eb861a65e2382a0bfd8909eda328d380", + "created_at": 1640772821, + "kind": 0, + "tags": [], + "content": "{\"name\":\"BT -UK\",\"about\":\"Testing Branle App\"}", + "sig": "81fd308d8179d1afc0278113c23f28066d8b6801b2f204fcc7481a30fe2d46523b6fc8cf79284ae2aae5788fd335ef6d9f1a510cbe76e9562c90e5332551a469" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "e3983af58d7d759961f70f44a64bdc1baad26ef8c7c90d4e231e6578a15ce2c8", + "pubkey": "683211bd155c7b764e4b99ba263a151d81209be7a566a2bb1971dc1bbd3b715e", + "created_at": 1640772973, + "kind": 0, + "tags": [], + "content": "{\"name\":\"blackcoffee\"}", + "sig": "d66d0ae79fb07a112d5b25ae181389f65aca17cf178504e942265c5ad36b69208341f0e75913aff34508a7a10c143469191f97014d11ebcd597e2421e512e5c9" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "e37828340ef5f7a06f71cf8a73d63c108766468b919b333188d62c685e5e281d", + "pubkey": "34e8ba3086c4719838067da5d5647faa6fc951b9e6e8fd8308cb6d2673d378f7", + "created_at": 1640773291, + "kind": 0, + "tags": [], + "content": "{\"name\":\"IkanNagaEmas\",\"about\":\"...\"}", + "sig": "a03a7fb5aac9e6e267042c1196d95df6c19e2e2e8c80ae16f681667b71647bf8a697e539332f93530ff10ddab47cbc6130799193a8d79619904cdcfbed48852d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "fd41123978acf37e942fe4cfd9242d288657c64cdffbcf8339cc7871e259f9af", + "pubkey": "a0ab7dbc61daef8fc0c58657b75d05f3fb7318f98dad8b05db786a12be186412", + "created_at": 1640773793, + "kind": 0, + "tags": [], + "content": "{\"name\":\"joa\",\"picture\":\"https://pbs.twimg.com/profile_images/1471302254258839552/-oB0r3K7.jpg\"}", + "sig": "d314bb84c339b5e886f8492c959be61205792d21022ce92064acdd1fb1ad9373bafdd7574f083278549fc216f6ac3ce5a29f39b39b3947d85a03180aa94be536" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7989911d76d1b1dc8d7b47f11faf361392a48f4c72d012d6b27b936a25895623", + "pubkey": "a78436b80b9a695d65790c1cdf53cba1c3d2a76f47f0fcff97087e93eba214e4", + "created_at": 1640774578, + "kind": 0, + "tags": [], + "content": "{\"name\":\"asangaleria\",\"picture\":\"https://pbs.twimg.com/profile_images/1468941015528579072/xsMRTVDQ_400x400.jpg\",\"about\":\"The Super Shadowy Coder\"}", + "sig": "6ed3291d9b44b6f11c11b5f2d33279c1f09765b429d00406f488e47dac6e18476a1b2531801664b309813fc5a8f29fd36b3c08c7b809ffa3231ab49596e15c97" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f0ae5af1644cd90082fd3935ee240265c772d5a6712eddef31b0d4f1d29bba23", + "pubkey": "4159b16e6caf997656d8d9c2b1a0a8b7a77c8c317c126b719f45e5b9dca214be", + "created_at": 1640775424, + "kind": 0, + "tags": [], + "content": "{\"name\":\"mandalorian\",\"picture\":\"https://pbs.twimg.com/profile_images/1362888579798736904/MVWFTb8s.jpg\",\"about\":\"We are all Satoshi #bitcoin\"}", + "sig": "5f5f02f17fc057379a52a70da12346766c3256fbe58210c24d5e996a8c13d3b8df8fd34bfaa6ba43c74dbbb09197370f64cdd9017a0897391b46be7b0fcc838a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "bd5daf3c2af1f1237a28fc0137b1084276003908618ee39a34869b1945e4a67b", + "pubkey": "7dd3b1b740764745f3fbd663e0414e9ccd9e8e771ff51e476ce12e31f3ee2dad", + "created_at": 1640775888, + "kind": 0, + "tags": [], + "content": "{\"name\":\"daTEmp\",\"picture\":\"test\"}", + "sig": "0a11dc9a1d7aab48aac187a0df81a1c106599f1f0f2907d9f8443ef33849c60496806ab2d696ffd41155885bd9ed9ce7c2c9e2509e3c3ae5045a975fb8631891" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c695168e6caccc63bbced7236a1035f943824e929bf7f5eb801854697f4723c7", + "pubkey": "4206ae729ed1407e62926e9a131ee9fadb7de714745ef1b80544648a04500667", + "created_at": 1640776176, + "kind": 0, + "tags": [], + "content": "{\"name\":\"MOEL\",\"about\":\"Just a IT guy in an analogue world\"}", + "sig": "be082423a6b564bfd398b164823a2e6427b507855aee7f8358fcaf3e081c386647e017f9dfe1ac4661e29f20cac44b39beb47187bb6edf478889f70b31555fb1" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4134f092043e3a238fecbeb37cc25b4564ed7359033275692ec6bc21bfca757e", + "pubkey": "c77e3c7c9a136c1ff7e941bad35d02d6d62cd8803e70345ddab263f69e255db0", + "created_at": 1640776619, + "kind": 0, + "tags": [], + "content": "{\"name\":\"happybeing\",\"about\":\"Twitter exile (in endless twitter jail)\"}", + "sig": "945cb36e23ec935be1178350b64cdc2eb3b5e71cbc97d3e87437addd43b5c2197bc533fe9bcfdb233dc30162e5917009cbb93671d01ff375e9df4a3d16cac9e8" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c13ea74cab53817398d7df72c1e4034909b89b08834393a3a439f9aa293b1d36", + "pubkey": "8f87ac34eb27a86fc917866fbc9016429bd89cf1d0d27a038a8eaac4c62c63e5", + "created_at": 1640777460, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Vlad\",\"about\":\"btctkvr.com\"}", + "sig": "b23cf8ed88e4137debc2687ef8e36a45a97894e99d9404edce35c5c74a43bc848048d0bd0dee3f2c14045567b416953c346699c86428aa1abdcd0cfcb6640c77" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "90cb97dac66c02a2d1d7a2e2854ad02eee926fa7cbdcf9d2203bd314dc773442", + "pubkey": "d820f32172a9ebb8d559025ae089dde72daf96e2c7e9956648a03e6863c2df69", + "created_at": 1640778905, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Vlad\",\"about\":\"The real Vlad\"}", + "sig": "bb1c0ad693d15f8c6bca049ab26bba1f66967503ed511023ce201fc9f62929314daf344fba72ac16ddf80c51fa17050dbe92bea0aa12e63d378eb4c214489c7b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5798a6eedcb0777f2e25e2d0a1a440bfa18cab81fb13c3ef6ffd8c747c2ba920", + "pubkey": "4b5dba06d1416c6624806993be940dd7c8d5b6120bd089da87a76e243f1e1402", + "created_at": 1640779515, + "kind": 0, + "tags": [], + "content": "{\"name\":\"onleines\",\"picture\":\"https://onleines.com/wp-content/uploads/2021/12/onleines_200x200.jpg\",\"about\":\"Everytime you spend bitcoin you buy bitcoin for someone.\"}", + "sig": "0d44de2cf61bbfeb430d4371260d67621735e70d08a9e081625e8c681f48f82f5dbb4acd3bbb09e58086c4aece7f750a6ef4639a4b1182d82aec9bef860cc2f4" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "6b8cd6f878b9a1ef295df5a14d50a052a625651a0752f403a829e398b3c9abc9", + "pubkey": "e60e6bdd29c04a6889dac2806f832f6d72dbe45e61b584e1a3bc3d92a69ddbc4", + "created_at": 1640780481, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Vlad\",\"about\":\"btctkvr.com\"}", + "sig": "a19eaae02e7bf9ab19faad23b8686f5ddf53e1ae7e9b6b3699bbfe43ea30d97fe1253d794813c862276bf34785dcd0e761ab9a5ae443efd6fac234dbd7331b89" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "908e1ac8eef4598ac07e4b7896bb579a8d320ab89a2372ccae0f0d3218b84c7e", + "pubkey": "0ec73135c2f6a8b24661045e0664e34078ef2d5af43ecb4246ad1edd84285824", + "created_at": 1640782421, + "kind": 0, + "tags": [], + "content": "{\"name\":\"age\",\"about\":\"Freedom is not free\"}", + "sig": "95bfc77d81967c3bf56e27f4aac61a537f1274be4b4da650397c3944e0e34aed3e40f523f64ed620c4bc29ff192c5f2bc2ceffb83cf4717b5796825a03654870" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4439e098507e0ef15700047538d4d7e33eb0a6823661acf3a63f8b77b0510047", + "pubkey": "0d94b0e57165ccafcb69702095411a1bc4d46a66da598017b6b90fc26d802193", + "created_at": 1640783702, + "kind": 0, + "tags": [], + "content": "{\"name\":\"natasha\"}", + "sig": "c9a496639f34776afd7516e0b5768a7a5447316d904cd3053f1dca60545c5d3cdf7c2bad04a72d0db759be57fe5ace6990a84fdda523e719e6a4dc4ed9c773c5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0bcc807751d5d6744d68c735ca594699f2215a2d5d8a354f8c26aeb0179a68ec", + "pubkey": "3707f1efc7515524dce41d3bf50bfd9fdaed3494620b5f94fcf16d2766da4ec2", + "created_at": 1640783959, + "kind": 0, + "tags": [], + "content": "{\"name\":\"metamick\",\"about\":\"building geyser.fund\"}", + "sig": "ba6816cedd789af8976d8d7cafabd8577bc9afc0c38e776fd0b36606c8aec98a773da5c2a719ffb95ac1fdd332e6a6dbf01fddbc6251676411758e76810e380f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2b91cb109d7c96901b3ced08f7251898f6484df362184802e149c007267fd677", + "pubkey": "e9e90bf017a8faa90a7b7c701fd9885469bf194fe6c9cff1a81b5bea4edbc0d6", + "created_at": 1640784860, + "kind": 0, + "tags": [], + "content": "{\"name\":\"surak.xyz\",\"about\":\"hello hi\"}", + "sig": "dffed16b29cc35df5debd74cf34b9b57f1eb4c89bec44995cc022f0e8db58bab264079a35bc467519a256ece15fdcd82c1193238a0335ac2af4ed40f7aae9d60" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "698f7a33629cc30bfcdc925c61dc7337d89cc36745c2b66ff4d57f44cc8b6bf6", + "pubkey": "2508ed2c2ab3f6728a880fafbc0895a2afeacbb74eb69847255fb60564af0d85", + "created_at": 1640785297, + "kind": 0, + "tags": [], + "content": "{\"name\":\"anyone\",\"about\":\"I am just a random anyone. Running #Bitcoin nodes only :)\"}", + "sig": "20e6c5c25fc3c9dc46898ebd7a88bb63eeaa97fcb77d89bded3c274ada1b363d7c782cb93ab066de808cb919e0742917d8d1a5cb9deae88346d0f10e70de651a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "bfc712f914b90bcf33e2cb5812dccf06836f3fb1c67424e25fad487348faf534", + "pubkey": "d13cd75aa116a6e47ebed7a9c7af2c271cf70765244f3cf93943fef7d7139b5c", + "created_at": 1640789453, + "kind": 0, + "tags": [], + "content": "{\"name\":\"steliosats\"}", + "sig": "b54c0844808e7174a45165b182ff30d9318f40829bdec9b49002f8955fe08ca3912656689019b356d250a60e6e3ec09323b37f175468246d265e185d32868f6e" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f40e2bb93f3a03230cd779a787277ec68dad38e91670e9c4e0f5cb0972ad8874", + "pubkey": "e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216", + "created_at": 1640789905, + "kind": 0, + "tags": [], + "content": "{\"name\":\"dolu\",\"picture\":\"https://pbs.twimg.com/profile_images/750950222/avatar_400x400.jpg\",\"about\":\"bips.xyz ⚡dolu89@lntxbot.com\"}", + "sig": "431eda971e3a914103bdff7744be08960b7db86ad71b8bab2afb9d06b47486661a65cee9b1a2d46adc17171177c4774d8618344164f838df24700f5561416652" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "3cfb5a416a8e2341a281dfe32f40dd25553b0cb957d3ef3f0ca02f741144b954", + "pubkey": "adfe3b50efce1f078497d1ecffd9bbc8315523514efc11a4d36b3400a55b364f", + "created_at": 1640792280, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Ricardo\"}", + "sig": "f3106bae916bcf30727d4e14d1f2c9182b1bf9cea2916d66d9c92edb120bde2ee16e4be603f9d82e5e8ba536f63e2c0e4d637d19fd5287b3e72090bce5a41ced" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2129243cc93f9bc5db6bdf6f36656862a6d33aa3449f5ed21cbc5f1f01a4d58c", + "pubkey": "874e8723ea944c419015060041f87ec2097e63aa6ebf61c9eb08d19dc9a7a8f7", + "created_at": 1640792329, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Massarelos\"}", + "sig": "115f416f73eff3840b7cb4cb9fe7ba03e91ef0628939588fbe3455a0cdabbe84c67e258a467ccd8cd1d3a4924d95799370567b8531befca83ecbcb3805ef0b5f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5ab2ed224885a2076df18e5d3009fc026201929fd772947d78484853096f2c0f", + "pubkey": "acbe0c6efb69bb98bb4142d16f0b95e6886c3f4184b21b2f5e63d80924628a24", + "created_at": 1640792393, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Bitcoin\"}", + "sig": "66903e74451d575e563eed633b559763434d0f3fe46702dafdfd0366dc2d160d792b210c304119b83e5b909cd42cf78b15b26d02a892b45e5e78c7a4899dab9b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d438fb4fe164158f0f3a942d666a08fc787108d49435a2cc5eee524c00d10fa6", + "pubkey": "f829bc3a2911273b37b34aec7b221c2198a7fd1d0f95023b085beadb7b0b1f8d", + "created_at": 1640792457, + "kind": 0, + "tags": [], + "content": "{\"name\":\"btc\"}", + "sig": "83508b0a690df69a52de303a64295dbff69eaee43b7d7ab726a27fc7fe3bba7f5e8379f1495fbfaa5ae0e6041e554b6fcb2ba250fba75d44d3cd8a511513947e" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5eade87891f2f1d4f9bfb7b4bc85fe23a216f197d2c486024c738e5240283cda", + "pubkey": "ffebd6c29c65c1ec83ede68521fdb2539c5a5d3dae7c6f20918b4f079d48ed01", + "created_at": 1640792514, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Bitcoin\"}", + "sig": "367d342be6da2da7942444d0fb4fdb5e44d246b28ddec0d28120cff4cc7ca300b4db9db505edaf86ab9675c6af2f8db36c995a7fe4c1dac1634b8f009f15507b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "804fee2c97f4c80e95e6751a47bcfd0fade8279d4be2be520cfe3029334aa6ba", + "pubkey": "75a020a2668008a5db55553273721eb05e6aac364eb3ee2516cb144c894d29d7", + "created_at": 1640793503, + "kind": 0, + "tags": [], + "content": "{\"name\":\"RandyMcMillan\",\"picture\":\"https://raw.githubusercontent.com/RandyMcMillan/BeeICON/master/BeeICON.PNG\",\"about\":\"E616FA7221A1613E5B99206297966C06BB06757B\"}", + "sig": "f4faa98948336ff4a49b3c1ef983cdf97c77e2b44a5c69d41259d11fcdead4f29c57982c32afe77352950fdd3031150e53c6fc56ef8e836909b6aa2be8ffa8da" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "77d68d4b142567525a23c2a4661435aa43aeeff5ac74f337932272bc93782038", + "pubkey": "c2504932f7dfd2c41b431f38dc5a8862558c3be874e3b4b8158e97b681800a39", + "created_at": 1640794880, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Pango\",\"picture\":\" https://www.thethirdpole.net/content/uploads/2019/05/indian-pangolin-750x500.jpg\"}", + "sig": "6e85f8be5d07ee8d4bcede4f08e6e2e029d161c14478065a73b1d33efec69475c4ed0a4fd06a081c607c8c28ed1da29d990330481e1792848702814df24c72bb" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "22167dc877a8eb55cc449427c9281ed073629a2f11d166cd17f9b62754d047c0", + "pubkey": "3d249bedba10de2d87ac2fb2d99c8ee013310304dae16bef0d8d9d7be60ccccf", + "created_at": 1640796449, + "kind": 0, + "tags": [], + "content": "{\"name\":\"UriBaba\"}", + "sig": "57ecde133a25f4f3cfd63e250c3b121e1c19472de6e9165f8ee66e99a6d83975c1e9b1078115bb0ae87a5e5273774f01ad384881bd8aa9dc5df8af42484b4b73" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5d40eed7f9090afe61a61476c48eb6b2d6ec1e943c73097684fa16be789eedb3", + "pubkey": "5478de52a6ca51c01f392da2e10116e486dc477c7a02887e653605b5259a6f37", + "created_at": 1640798257, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Satoshi\"}", + "sig": "fdde87067a5a69e91e8754b57cc454ae383bc0632d07090babeb32bb57ed04b605caf8f9a1ff00bc601d17e5d5dc057dd7e2e7df64d5c3f377268c41e6fa70ef" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "3ab31d3efec399b5a535321010126fc3f857a5f0370272c9949939a14bbbe03b", + "pubkey": "60faee54ff85727ca02dad12bc7ed4ba2ca4112ebc1eb19e6a5f4ffe388fc89e", + "created_at": 1640803527, + "kind": 0, + "tags": [], + "content": "{\"name\":\"manu_impostor\"}", + "sig": "db4228297bbee27e05db25849d385de1414b338e9790f1fac1d46a5af0f72f0ba8217be0b2c02484faa8565d28dfce858dcc9aedc90a19588261658373cf8a08" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d4a6512f8e94d05acf35a06914e2d3d9e7224a6b053de1d2dae32f5a62835284", + "pubkey": "c4a44427240656969f68202cf43d6e62fcfed789a58f0b544088ab92403c2456", + "created_at": 1640803877, + "kind": 0, + "tags": [], + "content": "{\"name\":\"georgemoio\",\"picture\":\"https://www.personality-database.com/profile_images/5004.png\",\"about\":\"books, bitcoin etc.\"}", + "sig": "bced8de601493978f6301a19e5404d1a9709e1d2c10989201b808b6ccb94d65cbf44f67d8c6904f14df3e0ea962affd54913894a419533be2eaba2810442497b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "36d1c13baef7673667008ded7f19cb9060506e261b5f3165df1703814fc739f7", + "pubkey": "03d331a47469e16c6de91e5d293cf4871af89941a6c0140ec4391add708d0914", + "created_at": 1640805850, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Me\"}", + "sig": "6d8ff5933f95b27077f5dbe73780021af8c2889beef48f547b2a74e953e5cce7d2ef531462c18bf427f8fb4d90d66af8500a77402f7a557c4ebf0cf3a488e4c2" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a3f07a21fa1e2253ac03a3a716e9e7879e2dfadb9d59623d14f564f55475cf69", + "pubkey": "ed7880269e9368b1700fcfb12757690c25beb0191c1ebee3db31be8a7aebc9aa", + "created_at": 1640807414, + "kind": 0, + "tags": [], + "content": "{}", + "sig": "c59b8137eb107661dbf0b6bf85fe4cc350ef905539974a84b0134d7cd049c81a2f755631c7bd549a037ecc3e9d6740a355e017466762035fd6def356315d62f4" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "67980776bc8a3dd78a9b6e8a50b3fb85130e36cdafdd7a6afb6e0012cc125479", + "pubkey": "f9acb0b034c4c1177e985f14639f317ef0fedee7657c060b146ee790024317ec", + "created_at": 1640808532, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Anakin\",\"picture\":\"https://i.postimg.cc/MKWLp1T9/anakin.jpg\",\"about\":\"May The Force Be With You\"}", + "sig": "52f2370773a031a4866fffe27e71b7b9a43bc8b965a207ec9cecced40a59a8d63402bb717ac1e8cf7fa91dfe79f9f480f5f7688c4fff9ff2e89df52cdf495a34" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b2c11e39e6145a314fd46fe27260e0807ef296fcfe55aa5008db4fe684ac25a4", + "pubkey": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", + "created_at": 1640810897, + "kind": 0, + "tags": [], + "content": "{\"name\":\"akovalenko\",\"about\":\"lnurl-pay.me (also t.me/kovalenko_anton)\"}", + "sig": "51661e5852fe168212e9e5c936f160e551ce16b55e83e9723e998c78510ae16d726891eab455b79c8991432054b1b5e44cc45f00600b0a798c104390f033096c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "52f2ff6b3ec8905a58b525871a4f81efdd4c677803d82c29902065a2fc45cef1", + "pubkey": "1bbb8324577ac089607e45813bac499ebdab4621d029f8c02b2c82b4410fd3f4", + "created_at": 1640814734, + "kind": 0, + "tags": [], + "content": "{\"name\":\"thrown\",\"picture\":\"https://i.insider.com/5506e2deecad049f206828d8\",\"about\":\"just testing\"}", + "sig": "c791f96f26a20d11690860e7d6efd7df49d55e9f3926781fb60d642c522353f404f7f45d0b67b668a94d5fb44478dc1da1a8c5cddcc32e3b178feb1bfac721ce" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "fb3329b5e15584cc9b18494b92d0172d08e2fc2a98d1b5624876949843a63503", + "pubkey": "c0323f681853a4e95072e3a08753e256a8368cf0714c86c5d4833fb40f9d6a40", + "created_at": 1640817827, + "kind": 0, + "tags": [], + "content": "{\"name\":\"TC\"}", + "sig": "056500e9ceb550af9fc14b53ea4becdced8cf8953d9ca70917d2d26b22270db751271ecf936c93ba650e7258e21a03f7898d021c210acda3f4afc5b35305996e" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "75e1bc1998260fe65725c0dfbb3dda7f2e06dc3e366fd2eada63891d802302a4", + "pubkey": "ce5061bfcc16476b9bde3f1d5b3ec7730c4361cf8c827fbd9c14eb8c7003a1de", + "created_at": 1640819493, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Josh\",\"picture\":\"https://pbs.twimg.com/profile_images/1251196359736262660/JXpEUINv_400x400.jpg\",\"about\":\"Co-founder of one of the oldest bitcoin exchanges as well as the DeFi project called The standard (TheStandard.io)\"}", + "sig": "97a600025ec18b5bcccc123ef7064614713a7773601120a23436150076b81fc5015a3f67f99bc7738f24ee0e257bf8d70d62d5d8606d55ef34f362a05a204539" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "980fd4d06deb7427a968514d510e365f7d7bb46b2a1b380e462e241c33b81b3b", + "pubkey": "d3dafcf3be208968aab8a6fb1baf8a9ad3f6d9f89861bc821a0a3f2c4858ea39", + "created_at": 1640820565, + "kind": 0, + "tags": [], + "content": "{\"name\":\"bstmstr\",\"about\":\"Just a wild and crazy guy trying this baby out.\"}", + "sig": "d1b6c95e24f6c74c535eb4758a1e8602b54ed80056762e8849e2c255df19cc66468eb19fb07e431b016bbcc6737ea8fe070f25cffa68975a5c78ee20550036f7" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "418add764821d941ee43984f6d8245ec0696a67b5d785e8a39a5b51c8bbc4a0f", + "pubkey": "6e2ec4ea80149bfc2382bbd3bfe8209085e76d42e86df5cc38782c98324ec529", + "created_at": 1640820686, + "kind": 0, + "tags": [], + "content": "{\"name\":\"rmn\",\"picture\":\"https://pbs.twimg.com/profile_images/1466093697070632964/rpdQZfja_400x400.jpg\"}", + "sig": "33e82fa78ee173861d54411468791798de0d081b5543a076b13677a972c9bbd4b5e9e2f67eb3a3aa0a23cb28903e642d73b7c98c6732ef8f0fe8fc29390c1ada" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "6b34145ca393518107e12077699231733bcc75eb3086e0ce6efad93f1eb2b1b6", + "pubkey": "68d96f67b538a5bb22ddcaf10d7f9b2ba9d877d9ded53e1492c2a3ff242ad906", + "created_at": 1640821048, + "kind": 0, + "tags": [], + "content": "{\"name\":\"bgpipes\",\"about\":\"Free people don't need permission! Bitcoin\"}", + "sig": "f83b083fb02719fdefc67780d6b23373a71886d5ecfbdfb2a82b23a2c906f33e165552a07d8db5c478ae5d5ef235c0f7037a04f8f0d6de70b20e06efa554b161" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "74400b91b523ffffb5bfc306aabe34c2aacf06869e9b7069dd58010656c9405c", + "pubkey": "f013d59700af565380fe275fe90d6391a13a3bf3f69c54dd97d16506c47ee917", + "created_at": 1640821134, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Tee8z\",\"picture\":\"https://pbs.twimg.com/profile_images/1441602030049583104/gNc7Wf_c_400x400.jpg\",\"about\":\"Stacking sats with some coding along the way\"}", + "sig": "c83ae7686440c5a07f664b0c5e5024c354fa99e441d96307394744bef7ea4c213377f9a0de9016a92da70b33a1086455f773dcbe1a6b84ed7d9c234d5e076979" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "cc1de558935e92129b3ecb7bc03e60eb3de41349f1883646d63a438c33d219cd", + "pubkey": "879eb53dfbef73203fc962c6250c724725ad136d71365ffba4ade694835fd45c", + "created_at": 1640821141, + "kind": 0, + "tags": [], + "content": "{\"name\":\"statusquont\",\"picture\":\"https://pbs.twimg.com/profile_images/1363065582602645504/j047AIGw_400x400.png\"}", + "sig": "f800ef17b59e91152df098b663ffa79a4c9b95f3889e444efc5cb07e9216f0ce5fc9648e346a48f980e6ae3056db7ba807bca06f501fde940f95e6b89acdd69a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "dbac2ceaab7961a70fa02671fa3bfbb442df67460fafa64cd7595cc376c1a531", + "pubkey": "415b0f8488826f9ef6c57329c0376927a632ba4919806351732c0698e85054f1", + "created_at": 1640821268, + "kind": 0, + "tags": [], + "content": "{\"name\":\"bob\",\"picture\":\"https://i.imgur.com/I9UfXDQ.jpeg\",\"about\":\"ah jeez here I go again\"}", + "sig": "666fd3b8c2d9d5e59b0e678e2485558eef95b53331b3e896de94eb749576dfefbc0cb412380e3a8a23c257ad1e497c47b77d74e0523c5b5423bdfcccfacbb207" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "986fb1b8ba9921ced31835128cc346911651844e9d252f50e8f18dbf7bc21a13", + "pubkey": "a50d04275690c87aa2c7d7c057efc53b594bec2c571cb56d800e61c137eb0297", + "created_at": 1640821467, + "kind": 0, + "tags": [], + "content": "{\"name\":\"kps\"}", + "sig": "2beca42b2913985d70f67630d983d703a2ee6c900f9cb86bb496a5e271e814e49f81f1ab6bb1a1e2a142c5a3c49476fd0d711b6320ec7589a6f81f990f1d2b60" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "48f30483211012571d837afaf9c04c7b709f82d961633e6d3509ab9292dc6b02", + "pubkey": "d1d13cf7041a146ea6b00d0b8e0fc8ca1982e2fca21b1643d65108aba820290b", + "created_at": 1640821639, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Sats_standard\"}", + "sig": "1743ae74c5af7c74036c2d262cc63392df287f5c150856ff6e94be0d3e516ebe88fb34ff338cd99d799e8cb0de3fb855d5f74d728d7879eef58a53772507c806" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "366b26f27b8fc115213eaf2960b60685f39b96041783d2f66b2cf3a7dec60084", + "pubkey": "5d9ba2c5ee0e86e2c4477b145eb301f2df06063a19f4c4ab9042bd347188ec8e", + "created_at": 1640821711, + "kind": 0, + "tags": [], + "content": "{\"name\":\"xavierfiechter\"}", + "sig": "98ffe1a5cc658128d53e65711008cdebd9534a7efdaf9d1fd4cf87daac03de8d89b23a6f7cfd9bee19652afc0901a386b8cb80abd5aadb91c278a38af87b39f3" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "374d2bcef8c0721163b23825201f1f00b72770cab254cf6690219a001b870bf5", + "pubkey": "f4d1256cb67db3f16abdbfccf09082a2cad2590bb9916e08bd89230478cb14df", + "created_at": 1640822019, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Sats\"}", + "sig": "73fe8b15640e5d6bcdf47eebf64e45886cdee4bc4f1ef898fc558a6ea64be6e12a4f4f10bc6d775805a0922d0a75189cd736390a502fdd2d1ab4f7eeff5689e4" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "ed426e4536cc37d8009fa2dc265b158fa73554c6ca14a0d216aa2bbd28901957", + "pubkey": "f133d6c1573b87f7a3ecbd2135d53ac6ab50e3b2d303b7032b71a54c9f7222cf", + "created_at": 1640822304, + "kind": 0, + "tags": [], + "content": "{\"name\":\"btc\",\"picture\":\"https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/BTC_Logo.svg/2000px-BTC_Logo.svg.png\",\"about\":\"a fan of the future\"}", + "sig": "d43afac4a048f7e3fcdbbab2162b0da2a392716d9cf9ec31177660f5a9ad4d771c107f4f9c9fb2fdef327bcc0408b59ca283b1f181e2de8a79e63528d4dba007" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "86fb209b267bfd00e5b39875d177a486e79ecf1f294ca726c6f1c5872e100877", + "pubkey": "6afba78a35ee690479951dcbc2f38229ad1923a50e8d5ce4a664195eca82f49f", + "created_at": 1640822619, + "kind": 0, + "tags": [], + "content": "{\"name\":\"sats\",\"picture\":\"https://www.pexels.com/photo/black-mountains-under-white-sky-9467294/\"}", + "sig": "250aff188efe87bf8a8610bed263bd290df00f669c9e8407a0e1f01b5dba68bc01b46936e368a01e32be66c5c7377368cb19fe0fb420775f3cbb128cb290da36" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0ac3cf7d57a9e69157aa998bc147a5dd992903c4cda7ca07cd424e5313f3955c", + "pubkey": "18bf7bb6540c3ee9511f5a0607ba1d04da045466ed9cb1c505b91876c0bdd2ea", + "created_at": 1640822676, + "kind": 0, + "tags": [], + "content": "{\"name\":\"AndrejStacks\",\"picture\":\"https://pbs.twimg.com/profile_images/1394037822567419906/fiBm_YSY_400x400.jpg\",\"about\":\"t o x i c \"}", + "sig": "f29524aebda00ee570915ce3c5d789cd799c8966a30fe8fe092518bd931c01d60245e65d622cbcd7a9c6781a1a3982b0add73769cd32990bb840f70ae991e27c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "17d84f4a9e66a60e6c1b804f45e93b6d77563606715a92dbb747cf20adb29ec1", + "pubkey": "681056163ddc85ce11675faeebc9eaadf31d260b7af8e576e0088655c817b563", + "created_at": 1640822678, + "kind": 0, + "tags": [], + "content": "{\"name\":\"mikeinspace\",\"picture\":\"https://cdn.masto.host/bitcoinhackers/accounts/avatars/000/000/627/original/d9e9770e36eb2aef.jpeg\"}", + "sig": "4fc7025ee42023768f6bec3bdc7ec5470bdc923585ac5a943159ff254f4b95b375b4918547db1eb4c626fa87ebd373a653897b5cdca9f6577458dcdbc0b42381" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "8da29d65088d9c062dbdc8ecf2c3ffd4072638afe3f9ecb73664ba2d295dad8e", + "pubkey": "d00df5019a98637f50cdc380f63551b1d85c01a9800e53b4c85a63c340faaa25", + "created_at": 1640822986, + "kind": 0, + "tags": [], + "content": "{\"name\":\"fk719y\"}", + "sig": "6bd4798346832abae9ee2e187e34f71165cc1a355607a0d6dcc553ae4d57d3034562e96491abf43e95b3e72a01e608fdf1e1a8e922bcbb3d69fe8a82a37fc23b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "448d271bf28859d3817460fa84f566c55d5df044c8bcb9dfc33106aa3c641662", + "pubkey": "5b5e50f8758b6efab4600f540630bdef83808b0b1aea8d30d029e87069527c91", + "created_at": 1640823025, + "kind": 0, + "tags": [], + "content": "{\"name\":\"MrHodl\",\"about\":\"Bitcoin\"}", + "sig": "56bdf5242ad2020af4739d67bf523f7abda400b9cd5511539908dfe8987f03016079edd889f8d6ce24e77abf545f77843c8a13fe2eb4032fe6eb2b91f59e6285" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "eaf72fb1328c56697f729dfab8215fe6c52c5d5baf41561656ca31e73f9d4491", + "pubkey": "30b8ef0232dc6ce03a0e2bfc151d5d20be7e11fda976c0c0fcca34af399d9dc4", + "created_at": 1640823032, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Kieran\",\"picture\":\"https://pbs.twimg.com/profile_images/1165185031821778945/dqn6kYuv_400x400.jpg\"}", + "sig": "5124d709890167cce3747e367a4536304ed3c4fa9308bb72f1296dbc147c812913c5e9773bf8c1676dd9db6a426222bb4b81e7c2da1000ff60c8f3dac974df78" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7ec4ba39e065148b60c0c910380bfe08f3e606cc500f839eedb5988701a640d2", + "pubkey": "43f10622beefe2aba3db70c833c3f6e43a18c0589e6d7ff56b582da474d754d8", + "created_at": 1640823083, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Juan\",\"picture\":\"https://pbs.twimg.com/profile_images/1395901786297442308/nEow6-q2_400x400.jpg\",\"about\":\"Juan Sebastián \"}", + "sig": "e2d7edfa3d6efaf79b17bcbcbe52afda38c322ae2a84b065d5a4ee91eebc4e2d3784d58de3d4ecef3199aa29566d22b5e523d6f6757dc46ee8a3e8794ec55056" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a0fd1135ab1bc61d77360fc3207ba1b88194cfb5d29a53e8a2b21d8976123b6b", + "pubkey": "b1a4a9cf68b11e61b6cce9364d69e7026cedec9b9acbfdd302b45ec255da92ea", + "created_at": 1640823272, + "kind": 0, + "tags": [], + "content": "{\"name\":\"one\"}", + "sig": "50fc1e98082683fc11cbe3542b5de3fc30747ca909d49845cefcb1ae7b3970a583b2edf24dc517f8cced5f64180802bc688f40c366889eedff8494c9e225239e" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b2579a728b7b5551679b38858140b870d599573461801a8c9b6060eed606d1fc", + "pubkey": "dbab9040bc1f0c436b0f92f517702498358edc1fde2c7884d0e1036c739d44f3", + "created_at": 1640823548, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Jp\"}", + "sig": "ff344c338e46cbd2364a4ebfe13187fd52fbe7983272574b6c4dbf3c58298079b82ce6704b0e5792435b33a212bddd5b3421c02a7fd480d155f3bc97fc957d63" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c1297c6e5c1f7d1895e32d225e80706dc311e174d1203637f95fe667c735f7ba", + "pubkey": "dd077886f2f045049a9fbc5f66124882437c38a065e8a8f81780824d19c23a50", + "created_at": 1640823635, + "kind": 0, + "tags": [], + "content": "{\"name\":\"thebitcoinrabbi\"}", + "sig": "ad3123f59bda86fbab8fdfe2e5befeb0b8747ce47a809e195f4be010944be684e7997d843db2ce97cdc42d7753e06827db66e7ac6909cb542b251ee10d69e132" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a1fbe923427456a4dec0f034ca14fc450902176dfbebe1e99cc44fa8110333ae", + "pubkey": "4673c5c55cc64dadc0f16138d2a95a99514c53e4cb30c67f3146c2c6edf09090", + "created_at": 1640825008, + "kind": 0, + "tags": [], + "content": "{\"name\":\"RobertMiranda\"}", + "sig": "d672c326e6143718183e73912c5ce8d72c2ab60eb58661828eb1a06b8dc0e546bcf90ac1257e6654dace492bc52aa3844f1b844c107d19a6680e43022c838891" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2a802744370e4c42ca04d3edcab8f587f191f96de7d0e3d93774cf801e6aea96", + "pubkey": "9137bebf0e1ea67b241a645feaafc14a8feae164f569a5ce6ed23e884ba7dcef", + "created_at": 1640825014, + "kind": 0, + "tags": [], + "content": "{\"name\":\"niknok\",\"about\":\"Nix\"}", + "sig": "f1d3ff56fceda8559dab059b6c7a57ba7565c0448e31758c3b6f62d3ba3b635f315f7545f16564bd4018f4cc82cdb9bafce2371b5238dfbc0c6999b1ca85d904" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "967553b268eb6ce75903ce9fd1a3301fa7fb5ce40ace2822e0b0b4878b76d43a", + "pubkey": "4b91936b4e3ae42646a5d226ae5e9a723e48f6e4cd48e0caf3c09af23d993308", + "created_at": 1640825232, + "kind": 0, + "tags": [], + "content": "{\"name\":\"zoom-out\",\"picture\":\"https://s3.amazonaws.com/keybase_processed_uploads/60fb8620f2e21aa58cffb7e997c34005_360_360.jpg\",\"about\":\"\\\"Our code is free for all to use, worldwide. We don't much care if you don't approve of the software we write.\\\" Eric Hughes, Manifesto, march 1993\"}", + "sig": "aed6e87dde55fa55d5366dfb0950bce76e116790962afc31c361301689e52457c77b894ffaad4f737ed2849a6a3d287293d245328da8f14c8a2bfe8cd38d0c10" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "75a84611c8e4e1c62c131c1744230a8021b0e52d4116c3319d0763e167183731", + "pubkey": "dc32cf0090dc20bf3edeb2f70720ad630535496561dc1895177ecc161cf8c808", + "created_at": 1640825304, + "kind": 0, + "tags": [], + "content": "{\"name\":\"tedt\"}", + "sig": "2f9c4650f0322a42e3ff03300e6d28a08ce9c012132acb9c8ffd2944d7dd1a2c0cddb7561db6e45e1a7ff71dd1adafddb893925978a0e001fdda05c7b47f3ef3" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f099a56f36293e7a51b02547d5682ce8320cb3a9d37f11118f3932dc70c8f84c", + "pubkey": "0c29b0aae3855c72fa3835a679a586070039eb3b8566bb216e5dddb165391742", + "created_at": 1640825367, + "kind": 0, + "tags": [], + "content": "{\"name\":\"sparklingfirefly\",\"picture\":\"https://s3.amazonaws.com/keybase_processed_uploads/60fb8620f2e21aa58cffb7e997c34005_360_360.jpg\",\"about\":\"\\\"We must defend our own privacy, if we expect to have any.\\\" Eric Hughes, 1993\"}", + "sig": "a125490d75c27efa493bfb98a131d7c410beb5dd772bcfc657371d02de5b626293feec32785e524f4fb3c1f6ffa4264fb4f4e658a0ef1cc61d31f7a6d38cde0b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f7c9769dd56d7efc60c4fb77fb351d103a857d9f7761ec65ca7bab258f36bd37", + "pubkey": "b226b1c3b26ae6eca77d3fd333a545d2f965d6d6bd8cd94bcfc12ab822333af1", + "created_at": 1640825382, + "kind": 0, + "tags": [], + "content": "{\"name\":\"patersvaatje\",\"picture\":\"https://www.talkovlaw.com/wp-content/uploads/2020/08/Negligent-Misrepresentation-Elements-in-California.jpg\",\"about\":\"\"}", + "sig": "dc23df73b06dbeff6b36d3ecbd1ca0b3fadcb3d24935877db304cb8c45cf250766e5029ccdb67ce8e57bbc19d6e047c6e8657efb6ec648258c88610104b16cdd" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9066a4bf76ea4a304a5633acd2e478c683b4e8842f87529034ff26bcb6799cbb", + "pubkey": "2cdf9575cfb9131ab8da1955bfddc55a2f0dc5cb550a731a787fddbb32f24851", + "created_at": 1640825851, + "kind": 0, + "tags": [], + "content": "{\"name\":\"eleven11\",\"about\":\"🍯🦡\"}", + "sig": "8df672e4108999c1031cdf9ccab53551521b29cf76cea66f07c96bb098c18aa4c86e61bd40634b43ec794164af4b8024d840cf7d531c4e7fa27fc02c972f0036" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "6256cfc89e9bed8705129446555fd6cf5206db86119b84a5114ea59bcd266606", + "pubkey": "46073c668afcacbf2caca6106ba9a22e0c184e856857ca17d36bfb64cb165917", + "created_at": 1640826022, + "kind": 0, + "tags": [], + "content": "{\"name\":\"SamSamskies\",\"picture\":\"https://pbs.twimg.com/profile_images/1457132530310725637/udb53cQX_400x400.jpg\",\"about\":\"Frontend Engineer at Strike | ⚡️ samsamskies@zbd.gg\"}", + "sig": "51782ae1ce58db86dd9ea9a556a58285c35d66db63219dff399667db27ace3888d4b5b4f00170d36f55a5d0e7128fe51617f7a7af8db8b6a1344c701b3a6048e" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "31d8742180174b7995bda9b21464ece2af674ccb200202867d204485626436bc", + "pubkey": "2b51f13ecc98b13217677a07bf46ffa6952ccd40c556345e791b656d6e02c00f", + "created_at": 1640826092, + "kind": 0, + "tags": [], + "content": "{\"name\":\"SamSamskies\",\"picture\":\"https://pbs.twimg.com/profile_images/1457132530310725637/udb53cQX_400x400.jpg\",\"about\":\"Frontend Engineer at Strike | ⚡️ samsamskies@zbd.gg\"}", + "sig": "771b504651ec01907eb40c04b745b6ff54f55bf4c36e4d3c2b2f5795b007626af0d239cef3b5dc37f56d5369c65a44a89876220409d8163c65ec7350ee0c83d5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "e0524b8439321323f30a6cc3e0caddc0f78916abe4efee3b68b34d65ae2a0342", + "pubkey": "6a46c3727488610e43abc8e5b9b1fab8562cc6de407b04a52dcdb1ba2d2ff7af", + "created_at": 1640826167, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Yoyoyoyoyoyoto\"}", + "sig": "4c7705399255578820290359c21bd0d73fbd4d10135790720612a4847731be0f687684e163a9d27351548482c17b87696492efda545c05d7867422923f3feaf8" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d90f178701ffe55ce9ca9f00e9c321a12159e33403689642f5d5a07ac642625e", + "pubkey": "04028120ae2cce40a56cb0b122a5a7f1f2b7dfd7c7125679482c44450ddbcad7", + "created_at": 1640827332, + "kind": 0, + "tags": [], + "content": "{\"name\":\"cryptaxe\",\"picture\":\"https://pbs.twimg.com/profile_images/982363718146146304/s1V0E_zy_400x400.jpg\"}", + "sig": "01ac231acf4513dd4d8d203044c89bba78e746d2d770f79f3022790607e856687577b2bb98c98a4adacfcf42fa5c8954d2249608e24f6bfcd3615a1d549b5ee2" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "fa9fed7cb9764fef3370b7fc37f7d2f6a957c6faf970fc3cf8a9cf7a569c3742", + "pubkey": "35f931e6f718699f35f4c86b6ce8c356c2b8c72bd1de32841c9ef97d49e46da9", + "created_at": 1640827514, + "kind": 0, + "tags": [], + "content": "{\"name\":\"rafaelpac\"}", + "sig": "d4644349bfa137ad4359c09db7bef366ea14ed6084c61100e080c16c7747ed3e8119956081676977bdcb281480b88dd5f54458b0207038e0cc841f725509fd3e" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "017c7531e74e6dbb8a896805cd49868af6175eeac05e041edabbbefbaa5ca874", + "pubkey": "ea42658e9a1291a32d1b74793edaef3d8757589a32b16931cacd85ba5470ea7c", + "created_at": 1640829472, + "kind": 0, + "tags": [], + "content": "{\"name\":\"asoltys\",\"picture\":\"https://adamsoltys.com/adam.jpg\",\"about\":\"coinos.io\"}", + "sig": "e2a8118fa840577ac36559ff592f53630e69ae8cc72f36465b8e3be7addfddb8605b041cf438810b5d9408be2283c4b6aad688689eba08fb8d9685b9d1607e6b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "1b137fdffe5906005a924a14136fd984c738524a82704f684a6cae94afff8bee", + "pubkey": "60f0eca610f3eb3d07488cf4b85c7117f44a534c7e9a1220d41d86610e2edc34", + "created_at": 1640829940, + "kind": 0, + "tags": [], + "content": "{\"name\":\"djbooth007\",\"picture\":\"https://djbooth007.com/img/fry-twitter.jpg\"}", + "sig": "fc547437099dd8fd7d2c32d73d6ad987924c3555ecd6e13b568e0f3e9d53cb759303dbe5b81a56a1a40c45316373ade7333861d3dc65071f2972926abddae2fe" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c0882a6634a894c932163e346e21ca38d032b31643feb86f9d08ffa5ea02deac", + "pubkey": "e91350b8cbd964c61acdb0ee863d1545c8a950d0c3db08dc9fe5c87aaa9464b1", + "created_at": 1640830845, + "kind": 0, + "tags": [], + "content": "{\"name\":\"BTCBoromir\"}", + "sig": "6e9e7ce1a698b776315e319db6b23dacf57c70e20ddf7395ba753c1c5b3ddfa078f3be8ab95f50cea4a9c864accff4956db9a5a28598e66f56492d4afd0d8113" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f8f1ecccc200fc4bb50fb9a84521f5354eeedce16455b81ff5f4d2831b672d33", + "pubkey": "e80cdf19a8ff173f52af67d8b1a5982b67f7a05987965b05e52b666f0afc4399", + "created_at": 1640830937, + "kind": 0, + "tags": [], + "content": "{\"name\":\"satoshi\"}", + "sig": "e672692c1d08f202cf0babad1fcaf316bbd561f5be6ceee1060d97c820d2d253b29481659ffc781f481991bb62baf0848be580949c7aef0b86f4620420568f04" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "aedb2d976b5539d2b59e29d05177d414a08abcb3d4e5220078a382cbb13d85ac", + "pubkey": "feb3f8253fb008ec772fdfc8f5addbb67b164faf4534e0e1a4a0c088712bc1c8", + "created_at": 1640831782, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Moshe\",\"picture\":\"https://taylormarshall.com/wp-content/uploads/2013/08/horns-of-Moses.jpg\"}", + "sig": "d4dcdfdf55c60898b13046486faeb793871329248cd03becb7774b9a2619a6db451d285c1f4891e5399d93c5b3808f2016d34c5c7318ebc97ab083ca065e8eb5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "cd53af43dc7ead20c23efad3c7c0d1a937037d1d32c873f3993daa92911d1869", + "pubkey": "944cb4bc9d3c71a3d5b68b6eb55d58eb4eff12c503fb0ec331c1c1404feb1721", + "created_at": 1640831812, + "kind": 0, + "tags": [], + "content": "{\"name\":\"HOLUWHACARYORDEY\"}", + "sig": "1a074a2500263878d6c1ea42dc4e0ceb06f3f26d29e015ca88f74686ebc480dd99f277fca3e8af73daf1cb3cb5ab1e4572a4217b87053a1cf25cbac634574c8f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "deaf3251aea7ddce5dc3ee80c6a918397aa1c2464f7f5fcfd17c88d6097f6c22", + "pubkey": "768ccaea05b46ca7fd7d4fcc6eb763f10c104df6a57bb80575edb97ce3a3b592", + "created_at": 1640832197, + "kind": 0, + "tags": [], + "content": "{\"name\":\"test\",\"picture\":\"https://test.com/test.png\",\"about\":\"test\"}", + "sig": "90f21eb732d3a8a3f9c26d68f9bef923edaa657a5cd887580050b33a2d6ad620dbff1ef425044ddc984b973500738955e1520f5c2e2d1376ff93ddad636d0520" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5c8ec5ce40ac29c4fc94bc49b510ad2d7760767751279bae9fc5033bf5c6262e", + "pubkey": "378ac275f194892fe922fabc68c125df250ae996bc25232998a38012555edc7d", + "created_at": 1640832473, + "kind": 0, + "tags": [], + "content": "{\"name\":\"AJ Motto\"}", + "sig": "43498e08908fe02227edd971fbebb44ed913c300670b4a1b9f987284a1c8ac390b2bee6910be3a645c9553475ac64d863bce4b2cb464e6206d74edad35ac0c8a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "297f33562c673a3e27b04b26ff620c22640689814a00403b5bedfb03d39dc560", + "pubkey": "5665a37f248e0290538c883d0fe9c94770ddfd723e04bb29e7f51f09bcfe83d2", + "created_at": 1640832862, + "kind": 0, + "tags": [], + "content": "{\"name\":\"taata\",\"about\":\"hey hey heyy\"}", + "sig": "c4075c821e02d0f5304d7d452db54ed39313de47b4da3d5c7fda3052f073dd0ac6d54e04db8f6a52a030829bd2a7a852d81e7c32b98f16c43ead5acb3a9c19fd" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0a27643b0b1eb31616ca4f53a9fb59320265b0066559c38240cf200c61292619", + "pubkey": "5b616d1b986787d7f847db067bad06abee0d56024dfad8fb69d10d40141b8cef", + "created_at": 1640834527, + "kind": 0, + "tags": [], + "content": "{\"name\":\"mac\"}", + "sig": "858cecda4cadb5790990d27ea80984f01c0c11fc55e3a7a6dfb112378619d971221f4d827981686cab39e9672b6f37b1a80aec3be8e488a55e787df56ddb2208" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "db15de87d968caf46e879fb07de1606e57302f989d0217a6c28b126d3c6d300a", + "pubkey": "f3c36ddfdb8dcfccc0cb44e808ad6324d337764eb930ddd395e6cff9866b4d60", + "created_at": 1640835914, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Ddsd\"}", + "sig": "2b9d5d0044c201626dcc9943e95c6daa5b9961093dc8e58e70f2c0b11e5dd4ab3b896e96113a97bfa2bafff54ef9b1eb999441ac19bb8d02a1489e57d292bb99" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "e8de1d660e9c60f77834facc3a16f5551a52e222a70dacd7a39df0ed5847c149", + "pubkey": "74cecab9c1792fbbcf621cd9374daaadfa5fb1312974740f2d1ae0f04990cb9b", + "created_at": 1640836323, + "kind": 0, + "tags": [], + "content": "{\"name\":\"test-prof-tb-zk\"}", + "sig": "deba5135831bc6d8a4c8baffee18b69caef861f30543c4857e519b55a646003ccfd9f28119a3b38236c6d561a24146a415f10e633b4d78b11a5dac234cb51e92" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2626915900520a72abc056742fa98776f417a62ac0115cc1c38e6c2d301c71e7", + "pubkey": "445753a57ff2fcbf76f714bde1657d72027ce0a739e72faab1bcccdea81a7e21", + "created_at": 1640836885, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Move2vietnam\",\"about\":\"Move2vietnam\"}", + "sig": "f54fbab186d170f2a8fb984d41a940722a99f40a0c1588d298b531b5cb781a6e4044261ba75355b319835669bcb2367ec9f854ebda59da90fefc5c92a6f9511f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "de71f8549b1fcf7cb0902cc6d22a2a74bcc9fe050b7c48cd6e42980072b42a3f", + "pubkey": "ddb4158ebc0b1de846946aa0d77296ebe6df220c88f6bf7c537a67366c8be4f5", + "created_at": 1640838626, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Bitadelic\",\"picture\":\"https://pbs.twimg.com/profile_images/1462955547414745089/QduEoHgS_400x400.jpg\",\"about\":\"#bitcoin\"}", + "sig": "dce9da2929069a90004b9464c35d217c03fcca88f83656de38f8b4dd782d396263929b4ec967bcc17011c1c33af086165bc3d3dca89c1433765236208accae0c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "90e2fad168b7907ddee021bf139cea0b5bf91751a830c3515a29cdbbb738bfad", + "pubkey": "96581c7e09acb6e3cc73ee6a77b9a60a06a372bcacd6cdb5c9d462906b3f4020", + "created_at": 1640845196, + "kind": 0, + "tags": [], + "content": "{\"name\":\"ethan\",\"about\":\"nah\"}", + "sig": "19334916f6f8a3c8fe6064c7135eb84c7f86efc1ef70c280f70994200e9992a9b6d525d95c993f35d0acd75fbc1f27c2e257c9ed9ca82ae3608ac8558e3d376d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "30e0862781df8614b9abb9431aa5bcdb9f43f01c3484d934fe1ae356dcb7b870", + "pubkey": "81da05a4223128d10e081e1334b7052e8b323e9a41cd8bc1901723b78967a0bd", + "created_at": 1640845259, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Fuckadabutt\",\"picture\":\"https://thumbs.dreamstime.com/z/black-referee-calling-offensive-foul-indicating-call-34943385.jpg\",\"about\":\"Hitler is innocent\"}", + "sig": "ebc007d25dc3eca551037e9daba3da36379870f08a71cd88877f47b35cd12b3f9f8132ff77a741961f7c35ceb8a29ca8b4d7eb1685e5a088933d5ad70003967d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a1e5db1270a3f37422eb6838ddc524852f65335ea3b8e4dd1229c97b0db0a14f", + "pubkey": "1246df7c9f342f1e56090cc0e6c42adcbda86f0a12bc37df46c281ecd9e70b4e", + "created_at": 1640845353, + "kind": 0, + "tags": [], + "content": "{\"name\":\"BdG\",\"picture\":\"https://bitcoinbrabant.com/wp-content/uploads/2021/09/BB-PNG@2x.png\",\"about\":\"Bitcoin Engineer\"}", + "sig": "c139781216e79e6a4471c0d388ad82d785c2d6b54354de4341fe7a71a2e027a468e7fb5e2d97936014a9d0d0f8f311a7549a9dd86bb863301e64a19dbb74cb17" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "52a3db93b37e91e4bab501997d0f3ce7ddadec1f9f98470918667cbacb34514b", + "pubkey": "f81384bbb30c7180420292607f9138d815db14d0d27819bc4cbb2d98f9962779", + "created_at": 1640845578, + "kind": 0, + "tags": [], + "content": "{\"name\":\"BdG\",\"picture\":\"https://bitcoinbrabant.com/wp-content/uploads/2021/09/BB-PNG@2x.png\",\"about\":\"Bitcoin Engineer\"}", + "sig": "c6ad501f5fe3d1bf3b774af04bfdc011613c8233ec3d661011da2e67a3c0f3ae19f57cea566aa23ecba0566c7857e07f3b186e7d33a949a0176915806de79bdb" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2870648b65f69d01d1dc6186576d3d51771fcb0290b1edf188390446077d50f1", + "pubkey": "dde137e543cdc0b73edafcda4c95dabdccd6f5d59fa871ae84df41df330d9154", + "created_at": 1640845654, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Jacco\",\"about\":\"Jacco \"}", + "sig": "845002cb53aaa094e424fe4d39c4e558e94f85fb32c9ee22c13dab6bf4a7736027234a877ea0e50a963ba73ad366944bd64312f7ba1d091b38ad3d06275b3f78" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4bc8f353e17052ef286cd7517e06d0a15b8d2de43e27a72fd60c1f156efaf5ed", + "pubkey": "6652d2ef6b9a349cef06691a72786603c762da2dfd4bc7294a07ec6f167b6955", + "created_at": 1640849839, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Poop\",\"about\":\"This is a test profile\"}", + "sig": "225c9887ddba0daba16993cd97f1630a273dfadfb2ff0701ce6fff6431a6503683da5480f63cf94900f8d141ca92d32caa663965239330e48269a95b596f96ab" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b53e5250322af1328b2fc6e44c8ab8949a6b399b23dc7c54f41bb54c1c5dc5b4", + "pubkey": "d41db72c6d75aa576d0684c0fd3e17d99e04c23c66d569b685cf8fe13ee9f24f", + "created_at": 1640853902, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Bram\",\"about\":\"Student of future\"}", + "sig": "4c20df504594f7ffa95d0c38d9b04df6850796483ed8a2753b4ecdc1a2c2aa16d0a77738ca65819c91b269a892d83467a88cafbe7e999c4afd3e1b8691a47de5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7f34529913cd7f03c034881c64c52064957c1793135653c2ce50b9fa621f196d", + "pubkey": "9082642585decd297e633fcc3a462eb76ce9a805793babd4aa0997fdeea45308", + "created_at": 1640854971, + "kind": 0, + "tags": [], + "content": "{\"name\":\"simonhodl\",\"about\":\"Pleb testing\"}", + "sig": "a6eab04dce3e18913de4a0f1646da4536b4596d342a53d7a0014aff2260aa51751fea165bf732bc65cc5828e800c888cecef0d049a27fac061e64039b13ac3fa" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "df15a6bef092e02076cdedd3148c7882028af03236e80b44be024131c808bde2", + "pubkey": "9742d4fe014ad75a11d34f121077780515eea133be9c7fc4f4e17b0d8b723440", + "created_at": 1640855871, + "kind": 0, + "tags": [], + "content": "{\"name\":\"\"}", + "sig": "68b3c045576bd0a8904faddc196e043719b8b89c56879021319a82a80f74cedcedc200b36f794a8a7e8c498d4a397ad5d4644dfa7ccbfca246146ea58e620358" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9fed9a193c933fcb8ba7d1fb649836154606f1b844596de4e66a192157881a1e", + "pubkey": "4149bd2ca7c08ab6321dc1f54176c78acd295daa2030dca04e8010ad992e714d", + "created_at": 1640856207, + "kind": 0, + "tags": [], + "content": "{\"name\":\"kalle\"}", + "sig": "59bf3bf5a62f5c4c851302f4ab46ed08380d861f06a70b1e44744c8f909041995915758b4cadee424b6e1959bf7645eb0d0a84d431481a3e2990659675754f98" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "e6ea8f2d00fd502bd6f40fd6bc5186f6b4b32c569758f4642309eb983e83b356", + "pubkey": "0c9fe407d2ffee6cb1a8a7b9166845532c07264001425d105524226e3bd54653", + "created_at": 1640856404, + "kind": 0, + "tags": [], + "content": "{\"name\":\"gjirr\"}", + "sig": "a7af49ab45b7f9d69f28925983842d43301d8d1f70d76d8f1b929c48a10646f3de1e0f14c2c4e5b019feaaf6483ddceccece30aabadbfff1933d79de1a156782" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "59efa7103564277f306986b9486bb4869be75f59055e5df40d997aa6add27020", + "pubkey": "0d28171ebaa769f2ab2fa0f813f508c03c3146ad08c6c085f726ecaae3e6bfab", + "created_at": 1640856493, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Dick\",\"about\":\"Dick\"}", + "sig": "c4aa933d92f2477290a41cfcf56016e6a7b358e8b7d3d7f0b9de546874772d950c345fcd07e008e86750121a60a9e1b56cd00d8a79db6ecac40e8b72e0aa0a07" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "83a53cddc68d3f0753c1b97f5689ebb00da6230f65fdd4a30c76c80ef9022100", + "pubkey": "2c5aaeee51ce9af671a52900c51d77f51a1bb8c1c57bc62a039fd77c0d654b51", + "created_at": 1640857120, + "kind": 0, + "tags": [], + "content": "{\"name\":\"begovoy\",\"picture\":\"https://pbs.twimg.com/profile_images/1446049166829006848/_HtByO9N_400x400.jpg\"}", + "sig": "89c95f8dd75c06db3ba54bcf97a35004765c452f52f711701a7ec266b04469dd2da793271dfbcebbc162a699af119322e6767ff2cc9ee4a90aac0251f64eed1c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d022a2beb63bffb394251c747e7728d538cda3806965f6ae7c5ba76ce341286c", + "pubkey": "8efeffee93d5866698c2e18bacda9d637abb4d06b38ac6610d6a79cd68f9788a", + "created_at": 1640857146, + "kind": 0, + "tags": [], + "content": "{\"name\":\"livestradamus\",\"picture\":\"https://cdn.masto.host/bitcoinhackers/accounts/avatars/000/000/644/original/71065c5a07bc0e95.png\",\"about\":\"Your friendly neighborhood anti state anti authoritarian bitcoiner\"}", + "sig": "01bf85905d99e6ed21e4c93b1f78493c4b50c4bd91e9cfd5366bb7f23e81e5f334f0fd153ec9e83433a041f9d42b8ad34f9fe9469974092ecc44874c4dd97a6d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4af72eacd32a04fe42cb48ae3cb940dd2fbaa46ec10c05d5d9aa674793ee523b", + "pubkey": "d42382e52de3a512d83e0333902588620264b56f6a66019950142a92e48d5bbb", + "created_at": 1640860717, + "kind": 0, + "tags": [], + "content": "{\"name\":\"1164\",\"about\":\"\"}", + "sig": "3edabbe80a55173b002b882f7b776c9d3e4c384e28693284fa95353f70b613ee687f7c13871f14ca3a4cdc7d67746dde97ae5c406712599ccb7d917c9f56289c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "ba15dffbeef1b18dfd854959834556180e45b4d8ed5bd292e36e55717934aff5", + "pubkey": "732da82d156fa436db5e6899d64981eac2a3851b6fc645253aaca511c41524e8", + "created_at": 1640861043, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Marnix Bruin\",\"picture\":\"https://user-images.githubusercontent.com/32618554/48676229-94c90f00-eb6c-11e8-9fda-4bf3b9076a25.png\"}", + "sig": "36063f6e6bc06f1382558d45b46520ff53330802b6fc849a86c42bcaed6a2fe2365fed838465b16941e2aba6d5285732715d3ee9905bfe46c765646f5524df3e" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d7e45b0a4b7865d9e288e6e9eef02616fac8e58027fc5b57de178d93dc8180ed", + "pubkey": "bb5726a868c68b9d787a361ad54216ce53fde04945e3e6c791c2d741fa3f528b", + "created_at": 1640861511, + "kind": 0, + "tags": [], + "content": "{\"name\":\"krebbinology\",\"picture\":\"https://imgur.com/a/NEd2cCg\",\"about\":\"Temporarily shelved\"}", + "sig": "6b47287eb0bd570e5e3e8285b6e1ea747470f4d3a59685ccd9779e1ce1d5955727e4659f91540076e729855d7b026c81b2269f00866b2fa690b640e0577b1973" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "001867cc64c5273f83878afa44749e3678459f8878399791df2a9c24719578f1", + "pubkey": "c5f458f108f97b20ec2d3db6b869b3d9b574b2600178ca6c72756ba09a38d22e", + "created_at": 1640861567, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Doe97\",\"about\":\"The moon\"}", + "sig": "f8d8ff48c0ba8a8cd5fd628638763db7db018a3e26f5731efcf044c6c7a97c8884a3cae0cffd0e7872a8474f2d217a5cc261fc8bd5181027609ab6d0eecf3096" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "49e723fb256e8b0fb2bd004116b98e1f40787b84eb4e9b05f4a94318e93b9999", + "pubkey": "242251fe8c6cecd3c03d721269a399ca894ef1aa5c3c8a6cd331534da0e64b7b", + "created_at": 1640861859, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Doe\",\"about\":\"S not\"}", + "sig": "7bdd7e7c82df06f0cd0c1768dac660f9bfbe5b4ceb29432436f0a844c4e370da41f18a51bb3ed8d47af72e34cd8cc9a1b9e231f615534130217f70a3db83aff9" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "61e7c124309be69db5a0973228b375a849662a8f9f3b06207c5bff31479e4ad8", + "pubkey": "d4866c0cfeab34b21497e49c6825cd72c156214c307e26cb92a36f58e48030bf", + "created_at": 1640862998, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Anthony.Win \",\"picture\":\"https://twitter.com/An7hony_Win/photo\"}", + "sig": "a4487a8cc9bb71d71d27c49407ad1bebb67be4ba0d8783a78de3fbe2e7f4693f974efa5cea295d2eab1a99fe62cb2e61bf01817e4f1889843070776e26b47069" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "18fc65fd789e8a9a54c645eb8bfc7e567ed4e257d498d02b2573a5786863bb05", + "pubkey": "a545d7f8e96b1241f4f4b098193c41bfa785a23dc3cc7887718b767f3a1cc307", + "created_at": 1640864274, + "kind": 0, + "tags": [], + "content": "{\"name\":\"55555\",\"about\":\"6655656\"}", + "sig": "25f97f402c4fe067d9f38cb9f9eab54cff50d7be4f661ebf1a4c38bd62970deed6de296c195c4f59db55975eb82b98dfe46f044e80f84179842a9ad2023a6248" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "91e74e90c41692a2d018b57ad474ff754e5be86e8b8de65a650c6973ac6c30ea", + "pubkey": "f00a7653a4f282dd62d0261acefd4c8bff242c11e226f9dfa72a6f335cdccf50", + "created_at": 1640864739, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Datapimp\"}", + "sig": "1302743401a25758a0f60075e2a6849ebbdf42616ec92492cf3c9576c762e69dddfece53182436d1281c4443c0e84b82a35485a923333241549c1d583c4ef896" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "8c7a06872eff260f90e301327bf7a640e35cc3feadcedb0c1361652980c4a82d", + "pubkey": "4e8a6e5ea0915df144a18f73343cb44a445b97603351e5f1a61eb84bb791b3d1", + "created_at": 1640865881, + "kind": 0, + "tags": [], + "content": "{\"name\":\"btc-bill\",\"picture\":\"https://pbs.twimg.com/profile_images/1467208960751058954/4EoJZv2I_400x400.jpg\",\"about\":\"#Bitcoin Only.\\nAlways stacking sats!\\nrunning a #Bitcoin full node with @getumbrel ☂️⚡️\\ntippin.me/@btc_bill \"}", + "sig": "002194e2067b91b886082508705195ab6cf8ddc820c2cdb3ed89b9535f2960a2395bfd5cf0576f03e3dcb1040678c0eb76ad8cc272d41ab212a1a6f7dc0326e7" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "267154927eb206a58b03e7c48e1156eaaa82f6a25073fbb337b38248f218bf37", + "pubkey": "e8b6c5832a39ed7635fb981cca73c4ac0bea136c5e3e73fa59d8abb989e499e2", + "created_at": 1640867927, + "kind": 0, + "tags": [], + "content": "{\"name\":\"domegabri\"}", + "sig": "3512b306e23feb149dcaac651a6290baa5c9f73e75981331aded8efd7ddeaed365c360cbb0f056182792139e143668fe1d85b348cbbdbaeec31dd6f1a58fc728" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "34058be1e4065a22c9b9ffa41d2934e73d8f26e2751858479a6941004b7eb521", + "pubkey": "7cd37e586c1de2ac1c033d57f11d1cc9dec06008c674d2db0c854b3afd2152b4", + "created_at": 1640868237, + "kind": 0, + "tags": [], + "content": "{\"name\":\"IsThisRealLife2140\"}", + "sig": "c9b64e88e31626e66d7b081e9f0d83beaa2457b3e73d1bf28253e89813c73887e19813214baf035115c1090c9fe0f0d4da1d6d16e1dec1f1d10896d8b8c9c8dd" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c7aa2436785bf3e42daad008d796b04e268778bdc34248e245a388482433fec2", + "pubkey": "49c2cb4d617edbe5c50ad7f29b57d1c66963294a50221ec971425ffd69518f92", + "created_at": 1640868337, + "kind": 0, + "tags": [], + "content": "{\"name\":\"domegabri\"}", + "sig": "c3c0122822da259e558bc778cca0fd63b0b95c210dd1e4e329f9049d1777dc04a7fb0d32f911cb3ce80c87f550ab34c921cbf5ea88f278185f7b3c4cc1d1b2f8" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "cfdf4e6c4ffe85392f8065b5141b283e61a246623b4bc1e742e034491aa8da26", + "pubkey": "e0ba6240f368f87902e3bb5569eab9c4302232838b53b93fcf511ee4f85ff33c", + "created_at": 1640868440, + "kind": 0, + "tags": [], + "content": "{\"name\":\"zaker\"}", + "sig": "f4eccba4ea14745de1f57eca5bae2cf105275fc96a0d7918b3e4f728e99b3b074d7eca439a4b3c9d9eda2811580e073b0df1bc8ee743a4872b9e7c37e4a575fe" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "474880016909fb5554e1701a7828f62ec93ca7d45949732c00425808cdbec6f3", + "pubkey": "8e7c9844b0e8a1f40409263d68bb6647578197ae6fadc759b1b89fc35f99056c", + "created_at": 1640868531, + "kind": 0, + "tags": [], + "content": "{\"name\":\"zaker\"}", + "sig": "0dc2fa5ae298edd2235586e18f32d8d8e2b3c6b6b32fc2ffe7aa6f3a95d5a457089bab9c3a57d42e1e898eaa1a5ffe9a1b5544f5ea825cffa814fac91d911b0a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "1bad302f6543574067068b6c6b6a6adb7d2cd1d921f9cec228a81901b7d16c91", + "pubkey": "2aa7bbd612d88cf807d7f57f7536ccd3776b3fb6119d00821f78c4823f7552d5", + "created_at": 1640868560, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Forever21\"}", + "sig": "67c69d61ec3621d4f09ad1b4bc670af175161ea82d0457b7ce75dbb0884b612892bf244d3df2aae54a2dde396beec04aa5436cff3d8166ddd2b26f842bc5c871" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c9d817d06eb4474403066e6e0d4ff34149d1a169d786aebf1fbdcbccf7605736", + "pubkey": "ee42e08ee1a025465ca22e999388b34126c195ec6fc32aa39513eaf43ac9f6f2", + "created_at": 1640868743, + "kind": 0, + "tags": [], + "content": "{\"name\":\"saibato\"}", + "sig": "de5e88e71c88a389268be759a897b94c2790b2bc4f48ebebaf796cb3c5547883e55ba16359d45313d1225dcf32134205666a174b87b32af1d6956e8c56418ff5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9a0d2a2bac9b60c5c1fea21970972d05897428fe7eece644ea683d90d1307fbf", + "pubkey": "b8381227e29637d5e8e4a7e674b09f0d345aecd38851b09b9e032af07f55d2a8", + "created_at": 1640869071, + "kind": 0, + "tags": [], + "content": "{\"name\":\"giacomozucco\",\"picture\":\"https://giacomozucco.com/wp-content/uploads/2020/06/unnamed.jpg\"}", + "sig": "7e8804c1a44be75bd7964fb7c29fd8495fc1bf0c7b7e3263b4fdf286f2d63451310cc4f83693135dfe958984c124134caef53883b0b0de4ce781a61c81a7c39b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "833404cf195dc5d55e8ac649af6afedaa08868810e26a964e310765b4b4b3301", + "pubkey": "de7defbf5f1e43cc434c77c5a2c0b41d428a286d987b8ed48fb238067f6dcbf9", + "created_at": 1640869207, + "kind": 0, + "tags": [], + "content": "{\"name\":\"drunkoshi\",\"picture\":\"https://pbs.twimg.com/profile_images/1153367483983810560/VORX49Ml_400x400.jpg\",\"about\":\"like satoshi but drunk\"}", + "sig": "be416369f451767df4a31cf8d2df8611e3148182e161cb49e9fc1b062022aba13d9055de17693779ad80dc3508cc49929ec1100f0e3f38f6350b4d1bd6e31f37" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "46d5770f77e4116910565a3639464e218148aea5442b37dc85516087d92eb3db", + "pubkey": "f6df09fa0548718044bb55cf6e7aceb64188fb2ace1c4a3709db74b3ee148eaf", + "created_at": 1640870451, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Booba\",\"about\":\"I'm a booba and I like other boobas\"}", + "sig": "ce79420a7aaaa45f740ae1c34763ed6a25126bb8d1806d2bed6368b3f87cfd2bbaf7718512a1c1d7d5e933727e317388a120498424499d820b813c9a200f9046" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "8f370e338ffce1fa34e59d322ac1f54b9697219bf56123544f044417f691662c", + "pubkey": "29d95000f054626e898b99261279a8ad758813aadf7a2e91f5b6341f29bbe791", + "created_at": 1640871064, + "kind": 0, + "tags": [], + "content": "{\"name\":\"ross\"}", + "sig": "e59130ef273e3e2c0d897e0237f646c8bb87b22067a4b4ee182dae9325c6a8174fd9689316f248656156abdbc854bc135f7c95c60a45b99e7997bb2f15f336cb" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5f1e770631e90f9b3eb7c946ceb41a6b1a79cfff0810005b61865db82be3a8b3", + "pubkey": "6efa94c93e2be0c212d9f066ac80cb36782c62190e0ed30116b2dfcc0dfda491", + "created_at": 1640871260, + "kind": 0, + "tags": [], + "content": "{\"name\":\"pashka\",\"picture\":\"https://i.ibb.co/DCGcqyg/photo-2020-12-04-13-47-07.jpg\"}", + "sig": "fbba4e06a084b0ff29838a879136b50b6eab129230c8d94234e12aa141c78b8d11c91d9cd424b2304ea7e465811c2fcaffbe170876479d80bc06a9ccf64369ab" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "3492d4581784a5d96ebf28ebe4e32e6e79597445d14b37258d6c9c6c4cfef76a", + "pubkey": "eb913c9cb6b998eff7ffd7dada1c9b143b346757b7d69eaec5e4bdbc734faf7b", + "created_at": 1640872291, + "kind": 0, + "tags": [], + "content": "{\"name\":\"halobend\"}", + "sig": "eeed33cbaf8bde69dd36a04bf8522c6665250b1ed98a8396026f74dee5954029340cce67092b9d4547574d853f7379217d993f81424cbcd19507090a79be8cdc" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "67a0d09f6a21c8077a60777c5352f1f512e32aef170202515561eddeb1af24b6", + "pubkey": "5c6629022ed3a6406aa614f9148d4bd354dc436b7f85bc4081ee7c2d8d9bb7f6", + "created_at": 1640872349, + "kind": 0, + "tags": [], + "content": "{\"name\":\"elcoro\",\"picture\":\"https://accountmanagement.gettyimages.com/Account/ProfileImage/IS2600203710.jpg\",\"about\":\"Hey it's me Wario! \"}", + "sig": "8f02af165c1b4302bee0c252997429ec5ddbdca0d61f72b734394412d6f0e9ac347e6ef9782d35710e817aad45b75bc426589d59b2a00506d4e24395677faacb" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2f413dc794070f6de309813d36b13b6a432212c67b5828319763db6aeb63b017", + "pubkey": "d290edd30e6dfaf2448b3ebfc45f749499f747b8b74eafa833488150a30b42b2", + "created_at": 1640872799, + "kind": 0, + "tags": [], + "content": "{\"name\":\"nigger\",\"about\":\"hey!\"}", + "sig": "38445d9c7dae1cf4e5793e1cc14bcc1607097f3f2b6e38a45e692c6853db26b2abcc66a90b03188033899ba74d07008b469bf9a31cd2fa15e3a0aa63a37d0df6" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4b3ad5a24b6ea88123582e1053fc4c84f3c751bb45064c93c0f7adbeaa6b6735", + "pubkey": "48799f4c7b65c44c48b87d77698803b3d4e6a822807dcfb532a006b4ffc9a92d", + "created_at": 1640873037, + "kind": 0, + "tags": [], + "content": "{\"name\":\"nigger\",\"about\":\"wazzup\"}", + "sig": "6a1dcfbcf2fe8fe19ddefd7abd80aae4acc25ed6a5eab40af021d962e4babc6720085f6d857ce803fa76127378c23591ae0262c8c6e8c66b34a8c2a45df8a75d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5a9320363cee5276bbc65e3cd510819e2cb2a60cce01a55db2157e46339022bf", + "pubkey": "3a4b86cb249d9324cbc67d09dc546ef62c737925683ac8d72fb47ed66249c31d", + "created_at": 1640873252, + "kind": 0, + "tags": [], + "content": "{\"name\":\"dreslan\",\"about\":\"Hobbyist everything\"}", + "sig": "12a1eaba726bd97617cae333c5edd2ea5fb1bcdb3b9c542888236ec9474ff1616bb41cf4b44de10e72a2043139fd07249a0e215bf194a74ac3519e349b3c6c3d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c7cd7660910b7906d76e4d56bbe4b81876a13d9976fa85396e1cd74a55f1f76e", + "pubkey": "fdc3d2aa2a62ceb75cba52fa779c4e8f4b1ce4b76eef980b6291d248f88e1c84", + "created_at": 1640873322, + "kind": 0, + "tags": [], + "content": "{\"name\":\"TestDun\"}", + "sig": "955e6b5e0608c91180dc6d8fb8d80b838d19997759a648b4ae321878e907a95a977eb42356a0bc326124048d3c6de7fc7e64959cf700ce9aac930e5eca0d5247" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c387a26b2bc5a8c0623219b5b570435ed8d2d96bddef42fb6715e91c9da8a4f4", + "pubkey": "ad5aab5be883a571ea37b231cd996d37522e77d0f121cedfd6787b91d848268e", + "created_at": 1640873679, + "kind": 0, + "tags": [], + "content": "{\"name\":\"jeff\",\"about\":\"Trying to decide if I want to dox myself. \"}", + "sig": "bad959e95ad8ea7ae7a77cff2ef26e04ed6d68f77bfdb5ac4d6894352557c2c2fe95935b712050a03ddb6c1b778eba7008696a3da5ed13671a2578b9b6799634" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c0e2051880d87c50df5148ee4a24e93d6eead519bea7280f4113c8c38d2474c5", + "pubkey": "e512d43a318d156d4ef939b3f50c420dcf15805b1d52ae8a35ce3c2cf819a4bf", + "created_at": 1640873743, + "kind": 0, + "tags": [], + "content": "{\"name\":\"alex\"}", + "sig": "582f02cce254e2dabd6f85c7bd5673e4a63c1081e13be93631b3fe37fa035e1cdc384d3e7b49aaec2f1b3f3623153b47c2078d6e137f1e3a6039eed9bd517c18" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "b66cfaebc18657db3521f54dca85f40a911b75338879174e4dbf1fa6d82aa4ed", + "pubkey": "715642983b3d01658ab6cd06ad8a497e6dbc6518c1f539cca922243dbc8f20e3", + "created_at": 1640873810, + "kind": 0, + "tags": [], + "content": "{\"name\":\"rukrukrukrukr4432\"}", + "sig": "a7ad971046c729ee7f3bf57bc16522a74796a6b6bc64ac3b47bf1d4e476e97df1a606511e2a17f4119f29ea9f40f5c896c7adea1abfde212b9f6c30ec55431e7" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a5e8e8ebbc29182bebbd31370be6aa4b8facee06b95fdedf104c1725a0197dc3", + "pubkey": "c30555ca8227460379d4662e4b4a8d619503de0c8dbd8722d91cea60c3e393bb", + "created_at": 1640873863, + "kind": 0, + "tags": [], + "content": "{\"name\":\"fed\"}", + "sig": "f64d8ab0acb51d8823e4e3902a8a03a9394c081386d5d06df8fcb39dbe41069e8b1d788c2d35224f4c0cca39e050c03a461af67182e69e95a3d9cd92654aa18e" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a28aa786591f7c13f259ea1768b9154d1f385320249b017ade195765ab11a991", + "pubkey": "23439bab5cf946de25adab1dc7bd4fe008d9793b62d937804a9938a02857bced", + "created_at": 1640874495, + "kind": 0, + "tags": [], + "content": "{\"name\":\"KayBeSee\",\"picture\":\"https://pbs.twimg.com/profile_images/1320807504700305408/Da45Hv8k_400x400.jpg\",\"about\":\"Testing this out\"}", + "sig": "64cf3346bbaf2bafbe64f36f045fbef88d4f6b81659157d7f78802e5804a72c26192b4bab062338de2dae25fa01c88454fbfc65615ff1202d770b80cf688d505" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "359f7b8ad739cb3daca68bbeb1da8c537dec8cb6706e6870b30c2a7750007806", + "pubkey": "cf59f57b495fb80c978410fac434648075e6776445cc0521fb1637d46b15ad35", + "created_at": 1640874890, + "kind": 0, + "tags": [], + "content": "{\"name\":\"enz\",\"about\":\"Ciao\"}", + "sig": "e268e54291ae3570dd98ab6ec939c48e605e45eb99aec9d440f15d1e77081511c4c4de1ab2829fbe8e60ad64388c74d86f4ea5b108cef6c6153599630f911963" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c1754a021521f2a2028f5d0fc773e9342695d0eb49a3ef9234f1cd81a317c673", + "pubkey": "d9c8c00017a2a345c2f32132436a26e1c72cb7a57e7b6b316f62dee2f8bcf8dd", + "created_at": 1640876052, + "kind": 0, + "tags": [], + "content": "{\"name\":\"kidehen\",\"about\":\"Founder & CEO, OpenLink Software \"}", + "sig": "8446817f5e63353f5a15c49ee628a4f3ea98a8072fd195c785623bfa72e797921458115cca209cfe852c878790e8640b5f51b8e66c5441f6a11250dd692a1c06" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "cf1bd537640b7acb9143a67a2e4954129f3809275cac6f188fa29da353ccfea7", + "pubkey": "eef51b196c4f8d3f9056a22775b17696948cf7a8ace968c8e6051fbbf9a14f1d", + "created_at": 1640876959, + "kind": 0, + "tags": [], + "content": "{\"name\":\"elkim\",\"picture\":\"https://pbs.twimg.com/profile_images/1394261060186689539/p7m_kt40_400x400.jpg\",\"about\":\"Hardware & mining specialist, custom overclocked gaming rigs and workstations as a service. Bitcoiner abovee all.\"}", + "sig": "edf20cb6bfe94dcdb71f673479c09fdcaabdb3407a6befb8c51e1d87696599a07575a338ba06fc24f9d8e6531287cbde5ddc764ec56aaf8dad478ce4147e774e" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "cb41289d08a5784a73b7bac2bf97620f5c927487d0c5a029ba81f840fe21fa36", + "pubkey": "2ffaffaede9277eeeae29a35bb249fb189b7d09b774f2deff464938db3511853", + "created_at": 1640877562, + "kind": 0, + "tags": [], + "content": "{\"name\":\"banterpanther_\",\"about\":\"Student of #Bitcoin & Lightning Network\"}", + "sig": "69a6f54ca8de9f2b9d7d88768be6ddd59d5b68a1da57559a187e0dc7f5da5e89209b797fc569b9a80c2e7821d3bf3061c5ae6d0ea27ff8cb8c1dd0a6dcb16fcd" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c82c176644544c1a412715584b568fd08703258c4c6cf09cc0d42f82168ee7c0", + "pubkey": "c2bb5d6529095edbfbdbe3f136175c146c6706526325b32da881c7c34c7b1ab8", + "created_at": 1640878156, + "kind": 0, + "tags": [], + "content": "{\"name\":\"MadisonBTC21M\",\"picture\":\"https://siasky.net/OABs5vvQ08PSySDSSIJgHgvXICgn7QJG7dAYKyfSjbeqUQ\",\"about\":\"Bitcoin maxi. Run your own bitcoin node easily on a Raspberry Pi 4 and an Umbrel\"}", + "sig": "07012dd680d7db52610dcf6146191b961e100d7aa87548e1a5ad1e1bb9a783581271e1c04abbda794889e3b3c110a0c105b0b3288ec17fe5e2dce97740880bb9" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "eb17cf354fe1544c0692e81dc2d6f2a0e13162db6780177a7a34a23a33c4da54", + "pubkey": "5d03a9c0bcd23ac466aae40fa91521f7bdeba4c2bf89425732c6397d7d257f4f", + "created_at": 1640878705, + "kind": 0, + "tags": [], + "content": "{}", + "sig": "652ff046e8a55dda2b286558add24a9c897e5d27f8c0a84f14e1f5c78c60f8c98ec7b5a7a868cae87d269db0d545cc06f09ca912ae884bb7909cd27675e26dde" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "05ce912795e16d4e70f9727d08cb357530aacb5c250e6c5309175c5eab944086", + "pubkey": "264b6a2c42b4532041c8eb611f2211bf7e3b344ce3035e0b3d807359e4880cd4", + "created_at": 1640879682, + "kind": 0, + "tags": [], + "content": "{\"name\":\"ss\",\"about\":\"ss\"}", + "sig": "f76d4458d5727f9bab541f4182c95ee0138aed855f29fc37fb6ed44f6d97a52dd7af59ae92004d6d4bac8b445a3efec43c56f235312b49b6685c255a7273826b" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7255fb8f56cb561db85160cfd63974998af9623a889c0f5cf0d31c7133de96d5", + "pubkey": "a1e838f2c17abf3a6890b73434c7c3620cdfeae706c5382254b898df762785fb", + "created_at": 1640880435, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Andrea Steffanoni\",\"about\":\"Mathematician. He loves participating in the Bitcoin revolution, as he lives the Bitcoin inner truth and perceives its deep moral roots\"}", + "sig": "5c36197abb5846fa19edc1a3361a4464b7ecb281e5ef98ff5fc05d6b2b50296dbebfa214c817741c031bb2740eaacf5b9d768108bdced0db65b93a91a0d36c72" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "7eed9d884b522d1dfdf347baaf41f46f195f4c8e94c8112db001341c921c1617", + "pubkey": "fe1f58058ba88af99d03d5a245286def1b39b529f206b9aef77c8a0261b0a3d3", + "created_at": 1640882044, + "kind": 0, + "tags": [], + "content": "{}", + "sig": "c3affc0576688d4fccb21e5d44901b986def9943952262f238d96d0f390f4c91a0056a17db4b91e458d7f04a1526e0771355af76b68a64fca232721c808f34b4" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "295e51fe6bbc8cf6ff220b04140a5e5e39a2e1da7aa2563e745f5be7595893b2", + "pubkey": "a12535e8bf4f712211b68f7fe7303d03c3c5cfe8155116d553fe6b8adba85d41", + "created_at": 1640884201, + "kind": 0, + "tags": [], + "content": "{\"name\":\"neno\",\"picture\":\"https://www.clipartkey.com/mpngs/m/208-2089425_king-of-fish-avatar-clip-arts-avatar-king.png\",\"about\":\"Free speech\"}", + "sig": "be25cc0f96fb2846a00ed1ddba1e6aaf3e4137f72bc7e5866f382c563f7d2e0289f911d5e68b9b1a07ceb84686bc509ff2afcb4667f9dab58abb689447cc1056" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "eb7ce65513f1efdcfd604f3f11f385062c99bc53c6b9addbde6ec36545718089", + "pubkey": "904ea00a4a245559d6184be5c6e2cf2c66ea7fc91eb5f1eb5349506d19d63a11", + "created_at": 1640885141, + "kind": 0, + "tags": [], + "content": "{\"name\":\"anm\",\"picture\":\"https://avatars.githubusercontent.com/u/265541\"}", + "sig": "f6f27b0759cc2f0848e4e0317a44dd736d3cd1168bbd91d9257a21499f2e639ab1696f45d5828377c08400f21764a3c9b7ef87dfb1d23d4ec15a728de69f8681" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "919e91a8db0b2f44e7478009c0087a3b776cce16b9a9466014fd4faf478dd7a0", + "pubkey": "2a308bcb53ae7c35e59c2d76eb11dcf93bc2106e02a4fffbd10bb817ea644d01", + "created_at": 1640886538, + "kind": 0, + "tags": [], + "content": "{\"name\":\"mersible\",\"picture\":\"https://siasky.net/fADPSYypZAN4P3eKEsVb4mzDokuP7xaqpIai7SkG2BevMQ\",\"about\":\"Bitcoin is the relentless, inviolable price signal that coordinates free human action for mutual benefit beyond reckoning.\\n\"}", + "sig": "46458ea9cf6760db3ba9e815c9033cd98ef5dd28c4805917d608ff9b647e098a4b055380a09f51d26bf68b45ec03665a2abaf1b2cba62ef2b2bf77773b57fd78" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "5fc2675e2564d2290470d1ad96fcb6c06e61391cb1d6e62d5d22696754b4186f", + "pubkey": "fb9edfa03d9571d8581fb28a6714ef979db95607a80b0edd75aa56dcf1681d96", + "created_at": 1640887007, + "kind": 0, + "tags": [], + "content": "{\"name\":\"retard\"}", + "sig": "a7c45002ec6d4c6b1f2fe2fa8c3b8b9161f7f3de0ab07883d2b2c5881b821bf7f836f7a64e6b3fedfbe1fc845a81ce1c2c877d4fda0d9122d9e7db13974aa3ab" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "71d62a9761cef95b4ae41bf0de95a9b45da9a07be6d572e16d66b7a09a2e99cf", + "pubkey": "2a80a638193299b36eca86edc3952e70555eef45c78d9349c63fd71344923f1e", + "created_at": 1640890697, + "kind": 0, + "tags": [], + "content": "{\"name\":\"john\",\"about\":\"Founder, Zaprite\"}", + "sig": "53a302b9668c23fa00f30247418a83f1687f587ec243f80a830bdcf96cce4bbd17964e55dd23c798c51bbfc170bab19f54972d3ca1a403bed56237e494398d2e" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "0cacb32a16b58daa25eedb4f68cca4b5622ddf0016d477692203c9e03c57397f", + "pubkey": "c494fa532e8adb690a817ea183133218cda78389639884686469960a6dc4956f", + "created_at": 1640891658, + "kind": 0, + "tags": [], + "content": "{\"name\":\"emekaokoye \",\"about\":\"Semantic AI\"}", + "sig": "bb9bc9f20f12ae8fe1608273bdcae56d5f5d6e1889378c6a3e5284d3a9a4c214205679d0b6d8fef590027248957fb4f7f199358fb30339938269515d90f3fcc5" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f1643afd10602755d65dfea4127e6a891f3ba23a79aef1e697e3b1d11fd0ae94", + "pubkey": "ed29e25895e584d043030e10888fbaa5d2078238297aef461cda7adaa819a116", + "created_at": 1640891942, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Gabridome\",\"about\":\"Bitcoin essentialist\"}", + "sig": "5ffd5f57361157a3a51e964b0802ddc4ddabbaff709f95986315837dda75149f29365cfc6eaa5d9cf82deef33cea133dd1a09459b3ecb9e0ed982dc8bd52a9e8" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d481bb1f93c40fbefe5e4bde1a0bd1672602b11bca6c60e85bb60ecb8ceb0d91", + "pubkey": "4b223f64f55bcf1e9a0c5e720cd72482cf55354902344b5babb76934da5995a0", + "created_at": 1640892378, + "kind": 0, + "tags": [], + "content": "{\"name\":\"qecez\"}", + "sig": "aa9625196c05a5c8cac718aba4372ab2f20ab39ba67f56b8df631196dff1de29ab0898c229fcc309c567305dada1502983e9323c2814001a0d3d71d4b9587993" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "baa8c7147f138a60392b8c4763f6be94a0945f5825c6a1db217e3f1cf96c0da6", + "pubkey": "05a3c1d2548027065c00d8bd50f3975b24e84ca6b78bcadb843caa1c113c5acc", + "created_at": 1640893651, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Isidro\",\"about\":\"\"}", + "sig": "0869540ac2d98d69a6d7232fce61125a5d0f385ad4d8373d758c80fe6f0a6a1ba88a0d51494809a1005f604cc6bb07c3067bf4e9909f4b7fbf6e8e9fd01c58cb" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "867ffecabda116f7615f3649bf481a876187f26e7e3442eca0b53ed6b9f48e1d", + "pubkey": "7e7272c475d920ad408e7a6faf9a123aa7b882cba7151e6105a0fc9d212fb240", + "created_at": 1640894128, + "kind": 0, + "tags": [], + "content": "{\"name\":\"kidehen\",\"picture\":\"\",\"about\":\"Founder & CEO, OpenLink Software\"}", + "sig": "e875dcaf5d8fcbb3f2d554081c97ae7303d0cb6c9a5afbb0255fd98adef16bfd975460e7687da13fefd004365b513916be63023a20ba9b0de45199395e432b21" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "da4619820e4d67c9fdc31102e148cbb19a210f3bcc8a911addc11fa3861be61f", + "pubkey": "1d643df20fb811b33c3cfb04c72db04c6ae031dbf68f613ba45407cbdd9446b6", + "created_at": 1640894466, + "kind": 0, + "tags": [], + "content": "{\"name\":\"supertestnet\"}", + "sig": "2b6bdad49957153e148316d94bff7791ce31337221819dadf7195c49516ef38d34994f47069039af61f4e0868b8346f9f8c4ec05ee559cbd8e1e81bfed30d5a1" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "9d1d2220b1a2f4c52de88725f77a4a23f46db5af8b2b8edfc1fe3888fcff4209", + "pubkey": "9933b9311ba25b126345cb1e23de02820701b3b0fd27380daea2cacbb88b955d", + "created_at": 1640895947, + "kind": 0, + "tags": [], + "content": "{\"name\":\"always_teste\",\"picture\":\"later\",\"about\":\"I make pranks IRL but not so much lately\"}", + "sig": "afaa160e5e8131e8dd3c344944dce41ee96bef7647798c76acec92736c0b20f8992e7a193162bdf936349a7f18954e075bc12e5a994dc54bb1c1c99074fc9124" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "00f685c264adfe0173e887ada5aa75afc29b9fd731992aa59f63c99d100098cd", + "pubkey": "b458f2c9b378a6a5fad9474f7646395052726664bb7ef6f036094d0ba2a04df9", + "created_at": 1640896705, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Iggy_Soda\",\"about\":\"lirning in publik\"}", + "sig": "bc25767786b523367371392acd32f8ae6011f92c906e522f598734fe49b94b8f45ae4f454fdd70095349b19dca039bd4dd02c69cb806919b699e439346796643" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "810442946619ee982b083fe9e8a00168fb42579d67f792d93df30a0d0e786992", + "pubkey": "3e0bdef18edd53d216b66a056422619dde11cee7c4d6d601fc15e1d608cc8073", + "created_at": 1640897260, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Tristram21\"}", + "sig": "290d0f753dd6bd9b0134549a59cf591003660450643b96dec8a6219cde83a6cc99ecff8e37af95ea583a93c88767a245648fc5428ad54bb7585c9d5d34c4a309" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "d2c59e6283c85c4f0ffbd3db869e6b3a76a58fc2c64ef60261098473703b95bf", + "pubkey": "e1cd5c62c66348e88c78b1a56c2be227ba8be8d83d1fc791b3e4a7f6c2303ffc", + "created_at": 1640898491, + "kind": 0, + "tags": [], + "content": "{\"name\":\"plantimals\",\"picture\":\"https://plantimals.org/img/avatar.png\"}", + "sig": "b326679171002794d0ea7e664b5f8fcb7e257df7d4779a9dcf81c6314d204c3229b45555f7d5b4b98bcfaa09707e938aa8764e77dd2052b25dbf295672eae6e8" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "2f748c5ed29ebe1ab45496d087624a944f7d9b90c909b7317fcf76aae741a1de", + "pubkey": "bb0c4cf44eb253f8c5f8c410befc3f612e1b05b6452beff6ef6b983e9c898c7e", + "created_at": 1640899451, + "kind": 0, + "tags": [], + "content": "{\"name\":\"rh2\"}", + "sig": "5d8a2c1446329609cc7e5c939e3c8d15234b1b5eece3b1700af05893f305daf69c880076c31d36abb69c7a2ba48df0fba2b569972dab467b8f3d501416f27284" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "1626d6db5abbeba7dc98f6121720e14ce3df27781ebfb9035f9957398f06dd01", + "pubkey": "d9a24887bace37aecc20405ce9cdbb9ab1cbccc4fcedc10d010f5289dffe1e47", + "created_at": 1640900149, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Meta\",\"about\":\"Meta\"}", + "sig": "565e5af5b460a83de756c37818ada22695cf9af783a2abcec2e16570f0c502dbbb99f1e082c01f79507d14025d204565aa0fc8c27946132ad3d896199216b9fa" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "22b4b9853ae95c3ebef15d624325ea4d00c41905c93e55c5b27728ba1331f380", + "pubkey": "ee1d5f4307cc8b43c6fb66ad592b9eb34e8d896a8937aabc1ff41ba9df9c79e8", + "created_at": 1640901546, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Flo\",\"about\":\"Hi\"}", + "sig": "eda1e8cdfecc8d6eb392dfea29e3bd1faef1ead925745403e426085e500d51d6cd04edce7661e23ae3a88ba1a4d27bf0a8ef06d81c8cf4e48da0cd84b0750372" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "38e2831a764d71d7a032b0933364bc8af488940cab48c9f99663e0e166c615be", + "pubkey": "dec38dcb7f9df66ead55754e22cc1c3e3c0669031c4be61c9a68fef45346d948", + "created_at": 1640902756, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Panda\",\"picture\":\"\"}", + "sig": "4c3ce4c11864b8e17e7131b5c78e64634526312166a2c1b288568d52de7a1ed3e31eea6a228a5bb359abdf98b6352cfbf212bfed23b830d109c355a9e30f2e5d" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "02668ef55e971a3804a9b48f848e1ec5fe3af33116a6a79d2250a097abdf114e", + "pubkey": "1f7dfb1b51bd4fb5d15245b28d86fab670a677580e2a0633a2cf76509d02471c", + "created_at": 1640903669, + "kind": 0, + "tags": [], + "content": "{\"name\":\"GlenACooper\",\"picture\":\"https://glencooper.com/qrcodes/20180417T1708Z-1GLENCo_firstbits_centered_bordered.png\",\"about\":\"Owner of 1GLENCo bitcoin firstbits.\\nDie-hard #bitcoin believer\\nhttps://glencooper.com\"}", + "sig": "d0f38300a83e3572400717d4a45265190a2b05ff3930b8dba4923a852d0416ddee99b3ba3a6331db5bc12df9c23316b4a5750467b95b25f56cf99adf96ef2efb" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "f73779fa55427682d15fcae40254a41655befcaa07dd192e346d362f53886979", + "pubkey": "bbce016b00173ab752831f0a3e23361652bd77dec6334dd5a98a9ebc6ec30b61", + "created_at": 1640908331, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Armando\",\"about\":\"Anarchist.\"}", + "sig": "bd83ad3042fea525f0442cb28e13a5352b43d8fef7d04a47e5cd93619154fa77f46db94b9c8a2fa1ef95e608aa017f94bf0009bf6c2810f5b0d887095cc5f75f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "a016d6ca56e761c8426cbde4f85ee0b3e8e4ffc5afd685f44908199771a31e4c", + "pubkey": "1eeb40ea577b4b150a3a2855575baf1b9b7c2fcfe72485c7f5c4a43d6c72bfb0", + "created_at": 1640909334, + "kind": 0, + "tags": [], + "content": "{\"picture\":\"https://images.pexels.com/photos/1382731/pexels-photo-1382731.jpeg?auto=compress&cs=tinysrgb&dpr=3&h=150&w=300\"}", + "sig": "af78793333fe07992cb95c1d8d505da82ce22afcceadf7cb4cc45227cdd129a47846243a0405e2778945efe995897cc04cfbf428969c3daed8d1539564e03484" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "4e452468fb83c777effd83db9ce11f863902c37e6b074f5125a5ec9e327faac9", + "pubkey": "52bee5eefd519a2703a5ccce32690555bd158ec30586e69dc0f7883848c9be14", + "created_at": 1640909707, + "kind": 0, + "tags": [], + "content": "{\"name\":\"BitcoinBram\",\"about\":\"Checking this shit out\"}", + "sig": "6fcabe2351abd92c1a804767966a18686eb168ef609ae96ac1275fc4ef3c6ab889bf1b1980702e23d6c0751f0a141c106cd3b90693c7c9918963c956bd8f7e0f" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "39245975168d202d90df4d2e78f4d63315be7ac501cae750814869f3e4a821dc", + "pubkey": "4fb37dc4a5ea2bbc924fc9c5b92c84eae5fe010b638655e48a83b373cc434988", + "created_at": 1640910127, + "kind": 0, + "tags": [], + "content": "{\"name\":\"mike\",\"picture\":\"https://pbs.twimg.com/profile_images/1463501914063179789/M_x3wXn5_400x400.jpg\",\"about\":\"yo\"}", + "sig": "625f49d7e2c3a7de0ecdc4b3a0f239efd916e7aaad9cbccb889846f33ad7f391d876aeac2fee021390e4f758cd2743e6646f41226b38d57e7a6b840e1a8475c7" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "cdb191721e0ee63e674b29f21408d26de5ac8119affd4334aca37d8901c64ef3", + "pubkey": "3602b0c96a0e9b17c82f13a6479fd30cba9640a0d7095559c0ef4c7afbdabaa9", + "created_at": 1640910381, + "kind": 0, + "tags": [], + "content": "{\"name\":\"iggysoda\",\"about\":\"lirning in publik\"}", + "sig": "d77277f0d221e0e015608af573fc6167a4160ee03bb8c9b0d62535268ff4c1dcf46c5b9b09f17d3d024df942f1df7ddb890972c1a00bfbbff574a69693c4b59c" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "420e03a4b739cfbe425f6a7dc01dd25ba511789297b7c78b67f571ae7c9c18f7", + "pubkey": "cd72b15a2fd5392f67cd22c8990cf881a0ab8a55526f4eb29d376b843f87796d", + "created_at": 1640915358, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Fernando\"}", + "sig": "7d40d511746f1fee54711ff7cc294591a6fa15cf739b87b185ae2e2328ebc9f509b1794cf059dd8ff825c77b70a3862d30a00fff6b545c4c3be616d4c61c0c9a" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "37cf51647058702587aa167d6acb47e296f0f46eb52673d2ed7628e87618f9e4", + "pubkey": "3036d508065eb550577171f3d594cfa0d8dca557d5253f6df31c461bfe25758c", + "created_at": 1640921955, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Gabriel\",\"picture\":\"https://bafybeiaoh5cbak2uvufpllgjyd3q72lqidx43bcrnilbcpmgrmbhju65iq.ipfs.dweb.link/photo_2017-03-23_01-54-20.jpg\"}", + "sig": "12ecd99aba3f4b2abb039756c8aa2fee7b1cd4791bbb1fdcc00d5b6395266f8a4e47823b7007db88e7991129713131604f299d72de95277a78378e46a57b8cd6" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "89bab6756bdf221e12d2e06e16172fc2539e76b71171ea2362b91a7a4d57c5db", + "pubkey": "87515ccb9d97ecaa4d636260bbffeba9d5a67eb4a3dfa0f2778a5f8a93078310", + "created_at": 1640923806, + "kind": 0, + "tags": [], + "content": "{\"name\":\"axe10ten\"}", + "sig": "1640049b6d3fba6badbb1193698469b3fc2ce618b732e1081f0008f5fc71f6985d8c311cb66e2760a263d09933bae24ec547d821660053ea8936e704e8729f22" + } + }, + { + "source": "seeds/events.json", + "event": { + "id": "c222198a4cc38a0ef99139bdb65e10888622d6f0857596fc40b300c385249630", + "pubkey": "48b9ffdd3a00e390ee3d97f05c0aed94049b05da01301a2b48c918b9a511796e", + "created_at": 1640931222, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Me\"}", + "sig": "83075c0ee9bd28212dea5b72c389f58e975c11ac85032efd3443835140e103951e1cda1f58184a8dea446a3b7756cad49a7a580002c80918667a577107966a12" + } + }, + { + "source": "synthetic", + "event": { + "id": "c001000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700000100, + "kind": 0, + "tags": [], + "content": "profile one", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c002000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700000200, + "kind": 0, + "tags": [], + "content": "profile two", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c003000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700000200, + "kind": 0, + "tags": [], + "content": "profile tie winner", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c004000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700000300, + "kind": 1, + "tags": [ + [ + "t", + "nostr" + ] + ], + "content": "bitcoin lightning network", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c005000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700000400, + "kind": 1, + "tags": [ + [ + "t", + "bitcoin" + ], + [ + "g", + "u4pruydqqvj" + ] + ], + "content": "lightning strikes twice", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c006000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700000500, + "kind": 1, + "tags": [], + "content": "unrelated text", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + }, + "expires_at": 2000000000 + }, + { + "source": "synthetic", + "event": { + "id": "c007000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700000600, + "kind": 7, + "tags": [ + [ + "e", + "c000000000000000000000000000000000000000000000000000000000000001" + ] + ], + "content": "reaction", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + }, + "remote_address": "203.0.113.7" + }, + { + "source": "synthetic", + "event": { + "id": "c008000000000000000000000000000000000000000000000000000000000000", + "pubkey": "b300000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700000700, + "kind": 1, + "tags": [ + [ + "e", + "c004000000000000000000000000000000000000000000000000000000000000" + ], + [ + "p", + "a100000000000000000000000000000000000000000000000000000000000000" + ] + ], + "content": "reply to note", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c009000000000000000000000000000000000000000000000000000000000000", + "pubkey": "b300000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700000800, + "kind": 30023, + "tags": [ + [ + "d", + "article-one" + ] + ], + "content": "long form article", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + }, + "deduplication": [ + "article-one" + ] + }, + { + "source": "synthetic", + "event": { + "id": "c00a000000000000000000000000000000000000000000000000000000000000", + "pubkey": "b300000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700000801, + "kind": 30023, + "tags": [ + [ + "d", + "article-one" + ] + ], + "content": "long form article v2", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + }, + "deduplication": [ + "article-one" + ] + }, + { + "source": "synthetic", + "event": { + "id": "c00b000000000000000000000000000000000000000000000000000000000000", + "pubkey": "b300000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700000802, + "kind": 30023, + "tags": [], + "content": "parameterized without d tag", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c00c000000000000000000000000000000000000000000000000000000000000", + "pubkey": "c400000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700000900, + "kind": 41, + "tags": [], + "content": "group chat", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c00d000000000000000000000000000000000000000000000000000000000000", + "pubkey": "c400000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700000901, + "kind": 41, + "tags": [], + "content": "group chat later", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c00e000000000000000000000000000000000000000000000000000000000000", + "pubkey": "c400000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001000, + "kind": 1, + "tags": [ + [ + "g", + "u4pruydqqvj" + ] + ], + "content": "geohash note", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c00f000000000000000000000000000000000000000000000000000000000000", + "pubkey": "c400000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001100, + "kind": 62, + "tags": [], + "content": "request to vanish", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c010000000000000000000000000000000000000000000000000000000000000", + "pubkey": "c400000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001200, + "kind": 1, + "tags": [], + "content": "expired note", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + }, + "expires_at": 1600000000 + }, + { + "source": "synthetic", + "event": { + "id": "c011000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001300, + "kind": 1, + "tags": [ + [ + "a", + "30023:b300000000000000000000000000000000000000000000000000000000000000:article-one" + ] + ], + "content": "addressable reference", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c012000000000000000000000000000000000000000000000000000000000000", + "pubkey": "d001000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001400, + "kind": 1, + "tags": [], + "content": "deletable by kind", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c013000000000000000000000000000000000000000000000000000000000000", + "pubkey": "d001000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001401, + "kind": 2, + "tags": [], + "content": "relay recommendation", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "source": "synthetic", + "event": { + "id": "c014000000000000000000000000000000000000000000000000000000000000", + "pubkey": "d002000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001500, + "kind": 1, + "tags": [], + "content": "target for a report", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + } + ], + "script": [ + { + "op": "events.create", + "request": { + "record": { + "event": { + "id": "c015000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001600, + "kind": 1, + "tags": [], + "content": "created once", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + } + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "events.create", + "request": { + "record": { + "event": { + "id": "c015000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001600, + "kind": 1, + "tags": [], + "content": "created once", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + } + }, + "result": { + "compare": { + "count": 0 + } + } + }, + { + "op": "events.create_many", + "request": { + "records": [] + }, + "result": { + "compare": { + "count": 0 + } + } + }, + { + "op": "events.upsert", + "request": { + "record": { + "event": { + "id": "c016000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001700, + "kind": 0, + "tags": [], + "content": "profile upsert", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + } + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "events.upsert_many", + "request": { + "records": [ + { + "event": { + "id": "c017000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001800, + "kind": 1, + "tags": [], + "content": "upsert many one", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "event": { + "id": "c018000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001801, + "kind": 1, + "tags": [], + "content": "upsert many two", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + }, + "expires_at": 2000000000, + "remote_address": "198.51.100.9" + } + ] + }, + "result": { + "compare": { + "count": 2 + } + } + }, + { + "op": "events.upsert", + "request": { + "record": { + "event": { + "id": "c019000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001600, + "kind": 0, + "tags": [], + "content": "profile older", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + } + }, + "result": { + "compare": { + "count": 0 + } + } + }, + { + "op": "events.upsert", + "request": { + "record": { + "event": { + "id": "c01a000000000000000000000000000000000000000000000000000000000000", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001800, + "kind": 0, + "tags": [], + "content": "profile newer", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + } + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "events.upsert", + "request": { + "record": { + "event": { + "id": "c01b000000000000000000000000000000000000000000000000000000000000", + "pubkey": "b300000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001900, + "kind": 30023, + "tags": [ + [ + "d", + "article-two" + ] + ], + "content": "article two", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + }, + "deduplication": [ + "article-two" + ] + } + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "events.upsert", + "request": { + "record": { + "event": { + "id": "c01c000000000000000000000000000000000000000000000000000000000000", + "pubkey": "b300000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700001900, + "kind": 30023, + "tags": [ + [ + "d", + "article-two" + ] + ], + "content": "article two tie", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + }, + "deduplication": [ + "article-two" + ] + } + }, + "result": { + "compare": { + "count": 0 + } + } + }, + { + "op": "events.upsert_many", + "request": { + "records": [ + { + "event": { + "id": "c01d000000000000000000000000000000000000000000000000000000000000", + "pubkey": "c400000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700002000, + "kind": 30000, + "tags": [ + [ + "d", + "many" + ] + ], + "content": "many one", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + }, + "deduplication": [ + "many" + ], + "remote_address": "198.51.100.1" + }, + { + "event": { + "id": "c01e000000000000000000000000000000000000000000000000000000000000", + "pubkey": "c400000000000000000000000000000000000000000000000000000000000000", + "created_at": 1700002001, + "kind": 30000, + "tags": [ + [ + "d", + "many-2" + ] + ], + "content": "many two", + "sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + }, + "deduplication": [ + "many-2" + ], + "remote_address": "198.51.100.2" + } + ] + }, + "result": { + "compare": { + "count": 2 + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "authors": [ + "a100000000000000000000000000000000000000000000000000000000000000" + ], + "kinds": [ + 1 + ] + } + ] + }, + "result": { + "compare": { + "rows": [ + [ + "c004000000000000000000000000000000000000000000000000000000000000", + 1700000300, + null, + false, + null + ], + [ + "c011000000000000000000000000000000000000000000000000000000000000", + 1700001300, + null, + false, + null + ], + [ + "c015000000000000000000000000000000000000000000000000000000000000", + 1700001600, + null, + false, + null + ] + ] + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "authors": [ + "a1000000" + ] + } + ] + }, + "result": { + "compare": { + "rows": [ + [ + "c001000000000000000000000000000000000000000000000000000000000000", + 1700000100, + null, + false, + null + ], + [ + "c002000000000000000000000000000000000000000000000000000000000000", + 1700000200, + null, + false, + null + ], + [ + "c003000000000000000000000000000000000000000000000000000000000000", + 1700000200, + null, + false, + null + ], + [ + "c004000000000000000000000000000000000000000000000000000000000000", + 1700000300, + null, + false, + null + ], + [ + "c011000000000000000000000000000000000000000000000000000000000000", + 1700001300, + null, + false, + null + ], + [ + "c015000000000000000000000000000000000000000000000000000000000000", + 1700001600, + null, + false, + null + ], + [ + "c01a000000000000000000000000000000000000000000000000000000000000", + 1700001800, + null, + false, + [ + "a100000000000000000000000000000000000000000000000000000000000000", + 0 + ] + ] + ] + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "authors": [ + "a100000" + ] + } + ] + }, + "result": { + "compare": { + "rows": [ + [ + "c001000000000000000000000000000000000000000000000000000000000000", + 1700000100, + null, + false, + null + ], + [ + "c002000000000000000000000000000000000000000000000000000000000000", + 1700000200, + null, + false, + null + ], + [ + "c003000000000000000000000000000000000000000000000000000000000000", + 1700000200, + null, + false, + null + ], + [ + "c004000000000000000000000000000000000000000000000000000000000000", + 1700000300, + null, + false, + null + ], + [ + "c011000000000000000000000000000000000000000000000000000000000000", + 1700001300, + null, + false, + null + ], + [ + "c015000000000000000000000000000000000000000000000000000000000000", + 1700001600, + null, + false, + null + ], + [ + "c01a000000000000000000000000000000000000000000000000000000000000", + 1700001800, + null, + false, + [ + "a100000000000000000000000000000000000000000000000000000000000000", + 0 + ] + ] + ] + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "ids": [ + "c00400000" + ] + } + ] + }, + "result": { + "compare": { + "rows": [ + [ + "c004000000000000000000000000000000000000000000000000000000000000", + 1700000300, + null, + false, + null + ] + ] + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "ids": [ + "c005000000000000000000000000000000000000000000000000000000000000", + "c006000000000000000000000000000000000000000000000000000000000000" + ] + } + ] + }, + "result": { + "compare": { + "rows": [ + [ + "c005000000000000000000000000000000000000000000000000000000000000", + 1700000400, + null, + false, + null + ], + [ + "c006000000000000000000000000000000000000000000000000000000000000", + 1700000500, + 2000000000, + false, + null + ] + ] + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "authors": [], + "kinds": [ + 1 + ] + } + ] + }, + "result": { + "compare": { + "rows": [] + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "kinds": [ + 1 + ], + "since": 1700000400, + "until": 1700000600 + } + ] + }, + "result": { + "compare": { + "rows": [ + [ + "c005000000000000000000000000000000000000000000000000000000000000", + 1700000400, + null, + false, + null + ], + [ + "c006000000000000000000000000000000000000000000000000000000000000", + 1700000500, + 2000000000, + false, + null + ] + ] + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "kinds": [ + 1 + ], + "limit": 3 + } + ] + }, + "result": { + "compare": { + "rows": [ + [ + "c018000000000000000000000000000000000000000000000000000000000000", + 1700001801, + 2000000000, + false, + [ + "a200000000000000000000000000000000000000000000000000000000000000", + 1 + ] + ], + [ + "c017000000000000000000000000000000000000000000000000000000000000", + 1700001800, + null, + false, + [ + "a200000000000000000000000000000000000000000000000000000000000000", + 1 + ] + ], + [ + "c015000000000000000000000000000000000000000000000000000000000000", + 1700001600, + null, + false, + null + ] + ] + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "#e": [ + "c004000000000000000000000000000000000000000000000000000000000000" + ] + } + ] + }, + "result": { + "compare": { + "rows": [ + [ + "c008000000000000000000000000000000000000000000000000000000000000", + 1700000700, + null, + false, + null + ] + ] + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "#g": [ + "u4pru*" + ] + } + ] + }, + "result": { + "compare": { + "rows": [ + [ + "c005000000000000000000000000000000000000000000000000000000000000", + 1700000400, + null, + false, + null + ], + [ + "c00e000000000000000000000000000000000000000000000000000000000000", + 1700001000, + null, + false, + null + ] + ] + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "#e": [] + } + ] + }, + "result": { + "compare": { + "rows": [] + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "search": "bitcoin lightning" + } + ] + }, + "result": { + "compare": { + "rows": [ + [ + "5454369c2445e12b001b51e5e48cf6722072ecdb187453c028cac8a12040633a", + 1640740934, + null, + false, + null + ], + [ + "c004000000000000000000000000000000000000000000000000000000000000", + 1700000300, + null, + false, + null + ], + [ + "cb41289d08a5784a73b7bac2bf97620f5c927487d0c5a029ba81f840fe21fa36", + 1640877562, + null, + false, + null + ], + [ + "9ae1fef4857bb0174e8fb1221f8da1da6705ebef42e143de7a8f8fe965ba9ee3", + 1640771785, + null, + false, + null + ] + ] + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "kinds": [ + 1 + ], + "limit": 2 + }, + { + "authors": [ + "b300000000000000000000000000000000000000000000000000000000000000" + ] + } + ] + }, + "result": { + "compare": { + "rows": [ + [ + "c01b000000000000000000000000000000000000000000000000000000000000", + 1700001900, + null, + false, + [ + "article-two" + ] + ], + [ + "c018000000000000000000000000000000000000000000000000000000000000", + 1700001801, + 2000000000, + false, + [ + "a200000000000000000000000000000000000000000000000000000000000000", + 1 + ] + ], + [ + "c017000000000000000000000000000000000000000000000000000000000000", + 1700001800, + null, + false, + [ + "a200000000000000000000000000000000000000000000000000000000000000", + 1 + ] + ], + [ + "c00b000000000000000000000000000000000000000000000000000000000000", + 1700000802, + null, + false, + null + ], + [ + "c00a000000000000000000000000000000000000000000000000000000000000", + 1700000801, + null, + false, + null + ], + [ + "c009000000000000000000000000000000000000000000000000000000000000", + 1700000800, + null, + false, + null + ], + [ + "c008000000000000000000000000000000000000000000000000000000000000", + 1700000700, + null, + false, + null + ] + ] + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [] + }, + "result": { + "compare": { + "error": true + } + } + }, + { + "op": "events.count_by_filters", + "request": { + "filters": [ + { + "authors": [ + "a100000000000000000000000000000000000000000000000000000000000000" + ] + } + ] + }, + "result": { + "compare": { + "count": 7 + } + } + }, + { + "op": "events.count_by_filters", + "request": { + "filters": [ + { + "kinds": [ + 1 + ] + }, + { + "kinds": [ + 7 + ] + } + ] + }, + "result": { + "compare": { + "count": 158 + } + } + }, + { + "op": "events.count_by_filters", + "request": { + "filters": [ + { + "kinds": [ + 1 + ], + "limit": 2 + } + ] + }, + "result": { + "compare": { + "count": 2 + } + } + }, + { + "op": "events.find_reconciliation_items", + "request": { + "filter": { + "authors": [ + "a200000000000000000000000000000000000000000000000000000000000000" + ] + } + }, + "result": { + "compare": { + "items": [ + [ + 1700000400, + "c005000000000000000000000000000000000000000000000000000000000000" + ], + [ + 1700000500, + "c006000000000000000000000000000000000000000000000000000000000000" + ], + [ + 1700000600, + "c007000000000000000000000000000000000000000000000000000000000000" + ], + [ + 1700001800, + "c017000000000000000000000000000000000000000000000000000000000000" + ], + [ + 1700001801, + "c018000000000000000000000000000000000000000000000000000000000000" + ] + ] + } + } + }, + { + "op": "events.has_active_request_to_vanish", + "request": { + "pubkey": "c400000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "value": true + } + } + }, + { + "op": "events.has_active_request_to_vanish", + "request": { + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "value": false + } + } + }, + { + "op": "events.delete_by_pubkey_and_ids", + "request": { + "pubkey": "d001000000000000000000000000000000000000000000000000000000000000", + "ids": [ + "c012000000000000000000000000000000000000000000000000000000000000" + ] + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "events.delete_by_pubkey_except_kinds", + "request": { + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "kinds": [ + 1 + ] + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "report.create", + "request": { + "report": { + "event_id": "c013000000000000000000000000000000000000000000000000000000000000", + "reporter_pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "reported_pubkey": "d002000000000000000000000000000000000000000000000000000000000000", + "reported_event_id": "c014000000000000000000000000000000000000000000000000000000000000", + "report_type": "spam", + "weight": 0.5, + "actionable": true + } + }, + "result": { + "compare": { + "report": { + "id": 1, + "event_id": "c013000000000000000000000000000000000000000000000000000000000000", + "reporter_pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "reported_pubkey": "d002000000000000000000000000000000000000000000000000000000000000", + "reported_event_id": "c014000000000000000000000000000000000000000000000000000000000000", + "report_type": "spam", + "weight": 0.5, + "actionable": true + } + } + } + }, + { + "op": "events.find_by_filters", + "request": { + "filters": [ + { + "authors": [ + "d002000000000000000000000000000000000000000000000000000000000000" + ] + } + ] + }, + "result": { + "compare": { + "rows": [] + } + } + }, + { + "op": "events.count_by_filters", + "request": { + "filters": [ + { + "authors": [ + "d002000000000000000000000000000000000000000000000000000000000000" + ] + } + ] + }, + "result": { + "compare": { + "count": 0 + } + } + }, + { + "op": "events.delete_expired_and_retained", + "request": { + "options": { + "max_days": 1 + } + }, + "result": { + "compare": { + "deleted": 2, + "expired": 1, + "retained": 486 + } + } + }, + { + "op": "events.delete_expired_and_retained", + "request": { + "options": { + "max_days": 0 + } + }, + "result": { + "compare": { + "deleted": 0, + "expired": 0, + "retained": 0 + } + } + }, + { + "op": "events.delete_expired_and_retained", + "request": { + "options": { + "max_days": 1, + "kind_whitelist": [ + [ + 1, + 1 + ] + ], + "pubkey_whitelist": [ + "a100000000000000000000000000000000000000000000000000000000000000" + ] + } + }, + "result": { + "compare": { + "deleted": 0, + "expired": 0, + "retained": 0 + } + } + }, + { + "op": "user.upsert", + "request": { + "patch": { + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "is_admitted": true, + "is_vanished": false, + "tos_accepted_at": { + "kind": "set", + "value": "2024-01-02T03:04:05Z" + } + } + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "user.find_by_pubkey", + "request": { + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "user": { + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "is_admitted": true, + "is_vanished": false, + "balance": 0 + } + } + } + }, + { + "op": "user.find_by_pubkey", + "request": { + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "user": null + } + } + }, + { + "op": "user.get_balance_by_pubkey", + "request": { + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "balance": 0 + } + } + }, + { + "op": "user.get_balance_by_pubkey", + "request": { + "pubkey": "c400000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "balance": 0 + } + } + }, + { + "op": "user.is_vanished", + "request": { + "pubkey": "c400000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "value": true + } + } + }, + { + "op": "user.set_vanished", + "request": { + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "vanished": true + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "user.is_vanished", + "request": { + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "value": true + } + } + }, + { + "op": "user.upsert", + "request": { + "patch": { + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "is_admitted": false, + "is_vanished": true, + "tos_accepted_at": { + "kind": "absent" + } + } + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "user.upsert", + "request": { + "patch": { + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "is_admitted": true, + "is_vanished": false, + "tos_accepted_at": { + "kind": "clear" + } + } + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "user.revoke_admission", + "request": { + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "user.find_by_pubkey", + "request": { + "pubkey": "b300000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "user": null + } + } + }, + { + "op": "invoice.upsert", + "request": { + "invoice": { + "id": "inv-1", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "bolt11": "lnbc1invoiceone", + "amount_requested": 1000, + "amount_paid": null, + "unit": "sats", + "status": "pending", + "description": "first invoice", + "confirmed_at": null, + "expires_at": "2024-03-01T00:00:00Z", + "created_at": "2024-02-29T00:00:00Z", + "verify_url": "https://example.test/verify/1" + } + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "invoice.upsert", + "request": { + "invoice": { + "id": "inv-2", + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "bolt11": "lnbc1invoicetwo", + "amount_requested": 2000, + "amount_paid": null, + "unit": "msats", + "status": "pending", + "description": "second invoice", + "confirmed_at": null, + "expires_at": null, + "created_at": "2024-02-28T00:00:00Z" + } + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "invoice.find_by_id", + "request": { + "id": "inv-1" + }, + "result": { + "compare": { + "invoice": { + "id": "inv-1", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "bolt11": "lnbc1invoiceone", + "amount_requested": 1000, + "amount_paid": null, + "unit": "sats", + "status": "pending", + "description": "first invoice", + "confirmed_at": null, + "expires_at": "2024-03-01T00:00:00.000Z", + "verify_url": "https://example.test/verify/1" + } + } + } + }, + { + "op": "invoice.find_by_id", + "request": { + "id": "missing" + }, + "result": { + "compare": { + "invoice": null + } + } + }, + { + "op": "invoice.find_pending_invoices", + "request": { + "offset": 0, + "limit": 10 + }, + "result": { + "compare": { + "invoices": [ + { + "id": "inv-2", + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "bolt11": "lnbc1invoicetwo", + "amount_requested": 2000, + "amount_paid": null, + "unit": "msats", + "status": "pending", + "description": "second invoice", + "confirmed_at": null, + "expires_at": null, + "verify_url": null + }, + { + "id": "inv-1", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "bolt11": "lnbc1invoiceone", + "amount_requested": 1000, + "amount_paid": null, + "unit": "sats", + "status": "pending", + "description": "first invoice", + "confirmed_at": null, + "expires_at": "2024-03-01T00:00:00.000Z", + "verify_url": "https://example.test/verify/1" + } + ] + } + } + }, + { + "op": "invoice.update_status", + "request": { + "id": "inv-2", + "status": "expired" + }, + "result": { + "compare": { + "invoice": { + "id": "inv-2", + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "bolt11": "lnbc1invoicetwo", + "amount_requested": 2000, + "amount_paid": null, + "unit": "msats", + "status": "expired", + "description": "second invoice", + "confirmed_at": null, + "expires_at": null, + "verify_url": null + } + } + } + }, + { + "op": "invoice.update_status", + "request": { + "id": "missing", + "status": "expired" + }, + "result": { + "compare": { + "invoice": null + } + } + }, + { + "op": "invoice.confirm_invoice", + "request": { + "invoice_id": "inv-1", + "amount_received": 1000, + "confirmed_at": "2024-03-02T00:00:00Z" + }, + "result": { + "compare": { + "ok": true + } + } + }, + { + "op": "user.get_balance_by_pubkey", + "request": { + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "balance": 1000000 + } + } + }, + { + "op": "invoice.find_by_id", + "request": { + "id": "inv-1" + }, + "result": { + "compare": { + "invoice": { + "id": "inv-1", + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "bolt11": "lnbc1invoiceone", + "amount_requested": 1000, + "amount_paid": 1000, + "unit": "sats", + "status": "pending", + "description": "first invoice", + "confirmed_at": "2024-03-02T00:00:00.000Z", + "expires_at": "2024-03-01T00:00:00.000Z", + "verify_url": "https://example.test/verify/1" + } + } + } + }, + { + "op": "nip05.upsert", + "request": { + "verification": { + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "nip05": "_@example.test", + "domain": "example.test", + "is_verified": true, + "last_verified_at": "2024-04-01T00:00:00Z", + "last_checked_at": "2024-04-02T00:00:00Z", + "failure_count": 0 + } + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "nip05.upsert", + "request": { + "verification": { + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "nip05": "bob@example.test", + "domain": "example.test", + "is_verified": false, + "last_verified_at": null, + "last_checked_at": "2024-04-03T00:00:00Z", + "failure_count": 2 + } + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "nip05.find_by_pubkey", + "request": { + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "verification": { + "pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "nip05": "_@example.test", + "domain": "example.test", + "is_verified": true, + "last_verified_at": "2024-04-01T00:00:00.000Z", + "last_checked_at": "2024-04-02T00:00:00.000Z", + "failure_count": 0 + } + } + } + }, + { + "op": "nip05.find_by_pubkey", + "request": { + "pubkey": "b300000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "verification": null + } + } + }, + { + "op": "nip05.find_pending_verifications", + "request": { + "update_frequency_ms": 315360000000, + "max_failures": 5, + "limit": 10 + }, + "result": { + "compare": { + "verifications": [] + } + } + }, + { + "op": "nip05.delete_by_pubkey", + "request": { + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "nip05.find_by_pubkey", + "request": { + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "verification": null + } + } + }, + { + "op": "invite.create", + "request": { + "code": "invite-one", + "options": { + "expires_at": "2100-01-01T00:00:00Z", + "created_by": "a100000000000000000000000000000000000000000000000000000000000000" + } + }, + "result": { + "compare": { + "invite": { + "code": "invite-one", + "created_by": "a100000000000000000000000000000000000000000000000000000000000000", + "claimed_by": null, + "expires_at": "2100-01-01T00:00:00.000Z", + "remaining_uses": 1 + } + } + } + }, + { + "op": "invite.create", + "request": { + "code": "invite-two", + "options": { + "remaining_uses": { + "kind": "clear" + } + } + }, + "result": { + "compare": { + "invite": { + "code": "invite-two", + "created_by": null, + "claimed_by": null, + "expires_at": null, + "remaining_uses": null + } + } + } + }, + { + "op": "invite.create", + "request": { + "code": "invite-expired", + "options": { + "expires_at": "2020-01-01T00:00:00Z", + "remaining_uses": { + "kind": "set", + "value": 3 + } + } + }, + "result": { + "compare": { + "invite": { + "code": "invite-expired", + "created_by": null, + "claimed_by": null, + "expires_at": "2020-01-01T00:00:00.000Z", + "remaining_uses": 3 + } + } + } + }, + { + "op": "invite.find_by_code", + "request": { + "code": "invite-one" + }, + "result": { + "compare": { + "invite": { + "code": "invite-one", + "created_by": "a100000000000000000000000000000000000000000000000000000000000000", + "claimed_by": null, + "expires_at": "2100-01-01T00:00:00.000Z", + "remaining_uses": 1 + } + } + } + }, + { + "op": "invite.claim_code", + "request": { + "code": "invite-one", + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "value": true + } + } + }, + { + "op": "invite.claim_code", + "request": { + "code": "invite-expired", + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "value": false + } + } + }, + { + "op": "invite.claim_code", + "request": { + "code": "invite-two", + "pubkey": "b300000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "value": true + } + } + }, + { + "op": "invite.find_active_codes", + "request": { + "limit": 10 + }, + "result": { + "compare": { + "invites": [ + { + "code": "invite-two", + "created_by": null, + "claimed_by": "b300000000000000000000000000000000000000000000000000000000000000", + "expires_at": null, + "remaining_uses": null + } + ] + } + } + }, + { + "op": "invite.delete_expired_codes", + "request": {}, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "invite.find_by_code", + "request": { + "code": "invite-expired" + }, + "result": { + "compare": { + "invite": null + } + } + }, + { + "op": "dvm.create", + "request": { + "id": "c01e000000000000000000000000000000000000000000000000000000000000", + "requester": "a100000000000000000000000000000000000000000000000000000000000000", + "kind": 5100 + }, + "result": { + "compare": { + "job": { + "id": "c01e000000000000000000000000000000000000000000000000000000000000", + "requester_pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "kind": 5100, + "worker_index": null, + "status": "submitted", + "result_event_id": null, + "error": null + } + } + } + }, + { + "op": "dvm.create", + "request": { + "id": "c01f000000000000000000000000000000000000000000000000000000000000", + "requester": "a200000000000000000000000000000000000000000000000000000000000000", + "kind": 5200 + }, + "result": { + "compare": { + "job": { + "id": "c01f000000000000000000000000000000000000000000000000000000000000", + "requester_pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "kind": 5200, + "worker_index": null, + "status": "submitted", + "result_event_id": null, + "error": null + } + } + } + }, + { + "op": "dvm.find_by_id", + "request": { + "id": "c01e000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "job": { + "id": "c01e000000000000000000000000000000000000000000000000000000000000", + "requester_pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "kind": 5100, + "worker_index": null, + "status": "submitted", + "result_event_id": null, + "error": null + } + } + } + }, + { + "op": "dvm.find_pending_jobs", + "request": { + "limit": 10 + }, + "result": { + "compare": { + "jobs": [ + { + "id": "c01e000000000000000000000000000000000000000000000000000000000000", + "requester_pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "kind": 5100, + "worker_index": null, + "status": "submitted", + "result_event_id": null, + "error": null + }, + { + "id": "c01f000000000000000000000000000000000000000000000000000000000000", + "requester_pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "kind": 5200, + "worker_index": null, + "status": "submitted", + "result_event_id": null, + "error": null + } + ] + } + } + }, + { + "op": "dvm.assign_worker", + "request": { + "id": "c01e000000000000000000000000000000000000000000000000000000000000", + "worker_index": 2 + }, + "result": { + "compare": { + "value": true + } + } + }, + { + "op": "dvm.assign_worker", + "request": { + "id": "c01e000000000000000000000000000000000000000000000000000000000000", + "worker_index": 3 + }, + "result": { + "compare": { + "value": false + } + } + }, + { + "op": "dvm.update_status", + "request": { + "update": { + "id": "c01e000000000000000000000000000000000000000000000000000000000000", + "status": "picked_up", + "result_event_id": { + "kind": "absent" + }, + "error": { + "kind": "absent" + } + } + }, + "result": { + "compare": { + "job": { + "id": "c01e000000000000000000000000000000000000000000000000000000000000", + "requester_pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "kind": 5100, + "worker_index": 2, + "status": "picked_up", + "result_event_id": null, + "error": null + } + } + } + }, + { + "op": "dvm.update_status", + "request": { + "update": { + "id": "c01f000000000000000000000000000000000000000000000000000000000000", + "status": "completed", + "result_event_id": { + "kind": "set", + "value": "c020000000000000000000000000000000000000000000000000000000000000" + }, + "error": { + "kind": "clear" + } + } + }, + "result": { + "compare": { + "job": { + "id": "c01f000000000000000000000000000000000000000000000000000000000000", + "requester_pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "kind": 5200, + "worker_index": null, + "status": "completed", + "result_event_id": "c020000000000000000000000000000000000000000000000000000000000000", + "error": null + } + } + } + }, + { + "op": "dvm.find_pending_jobs", + "request": { + "limit": 10, + "kinds": [ + 5100 + ] + }, + "result": { + "compare": { + "jobs": [ + { + "id": "c01e000000000000000000000000000000000000000000000000000000000000", + "requester_pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "kind": 5100, + "worker_index": 2, + "status": "picked_up", + "result_event_id": null, + "error": null + } + ] + } + } + }, + { + "op": "dvm.find_by_id", + "request": { + "id": "c01f000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "job": { + "id": "c01f000000000000000000000000000000000000000000000000000000000000", + "requester_pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "kind": 5200, + "worker_index": null, + "status": "completed", + "result_event_id": "c020000000000000000000000000000000000000000000000000000000000000", + "error": null + } + } + } + }, + { + "op": "report.create_many", + "request": { + "reports": [ + { + "event_id": "c013000000000000000000000000000000000000000000000000000000000000", + "reporter_pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "reported_pubkey": null, + "reported_event_id": "c014000000000000000000000000000000000000000000000000000000000000", + "report_type": "nudity", + "weight": 0.25, + "actionable": true + }, + { + "event_id": "c013000000000000000000000000000000000000000000000000000000000000", + "reporter_pubkey": "b300000000000000000000000000000000000000000000000000000000000000", + "reported_pubkey": "d002000000000000000000000000000000000000000000000000000000000000", + "reported_event_id": null, + "report_type": "other", + "weight": 0.125, + "actionable": false + } + ] + }, + "result": { + "compare": { + "reports": [ + { + "id": 2, + "event_id": "c013000000000000000000000000000000000000000000000000000000000000", + "reporter_pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "reported_pubkey": null, + "reported_event_id": "c014000000000000000000000000000000000000000000000000000000000000", + "report_type": "nudity", + "weight": 0.25, + "actionable": true + }, + { + "id": 3, + "event_id": "c013000000000000000000000000000000000000000000000000000000000000", + "reporter_pubkey": "b300000000000000000000000000000000000000000000000000000000000000", + "reported_pubkey": "d002000000000000000000000000000000000000000000000000000000000000", + "reported_event_id": null, + "report_type": "other", + "weight": 0.125, + "actionable": false + } + ] + } + } + }, + { + "op": "report.create_many", + "request": { + "reports": [] + }, + "result": { + "compare": { + "reports": [] + } + } + }, + { + "op": "report.find_by_event_id", + "request": { + "event_id": "c013000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "reports": [ + { + "id": 1, + "event_id": "c013000000000000000000000000000000000000000000000000000000000000", + "reporter_pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "reported_pubkey": "d002000000000000000000000000000000000000000000000000000000000000", + "reported_event_id": "c014000000000000000000000000000000000000000000000000000000000000", + "report_type": "spam", + "weight": 0.5, + "actionable": true + }, + { + "id": 2, + "event_id": "c013000000000000000000000000000000000000000000000000000000000000", + "reporter_pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "reported_pubkey": null, + "reported_event_id": "c014000000000000000000000000000000000000000000000000000000000000", + "report_type": "nudity", + "weight": 0.25, + "actionable": true + }, + { + "id": 3, + "event_id": "c013000000000000000000000000000000000000000000000000000000000000", + "reporter_pubkey": "b300000000000000000000000000000000000000000000000000000000000000", + "reported_pubkey": "d002000000000000000000000000000000000000000000000000000000000000", + "reported_event_id": null, + "report_type": "other", + "weight": 0.125, + "actionable": false + } + ] + } + } + }, + { + "op": "report.find_actionable", + "request": { + "limit": 10 + }, + "result": { + "compare": { + "reports": [ + { + "id": 1, + "event_id": "c013000000000000000000000000000000000000000000000000000000000000", + "reporter_pubkey": "a100000000000000000000000000000000000000000000000000000000000000", + "reported_pubkey": "d002000000000000000000000000000000000000000000000000000000000000", + "reported_event_id": "c014000000000000000000000000000000000000000000000000000000000000", + "report_type": "spam", + "weight": 0.5, + "actionable": true + }, + { + "id": 2, + "event_id": "c013000000000000000000000000000000000000000000000000000000000000", + "reporter_pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "reported_pubkey": null, + "reported_event_id": "c014000000000000000000000000000000000000000000000000000000000000", + "report_type": "nudity", + "weight": 0.25, + "actionable": true + } + ] + } + } + }, + { + "op": "report.find_actionable_targets", + "request": {}, + "result": { + "compare": { + "targets": [ + { + "reported_pubkey": "d002000000000000000000000000000000000000000000000000000000000000", + "reported_event_id": "c014000000000000000000000000000000000000000000000000000000000000" + }, + { + "reported_pubkey": null, + "reported_event_id": "c014000000000000000000000000000000000000000000000000000000000000" + } + ] + } + } + }, + { + "op": "delivery_log.append", + "request": { + "entry": { + "outbox_id": null, + "event_type": "settings.changed", + "target_id": "target-http", + "target_type": "http", + "status": "success", + "attempt_number": 1, + "error_snippet": null + } + }, + "result": { + "compare": { + "ok": true + } + } + }, + { + "op": "delivery_log.append", + "request": { + "entry": { + "outbox_id": null, + "event_type": "relay.restarted", + "target_id": "target-discord", + "target_type": "discord", + "status": "failed", + "attempt_number": 2, + "error_snippet": "connection refused" + } + }, + "result": { + "compare": { + "ok": true + } + } + }, + { + "op": "delivery_log.find_recent", + "request": { + "limit": 10, + "filters": {} + }, + "result": { + "compare": { + "entries": [ + { + "outbox_id": null, + "event_type": "relay.restarted", + "target_id": "target-discord", + "target_type": "discord", + "status": "failed", + "attempt_number": 2, + "error_snippet": "connection refused" + }, + { + "outbox_id": null, + "event_type": "settings.changed", + "target_id": "target-http", + "target_type": "http", + "status": "success", + "attempt_number": 1, + "error_snippet": null + } + ] + } + } + }, + { + "op": "delivery_log.find_recent", + "request": { + "limit": 10, + "filters": { + "status": "success" + } + }, + "result": { + "compare": { + "entries": [ + { + "outbox_id": null, + "event_type": "settings.changed", + "target_id": "target-http", + "target_type": "http", + "status": "success", + "attempt_number": 1, + "error_snippet": null + } + ] + } + } + }, + { + "op": "delivery_log.find_recent", + "request": { + "limit": 10, + "filters": { + "event_type": "relay.restarted" + } + }, + "result": { + "compare": { + "entries": [ + { + "outbox_id": null, + "event_type": "relay.restarted", + "target_id": "target-discord", + "target_type": "discord", + "status": "failed", + "attempt_number": 2, + "error_snippet": "connection refused" + } + ] + } + } + }, + { + "op": "delivery_log.delete_older_than", + "request": { + "cutoff": "1970-01-02T00:00:00Z" + }, + "result": { + "compare": { + "count": 0 + } + } + }, + { + "op": "outbox.enqueue", + "request": { + "event_type": "admission.invoice.paid", + "payload": { + "invoiceId": "inv-1", + "amount": 1000 + } + }, + "result": { + "compare": { + "message": { + "event_type": "admission.invoice.paid", + "payload": { + "invoiceId": "inv-1", + "amount": 1000 + }, + "status": "pending", + "attempt_count": 0, + "last_error": null + } + }, + "ref": { + "id": "df411304-0744-4ca4-a7a4-84d8f32990e2" + } + } + }, + { + "op": "outbox.enqueue", + "request": { + "event_type": "settings.changed", + "payload": { + "changed": [ + "nip50.enabled" + ] + } + }, + "result": { + "compare": { + "message": { + "event_type": "settings.changed", + "payload": { + "changed": [ + "nip50.enabled" + ] + }, + "status": "pending", + "attempt_count": 0, + "last_error": null + } + }, + "ref": { + "id": "8a0f84e0-63b0-4ea3-a479-e7f8c3bb2f00" + } + } + }, + { + "op": "outbox.mark_failed", + "request": { + "enqueue_event_type": "admission.invoice.paid", + "error": "boom", + "attempt_count": 2, + "max_attempts": 5, + "base_delay_ms": 0 + }, + "result": { + "compare": { + "ok": true + } + } + }, + { + "op": "outbox.claim_batch", + "request": { + "limit": 10 + }, + "result": { + "compare": { + "messages": [ + { + "event_type": "admission.invoice.paid", + "payload": { + "amount": 1000, + "invoiceId": "inv-1" + }, + "status": "processing", + "attempt_count": 2, + "last_error": "boom" + }, + { + "event_type": "settings.changed", + "payload": { + "changed": [ + "nip50.enabled" + ] + }, + "status": "processing", + "attempt_count": 0, + "last_error": null + } + ] + } + } + }, + { + "op": "outbox.mark_delivered", + "request": { + "enqueue_event_type": "admission.invoice.paid" + }, + "result": { + "compare": { + "ok": true + } + } + }, + { + "op": "outbox.claim_batch", + "request": { + "limit": 10 + }, + "result": { + "compare": { + "messages": [] + } + } + }, + { + "op": "outbox.delete_terminal_older_than", + "request": { + "cutoff": "2100-01-01T00:00:00Z" + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "outbox.claim_batch", + "request": { + "limit": 10 + }, + "result": { + "compare": { + "messages": [] + } + } + }, + { + "op": "transaction", + "request": { + "ops": [ + { + "op": "user.upsert", + "request": { + "patch": { + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "is_admitted": true, + "is_vanished": false, + "tos_accepted_at": { + "kind": "set", + "value": "2024-05-01T00:00:00Z" + } + } + } + }, + { + "op": "user.admit_user", + "request": { + "pubkey": "c400000000000000000000000000000000000000000000000000000000000000", + "admitted_at": "2024-05-02T00:00:00Z" + } + }, + { + "op": "invoice.find_by_id", + "request": { + "id": "inv-2" + } + }, + { + "op": "outbox.enqueue", + "request": { + "event_type": "admission.invoice.created", + "payload": { + "invoiceId": "inv-2" + } + } + } + ] + }, + "result": { + "compare": { + "failed": false, + "results": [ + { + "count": 1 + }, + { + "ok": true + }, + { + "invoice": { + "id": "inv-2", + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "bolt11": "lnbc1invoicetwo", + "amount_requested": 2000, + "amount_paid": null, + "unit": "msats", + "status": "expired", + "description": "second invoice", + "confirmed_at": null, + "expires_at": null, + "verify_url": null + } + }, + { + "message": { + "event_type": "admission.invoice.created", + "payload": { + "invoiceId": "inv-2" + }, + "status": "pending", + "attempt_count": 0, + "last_error": null + } + } + ] + } + } + }, + { + "op": "transaction", + "request": { + "ops": [ + { + "op": "invoice.upsert", + "request": { + "invoice": { + "id": "inv-3", + "pubkey": "b300000000000000000000000000000000000000000000000000000000000000", + "bolt11": "lnbc1invoicethree", + "amount_requested": 500, + "amount_paid": null, + "unit": "btc", + "status": "pending", + "description": "third", + "confirmed_at": null, + "expires_at": null, + "created_at": "2024-05-03T00:00:00Z" + } + } + }, + { + "op": "user.upsert", + "request": { + "patch": { + "pubkey": "b300000000000000000000000000000000000000000000000000000000000000", + "is_admitted": true, + "is_vanished": false, + "tos_accepted_at": { + "kind": "clear" + } + } + } + }, + { + "op": "invoice.confirm_invoice", + "request": { + "invoice_id": "inv-3", + "amount_received": 1000000000, + "confirmed_at": "2024-05-04T00:00:00Z" + } + } + ] + }, + "result": { + "compare": { + "failed": true, + "at": 2 + } + } + }, + { + "op": "invoice.find_by_id", + "request": { + "id": "inv-3" + }, + "result": { + "compare": { + "invoice": null + } + } + }, + { + "op": "user.find_by_pubkey", + "request": { + "pubkey": "b300000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "user": null + } + } + } + ], + "projection": { + "event_row": "[event_id, created_at, expires_at, deleted_at.is_some(), event_deduplication]", + "dropped_timestamps": "first_seen, updated_at, created_at of rows whose created_at the call generated, delivered_at, available_at, picked_up_at, completed_at", + "sorted_lists": "reports (by id), dvm jobs (by id), invite codes (by code), delivery log (by target_id/attempt_number), outbox messages (by event_type)", + "transaction": "a group result is {failed, at} or {failed, results}; the failing op is confirm_invoice with an amount that overflows int8" + } +} diff --git a/crates/nostream-storage/tests/fixtures/schema.json b/crates/nostream-storage/tests/fixtures/schema.json new file mode 100644 index 00000000..cd32df3d --- /dev/null +++ b/crates/nostream-storage/tests/fixtures/schema.json @@ -0,0 +1,1588 @@ +{ + "columns": [ + [ + "dvm_jobs", + "id", + 1, + "bytea", + "bytea", + "NO", + null, + null, + null, + null, + null + ], + [ + "dvm_jobs", + "requester_pubkey", + 2, + "bytea", + "bytea", + "NO", + null, + null, + null, + null, + null + ], + [ + "dvm_jobs", + "kind", + 3, + "integer", + "int4", + "NO", + null, + null, + 32, + 0, + null + ], + [ + "dvm_jobs", + "worker_index", + 4, + "integer", + "int4", + "YES", + null, + null, + 32, + 0, + null + ], + [ + "dvm_jobs", + "status", + 5, + "text", + "text", + "NO", + "'submitted'::text", + null, + null, + null, + null + ], + [ + "dvm_jobs", + "result_event_id", + 6, + "bytea", + "bytea", + "YES", + null, + null, + null, + null, + null + ], + [ + "dvm_jobs", + "error", + 7, + "text", + "text", + "YES", + null, + null, + null, + null, + null + ], + [ + "dvm_jobs", + "picked_up_at", + 8, + "timestamp with time zone", + "timestamptz", + "YES", + null, + null, + null, + null, + 6 + ], + [ + "dvm_jobs", + "completed_at", + 9, + "timestamp with time zone", + "timestamptz", + "YES", + null, + null, + null, + null, + 6 + ], + [ + "dvm_jobs", + "created_at", + 10, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "dvm_jobs", + "updated_at", + 11, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "event_tags", + "id", + 1, + "uuid", + "uuid", + "NO", + "uuid_generate_v4()", + null, + null, + null, + null + ], + [ + "event_tags", + "event_id", + 2, + "bytea", + "bytea", + "NO", + null, + null, + null, + null, + null + ], + [ + "event_tags", + "tag_name", + 3, + "text", + "text", + "NO", + null, + null, + null, + null, + null + ], + [ + "event_tags", + "tag_value", + 4, + "text", + "text", + "NO", + null, + null, + null, + null, + null + ], + [ + "events", + "id", + 1, + "uuid", + "uuid", + "NO", + "uuid_generate_v4()", + null, + null, + null, + null + ], + [ + "events", + "event_id", + 2, + "bytea", + "bytea", + "NO", + null, + null, + null, + null, + null + ], + [ + "events", + "event_pubkey", + 3, + "bytea", + "bytea", + "NO", + null, + null, + null, + null, + null + ], + [ + "events", + "event_kind", + 4, + "integer", + "int4", + "NO", + null, + null, + 32, + 0, + null + ], + [ + "events", + "event_created_at", + 5, + "integer", + "int4", + "NO", + null, + null, + 32, + 0, + null + ], + [ + "events", + "event_content", + 6, + "text", + "text", + "NO", + null, + null, + null, + null, + null + ], + [ + "events", + "event_tags", + 7, + "jsonb", + "jsonb", + "YES", + null, + null, + null, + null, + null + ], + [ + "events", + "event_signature", + 8, + "bytea", + "bytea", + "NO", + null, + null, + null, + null, + null + ], + [ + "events", + "first_seen", + 9, + "timestamp without time zone", + "timestamp", + "YES", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "events", + "deleted_at", + 11, + "timestamp without time zone", + "timestamp", + "YES", + null, + null, + null, + null, + 6 + ], + [ + "events", + "event_deduplication", + 12, + "jsonb", + "jsonb", + "YES", + null, + null, + null, + null, + null + ], + [ + "events", + "remote_address", + 13, + "inet", + "inet", + "YES", + null, + null, + null, + null, + null + ], + [ + "events", + "expires_at", + 14, + "integer", + "int4", + "YES", + null, + null, + 32, + 0, + null + ], + [ + "invite_codes", + "code", + 1, + "character varying", + "varchar", + "NO", + null, + 64, + null, + null, + null + ], + [ + "invite_codes", + "created_by", + 2, + "bytea", + "bytea", + "YES", + null, + null, + null, + null, + null + ], + [ + "invite_codes", + "claimed_by", + 3, + "bytea", + "bytea", + "YES", + null, + null, + null, + null, + null + ], + [ + "invite_codes", + "expires_at", + 4, + "timestamp with time zone", + "timestamptz", + "YES", + null, + null, + null, + null, + 6 + ], + [ + "invite_codes", + "remaining_uses", + 5, + "integer", + "int4", + "YES", + "1", + null, + 32, + 0, + null + ], + [ + "invite_codes", + "created_at", + 6, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "invite_codes", + "updated_at", + 7, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "invoices", + "id", + 1, + "text", + "text", + "NO", + null, + null, + null, + null, + null + ], + [ + "invoices", + "pubkey", + 2, + "bytea", + "bytea", + "NO", + null, + null, + null, + null, + null + ], + [ + "invoices", + "bolt11", + 3, + "text", + "text", + "NO", + null, + null, + null, + null, + null + ], + [ + "invoices", + "amount_requested", + 4, + "bigint", + "int8", + "NO", + null, + null, + 64, + 0, + null + ], + [ + "invoices", + "amount_paid", + 5, + "bigint", + "int8", + "YES", + null, + null, + 64, + 0, + null + ], + [ + "invoices", + "unit", + 6, + "text", + "text", + "YES", + null, + null, + null, + null, + null + ], + [ + "invoices", + "status", + 7, + "text", + "text", + "YES", + null, + null, + null, + null, + null + ], + [ + "invoices", + "description", + 8, + "text", + "text", + "YES", + null, + null, + null, + null, + null + ], + [ + "invoices", + "confirmed_at", + 9, + "timestamp without time zone", + "timestamp", + "YES", + null, + null, + null, + null, + 3 + ], + [ + "invoices", + "expires_at", + 10, + "timestamp without time zone", + "timestamp", + "YES", + null, + null, + null, + null, + 3 + ], + [ + "invoices", + "created_at", + 11, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "invoices", + "updated_at", + 12, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "invoices", + "verify_url", + 13, + "text", + "text", + "YES", + null, + null, + null, + null, + null + ], + [ + "nip05_verifications", + "pubkey", + 1, + "bytea", + "bytea", + "NO", + null, + null, + null, + null, + null + ], + [ + "nip05_verifications", + "nip05", + 2, + "text", + "text", + "NO", + null, + null, + null, + null, + null + ], + [ + "nip05_verifications", + "domain", + 3, + "text", + "text", + "NO", + null, + null, + null, + null, + null + ], + [ + "nip05_verifications", + "is_verified", + 4, + "boolean", + "bool", + "NO", + "false", + null, + null, + null, + null + ], + [ + "nip05_verifications", + "last_verified_at", + 5, + "timestamp with time zone", + "timestamptz", + "YES", + null, + null, + null, + null, + 6 + ], + [ + "nip05_verifications", + "last_checked_at", + 6, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "nip05_verifications", + "failure_count", + 7, + "integer", + "int4", + "NO", + "0", + null, + 32, + 0, + null + ], + [ + "nip05_verifications", + "created_at", + 8, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "nip05_verifications", + "updated_at", + 9, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "notification_delivery_log", + "id", + 1, + "uuid", + "uuid", + "NO", + "uuid_generate_v4()", + null, + null, + null, + null + ], + [ + "notification_delivery_log", + "outbox_id", + 2, + "uuid", + "uuid", + "YES", + null, + null, + null, + null, + null + ], + [ + "notification_delivery_log", + "event_type", + 3, + "text", + "text", + "NO", + null, + null, + null, + null, + null + ], + [ + "notification_delivery_log", + "target_id", + 4, + "text", + "text", + "NO", + null, + null, + null, + null, + null + ], + [ + "notification_delivery_log", + "target_type", + 5, + "text", + "text", + "NO", + null, + null, + null, + null, + null + ], + [ + "notification_delivery_log", + "status", + 6, + "text", + "text", + "NO", + null, + null, + null, + null, + null + ], + [ + "notification_delivery_log", + "attempt_number", + 7, + "integer", + "int4", + "NO", + null, + null, + 32, + 0, + null + ], + [ + "notification_delivery_log", + "error_snippet", + 8, + "text", + "text", + "YES", + null, + null, + null, + null, + null + ], + [ + "notification_delivery_log", + "created_at", + 9, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "notification_outbox", + "id", + 1, + "uuid", + "uuid", + "NO", + "uuid_generate_v4()", + null, + null, + null, + null + ], + [ + "notification_outbox", + "event_type", + 2, + "text", + "text", + "NO", + null, + null, + null, + null, + null + ], + [ + "notification_outbox", + "payload", + 3, + "jsonb", + "jsonb", + "NO", + null, + null, + null, + null, + null + ], + [ + "notification_outbox", + "status", + 4, + "text", + "text", + "NO", + "'pending'::text", + null, + null, + null, + null + ], + [ + "notification_outbox", + "attempt_count", + 5, + "integer", + "int4", + "NO", + "0", + null, + 32, + 0, + null + ], + [ + "notification_outbox", + "available_at", + 6, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "notification_outbox", + "last_error", + 7, + "text", + "text", + "YES", + null, + null, + null, + null, + null + ], + [ + "notification_outbox", + "delivered_at", + 8, + "timestamp with time zone", + "timestamptz", + "YES", + null, + null, + null, + null, + 6 + ], + [ + "notification_outbox", + "created_at", + 9, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "notification_outbox", + "updated_at", + 10, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "reports", + "id", + 1, + "integer", + "int4", + "NO", + "nextval('reports_id_seq'::regclass)", + null, + 32, + 0, + null + ], + [ + "reports", + "event_id", + 2, + "bytea", + "bytea", + "NO", + null, + null, + null, + null, + null + ], + [ + "reports", + "reporter_pubkey", + 3, + "bytea", + "bytea", + "NO", + null, + null, + null, + null, + null + ], + [ + "reports", + "reported_pubkey", + 4, + "bytea", + "bytea", + "YES", + null, + null, + null, + null, + null + ], + [ + "reports", + "reported_event_id", + 5, + "bytea", + "bytea", + "YES", + null, + null, + null, + null, + null + ], + [ + "reports", + "report_type", + 6, + "text", + "text", + "NO", + null, + null, + null, + null, + null + ], + [ + "reports", + "weight", + 7, + "real", + "float4", + "NO", + null, + null, + 24, + null, + null + ], + [ + "reports", + "actionable", + 8, + "boolean", + "bool", + "NO", + "false", + null, + null, + null, + null + ], + [ + "reports", + "created_at", + 9, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "users", + "pubkey", + 1, + "bytea", + "bytea", + "NO", + null, + null, + null, + null, + null + ], + [ + "users", + "is_admitted", + 2, + "boolean", + "bool", + "YES", + "false", + null, + null, + null, + null + ], + [ + "users", + "balance", + 3, + "bigint", + "int8", + "YES", + "'0'::bigint", + null, + 64, + 0, + null + ], + [ + "users", + "tos_accepted_at", + 4, + "timestamp without time zone", + "timestamp", + "YES", + null, + null, + null, + null, + 3 + ], + [ + "users", + "created_at", + 5, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "users", + "updated_at", + 6, + "timestamp with time zone", + "timestamptz", + "NO", + "CURRENT_TIMESTAMP", + null, + null, + null, + 6 + ], + [ + "users", + "is_vanished", + 7, + "boolean", + "bool", + "NO", + "false", + null, + null, + null, + null + ] + ], + "indexes": [ + [ + "dvm_jobs", + "dvm_jobs_pkey", + "CREATE UNIQUE INDEX dvm_jobs_pkey ON public.dvm_jobs USING btree (id)" + ], + [ + "dvm_jobs", + "idx_dvm_jobs_kind", + "CREATE INDEX idx_dvm_jobs_kind ON public.dvm_jobs USING btree (kind)" + ], + [ + "dvm_jobs", + "idx_dvm_jobs_requester_pubkey", + "CREATE INDEX idx_dvm_jobs_requester_pubkey ON public.dvm_jobs USING btree (requester_pubkey)" + ], + [ + "dvm_jobs", + "idx_dvm_jobs_status_created_at", + "CREATE INDEX idx_dvm_jobs_status_created_at ON public.dvm_jobs USING btree (status, created_at)" + ], + [ + "event_tags", + "event_tags_event_id_index", + "CREATE INDEX event_tags_event_id_index ON public.event_tags USING btree (event_id)" + ], + [ + "event_tags", + "event_tags_pkey", + "CREATE UNIQUE INDEX event_tags_pkey ON public.event_tags USING btree (id)" + ], + [ + "event_tags", + "event_tags_tag_name_tag_value_index", + "CREATE INDEX event_tags_tag_name_tag_value_index ON public.event_tags USING btree (tag_name, tag_value)" + ], + [ + "events", + "event_tags_idx", + "CREATE INDEX event_tags_idx ON public.events USING gin (event_tags)" + ], + [ + "events", + "events_active_pubkey_kind_created_at_idx", + "CREATE INDEX events_active_pubkey_kind_created_at_idx ON public.events USING btree (event_pubkey, event_kind, event_created_at DESC, event_id)" + ], + [ + "events", + "events_content_fts_idx", + "CREATE INDEX events_content_fts_idx ON public.events USING gin (to_tsvector('simple'::regconfig, event_content))" + ], + [ + "events", + "events_deleted_at_partial_idx", + "CREATE INDEX events_deleted_at_partial_idx ON public.events USING btree (deleted_at) WHERE (deleted_at IS NOT NULL)" + ], + [ + "events", + "events_event_created_at_index", + "CREATE INDEX events_event_created_at_index ON public.events USING btree (event_created_at)" + ], + [ + "events", + "events_event_id_index", + "CREATE INDEX events_event_id_index ON public.events USING btree (event_id)" + ], + [ + "events", + "events_event_id_unique", + "CREATE UNIQUE INDEX events_event_id_unique ON public.events USING btree (event_id)" + ], + [ + "events", + "events_event_kind_index", + "CREATE INDEX events_event_kind_index ON public.events USING btree (event_kind)" + ], + [ + "events", + "events_event_pubkey_index", + "CREATE INDEX events_event_pubkey_index ON public.events USING btree (event_pubkey)" + ], + [ + "events", + "events_expires_at_index", + "CREATE INDEX events_expires_at_index ON public.events USING btree (expires_at)" + ], + [ + "events", + "events_pkey", + "CREATE UNIQUE INDEX events_pkey ON public.events USING btree (id)" + ], + [ + "events", + "kind_tags_created_at_idx", + "CREATE INDEX kind_tags_created_at_idx ON public.events USING gin (event_kind, event_tags, event_created_at)" + ], + [ + "events", + "replaceable_events_idx", + "CREATE UNIQUE INDEX replaceable_events_idx ON public.events USING btree (event_pubkey, event_kind, event_deduplication) WHERE ((event_kind = 0) OR (event_kind = 3) OR (event_kind = 41) OR ((event_kind >= 10000) AND (event_kind < 20000)) OR ((event_kind >= 30000) AND (event_kind < 40000)))" + ], + [ + "invite_codes", + "idx_invite_codes_expires_at", + "CREATE INDEX idx_invite_codes_expires_at ON public.invite_codes USING btree (expires_at) WHERE (expires_at IS NOT NULL)" + ], + [ + "invite_codes", + "invite_codes_pkey", + "CREATE UNIQUE INDEX invite_codes_pkey ON public.invite_codes USING btree (code)" + ], + [ + "invoices", + "invoices_pending_created_at_idx", + "CREATE INDEX invoices_pending_created_at_idx ON public.invoices USING btree (created_at) WHERE (status = 'pending'::text)" + ], + [ + "invoices", + "invoices_pkey", + "CREATE UNIQUE INDEX invoices_pkey ON public.invoices USING btree (id)" + ], + [ + "invoices", + "invoices_pubkey_index", + "CREATE INDEX invoices_pubkey_index ON public.invoices USING btree (pubkey)" + ], + [ + "nip05_verifications", + "idx_nip05_verifications_domain", + "CREATE INDEX idx_nip05_verifications_domain ON public.nip05_verifications USING btree (domain)" + ], + [ + "nip05_verifications", + "idx_nip05_verifications_is_verified", + "CREATE INDEX idx_nip05_verifications_is_verified ON public.nip05_verifications USING btree (is_verified)" + ], + [ + "nip05_verifications", + "idx_nip05_verifications_last_checked_at", + "CREATE INDEX idx_nip05_verifications_last_checked_at ON public.nip05_verifications USING btree (last_checked_at)" + ], + [ + "nip05_verifications", + "nip05_verifications_pkey", + "CREATE UNIQUE INDEX nip05_verifications_pkey ON public.nip05_verifications USING btree (pubkey)" + ], + [ + "notification_delivery_log", + "idx_notification_delivery_log_created_at", + "CREATE INDEX idx_notification_delivery_log_created_at ON public.notification_delivery_log USING btree (created_at)" + ], + [ + "notification_delivery_log", + "idx_notification_delivery_log_event_created_at", + "CREATE INDEX idx_notification_delivery_log_event_created_at ON public.notification_delivery_log USING btree (event_type, created_at)" + ], + [ + "notification_delivery_log", + "notification_delivery_log_pkey", + "CREATE UNIQUE INDEX notification_delivery_log_pkey ON public.notification_delivery_log USING btree (id)" + ], + [ + "notification_outbox", + "idx_notification_outbox_dispatch", + "CREATE INDEX idx_notification_outbox_dispatch ON public.notification_outbox USING btree (status, available_at, created_at)" + ], + [ + "notification_outbox", + "idx_notification_outbox_event_type_created_at", + "CREATE INDEX idx_notification_outbox_event_type_created_at ON public.notification_outbox USING btree (event_type, created_at)" + ], + [ + "notification_outbox", + "notification_outbox_pkey", + "CREATE UNIQUE INDEX notification_outbox_pkey ON public.notification_outbox USING btree (id)" + ], + [ + "reports", + "idx_reports_actionable_created_at", + "CREATE INDEX idx_reports_actionable_created_at ON public.reports USING btree (actionable, created_at)" + ], + [ + "reports", + "idx_reports_event_id", + "CREATE INDEX idx_reports_event_id ON public.reports USING btree (event_id)" + ], + [ + "reports", + "idx_reports_reported_event_id", + "CREATE INDEX idx_reports_reported_event_id ON public.reports USING btree (reported_event_id)" + ], + [ + "reports", + "idx_reports_reported_pubkey", + "CREATE INDEX idx_reports_reported_pubkey ON public.reports USING btree (reported_pubkey)" + ], + [ + "reports", + "idx_reports_reporter_pubkey", + "CREATE INDEX idx_reports_reporter_pubkey ON public.reports USING btree (reporter_pubkey)" + ], + [ + "reports", + "reports_actionable_reported_event_id_idx", + "CREATE INDEX reports_actionable_reported_event_id_idx ON public.reports USING btree (reported_event_id) WHERE ((actionable = true) AND (reported_event_id IS NOT NULL))" + ], + [ + "reports", + "reports_actionable_reported_pubkey_idx", + "CREATE INDEX reports_actionable_reported_pubkey_idx ON public.reports USING btree (reported_pubkey) WHERE ((actionable = true) AND (reported_pubkey IS NOT NULL))" + ], + [ + "reports", + "reports_pkey", + "CREATE UNIQUE INDEX reports_pkey ON public.reports USING btree (id)" + ], + [ + "users", + "users_pkey", + "CREATE UNIQUE INDEX users_pkey ON public.users USING btree (pubkey)" + ] + ], + "constraints": [ + [ + "dvm_jobs", + "dvm_jobs_pkey", + "PRIMARY KEY (id)" + ], + [ + "dvm_jobs", + "dvm_jobs_status_check", + "CHECK ((status = ANY (ARRAY['submitted'::text, 'picked_up'::text, 'completed'::text, 'failed'::text, 'timed_out'::text])))" + ], + [ + "event_tags", + "event_tags_pkey", + "PRIMARY KEY (id)" + ], + [ + "events", + "events_event_id_unique", + "UNIQUE (event_id)" + ], + [ + "events", + "events_pkey", + "PRIMARY KEY (id)" + ], + [ + "invite_codes", + "chk_remaining_uses_non_negative", + "CHECK ((remaining_uses >= 0))" + ], + [ + "invite_codes", + "invite_codes_pkey", + "PRIMARY KEY (code)" + ], + [ + "invoices", + "invoices_pkey", + "PRIMARY KEY (id)" + ], + [ + "invoices", + "invoices_status_check", + "CHECK ((status = ANY (ARRAY['pending'::text, 'completed'::text, 'expired'::text])))" + ], + [ + "invoices", + "invoices_unit_check", + "CHECK ((unit = ANY (ARRAY['msats'::text, 'sats'::text, 'btc'::text])))" + ], + [ + "nip05_verifications", + "nip05_verifications_pkey", + "PRIMARY KEY (pubkey)" + ], + [ + "notification_delivery_log", + "notification_delivery_log_outbox_id_foreign", + "FOREIGN KEY (outbox_id) REFERENCES notification_outbox(id) ON DELETE SET NULL" + ], + [ + "notification_delivery_log", + "notification_delivery_log_pkey", + "PRIMARY KEY (id)" + ], + [ + "notification_delivery_log", + "notification_delivery_log_status_check", + "CHECK ((status = ANY (ARRAY['success'::text, 'failed'::text])))" + ], + [ + "notification_delivery_log", + "notification_delivery_log_target_type_check", + "CHECK ((target_type = ANY (ARRAY['http'::text, 'discord'::text, 'slack'::text, 'telegram'::text])))" + ], + [ + "notification_outbox", + "notification_outbox_pkey", + "PRIMARY KEY (id)" + ], + [ + "notification_outbox", + "notification_outbox_status_check", + "CHECK ((status = ANY (ARRAY['pending'::text, 'processing'::text, 'delivered'::text, 'dead'::text])))" + ], + [ + "reports", + "reports_pkey", + "PRIMARY KEY (id)" + ], + [ + "reports", + "reports_report_type_check", + "CHECK ((report_type = ANY (ARRAY['nudity'::text, 'malware'::text, 'profanity'::text, 'illegal'::text, 'spam'::text, 'impersonation'::text, 'other'::text])))" + ], + [ + "users", + "users_pkey", + "PRIMARY KEY (pubkey)" + ] + ], + "functions": [ + [ + "admit_user", + "user_pubkey bytea, tos_accepted timestamp without time zone", + "CREATE OR REPLACE FUNCTION public.admit_user(user_pubkey bytea, tos_accepted timestamp without time zone)\n RETURNS integer\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n PERFORM ASSERT_SERIALIZED();\n\n INSERT INTO \"users\" (\"pubkey\", \"is_admitted\", \"tos_accepted_at\", \"created_at\", \"updated_at\")\n VALUES (user_pubkey, true, tos_accepted, now_utc(), now_utc())\n ON CONFLICT (\"pubkey\")\n DO UPDATE SET\n \"is_admitted\" = true,\n \"tos_accepted_at\" = tos_accepted,\n \"updated_at\" = now_utc();\n\n RETURN 0;\nEND;\n$function$\n" + ], + [ + "assert_serialized", + "", + "CREATE OR REPLACE FUNCTION public.assert_serialized()\n RETURNS void\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n IF (select current_setting('transaction_isolation') <> 'serializable') THEN\n RAISE EXCEPTION 'SN_NOT_SERIALIZABLE';\n END IF;\nEND;\n$function$\n" + ], + [ + "charge_user", + "charged_user bytea, amount bigint", + "CREATE OR REPLACE FUNCTION public.charge_user(charged_user bytea, amount bigint)\n RETURNS integer\n LANGUAGE plpgsql\nAS $function$\nDECLARE\n current_balance BIGINT;\nBEGIN\n PERFORM ASSERT_SERIALIZED();\n\n SELECT \"balance\" INTO current_balance FROM \"users\" WHERE \"pubkey\" = charged_user;\n IF current_balance - amount >= 0 THEN\n UPDATE \"users\" SET balance = balance - amount WHERE \"pubkey\" = charged_user;\n RETURN 1;\n ELSE\n RETURN 0;\n END IF;\nEND;\n$function$\n" + ], + [ + "confirm_invoice", + "invoice_id text, amount_received bigint, confirmation_date timestamp without time zone", + "CREATE OR REPLACE FUNCTION public.confirm_invoice(invoice_id text, amount_received bigint, confirmation_date timestamp without time zone)\n RETURNS integer\n LANGUAGE plpgsql\nAS $function$\nDECLARE\n payee BYTEA;\n confirmed_date TIMESTAMP WITHOUT TIME ZONE;\n invoice_unit TEXT;\nBEGIN\n PERFORM ASSERT_SERIALIZED();\n\n SELECT \"pubkey\", \"confirmed_at\", \"unit\" INTO payee, confirmed_date, invoice_unit FROM \"invoices\" WHERE id = invoice_id;\n IF confirmed_date IS NULL THEN\n UPDATE invoices\n SET\n \"confirmed_at\" = confirmation_date,\n \"amount_paid\" = amount_received,\n \"updated_at\" = now_utc()\n WHERE id = invoice_id;\n IF invoice_unit = 'sats' THEN\n UPDATE users SET balance = balance + amount_received * 1000 WHERE \"pubkey\" = payee;\n ELSIF invoice_unit = 'msats' THEN\n UPDATE users SET balance = balance + amount_received WHERE \"pubkey\" = payee;\n ELSIF invoice_unit = 'btc' THEN\n UPDATE users SET balance = balance + amount_received * 100000000 * 1000 WHERE \"pubkey\" = payee;\n END IF;\n END IF;\n RETURN 0;\nEND;\n$function$\n" + ], + [ + "now_utc", + "", + "CREATE OR REPLACE FUNCTION public.now_utc()\n RETURNS timestamp without time zone\n LANGUAGE sql\nAS $function$\n select now() at time zone 'utc';\n$function$\n" + ], + [ + "process_event_tags", + "", + "CREATE OR REPLACE FUNCTION public.process_event_tags()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\n DECLARE\n tag_element jsonb;\n tag_name text;\n tag_value text;\n BEGIN\n DELETE FROM event_tags WHERE event_id = OLD.event_id;\n\n IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN\n FOR tag_element IN SELECT jsonb_array_elements(NEW.event_tags)\n LOOP\n tag_name := trim((tag_element->0)::text, '\"');\n tag_value := trim((tag_element->1)::text, '\"');\n IF length(tag_name) = 1 AND tag_value IS NOT NULL AND tag_value <> '' THEN\n INSERT INTO event_tags (event_id, tag_name, tag_value) VALUES (NEW.event_id, tag_name, tag_value);\n END IF;\n END LOOP;\n END IF;\n\n RETURN NEW;\n END;\n $function$\n" + ] + ], + "triggers": [ + [ + "events", + "insert_event_tags", + "CREATE TRIGGER insert_event_tags AFTER INSERT OR DELETE OR UPDATE ON public.events FOR EACH ROW EXECUTE FUNCTION process_event_tags()" + ] + ], + "extensions": [ + [ + "btree_gin", + "1.3" + ], + [ + "plpgsql", + "1.0" + ], + [ + "uuid-ossp", + "1.1" + ] + ], + "sequences": [ + [ + "reports_id_seq", + "integer", + "1", + "1", + "2147483647", + "1", + "NO" + ] + ] +} diff --git a/crates/nostream-storage/tests/migrations.rs b/crates/nostream-storage/tests/migrations.rs new file mode 100644 index 00000000..51dbadbd --- /dev/null +++ b/crates/nostream-storage/tests/migrations.rs @@ -0,0 +1,346 @@ +//! The schema contract: the 37 sqlx migrations apply cleanly from empty, are idempotent, and +//! produce the same `public` catalogue as the capture's knex migrations. +//! +//! Run with `--ignored` and `PARITY_ADMIN_URL` set (for example +//! `postgres://postgres:postgres@host:port/postgres`); `scripts/storage-parity.sh` does both. +//! The test creates and migrates its own scratch databases, so either database-backed target +//! can run alone. + +// This integration-test target may unwrap: a failed assertion here is a failed test, +// which is the point. The allowance names this file rather than exempting test targets +// workspace-wide, so a new test file still has to ask. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use sqlx::postgres::PgPool; +use sqlx::{Executor, Row}; + +use nostream_storage::postgres::{PostgresBackend, migrate_pool}; + +const RUST_DATABASE: &str = "nostream_parity_rust"; +const CUCUMBER_DATABASE: &str = "nostream_parity_cucumber"; +const EXPECTED_MIGRATIONS: i64 = 37; + +fn admin_url() -> String { + std::env::var("PARITY_ADMIN_URL").expect( + "PARITY_ADMIN_URL must name an administrative database, e.g. \ + postgres://postgres:postgres@host:5432/postgres", + ) +} + +/// Replaces the database segment of a URL, keeping scheme, authority and parameters. +fn with_database(url: &str, database: &str) -> String { + assert!( + database + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'), + "a scratch database name is interpolated as an identifier" + ); + + let (prefix, query) = match url.split_once('?') { + Some((prefix, query)) => (prefix, Some(query)), + None => (url, None), + }; + let base = match prefix.rfind('/') { + Some(index) if index > "postgres://".len() => &prefix[..index], + _ => prefix, + }; + match query { + Some(query) => format!("{base}/{database}?{query}"), + None => format!("{base}/{database}"), + } +} + +async fn recreate_database(admin: &PgPool, database: &str) { + admin + .execute(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {database} WITH (FORCE)" + ))) + .await + .unwrap_or_else(|error| panic!("drop {database}: {error}")); + admin + .execute(sqlx::AssertSqlSafe(format!("CREATE DATABASE {database}"))) + .await + .unwrap_or_else(|error| panic!("create {database}: {error}")); +} + +async fn applied_migrations(pool: &PgPool) -> Vec<(i64, bool)> { + sqlx::query("SELECT version, success FROM _sqlx_migrations ORDER BY version") + .fetch_all(pool) + .await + .expect("the migrations table is readable") + .iter() + .map(|row| (row.get::("version"), row.get::("success"))) + .collect() +} + +async fn catalogue(pool: &PgPool) -> Value { + let excluded = "'knex_migrations','knex_migrations_lock','_sqlx_migrations'"; + + let columns: Vec = sqlx::query(sqlx::AssertSqlSafe(format!( + "SELECT table_name, column_name, ordinal_position::int AS position, data_type, \ + udt_name, is_nullable, column_default, character_maximum_length::int AS max_length, \ + numeric_precision::int AS numeric_precision, numeric_scale::int AS numeric_scale, \ + datetime_precision::int AS datetime_precision \ + FROM information_schema.columns \ + WHERE table_schema = 'public' AND table_name NOT IN ({excluded}) \ + ORDER BY table_name, ordinal_position" + ))) + .fetch_all(pool) + .await + .expect("columns are readable") + .iter() + .map(|row| { + json!([ + row.get::("table_name"), + row.get::("column_name"), + row.get::("position"), + row.get::("data_type"), + row.get::("udt_name"), + row.get::("is_nullable"), + row.get::, _>("column_default"), + row.get::, _>("max_length"), + row.get::, _>("numeric_precision"), + row.get::, _>("numeric_scale"), + row.get::, _>("datetime_precision"), + ]) + }) + .collect(); + + let indexes: Vec = sqlx::query(sqlx::AssertSqlSafe(format!( + "SELECT tablename AS table_name, indexname AS index_name, indexdef AS definition \ + FROM pg_indexes WHERE schemaname = 'public' AND tablename NOT IN ({excluded}) \ + ORDER BY tablename, indexname" + ))) + .fetch_all(pool) + .await + .expect("indexes are readable") + .iter() + .map(|row| { + json!([ + row.get::("table_name"), + row.get::("index_name"), + row.get::("definition"), + ]) + }) + .collect(); + + let constraints: Vec = sqlx::query(sqlx::AssertSqlSafe(format!( + "SELECT c.relname AS table_name, con.conname AS constraint_name, \ + pg_get_constraintdef(con.oid) AS definition \ + FROM pg_constraint con \ + JOIN pg_class c ON c.oid = con.conrelid \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE n.nspname = 'public' AND c.relname NOT IN ({excluded}) \ + ORDER BY c.relname, con.conname" + ))) + .fetch_all(pool) + .await + .expect("constraints are readable") + .iter() + .map(|row| { + json!([ + row.get::("table_name"), + row.get::("constraint_name"), + row.get::("definition"), + ]) + }) + .collect(); + + let functions: Vec = sqlx::query( + "SELECT p.proname AS name, pg_get_function_identity_arguments(p.oid) AS identity_arguments, \ + pg_get_functiondef(p.oid) AS definition \ + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace \ + WHERE n.nspname = 'public' \ + AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e') \ + ORDER BY p.proname, pg_get_function_identity_arguments(p.oid)", + ) + .fetch_all(pool) + .await + .expect("functions are readable") + .iter() + .map(|row| { + json!([ + row.get::("name"), + row.get::("identity_arguments"), + row.get::("definition"), + ]) + }) + .collect(); + + let triggers: Vec = sqlx::query( + "SELECT c.relname AS table_name, t.tgname AS trigger_name, \ + pg_get_triggerdef(t.oid) AS definition \ + FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE NOT t.tgisinternal AND n.nspname = 'public' \ + ORDER BY c.relname, t.tgname", + ) + .fetch_all(pool) + .await + .expect("triggers are readable") + .iter() + .map(|row| { + json!([ + row.get::("table_name"), + row.get::("trigger_name"), + row.get::("definition"), + ]) + }) + .collect(); + + let extensions: Vec = sqlx::query( + "SELECT extname AS name, extversion AS version FROM pg_extension ORDER BY extname", + ) + .fetch_all(pool) + .await + .expect("extensions are readable") + .iter() + .map(|row| { + json!([ + row.get::("name"), + row.get::("version"), + ]) + }) + .collect(); + + let sequences: Vec = sqlx::query( + "SELECT sequence_name AS name, data_type, start_value::text, minimum_value::text, \ + maximum_value::text, increment::text, cycle_option \ + FROM information_schema.sequences \ + WHERE sequence_schema = 'public' AND sequence_name NOT LIKE 'knex_migrations%' \ + ORDER BY sequence_name", + ) + .fetch_all(pool) + .await + .expect("sequences are readable") + .iter() + .map(|row| { + json!([ + row.get::("name"), + row.get::("data_type"), + row.get::("start_value"), + row.get::("minimum_value"), + row.get::("maximum_value"), + row.get::("increment"), + row.get::("cycle_option"), + ]) + }) + .collect(); + + json!({ + "columns": columns, + "indexes": indexes, + "constraints": constraints, + "functions": functions, + "triggers": triggers, + "extensions": extensions, + "sequences": sequences, + }) +} + +fn fixture() -> Value { + let raw = include_str!("fixtures/schema.json"); + serde_json::from_str(raw).expect("tests/fixtures/schema.json is valid JSON") +} + +/// Prints the first differing entry per section, so a drift names the object that changed. +fn assert_same(actual: &Value, expected: &Value) { + for section in [ + "columns", + "indexes", + "constraints", + "functions", + "triggers", + "extensions", + "sequences", + ] { + let actual_section = &actual[section]; + let expected_section = &expected[section]; + if actual_section == expected_section { + continue; + } + let actual_len = actual_section.as_array().map_or(0, Vec::len); + let expected_len = expected_section.as_array().map_or(0, Vec::len); + let mut first_difference = None; + for index in 0..actual_len.max(expected_len) { + let left = actual_section.get(index); + let right = expected_section.get(index); + if left != right { + first_difference = Some((index, left, right)); + break; + } + } + match first_difference { + Some((index, left, right)) => panic!( + "schema drift in `{section}` at entry {index} of {actual_len} (expected \ + {expected_len}):\n sqlx: {left:?}\n knex: {right:?}" + ), + None => panic!("schema drift in `{section}`: {actual_len} vs {expected_len} entries"), + } + } +} + +#[tokio::test] +#[ignore = "needs PARITY_ADMIN_URL; scripts/storage-parity.sh sets it"] +async fn the_migrations_apply_from_empty_are_idempotent_and_match_the_capture_schema() { + let admin = PgPool::connect(&admin_url()) + .await + .expect("the administrative database is reachable"); + recreate_database(&admin, RUST_DATABASE).await; + recreate_database(&admin, CUCUMBER_DATABASE).await; + + let backend = PostgresBackend::connect(&with_database(&admin_url(), RUST_DATABASE)) + .await + .expect("the scratch database is reachable"); + + migrate_pool(backend.master()) + .await + .expect("the migrations apply to an empty database"); + let first = applied_migrations(backend.master()).await; + assert_eq!( + first.len() as i64, + EXPECTED_MIGRATIONS, + "_sqlx_migrations holds one row per migration" + ); + assert!( + first.iter().all(|(_, success)| *success), + "every migration row is successful" + ); + + // A second run changes nothing: same versions, same success flags. + migrate_pool(backend.master()) + .await + .expect("a second migration run is a no-op"); + assert_eq!(applied_migrations(backend.master()).await, first); + + let actual = catalogue(backend.master()).await; + assert_same(&actual, &fixture()); + + // The Cucumber target needs the migrated schema too; this target owns that database. + let cucumber = PgPool::connect(&with_database(&admin_url(), CUCUMBER_DATABASE)) + .await + .expect("the Cucumber database is reachable"); + migrate_pool(&cucumber) + .await + .expect("the migrations apply to the Cucumber database"); + assert_eq!( + applied_migrations(&cucumber).await.len() as i64, + EXPECTED_MIGRATIONS + ); +} + +#[test] +fn a_scratch_url_replaces_only_the_database() { + assert_eq!( + with_database("postgres://u:p@host:5432/postgres", "nostream_parity_rust"), + "postgres://u:p@host:5432/nostream_parity_rust" + ); + assert_eq!( + with_database( + "postgres://u:p@host:5432/postgres?sslmode=disable", + "scratch" + ), + "postgres://u:p@host:5432/scratch?sslmode=disable" + ); +} diff --git a/crates/nostream-storage/tests/parity.rs b/crates/nostream-storage/tests/parity.rs new file mode 100644 index 00000000..f5e214b5 --- /dev/null +++ b/crates/nostream-storage/tests/parity.rs @@ -0,0 +1,1027 @@ +//! Behavioural parity: replays `tests/fixtures/repository-parity.json` through +//! `PostgresBackend` and compares every result with the TypeScript implementation's recording. +//! +//! Run with `--ignored` and `PARITY_ADMIN_URL` set; `scripts/storage-parity.sh` does both. +//! The test creates the database if absent and runs the migrator, so this target and +//! `migrations.rs` have no ordering dependency. + +// This integration-test target may unwrap: a failed assertion here is a failed test, +// which is the point. The allowance names this file rather than exempting test targets +// workspace-wide, so a new test file still has to ask. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::collections::HashMap; +use std::future::poll_fn; +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use serde_json::{Value, json}; +use sqlx::Executor; +use sqlx::postgres::PgPool; + +use nostream_config::Settings; +use nostream_core::{Filter, Kind}; +use nostream_storage::postgres::{PostgresBackend, PostgresTransaction}; +use nostream_storage::repositories::{ + ActionableTarget, CreateInviteCodeOptions, DeliveryLogEntry, DeliveryLogFilters, DvmJob, + DvmJobRepository, DvmJobUpdate, EventPurgeCounts, EventRecord, EventRepository, + EventRetentionOptions, FieldUpdate, InviteCode, InviteCodeRepository, Invoice, + InvoiceRepository, InvoiceStatus, NewDeliveryLogEntry, NewReport, Nip05Verification, + Nip05VerificationRepository, NotificationDeliveryLogRepository, NotificationOutboxRepository, + OutboxMessage, QueryStream, ReconciliationItem, Report, ReportRepository, User, UserPatch, + UserRepository, +}; + +const DATABASE: &str = "nostream_parity_rust"; + +fn fixture() -> Value { + serde_json::from_str(include_str!("fixtures/repository-parity.json")) + .expect("tests/fixtures/repository-parity.json is valid JSON") +} + +fn admin_url() -> String { + std::env::var("PARITY_ADMIN_URL").expect( + "PARITY_ADMIN_URL must name an administrative database, e.g. \ + postgres://postgres:postgres@host:5432/postgres", + ) +} + +fn with_database(url: &str, database: &str) -> String { + let (prefix, query) = match url.split_once('?') { + Some((prefix, query)) => (prefix, Some(query)), + None => (url, None), + }; + let base = match prefix.rfind('/') { + Some(index) if index > "postgres://".len() => &prefix[..index], + _ => prefix, + }; + match query { + Some(query) => format!("{base}/{database}?{query}"), + None => format!("{base}/{database}"), + } +} + +fn iso(value: &DateTime) -> Value { + Value::String(value.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()) +} + +fn optional_iso(value: &Option>) -> Value { + match value { + Some(value) => iso(value), + None => Value::Null, + } +} + +fn parse_iso(value: &Value) -> DateTime { + DateTime::parse_from_rfc3339(value.as_str().expect("an ISO timestamp")) + .expect("a valid ISO timestamp") + .with_timezone(&Utc) +} + +fn text(value: &Value, key: &str) -> String { + value[key] + .as_str() + .unwrap_or_else(|| panic!("{key} is a string")) + .to_owned() +} + +fn integer(value: &Value, key: &str) -> i64 { + value[key] + .as_i64() + .unwrap_or_else(|| panic!("{key} is an integer")) +} + +fn optional_text(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_str).map(str::to_owned) +} + +// --------------------------------------------------------------------------------------- +// Encoders mirroring the generator. +// --------------------------------------------------------------------------------------- + +fn event_row(record: &EventRecord) -> Value { + json!([ + record.event.id.to_string(), + record.event.created_at, + record.expires_at, + record.deleted_at.is_some(), + record.event_deduplication, + ]) +} + +fn event_record(value: &Value) -> EventRecord { + let event: nostream_core::Event = + serde_json::from_value(value["event"].clone()).expect("a wire event"); + let mut record = EventRecord::from_event(&event); + if let Some(address) = optional_text(value, "remote_address") { + record = record.with_remote_address(address.parse().expect("an IP address")); + } + if let Some(expires_at) = value.get("expires_at").and_then(Value::as_i64) { + record = record.with_expires_at(expires_at); + } + if let Some(deduplication) = value.get("deduplication") { + record = record.with_deduplication( + serde_json::from_value(deduplication.clone()).expect("deduplication values"), + ); + } + record +} + +fn invoice_view(invoice: &Invoice) -> Value { + json!({ + "id": invoice.id, + "pubkey": invoice.pubkey.to_string(), + "bolt11": invoice.bolt11, + "amount_requested": invoice.amount_requested, + "amount_paid": invoice.amount_paid, + "unit": invoice.unit.as_str(), + "status": invoice.status.as_str(), + "description": invoice.description, + "confirmed_at": optional_iso(&invoice.confirmed_at), + "expires_at": optional_iso(&invoice.expires_at), + "verify_url": invoice.verify_url, + }) +} + +fn optional_invoice_view(invoice: &Option) -> Value { + match invoice { + Some(invoice) => invoice_view(invoice), + None => Value::Null, + } +} + +fn user_view(user: &User) -> Value { + json!({ + "pubkey": user.pubkey.to_string(), + "is_admitted": user.is_admitted, + "is_vanished": user.is_vanished, + "balance": user.balance, + }) +} + +fn optional_user_view(user: &Option) -> Value { + match user { + Some(user) => user_view(user), + None => Value::Null, + } +} + +fn nip05_view(verification: &Nip05Verification) -> Value { + json!({ + "pubkey": verification.pubkey.to_string(), + "nip05": verification.nip05, + "domain": verification.domain, + "is_verified": verification.is_verified, + "last_verified_at": optional_iso(&verification.last_verified_at), + "last_checked_at": iso(&verification.last_checked_at), + "failure_count": verification.failure_count, + }) +} + +fn invite_view(invite: &InviteCode) -> Value { + json!({ + "code": invite.code, + "created_by": invite.created_by.map(|pubkey| pubkey.to_string()), + "claimed_by": invite.claimed_by.map(|pubkey| pubkey.to_string()), + "expires_at": optional_iso(&invite.expires_at), + "remaining_uses": invite.remaining_uses, + }) +} + +fn dvm_view(job: &DvmJob) -> Value { + json!({ + "id": job.id.to_string(), + "requester_pubkey": job.requester_pubkey.to_string(), + "kind": job.kind.0, + "worker_index": job.worker_index, + "status": job.status.as_str(), + "result_event_id": job.result_event_id.map(|id| id.to_string()), + "error": job.error, + }) +} + +fn optional_dvm_view(job: &Option) -> Value { + match job { + Some(job) => dvm_view(job), + None => Value::Null, + } +} + +fn report_view(report: &Report) -> Value { + json!({ + "id": report.id, + "event_id": report.event_id.to_string(), + "reporter_pubkey": report.reporter_pubkey.to_string(), + "reported_pubkey": report.reported_pubkey.map(|pubkey| pubkey.to_string()), + "reported_event_id": report.reported_event_id.map(|id| id.to_string()), + "report_type": report.report_type.as_str(), + "weight": report.weight, + "actionable": report.actionable, + }) +} + +fn delivery_view(entry: &DeliveryLogEntry) -> Value { + json!({ + "outbox_id": entry.outbox_id, + "event_type": entry.event_type, + "target_id": entry.target_id, + "target_type": entry.target_type.as_str(), + "status": entry.status.as_str(), + "attempt_number": entry.attempt_number, + "error_snippet": entry.error_snippet, + }) +} + +fn outbox_view(message: &OutboxMessage) -> Value { + json!({ + "event_type": message.event_type, + "payload": message.payload, + "status": message.status.as_str(), + "attempt_count": message.attempt_count, + "last_error": message.last_error, + }) +} + +fn sort_by_key(items: &mut [T], key: impl Fn(&T) -> String) { + items.sort_by_key(|item| key(item)); +} + +async fn collect( + mut stream: QueryStream<'_, T>, +) -> Result, nostream_storage::StorageError> { + let mut items = Vec::new(); + while let Some(item) = poll_fn(|context| stream.as_mut().poll_next(context)).await { + items.push(item?); + } + Ok(items) +} + +/// The three repositories a serializable transaction carries; every other op uses the +/// backend's pools. +#[derive(Clone, Copy)] +enum Client<'a> { + Backend(&'a PostgresBackend), + Transaction(&'a PostgresTransaction), +} + +impl<'a> Client<'a> { + fn users(&self) -> &'a dyn UserRepository { + match self { + Self::Backend(backend) => *backend, + Self::Transaction(transaction) => *transaction, + } + } + + fn invoices(&self) -> &'a dyn InvoiceRepository { + match self { + Self::Backend(backend) => *backend, + Self::Transaction(transaction) => *transaction, + } + } + + fn outbox(&self) -> &'a dyn NotificationOutboxRepository { + match self { + Self::Backend(backend) => *backend, + Self::Transaction(transaction) => *transaction, + } + } +} + +fn field_update(value: &Value) -> FieldUpdate { + match value["kind"].as_str().expect("a FieldUpdate kind") { + "absent" => FieldUpdate::Absent, + "clear" => FieldUpdate::Clear, + _ => FieldUpdate::Set(value["value"].clone()), + } +} + +fn parse_event_id(value: &Value) -> nostream_core::EventId { + value + .as_str() + .expect("an event id") + .parse() + .expect("a valid event id") +} + +fn parse_pubkey(value: &Value) -> nostream_core::Pubkey { + value + .as_str() + .expect("a pubkey") + .parse() + .expect("a valid pubkey") +} + +async fn run_op( + backend: &PostgresBackend, + client: Client<'_>, + op: &Value, + refs: &mut HashMap, +) -> Result { + let name = op["op"].as_str().expect("an op name"); + let request = &op["request"]; + + match name { + "events.create" => { + let record = event_record(&request["record"]); + Ok(json!({ "count": EventRepository::create(backend, &record).await? })) + } + "events.create_many" => { + let records: Vec = request["records"] + .as_array() + .expect("records") + .iter() + .map(event_record) + .collect(); + Ok(json!({ "count": EventRepository::create_many(backend, &records).await? })) + } + "events.upsert" => { + let record = event_record(&request["record"]); + Ok(json!({ "count": EventRepository::upsert(backend, &record).await? })) + } + "events.upsert_many" => { + let records: Vec = request["records"] + .as_array() + .expect("records") + .iter() + .map(event_record) + .collect(); + Ok(json!({ "count": EventRepository::upsert_many(backend, &records).await? })) + } + "events.find_by_filters" => { + let filters: Vec = request["filters"] + .as_array() + .expect("filters") + .iter() + .map(|filter| serde_json::from_value(filter.clone()).expect("a filter")) + .collect(); + if filters.is_empty() { + let failed = EventRepository::find_by_filters(backend, &[]) + .await + .is_err(); + return Ok(json!({ "error": failed })); + } + let rows = collect(EventRepository::find_by_filters(backend, &filters).await?).await?; + Ok(json!({ "rows": rows.iter().map(event_row).collect::>() })) + } + "events.count_by_filters" => { + let filters: Vec = request["filters"] + .as_array() + .expect("filters") + .iter() + .map(|filter| serde_json::from_value(filter.clone()).expect("a filter")) + .collect(); + Ok(json!({ "count": EventRepository::count_by_filters(backend, &filters).await? })) + } + "events.find_reconciliation_items" => { + let filter: Filter = + serde_json::from_value(request["filter"].clone()).expect("a filter"); + let items = + collect(EventRepository::find_reconciliation_items(backend, &filter).await?) + .await?; + Ok(json!({ + "items": items.iter().map(|ReconciliationItem { created_at, event_id }| { + json!([created_at, event_id.to_string()]) + }).collect::>() + })) + } + "events.delete_by_pubkey_and_ids" => { + let pubkey = parse_pubkey(&request["pubkey"]); + let ids: Vec = request["ids"] + .as_array() + .expect("ids") + .iter() + .map(parse_event_id) + .collect(); + Ok( + json!({ "count": EventRepository::delete_by_pubkey_and_ids(backend, &pubkey, &ids).await? }), + ) + } + "events.delete_by_pubkey_except_kinds" => { + let pubkey = parse_pubkey(&request["pubkey"]); + let kinds: Vec = request["kinds"] + .as_array() + .expect("kinds") + .iter() + .map(|kind| Kind(kind.as_u64().expect("a kind"))) + .collect(); + Ok( + json!({ "count": EventRepository::delete_by_pubkey_except_kinds(backend, &pubkey, &kinds).await? }), + ) + } + "events.has_active_request_to_vanish" => { + let pubkey = parse_pubkey(&request["pubkey"]); + Ok( + json!({ "value": EventRepository::has_active_request_to_vanish(backend, &pubkey).await? }), + ) + } + "events.delete_expired_and_retained" => { + let options = EventRetentionOptions { + max_days: request["options"].get("max_days").and_then(Value::as_i64), + kind_whitelist: request["options"] + .get("kind_whitelist") + .and_then(Value::as_array) + .map(|selectors| { + selectors + .iter() + .map(|selector| match selector { + Value::Array(range) => nostream_core::KindSelector::Range([ + Kind(range[0].as_u64().expect("a range start")), + Kind(range[1].as_u64().expect("a range end")), + ]), + other => nostream_core::KindSelector::Single(Kind( + other.as_u64().expect("a kind"), + )), + }) + .collect() + }), + pubkey_whitelist: request["options"] + .get("pubkey_whitelist") + .and_then(Value::as_array) + .map(|pubkeys| { + pubkeys + .iter() + .map(|pubkey| nostream_core::SettingsPubkey(text_of(pubkey))) + .collect() + }), + }; + let EventPurgeCounts { + deleted, + expired, + retained, + } = EventRepository::delete_expired_and_retained(backend, &options).await?; + Ok(json!({ "deleted": deleted, "expired": expired, "retained": retained })) + } + "user.find_by_pubkey" => { + let pubkey = parse_pubkey(&request["pubkey"]); + Ok( + json!({ "user": optional_user_view(&client.users().find_by_pubkey(&pubkey).await?) }), + ) + } + "user.upsert" => { + let patch = &request["patch"]; + let tos_accepted_at = match field_update(&patch["tos_accepted_at"]) { + FieldUpdate::Absent => FieldUpdate::Absent, + FieldUpdate::Clear => FieldUpdate::Clear, + FieldUpdate::Set(value) => FieldUpdate::Set(parse_iso(&value)), + }; + let user = UserPatch { + pubkey: parse_pubkey(&patch["pubkey"]), + is_admitted: patch["is_admitted"].as_bool().expect("is_admitted"), + is_vanished: patch["is_vanished"].as_bool().expect("is_vanished"), + tos_accepted_at, + }; + Ok(json!({ "count": client.users().upsert(&user).await? })) + } + "user.get_balance_by_pubkey" => { + let pubkey = parse_pubkey(&request["pubkey"]); + Ok(json!({ "balance": client.users().get_balance_by_pubkey(&pubkey).await? })) + } + "user.is_vanished" => { + let pubkey = parse_pubkey(&request["pubkey"]); + Ok(json!({ "value": client.users().is_vanished(&pubkey).await? })) + } + "user.set_vanished" => { + let pubkey = parse_pubkey(&request["pubkey"]); + let vanished = request["vanished"].as_bool().expect("vanished"); + Ok(json!({ "count": client.users().set_vanished(&pubkey, vanished).await? })) + } + "user.admit_user" => { + let pubkey = parse_pubkey(&request["pubkey"]); + client + .users() + .admit_user(&pubkey, parse_iso(&request["admitted_at"])) + .await?; + Ok(json!({ "ok": true })) + } + "user.revoke_admission" => { + let pubkey = parse_pubkey(&request["pubkey"]); + Ok(json!({ "count": client.users().revoke_admission(&pubkey).await? })) + } + "invoice.find_by_id" => { + let id = text(request, "id"); + Ok( + json!({ "invoice": optional_invoice_view(&client.invoices().find_by_id(&id).await?) }), + ) + } + "invoice.upsert" => { + let value = &request["invoice"]; + let invoice = Invoice { + id: text(value, "id"), + pubkey: parse_pubkey(&value["pubkey"]), + bolt11: text(value, "bolt11"), + amount_requested: integer(value, "amount_requested"), + amount_paid: value.get("amount_paid").and_then(Value::as_i64), + unit: value["unit"] + .as_str() + .expect("a unit") + .parse() + .expect("a unit"), + status: value["status"] + .as_str() + .expect("a status") + .parse() + .expect("a status"), + description: text(value, "description"), + confirmed_at: value + .get("confirmed_at") + .filter(|value| !value.is_null()) + .map(parse_iso), + expires_at: value + .get("expires_at") + .filter(|value| !value.is_null()) + .map(parse_iso), + updated_at: parse_iso(&value["created_at"]), + created_at: parse_iso(&value["created_at"]), + verify_url: optional_text(value, "verify_url"), + }; + Ok(json!({ "count": client.invoices().upsert(&invoice).await? })) + } + "invoice.update_status" => { + let id = text(request, "id"); + let status: InvoiceStatus = request["status"] + .as_str() + .expect("a status") + .parse() + .expect("a status"); + Ok(json!({ + "invoice": optional_invoice_view( + &client.invoices().update_status(&id, status).await? + ) + })) + } + "invoice.confirm_invoice" => { + let id = text(request, "invoice_id"); + client + .invoices() + .confirm_invoice( + &id, + integer(request, "amount_received"), + parse_iso(&request["confirmed_at"]), + ) + .await?; + Ok(json!({ "ok": true })) + } + "invoice.find_pending_invoices" => { + let offset = integer(request, "offset") as u64; + let limit = integer(request, "limit") as u64; + let invoices = client + .invoices() + .find_pending_invoices(offset, limit) + .await?; + Ok(json!({ + "invoices": invoices.iter().map(invoice_view).collect::>() + })) + } + "nip05.find_by_pubkey" => { + let pubkey = parse_pubkey(&request["pubkey"]); + let verification = + Nip05VerificationRepository::find_by_pubkey(backend, &pubkey).await?; + Ok(json!({ + "verification": verification.as_ref().map(nip05_view).unwrap_or(Value::Null) + })) + } + "nip05.upsert" => { + let value = &request["verification"]; + let verification = Nip05Verification { + pubkey: parse_pubkey(&value["pubkey"]), + nip05: text(value, "nip05"), + domain: text(value, "domain"), + is_verified: value["is_verified"].as_bool().expect("is_verified"), + last_verified_at: value + .get("last_verified_at") + .filter(|value| !value.is_null()) + .map(parse_iso), + last_checked_at: parse_iso(&value["last_checked_at"]), + failure_count: integer(value, "failure_count") as i32, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + Ok( + json!({ "count": Nip05VerificationRepository::upsert(backend, &verification).await? }), + ) + } + "nip05.find_pending_verifications" => { + let verifications = backend + .find_pending_verifications( + integer(request, "update_frequency_ms"), + integer(request, "max_failures") as i32, + integer(request, "limit") as u64, + ) + .await?; + Ok(json!({ + "verifications": verifications.iter().map(nip05_view).collect::>() + })) + } + "nip05.delete_by_pubkey" => { + let pubkey = parse_pubkey(&request["pubkey"]); + Ok( + json!({ "count": Nip05VerificationRepository::delete_by_pubkey(backend, &pubkey).await? }), + ) + } + "invite.create" => { + let options = CreateInviteCodeOptions { + expires_at: request["options"] + .get("expires_at") + .filter(|value| !value.is_null()) + .map(parse_iso), + remaining_uses: match field_update( + request["options"] + .get("remaining_uses") + .unwrap_or(&json!({"kind": "absent"})), + ) { + FieldUpdate::Absent => FieldUpdate::Absent, + FieldUpdate::Clear => FieldUpdate::Clear, + FieldUpdate::Set(value) => { + FieldUpdate::Set(value.as_i64().expect("remaining uses") as i32) + } + }, + created_by: optional_text(&request["options"], "created_by") + .map(|pubkey| pubkey.parse().expect("a pubkey")), + }; + let code = text(request, "code"); + let invite = InviteCodeRepository::create(backend, &code, &options).await?; + Ok(json!({ "invite": invite_view(&invite) })) + } + "invite.find_by_code" => { + let code = text(request, "code"); + let invite = InviteCodeRepository::find_by_code(backend, &code).await?; + Ok(json!({ "invite": invite.as_ref().map(invite_view).unwrap_or(Value::Null) })) + } + "invite.claim_code" => { + let code = text(request, "code"); + let pubkey = parse_pubkey(&request["pubkey"]); + Ok(json!({ "value": InviteCodeRepository::claim_code(backend, &code, &pubkey).await? })) + } + "invite.find_active_codes" => { + let mut invites = + InviteCodeRepository::find_active_codes(backend, integer(request, "limit") as u64) + .await?; + sort_by_key(&mut invites, |invite| invite.code.clone()); + Ok(json!({ "invites": invites.iter().map(invite_view).collect::>() })) + } + "invite.delete_expired_codes" => { + Ok(json!({ "count": InviteCodeRepository::delete_expired_codes(backend).await? })) + } + "dvm.create" => { + let id = parse_event_id(&request["id"]); + let requester = parse_pubkey(&request["requester"]); + let kind = Kind(request["kind"].as_u64().expect("a kind")); + let job = DvmJobRepository::create(backend, &id, &requester, kind).await?; + Ok(json!({ "job": dvm_view(&job) })) + } + "dvm.find_by_id" => { + let id = parse_event_id(&request["id"]); + Ok( + json!({ "job": optional_dvm_view(&DvmJobRepository::find_by_id(backend, &id).await?) }), + ) + } + "dvm.assign_worker" => { + let id = parse_event_id(&request["id"]); + let worker_index = integer(request, "worker_index") as i32; + Ok( + json!({ "value": DvmJobRepository::assign_worker(backend, &id, worker_index).await? }), + ) + } + "dvm.update_status" => { + let update = &request["update"]; + let dvm_update = DvmJobUpdate { + id: parse_event_id(&update["id"]), + status: update["status"] + .as_str() + .expect("a status") + .parse() + .expect("a status"), + result_event_id: match field_update(&update["result_event_id"]) { + FieldUpdate::Absent => FieldUpdate::Absent, + FieldUpdate::Clear => FieldUpdate::Clear, + FieldUpdate::Set(value) => FieldUpdate::Set(parse_event_id(&value)), + }, + error: match field_update(&update["error"]) { + FieldUpdate::Absent => FieldUpdate::Absent, + FieldUpdate::Clear => FieldUpdate::Clear, + FieldUpdate::Set(value) => { + FieldUpdate::Set(value.as_str().expect("an error").to_owned()) + } + }, + }; + Ok( + json!({ "job": optional_dvm_view(&DvmJobRepository::update_status(backend, &dvm_update).await?) }), + ) + } + "dvm.find_pending_jobs" => { + let kinds: Option> = + request.get("kinds").and_then(Value::as_array).map(|kinds| { + kinds + .iter() + .map(|kind| Kind(kind.as_u64().expect("a kind"))) + .collect() + }); + let mut jobs = DvmJobRepository::find_pending_jobs( + backend, + integer(request, "limit") as u64, + kinds.as_deref(), + ) + .await?; + sort_by_key(&mut jobs, |job| job.id.to_string()); + Ok(json!({ "jobs": jobs.iter().map(dvm_view).collect::>() })) + } + "report.create" => { + let report = new_report(&request["report"]); + let created = ReportRepository::create(backend, &report).await?; + Ok(json!({ "report": report_view(&created) })) + } + "report.create_many" => { + let reports: Vec = request["reports"] + .as_array() + .expect("reports") + .iter() + .map(new_report) + .collect(); + let mut created = ReportRepository::create_many(backend, &reports).await?; + sort_by_key(&mut created, |report| report.id.to_string()); + Ok(json!({ "reports": created.iter().map(report_view).collect::>() })) + } + "report.find_by_event_id" => { + let event_id = parse_event_id(&request["event_id"]); + let mut reports = ReportRepository::find_by_event_id(backend, &event_id).await?; + sort_by_key(&mut reports, |report| report.id.to_string()); + Ok(json!({ "reports": reports.iter().map(report_view).collect::>() })) + } + "report.find_actionable" => { + let mut reports = + ReportRepository::find_actionable(backend, integer(request, "limit") as u64) + .await?; + sort_by_key(&mut reports, |report| report.id.to_string()); + Ok(json!({ "reports": reports.iter().map(report_view).collect::>() })) + } + "report.find_actionable_targets" => { + let mut targets: Vec = + ReportRepository::find_actionable_targets(backend).await?; + sort_by_key(&mut targets, |target| { + format!( + "{}/{}", + target + .reported_pubkey + .map(|pubkey| pubkey.to_string()) + .unwrap_or_else(|| "null".to_owned()), + target + .reported_event_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "null".to_owned()) + ) + }); + Ok(json!({ + "targets": targets.iter().map(|target| json!({ + "reported_pubkey": target.reported_pubkey.map(|pubkey| pubkey.to_string()), + "reported_event_id": target + .reported_event_id + .map(|id| id.to_string()), + })).collect::>() + })) + } + "delivery_log.append" => { + let entry = &request["entry"]; + let new_entry = NewDeliveryLogEntry { + outbox_id: optional_text(entry, "outbox_id"), + event_type: text(entry, "event_type"), + target_id: text(entry, "target_id"), + target_type: entry["target_type"] + .as_str() + .expect("a target type") + .parse() + .expect("a target type"), + status: entry["status"] + .as_str() + .expect("a status") + .parse() + .expect("a status"), + attempt_number: integer(entry, "attempt_number") as i32, + error_snippet: optional_text(entry, "error_snippet"), + }; + NotificationDeliveryLogRepository::append(backend, &new_entry).await?; + Ok(json!({ "ok": true })) + } + "delivery_log.find_recent" => { + let filters = DeliveryLogFilters { + status: request["filters"] + .get("status") + .and_then(Value::as_str) + .map(|status| status.parse().expect("a status")), + event_type: request["filters"] + .get("event_type") + .and_then(Value::as_str) + .map(str::to_owned), + }; + let mut entries = NotificationDeliveryLogRepository::find_recent( + backend, + integer(request, "limit") as u64, + &filters, + ) + .await?; + sort_by_key(&mut entries, |entry| { + format!("{}/{:02}", entry.target_id, entry.attempt_number) + }); + Ok(json!({ "entries": entries.iter().map(delivery_view).collect::>() })) + } + "delivery_log.find_successful_target_ids" => { + let outbox_id = text(request, "outbox_id"); + let mut ids = + NotificationDeliveryLogRepository::find_successful_target_ids(backend, &outbox_id) + .await?; + ids.sort(); + Ok(json!({ "target_ids": ids })) + } + "delivery_log.delete_older_than" => { + let cutoff = parse_iso(&request["cutoff"]); + Ok( + json!({ "count": NotificationDeliveryLogRepository::delete_older_than(backend, cutoff).await? }), + ) + } + "outbox.enqueue" => { + let event_type = text(request, "event_type"); + let message = + NotificationOutboxRepository::enqueue(backend, &event_type, &request["payload"]) + .await?; + refs.insert(message.event_type.clone(), message.id.clone()); + Ok(json!({ "message": outbox_view(&message) })) + } + "outbox.claim_batch" => { + let mut messages = client + .outbox() + .claim_batch(integer(request, "limit") as u64) + .await?; + sort_by_key(&mut messages, |message| message.event_type.clone()); + Ok(json!({ "messages": messages.iter().map(outbox_view).collect::>() })) + } + "outbox.mark_delivered" => { + let id = resolve_ref(refs, request)?; + client.outbox().mark_delivered(&id).await?; + Ok(json!({ "ok": true })) + } + "outbox.mark_failed" => { + let id = resolve_ref(refs, request)?; + client + .outbox() + .mark_failed( + &id, + &text(request, "error"), + integer(request, "attempt_count") as i32, + integer(request, "max_attempts") as i32, + integer(request, "base_delay_ms"), + ) + .await?; + Ok(json!({ "ok": true })) + } + "outbox.delete_terminal_older_than" => { + let cutoff = parse_iso(&request["cutoff"]); + Ok(json!({ "count": client.outbox().delete_terminal_older_than(cutoff).await? })) + } + other => panic!("unknown op {other}"), + } +} + +fn resolve_ref( + refs: &HashMap, + request: &Value, +) -> Result { + let event_type = text(request, "enqueue_event_type"); + refs.get(&event_type) + .cloned() + .ok_or(nostream_storage::StorageError::InvalidArgument { + message: "no enqueued row to address", + }) +} + +fn text_of(value: &Value) -> String { + value.as_str().expect("a string").to_owned() +} + +fn new_report(value: &Value) -> NewReport { + NewReport { + event_id: parse_event_id(&value["event_id"]), + reporter_pubkey: parse_pubkey(&value["reporter_pubkey"]), + reported_pubkey: optional_text(value, "reported_pubkey") + .map(|pubkey| pubkey.parse().expect("a pubkey")), + reported_event_id: optional_text(value, "reported_event_id") + .map(|id| id.parse().expect("an event id")), + report_type: value["report_type"] + .as_str() + .expect("a report type") + .parse() + .expect("a report type"), + weight: value["weight"].as_f64().expect("a weight") as f32, + actionable: value["actionable"].as_bool().expect("actionable"), + } +} + +async fn run_transaction(backend: &PostgresBackend, op: &Value) -> Value { + let ops = op["request"]["ops"].as_array().expect("transaction ops"); + let transaction = backend.begin().await.expect("a serializable transaction"); + let mut results = Vec::new(); + let mut failed_at = None; + + for (index, nested) in ops.iter().enumerate() { + match run_op( + backend, + Client::Transaction(&transaction), + nested, + &mut HashMap::new(), + ) + .await + { + Ok(result) => results.push(result), + Err(_) => { + failed_at = Some(index); + break; + } + } + } + + match failed_at { + None => { + transaction.commit().await.expect("the transaction commits"); + json!({ "failed": false, "results": results }) + } + Some(index) => { + drop(transaction); + json!({ "failed": true, "at": index }) + } + } +} + +fn settings_from_fixture(fixture: &Value) -> Settings { + let raw = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/default-settings.yaml" + )) + .expect("the shipped defaults are readable"); + let mut document: serde_yaml::Value = + serde_yaml::from_str(&raw).expect("the shipped defaults are valid YAML"); + if let Some(overrides) = fixture["settings"]["overrides"].as_object() { + for (path, value) in overrides { + let yaml = serde_yaml::to_value(value).expect("a YAML value"); + document = nostream_config::path::set_by_path(&document, path, yaml) + .expect("the override path is writable"); + } + } + serde_yaml::from_value(document).expect("the settings fit the typed schema") +} + +#[tokio::test] +#[ignore = "needs PARITY_ADMIN_URL; scripts/storage-parity.sh sets it"] +async fn the_typescript_script_replays_with_an_empty_diff() { + let fixture = fixture(); + let admin_url = admin_url(); + let admin = PgPool::connect(&admin_url) + .await + .expect("the administrative database is reachable"); + admin + .execute(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {DATABASE} WITH (FORCE)" + ))) + .await + .expect("the parity database drops"); + admin + .execute(sqlx::AssertSqlSafe(format!("CREATE DATABASE {DATABASE}"))) + .await + .expect("the parity database is created"); + drop(admin); + + let backend = PostgresBackend::connect(&with_database(&admin_url, DATABASE)) + .await + .expect("the parity database is reachable") + .with_settings(Arc::new(settings_from_fixture(&fixture))); + backend.migrate().await.expect("the schema migrates"); + + let corpus: Vec = fixture["corpus"] + .as_array() + .expect("corpus") + .iter() + .map(event_record) + .collect(); + EventRepository::create_many(&backend, &corpus) + .await + .expect("the corpus inserts"); + + let mut refs = HashMap::new(); + for (index, op) in fixture["script"] + .as_array() + .expect("script") + .iter() + .enumerate() + { + let name = op["op"].as_str().expect("an op name"); + let actual = if name == "transaction" { + run_transaction(&backend, op).await + } else { + run_op(&backend, Client::Backend(&backend), op, &mut refs) + .await + .unwrap_or_else(|error| panic!("op {index} ({name}) failed: {error}")) + }; + let expected = &op["result"]["compare"]; + assert_eq!( + &actual, expected, + "op {index} ({name}) differs from the TypeScript capture" + ); + } +} From 8d1ec5030cd4691bb28fa2d0365a2faa9b35500a Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 09:31:06 -0400 Subject: [PATCH 03/16] ci(storage): postgres service and parity step, ADR 0003 and changeset --- .changeset/storage-lmdb-postgres.md | 2 + .github/workflows/checks.yml | 19 +++ CLAUDE.md | 13 ++ .../0003-storage-seam-and-postgres.md | 129 ++++++++++++++++++ scripts/storage-parity.sh | 87 ++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 .changeset/storage-lmdb-postgres.md create mode 100644 docs/rewrite/decisions/0003-storage-seam-and-postgres.md create mode 100755 scripts/storage-parity.sh diff --git a/.changeset/storage-lmdb-postgres.md b/.changeset/storage-lmdb-postgres.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/storage-lmdb-postgres.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 151bf240..a54208b5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -223,6 +223,19 @@ jobs: runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.rust == 'true' + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 permissions: contents: read steps: @@ -251,6 +264,12 @@ jobs: -D clippy::todo -D clippy::unimplemented -D clippy::panic - name: Tests run: cargo test --workspace --all-features + # The two database-backed storage targets create and migrate their own scratch + # databases; the Cucumber half of the harness stays in scripts/storage-parity.sh. + - name: Storage parity against Postgres + env: + PARITY_ADMIN_URL: postgres://postgres:postgres@localhost:5432/postgres + run: cargo test -p nostream-storage --test migrations --test parity -- --ignored --test-threads=1 - name: Release build run: cargo build --workspace --all-features --release diff --git a/CLAUDE.md b/CLAUDE.md index d6bed00e..b57f8b32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,19 @@ cargo test --workspace --all-features cargo build --workspace --all-features --release ``` +CI additionally runs the two database-backed storage tests ("Storage parity against Postgres") +against a `postgres:16-alpine` service, and they are `#[ignore]`d so a checkout without a database still passes +`cargo test --workspace`: + +``` +cargo test -p nostream-storage --test migrations --test parity -- --ignored --test-threads=1 +``` + +`PARITY_ADMIN_URL` names the administrative database (CI sets +`postgres://postgres:postgres@localhost:5432/postgres`); the targets create, migrate and +drop their own scratch databases. `scripts/storage-parity.sh` runs both plus the +TypeScript Cucumber suite against the sqlx-migrated schema — the complete local oracle. + - **`--workspace`, never a single crate.** A crate-local `cargo test -p` proves nothing about a workspace lint that another member trips. - **`--all-features` is not optional.** `nostreamd`'s `otlp` feature gates three diff --git a/docs/rewrite/decisions/0003-storage-seam-and-postgres.md b/docs/rewrite/decisions/0003-storage-seam-and-postgres.md new file mode 100644 index 00000000..809c7467 --- /dev/null +++ b/docs/rewrite/decisions/0003-storage-seam-and-postgres.md @@ -0,0 +1,129 @@ +# ADR 0003: The storage seam and the Postgres backend (job 007) + +- **Status:** accepted (job 007, Phase 2 slice 1) +- **Deciders:** the rewrite team, on the evidence in job 007's recon and spec +- **Date:** 2026-10-19 + +## Context + +`nostream-storage` declared nine repository traits with 27 representative methods and a +Postgres backend whose `connect` returned `NotImplemented`. The relay needs all 51 +methods the capture's interfaces declare (`src/@types/repositories.ts:40-146`), a +streaming subscription path, a stored-row type that carries the five storage-only +columns, and a real Postgres implementation — because Postgres is the differential +oracle every later backend (LMDB, NIP-50, the migration tool) is judged against. + +This ADR records the decisions that shape the rest of the phase. Each heading is +greppable. + +## Scope slice — surfaces 1 and 2 plus the harness + +The 192 TypeScript repository unit tests assert **SQL text** (83 `.toString()` +assertions), so they cannot be re-expressed against a second backend. The +backend-agnostic oracle is therefore a differential harness: one corpus, one recorded +51-method script, replayed through the Postgres implementation. The LMDB store, NIP-50 +on LMDB, `nostream-cli migrate pg-to-lmdb` and the benchmark rig are follow-ups, each +with its own recon and expected cost. This job hands them `EventRecord`, `QueryStream`, +the complete trait set and the fixture as the reference. + +## EventRecord — the stored row is not the wire event + +`DBEvent` carries `event_deduplication`, `first_seen`, `deleted_at`, `expires_at` and +insert-time `remote_address`, none of which are wire data. Putting them on +`nostream_core::Event` would serialize them into every frame, leak them into +`PartialEq` (two wire-identical events comparing unequal after a retention pass) and +force every core construction site to name them; `#[serde(skip)]` would fix only the +first. `EventRecord` wraps the seven-field event and adds the five columns, so the +canonical hash, `serialize_event`, the filter matcher and `Event::eq` are untouched and +`deny_unknown_fields` still rejects a client-supplied `first_seen`. The wrapper is +`Serialize + Deserialize` because the LMDB backend will store it. + +## QueryStream — a borrowing stream, not a channel and not a Vec + +`find_by_filters` and `find_reconciliation_items` return +`QueryStream<'a, T>` = `Pin> + Send + +'a>>`, borrowing the store for the stream's life. Postgres holds a portal and a pooled +connection; an LMDB backend will hold a read transaction. A channel would force a +background task and a buffer policy LMDB does not need; a `Vec` would materialise a +10M-row NIP-77 reconciliation. + +## sqlx runtime queries — no compile-time macros + +Queries use `sqlx::query` with explicit `Row::try_get` conversions, never +`query!`/`query_as!`, and no `.sqlx` cache is committed. CI has no Postgres service for +the macros (they check against a live database at build time), and the event query is +dynamically composed — zero to many filters, each with hex groups, tag `EXISTS` clauses +and optional search — which the macros cannot express. The migrator is embedded with +`sqlx::migrate!("./migrations")`, which needs no database at build time. + +## Transactions — serializable, interior-mutable, three repositories + +`PostgresBackend::begin()` opens `BEGIN ISOLATION LEVEL SERIALIZABLE`, the port of +`src/database/transaction.ts:12`. `PostgresTransaction` implements exactly +`UserRepository`, `InvoiceRepository` and `NotificationOutboxRepository` — the three the +payments service spans — and not the streaming traits, because a stream would hold the +connection for its whole life. Because sqlx implements `Executor` for +`&mut PgConnection` while the trait methods take `&self`, the transaction holds a +`tokio::sync::Mutex>` and takes the ambient connection per call; +committing consumes it, dropping rolls back. `confirm_invoice` and `admit_user` call +the schema functions, which begin with `ASSERT_SERIALIZED()`; a standalone call opens +its own serializable transaction. + +## UNION, not UNION ALL + +The capture composes multi-filter queries as `(q1) union (q2) order by …` +(`test/unit/repositories/event-repository.spec.ts:465`), with each branch wrapped so it +keeps its own `ORDER BY`/`LIMIT` and the first filter's order applied to the union. +Recon's "UNION ALL" reading is corrected by that measurement. A mixed search/non-search +filter list fails at the server in both implementations (differing column counts); the +rewrite reproduces the failure rather than papering over it. + +## Error propagation — a deliberate divergence + +The capture swallows every event-insert error into `0` +(`src/repositories/event-repository.ts:347-372`, `:411-449`). The Rust backend returns +`Ok(0)` only for a conflict the statement itself ignores (`ON CONFLICT DO NOTHING`) and +`Err` for every other database error: a swallowed error is the failure mode the crate's +stub discipline exists to prevent. The parity fixture therefore encodes no +error-swallowing case; the fixture's undo group uses an overflow the server rejects on +both sides. + +## Migrations — 37 forward-only files, and three non-transactional knex files + +Each knex migration becomes one `.sql` file whose version is the knex timestamp with the +underscore removed (the shared `20230107_230900` becomes `…230900` and `…230901`). There +are no down migrations: two knex migrations are irreversible data migrations and the +rewrite never rolls back. The `event_tags` backfill becomes one set-based `INSERT … +SELECT` producing the same rows as the capture's per-event loop. + +The capture has **three** `exports.config = { transaction: false }` files, not two: +`20260420_120000`, `20260427_000000` and `20260926_120000`. sqlx sends a migration file +as a single statement batch, and PostgreSQL refuses `CREATE INDEX CONCURRENTLY` in a +multi-statement batch even outside an explicit transaction. The resolution preserves +the index definitions exactly: the NIP-50 file (one statement) keeps `CONCURRENTLY` +under `-- no-transaction`; the hot-path file keeps `-- no-transaction` but builds its +three indexes without `CONCURRENTLY`; the actionable-report file is transactional with +plain `CREATE INDEX`. The harness compares the whole `public` catalogue against the +knex schema, so a different index definition fails. + +## Session time zone — UTC on every connection + +Every connection sets `timezone=UTC`, so `timestamp` columns round-trip +deterministically and the capture's server-default assumption holds. The capture's +generator must run with `TZ=UTC` as well: `node-postgres` parses a `timestamp without +time zone` column in the process's local zone, so any other host zone records shifted +`confirmed_at`/`expires_at` values that no correct backend would match. + +## The harness's limits + +`scripts/storage-parity.sh` is the oracle: the 37 migrations apply from empty and match +the knex catalogue; 110 recorded operations covering all 51 methods replay with an empty +diff; and the unmodified TypeScript relay's 118 Cucumber scenarios run against the +sqlx-migrated database. What it does not judge: the 192 SQL-text unit tests (they are +Postgres-implementation assertions), read-replica *behaviour* (only URL selection is +tested), LMDB, and NIP-50 ranking beyond "same hits, same deterministic order" — the +fixture compares `ts_rank` output from the same server, so a different ranker in a later +backend is a recorded divergence, not a bug. Cucumber stays out of CI: the TS +integration job has no Rust toolchain, so the Rust half +(`tests/migrations.rs`, `tests/parity.rs`) runs there against a `postgres` service and +the script remains the local, complete run. diff --git a/scripts/storage-parity.sh b/scripts/storage-parity.sh new file mode 100755 index 00000000..9aea44be --- /dev/null +++ b/scripts/storage-parity.sh @@ -0,0 +1,87 @@ +#!/bin/sh +# The storage-parity oracle: the 37 sqlx migrations, the differential 51-method replay and the +# unmodified TypeScript relay's Cucumber suite, all against databases this script creates. +# +# Usage: scripts/storage-parity.sh +# +# Environment: +# PARITY_PG_PORT published Postgres port (default 18776) +# PARITY_REDIS_PORT published Redis port (default 18777) +# PARITY_HOST host the published ports are reached on (default 100.106.134.5) +# NODE_PATH node_modules with cucumber-js and ts-node (default the primary checkout) +# CUCUMBER_BIN cucumber-js binary (default $NODE_PATH/.bin/cucumber-js) +# +# Both containers carry the `org.testcontainers=true` label the sandbox's Docker policy +# requires, publish their port instead of bind-mounting (the policy denies a /workspace +# source), and are removed by the exit trap. Nothing is written outside the worktree. +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +root=$(CDPATH= cd -- "$script_dir/.." && pwd) +cd "$root" + +PARITY_PG_PORT=${PARITY_PG_PORT:-18776} +PARITY_REDIS_PORT=${PARITY_REDIS_PORT:-18777} +PARITY_HOST=${PARITY_HOST:-100.106.134.5} +NODE_ROOT=${NODE_PATH:-/workspace/projects/nostream/node_modules} +CUCUMBER_BIN=${CUCUMBER_BIN:-$NODE_ROOT/.bin/cucumber-js} + +pg_container="nostream-parity-pg-$$" +redis_container="nostream-parity-redis-$$" +admin_url="postgres://postgres:postgres@$PARITY_HOST:$PARITY_PG_PORT/postgres" + +cleanup() { + docker rm -f "$pg_container" "$redis_container" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT HUP TERM + +fail() { + echo "storage-parity: $1" >&2 + exit 1 +} + +wait_for_log() { + container=$1 + pattern=$2 + attempts=0 + until docker logs "$container" 2>&1 | grep -q "$pattern"; do + attempts=$((attempts + 1)) + [ "$attempts" -ge 90 ] && fail "$container did not report: $pattern" + sleep 1 + done +} + +echo "== containers" +docker rm -f "$pg_container" "$redis_container" >/dev/null 2>&1 || true +docker create --name "$pg_container" --label org.testcontainers=true \ + -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres \ + -p "$PARITY_PG_PORT:5432" postgres:16-alpine >/dev/null +docker create --name "$redis_container" --label org.testcontainers=true \ + -p "$PARITY_REDIS_PORT:6379" redis:7-alpine >/dev/null +docker start "$pg_container" "$redis_container" >/dev/null +wait_for_log "$pg_container" "database system is ready to accept connections" +wait_for_log "$redis_container" "Ready to accept connections" +echo " postgres and redis are accepting connections on $PARITY_HOST" + +echo "== migrations and schema parity" +./scripts/rust.sh env PARITY_ADMIN_URL="$admin_url" \ + cargo test -p nostream-storage --test migrations -- --ignored --test-threads=1 \ + || fail "the migrations target failed" + +echo "== behavioural parity" +./scripts/rust.sh env PARITY_ADMIN_URL="$admin_url" \ + cargo test -p nostream-storage --test parity -- --ignored --test-threads=1 \ + || fail "the parity target failed" + +echo "== cucumber on the sqlx-migrated schema" +[ -x "$CUCUMBER_BIN" ] || fail "cucumber-js not found at $CUCUMBER_BIN (set CUCUMBER_BIN or NODE_PATH)" +mkdir -p .test-reports/integration +DB_URI="postgres://postgres:postgres@$PARITY_HOST:$PARITY_PG_PORT/nostream_parity_cucumber" \ +REDIS_URI="redis://$PARITY_HOST:$PARITY_REDIS_PORT" \ +NODE_PATH="$NODE_ROOT" \ +TS_NODE_TRANSPILE_ONLY=true \ +NODE_OPTIONS=--no-experimental-strip-types \ + "$CUCUMBER_BIN" --format summary \ + || fail "the cucumber suite failed" + +echo "== storage-parity: all three steps passed" From 8fc8f2b83256d73e2ace9eaa81c22431eb0f93cd Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 09:50:45 -0400 Subject: [PATCH 04/16] fix(storage): index-usable NIP-50, single pool without replicas, UTC everywhere, clear semantics --- Cargo.toml | 2 +- crates/nostream-core/src/event.rs | 7 +- crates/nostream-storage/Cargo.toml | 9 +- .../20260420120000_add_hot_path_indexes.sql | 5 +- crates/nostream-storage/src/backend.rs | 15 -- crates/nostream-storage/src/error.rs | 9 +- crates/nostream-storage/src/lib.rs | 2 + .../nostream-storage/src/postgres/events.rs | 62 +++++-- .../nostream-storage/src/postgres/metadata.rs | 39 +---- crates/nostream-storage/src/postgres/mod.rs | 52 +++--- .../src/postgres/repository.rs | 2 +- crates/nostream-storage/src/postgres/rows.rs | 103 +++++++++--- crates/nostream-storage/src/postgres/sql.rs | 159 ++++++++++++------ crates/nostream-storage/src/postgres/users.rs | 18 +- crates/nostream-storage/src/repositories.rs | 3 +- 15 files changed, 316 insertions(+), 171 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4b1a9901..7ca802f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ chacha20 = "0.10.2" chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] } clap = { version = "4", features = ["derive"] } futures-core = "0.3" -heed = "0.20" +heed = { version = "0.20", features = ["read-txn-no-tls"] } hex = "0.4.3" hkdf = "0.13.0" hmac = "0.13.0" diff --git a/crates/nostream-core/src/event.rs b/crates/nostream-core/src/event.rs index a0d0bde8..4a1dbb5b 100644 --- a/crates/nostream-core/src/event.rs +++ b/crates/nostream-core/src/event.rs @@ -318,9 +318,10 @@ impl fmt::Display for Kind { /// One kind or an inclusive `[first, last]` range (`src/@types/settings.ts` `EventKindsRange`). /// -/// Settings criteria use it (`isEventKindOrRangeMatch`, `src/utils/event.ts:34-37`); -/// a subscription filter's `kinds` holds numbers only, so a range element never matches -/// there and is unrepresentable in `Filter`. +/// Two callers: settings criteria (`isEventKindOrRangeMatch`, `src/utils/event.ts:34-37`) +/// and the storage retention whitelist (`EventRetentionOptions::kind_whitelist`, the capture's +/// `(EventKinds | EventKindsRange)[]`). A subscription filter's `kinds` holds numbers only, so +/// a range element never matches there and is unrepresentable in `Filter`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] pub enum KindSelector { diff --git a/crates/nostream-storage/Cargo.toml b/crates/nostream-storage/Cargo.toml index 1106ba3c..41536ea2 100644 --- a/crates/nostream-storage/Cargo.toml +++ b/crates/nostream-storage/Cargo.toml @@ -7,6 +7,13 @@ license.workspace = true repository.workspace = true description = "Repository traits, backend selection and the Postgres storage backend" +[features] +# The Postgres backend and its driver. On by default because Postgres is the reference +# implementation; a build that only needs the trait seam (the LMDB follow-up) can turn it +# off and lose the sqlx link entirely. +default = ["postgres"] +postgres = ["dep:sqlx"] + [dependencies] chrono.workspace = true futures-core.workspace = true @@ -15,7 +22,7 @@ nostream-config.workspace = true nostream-core.workspace = true serde.workspace = true serde_json.workspace = true -sqlx.workspace = true +sqlx = { workspace = true, optional = true } thiserror.workspace = true tokio.workspace = true diff --git a/crates/nostream-storage/migrations/20260420120000_add_hot_path_indexes.sql b/crates/nostream-storage/migrations/20260420120000_add_hot_path_indexes.sql index cbf6589c..e1a02ae8 100644 --- a/crates/nostream-storage/migrations/20260420120000_add_hot_path_indexes.sql +++ b/crates/nostream-storage/migrations/20260420120000_add_hot_path_indexes.sql @@ -3,8 +3,9 @@ -- CREATE INDEX CONCURRENTLY, which PostgreSQL refuses inside a transaction block. The -- rewrite's migrator applies migrations to a fresh database, and a multi-statement simple -- query is itself an implicit transaction block, so the three indexes are built without --- CONCURRENTLY here; the resulting index definitions are identical (the SQL text is --- recorded in the ADR). +-- CONCURRENTLY here; the resulting index definitions are identical (ADR 0003, "Migrations"). +-- The file keeps `-- no-transaction` to mirror the knex file's non-transactional intent; each +-- statement autocommits. CREATE INDEX IF NOT EXISTS events_active_pubkey_kind_created_at_idx ON events (event_pubkey, event_kind, event_created_at DESC, event_id); diff --git a/crates/nostream-storage/src/backend.rs b/crates/nostream-storage/src/backend.rs index 1ad0ba18..81e2d221 100644 --- a/crates/nostream-storage/src/backend.rs +++ b/crates/nostream-storage/src/backend.rs @@ -77,21 +77,6 @@ pub struct LmdbEnv { path: PathBuf, } -/// Phase 1 stub — job 005; connects to Postgres with the Phase-2 driver choice; see `src/database/client.ts`. -#[derive(Debug, Clone, Default)] -pub struct PostgresBackend; - -impl PostgresBackend { - /// Connects to the database named by `DATABASE_URI` (or the `DB_*` parts). - /// - /// Phase 1 stub — job 005; will connect a pool through the Phase-2 driver; see `src/database/client.ts`. - pub async fn connect(_database_uri: &str) -> Result { - Err(StorageError::NotImplemented { - module: "storage::backend::postgres::connect", - }) - } -} - impl LmdbEnv { /// Opens (creating if needed) an LMDB environment at `path` with `map_size` bytes. pub fn open(path: impl Into, map_size: usize) -> Result { diff --git a/crates/nostream-storage/src/error.rs b/crates/nostream-storage/src/error.rs index 10c68883..138208dc 100644 --- a/crates/nostream-storage/src/error.rs +++ b/crates/nostream-storage/src/error.rs @@ -10,11 +10,15 @@ pub enum StorageError { NotImplemented { module: &'static str }, /// A database operation failed. The driver's message names the statement class for an - /// operator; callers must not match on its text. + /// operator; callers must not match on its text, and a handler must never forward it. + /// Present only with the `postgres` feature, so the crate's error type names no + /// backend's driver in a build that does not link one. + #[cfg(feature = "postgres")] #[error("database operation failed: {0}")] Database(#[from] sqlx::Error), - /// The embedded migrator could not apply the schema. + /// The embedded migrator could not apply the schema (with the `postgres` feature). + #[cfg(feature = "postgres")] #[error("database migration failed: {0}")] Migration(#[from] sqlx::migrate::MigrateError), @@ -105,6 +109,7 @@ mod tests { ); } + #[cfg(feature = "postgres")] #[test] fn a_database_error_keeps_the_drivers_message() { let error = StorageError::from(sqlx::Error::RowNotFound); diff --git a/crates/nostream-storage/src/lib.rs b/crates/nostream-storage/src/lib.rs index 47ce0bb2..ff1077a3 100644 --- a/crates/nostream-storage/src/lib.rs +++ b/crates/nostream-storage/src/lib.rs @@ -22,10 +22,12 @@ pub mod backend; pub mod cache; pub mod error; pub mod event_store; +#[cfg(feature = "postgres")] pub mod postgres; pub mod records; pub mod repositories; pub use backend::{Backend, LmdbEnv}; pub use error::StorageError; +#[cfg(feature = "postgres")] pub use postgres::{PostgresBackend, PostgresTransaction}; diff --git a/crates/nostream-storage/src/postgres/events.rs b/crates/nostream-storage/src/postgres/events.rs index 459fb7ab..d9bb73f3 100644 --- a/crates/nostream-storage/src/postgres/events.rs +++ b/crates/nostream-storage/src/postgres/events.rs @@ -9,6 +9,7 @@ use nostream_core::{EventId, Filter, Kind, KindSelector, Pubkey}; use sqlx::postgres::{PgRow, Postgres}; use sqlx::{Executor, Row}; +use super::rows::int_column; use super::sql::{self, hex_bytes}; use super::{Arg, Args, EventQuery, Gates, RETENTION_BATCH_SIZE}; use crate::error::StorageError; @@ -34,6 +35,12 @@ event_content, event_signature, remote_address, expires_at, event_deduplication, /// The kind of the capture's `REQUEST_TO_VANISH`, which every retention whitelist keeps. const REQUEST_TO_VANISH: i32 = 62; +/// PostgreSQL's bind limit is 65 535 parameters per statement; one insert row costs nine +/// parameters and one upsert row eleven, so these are the largest single-statement batches +/// the server accepts. Up to one batch, this is exactly the capture's one statement. +const CREATE_BATCH_ROWS: usize = 65_535 / 9; +const UPSERT_BATCH_ROWS: usize = 65_535 / 11; + /// `create`: `INSERT ... ON CONFLICT (event_id) DO NOTHING`, returning the row count. pub(crate) async fn create<'e, E>(executor: E, record: &EventRecord) -> Result where @@ -55,7 +62,7 @@ where } let mut affected = 0; - for batch in records.chunks(1000) { + for batch in records.chunks(CREATE_BATCH_ROWS) { let mut args = Args::default(); let values = batch .iter() @@ -111,7 +118,7 @@ where } let mut affected = 0; - for batch in records.chunks(1000) { + for batch in records.chunks(UPSERT_BATCH_ROWS) { let mut args = Args::default(); let values = batch .iter() @@ -271,7 +278,7 @@ where }; let now = Utc::now().timestamp(); - let retention_limit = now - max_days * 86_400; + let retention_limit = now.saturating_sub(max_days.saturating_mul(86_400)); let mut args = Args::default(); let now_slot = args.push(Arg::BigInt(now)); @@ -407,13 +414,6 @@ fn tags_json(record: &EventRecord) -> Result { }) } -fn int_column(column: &'static str, value: i64) -> Result { - i32::try_from(value).map_err(|_| StorageError::ValueOutOfRange { - column, - value: value.to_string(), - }) -} - /// A stream of stored events, mapping each driver row. struct EventStream<'a> { inner: Pin> + Send + 'a>>, @@ -451,3 +451,45 @@ impl Stream for ReconciliationStream<'_> { } } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + + #[test] + fn the_retention_whitelist_deduplicates_and_always_keeps_kind_62() { + let whitelist = retention_kinds(Some(&[ + KindSelector::Single(Kind(1)), + KindSelector::Range([Kind(1), Kind(1)]), + KindSelector::Single(Kind(62)), + KindSelector::Single(Kind(1)), + ])); + assert_eq!( + whitelist, + vec![ + KindSelector::Single(Kind(1)), + KindSelector::Range([Kind(1), Kind(1)]), + KindSelector::Single(Kind(62)), + ] + ); + + let default = retention_kinds(None); + assert_eq!(default, vec![KindSelector::Single(Kind(62))]); + } + + #[test] + fn the_retention_whitelist_clause_names_kinds_and_ranges() { + let mut args = Args::default(); + let clause = push_retention_kinds( + &mut args, + &[ + KindSelector::Single(Kind(1)), + KindSelector::Range([Kind(10_000), Kind(19_999)]), + ], + ); + assert_eq!(clause, "event_kind = $1 OR event_kind BETWEEN $2 AND $3"); + assert_eq!(args.len(), 3); + } +} diff --git a/crates/nostream-storage/src/postgres/metadata.rs b/crates/nostream-storage/src/postgres/metadata.rs index 50ce6751..b27d05f3 100644 --- a/crates/nostream-storage/src/postgres/metadata.rs +++ b/crates/nostream-storage/src/postgres/metadata.rs @@ -6,6 +6,7 @@ use nostream_core::{EventId, Kind, Pubkey}; use sqlx::postgres::Postgres; use sqlx::{Executor, Row}; +use super::rows::{bytes_to_event_id, bytes_to_pubkey, int_column, optional_bytes}; use super::{Arg, Args, EventQuery}; use crate::error::StorageError; use crate::repositories::{ @@ -297,7 +298,7 @@ where let mut args = Args::default(); let id_slot = args.push(Arg::Bytes(id.as_bytes().to_vec())); let requester_slot = args.push(Arg::Bytes(requester.as_bytes().to_vec())); - let kind_slot = args.push(Arg::Int(int32(kind.0 as i64, "kind")?)); + let kind_slot = args.push(Arg::Int(int_column("kind", kind.0 as i64)?)); let status_slot = args.push(Arg::Text(DvmJobStatus::Submitted.as_str().to_owned())); let created_slot = args.push(Arg::Timestamp(now)); let updated_slot = args.push(Arg::Timestamp(now)); @@ -446,7 +447,7 @@ where if let Some(kinds) = kinds.filter(|kinds| !kinds.is_empty()) { let kinds: Vec = kinds .iter() - .map(|kind| int32(kind.0 as i64, "kind")) + .map(|kind| int_column("kind", kind.0 as i64)) .collect::>()?; let kinds_slot = args.push(Arg::IntList(kinds)); kinds_clause = format!(" AND kind = ANY({kinds_slot})"); @@ -585,37 +586,3 @@ where }) .collect() } - -fn int32(value: i64, column: &'static str) -> Result { - i32::try_from(value).map_err(|_| StorageError::ValueOutOfRange { - column, - value: value.to_string(), - }) -} - -fn optional_bytes( - row: &sqlx::postgres::PgRow, - column: &'static str, -) -> Result>, StorageError> { - Ok(row.try_get::>, _>(column)?) -} - -fn bytes_to_pubkey(bytes: Vec, column: &'static str) -> Result { - bytes - .try_into() - .map(Pubkey::from_byte_array) - .map_err(|bytes: Vec| StorageError::ValueOutOfRange { - column, - value: format!("{} bytes", bytes.len()), - }) -} - -fn bytes_to_event_id(bytes: Vec, column: &'static str) -> Result { - bytes - .try_into() - .map(EventId::from_byte_array) - .map_err(|bytes: Vec| StorageError::ValueOutOfRange { - column, - value: format!("{} bytes", bytes.len()), - }) -} diff --git a/crates/nostream-storage/src/postgres/mod.rs b/crates/nostream-storage/src/postgres/mod.rs index f2bb972c..0bd0ddf2 100644 --- a/crates/nostream-storage/src/postgres/mod.rs +++ b/crates/nostream-storage/src/postgres/mod.rs @@ -49,7 +49,7 @@ pub struct PostgresBackend { /// The connection settings one pool is built from, resolved from the environment exactly /// as `src/database/client.ts:31-92` resolves them. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConnectParts { +pub(crate) struct ConnectParts { pub host: String, pub port: u16, pub username: String, @@ -73,7 +73,7 @@ impl ConnectParts { /// The pool bounds one backend is built with. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct PoolBounds { +pub(crate) struct PoolBounds { pub min_connections: u32, pub max_connections: u32, pub acquire_timeout_ms: u64, @@ -96,23 +96,19 @@ impl PostgresBackend { Self::from_lookup(&|name| env::var(name).ok()).await } - /// Connects master and reader pools from `url`; the harness and the tests use it. + /// Connects the master pool from `url`; with no replica configured the reader is the + /// master pool itself (`src/database/client.ts:97-100`). The harness and the tests use + /// this. pub async fn connect(url: &str) -> Result { let options = url.parse::()?; - Self::with_pools( - options.clone(), - PoolBounds::default(), - options, - PoolBounds::default(), - ) - .await + Self::with_pools(utc(options), PoolBounds::default(), None).await } /// Builds the backend without dialling. The dyn-compatibility test and the migration /// target use it, because neither needs a live connection to prove the trait objects. pub fn connect_lazy(url: &str) -> Result { let options = url.parse::()?; - let master = lazy_pool(options, &PoolBounds::default()); + let master = lazy_pool(utc(options), &PoolBounds::default()); let reader = master.clone(); Ok(Self { master, @@ -182,33 +178,31 @@ impl PostgresBackend { { let master = master_options(lookup)?; let bounds = pool_bounds(lookup, ""); - let reader = if lookup("READ_REPLICA_ENABLED").as_deref() == Some("true") { + let replica = if lookup("READ_REPLICA_ENABLED").as_deref() == Some("true") { let index = replica_index(lookup)?; - ( - replica_parts(lookup, index)?.options(), + Some(( + utc(replica_parts(lookup, index)?.options()), pool_bounds(lookup, &format!("RR{index}_")), - ) + )) } else { - (master.clone(), bounds.clone()) + None }; - Self::with_pools( - master.options([("timezone", "UTC")]), - bounds, - reader.0.options([("timezone", "UTC")]), - reader.1, - ) - .await + Self::with_pools(utc(master), bounds, replica).await } + /// Opens the master pool and, only when a replica is configured, a second one; otherwise + /// the reader is the master pool, as `src/database/client.ts:97-100` returns it. async fn with_pools( master: PgConnectOptions, master_bounds: PoolBounds, - reader: PgConnectOptions, - reader_bounds: PoolBounds, + replica: Option<(PgConnectOptions, PoolBounds)>, ) -> Result { let master_pool = connected_pool(master, &master_bounds).await?; - let reader_pool = connected_pool(reader, &reader_bounds).await?; + let reader_pool = match replica { + Some((options, bounds)) => connected_pool(options, &bounds).await?, + None => master_pool.clone(), + }; Ok(Self { master: master_pool, reader: reader_pool, @@ -250,6 +244,12 @@ fn pool_options(bounds: &PoolBounds) -> PgPoolOptions { .acquire_timeout(Duration::from_millis(bounds.acquire_timeout_ms)) } +/// Every connection is opened with the session time zone pinned to UTC, so a `timestamp` +/// column round-trips deterministically. +fn utc(options: PgConnectOptions) -> PgConnectOptions { + options.options([("timezone", "UTC")]) +} + fn lazy_pool(options: PgConnectOptions, bounds: &PoolBounds) -> PgPool { pool_options(bounds).connect_lazy_with(options) } diff --git a/crates/nostream-storage/src/postgres/repository.rs b/crates/nostream-storage/src/postgres/repository.rs index 5743eb67..16064b98 100644 --- a/crates/nostream-storage/src/postgres/repository.rs +++ b/crates/nostream-storage/src/postgres/repository.rs @@ -153,7 +153,7 @@ impl UserRepository for PostgresBackend { if let Some(user) = users::find_by_pubkey(&self.master, pubkey).await? { return Ok(user.is_vanished); } - let vanished = events::has_active_request_to_vanish(&self.master, pubkey).await?; + let vanished = events::has_active_request_to_vanish(&self.reader, pubkey).await?; users::upsert_vanish_state(&self.master, pubkey, vanished).await?; Ok(vanished) }) diff --git a/crates/nostream-storage/src/postgres/rows.rs b/crates/nostream-storage/src/postgres/rows.rs index 402b31d4..9eb1173c 100644 --- a/crates/nostream-storage/src/postgres/rows.rs +++ b/crates/nostream-storage/src/postgres/rows.rs @@ -238,17 +238,32 @@ fn optional_fixed( fn tags(row: &PgRow) -> Result, StorageError> { let value: Option = row.try_get("event_tags")?; - match value { - None => Ok(Vec::new()), - Some(value) => { - let display = value.to_string(); - serde_json::from_value(value).map_err(|_| StorageError::InvalidRow { - table: "events", - column: "event_tags", - value: display, - }) + let Some(value) = value else { + return Ok(Vec::new()); + }; + let invalid = || StorageError::InvalidRow { + table: "events", + column: "event_tags", + value: value.to_string(), + }; + let Some(rows) = value.as_array() else { + return Err(invalid()); + }; + let mut tags = Vec::with_capacity(rows.len()); + for row in rows { + let Some(items) = row.as_array() else { + return Err(invalid()); + }; + let mut tag = Vec::with_capacity(items.len()); + for item in items { + let Some(text) = item.as_str() else { + return Err(invalid()); + }; + tag.push(text.to_owned()); } + tags.push(tag); } + Ok(tags) } fn optional_json_array( @@ -256,19 +271,18 @@ fn optional_json_array( column: &'static str, ) -> Result>, StorageError> { let value: Option = row.try_get(column)?; - match value { - None => Ok(None), - Some(value) => { - let display = value.to_string(); - serde_json::from_value(value) - .map(Some) - .map_err(|_| StorageError::InvalidRow { - table: "events", - column, - value: display, - }) - } - } + let Some(value) = value else { + return Ok(None); + }; + value + .as_array() + .cloned() + .map(Some) + .ok_or_else(|| StorageError::InvalidRow { + table: "events", + column, + value: value.to_string(), + }) } fn optional_address(row: &PgRow, column: &'static str) -> Result, StorageError> { @@ -311,3 +325,48 @@ where let value: String = row.try_get(column)?; value.parse() } + +/// An integer column value checked into `i32` (the `kind`, `event_created_at` and +/// `expires_at` columns). +pub(crate) fn int_column(column: &'static str, value: i64) -> Result { + i32::try_from(value).map_err(|_| StorageError::ValueOutOfRange { + column, + value: value.to_string(), + }) +} + +/// The optional `bytea` column backing a nullable key. +pub(crate) fn optional_bytes( + row: &PgRow, + column: &'static str, +) -> Result>, StorageError> { + Ok(row.try_get::>, _>(column)?) +} + +/// A 32-byte `bytea` as a pubkey. +pub(crate) fn bytes_to_pubkey( + bytes: Vec, + column: &'static str, +) -> Result { + bytes + .try_into() + .map(Pubkey::from_byte_array) + .map_err(|bytes: Vec| StorageError::ValueOutOfRange { + column, + value: format!("{} bytes", bytes.len()), + }) +} + +/// A 32-byte `bytea` as an event id. +pub(crate) fn bytes_to_event_id( + bytes: Vec, + column: &'static str, +) -> Result { + bytes + .try_into() + .map(EventId::from_byte_array) + .map_err(|bytes: Vec| StorageError::ValueOutOfRange { + column, + value: format!("{} bytes", bytes.len()), + }) +} diff --git a/crates/nostream-storage/src/postgres/sql.rs b/crates/nostream-storage/src/postgres/sql.rs index 1ae6128d..098745c8 100644 --- a/crates/nostream-storage/src/postgres/sql.rs +++ b/crates/nostream-storage/src/postgres/sql.rs @@ -128,14 +128,14 @@ pub(crate) fn find_by_filters( let mut builder = Builder::default(); if filters.len() == 1 { - push_event_branch(&mut builder, &filters[0], gates); + push_event_branch(&mut builder, &filters[0], gates)?; } else { for (index, filter) in filters.iter().enumerate() { if index > 0 { builder.push(" UNION "); } builder.push("("); - push_event_branch(&mut builder, filter, gates); + push_event_branch(&mut builder, filter, gates)?; builder.push(")"); } push_order(&mut builder, &filters[0], is_search(&filters[0], gates)); @@ -159,14 +159,14 @@ pub(crate) fn count_by_filters( let mut builder = Builder::default(); builder.push("SELECT count(*) AS count FROM ("); if filters.len() == 1 { - push_id_branch(&mut builder, &filters[0], gates, now); + push_id_branch(&mut builder, &filters[0], gates, now)?; } else { for (index, filter) in filters.iter().enumerate() { if index > 0 { builder.push(" UNION "); } builder.push("("); - push_id_branch(&mut builder, filter, gates, now); + push_id_branch(&mut builder, filter, gates, now)?; builder.push(")"); } } @@ -184,7 +184,7 @@ pub(crate) fn find_reconciliation_items( ) -> Result { let mut builder = Builder::default(); builder.push("SELECT events.event_created_at, events.event_id FROM events WHERE 1 = 1"); - push_conditions(&mut builder, filter, gates); + push_conditions(&mut builder, filter, gates)?; push_live_only(&mut builder, now); builder.push(" ORDER BY event_created_at ASC, event_id ASC"); @@ -214,40 +214,49 @@ impl Builder { } } -fn push_event_branch(builder: &mut Builder, filter: &Filter, gates: &Gates) { +fn push_event_branch( + builder: &mut Builder, + filter: &Filter, + gates: &Gates, +) -> Result<(), StorageError> { builder.push("SELECT "); builder.push(EVENT_COLUMNS); let search = is_search(filter, gates); if search { - let (language, query) = search_binding(filter, gates); - let language_slot = builder.arg(Arg::Text(language.clone())); - let query_language_slot = builder.arg(Arg::Text(language)); - let query_slot = builder.arg(Arg::Text(query)); + let config = search_config(gates)?; + let query_slot = builder.arg(Arg::Text(search_query(filter, gates))); builder.push(&format!( - ", ts_rank(to_tsvector({language_slot}::regconfig, events.event_content), \ -plainto_tsquery({query_language_slot}::regconfig, {query_slot})) AS search_rank" + ", ts_rank(to_tsvector('{config}'::regconfig, events.event_content), \ +plainto_tsquery('{config}'::regconfig, {query_slot})) AS search_rank" )); } builder.push(" FROM events WHERE 1 = 1"); - push_conditions(builder, filter, gates); + push_conditions(builder, filter, gates)?; let limit = filter.limit.unwrap_or(DEFAULT_FILTER_LIMIT); push_order(builder, filter, search); - let limit_slot = builder.arg(Arg::BigInt(limit as i64)); + let limit_slot = builder.arg(Arg::BigInt(i64::try_from(limit).unwrap_or(i64::MAX))); builder.push(&format!(" LIMIT {limit_slot}")); + Ok(()) } -fn push_id_branch(builder: &mut Builder, filter: &Filter, gates: &Gates, now: i64) { +fn push_id_branch( + builder: &mut Builder, + filter: &Filter, + gates: &Gates, + now: i64, +) -> Result<(), StorageError> { builder.push("SELECT events.event_id FROM events WHERE 1 = 1"); - push_conditions(builder, filter, gates); + push_conditions(builder, filter, gates)?; push_live_only(builder, now); - if filter.limit.is_some() { + if let Some(limit) = filter.limit { push_order(builder, filter, false); - let limit_slot = builder.arg(Arg::BigInt(filter.limit.unwrap_or_default() as i64)); + let limit_slot = builder.arg(Arg::BigInt(i64::try_from(limit).unwrap_or(i64::MAX))); builder.push(&format!(" LIMIT {limit_slot}")); } + Ok(()) } fn push_live_only(builder: &mut Builder, now: i64) { @@ -267,7 +276,11 @@ fn push_order(builder: &mut Builder, filter: &Filter, search: bool) { } } -fn push_conditions(builder: &mut Builder, filter: &Filter, gates: &Gates) { +fn push_conditions( + builder: &mut Builder, + filter: &Filter, + gates: &Gates, +) -> Result<(), StorageError> { if let Some(authors) = &filter.authors { push_hex_criteria(builder, "events.event_pubkey", authors); } @@ -302,13 +315,11 @@ fn push_conditions(builder: &mut Builder, filter: &Filter, gates: &Gates) { } if is_search(filter, gates) { - let (language, query) = search_binding(filter, gates); - let language_slot = builder.arg(Arg::Text(language.clone())); - let query_language_slot = builder.arg(Arg::Text(language)); - let query_slot = builder.arg(Arg::Text(query)); + let config = search_config(gates)?; + let query_slot = builder.arg(Arg::Text(search_query(filter, gates))); builder.push(&format!( - " AND (to_tsvector({language_slot}::regconfig, events.event_content) @@ \ -plainto_tsquery({query_language_slot}::regconfig, {query_slot}))" + " AND (to_tsvector('{config}'::regconfig, events.event_content) @@ \ +plainto_tsquery('{config}'::regconfig, {query_slot}))" )); } @@ -349,6 +360,8 @@ AND event_tags.tag_name = {name_slot} AND (" builder.push(&parts.join(" OR ")); builder.push("))"); } + + Ok(()) } /// One hex criterion family: 64 characters compare exactly, an even-length prefix compares @@ -427,16 +440,44 @@ fn is_search(filter: &Filter, gates: &Gates) -> bool { .is_some_and(|search| !search.trim().is_empty()) } -fn search_binding(filter: &Filter, gates: &Gates) -> (String, String) { - let Some(gate) = &gates.nip50 else { - return (DEFAULT_TS_CONFIG.to_owned(), String::new()); - }; +/// The text-search configuration, inlined into the SQL as a literal. +/// +/// The capture binds `?::regconfig` (`src/repositories/event-repository.ts:203-206`), and a +/// driver bind makes the expression non-constant, so PostgreSQL cannot match the GIN index +/// built on `to_tsvector('simple', event_content)` — the capture misses its own index too. +/// This port inlines the settings value instead, which is the indexed expression; the value +/// is settings text, so it is checked against the identifier shape first. +fn search_config(gates: &Gates) -> Result { + let language = gates + .nip50 + .as_ref() + .map(|gate| gate.language.as_str()) + .unwrap_or(DEFAULT_TS_CONFIG); + let valid = !language.is_empty() + && language + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte == b'_') + && language + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'); + if !valid { + return Err(StorageError::InvalidArgument { + message: "nip50.language must be a PostgreSQL text-search configuration name", + }); + } + Ok(language.to_owned()) +} + +fn search_query(filter: &Filter, gates: &Gates) -> String { let query = filter.search.as_deref().unwrap_or_default().trim(); - let query = query - .chars() - .take(gate.max_query_length as usize) - .collect::(); - (gate.language.clone(), query) + let limit = gates + .nip50 + .as_ref() + .map_or(super::DEFAULT_MAX_SEARCH_QUERY_LENGTH, |gate| { + gate.max_query_length + }); + query.chars().take(limit as usize).collect() } /// Hex helpers, kept local so the query composer needs no hex crate dependency. @@ -574,18 +615,21 @@ mod tests { let with = find_by_filters(&[filter], &gates(true, false)).unwrap(); let text = sql(&with); - assert!(text.contains("ts_rank(to_tsvector($1::regconfig, events.event_content)")); - assert!(text.contains("to_tsvector($4::regconfig, events.event_content) @@")); + assert!(text.contains( + "ts_rank(to_tsvector('simple'::regconfig, events.event_content), \ +plainto_tsquery('simple'::regconfig, $1))" + )); + assert!(text.contains( + "to_tsvector('simple'::regconfig, events.event_content) @@ \ +plainto_tsquery('simple'::regconfig, $2)" + )); assert!(text.contains("ORDER BY search_rank DESC, event_id ASC")); + // The configuration is SQL text, not a bind: only the search term is bound. assert_eq!( with.args, Args { items: vec![ - Arg::Text("simple".to_owned()), - Arg::Text("simple".to_owned()), Arg::Text("bitcoin lightning".to_owned()), - Arg::Text("simple".to_owned()), - Arg::Text("simple".to_owned()), Arg::Text("bitcoin lightning".to_owned()), Arg::BigInt(500), ] @@ -593,6 +637,33 @@ mod tests { ); } + #[test] + fn the_search_language_must_be_a_config_identifier() { + let mut filter = filter(); + filter.search = Some("bitcoin".to_owned()); + for language in ["english", "simple", "pt_br2"] { + let gate = Gates { + nip50: Some(Nip50Gate { + language: language.to_owned(), + max_query_length: 256, + }), + nip56: false, + }; + assert!(find_by_filters(&[filter.clone()], &gate).is_ok()); + } + for language in ["", "simple'; DROP TABLE events; --", "Simple", "a-b"] { + let gate = Gates { + nip50: Some(Nip50Gate { + language: language.to_owned(), + max_query_length: 256, + }), + nip56: false, + }; + let error = find_by_filters(&[filter.clone()], &gate).expect_err("rejected"); + assert!(matches!(error, StorageError::InvalidArgument { .. })); + } + } + #[test] fn a_search_query_is_truncated_to_the_configured_length() { let mut filter = filter(); @@ -612,13 +683,7 @@ mod tests { .iter() .any(|arg| { arg == &Arg::Text("a".repeat(10)) }) ); - assert!( - query - .args - .items - .iter() - .any(|arg| arg == &Arg::Text("english".to_owned())) - ); + assert!(sql(&query).contains("to_tsvector('english'::regconfig")); } #[test] diff --git a/crates/nostream-storage/src/postgres/users.rs b/crates/nostream-storage/src/postgres/users.rs index c1269d35..df986d28 100644 --- a/crates/nostream-storage/src/postgres/users.rs +++ b/crates/nostream-storage/src/postgres/users.rs @@ -59,10 +59,20 @@ where "is_admitted = excluded.is_admitted, is_vanished = excluded.is_vanished, \ updated_at = excluded.updated_at", ); - if let FieldUpdate::Set(tos_accepted_at) = patch.tos_accepted_at { - columns.push("tos_accepted_at"); - values.push(args.push(Arg::NaiveTimestamp(tos_accepted_at.naive_utc()))); - merge.push_str(", tos_accepted_at = excluded.tos_accepted_at"); + match patch.tos_accepted_at { + // Absent leaves the column alone (knex skips `undefined`). + FieldUpdate::Absent => {} + // `null` in the capture's row: the column is written and the merge clears it. + FieldUpdate::Clear => { + columns.push("tos_accepted_at"); + values.push(args.push(Arg::NullNaiveTimestamp)); + merge.push_str(", tos_accepted_at = excluded.tos_accepted_at"); + } + FieldUpdate::Set(tos_accepted_at) => { + columns.push("tos_accepted_at"); + values.push(args.push(Arg::NaiveTimestamp(tos_accepted_at.naive_utc()))); + merge.push_str(", tos_accepted_at = excluded.tos_accepted_at"); + } } let sql = format!( diff --git a/crates/nostream-storage/src/repositories.rs b/crates/nostream-storage/src/repositories.rs index 9725f117..3f0cfff6 100644 --- a/crates/nostream-storage/src/repositories.rs +++ b/crates/nostream-storage/src/repositories.rs @@ -53,7 +53,8 @@ pub struct EventRetentionOptions { /// Symbols in `src/constants/base.ts`). #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct EventRecord { - /// `events.id` (uuid, DB-generated). `None` on a write; populated on every read. + /// `events.id` (uuid, DB-generated). `None` on a write; the Postgres backend populates + /// it on every read, while another backend may leave it `None` (it is backend-defined). pub row_id: Option, /// The seven wire fields (`nostream_core::Event`), unchanged. pub event: nostream_core::Event, From 1f942d203fba874576c5bd0748c6e1968dc5d297 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 09:50:45 -0400 Subject: [PATCH 05/16] test(storage): 51-method fixture coverage, whitelist and NIP-50 plan guards --- .../tests/capture/gen-storage-parity.mjs | 68 +++++++++-- .../tests/fixtures/repository-parity.json | 113 ++++++++++++++---- .../tests/fixtures/schema.json | 9 ++ crates/nostream-storage/tests/migrations.rs | 38 ++++++ crates/nostream-storage/tests/parity.rs | 37 +++++- 5 files changed, 230 insertions(+), 35 deletions(-) diff --git a/crates/nostream-storage/tests/capture/gen-storage-parity.mjs b/crates/nostream-storage/tests/capture/gen-storage-parity.mjs index 20cde744..bd4034de 100644 --- a/crates/nostream-storage/tests/capture/gen-storage-parity.mjs +++ b/crates/nostream-storage/tests/capture/gen-storage-parity.mjs @@ -495,19 +495,21 @@ const script = [ }, // ---- events: retention (last, because it removes rows) --------------------------- + // The whitelisted pass runs first, while its excluded rows still exist, so a broken + // whitelist cannot hide behind an already-empty candidate set. { op: 'events.delete_expired_and_retained', - request: { options: { max_days: 1 } }, + request: { + options: { max_days: 1, kind_whitelist: [[1, 1]], pubkey_whitelist: [PK_A] }, + }, }, { op: 'events.delete_expired_and_retained', - request: { options: { max_days: 0 } }, + request: { options: { max_days: 1 } }, }, { op: 'events.delete_expired_and_retained', - request: { - options: { max_days: 1, kind_whitelist: [[1, 1]], pubkey_whitelist: [PK_A] }, - }, + request: { options: { max_days: 0 } }, }, // ---- users ---------------------------------------------------------------------- @@ -536,10 +538,16 @@ const script = [ op: 'user.upsert', request: { patch: { pubkey: PK_B, is_admitted: false, is_vanished: true, tos_accepted_at: { kind: 'absent' } } }, }, + { + op: 'user.upsert', + request: { patch: { pubkey: PK_B, is_admitted: true, is_vanished: false, tos_accepted_at: { kind: 'set', value: '2024-06-01T00:00:00Z' } } }, + }, + { op: 'user.tos_accepted_at', request: { pubkey: PK_B } }, { op: 'user.upsert', request: { patch: { pubkey: PK_B, is_admitted: true, is_vanished: false, tos_accepted_at: { kind: 'clear' } } }, }, + { op: 'user.tos_accepted_at', request: { pubkey: PK_B } }, { op: 'user.revoke_admission', request: { pubkey: PK_B } }, { op: 'user.find_by_pubkey', request: { pubkey: PK_C } }, @@ -785,6 +793,24 @@ const script = [ { op: 'outbox.claim_batch', request: { limit: 10 } }, { op: 'outbox.delete_terminal_older_than', request: { cutoff: '2100-01-01T00:00:00Z' } }, { op: 'outbox.claim_batch', request: { limit: 10 } }, + { + op: 'delivery_log.append', + request: { + entry: { + outbox_id_from: 'settings.changed', + event_type: 'settings.changed', + target_id: 'target-slack', + target_type: 'slack', + status: 'success', + attempt_number: 1, + error_snippet: null, + }, + }, + }, + { + op: 'delivery_log.find_successful_target_ids', + request: { outbox_id_from: 'settings.changed' }, + }, // ---- serializable transaction groups -------------------------------------------- { @@ -933,6 +959,13 @@ const run = async (op, client, inTransaction = false) => { .then(() => true), }, } + case 'user.tos_accepted_at': { + const [row] = await client('users') + .select('tos_accepted_at') + .where('pubkey', Buffer.from(request.pubkey, 'hex')) + .limit(1) + return { compare: { value: iso(row?.tos_accepted_at) } } + } case 'user.revoke_admission': return { compare: { count: await repository.user.revokeAdmission(request.pubkey) } } case 'invoice.find_by_id': @@ -1139,7 +1172,9 @@ const run = async (op, client, inTransaction = false) => { compare: { ok: await repository.delivery .append({ - outboxId: request.entry.outbox_id, + outboxId: request.entry.outbox_id_from + ? resolveId({ enqueue_event_type: request.entry.outbox_id_from }) + : request.entry.outbox_id, eventType: request.entry.event_type, targetId: request.entry.target_id, targetType: request.entry.target_type, @@ -1162,14 +1197,16 @@ const run = async (op, client, inTransaction = false) => { }, } } - case 'delivery_log.find_successful_target_ids': + case 'delivery_log.find_successful_target_ids': { + const outboxId = request.outbox_id_from + ? resolveId({ enqueue_event_type: request.outbox_id_from }) + : request.outbox_id return { compare: { - target_ids: ( - await repository.delivery.findSuccessfulTargetIds(request.outbox_id) - ).sort(), + target_ids: (await repository.delivery.findSuccessfulTargetIds(outboxId)).sort(), }, } + } case 'delivery_log.delete_older_than': return { compare: { @@ -1354,7 +1391,16 @@ const main = async () => { 'cycle_option', ], } - const schema = {} + const subject = { + repo: 'cameri/nostream', + capture_commit: '242a3001d77149e4d70151ec03060c4ef44a1547', + generator: 'crates/nostream-storage/tests/capture/gen-storage-parity.mjs', + generated_on: new Date().toISOString().slice(0, 10), + server_image: 'postgres:16-alpine (PostgreSQL 16.15)', + migration_command: 'knex migrate:latest', + migration_count: applied.length, + } + const schema = { subject } for (const [key, sql] of Object.entries(queries)) { schema[key] = (await db.raw(sql)).rows.map((row) => shapes[key].map((field) => (row[field] === undefined ? null : row[field])), diff --git a/crates/nostream-storage/tests/fixtures/repository-parity.json b/crates/nostream-storage/tests/fixtures/repository-parity.json index 81effc0b..3b5db0b5 100644 --- a/crates/nostream-storage/tests/fixtures/repository-parity.json +++ b/crates/nostream-storage/tests/fixtures/repository-parity.json @@ -10081,14 +10081,23 @@ "op": "events.delete_expired_and_retained", "request": { "options": { - "max_days": 1 + "max_days": 1, + "kind_whitelist": [ + [ + 1, + 1 + ] + ], + "pubkey_whitelist": [ + "a100000000000000000000000000000000000000000000000000000000000000" + ] } }, "result": { "compare": { - "deleted": 2, - "expired": 1, - "retained": 486 + "deleted": 1, + "expired": 0, + "retained": 326 } } }, @@ -10096,14 +10105,14 @@ "op": "events.delete_expired_and_retained", "request": { "options": { - "max_days": 0 + "max_days": 1 } }, "result": { "compare": { - "deleted": 0, - "expired": 0, - "retained": 0 + "deleted": 1, + "expired": 1, + "retained": 160 } } }, @@ -10111,16 +10120,7 @@ "op": "events.delete_expired_and_retained", "request": { "options": { - "max_days": 1, - "kind_whitelist": [ - [ - 1, - 1 - ] - ], - "pubkey_whitelist": [ - "a100000000000000000000000000000000000000000000000000000000000000" - ] + "max_days": 0 } }, "result": { @@ -10251,6 +10251,36 @@ } } }, + { + "op": "user.upsert", + "request": { + "patch": { + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000", + "is_admitted": true, + "is_vanished": false, + "tos_accepted_at": { + "kind": "set", + "value": "2024-06-01T00:00:00Z" + } + } + }, + "result": { + "compare": { + "count": 1 + } + } + }, + { + "op": "user.tos_accepted_at", + "request": { + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "value": "2024-06-01T00:00:00.000Z" + } + } + }, { "op": "user.upsert", "request": { @@ -10269,6 +10299,17 @@ } } }, + { + "op": "user.tos_accepted_at", + "request": { + "pubkey": "a200000000000000000000000000000000000000000000000000000000000000" + }, + "result": { + "compare": { + "value": null + } + } + }, { "op": "user.revoke_admission", "request": { @@ -11276,7 +11317,7 @@ } }, "ref": { - "id": "df411304-0744-4ca4-a7a4-84d8f32990e2" + "id": "d05eb04a-9e3b-4fee-9226-57efc47138c2" } } }, @@ -11305,7 +11346,7 @@ } }, "ref": { - "id": "8a0f84e0-63b0-4ea3-a479-e7f8c3bb2f00" + "id": "78642867-7c9f-4628-8dab-89a15adf2fe1" } } }, @@ -11401,6 +11442,38 @@ } } }, + { + "op": "delivery_log.append", + "request": { + "entry": { + "outbox_id_from": "settings.changed", + "event_type": "settings.changed", + "target_id": "target-slack", + "target_type": "slack", + "status": "success", + "attempt_number": 1, + "error_snippet": null + } + }, + "result": { + "compare": { + "ok": true + } + } + }, + { + "op": "delivery_log.find_successful_target_ids", + "request": { + "outbox_id_from": "settings.changed" + }, + "result": { + "compare": { + "target_ids": [ + "target-slack" + ] + } + } + }, { "op": "transaction", "request": { diff --git a/crates/nostream-storage/tests/fixtures/schema.json b/crates/nostream-storage/tests/fixtures/schema.json index cd32df3d..a5ebd6fb 100644 --- a/crates/nostream-storage/tests/fixtures/schema.json +++ b/crates/nostream-storage/tests/fixtures/schema.json @@ -1,4 +1,13 @@ { + "subject": { + "repo": "cameri/nostream", + "capture_commit": "242a3001d77149e4d70151ec03060c4ef44a1547", + "generator": "crates/nostream-storage/tests/capture/gen-storage-parity.mjs", + "generated_on": "2026-10-19", + "server_image": "postgres:16-alpine (PostgreSQL 16.15)", + "migration_command": "knex migrate:latest", + "migration_count": 37 + }, "columns": [ [ "dvm_jobs", diff --git a/crates/nostream-storage/tests/migrations.rs b/crates/nostream-storage/tests/migrations.rs index 51dbadbd..288c9e65 100644 --- a/crates/nostream-storage/tests/migrations.rs +++ b/crates/nostream-storage/tests/migrations.rs @@ -74,6 +74,33 @@ async fn applied_migrations(pool: &PgPool) -> Vec<(i64, bool)> { .collect() } +async fn search_plan(pool: &PgPool) -> String { + let mut transaction = pool.begin().await.expect("a transaction"); + sqlx::query("SET LOCAL enable_seqscan = off") + .execute(&mut *transaction) + .await + .expect("sequential scans can be disabled"); + let rows = sqlx::query( + "EXPLAIN (ANALYZE, BUFFERS) SELECT event_id FROM events \ + WHERE to_tsvector('simple'::regconfig, event_content) @@ \ + plainto_tsquery('simple'::regconfig, $1)", + ) + .bind("bitcoin lightning") + .fetch_all(&mut *transaction) + .await + .expect("the search plan is explainable"); + let plan = rows + .iter() + .map(|row| row.get::("QUERY PLAN")) + .collect::>() + .join("\n"); + transaction + .rollback() + .await + .expect("the plan transaction rolls back"); + plan +} + async fn catalogue(pool: &PgPool) -> Value { let excluded = "'knex_migrations','knex_migrations_lock','_sqlx_migrations'"; @@ -317,6 +344,17 @@ async fn the_migrations_apply_from_empty_are_idempotent_and_match_the_capture_sc let actual = catalogue(backend.master()).await; assert_same(&actual, &fixture()); + // The NIP-50 predicate must be able to use the GIN index: the configuration is inlined + // as a constant (not a `text` bind, which would make the expression non-constant). With + // sequential scans disabled, the only way to answer the search is that index; a + // rewritten predicate that no longer matches the index expression shows up here as a + // Seq Scan (or no plan at all) rather than as a slower search. + let plan = search_plan(backend.master()).await; + assert!( + plan.contains("events_content_fts_idx"), + "the NIP-50 predicate does not use the FTS index:\n{plan}" + ); + // The Cucumber target needs the migrated schema too; this target owns that database. let cucumber = PgPool::connect(&with_database(&admin_url(), CUCUMBER_DATABASE)) .await diff --git a/crates/nostream-storage/tests/parity.rs b/crates/nostream-storage/tests/parity.rs index f5e214b5..554639c5 100644 --- a/crates/nostream-storage/tests/parity.rs +++ b/crates/nostream-storage/tests/parity.rs @@ -493,6 +493,24 @@ async fn run_op( .await?; Ok(json!({ "ok": true })) } + "user.tos_accepted_at" => { + let pubkey = parse_pubkey(&request["pubkey"]); + let row = sqlx::query("SELECT tos_accepted_at FROM users WHERE pubkey = $1") + .bind(pubkey.as_bytes().to_vec()) + .fetch_optional(backend.master()) + .await?; + let value = match row { + None => Value::Null, + Some(row) => { + let value: Option = + sqlx::Row::try_get(&row, "tos_accepted_at")?; + value + .map(|value| iso(&value.and_utc())) + .unwrap_or(Value::Null) + } + }; + Ok(json!({ "value": value })) + } "user.revoke_admission" => { let pubkey = parse_pubkey(&request["pubkey"]); Ok(json!({ "count": client.users().revoke_admission(&pubkey).await? })) @@ -781,8 +799,12 @@ async fn run_op( } "delivery_log.append" => { let entry = &request["entry"]; + let outbox_id = match entry.get("outbox_id_from") { + Some(_) => Some(resolve_ref(refs, entry)?), + None => optional_text(entry, "outbox_id"), + }; let new_entry = NewDeliveryLogEntry { - outbox_id: optional_text(entry, "outbox_id"), + outbox_id, event_type: text(entry, "event_type"), target_id: text(entry, "target_id"), target_type: entry["target_type"] @@ -824,7 +846,10 @@ async fn run_op( Ok(json!({ "entries": entries.iter().map(delivery_view).collect::>() })) } "delivery_log.find_successful_target_ids" => { - let outbox_id = text(request, "outbox_id"); + let outbox_id = match request.get("outbox_id_from") { + Some(_) => resolve_ref(refs, request)?, + None => text(request, "outbox_id"), + }; let mut ids = NotificationDeliveryLogRepository::find_successful_target_ids(backend, &outbox_id) .await?; @@ -884,8 +909,12 @@ fn resolve_ref( refs: &HashMap, request: &Value, ) -> Result { - let event_type = text(request, "enqueue_event_type"); - refs.get(&event_type) + let event_type = request + .get("enqueue_event_type") + .or_else(|| request.get("outbox_id_from")) + .and_then(Value::as_str) + .expect("an enqueue reference"); + refs.get(event_type) .cloned() .ok_or(nostream_storage::StorageError::InvalidArgument { message: "no enqueued row to address", From 22e4eed400a637831daaa9b16a871d9913107ad2 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 09:50:45 -0400 Subject: [PATCH 06/16] fix(scripts): bind parity ports to one address with per-run credentials; record ADR revisions --- .../0003-storage-seam-and-postgres.md | 105 +++++++++++++++++- scripts/storage-parity.sh | 60 ++++++---- 2 files changed, 139 insertions(+), 26 deletions(-) diff --git a/docs/rewrite/decisions/0003-storage-seam-and-postgres.md b/docs/rewrite/decisions/0003-storage-seam-and-postgres.md index 809c7467..57199211 100644 --- a/docs/rewrite/decisions/0003-storage-seam-and-postgres.md +++ b/docs/rewrite/decisions/0003-storage-seam-and-postgres.md @@ -108,11 +108,66 @@ knex schema, so a different index definition fails. ## Session time zone — UTC on every connection -Every connection sets `timezone=UTC`, so `timestamp` columns round-trip -deterministically and the capture's server-default assumption holds. The capture's -generator must run with `TZ=UTC` as well: `node-postgres` parses a `timestamp without -time zone` column in the process's local zone, so any other host zone records shifted -`confirmed_at`/`expires_at` values that no correct backend would match. +Every constructor (`from_env`, `connect`, `connect_lazy`) sets `timezone=UTC`, so +`timestamp` columns round-trip deterministically and the capture's server-default +assumption holds. The capture's generator must run with `TZ=UTC` as well: +`node-postgres` parses a `timestamp without time zone` column in the process's local +zone, so any other host zone records shifted `confirmed_at`/`expires_at` values that no +correct backend would match. + +## Read replicas — one pool until a replica is configured + +With `READ_REPLICA_ENABLED` unset the reader is the master pool itself (a clone of the +same `PgPool`), exactly as `src/database/client.ts:97-100` returns the write client; a +second pool is opened only when a replica is configured. `users::is_vanished`'s vanish +probe reads through the reader, because the capture's `hasActiveRequestToVanish` has no +client parameter and therefore reads the replica. + +## NIP-50 — the text-search configuration is SQL text, not a bind + +The capture binds the configuration (`?::regconfig`, +`src/repositories/event-repository.ts:203-206`); a driver bind makes the expression +non-constant, so PostgreSQL cannot match the GIN index built on +`to_tsvector('simple', event_content)` — the capture misses its own index too (measured +by the performance lens: 444.8-671.3 ms per execution through a `text`-typed bind versus +0.088 ms through the index). This port deliberately diverges: `nip50.language` is +validated against `^[a-z_][a-z0-9_]*$` (an invalid value is +`StorageError::InvalidArgument`, never SQL) and inlined as +`to_tsvector(''::regconfig, …)`, the exact indexed expression; the search term +stays a bind. Hits and their order are unchanged (same `ts_rank` over the same predicate); +only the plan changes. `tests/migrations.rs` EXPLAINs the predicate with sequential scans +disabled and fails unless the plan names `events_content_fts_idx`; the unit tests pin the +inlined SQL. + +## Batching — the bind limit, and its partial-commit window + +`create_many`/`upsert_many` split their input at PostgreSQL's 65 535-bind limit +(7 281 rows at nine binds each, 5 957 at eleven), so up to one batch is the capture's +single statement. Above one batch, a mid-batch error returns `Err` after the earlier +batches committed: the rows already written stay written. The capture's single statement +would instead fail wholesale (or, past the same limit, be swallowed into `0` by its +`catch`). Callers that need all-or-nothing semantics for more than one batch must own a +transaction. + +## Backend coupling — sqlx behind a feature + +The `postgres` feature (default on) owns the `sqlx` dependency, the `postgres` module and +the two driver error variants (`Database`, `Migration`); a build with +`--no-default-features` compiles the trait seam with no Postgres driver linked, which is +what the LMDB follow-up will use. `heed` is not optional yet, because `LmdbEnv` is part of +this crate's Phase-1 slice; the LMDB backend decides whether to keep it when the seam gains +a second implementation. + +## The seam's stream is `Send` — heed's `read-txn-no-tls` is required + +`QueryStream` is `Send`-bounded, which matches how the relay consumes it. A stream that +owns an LMDB read transaction can only satisfy that bound with heed's `read-txn-no-tls` +feature (heed 0.20.5 `src/txn.rs`: `RoTxn: Send` under that feature), so the workspace +enables it. The feature changes no LMDB behaviour; it declares that read transactions may +cross threads. The cost the LMDB backend inherits: a `QueryStream` holds a read transaction +for its whole life, which delays reuse of pages freed by writers. That is deliberate for +reconciliation (a materialised `Vec` of 10M pairs is worse), and revisit 1 below says when to +change it. ## The harness's limits @@ -127,3 +182,43 @@ backend is a recorded divergence, not a bug. Cucumber stays out of CI: the TS integration job has no Rust toolchain, so the Rust half (`tests/migrations.rs`, `tests/parity.rs`) runs there against a `postgres` service and the script remains the local, complete run. + +## Consequences + +- The Postgres backend is the reference implementation; every later backend is judged by + the differential fixture. `EventRecord::row_id` is Postgres's `events.id` and is + backend-defined: an LMDB store may leave it `None` on read, and the doc says so. +- `events.expires_at` is a PostgreSQL `integer`, so a NIP-40 expiry beyond `i32::MAX` + (2038-01-19) is `StorageError::ValueOutOfRange` here. An LMDB store would accept it, + which is a future parity question, not an error to paper over. +- `PostgresBackend::begin()` and `PostgresTransaction` are the only transaction entry + point. A backend-agnostic caller (the payments service) cannot be written against two + backends until a trait-level transaction abstraction exists; that is follow-up work. +- The parity projection compares `(event_id, created_at, expires_at, + deleted_at.is_some(), event_deduplication)` and drops call-generated timestamps. It + does not compare `remote_address` or `row_id`; a backend that stored a wrong address + would pass the oracle. If the address becomes part of the row contract, add it to the + projection (the corpus already carries one). +- The harness publishes its ports on `PARITY_BIND` (default `127.0.0.1`) with per-run + generated Postgres and Redis passwords; no host address or credential is committed. +- 43 new non-database tests (records, env resolution, SQL composition, error mapping) keep + `cargo test --workspace` meaningful without a database; the two database targets stay + `#[ignore]`d and run in CI against a `postgres:16-alpine` service. + +## Revisit triggers + +1. **A long-lived LMDB read transaction hurts writes.** If the benchmark follow-up measures + write stalls caused by an open `find_reconciliation_items` stream, change the seam to a + bounded stream (or batched reads) and re-record the `QueryStream` decision; the `Send` + bound and heed's feature stay. +2. **A second backend needs a transaction abstraction.** The first caller that must run + against both backends inside one transaction reopens the transaction-shape decision: + either a trait-level transaction or a backend-specific payments service. +3. **A NIP-40 expiry past 2038 becomes reachable.** If a client can publish one, the + `events.expires_at integer` limit reopens: either widen the column (knex-identical? no — + the schema is the capture's) or clamp and record the divergence in the fixture. +4. **The NIP-50 language changes at runtime.** The index is built with `simple`; changing + `nip50.language` without rebuilding the index silently drops the planner to a scan and + the migrations target's plan assertion is the alarm (it EXPLAINs with `simple`). +5. **The capture's own FTS miss is fixed upstream.** If the TypeScript starts inlining the + configuration, the divergence above disappears and the fixture regenerates unchanged. diff --git a/scripts/storage-parity.sh b/scripts/storage-parity.sh index 9aea44be..43b9d6ed 100755 --- a/scripts/storage-parity.sh +++ b/scripts/storage-parity.sh @@ -4,36 +4,53 @@ # # Usage: scripts/storage-parity.sh # -# Environment: -# PARITY_PG_PORT published Postgres port (default 18776) -# PARITY_REDIS_PORT published Redis port (default 18777) -# PARITY_HOST host the published ports are reached on (default 100.106.134.5) -# NODE_PATH node_modules with cucumber-js and ts-node (default the primary checkout) -# CUCUMBER_BIN cucumber-js binary (default $NODE_PATH/.bin/cucumber-js) +# Environment (no host address or private path is committed; set these per host): +# PARITY_BIND host address the published ports bind to. Default 127.0.0.1, so +# nothing is reachable off-host. On a host where the harness runs +# from another machine (this workspace's sandbox talks to the daemon +# host) set the address that machine reaches, e.g. the tailnet one. +# PARITY_HOST host the harness dials. Default $PARITY_BIND. +# PARITY_PG_PORT published Postgres port (default 18776) +# PARITY_REDIS_PORT published Redis port (default 18777) +# PARITY_PG_PASSWORD Postgres password; generated per run (openssl rand -hex 16) if unset +# PARITY_REDIS_PASSWORD Redis password; generated per run if unset +# NODE_PATH node_modules with cucumber-js and ts-node; required unless +# CUCUMBER_BIN is set +# CUCUMBER_BIN cucumber-js binary (default $NODE_PATH/.bin/cucumber-js) # # Both containers carry the `org.testcontainers=true` label the sandbox's Docker policy -# requires, publish their port instead of bind-mounting (the policy denies a /workspace -# source), and are removed by the exit trap. Nothing is written outside the worktree. +# requires, publish their port on PARITY_BIND only (never a wildcard), use per-run +# credentials, and are removed by the exit trap. Nothing is written outside the worktree. set -eu script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) root=$(CDPATH= cd -- "$script_dir/.." && pwd) cd "$root" +PARITY_BIND=${PARITY_BIND:-127.0.0.1} +PARITY_HOST=${PARITY_HOST:-$PARITY_BIND} PARITY_PG_PORT=${PARITY_PG_PORT:-18776} PARITY_REDIS_PORT=${PARITY_REDIS_PORT:-18777} -PARITY_HOST=${PARITY_HOST:-100.106.134.5} -NODE_ROOT=${NODE_PATH:-/workspace/projects/nostream/node_modules} -CUCUMBER_BIN=${CUCUMBER_BIN:-$NODE_ROOT/.bin/cucumber-js} +PARITY_PG_PASSWORD=${PARITY_PG_PASSWORD:-$(openssl rand -hex 16)} +PARITY_REDIS_PASSWORD=${PARITY_REDIS_PASSWORD:-$(openssl rand -hex 16)} + +if [ -z "${CUCUMBER_BIN:-}" ]; then + if [ -z "${NODE_PATH:-}" ]; then + echo "storage-parity: set CUCUMBER_BIN or NODE_PATH to a node_modules with cucumber-js" >&2 + exit 64 + fi + CUCUMBER_BIN="$NODE_PATH/.bin/cucumber-js" +fi pg_container="nostream-parity-pg-$$" redis_container="nostream-parity-redis-$$" -admin_url="postgres://postgres:postgres@$PARITY_HOST:$PARITY_PG_PORT/postgres" +admin_url="postgres://postgres:$PARITY_PG_PASSWORD@$PARITY_HOST:$PARITY_PG_PORT/postgres" cleanup() { docker rm -f "$pg_container" "$redis_container" >/dev/null 2>&1 || true } -trap cleanup EXIT INT HUP TERM +trap cleanup EXIT +trap 'exit 130' INT HUP TERM fail() { echo "storage-parity: $1" >&2 @@ -51,13 +68,14 @@ wait_for_log() { done } -echo "== containers" +echo "== containers (bound to $PARITY_BIND, per-run credentials)" docker rm -f "$pg_container" "$redis_container" >/dev/null 2>&1 || true docker create --name "$pg_container" --label org.testcontainers=true \ - -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres \ - -p "$PARITY_PG_PORT:5432" postgres:16-alpine >/dev/null + -e POSTGRES_PASSWORD="$PARITY_PG_PASSWORD" -e POSTGRES_DB=postgres \ + -p "$PARITY_BIND:$PARITY_PG_PORT:5432" postgres:16-alpine >/dev/null docker create --name "$redis_container" --label org.testcontainers=true \ - -p "$PARITY_REDIS_PORT:6379" redis:7-alpine >/dev/null + -p "$PARITY_BIND:$PARITY_REDIS_PORT:6379" redis:7-alpine \ + redis-server --requirepass "$PARITY_REDIS_PASSWORD" >/dev/null docker start "$pg_container" "$redis_container" >/dev/null wait_for_log "$pg_container" "database system is ready to accept connections" wait_for_log "$redis_container" "Ready to accept connections" @@ -74,11 +92,11 @@ echo "== behavioural parity" || fail "the parity target failed" echo "== cucumber on the sqlx-migrated schema" -[ -x "$CUCUMBER_BIN" ] || fail "cucumber-js not found at $CUCUMBER_BIN (set CUCUMBER_BIN or NODE_PATH)" +[ -x "$CUCUMBER_BIN" ] || fail "cucumber-js not found at $CUCUMBER_BIN" mkdir -p .test-reports/integration -DB_URI="postgres://postgres:postgres@$PARITY_HOST:$PARITY_PG_PORT/nostream_parity_cucumber" \ -REDIS_URI="redis://$PARITY_HOST:$PARITY_REDIS_PORT" \ -NODE_PATH="$NODE_ROOT" \ +DB_URI="postgres://postgres:$PARITY_PG_PASSWORD@$PARITY_HOST:$PARITY_PG_PORT/nostream_parity_cucumber" \ +REDIS_URI="redis://:$PARITY_REDIS_PASSWORD@$PARITY_HOST:$PARITY_REDIS_PORT" \ +NODE_PATH="${NODE_PATH:-}" \ TS_NODE_TRANSPILE_ONLY=true \ NODE_OPTIONS=--no-experimental-strip-types \ "$CUCUMBER_BIN" --format summary \ From dbf4e0f159c1c4c558c3a184682c6ccf6d4ffb01 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 09:50:54 -0400 Subject: [PATCH 07/16] docs(storage): record the query costs inherited from the capture --- .../0003-storage-seam-and-postgres.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/rewrite/decisions/0003-storage-seam-and-postgres.md b/docs/rewrite/decisions/0003-storage-seam-and-postgres.md index 57199211..ca1ed0a8 100644 --- a/docs/rewrite/decisions/0003-storage-seam-and-postgres.md +++ b/docs/rewrite/decisions/0003-storage-seam-and-postgres.md @@ -205,6 +205,25 @@ the script remains the local, complete run. `cargo test --workspace` meaningful without a database; the two database targets stay `#[ignore]`d and run in CI against a `postgres:16-alpine` service. +## Costs inherited from the capture + +These queries are the capture's SQL and index set; the performance lens measured them and +they are recorded here so the benchmark follow-up starts from facts, not surprise: + +- `count_by_filters` hashes the whole matching id set (267.6 ms for two broad branches at + 200k events, 7 MB spilled); NIP-45 COUNT inherits it. +- A tag `EXISTS` with many values gathers every matching event before the `LIMIT` (44.9 ms + for 200 `#p` values); a covering `(tag_name, tag_value, event_id)` index would help, but + the schema is pinned to the capture. +- A kind-only limited REQ walks the `created_at` index and filters (5 508 rows discarded to + return 500); there is no `(event_kind, created_at DESC, event_id)` index in the capture. +- `claim_batch`'s `pending OR processing` predicate cannot use + `idx_notification_outbox_dispatch` (12.2 ms and a scan at 20k outbox rows). +- Hex prefix criteria use `substring(...)`, which is not sargable. + +None of these is introduced here, and changing any would diverge from the capture's +schema or SQL; the hunt for them belongs to the benchmark rig (follow-up 6). + ## Revisit triggers 1. **A long-lived LMDB read transaction hurts writes.** If the benchmark follow-up measures From 7349441953f77cd399d732966b97be9a1b9a5ab1 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 09:57:01 -0400 Subject: [PATCH 08/16] fix(scripts): fail fast when the parity bind cannot be dialled; prove the credential is not echoed The 127.0.0.1 default is right for CI and for a host run and wrong for this sandbox, where the cargo tests run inside the builder container whose loopback is not the daemon host's. Preflight now refuses a loopback bind against a remote daemon with one line naming PARITY_BIND and PARITY_HOST, probes the dialled address with a three-second timeout before the first container starts, and runs a forced connection-failure test that fails the harness if the credential reaches the log. --- crates/nostream-storage/tests/migrations.rs | 20 ++++++++++ scripts/storage-parity.sh | 43 +++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/crates/nostream-storage/tests/migrations.rs b/crates/nostream-storage/tests/migrations.rs index 288c9e65..6ef864b0 100644 --- a/crates/nostream-storage/tests/migrations.rs +++ b/crates/nostream-storage/tests/migrations.rs @@ -382,3 +382,23 @@ fn a_scratch_url_replaces_only_the_database() { "postgres://u:p@host:5432/scratch?sslmode=disable" ); } + +/// The failure path must not echo the credentials: sqlx redacts the connection string in +/// its `Display`/`Debug`, and this test is the guard. A panicking test that formatted the +/// environment value itself would leak, so the assertion runs on the error, not the URL. +#[tokio::test] +async fn a_failed_connection_does_not_echo_the_password() { + let password = "leak-marker-9f3a1c"; + let url = format!("postgres://postgres:{password}@127.0.0.1:1/postgres"); + // A refused connection retries until the acquire timeout; keep the probe short. + let error = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(std::time::Duration::from_secs(2)) + .connect(&url) + .await + .expect_err("nothing listens on port 1"); + let rendered = format!("{error}\n{error:?}"); + assert!( + !rendered.contains(password), + "the connection error echoed the password: {rendered}" + ); +} diff --git a/scripts/storage-parity.sh b/scripts/storage-parity.sh index 43b9d6ed..4e12f286 100755 --- a/scripts/storage-parity.sh +++ b/scripts/storage-parity.sh @@ -18,6 +18,11 @@ # CUCUMBER_BIN is set # CUCUMBER_BIN cucumber-js binary (default $NODE_PATH/.bin/cucumber-js) # +# Preflight: with a local Docker daemon, PARITY_BIND=127.0.0.1 is right; with a remote +# daemon (DOCKER_HOST=tcp://…) it refuses, because the daemon's loopback is not this +# machine's. The published port is then probed with a three-second timeout, and the +# connection-failure redaction test runs before any container exists. +# # Both containers carry the `org.testcontainers=true` label the sandbox's Docker policy # requires, publish their port on PARITY_BIND only (never a wildcard), use per-run # credentials, and are removed by the exit trap. Nothing is written outside the worktree. @@ -57,6 +62,38 @@ fail() { exit 1 } +is_loopback() { + case "$1" in + 127.*|localhost|::1) return 0 ;; + *) return 1 ;; + esac +} + +# Preflight, before any container exists. A loopback bind is right for a daemon on this +# machine and wrong for a remote one: the daemon would publish on its own loopback, which +# this machine cannot dial. No default guesses the daemon host's address. +if is_loopback "$PARITY_BIND" && [ -n "${DOCKER_HOST:-}" ]; then + daemon=${DOCKER_HOST#*://} + daemon=${daemon%%:*} + if ! is_loopback "$daemon"; then + fail "PARITY_BIND=$PARITY_BIND is this machine's loopback, but the Docker daemon is remote (DOCKER_HOST=$DOCKER_HOST): set PARITY_BIND and PARITY_HOST to an address the daemon host accepts and this machine can reach" + fi +fi +docker version >/dev/null 2>&1 \ + || fail "the Docker daemon at ${DOCKER_HOST:-/var/run/docker.sock} is not reachable" + +# A forced connection failure must not echo the credential; the test fails if it does. +echo "== credential redaction" +./scripts/rust.sh cargo test -p nostream-storage --test migrations \ + a_failed_connection_does_not_echo_the_password -- --exact \ + || fail "the connection-failure log echoed a credential" + +# The dialed address, probed with a three-second timeout: a refusal here names the two +# variables instead of surfacing as a sqlx timeout later. +probe_dial() { + node -e 'const net = require("net"); const s = net.connect({ host: process.argv[1], port: Number(process.argv[2]) }); s.setTimeout(3000); s.on("connect", () => { s.destroy(); process.exit(0) }); s.on("timeout", () => { s.destroy(); process.exit(1) }); s.on("error", () => process.exit(1))' "$PARITY_HOST" "$PARITY_PG_PORT" +} + wait_for_log() { container=$1 pattern=$2 @@ -79,6 +116,12 @@ docker create --name "$redis_container" --label org.testcontainers=true \ docker start "$pg_container" "$redis_container" >/dev/null wait_for_log "$pg_container" "database system is ready to accept connections" wait_for_log "$redis_container" "Ready to accept connections" +attempts=0 +until probe_dial; do + attempts=$((attempts + 1)) + [ "$attempts" -ge 5 ] && fail "the published Postgres port is not reachable at $PARITY_HOST:$PARITY_PG_PORT: set PARITY_BIND and PARITY_HOST to an address this machine can dial (127.0.0.1 on the daemon host; the daemon host's reachable address from elsewhere)" + sleep 1 +done echo " postgres and redis are accepting connections on $PARITY_HOST" echo "== migrations and schema parity" From 124be0ee17b6629a10750ae386ef09e3221514e8 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 10:04:13 -0400 Subject: [PATCH 09/16] fix(storage): feature-gate the database test targets and EXPLAIN the composer's search SQL --- .github/workflows/checks.yml | 2 + .../nostream-storage/src/postgres/events.rs | 9 ++-- crates/nostream-storage/src/postgres/mod.rs | 13 +++++ crates/nostream-storage/src/repositories.rs | 11 +++- crates/nostream-storage/tests/common/mod.rs | 27 ++++++++++ crates/nostream-storage/tests/migrations.rs | 50 +++++++++++++++---- crates/nostream-storage/tests/parity.rs | 24 ++------- crates/nostream-storage/tests/storage.rs | 15 +++++- 8 files changed, 113 insertions(+), 38 deletions(-) create mode 100644 crates/nostream-storage/tests/common/mod.rs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index a54208b5..17e64508 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -270,6 +270,8 @@ jobs: env: PARITY_ADMIN_URL: postgres://postgres:postgres@localhost:5432/postgres run: cargo test -p nostream-storage --test migrations --test parity -- --ignored --test-threads=1 + - name: Storage builds without the Postgres driver + run: cargo check -p nostream-storage --no-default-features --all-targets - name: Release build run: cargo build --workspace --all-features --release diff --git a/crates/nostream-storage/src/postgres/events.rs b/crates/nostream-storage/src/postgres/events.rs index d9bb73f3..c89d19c0 100644 --- a/crates/nostream-storage/src/postgres/events.rs +++ b/crates/nostream-storage/src/postgres/events.rs @@ -35,11 +35,12 @@ event_content, event_signature, remote_address, expires_at, event_deduplication, /// The kind of the capture's `REQUEST_TO_VANISH`, which every retention whitelist keeps. const REQUEST_TO_VANISH: i32 = 62; -/// PostgreSQL's bind limit is 65 535 parameters per statement; one insert row costs nine -/// parameters and one upsert row eleven, so these are the largest single-statement batches -/// the server accepts. Up to one batch, this is exactly the capture's one statement. +/// PostgreSQL's bind limit is 65 535 parameters per statement. One `create` row binds nine +/// parameters (`INSERT_COLUMNS`) and one `upsert` row binds ten (`UPSERT_COLUMNS`), so these +/// are the largest single-statement batches the server accepts. Up to one batch, this is +/// exactly the capture's one statement. const CREATE_BATCH_ROWS: usize = 65_535 / 9; -const UPSERT_BATCH_ROWS: usize = 65_535 / 11; +const UPSERT_BATCH_ROWS: usize = 65_535 / 10; /// `create`: `INSERT ... ON CONFLICT (event_id) DO NOTHING`, returning the row count. pub(crate) async fn create<'e, E>(executor: E, record: &EventRecord) -> Result diff --git a/crates/nostream-storage/src/postgres/mod.rs b/crates/nostream-storage/src/postgres/mod.rs index 0bd0ddf2..ed949010 100644 --- a/crates/nostream-storage/src/postgres/mod.rs +++ b/crates/nostream-storage/src/postgres/mod.rs @@ -134,6 +134,19 @@ impl PostgresBackend { migrate_pool(&self.master).await } + /// The SQL `find_by_filters` composes for these filters, at this backend's settings. + /// + /// The migrations target EXPLAINs it, so the NIP-50 plan guard sees the composer's own + /// text rather than a hand-written copy (ADR 0003, revisit trigger 4). Not part of the + /// repository seam. + #[doc(hidden)] + pub fn event_query_sql( + &self, + filters: &[nostream_core::Filter], + ) -> Result { + sql::find_by_filters(filters, &self.gates()).map(|query| query.sql) + } + /// Opens a serializable transaction (`src/database/transaction.ts:12`). pub fn begin(&self) -> crate::repositories::StorageFuture<'_, PostgresTransaction> { Box::pin(async move { diff --git a/crates/nostream-storage/src/repositories.rs b/crates/nostream-storage/src/repositories.rs index 3f0cfff6..407c6749 100644 --- a/crates/nostream-storage/src/repositories.rs +++ b/crates/nostream-storage/src/repositories.rs @@ -353,13 +353,20 @@ pub trait EventRepository: Send + Sync { /// Inserts one event, returning the affected row count (0 on a duplicate `event_id`). fn create<'a>(&'a self, record: &'a EventRecord) -> StorageFuture<'a, u64>; - /// Inserts every event in one statement, returning the affected row count. + /// Inserts every event, returning the affected row count. + /// + /// Atomicity is backend-defined: the Postgres backend sends one statement up to its bind + /// limit and batches beyond it, so a mid-batch error can leave earlier batches written + /// (ADR 0003, "Batching"). A caller that needs all-or-nothing for any size owns a + /// transaction. fn create_many<'a>(&'a self, records: &'a [EventRecord]) -> StorageFuture<'a, u64>; /// Inserts or replaces a replaceable event, returning the affected row count. fn upsert<'a>(&'a self, record: &'a EventRecord) -> StorageFuture<'a, u64>; - /// Inserts or replaces every event in one statement, returning the affected row count. + /// Inserts or replaces every event, returning the affected row count. + /// + /// The same backend-defined atomicity as [`EventRepository::create_many`]. fn upsert_many<'a>(&'a self, records: &'a [EventRecord]) -> StorageFuture<'a, u64>; /// Streams the events matching the filters, in the query's order. diff --git a/crates/nostream-storage/tests/common/mod.rs b/crates/nostream-storage/tests/common/mod.rs new file mode 100644 index 00000000..47d36663 --- /dev/null +++ b/crates/nostream-storage/tests/common/mod.rs @@ -0,0 +1,27 @@ +//! Helpers shared by the storage integration tests. A directory module, so cargo does not +//! build it as a target of its own. + +#![allow(dead_code)] + +use nostream_config::Settings; +use serde_json::Value; + +/// The shipped defaults with a fixture's overrides applied — the settings both the capture +/// and the replay load, so the gates under test are the fixture's. +pub fn settings_from_fixture(fixture: &Value) -> Settings { + let raw = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/default-settings.yaml" + )) + .expect("the shipped defaults are readable"); + let mut document: serde_yaml::Value = + serde_yaml::from_str(&raw).expect("the shipped defaults are valid YAML"); + if let Some(overrides) = fixture["settings"]["overrides"].as_object() { + for (path, value) in overrides { + let yaml = serde_yaml::to_value(value).expect("a YAML value"); + document = nostream_config::path::set_by_path(&document, path, yaml) + .expect("the override path is writable"); + } + } + serde_yaml::from_value(document).expect("the settings fit the typed schema") +} diff --git a/crates/nostream-storage/tests/migrations.rs b/crates/nostream-storage/tests/migrations.rs index 6ef864b0..08e049b8 100644 --- a/crates/nostream-storage/tests/migrations.rs +++ b/crates/nostream-storage/tests/migrations.rs @@ -6,11 +6,14 @@ //! The test creates and migrates its own scratch databases, so either database-backed target //! can run alone. +#![cfg(feature = "postgres")] // This integration-test target may unwrap: a failed assertion here is a failed test, // which is the point. The allowance names this file rather than exempting test targets // workspace-wide, so a new test file still has to ask. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod common; + use serde_json::{Value, json}; use sqlx::postgres::PgPool; use sqlx::{Executor, Row}; @@ -74,18 +77,46 @@ async fn applied_migrations(pool: &PgPool) -> Vec<(i64, bool)> { .collect() } -async fn search_plan(pool: &PgPool) -> String { +/// EXPLAINs the composer's own search statement, prepared with the same binds the backend +/// sends. Sequential scans are disabled, so the only way to answer it is the GIN index the +/// migrations built on `to_tsvector('simple', event_content)`; a configuration the index was +/// not built with shows up here as a scan and fails the target. +async fn composer_search_plan(pool: &PgPool) -> String { + let fixture: Value = serde_json::from_str(include_str!("fixtures/repository-parity.json")) + .expect("the parity fixture is valid JSON"); + let settings = common::settings_from_fixture(&fixture); + let backend = + nostream_storage::PostgresBackend::connect(&with_database(&admin_url(), RUST_DATABASE)) + .await + .expect("the parity database is reachable") + .with_settings(std::sync::Arc::new(settings)); + let filter = nostream_core::Filter { + search: Some("bitcoin lightning".to_owned()), + ..Default::default() + }; + let composed = backend + .event_query_sql(std::slice::from_ref(&filter)) + .expect("the composer builds the search statement"); + assert!( + composed.contains("to_tsvector('simple'::regconfig, events.event_content)"), + "the composer's NIP-50 predicate is not the indexed expression: {composed}" + ); + let mut transaction = pool.begin().await.expect("a transaction"); sqlx::query("SET LOCAL enable_seqscan = off") .execute(&mut *transaction) .await .expect("sequential scans can be disabled"); + sqlx::query(sqlx::AssertSqlSafe(format!( + "PREPARE nip50_plan AS {composed}" + ))) + .execute(&mut *transaction) + .await + .expect("the composed statement prepares"); let rows = sqlx::query( - "EXPLAIN (ANALYZE, BUFFERS) SELECT event_id FROM events \ - WHERE to_tsvector('simple'::regconfig, event_content) @@ \ - plainto_tsquery('simple'::regconfig, $1)", + "EXPLAIN (ANALYZE, BUFFERS) EXECUTE nip50_plan('bitcoin lightning', \ + 'bitcoin lightning', 500)", ) - .bind("bitcoin lightning") .fetch_all(&mut *transaction) .await .expect("the search plan is explainable"); @@ -345,11 +376,10 @@ async fn the_migrations_apply_from_empty_are_idempotent_and_match_the_capture_sc assert_same(&actual, &fixture()); // The NIP-50 predicate must be able to use the GIN index: the configuration is inlined - // as a constant (not a `text` bind, which would make the expression non-constant). With - // sequential scans disabled, the only way to answer the search is that index; a - // rewritten predicate that no longer matches the index expression shows up here as a - // Seq Scan (or no plan at all) rather than as a slower search. - let plan = search_plan(backend.master()).await; + // as a constant (not a `text` bind, which would make the expression non-constant). This + // EXPLAINs the composer's statement at the fixture's settings, so a configuration the + // index was not built with fails here rather than as a slower search in production. + let plan = composer_search_plan(backend.master()).await; assert!( plan.contains("events_content_fts_idx"), "the NIP-50 predicate does not use the FTS index:\n{plan}" diff --git a/crates/nostream-storage/tests/parity.rs b/crates/nostream-storage/tests/parity.rs index 554639c5..14b68e43 100644 --- a/crates/nostream-storage/tests/parity.rs +++ b/crates/nostream-storage/tests/parity.rs @@ -5,6 +5,7 @@ //! The test creates the database if absent and runs the migrator, so this target and //! `migrations.rs` have no ordering dependency. +#![cfg(feature = "postgres")] // This integration-test target may unwrap: a failed assertion here is a failed test, // which is the point. The allowance names this file rather than exempting test targets // workspace-wide, so a new test file still has to ask. @@ -19,9 +20,10 @@ use serde_json::{Value, json}; use sqlx::Executor; use sqlx::postgres::PgPool; -use nostream_config::Settings; use nostream_core::{Filter, Kind}; use nostream_storage::postgres::{PostgresBackend, PostgresTransaction}; +mod common; + use nostream_storage::repositories::{ ActionableTarget, CreateInviteCodeOptions, DeliveryLogEntry, DeliveryLogFilters, DvmJob, DvmJobRepository, DvmJobUpdate, EventPurgeCounts, EventRecord, EventRepository, @@ -978,24 +980,6 @@ async fn run_transaction(backend: &PostgresBackend, op: &Value) -> Value { } } -fn settings_from_fixture(fixture: &Value) -> Settings { - let raw = std::fs::read_to_string(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../resources/default-settings.yaml" - )) - .expect("the shipped defaults are readable"); - let mut document: serde_yaml::Value = - serde_yaml::from_str(&raw).expect("the shipped defaults are valid YAML"); - if let Some(overrides) = fixture["settings"]["overrides"].as_object() { - for (path, value) in overrides { - let yaml = serde_yaml::to_value(value).expect("a YAML value"); - document = nostream_config::path::set_by_path(&document, path, yaml) - .expect("the override path is writable"); - } - } - serde_yaml::from_value(document).expect("the settings fit the typed schema") -} - #[tokio::test] #[ignore = "needs PARITY_ADMIN_URL; scripts/storage-parity.sh sets it"] async fn the_typescript_script_replays_with_an_empty_diff() { @@ -1019,7 +1003,7 @@ async fn the_typescript_script_replays_with_an_empty_diff() { let backend = PostgresBackend::connect(&with_database(&admin_url, DATABASE)) .await .expect("the parity database is reachable") - .with_settings(Arc::new(settings_from_fixture(&fixture))); + .with_settings(Arc::new(common::settings_from_fixture(&fixture))); backend.migrate().await.expect("the schema migrates"); let corpus: Vec = fixture["corpus"] diff --git a/crates/nostream-storage/tests/storage.rs b/crates/nostream-storage/tests/storage.rs index 18d0b113..f0e79090 100644 --- a/crates/nostream-storage/tests/storage.rs +++ b/crates/nostream-storage/tests/storage.rs @@ -35,7 +35,6 @@ async fn the_repository_traits_are_dyn_compatible() { use std::sync::Arc; use nostream_core::{EventId, Filter, Kind, Pubkey}; - use nostream_storage::PostgresBackend; use nostream_storage::StorageError; use nostream_storage::repositories::{ ActionableTarget, CreateInviteCodeOptions, DeliveryLogEntry, DeliveryLogFilters, DvmJob, @@ -386,8 +385,20 @@ async fn the_repository_traits_are_dyn_compatible() { let _: Arc = noop.clone(); let _: Arc = noop.clone(); let _: Arc = noop; +} + +/// The real backend coerces into the same nine forms without a database. Separate from the +/// `Noop` check so the seam still compiles and is tested with the `postgres` feature off. +#[cfg(feature = "postgres")] +#[tokio::test] +async fn the_postgres_backend_coerces_to_all_nine_trait_objects() { + use nostream_storage::PostgresBackend; + use nostream_storage::repositories::{ + DvmJobRepository, EventRepository, InviteCodeRepository, InvoiceRepository, + Nip05VerificationRepository, NotificationDeliveryLogRepository, + NotificationOutboxRepository, ReportRepository, UserRepository, + }; - // The real backend coerces into the same nine forms without a database. let backend = PostgresBackend::connect_lazy("postgres://postgres:postgres@127.0.0.1/postgres") .expect("a lazy backend"); let _: &dyn EventRepository = &backend; From 847772ec5204a7a4bf35f2f5a809c19cea940dff Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 10:04:13 -0400 Subject: [PATCH 10/16] fix(scripts): parse the daemon endpoint, refuse wildcard binds, require the redaction test to run --- scripts/storage-parity.sh | 50 +++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/scripts/storage-parity.sh b/scripts/storage-parity.sh index 4e12f286..cab2bd11 100755 --- a/scripts/storage-parity.sh +++ b/scripts/storage-parity.sh @@ -69,24 +69,48 @@ is_loopback() { esac } -# Preflight, before any container exists. A loopback bind is right for a daemon on this -# machine and wrong for a remote one: the daemon would publish on its own loopback, which -# this machine cannot dial. No default guesses the daemon host's address. -if is_loopback "$PARITY_BIND" && [ -n "${DOCKER_HOST:-}" ]; then - daemon=${DOCKER_HOST#*://} - daemon=${daemon%%:*} - if ! is_loopback "$daemon"; then - fail "PARITY_BIND=$PARITY_BIND is this machine's loopback, but the Docker daemon is remote (DOCKER_HOST=$DOCKER_HOST): set PARITY_BIND and PARITY_HOST to an address the daemon host accepts and this machine can reach" - fi +# Only a TCP (or SSH) daemon endpoint can be on another machine. A unix socket, a bare +# socket path, fd:// and npipe:// all name a daemon on this machine, and tcp://[::1] is the +# local loopback spelled in brackets. +is_remote_daemon() { + case "${DOCKER_HOST:-}" in + tcp://*|ssh://*) + daemon=${DOCKER_HOST#*://} + case "$daemon" in + \[*) daemon=${daemon#\[}; daemon=${daemon%%\]*} ;; + *) daemon=${daemon%%:*} ;; + esac + ! is_loopback "$daemon" + ;; + *) return 1 ;; + esac +} + +# Preflight, before any container exists. A wildcard bind is never right, and a loopback +# bind is right only for a daemon on this machine. No default guesses a daemon's address. +case "$PARITY_BIND" in + 0.0.0.0|'::'|'*') + fail "PARITY_BIND=$PARITY_BIND is a wildcard: publish on one address (127.0.0.1 for a local daemon, or the daemon host's reachable address)" + ;; +esac +if is_loopback "$PARITY_BIND" && is_remote_daemon; then + fail "PARITY_BIND=$PARITY_BIND is this machine's loopback, but the Docker daemon is remote (DOCKER_HOST=$DOCKER_HOST): set PARITY_BIND and PARITY_HOST to an address the daemon host accepts and this machine can reach" fi docker version >/dev/null 2>&1 \ || fail "the Docker daemon at ${DOCKER_HOST:-/var/run/docker.sock} is not reachable" -# A forced connection failure must not echo the credential; the test fails if it does. +# A forced connection failure must not echo the credential; the test fails if it does, and +# the run fails if the test was renamed away instead of passing. echo "== credential redaction" -./scripts/rust.sh cargo test -p nostream-storage --test migrations \ - a_failed_connection_does_not_echo_the_password -- --exact \ - || fail "the connection-failure log echoed a credential" +redaction_output=$(./scripts/rust.sh cargo test -p nostream-storage --test migrations \ + a_failed_connection_does_not_echo_the_password -- --exact 2>&1) || { + printf '%s\n' "$redaction_output" + fail "the connection-failure log echoed a credential" +} +printf '%s\n' "$redaction_output" | grep -q '1 passed' || { + printf '%s\n' "$redaction_output" + fail "the credential-redaction test did not run (renamed?)" +} # The dialed address, probed with a three-second timeout: a refusal here names the two # variables instead of surfacing as a sqlx timeout later. From 281b3b35ca4695333bee2fafcb31704f0b96f9b5 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 10:04:13 -0400 Subject: [PATCH 11/16] docs(storage): correct heed NO_TLS, fixture counts and revisit triggers --- .../0003-storage-seam-and-postgres.md | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/docs/rewrite/decisions/0003-storage-seam-and-postgres.md b/docs/rewrite/decisions/0003-storage-seam-and-postgres.md index ca1ed0a8..dcea2992 100644 --- a/docs/rewrite/decisions/0003-storage-seam-and-postgres.md +++ b/docs/rewrite/decisions/0003-storage-seam-and-postgres.md @@ -163,17 +163,21 @@ a second implementation. `QueryStream` is `Send`-bounded, which matches how the relay consumes it. A stream that owns an LMDB read transaction can only satisfy that bound with heed's `read-txn-no-tls` feature (heed 0.20.5 `src/txn.rs`: `RoTxn: Send` under that feature), so the workspace -enables it. The feature changes no LMDB behaviour; it declares that read transactions may -cross threads. The cost the LMDB backend inherits: a `QueryStream` holds a read transaction -for its whole life, which delays reuse of pages freed by writers. That is deliberate for -reconciliation (a materialised `Vec` of 10M pairs is worse), and revisit 1 below says when to -change it. +enables it. The feature is not free of behaviour: `EnvOpenOptions::open` ORs +`EnvFlags::NO_TLS` (LMDB's `MDB_NOTLS`) into every environment the crate opens, so reader +slots move to the transaction instead of thread-local storage — that forced flag is what +makes moving a read transaction across threads sound. The LMDB backend inherits it for +every environment it creates and must reason about it in revisit 1: a held read transaction +prevents reuse of pages freed by writers for as long as the stream lives. That is +deliberate for reconciliation (a materialised `Vec` of 10M pairs is worse), and revisit 1 +says when to change it. ## The harness's limits `scripts/storage-parity.sh` is the oracle: the 37 migrations apply from empty and match -the knex catalogue; 110 recorded operations covering all 51 methods replay with an empty -diff; and the unmodified TypeScript relay's 118 Cucumber scenarios run against the +the knex catalogue; the fixture's 115 script entries — 112 direct method calls, 2 +serializable transaction groups holding 7 nested calls, and one direct-column probe +(`user.tos_accepted_at`), covering all 51 methods — replay with an empty diff; and the unmodified TypeScript relay's 118 Cucumber scenarios run against the sqlx-migrated database. What it does not judge: the 192 SQL-text unit tests (they are Postgres-implementation assertions), read-replica *behaviour* (only URL selection is tested), LMDB, and NIP-50 ranking beyond "same hits, same deterministic order" — the @@ -199,6 +203,9 @@ the script remains the local, complete run. does not compare `remote_address` or `row_id`; a backend that stored a wrong address would pass the oracle. If the address becomes part of the row contract, add it to the projection (the corpus already carries one). +- `EventRecord` is `Serialize + Deserialize` with no version tag and no `#[serde(default)]`. + An LMDB store that writes it must decide its encoding version before the first field is + added, or existing rows stop decoding; that decision belongs to follow-up 3 (trigger 6). - The harness publishes its ports on `PARITY_BIND` (default `127.0.0.1`) with per-run generated Postgres and Redis passwords; no host address or credential is committed. - 43 new non-database tests (records, env resolution, SQL composition, error mapping) keep @@ -236,8 +243,16 @@ schema or SQL; the hunt for them belongs to the benchmark rig (follow-up 6). 3. **A NIP-40 expiry past 2038 becomes reachable.** If a client can publish one, the `events.expires_at integer` limit reopens: either widen the column (knex-identical? no — the schema is the capture's) or clamp and record the divergence in the fixture. -4. **The NIP-50 language changes at runtime.** The index is built with `simple`; changing - `nip50.language` without rebuilding the index silently drops the planner to a scan and - the migrations target's plan assertion is the alarm (it EXPLAINs with `simple`). +4. **The NIP-50 language changes at runtime.** The index is built with `simple`. The + migrations target EXPLAINs the *composer's* statement at the fixture's settings, so a + composer or schema change that stops matching the index fails that target; it cannot + see a deployment that runs a different `nip50.language`, because the harness loads the + fixture's settings. That deployment's alarm is the same EXPLAIN run with its settings — + i.e. regenerate the fixture (or run the target with the deployed settings) after + changing the language, and rebuild `events_content_fts_idx` with it. 5. **The capture's own FTS miss is fixed upstream.** If the TypeScript starts inlining the - configuration, the divergence above disappears and the fixture regenerates unchanged. + configuration, the divergence above disappears; the fixture's compared values do not + change (its `subject` block is regenerated, so the file itself does). +6. **The LMDB row encoding gains a field.** Before `EventRecord` is persisted by a second + backend, version the encoding (a header tag or `#[serde(default)]` on new fields), or + decoding an existing store breaks on the first schema change. From 82e801af0cc168b3e5a46f298b7d0a936453d406 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 10:15:18 -0400 Subject: [PATCH 12/16] fix(storage): normalise bracketed IPv6 before the wildcard and loopback checks; correct the record and add the language switch --- .../nostream-storage/src/postgres/events.rs | 7 +++--- crates/nostream-storage/tests/common/mod.rs | 10 +++++++++ .../0003-storage-seam-and-postgres.md | 22 ++++++++++--------- scripts/storage-parity.sh | 17 ++++++++++---- 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/crates/nostream-storage/src/postgres/events.rs b/crates/nostream-storage/src/postgres/events.rs index c89d19c0..0e17d26a 100644 --- a/crates/nostream-storage/src/postgres/events.rs +++ b/crates/nostream-storage/src/postgres/events.rs @@ -35,9 +35,10 @@ event_content, event_signature, remote_address, expires_at, event_deduplication, /// The kind of the capture's `REQUEST_TO_VANISH`, which every retention whitelist keeps. const REQUEST_TO_VANISH: i32 = 62; -/// PostgreSQL's bind limit is 65 535 parameters per statement. One `create` row binds nine -/// parameters (`INSERT_COLUMNS`) and one `upsert` row binds ten (`UPSERT_COLUMNS`), so these -/// are the largest single-statement batches the server accepts. Up to one batch, this is +/// PostgreSQL's bind limit is 65 535 parameters per statement. One `create` row binds the +/// nine insert values; one `upsert` row binds those nine plus the deduplication value +/// (`deleted_at` is the literal NULL in the tuple, so its column costs no parameter). These +/// are the largest single-statement batches the server accepts; up to one batch, this is /// exactly the capture's one statement. const CREATE_BATCH_ROWS: usize = 65_535 / 9; const UPSERT_BATCH_ROWS: usize = 65_535 / 10; diff --git a/crates/nostream-storage/tests/common/mod.rs b/crates/nostream-storage/tests/common/mod.rs index 47d36663..874d93af 100644 --- a/crates/nostream-storage/tests/common/mod.rs +++ b/crates/nostream-storage/tests/common/mod.rs @@ -8,6 +8,8 @@ use serde_json::Value; /// The shipped defaults with a fixture's overrides applied — the settings both the capture /// and the replay load, so the gates under test are the fixture's. +/// `PARITY_NIP50_LANGUAGE` overrides the fixture's `nip50.language`, so the NIP-50 plan +/// guard can be run against a deployed configuration (ADR 0003, revisit trigger 4). pub fn settings_from_fixture(fixture: &Value) -> Settings { let raw = std::fs::read_to_string(concat!( env!("CARGO_MANIFEST_DIR"), @@ -23,5 +25,13 @@ pub fn settings_from_fixture(fixture: &Value) -> Settings { .expect("the override path is writable"); } } + if let Ok(language) = std::env::var("PARITY_NIP50_LANGUAGE") { + document = nostream_config::path::set_by_path( + &document, + "nip50.language", + serde_yaml::Value::String(language), + ) + .expect("nip50.language is writable"); + } serde_yaml::from_value(document).expect("the settings fit the typed schema") } diff --git a/docs/rewrite/decisions/0003-storage-seam-and-postgres.md b/docs/rewrite/decisions/0003-storage-seam-and-postgres.md index dcea2992..694e8354 100644 --- a/docs/rewrite/decisions/0003-storage-seam-and-postgres.md +++ b/docs/rewrite/decisions/0003-storage-seam-and-postgres.md @@ -142,8 +142,8 @@ inlined SQL. ## Batching — the bind limit, and its partial-commit window `create_many`/`upsert_many` split their input at PostgreSQL's 65 535-bind limit -(7 281 rows at nine binds each, 5 957 at eleven), so up to one batch is the capture's -single statement. Above one batch, a mid-batch error returns `Err` after the earlier +(7 281 rows at nine binds each, 6 553 at ten — an upsert row's `deleted_at` is the literal +NULL, so it costs no parameter), so up to one batch is the capture's single statement. Above one batch, a mid-batch error returns `Err` after the earlier batches committed: the rows already written stay written. The capture's single statement would instead fail wholesale (or, past the same limit, be swallowed into `0` by its `catch`). Callers that need all-or-nothing semantics for more than one batch must own a @@ -175,9 +175,10 @@ says when to change it. ## The harness's limits `scripts/storage-parity.sh` is the oracle: the 37 migrations apply from empty and match -the knex catalogue; the fixture's 115 script entries — 112 direct method calls, 2 -serializable transaction groups holding 7 nested calls, and one direct-column probe -(`user.tos_accepted_at`), covering all 51 methods — replay with an empty diff; and the unmodified TypeScript relay's 118 Cucumber scenarios run against the +the knex catalogue; the fixture's 115 script entries — 111 direct method calls, 2 +serializable transaction groups holding 7 nested calls, and two probe calls of one +direct-column op (`user.tos_accepted_at`) — covering all 51 methods replay with an empty +diff; and the unmodified TypeScript relay's 118 Cucumber scenarios run against the sqlx-migrated database. What it does not judge: the 192 SQL-text unit tests (they are Postgres-implementation assertions), read-replica *behaviour* (only URL selection is tested), LMDB, and NIP-50 ranking beyond "same hits, same deterministic order" — the @@ -245,11 +246,12 @@ schema or SQL; the hunt for them belongs to the benchmark rig (follow-up 6). the schema is the capture's) or clamp and record the divergence in the fixture. 4. **The NIP-50 language changes at runtime.** The index is built with `simple`. The migrations target EXPLAINs the *composer's* statement at the fixture's settings, so a - composer or schema change that stops matching the index fails that target; it cannot - see a deployment that runs a different `nip50.language`, because the harness loads the - fixture's settings. That deployment's alarm is the same EXPLAIN run with its settings — - i.e. regenerate the fixture (or run the target with the deployed settings) after - changing the language, and rebuild `events_content_fts_idx` with it. + composer or schema change that stops matching the index fails that target. A deployment + that runs a different `nip50.language` is covered by the same guard through + `PARITY_NIP50_LANGUAGE`: `PARITY_NIP50_LANGUAGE=english cargo test -p nostream-storage + --test migrations -- --ignored` composes at that language and fails unless the index was + rebuilt with it. The switch is in `tests/common/mod.rs::settings_from_fixture`; editing + the fixture's override is the equivalent path. 5. **The capture's own FTS miss is fixed upstream.** If the TypeScript starts inlining the configuration, the divergence above disappears; the fixture's compared values do not change (its `subject` block is regenerated, so the file itself does). diff --git a/scripts/storage-parity.sh b/scripts/storage-parity.sh index cab2bd11..2a476bc7 100755 --- a/scripts/storage-parity.sh +++ b/scripts/storage-parity.sh @@ -62,8 +62,17 @@ fail() { exit 1 } -is_loopback() { +# Docker accepts a bracketed IPv6 address in `-p`; strip one bracket pair so the loopback +# and wildcard tests see the address itself. +unbracket() { case "$1" in + \[*\]) address=${1#\[}; printf '%s' "${address%\]}" ;; + *) printf '%s' "$1" ;; + esac +} + +is_loopback() { + case "$(unbracket "$1")" in 127.*|localhost|::1) return 0 ;; *) return 1 ;; esac @@ -88,8 +97,8 @@ is_remote_daemon() { # Preflight, before any container exists. A wildcard bind is never right, and a loopback # bind is right only for a daemon on this machine. No default guesses a daemon's address. -case "$PARITY_BIND" in - 0.0.0.0|'::'|'*') +case "$(unbracket "$PARITY_BIND")" in + 0.0.0.0|'::'|'::0'|'0:0:0:0:0:0:0:0'|'*') fail "PARITY_BIND=$PARITY_BIND is a wildcard: publish on one address (127.0.0.1 for a local daemon, or the daemon host's reachable address)" ;; esac @@ -107,7 +116,7 @@ redaction_output=$(./scripts/rust.sh cargo test -p nostream-storage --test migra printf '%s\n' "$redaction_output" fail "the connection-failure log echoed a credential" } -printf '%s\n' "$redaction_output" | grep -q '1 passed' || { +printf '%s\n' "$redaction_output" | grep -q 'test result: ok. 1 passed' || { printf '%s\n' "$redaction_output" fail "the credential-redaction test did not run (renamed?)" } From edfd19d8a76f3d81821107e83ec566fc3134f8ca Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 10:16:10 -0400 Subject: [PATCH 13/16] fix(scripts): refuse every all-zero IPv6 bind spelling and dial the unbracketed host --- scripts/storage-parity.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/storage-parity.sh b/scripts/storage-parity.sh index 2a476bc7..430de44c 100755 --- a/scripts/storage-parity.sh +++ b/scripts/storage-parity.sh @@ -97,10 +97,21 @@ is_remote_daemon() { # Preflight, before any container exists. A wildcard bind is never right, and a loopback # bind is right only for a daemon on this machine. No default guesses a daemon's address. -case "$(unbracket "$PARITY_BIND")" in - 0.0.0.0|'::'|'::0'|'0:0:0:0:0:0:0:0'|'*') +bind_address=$(unbracket "$PARITY_BIND") +case "$bind_address" in + 0.0.0.0|'*') fail "PARITY_BIND=$PARITY_BIND is a wildcard: publish on one address (127.0.0.1 for a local daemon, or the daemon host's reachable address)" ;; + *:*) + # Every spelling of the IPv6 wildcard is all-zero groups: `::`, `0::`, `::0000`, + # `0:0:0:0:0:0:0:0`. Any digit other than 0 (or a hex letter) means a real address. + case "$bind_address" in + *[!0:]*) ;; + *) + fail "PARITY_BIND=$PARITY_BIND is a wildcard: publish on one address (127.0.0.1 for a local daemon, or the daemon host's reachable address)" + ;; + esac + ;; esac if is_loopback "$PARITY_BIND" && is_remote_daemon; then fail "PARITY_BIND=$PARITY_BIND is this machine's loopback, but the Docker daemon is remote (DOCKER_HOST=$DOCKER_HOST): set PARITY_BIND and PARITY_HOST to an address the daemon host accepts and this machine can reach" @@ -124,7 +135,8 @@ printf '%s\n' "$redaction_output" | grep -q 'test result: ok. 1 passed' || { # The dialed address, probed with a three-second timeout: a refusal here names the two # variables instead of surfacing as a sqlx timeout later. probe_dial() { - node -e 'const net = require("net"); const s = net.connect({ host: process.argv[1], port: Number(process.argv[2]) }); s.setTimeout(3000); s.on("connect", () => { s.destroy(); process.exit(0) }); s.on("timeout", () => { s.destroy(); process.exit(1) }); s.on("error", () => process.exit(1))' "$PARITY_HOST" "$PARITY_PG_PORT" + # The probe wants the address itself; URLs keep the bracketed IPv6 spelling. + node -e 'const net = require("net"); const s = net.connect({ host: process.argv[1], port: Number(process.argv[2]) }); s.setTimeout(3000); s.on("connect", () => { s.destroy(); process.exit(0) }); s.on("timeout", () => { s.destroy(); process.exit(1) }); s.on("error", () => process.exit(1))' "$(unbracket "$PARITY_HOST")" "$PARITY_PG_PORT" } wait_for_log() { From 641fa42aac7a58b79ff7f2013c2b9074bb850ccf Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 10:24:58 -0400 Subject: [PATCH 14/16] fix(scripts): classify addresses by their canonical form; reword the NIP-50 trigger --- crates/nostream-storage/tests/common/mod.rs | 4 +- .../0003-storage-seam-and-postgres.md | 20 ++-- scripts/storage-parity.sh | 93 ++++++++++++++----- 3 files changed, 85 insertions(+), 32 deletions(-) diff --git a/crates/nostream-storage/tests/common/mod.rs b/crates/nostream-storage/tests/common/mod.rs index 874d93af..1c003475 100644 --- a/crates/nostream-storage/tests/common/mod.rs +++ b/crates/nostream-storage/tests/common/mod.rs @@ -7,7 +7,9 @@ use nostream_config::Settings; use serde_json::Value; /// The shipped defaults with a fixture's overrides applied — the settings both the capture -/// and the replay load, so the gates under test are the fixture's. +/// and the replay load, so the gates under test are the fixture's. `PARITY_NIP50_LANGUAGE`, +/// when exported, replaces the fixture's language for every target that uses this helper +/// (both database targets), which is how the plan guard sees a deployment's language. /// `PARITY_NIP50_LANGUAGE` overrides the fixture's `nip50.language`, so the NIP-50 plan /// guard can be run against a deployed configuration (ADR 0003, revisit trigger 4). pub fn settings_from_fixture(fixture: &Value) -> Settings { diff --git a/docs/rewrite/decisions/0003-storage-seam-and-postgres.md b/docs/rewrite/decisions/0003-storage-seam-and-postgres.md index 694e8354..bd41074d 100644 --- a/docs/rewrite/decisions/0003-storage-seam-and-postgres.md +++ b/docs/rewrite/decisions/0003-storage-seam-and-postgres.md @@ -244,14 +244,18 @@ schema or SQL; the hunt for them belongs to the benchmark rig (follow-up 6). 3. **A NIP-40 expiry past 2038 becomes reachable.** If a client can publish one, the `events.expires_at integer` limit reopens: either widen the column (knex-identical? no — the schema is the capture's) or clamp and record the divergence in the fixture. -4. **The NIP-50 language changes at runtime.** The index is built with `simple`. The - migrations target EXPLAINs the *composer's* statement at the fixture's settings, so a - composer or schema change that stops matching the index fails that target. A deployment - that runs a different `nip50.language` is covered by the same guard through - `PARITY_NIP50_LANGUAGE`: `PARITY_NIP50_LANGUAGE=english cargo test -p nostream-storage - --test migrations -- --ignored` composes at that language and fails unless the index was - rebuilt with it. The switch is in `tests/common/mod.rs::settings_from_fixture`; editing - the fixture's override is the equivalent path. +4. **The NIP-50 language changes at runtime.** The shipped index is built on + `to_tsvector('simple', event_content)`, and the migrations target asserts that the + composer's predicate is that exact expression before it EXPLAINs it. To see the + deployment's language, export it: + `PARITY_ADMIN_URL=postgres://…/postgres PARITY_NIP50_LANGUAGE=english cargo test -p + nostream-storage --test migrations -- --ignored --test-threads=1`. That run fails at the + indexed-expression assertion, naming the language it composed (`english`), because the + assertion names the shipped `simple` expression. A deployment that rebuilds + `events_content_fts_idx` for another language must re-run this guard against *its* + database and index; this target recreates and migrates its own scratch database, so it + cannot certify a rebuilt index. Do not derive the assertion from the settings: the + scratch index is `simple`, so a derived assertion would only move the failure. 5. **The capture's own FTS miss is fixed upstream.** If the TypeScript starts inlining the configuration, the divergence above disappears; the fixture's compared values do not change (its `subject` block is regenerated, so the file itself does). diff --git a/scripts/storage-parity.sh b/scripts/storage-parity.sh index 430de44c..0809c4ad 100755 --- a/scripts/storage-parity.sh +++ b/scripts/storage-parity.sh @@ -49,7 +49,6 @@ fi pg_container="nostream-parity-pg-$$" redis_container="nostream-parity-redis-$$" -admin_url="postgres://postgres:$PARITY_PG_PASSWORD@$PARITY_HOST:$PARITY_PG_PORT/postgres" cleanup() { docker rm -f "$pg_container" "$redis_container" >/dev/null 2>&1 || true @@ -71,11 +70,50 @@ unbracket() { esac } -is_loopback() { - case "$(unbracket "$1")" in - 127.*|localhost|::1) return 0 ;; - *) return 1 ;; - esac +# Address parsing for both the bind and the daemon endpoint. Character matching cannot +# decide this: Docker accepts dotted, shorthand (`0x7f.1`, `2130706433`) and IPv4-mapped +# (`::ffff:0:0`) spellings. WHATWG URL canonicalises all of them (bracketing IPv6 first, +# which the parser requires), so classify the canonical text. Prints +# " "; class is wildcard, loopback, host or invalid. +address_info() { + node -e ' +const net = require("node:net"); +const raw = process.argv[1]; +if (raw.toLowerCase() === "localhost") { + process.stdout.write("loopback localhost localhost"); + process.exit(0); +} +const bare = raw.replace(/^\[|\]$/g, ""); +let host; +try { + host = new URL("http://" + (bare.includes(":") ? "[" + bare + "]" : bare) + "/").hostname; +} catch { + process.stdout.write("invalid - -"); + process.exit(0); +} +const plain = host.replace(/^\[|\]$/g, ""); +const family = net.isIP(plain); +if (family === 0) { + process.stdout.write("invalid - -"); + process.exit(0); +} +const urlHost = family === 6 ? "[" + plain + "]" : plain; +// An IPv4-mapped address is loopback when its first mapped octet is 127, i.e. the first +// group after `::ffff:` is 0x7f00..0x7fff. A string prefix would also match 0x7f0/0x7ff. +const mappedLoopback = (() => { + const match = /^::ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/.exec(plain); + if (!match) return false; + const group = parseInt(match[1], 16); + return group >= 0x7f00 && group <= 0x7fff; +})(); +let cls = "host"; +if (plain === "::" || plain === "::ffff:0:0" || plain === "0.0.0.0") { + cls = "wildcard"; +} else if (plain === "::1" || mappedLoopback || plain.startsWith("127.")) { + cls = "loopback"; +} +process.stdout.write(cls + " " + urlHost + " " + plain); +' "$1" } # Only a TCP (or SSH) daemon endpoint can be on another machine. A unix socket, a bare @@ -89,7 +127,7 @@ is_remote_daemon() { \[*) daemon=${daemon#\[}; daemon=${daemon%%\]*} ;; *) daemon=${daemon%%:*} ;; esac - ! is_loopback "$daemon" + [ "$(address_info "$daemon" | cut -d' ' -f1)" != "loopback" ] ;; *) return 1 ;; esac @@ -97,25 +135,34 @@ is_remote_daemon() { # Preflight, before any container exists. A wildcard bind is never right, and a loopback # bind is right only for a daemon on this machine. No default guesses a daemon's address. -bind_address=$(unbracket "$PARITY_BIND") -case "$bind_address" in - 0.0.0.0|'*') +bind_info=$(address_info "$PARITY_BIND") \ + || fail "PARITY_BIND=$PARITY_BIND could not be classified (node is required)" +bind_class=${bind_info%% *} +case "$bind_class" in + wildcard) fail "PARITY_BIND=$PARITY_BIND is a wildcard: publish on one address (127.0.0.1 for a local daemon, or the daemon host's reachable address)" ;; - *:*) - # Every spelling of the IPv6 wildcard is all-zero groups: `::`, `0::`, `::0000`, - # `0:0:0:0:0:0:0:0`. Any digit other than 0 (or a hex letter) means a real address. - case "$bind_address" in - *[!0:]*) ;; - *) - fail "PARITY_BIND=$PARITY_BIND is a wildcard: publish on one address (127.0.0.1 for a local daemon, or the daemon host's reachable address)" - ;; - esac + invalid) + fail "PARITY_BIND=$PARITY_BIND is not an IP address or localhost: publish on one address (127.0.0.1 for a local daemon, or the daemon host's reachable address)" ;; esac -if is_loopback "$PARITY_BIND" && is_remote_daemon; then +if [ "$bind_class" = "loopback" ] && is_remote_daemon; then fail "PARITY_BIND=$PARITY_BIND is this machine's loopback, but the Docker daemon is remote (DOCKER_HOST=$DOCKER_HOST): set PARITY_BIND and PARITY_HOST to an address the daemon host accepts and this machine can reach" fi + +# The dial host is canonicalised too: `admin_url` splices it raw, and an unbracketed IPv6 or +# a shorthand IPv4 would not survive there. A hostname is used as written. +host_info=$(address_info "$PARITY_HOST") \ + || fail "PARITY_HOST=$PARITY_HOST could not be parsed (node is required)" +host_class=${host_info%% *} +if [ "$host_class" = "invalid" ]; then + host_url=$PARITY_HOST + host_plain=$(unbracket "$PARITY_HOST") +else + host_url=$(printf '%s' "$host_info" | cut -d' ' -f2) + host_plain=$(printf '%s' "$host_info" | cut -d' ' -f3) +fi +admin_url="postgres://postgres:$PARITY_PG_PASSWORD@$host_url:$PARITY_PG_PORT/postgres" docker version >/dev/null 2>&1 \ || fail "the Docker daemon at ${DOCKER_HOST:-/var/run/docker.sock} is not reachable" @@ -136,7 +183,7 @@ printf '%s\n' "$redaction_output" | grep -q 'test result: ok. 1 passed' || { # variables instead of surfacing as a sqlx timeout later. probe_dial() { # The probe wants the address itself; URLs keep the bracketed IPv6 spelling. - node -e 'const net = require("net"); const s = net.connect({ host: process.argv[1], port: Number(process.argv[2]) }); s.setTimeout(3000); s.on("connect", () => { s.destroy(); process.exit(0) }); s.on("timeout", () => { s.destroy(); process.exit(1) }); s.on("error", () => process.exit(1))' "$(unbracket "$PARITY_HOST")" "$PARITY_PG_PORT" + node -e 'const net = require("net"); const s = net.connect({ host: process.argv[1], port: Number(process.argv[2]) }); s.setTimeout(3000); s.on("connect", () => { s.destroy(); process.exit(0) }); s.on("timeout", () => { s.destroy(); process.exit(1) }); s.on("error", () => process.exit(1))' "$host_plain" "$PARITY_PG_PORT" } wait_for_log() { @@ -182,8 +229,8 @@ echo "== behavioural parity" echo "== cucumber on the sqlx-migrated schema" [ -x "$CUCUMBER_BIN" ] || fail "cucumber-js not found at $CUCUMBER_BIN" mkdir -p .test-reports/integration -DB_URI="postgres://postgres:$PARITY_PG_PASSWORD@$PARITY_HOST:$PARITY_PG_PORT/nostream_parity_cucumber" \ -REDIS_URI="redis://:$PARITY_REDIS_PASSWORD@$PARITY_HOST:$PARITY_REDIS_PORT" \ +DB_URI="postgres://postgres:$PARITY_PG_PASSWORD@$host_url:$PARITY_PG_PORT/nostream_parity_cucumber" \ +REDIS_URI="redis://:$PARITY_REDIS_PASSWORD@$host_url:$PARITY_REDIS_PORT" \ NODE_PATH="${NODE_PATH:-}" \ TS_NODE_TRANSPILE_ONLY=true \ NODE_OPTIONS=--no-experimental-strip-types \ From de5f3ef1e3ec6c6af8c281259db5c396ea6e6a30 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 21:14:20 -0400 Subject: [PATCH 15/16] fix(storage): bind negative invite counts and exercise transactional enqueue FieldUpdate::Set bound a negative count as NULLInt, the sentinel reserved for Clear, so Set(-5) silently became unlimited. Bind the integer and let chk_remaining_uses_non_negative refuse it. The parity fixture enqueued through the backend even inside a transaction, so PostgresTransaction::enqueue was never exercised and the row survived a rollback. Enqueue through the client, and add a transaction that enqueues, fails on the negative invite count, and is followed by a claim that proves the row rolled back. --- .../nostream-storage/src/postgres/metadata.rs | 23 ++++---- .../tests/capture/gen-storage-parity.mjs | 14 +++++ .../tests/fixtures/repository-parity.json | 55 +++++++++++++++++++ crates/nostream-storage/tests/parity.rs | 7 ++- 4 files changed, 83 insertions(+), 16 deletions(-) diff --git a/crates/nostream-storage/src/postgres/metadata.rs b/crates/nostream-storage/src/postgres/metadata.rs index b27d05f3..8a92908a 100644 --- a/crates/nostream-storage/src/postgres/metadata.rs +++ b/crates/nostream-storage/src/postgres/metadata.rs @@ -155,15 +155,12 @@ where Some(expires_at) => args.push(Arg::Timestamp(expires_at)), None => args.push(Arg::NullTimestamp), }; - let remaining_uses = match options.remaining_uses { - FieldUpdate::Absent => 1, - FieldUpdate::Clear => -1, - FieldUpdate::Set(uses) => uses, - }; - let remaining_slot = if remaining_uses < 0 { - args.push(Arg::NullInt) - } else { - args.push(Arg::Int(remaining_uses)) + // `Clear` is the only sentinel that means "unlimited"; a `Set` value is bound + // as it arrives and the table's CHECK lets the database refuse a negative count. + let remaining_slot = match &options.remaining_uses { + FieldUpdate::Absent => args.push(Arg::Int(1)), + FieldUpdate::Clear => args.push(Arg::NullInt), + FieldUpdate::Set(uses) => args.push(Arg::Int(*uses)), }; let created_slot = args.push(Arg::Timestamp(now)); let updated_slot = args.push(Arg::Timestamp(now)); @@ -182,10 +179,10 @@ where created_by: options.created_by, claimed_by: None, expires_at: options.expires_at, - remaining_uses: if remaining_uses < 0 { - None - } else { - Some(remaining_uses) + remaining_uses: match &options.remaining_uses { + FieldUpdate::Absent => Some(1), + FieldUpdate::Clear => None, + FieldUpdate::Set(uses) => Some(*uses), }, created_at: now, updated_at: now, diff --git a/crates/nostream-storage/tests/capture/gen-storage-parity.mjs b/crates/nostream-storage/tests/capture/gen-storage-parity.mjs index bd4034de..4d6884f6 100644 --- a/crates/nostream-storage/tests/capture/gen-storage-parity.mjs +++ b/crates/nostream-storage/tests/capture/gen-storage-parity.mjs @@ -836,6 +836,20 @@ const script = [ }, { op: 'invoice.find_by_id', request: { id: 'inv-3' } }, { op: 'user.find_by_pubkey', request: { pubkey: PK_C } }, + // An enqueue inside a transaction that later fails must roll back with it: the + // trailing claim sees only the enqueue from the successful transaction above. + { + op: 'transaction', + request: { + ops: [ + { op: 'outbox.enqueue', request: { event_type: 'transaction.rollback.probe', payload: { probe: true } } }, + // A negative count is a Set, not the unlimited sentinel, so the table's + // CHECK refuses it: the statement fails and takes the enqueue with it. + { op: 'invite.create', request: { code: 'invite-rollback', options: { remaining_uses: { kind: 'set', value: -5 } } } }, + ], + }, + }, + { op: 'outbox.claim_batch', request: { limit: 10 } }, ] // --------------------------------------------------------------------------------------- diff --git a/crates/nostream-storage/tests/fixtures/repository-parity.json b/crates/nostream-storage/tests/fixtures/repository-parity.json index 3b5db0b5..ff33cfe3 100644 --- a/crates/nostream-storage/tests/fixtures/repository-parity.json +++ b/crates/nostream-storage/tests/fixtures/repository-parity.json @@ -11629,6 +11629,61 @@ "user": null } } + }, + { + "op": "transaction", + "request": { + "ops": [ + { + "op": "outbox.enqueue", + "request": { + "event_type": "transaction.rollback.probe", + "payload": { + "probe": true + } + } + }, + { + "op": "invite.create", + "request": { + "code": "invite-rollback", + "options": { + "remaining_uses": { + "kind": "set", + "value": -5 + } + } + } + } + ] + }, + "result": { + "compare": { + "failed": true, + "at": 1 + } + } + }, + { + "op": "outbox.claim_batch", + "request": { + "limit": 10 + }, + "result": { + "compare": { + "messages": [ + { + "event_type": "admission.invoice.created", + "payload": { + "invoiceId": "inv-2" + }, + "status": "processing", + "attempt_count": 0, + "last_error": null + } + ] + } + } } ], "projection": { diff --git a/crates/nostream-storage/tests/parity.rs b/crates/nostream-storage/tests/parity.rs index 14b68e43..2defdf19 100644 --- a/crates/nostream-storage/tests/parity.rs +++ b/crates/nostream-storage/tests/parity.rs @@ -866,9 +866,10 @@ async fn run_op( } "outbox.enqueue" => { let event_type = text(request, "event_type"); - let message = - NotificationOutboxRepository::enqueue(backend, &event_type, &request["payload"]) - .await?; + let message = client + .outbox() + .enqueue(&event_type, &request["payload"]) + .await?; refs.insert(message.event_type.clone(), message.id.clone()); Ok(json!({ "message": outbox_view(&message) })) } From 12539d49031897b0c2e8a0df20271d79f7d6b5bf Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Mon, 19 Oct 2026 21:15:22 -0400 Subject: [PATCH 16/16] fix(scripts): percent-encode parity credentials in URLs PARITY_PG_PASSWORD and PARITY_REDIS_PASSWORD were spliced raw into the admin URL and the Cucumber URLs, so a password containing @, :, /, ? or # truncated the userinfo. Encode for the URL and keep passing the raw value to the containers. --- scripts/storage-parity.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/storage-parity.sh b/scripts/storage-parity.sh index 0809c4ad..9d7f128d 100755 --- a/scripts/storage-parity.sh +++ b/scripts/storage-parity.sh @@ -116,6 +116,12 @@ process.stdout.write(cls + " " + urlHost + " " + plain); ' "$1" } +# Credentials are spliced into URLs, where a reserved character would truncate the +# userinfo or the authority. Encode for the URL and hand the raw value to the container. +url_escape() { + node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1" +} + # Only a TCP (or SSH) daemon endpoint can be on another machine. A unix socket, a bare # socket path, fd:// and npipe:// all name a daemon on this machine, and tcp://[::1] is the # local loopback spelled in brackets. @@ -162,7 +168,9 @@ else host_url=$(printf '%s' "$host_info" | cut -d' ' -f2) host_plain=$(printf '%s' "$host_info" | cut -d' ' -f3) fi -admin_url="postgres://postgres:$PARITY_PG_PASSWORD@$host_url:$PARITY_PG_PORT/postgres" +pg_password_url=$(url_escape "$PARITY_PG_PASSWORD") +redis_password_url=$(url_escape "$PARITY_REDIS_PASSWORD") +admin_url="postgres://postgres:$pg_password_url@$host_url:$PARITY_PG_PORT/postgres" docker version >/dev/null 2>&1 \ || fail "the Docker daemon at ${DOCKER_HOST:-/var/run/docker.sock} is not reachable" @@ -229,8 +237,8 @@ echo "== behavioural parity" echo "== cucumber on the sqlx-migrated schema" [ -x "$CUCUMBER_BIN" ] || fail "cucumber-js not found at $CUCUMBER_BIN" mkdir -p .test-reports/integration -DB_URI="postgres://postgres:$PARITY_PG_PASSWORD@$host_url:$PARITY_PG_PORT/nostream_parity_cucumber" \ -REDIS_URI="redis://:$PARITY_REDIS_PASSWORD@$host_url:$PARITY_REDIS_PORT" \ +DB_URI="postgres://postgres:$pg_password_url@$host_url:$PARITY_PG_PORT/nostream_parity_cucumber" \ +REDIS_URI="redis://:$redis_password_url@$host_url:$PARITY_REDIS_PORT" \ NODE_PATH="${NODE_PATH:-}" \ TS_NODE_TRANSPILE_ONLY=true \ NODE_OPTIONS=--no-experimental-strip-types \