commit 89b1bad231d4017a53aec2d33a8fe008e56f2ef9
parent 3303026a759d61933d9c4ab3bc90885def8abae4
Author: triesap <tyson@radroots.org>
Date: Sat, 13 Jun 2026 20:09:03 -0700
refactor: remove obsolete relay surfaces
- delete pre-v2 runtime crates, fixtures, tests, and local configs
- reduce tangle runtime and CLI to the v2 BaseRelay/Pocket config path
- keep AUTH parsing and deterministic v2 fixtures inside active crates
- update reference surfaces and validation scripts around the current v2 surface
Diffstat:
38 files changed, 1119 insertions(+), 41249 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3,248 +3,18 @@
version = 4
[[package]]
-name = "addr"
-version = "0.15.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a93b8a41dbe230ad5087cc721f8d41611de654542180586b315d9f4cf6b72bef"
-dependencies = [
- "psl-types",
-]
-
-[[package]]
-name = "affinitypool"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c4a46f56d354df11b6bcd8ca4f84fa03ac816cc41c72568a1b337d8f7e5af90e"
-dependencies = [
- "arc-swap",
- "async-task",
- "crossbeam-deque",
- "crossbeam-utils",
- "libc",
- "num_cpus",
- "parking_lot",
- "thiserror",
- "winapi",
-]
-
-[[package]]
-name = "ahash"
-version = "0.7.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
-dependencies = [
- "getrandom 0.2.17",
- "once_cell",
- "version_check",
-]
-
-[[package]]
-name = "ahash"
-version = "0.8.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
-dependencies = [
- "cfg-if",
- "getrandom 0.3.4",
- "once_cell",
- "version_check",
- "zerocopy",
-]
-
-[[package]]
-name = "aho-corasick"
-version = "1.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
-dependencies = [
- "memchr",
-]
-
-[[package]]
-name = "allocator-api2"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
-
-[[package]]
-name = "ammonia"
-version = "4.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
-dependencies = [
- "cssparser",
- "html5ever",
- "maplit",
- "tendril",
- "url",
-]
-
-[[package]]
-name = "android_system_properties"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "anyhow"
-version = "1.0.102"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
-
-[[package]]
-name = "approx"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "arc-swap"
-version = "1.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
-dependencies = [
- "rustversion",
-]
-
-[[package]]
-name = "argon2"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
-dependencies = [
- "base64ct",
- "blake2",
- "cpufeatures 0.2.17",
- "password-hash",
-]
-
-[[package]]
-name = "arrayref"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
-
-[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
-name = "as-slice"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0"
-dependencies = [
- "generic-array 0.12.4",
- "generic-array 0.13.3",
- "generic-array 0.14.7",
- "stable_deref_trait",
-]
-
-[[package]]
-name = "async-channel"
-version = "2.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
-dependencies = [
- "concurrent-queue",
- "event-listener-strategy",
- "futures-core",
- "pin-project-lite",
-]
-
-[[package]]
-name = "async-stream"
-version = "0.3.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
-dependencies = [
- "async-stream-impl",
- "futures-core",
- "pin-project-lite",
-]
-
-[[package]]
-name = "async-stream-impl"
-version = "0.3.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "async-task"
-version = "4.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
-
-[[package]]
-name = "async-trait"
-version = "0.1.89"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "atomic-polyfill"
-version = "1.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
-dependencies = [
- "critical-section",
-]
-
-[[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 = "aws-lc-rs"
-version = "1.17.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
-dependencies = [
- "aws-lc-sys",
- "untrusted 0.7.1",
- "zeroize",
-]
-
-[[package]]
-name = "aws-lc-sys"
-version = "0.41.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
-dependencies = [
- "cc",
- "cmake",
- "dunce",
- "fs_extra",
-]
-
-[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -273,7 +43,7 @@ dependencies = [
"sha1",
"sync_wrapper",
"tokio",
- "tokio-tungstenite 0.29.0",
+ "tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -318,19 +88,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
-name = "bcrypt"
-version = "0.18.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a0f5948f30df5f43ac29d310b7476793be97c50787e6ef4a63d960a0d0be827"
-dependencies = [
- "base64",
- "blowfish",
- "getrandom 0.3.4",
- "subtle",
- "zeroize",
-]
-
-[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -340,44 +97,6 @@ dependencies = [
]
[[package]]
-name = "bincode"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
-dependencies = [
- "bincode_derive",
- "serde",
- "unty",
-]
-
-[[package]]
-name = "bincode_derive"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09"
-dependencies = [
- "virtue",
-]
-
-[[package]]
-name = "bindgen"
-version = "0.72.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
-dependencies = [
- "bitflags",
- "cexpr",
- "clang-sys",
- "itertools 0.13.0",
- "proc-macro2",
- "quote",
- "regex",
- "rustc-hash",
- "shlex 1.3.0",
- "syn 2.0.117",
-]
-
-[[package]]
name = "bitcoin-io"
version = "0.1.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -403,5182 +122,1410 @@ dependencies = [
]
[[package]]
-name = "bitvec"
-version = "1.0.1"
+name = "block-buffer"
+version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
- "funty",
- "radium",
- "tap",
- "wyz",
+ "generic-array",
]
[[package]]
-name = "blake2"
-version = "0.10.6"
+name = "byteorder"
+version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
-dependencies = [
- "digest",
-]
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
-name = "blake3"
-version = "1.8.5"
+name = "bytes"
+version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
-dependencies = [
- "arrayref",
- "arrayvec",
- "cc",
- "cfg-if",
- "constant_time_eq",
- "cpufeatures 0.3.0",
-]
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
-name = "block-buffer"
-version = "0.10.4"
+name = "cc"
+version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
- "generic-array 0.14.7",
+ "find-msvc-tools",
+ "shlex",
]
[[package]]
-name = "blowfish"
-version = "0.9.1"
+name = "cfg-if"
+version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
-dependencies = [
- "byteorder",
- "cipher",
-]
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
-name = "bnum"
-version = "0.12.1"
+name = "const-oid"
+version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f781dba93de3a5ef6dc5b17c9958b208f6f3f021623b360fb605ea51ce443f10"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
-name = "borsh"
-version = "1.6.1"
+name = "convert_case"
+version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
-dependencies = [
- "borsh-derive",
- "bytes",
- "cfg_aliases",
-]
+checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
-name = "borsh-derive"
-version = "1.6.1"
+name = "cpufeatures"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
- "once_cell",
- "proc-macro-crate",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
+ "libc",
]
[[package]]
-name = "boxcar"
-version = "0.2.14"
+name = "crossbeam-queue"
+version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e"
+checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+dependencies = [
+ "crossbeam-utils",
+]
[[package]]
-name = "bumpalo"
-version = "3.20.3"
+name = "crossbeam-utils"
+version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
-name = "bytecheck"
-version = "0.6.12"
+name = "crypto-bigint"
+version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
- "bytecheck_derive",
- "ptr_meta",
- "simdutf8",
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
]
[[package]]
-name = "bytecheck_derive"
-version = "0.6.12"
+name = "crypto-common"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
- "proc-macro2",
- "quote",
- "syn 1.0.109",
+ "generic-array",
+ "typenum",
]
[[package]]
-name = "bytemuck"
-version = "1.25.0"
+name = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
+[[package]]
+name = "der"
+version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
- "bytemuck_derive",
+ "const-oid",
+ "zeroize",
]
[[package]]
-name = "bytemuck_derive"
-version = "1.10.2"
+name = "derive_more"
+version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
+ "convert_case",
"proc-macro2",
"quote",
- "syn 2.0.117",
+ "rustc_version",
+ "syn",
]
[[package]]
-name = "byteorder"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
-
-[[package]]
-name = "bytes"
-version = "1.11.1"
+name = "digest"
+version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
- "serde",
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+ "subtle",
]
[[package]]
-name = "bzip2-sys"
-version = "0.1.13+1.0.8"
+name = "displaydoc"
+version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
- "cc",
- "pkg-config",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
-name = "castaway"
-version = "0.2.4"
+name = "doxygen-rs"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9"
dependencies = [
- "rustversion",
+ "phf",
]
[[package]]
-name = "cc"
-version = "1.2.63"
+name = "ecdsa"
+version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
- "find-msvc-tools",
- "jobserver",
- "libc",
- "shlex 2.0.1",
+ "der",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
]
[[package]]
-name = "cexpr"
-version = "0.6.0"
+name = "elliptic-curve"
+version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
- "nom",
+ "base16ct",
+ "crypto-bigint",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
]
[[package]]
-name = "cfg-if"
-version = "1.0.4"
+name = "ff"
+version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
[[package]]
-name = "cfg_aliases"
-version = "0.2.1"
+name = "find-msvc-tools"
+version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
-name = "chrono"
-version = "0.4.45"
+name = "form_urlencoded"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
- "iana-time-zone",
- "js-sys",
- "num-traits",
- "serde",
- "wasm-bindgen",
- "windows-link 0.2.1",
+ "percent-encoding",
]
[[package]]
-name = "ciborium"
-version = "0.2.2"
+name = "futures-channel"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
- "ciborium-io",
- "ciborium-ll",
- "serde",
+ "futures-core",
]
[[package]]
-name = "ciborium-io"
-version = "0.2.2"
+name = "futures-core"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
-name = "ciborium-ll"
-version = "0.2.2"
+name = "futures-sink"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
-dependencies = [
- "ciborium-io",
- "half",
-]
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
-name = "cipher"
-version = "0.4.4"
+name = "futures-task"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
-dependencies = [
- "crypto-common",
- "inout",
-]
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
-name = "clang-sys"
-version = "1.8.1"
+name = "futures-util"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
- "glob",
- "libc",
- "libloading",
+ "futures-core",
+ "futures-sink",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
]
[[package]]
-name = "cmake"
-version = "0.1.58"
+name = "generic-array"
+version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
- "cc",
+ "typenum",
+ "version_check",
+ "zeroize",
]
[[package]]
-name = "concurrent-queue"
-version = "2.5.0"
+name = "getrandom"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
- "crossbeam-utils",
+ "cfg-if",
+ "libc",
+ "wasi",
]
[[package]]
-name = "const-oid"
-version = "0.9.6"
+name = "getrandom"
+version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
[[package]]
-name = "constant_time_eq"
-version = "0.4.2"
+name = "group"
+version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
+]
[[package]]
-name = "convert_case"
-version = "0.4.0"
+name = "heed"
+version = "0.20.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
+checksum = "7d4f449bab7320c56003d37732a917e18798e2f1709d80263face2b4f9436ddb"
+dependencies = [
+ "bitflags",
+ "byteorder",
+ "heed-traits",
+ "heed-types",
+ "libc",
+ "lmdb-master-sys",
+ "once_cell",
+ "page_size",
+ "serde",
+ "synchronoise",
+ "url",
+]
[[package]]
-name = "core-foundation-sys"
-version = "0.8.7"
+name = "heed-traits"
+version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+checksum = "eb3130048d404c57ce5a1ac61a903696e8fcde7e8c2991e9fcfc1f27c3ef74ff"
[[package]]
-name = "cpufeatures"
-version = "0.2.17"
+name = "heed-types"
+version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+checksum = "9d3f528b053a6d700b2734eabcd0fd49cb8230647aa72958467527b0b7917114"
dependencies = [
- "libc",
+ "bincode",
+ "byteorder",
+ "heed-traits",
+ "serde",
+ "serde_json",
]
[[package]]
-name = "cpufeatures"
-version = "0.3.0"
+name = "hex-conservative"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
dependencies = [
- "libc",
+ "arrayvec",
]
[[package]]
-name = "critical-section"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
-
-[[package]]
-name = "crossbeam-deque"
-version = "0.8.6"
+name = "hmac"
+version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
- "crossbeam-epoch",
- "crossbeam-utils",
+ "digest",
]
[[package]]
-name = "crossbeam-epoch"
-version = "0.9.18"
+name = "http"
+version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [
- "crossbeam-utils",
+ "bytes",
+ "itoa",
]
[[package]]
-name = "crossbeam-queue"
-version = "0.3.12"
+name = "http-body"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
- "crossbeam-utils",
+ "bytes",
+ "http",
]
[[package]]
-name = "crossbeam-skiplist"
+name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
- "crossbeam-epoch",
- "crossbeam-utils",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
]
[[package]]
-name = "crossbeam-utils"
-version = "0.8.21"
+name = "httparse"
+version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
-name = "crunchy"
-version = "0.2.4"
+name = "httpdate"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
-name = "crypto-bigint"
-version = "0.5.5"
+name = "hyper"
+version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
- "generic-array 0.14.7",
- "rand_core 0.6.4",
- "subtle",
- "zeroize",
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
]
[[package]]
-name = "crypto-common"
-version = "0.1.7"
+name = "hyper-util"
+version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
- "generic-array 0.14.7",
- "typenum",
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
]
[[package]]
-name = "cssparser"
-version = "0.35.0"
+name = "icu_collections"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
- "cssparser-macros",
- "dtoa-short",
- "itoa",
- "phf 0.11.3",
- "smallvec",
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
]
[[package]]
-name = "cssparser-macros"
-version = "0.6.1"
+name = "icu_locale_core"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
- "quote",
- "syn 2.0.117",
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
]
[[package]]
-name = "curve25519-dalek"
-version = "4.1.3"
+name = "icu_normalizer"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
- "cfg-if",
- "cpufeatures 0.2.17",
- "curve25519-dalek-derive",
- "digest",
- "fiat-crypto",
- "rustc_version",
- "subtle",
- "zeroize",
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
]
[[package]]
-name = "curve25519-dalek-derive"
-version = "0.1.1"
+name = "icu_normalizer_data"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
-name = "dashmap"
-version = "6.2.1"
+name = "icu_properties"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
- "cfg-if",
- "crossbeam-utils",
- "hashbrown 0.14.5",
- "lock_api",
- "once_cell",
- "parking_lot_core",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
]
[[package]]
-name = "data-encoding"
-version = "2.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
-
-[[package]]
-name = "der"
-version = "0.7.10"
+name = "icu_properties_data"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
-dependencies = [
- "const-oid",
- "pem-rfc7468",
- "zeroize",
-]
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
-name = "deranged"
-version = "0.5.8"
+name = "icu_provider"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
- "powerfmt",
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
]
[[package]]
-name = "derive_more"
-version = "0.99.20"
+name = "idna"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
- "convert_case",
- "proc-macro2",
- "quote",
- "rustc_version",
- "syn 2.0.117",
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
]
[[package]]
-name = "deunicode"
-version = "1.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
-
-[[package]]
-name = "digest"
-version = "0.10.7"
+name = "idna_adapter"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
- "block-buffer",
- "const-oid",
- "crypto-common",
- "subtle",
+ "icu_normalizer",
+ "icu_properties",
]
[[package]]
-name = "diskann"
-version = "0.53.0"
+name = "itoa"
+version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "376186e025eb294c22f06236b23417608f1867def159c3a61a5c57788a3e889e"
-dependencies = [
- "anyhow",
- "bytemuck",
- "diskann-utils",
- "diskann-vector",
- "diskann-wide",
- "futures-util",
- "half",
- "hashbrown 0.16.1",
- "num-traits",
- "rand 0.9.4",
- "thiserror",
- "tokio",
-]
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
-name = "diskann-utils"
-version = "0.53.0"
+name = "k256"
+version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7b70289db1b66826fa1ef2b4113bf2f9d0dedc8df983b2b804c38dc1e519e15e"
+checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
dependencies = [
- "bytemuck",
"cfg-if",
- "diskann-vector",
- "diskann-wide",
- "half",
- "rand 0.9.4",
- "rand_distr",
- "rayon",
- "thiserror",
+ "ecdsa",
+ "elliptic-curve",
+ "once_cell",
+ "sha2",
+ "signature",
]
[[package]]
-name = "diskann-vector"
-version = "0.53.0"
+name = "libc"
+version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f62c9d81aad6e3df6a026b1bb693dbbcfbee5ea93d9e7a5ff15c31576263bc29"
-dependencies = [
- "cfg-if",
- "diskann-wide",
- "half",
-]
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
-name = "diskann-wide"
-version = "0.53.0"
+name = "litemap"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46fcacef8ea9274969f98499456718f3dcaa5d3d7392b3171079653370fa0b20"
-dependencies = [
- "cfg-if",
- "half",
-]
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
-name = "displaydoc"
+name = "lmdb-master-sys"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b"
dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
+ "cc",
+ "doxygen-rs",
+ "libc",
]
[[package]]
-name = "dmp"
-version = "0.2.3"
+name = "log"
+version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb2dfc7a18dffd3ef60a442b72a827126f1557d914620f8fc4d1049916da43c1"
-dependencies = [
- "trice",
- "urlencoding",
-]
+checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
-name = "doxygen-rs"
-version = "0.4.2"
+name = "matchit"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9"
-dependencies = [
- "phf 0.11.3",
-]
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
-name = "dtoa"
-version = "1.0.11"
+name = "memchr"
+version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
-name = "dtoa-short"
-version = "0.3.5"
+name = "memmap2"
+version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
dependencies = [
- "dtoa",
+ "libc",
]
[[package]]
-name = "dunce"
-version = "1.0.5"
+name = "mime"
+version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
-name = "earcutr"
-version = "0.4.3"
+name = "mio"
+version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
- "itertools 0.11.0",
- "num-traits",
+ "libc",
+ "wasi",
+ "windows-sys",
]
[[package]]
-name = "ecdsa"
-version = "0.16.9"
+name = "mmap-append"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+checksum = "df2ef74bd4eba425972bd31da530dbe0c0e62d15f7c06e1c6ebcc3b6d5446237"
dependencies = [
- "der",
- "digest",
- "elliptic-curve",
- "rfc6979",
- "signature",
- "spki",
+ "libc",
+ "memmap2",
]
[[package]]
-name = "ed25519"
-version = "2.2.3"
+name = "once_cell"
+version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
-dependencies = [
- "pkcs8",
- "signature",
-]
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
-name = "ed25519-dalek"
-version = "2.2.0"
+name = "page_size"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [
- "curve25519-dalek",
- "ed25519",
- "serde",
- "sha2",
- "subtle",
- "zeroize",
+ "libc",
+ "winapi",
]
[[package]]
-name = "either"
-version = "1.16.0"
+name = "percent-encoding"
+version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
-name = "elliptic-curve"
-version = "0.13.8"
+name = "phf"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
- "base16ct",
- "crypto-bigint",
- "digest",
- "ff",
- "generic-array 0.14.7",
- "group",
- "hkdf",
- "pem-rfc7468",
- "pkcs8",
- "rand_core 0.6.4",
- "sec1",
- "subtle",
- "zeroize",
+ "phf_macros",
+ "phf_shared",
]
[[package]]
-name = "endian-type"
-version = "0.2.0"
+name = "phf_generator"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand 0.8.6",
+]
[[package]]
-name = "equivalent"
-version = "1.0.2"
+name = "phf_macros"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
[[package]]
-name = "errno"
-version = "0.3.14"
+name = "phf_shared"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
- "libc",
- "windows-sys 0.61.2",
+ "siphasher",
]
[[package]]
-name = "event-listener"
-version = "5.4.1"
+name = "pin-project-lite"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
-dependencies = [
- "concurrent-queue",
- "parking",
- "pin-project-lite",
-]
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
-name = "event-listener-strategy"
-version = "0.5.4"
+name = "pkcs8"
+version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
- "event-listener",
- "pin-project-lite",
+ "der",
+ "spki",
]
[[package]]
-name = "ext-sort"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf5d3b056bcc471d38082b8c453acb6670f7327fd44219b3c411e40834883569"
-dependencies = [
- "log",
- "rayon",
- "rmp-serde",
- "serde",
- "tempfile",
-]
-
-[[package]]
-name = "fastnum"
-version = "0.7.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4089ab2dfd45d8ddc92febb5ca80644389d5ebb954f40231274a3f18341762e2"
-dependencies = [
- "bnum",
- "num-integer",
- "num-traits",
-]
-
-[[package]]
-name = "fastrand"
-version = "2.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
-
-[[package]]
-name = "ff"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
-dependencies = [
- "rand_core 0.6.4",
- "subtle",
-]
-
-[[package]]
-name = "fiat-crypto"
-version = "0.2.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
-
-[[package]]
-name = "find-msvc-tools"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
-
-[[package]]
-name = "flatbuffers"
-version = "25.12.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3"
-dependencies = [
- "bitflags",
- "rustc_version",
- "serde",
-]
-
-[[package]]
-name = "float_next_after"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
-
-[[package]]
-name = "fnv"
-version = "1.0.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
-
-[[package]]
-name = "foldhash"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
-
-[[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"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
-dependencies = [
- "percent-encoding",
-]
-
-[[package]]
-name = "fs_extra"
-version = "1.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
-
-[[package]]
-name = "fst"
-version = "0.4.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a"
-
-[[package]]
-name = "funty"
-version = "2.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
-
-[[package]]
-name = "futf"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
-dependencies = [
- "mac",
- "new_debug_unreachable",
-]
-
-[[package]]
-name = "futures"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
-dependencies = [
- "futures-channel",
- "futures-core",
- "futures-executor",
- "futures-io",
- "futures-sink",
- "futures-task",
- "futures-util",
-]
-
-[[package]]
-name = "futures-channel"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
-dependencies = [
- "futures-core",
- "futures-sink",
-]
-
-[[package]]
-name = "futures-core"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
-
-[[package]]
-name = "futures-executor"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
-dependencies = [
- "futures-core",
- "futures-task",
- "futures-util",
-]
-
-[[package]]
-name = "futures-io"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
-
-[[package]]
-name = "futures-macro"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "futures-sink"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
-
-[[package]]
-name = "futures-task"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
-
-[[package]]
-name = "futures-util"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
-dependencies = [
- "futures-channel",
- "futures-core",
- "futures-io",
- "futures-macro",
- "futures-sink",
- "futures-task",
- "memchr",
- "pin-project-lite",
- "slab",
-]
-
-[[package]]
-name = "fuzzy-matcher"
-version = "0.3.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
-dependencies = [
- "thread_local",
-]
-
-[[package]]
-name = "generic-array"
-version = "0.12.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd"
-dependencies = [
- "typenum",
-]
-
-[[package]]
-name = "generic-array"
-version = "0.13.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309"
-dependencies = [
- "typenum",
-]
-
-[[package]]
-name = "generic-array"
-version = "0.14.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
-dependencies = [
- "typenum",
- "version_check",
- "zeroize",
-]
-
-[[package]]
-name = "geo"
-version = "0.32.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f3901269ec6d4f6068d3f09e5f02f995bd076398dcd1dfec407cd230b02d11b"
-dependencies = [
- "earcutr",
- "float_next_after",
- "geo-types",
- "geographiclib-rs",
- "i_overlay",
- "log",
- "num-traits",
- "rand 0.8.6",
- "robust",
- "rstar 0.12.2",
- "serde",
- "sif-itree",
- "spade",
-]
-
-[[package]]
-name = "geo-types"
-version = "0.7.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5"
-dependencies = [
- "approx",
- "num-traits",
- "rayon",
- "rstar 0.10.0",
- "rstar 0.11.0",
- "rstar 0.12.2",
- "rstar 0.8.4",
- "rstar 0.9.3",
- "serde",
-]
-
-[[package]]
-name = "geographiclib-rs"
-version = "0.2.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7"
-dependencies = [
- "libm",
-]
-
-[[package]]
-name = "getrandom"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+name = "pocket-db"
+version = "0.1.0"
+source = "git+https://github.com/triesap/pocket?rev=329334f20948c796c6016b673b92551ac4855ad7#329334f20948c796c6016b673b92551ac4855ad7"
dependencies = [
- "cfg-if",
- "js-sys",
+ "heed",
"libc",
- "wasi",
- "wasm-bindgen",
+ "mmap-append",
+ "pocket-types",
]
[[package]]
-name = "getrandom"
-version = "0.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+name = "pocket-types"
+version = "0.1.0"
+source = "git+https://github.com/triesap/pocket?rev=329334f20948c796c6016b673b92551ac4855ad7#329334f20948c796c6016b673b92551ac4855ad7"
dependencies = [
- "cfg-if",
- "js-sys",
- "libc",
- "r-efi 5.3.0",
- "wasip2",
- "wasm-bindgen",
+ "derive_more",
+ "rand 0.9.4",
+ "secp256k1",
]
[[package]]
-name = "getrandom"
-version = "0.4.2"
+name = "potential_utf"
+version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
- "cfg-if",
- "libc",
- "r-efi 6.0.0",
- "wasip2",
- "wasip3",
+ "zerovec",
]
[[package]]
-name = "glob"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
-
-[[package]]
-name = "group"
-version = "0.13.0"
+name = "ppv-lite86"
+version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
- "ff",
- "rand_core 0.6.4",
- "subtle",
+ "zerocopy",
]
[[package]]
-name = "h2"
-version = "0.4.14"
+name = "proc-macro2"
+version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
- "atomic-waker",
- "bytes",
- "fnv",
- "futures-core",
- "futures-sink",
- "http",
- "indexmap",
- "slab",
- "tokio",
- "tokio-util",
- "tracing",
-]
-
-[[package]]
-name = "half"
-version = "2.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
-dependencies = [
- "bytemuck",
- "cfg-if",
- "crunchy",
- "num-traits",
- "rand 0.9.4",
- "rand_distr",
- "zerocopy",
-]
-
-[[package]]
-name = "hash32"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc"
-dependencies = [
- "byteorder",
-]
-
-[[package]]
-name = "hash32"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
-dependencies = [
- "byteorder",
-]
-
-[[package]]
-name = "hash32"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
-dependencies = [
- "byteorder",
-]
-
-[[package]]
-name = "hashbrown"
-version = "0.12.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
-dependencies = [
- "ahash 0.7.8",
-]
-
-[[package]]
-name = "hashbrown"
-version = "0.14.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
-
-[[package]]
-name = "hashbrown"
-version = "0.15.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
-dependencies = [
- "foldhash 0.1.5",
-]
-
-[[package]]
-name = "hashbrown"
-version = "0.16.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
-dependencies = [
- "allocator-api2",
- "equivalent",
- "foldhash 0.2.0",
-]
-
-[[package]]
-name = "hashbrown"
-version = "0.17.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
-
-[[package]]
-name = "headers"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
-dependencies = [
- "base64",
- "bytes",
- "headers-core",
- "http",
- "httpdate",
- "mime",
- "sha1",
-]
-
-[[package]]
-name = "headers-core"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
-dependencies = [
- "http",
-]
-
-[[package]]
-name = "heapless"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422"
-dependencies = [
- "as-slice",
- "generic-array 0.14.7",
- "hash32 0.1.1",
- "stable_deref_trait",
-]
-
-[[package]]
-name = "heapless"
-version = "0.7.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
-dependencies = [
- "atomic-polyfill",
- "hash32 0.2.1",
- "rustc_version",
- "spin",
- "stable_deref_trait",
-]
-
-[[package]]
-name = "heapless"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
-dependencies = [
- "hash32 0.3.1",
- "stable_deref_trait",
-]
-
-[[package]]
-name = "heck"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
-
-[[package]]
-name = "heck"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
-
-[[package]]
-name = "heed"
-version = "0.20.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d4f449bab7320c56003d37732a917e18798e2f1709d80263face2b4f9436ddb"
-dependencies = [
- "bitflags",
- "byteorder",
- "heed-traits",
- "heed-types",
- "libc",
- "lmdb-master-sys",
- "once_cell",
- "page_size",
- "serde",
- "synchronoise",
- "url",
-]
-
-[[package]]
-name = "heed-traits"
-version = "0.20.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb3130048d404c57ce5a1ac61a903696e8fcde7e8c2991e9fcfc1f27c3ef74ff"
-
-[[package]]
-name = "heed-types"
-version = "0.20.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d3f528b053a6d700b2734eabcd0fd49cb8230647aa72958467527b0b7917114"
-dependencies = [
- "bincode 1.3.3",
- "byteorder",
- "heed-traits",
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "hermit-abi"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
-
-[[package]]
-name = "hex"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
-
-[[package]]
-name = "hex-conservative"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
-dependencies = [
- "arrayvec",
-]
-
-[[package]]
-name = "hkdf"
-version = "0.12.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
-dependencies = [
- "hmac",
-]
-
-[[package]]
-name = "hmac"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
-dependencies = [
- "digest",
-]
-
-[[package]]
-name = "html5ever"
-version = "0.35.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
-dependencies = [
- "log",
- "markup5ever",
- "match_token",
-]
-
-[[package]]
-name = "http"
-version = "1.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
-dependencies = [
- "bytes",
- "itoa",
-]
-
-[[package]]
-name = "http-body"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
-dependencies = [
- "bytes",
- "http",
-]
-
-[[package]]
-name = "http-body-util"
-version = "0.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
-dependencies = [
- "bytes",
- "futures-core",
- "http",
- "http-body",
- "pin-project-lite",
-]
-
-[[package]]
-name = "httparse"
-version = "1.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
-
-[[package]]
-name = "httpdate"
-version = "1.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
-
-[[package]]
-name = "humantime"
-version = "2.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
-
-[[package]]
-name = "hyper"
-version = "1.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
-dependencies = [
- "atomic-waker",
- "bytes",
- "futures-channel",
- "futures-core",
- "h2",
- "http",
- "http-body",
- "httparse",
- "httpdate",
- "itoa",
- "pin-project-lite",
- "smallvec",
- "tokio",
- "want",
-]
-
-[[package]]
-name = "hyper-timeout"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
-dependencies = [
- "hyper",
- "hyper-util",
- "pin-project-lite",
- "tokio",
- "tower-service",
-]
-
-[[package]]
-name = "hyper-util"
-version = "0.1.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
-dependencies = [
- "base64",
- "bytes",
- "futures-channel",
- "futures-util",
- "http",
- "http-body",
- "hyper",
- "ipnet",
- "libc",
- "percent-encoding",
- "pin-project-lite",
- "socket2",
- "tokio",
- "tower-service",
- "tracing",
-]
-
-[[package]]
-name = "i_float"
-version = "1.15.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "010025c2c532c8d82e42d0b8bb5184afa449fa6f06c709ea9adcb16c49ae405b"
-dependencies = [
- "libm",
-]
-
-[[package]]
-name = "i_key_sort"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9190f86706ca38ac8add223b2aed8b1330002b5cdbbce28fb58b10914d38fc27"
-
-[[package]]
-name = "i_overlay"
-version = "4.0.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "413183068e6e0289e18d7d0a1f661b81546e6918d5453a44570b9ab30cbed1b3"
-dependencies = [
- "i_float",
- "i_key_sort",
- "i_shape",
- "i_tree",
- "rayon",
-]
-
-[[package]]
-name = "i_shape"
-version = "1.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ea154b742f7d43dae2897fcd5ead86bc7b5eefcedd305a7ebf9f69d44d61082"
-dependencies = [
- "i_float",
-]
-
-[[package]]
-name = "i_tree"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "35e6d558e6d4c7b82bc51d9c771e7a927862a161a7d87bf2b0541450e0e20915"
-
-[[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 0.62.2",
-]
-
-[[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.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
-dependencies = [
- "displaydoc",
- "potential_utf",
- "utf8_iter",
- "yoke",
- "zerofrom",
- "zerovec",
-]
-
-[[package]]
-name = "icu_locale_core"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
-dependencies = [
- "displaydoc",
- "litemap",
- "tinystr",
- "writeable",
- "zerovec",
-]
-
-[[package]]
-name = "icu_normalizer"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
-dependencies = [
- "icu_collections",
- "icu_normalizer_data",
- "icu_properties",
- "icu_provider",
- "smallvec",
- "zerovec",
-]
-
-[[package]]
-name = "icu_normalizer_data"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
-
-[[package]]
-name = "icu_properties"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
-dependencies = [
- "icu_collections",
- "icu_locale_core",
- "icu_properties_data",
- "icu_provider",
- "zerotrie",
- "zerovec",
-]
-
-[[package]]
-name = "icu_properties_data"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
-
-[[package]]
-name = "icu_provider"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
-dependencies = [
- "displaydoc",
- "icu_locale_core",
- "writeable",
- "yoke",
- "zerofrom",
- "zerotrie",
- "zerovec",
-]
-
-[[package]]
-name = "id-arena"
-version = "2.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
-
-[[package]]
-name = "idna"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
-dependencies = [
- "idna_adapter",
- "smallvec",
- "utf8_iter",
-]
-
-[[package]]
-name = "idna_adapter"
-version = "1.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
-dependencies = [
- "icu_normalizer",
- "icu_properties",
-]
-
-[[package]]
-name = "indexmap"
-version = "2.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
-dependencies = [
- "equivalent",
- "hashbrown 0.17.1",
- "serde",
- "serde_core",
-]
-
-[[package]]
-name = "inout"
-version = "0.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
-dependencies = [
- "generic-array 0.14.7",
-]
-
-[[package]]
-name = "ipnet"
-version = "2.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
-
-[[package]]
-name = "itertools"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
-dependencies = [
- "either",
-]
-
-[[package]]
-name = "itertools"
-version = "0.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
-dependencies = [
- "either",
-]
-
-[[package]]
-name = "itertools"
-version = "0.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
-dependencies = [
- "either",
-]
-
-[[package]]
-name = "itoa"
-version = "1.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
-
-[[package]]
-name = "jobserver"
-version = "0.1.34"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
-dependencies = [
- "getrandom 0.3.4",
- "libc",
-]
-
-[[package]]
-name = "js-sys"
-version = "0.3.99"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
-dependencies = [
- "cfg-if",
- "futures-util",
- "once_cell",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "jsonwebtoken"
-version = "10.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
-dependencies = [
- "aws-lc-rs",
- "base64",
- "ed25519-dalek",
- "getrandom 0.2.17",
- "hmac",
- "js-sys",
- "p256",
- "p384",
- "pem",
- "rand 0.8.6",
- "rsa",
- "serde",
- "serde_json",
- "sha2",
- "signature",
- "simple_asn1",
- "zeroize",
-]
-
-[[package]]
-name = "k256"
-version = "0.13.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
-dependencies = [
- "cfg-if",
- "ecdsa",
- "elliptic-curve",
- "once_cell",
- "sha2",
- "signature",
-]
-
-[[package]]
-name = "lazy_static"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
-dependencies = [
- "spin",
-]
-
-[[package]]
-name = "leb128fmt"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
-
-[[package]]
-name = "lexicmp"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e8f89da8fd95c4eb6274e914694bea90c7826523b26f2a2fd863d44b9d42c43"
-dependencies = [
- "deunicode",
-]
-
-[[package]]
-name = "libc"
-version = "0.2.186"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
-
-[[package]]
-name = "libloading"
-version = "0.8.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
-dependencies = [
- "cfg-if",
- "windows-link 0.2.1",
-]
-
-[[package]]
-name = "libm"
-version = "0.2.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
-
-[[package]]
-name = "libz-sys"
-version = "1.1.29"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
-dependencies = [
- "cc",
- "pkg-config",
- "vcpkg",
-]
-
-[[package]]
-name = "linux-raw-sys"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
-
-[[package]]
-name = "litemap"
-version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
-
-[[package]]
-name = "lmdb-master-sys"
-version = "0.2.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b"
-dependencies = [
- "cc",
- "doxygen-rs",
- "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.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
-
-[[package]]
-name = "lz4"
-version = "1.28.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4"
-dependencies = [
- "lz4-sys",
-]
-
-[[package]]
-name = "lz4-sys"
-version = "1.11.1+lz4-1.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6"
-dependencies = [
- "cc",
- "libc",
-]
-
-[[package]]
-name = "mac"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
-
-[[package]]
-name = "maplit"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
-
-[[package]]
-name = "markup5ever"
-version = "0.35.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
-dependencies = [
- "log",
- "tendril",
- "web_atoms",
-]
-
-[[package]]
-name = "match_token"
-version = "0.35.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "matchers"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
-dependencies = [
- "regex-automata",
-]
-
-[[package]]
-name = "matchit"
-version = "0.8.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
-
-[[package]]
-name = "matrixmultiply"
-version = "0.3.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
-dependencies = [
- "autocfg",
- "rawpointer",
-]
-
-[[package]]
-name = "md-5"
-version = "0.10.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
-dependencies = [
- "cfg-if",
- "digest",
-]
-
-[[package]]
-name = "memchr"
-version = "2.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
-
-[[package]]
-name = "memmap2"
-version = "0.9.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "mime"
-version = "0.3.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
-
-[[package]]
-name = "mime_guess"
-version = "2.0.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
-dependencies = [
- "mime",
- "unicase",
-]
-
-[[package]]
-name = "minimal-lexical"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
-
-[[package]]
-name = "mio"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
-dependencies = [
- "libc",
- "wasi",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "mmap-append"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df2ef74bd4eba425972bd31da530dbe0c0e62d15f7c06e1c6ebcc3b6d5446237"
-dependencies = [
- "libc",
- "memmap2",
-]
-
-[[package]]
-name = "ndarray"
-version = "0.17.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
-dependencies = [
- "matrixmultiply",
- "num-complex",
- "num-integer",
- "num-traits",
- "portable-atomic",
- "portable-atomic-util",
- "rawpointer",
-]
-
-[[package]]
-name = "ndarray-stats"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b6e54a8b65764f71827a90ca1d56965ec0c67f069f996477bd493402a901d1f"
-dependencies = [
- "indexmap",
- "itertools 0.13.0",
- "ndarray",
- "noisy_float",
- "num-integer",
- "num-traits",
- "rand 0.8.6",
-]
-
-[[package]]
-name = "new_debug_unreachable"
-version = "1.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
-
-[[package]]
-name = "nibble_vec"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
-dependencies = [
- "smallvec",
-]
-
-[[package]]
-name = "noisy_float"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c16843be85dd410c6a12251c4eca0dd1d3ee8c5725f746c4d5e0fdcec0a864b2"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "nom"
-version = "7.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
-dependencies = [
- "memchr",
- "minimal-lexical",
-]
-
-[[package]]
-name = "ntapi"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
-dependencies = [
- "winapi",
-]
-
-[[package]]
-name = "nu-ansi-term"
-version = "0.50.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
-dependencies = [
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "num-bigint"
-version = "0.4.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
-dependencies = [
- "num-integer",
- "num-traits",
-]
-
-[[package]]
-name = "num-bigint-dig"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
-dependencies = [
- "lazy_static",
- "libm",
- "num-integer",
- "num-iter",
- "num-traits",
- "rand 0.8.6",
- "smallvec",
- "zeroize",
-]
-
-[[package]]
-name = "num-complex"
-version = "0.4.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "num-conv"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
-
-[[package]]
-name = "num-integer"
-version = "0.1.46"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "num-iter"
-version = "0.1.45"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
-dependencies = [
- "autocfg",
- "num-integer",
- "num-traits",
-]
-
-[[package]]
-name = "num-traits"
-version = "0.2.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
-dependencies = [
- "autocfg",
- "libm",
-]
-
-[[package]]
-name = "num_cpus"
-version = "1.17.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
-dependencies = [
- "hermit-abi",
- "libc",
-]
-
-[[package]]
-name = "objc2-core-foundation"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
-dependencies = [
- "bitflags",
-]
-
-[[package]]
-name = "objc2-io-kit"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
-dependencies = [
- "libc",
- "objc2-core-foundation",
-]
-
-[[package]]
-name = "object_store"
-version = "0.13.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49"
-dependencies = [
- "async-trait",
- "bytes",
- "chrono",
- "futures-channel",
- "futures-core",
- "futures-util",
- "http",
- "humantime",
- "itertools 0.14.0",
- "parking_lot",
- "percent-encoding",
- "thiserror",
- "tokio",
- "tracing",
- "url",
- "walkdir",
- "wasm-bindgen-futures",
- "web-time",
-]
-
-[[package]]
-name = "once_cell"
-version = "1.21.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
-
-[[package]]
-name = "p256"
-version = "0.13.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
-dependencies = [
- "ecdsa",
- "elliptic-curve",
- "primeorder",
- "sha2",
-]
-
-[[package]]
-name = "p384"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
-dependencies = [
- "ecdsa",
- "elliptic-curve",
- "primeorder",
- "sha2",
-]
-
-[[package]]
-name = "page_size"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
-dependencies = [
- "libc",
- "winapi",
-]
-
-[[package]]
-name = "papaya"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7"
-dependencies = [
- "equivalent",
- "seize",
-]
-
-[[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 0.2.1",
-]
-
-[[package]]
-name = "password-hash"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
-dependencies = [
- "base64ct",
- "rand_core 0.6.4",
- "subtle",
-]
-
-[[package]]
-name = "path-clean"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef"
-
-[[package]]
-name = "pbkdf2"
-version = "0.12.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
-dependencies = [
- "digest",
- "hmac",
- "password-hash",
- "sha2",
-]
-
-[[package]]
-name = "pdqselect"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ec91767ecc0a0bbe558ce8c9da33c068066c57ecc8bb8477ef8c1ad3ef77c27"
-
-[[package]]
-name = "pem"
-version = "3.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
-dependencies = [
- "base64",
- "serde_core",
-]
-
-[[package]]
-name = "pem-rfc7468"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
-dependencies = [
- "base64ct",
-]
-
-[[package]]
-name = "percent-encoding"
-version = "2.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
-
-[[package]]
-name = "phf"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
-dependencies = [
- "phf_macros 0.11.3",
- "phf_shared 0.11.3",
-]
-
-[[package]]
-name = "phf"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
-dependencies = [
- "phf_macros 0.13.1",
- "phf_shared 0.13.1",
- "serde",
-]
-
-[[package]]
-name = "phf_codegen"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
-dependencies = [
- "phf_generator 0.11.3",
- "phf_shared 0.11.3",
-]
-
-[[package]]
-name = "phf_generator"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
-dependencies = [
- "phf_shared 0.11.3",
- "rand 0.8.6",
-]
-
-[[package]]
-name = "phf_generator"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
-dependencies = [
- "fastrand",
- "phf_shared 0.13.1",
-]
-
-[[package]]
-name = "phf_macros"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
-dependencies = [
- "phf_generator 0.11.3",
- "phf_shared 0.11.3",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "phf_macros"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
-dependencies = [
- "phf_generator 0.13.1",
- "phf_shared 0.13.1",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
- "unicase",
-]
-
-[[package]]
-name = "phf_shared"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
-dependencies = [
- "siphasher",
-]
-
-[[package]]
-name = "phf_shared"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
-dependencies = [
- "siphasher",
- "unicase",
-]
-
-[[package]]
-name = "pin-project"
-version = "1.1.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
-dependencies = [
- "pin-project-internal",
-]
-
-[[package]]
-name = "pin-project-internal"
-version = "1.1.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "pin-project-lite"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
-
-[[package]]
-name = "pin-utils"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
-
-[[package]]
-name = "pkcs1"
-version = "0.7.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
-dependencies = [
- "der",
- "pkcs8",
- "spki",
-]
-
-[[package]]
-name = "pkcs8"
-version = "0.10.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
-dependencies = [
- "der",
- "spki",
-]
-
-[[package]]
-name = "pkg-config"
-version = "0.3.33"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
-
-[[package]]
-name = "pocket-db"
-version = "0.1.0"
-source = "git+https://github.com/triesap/pocket?rev=329334f20948c796c6016b673b92551ac4855ad7#329334f20948c796c6016b673b92551ac4855ad7"
-dependencies = [
- "heed",
- "libc",
- "mmap-append",
- "pocket-types",
-]
-
-[[package]]
-name = "pocket-types"
-version = "0.1.0"
-source = "git+https://github.com/triesap/pocket?rev=329334f20948c796c6016b673b92551ac4855ad7#329334f20948c796c6016b673b92551ac4855ad7"
-dependencies = [
- "derive_more",
- "rand 0.9.4",
- "secp256k1",
-]
-
-[[package]]
-name = "portable-atomic"
-version = "1.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
-
-[[package]]
-name = "portable-atomic-util"
-version = "0.2.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
-dependencies = [
- "portable-atomic",
-]
-
-[[package]]
-name = "potential_utf"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
-dependencies = [
- "zerovec",
-]
-
-[[package]]
-name = "powerfmt"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
-
-[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
-dependencies = [
- "zerocopy",
-]
-
-[[package]]
-name = "precomputed-hash"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
-
-[[package]]
-name = "prettyplease"
-version = "0.2.37"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
-dependencies = [
- "proc-macro2",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "primeorder"
-version = "0.13.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
-dependencies = [
- "elliptic-curve",
-]
-
-[[package]]
-name = "proc-macro-crate"
-version = "3.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
-dependencies = [
- "toml_edit",
-]
-
-[[package]]
-name = "proc-macro2"
-version = "1.0.106"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "prost"
-version = "0.14.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
-dependencies = [
- "bytes",
- "prost-derive",
-]
-
-[[package]]
-name = "prost-derive"
-version = "0.14.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
-dependencies = [
- "anyhow",
- "itertools 0.14.0",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "prost-types"
-version = "0.14.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
-dependencies = [
- "prost",
-]
-
-[[package]]
-name = "psl-types"
-version = "2.0.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
-
-[[package]]
-name = "ptr_meta"
-version = "0.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
-dependencies = [
- "ptr_meta_derive",
-]
-
-[[package]]
-name = "ptr_meta_derive"
-version = "0.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "quick_cache"
-version = "0.6.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a3db184a8b66cfe87f0263a1de147a6b554c864d1767c6f7fa4eb0e5497b565"
-dependencies = [
- "ahash 0.8.12",
- "equivalent",
- "hashbrown 0.16.1",
- "parking_lot",
-]
-
-[[package]]
-name = "quote"
-version = "1.0.45"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
-dependencies = [
- "proc-macro2",
-]
-
-[[package]]
-name = "r-efi"
-version = "5.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
-
-[[package]]
-name = "r-efi"
-version = "6.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
-
-[[package]]
-name = "radium"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
-
-[[package]]
-name = "radix_trie"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a"
-dependencies = [
- "endian-type",
- "nibble_vec",
- "serde",
-]
-
-[[package]]
-name = "rand"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
-dependencies = [
- "libc",
- "rand_chacha 0.3.1",
- "rand_core 0.6.4",
-]
-
-[[package]]
-name = "rand"
-version = "0.9.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
-dependencies = [
- "rand_chacha 0.9.0",
- "rand_core 0.9.5",
-]
-
-[[package]]
-name = "rand_chacha"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.6.4",
-]
-
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.9.5",
-]
-
-[[package]]
-name = "rand_core"
-version = "0.6.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
-dependencies = [
- "getrandom 0.2.17",
-]
-
-[[package]]
-name = "rand_core"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
-dependencies = [
- "getrandom 0.3.4",
-]
-
-[[package]]
-name = "rand_distr"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
-dependencies = [
- "num-traits",
- "rand 0.9.4",
-]
-
-[[package]]
-name = "rawpointer"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
-
-[[package]]
-name = "rayon"
-version = "1.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
-dependencies = [
- "either",
- "rayon-core",
-]
-
-[[package]]
-name = "rayon-core"
-version = "1.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
-dependencies = [
- "crossbeam-deque",
- "crossbeam-utils",
-]
-
-[[package]]
-name = "reblessive"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbc4a4ea2a66a41a1152c4b3d86e8954dc087bdf33af35446e6e176db4e73c8c"
-
-[[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"
-version = "1.12.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
-dependencies = [
- "aho-corasick",
- "memchr",
- "regex-automata",
- "regex-syntax",
-]
-
-[[package]]
-name = "regex-automata"
-version = "0.4.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
-dependencies = [
- "aho-corasick",
- "memchr",
- "regex-syntax",
-]
-
-[[package]]
-name = "regex-syntax"
-version = "0.8.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
-
-[[package]]
-name = "rend"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
-dependencies = [
- "bytecheck",
-]
-
-[[package]]
-name = "reqwest"
-version = "0.13.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
-dependencies = [
- "base64",
- "bytes",
- "futures-core",
- "futures-util",
- "http",
- "http-body",
- "http-body-util",
- "hyper",
- "hyper-util",
- "js-sys",
- "log",
- "mime_guess",
- "percent-encoding",
- "pin-project-lite",
- "serde",
- "serde_json",
- "sync_wrapper",
- "tokio",
- "tokio-util",
- "tower",
- "tower-http",
- "tower-service",
- "url",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "wasm-streams",
- "web-sys",
-]
-
-[[package]]
-name = "revision"
-version = "0.28.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e735a8c2864f0b0fd48a55d0a71c081c7cbef8c8958a4665d8de423f20f2d0cf"
-dependencies = [
- "bytes",
- "chrono",
- "geo",
- "regex",
- "revision-derive",
- "roaring",
- "rust_decimal",
- "uuid",
-]
-
-[[package]]
-name = "revision-derive"
-version = "0.28.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f446f8c55ba240992330b09f69fe9e5ec8a2e1ba266843cb9f59d7bc6037c821"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "rfc6979"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
-dependencies = [
- "hmac",
- "subtle",
-]
-
-[[package]]
-name = "ring"
-version = "0.17.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
-dependencies = [
- "cc",
- "cfg-if",
- "getrandom 0.2.17",
- "libc",
- "untrusted 0.9.0",
- "windows-sys 0.52.0",
-]
-
-[[package]]
-name = "rkyv"
-version = "0.7.46"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
-dependencies = [
- "bitvec",
- "bytecheck",
- "bytes",
- "hashbrown 0.12.3",
- "ptr_meta",
- "rend",
- "rkyv_derive",
- "seahash",
- "tinyvec",
- "uuid",
-]
-
-[[package]]
-name = "rkyv_derive"
-version = "0.7.46"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "rmp"
-version = "0.8.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "rmp-serde"
-version = "1.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
-dependencies = [
- "rmp",
- "serde",
-]
-
-[[package]]
-name = "roaring"
-version = "0.11.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9"
-dependencies = [
- "bytemuck",
- "byteorder",
- "serde",
-]
-
-[[package]]
-name = "robust"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839"
-
-[[package]]
-name = "rsa"
-version = "0.9.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
-dependencies = [
- "const-oid",
- "digest",
- "num-bigint-dig",
- "num-integer",
- "num-traits",
- "pkcs1",
- "pkcs8",
- "rand_core 0.6.4",
- "signature",
- "spki",
- "subtle",
- "zeroize",
-]
-
-[[package]]
-name = "rstar"
-version = "0.8.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a45c0e8804d37e4d97e55c6f258bc9ad9c5ee7b07437009dd152d764949a27c"
-dependencies = [
- "heapless 0.6.1",
- "num-traits",
- "pdqselect",
- "serde",
- "smallvec",
-]
-
-[[package]]
-name = "rstar"
-version = "0.9.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b40f1bfe5acdab44bc63e6699c28b74f75ec43afb59f3eda01e145aff86a25fa"
-dependencies = [
- "heapless 0.7.17",
- "num-traits",
- "serde",
- "smallvec",
-]
-
-[[package]]
-name = "rstar"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1f39465655a1e3d8ae79c6d9e007f4953bfc5d55297602df9dc38f9ae9f1359a"
-dependencies = [
- "heapless 0.7.17",
- "num-traits",
- "serde",
- "smallvec",
-]
-
-[[package]]
-name = "rstar"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "73111312eb7a2287d229f06c00ff35b51ddee180f017ab6dec1f69d62ac098d6"
-dependencies = [
- "heapless 0.7.17",
- "num-traits",
- "serde",
- "smallvec",
-]
-
-[[package]]
-name = "rstar"
-version = "0.12.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb"
-dependencies = [
- "heapless 0.8.0",
- "num-traits",
- "serde",
- "smallvec",
-]
-
-[[package]]
-name = "rust-stemmers"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54"
-dependencies = [
- "serde",
- "serde_derive",
-]
-
-[[package]]
-name = "rust_decimal"
-version = "1.42.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995"
-dependencies = [
- "arrayvec",
- "borsh",
- "bytes",
- "num-traits",
- "rand 0.8.6",
- "rkyv",
- "serde",
- "serde_json",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "rustc-hash"
-version = "2.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
-
-[[package]]
-name = "rustc_version"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
-dependencies = [
- "semver",
-]
-
-[[package]]
-name = "rustix"
-version = "1.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
-dependencies = [
- "bitflags",
- "errno",
- "libc",
- "linux-raw-sys",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "rustls-pki-types"
-version = "1.14.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
-dependencies = [
- "web-time",
- "zeroize",
-]
-
-[[package]]
-name = "rustversion"
-version = "1.0.22"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
-
-[[package]]
-name = "ryu"
-version = "1.0.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
-
-[[package]]
-name = "salsa20"
-version = "0.10.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
-dependencies = [
- "cipher",
-]
-
-[[package]]
-name = "same-file"
-version = "1.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
-dependencies = [
- "winapi-util",
-]
-
-[[package]]
-name = "scopeguard"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
-
-[[package]]
-name = "scrypt"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
-dependencies = [
- "password-hash",
- "pbkdf2",
- "salsa20",
- "sha2",
-]
-
-[[package]]
-name = "seahash"
-version = "4.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
-
-[[package]]
-name = "sec1"
-version = "0.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
-dependencies = [
- "base16ct",
- "der",
- "generic-array 0.14.7",
- "pkcs8",
- "subtle",
- "zeroize",
-]
-
-[[package]]
-name = "secp256k1"
-version = "0.31.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2"
-dependencies = [
- "bitcoin_hashes",
- "rand 0.9.4",
- "secp256k1-sys",
-]
-
-[[package]]
-name = "secp256k1-sys"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38"
-dependencies = [
- "cc",
-]
-
-[[package]]
-name = "seize"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
-dependencies = [
- "libc",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "semver"
-version = "1.0.28"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
-dependencies = [
- "serde",
- "serde_core",
-]
-
-[[package]]
-name = "serde"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
-dependencies = [
- "serde_core",
- "serde_derive",
-]
-
-[[package]]
-name = "serde_core"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
-dependencies = [
- "serde_derive",
-]
-
-[[package]]
-name = "serde_derive"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "serde_json"
-version = "1.0.150"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
-dependencies = [
- "itoa",
- "memchr",
- "serde",
- "serde_core",
- "zmij",
-]
-
-[[package]]
-name = "serde_path_to_error"
-version = "0.1.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
-dependencies = [
- "itoa",
- "serde",
- "serde_core",
-]
-
-[[package]]
-name = "serde_urlencoded"
-version = "0.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
-dependencies = [
- "form_urlencoded",
- "itoa",
- "ryu",
- "serde",
-]
-
-[[package]]
-name = "sha1"
-version = "0.10.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
-dependencies = [
- "cfg-if",
- "cpufeatures 0.2.17",
- "digest",
-]
-
-[[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",
-]
-
-[[package]]
-name = "sharded-slab"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
-dependencies = [
- "lazy_static",
-]
-
-[[package]]
-name = "shlex"
-version = "1.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
-
-[[package]]
-name = "shlex"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
-
-[[package]]
-name = "sif-itree"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7f45b8998ced5134fb1d75732c77842a3e888f19c1ff98481822e8fbfbf930b"
-
-[[package]]
-name = "signal-hook-registry"
-version = "1.4.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
-dependencies = [
- "errno",
- "libc",
-]
-
-[[package]]
-name = "signature"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
-dependencies = [
- "digest",
- "rand_core 0.6.4",
-]
-
-[[package]]
-name = "simdutf8"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
-
-[[package]]
-name = "simple_asn1"
-version = "0.6.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
-dependencies = [
- "num-bigint",
- "num-traits",
- "thiserror",
- "time",
-]
-
-[[package]]
-name = "siphasher"
-version = "1.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
-
-[[package]]
-name = "slab"
-version = "0.4.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
-
-[[package]]
-name = "smallvec"
-version = "1.15.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
-dependencies = [
- "serde",
-]
-
-[[package]]
-name = "socket2"
-version = "0.6.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
-dependencies = [
- "libc",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "spade"
-version = "2.15.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa"
-dependencies = [
- "hashbrown 0.16.1",
- "num-traits",
- "robust",
- "smallvec",
-]
-
-[[package]]
-name = "spin"
-version = "0.9.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
-dependencies = [
- "lock_api",
-]
-
-[[package]]
-name = "spki"
-version = "0.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
-dependencies = [
- "base64ct",
- "der",
-]
-
-[[package]]
-name = "stable_deref_trait"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
-
-[[package]]
-name = "storekey"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd9a94571bde7369ecaac47cec2e6844642d99166bd452fbd8def74b5b917b2f"
-dependencies = [
- "bytes",
- "storekey-derive",
- "uuid",
-]
-
-[[package]]
-name = "storekey-derive"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6079d53242246522ec982de613c5c952cc7b1380ef2f8622fcdab9bfe73c0098"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "string_cache"
-version = "0.8.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
-dependencies = [
- "new_debug_unreachable",
- "parking_lot",
- "phf_shared 0.11.3",
- "precomputed-hash",
- "serde",
-]
-
-[[package]]
-name = "string_cache_codegen"
-version = "0.5.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
-dependencies = [
- "phf_generator 0.11.3",
- "phf_shared 0.11.3",
- "proc-macro2",
- "quote",
-]
-
-[[package]]
-name = "strsim"
-version = "0.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
-
-[[package]]
-name = "subtle"
-version = "2.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
-
-[[package]]
-name = "surrealdb"
-version = "3.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ddeca7bd0efc07675ddc3ec7b8e5eba2e8268b4d6c8bbcd31fcdfeab6f0484a"
-dependencies = [
- "anyhow",
- "async-channel",
- "boxcar",
- "chrono",
- "futures",
- "getrandom 0.3.4",
- "indexmap",
- "js-sys",
- "path-clean",
- "reqwest",
- "ring",
- "rustls-pki-types",
- "semver",
- "serde",
- "serde_json",
- "surrealdb-core",
- "surrealdb-types",
- "surrealdb-types-derive",
- "tokio",
- "tokio-tungstenite 0.28.0",
- "tokio-tungstenite-wasm",
- "tokio-util",
- "tracing",
- "url",
- "uuid",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "wasmtimer",
- "web-sys",
-]
-
-[[package]]
-name = "surrealdb-collections"
-version = "3.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "430d104b7ecdcfec487cfb6eb227475ec648f03c2bf991e26641bc309607b7b8"
-dependencies = [
- "revision",
- "storekey",
-]
-
-[[package]]
-name = "surrealdb-core"
-version = "3.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68e4926bc2f0cd6d1eb6a95da2024432510cbf37571133c22c62ddce3d98e22a"
-dependencies = [
- "addr",
- "affinitypool",
- "ahash 0.8.12",
- "ammonia",
- "anyhow",
- "argon2",
- "async-channel",
- "async-stream",
- "base64",
- "bcrypt",
- "blake3",
- "bytes",
- "chrono",
- "ciborium",
- "dashmap",
- "deunicode",
- "diskann",
- "diskann-utils",
- "diskann-vector",
- "dmp",
- "ext-sort",
- "fastnum",
- "fst",
- "futures",
- "fuzzy-matcher",
- "geo",
- "geo-types",
- "getrandom 0.3.4",
- "half",
- "headers",
- "hex",
- "http",
- "humantime",
- "ipnet",
- "jsonwebtoken",
- "lexicmp",
- "md-5",
- "memchr",
- "mime",
- "ndarray",
- "ndarray-stats",
- "num-traits",
- "num_cpus",
- "object_store",
- "parking_lot",
- "path-clean",
- "pbkdf2",
- "phf 0.13.1",
- "pin-project-lite",
- "quick_cache",
- "radix_trie",
- "rand 0.9.4",
- "rand_core 0.6.4",
- "rayon",
- "reblessive",
- "regex",
- "revision",
- "ring",
- "roaring",
- "rust-stemmers",
- "rust_decimal",
- "scrypt",
- "semver",
- "serde",
- "serde_json",
- "sha1",
- "sha2",
- "storekey",
- "strsim",
- "subtle",
- "surrealdb-collections",
- "surrealdb-protocol",
- "surrealdb-rocksdb",
- "surrealdb-strand",
- "surrealdb-types",
- "surrealmx",
- "sysinfo",
- "tempfile",
- "thiserror",
- "tokio",
- "tokio-util",
- "tracing",
- "ulid",
- "unicase",
- "url",
- "uuid",
- "vart",
- "wasm-bindgen-futures",
- "wasmtimer",
- "web-time",
-]
-
-[[package]]
-name = "surrealdb-librocksdb-sys"
-version = "0.18.3+11.0.0-4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25b82a18c7fa4b57206784a1a31e7b942ae1d3e24493e0c733019a409b2b4bea"
-dependencies = [
- "bindgen",
- "bzip2-sys",
- "cc",
- "libc",
- "libz-sys",
- "lz4-sys",
- "zstd-sys",
-]
-
-[[package]]
-name = "surrealdb-protocol"
-version = "0.10.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f4e06f586c9179a02349b88b0c18e3a0850c55431aa513e0cd66529c00da1af"
-dependencies = [
- "anyhow",
- "async-trait",
- "bytes",
- "chrono",
- "flatbuffers",
- "futures",
- "geo",
- "prost",
- "prost-types",
- "rust_decimal",
- "semver",
- "serde",
- "serde_json",
- "tonic",
- "tonic-prost",
- "uuid",
-]
-
-[[package]]
-name = "surrealdb-rocksdb"
-version = "0.24.0-surreal.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02e8c3a004982458af159bcbf369e41663d538cd4a291a49c0d4a2fb373cbb7e"
-dependencies = [
- "libc",
- "surrealdb-librocksdb-sys",
-]
-
-[[package]]
-name = "surrealdb-strand"
-version = "3.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "95440b332afe817529f0c6dc7440eb512cd43dd71325af0aeade47deaa843d5e"
-dependencies = [
- "revision",
- "serde",
- "storekey",
-]
-
-[[package]]
-name = "surrealdb-types"
-version = "3.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5dcc4329473a0d81be33a39b38e576e252d037d38ac4dd057283f1035826d0ae"
-dependencies = [
- "anyhow",
- "async-channel",
- "bytes",
- "castaway",
- "chrono",
- "flatbuffers",
- "geo",
- "hex",
- "http",
- "papaya",
- "rand 0.9.4",
- "regex",
- "reqwest",
- "rust_decimal",
- "semver",
- "serde",
- "serde_json",
- "surrealdb-protocol",
- "surrealdb-types-derive",
- "tracing",
- "ulid",
- "url",
- "uuid",
-]
-
-[[package]]
-name = "surrealdb-types-derive"
-version = "3.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7de5361377f98a936bb6416daa769811bc66bb94e5804abc6c26ac18c7f01870"
-dependencies = [
- "heck 0.4.1",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "surrealmx"
-version = "0.22.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e8a87f050a4860832ccf4a53e43ea264e98d27618983602b5c575e6df296054"
-dependencies = [
- "arc-swap",
- "bincode 2.0.1",
- "bytes",
- "crossbeam-deque",
- "crossbeam-queue",
- "crossbeam-skiplist",
- "lz4",
- "papaya",
- "parking_lot",
- "serde",
- "smallvec",
- "thiserror",
- "tracing",
- "web-time",
-]
-
-[[package]]
-name = "syn"
-version = "1.0.109"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "syn"
-version = "2.0.117"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "sync_wrapper"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
-dependencies = [
- "futures-core",
-]
-
-[[package]]
-name = "synchronoise"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2"
-dependencies = [
- "crossbeam-queue",
-]
-
-[[package]]
-name = "synstructure"
-version = "0.13.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "sysinfo"
-version = "0.37.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f"
-dependencies = [
- "libc",
- "memchr",
- "ntapi",
- "objc2-core-foundation",
- "objc2-io-kit",
- "windows",
-]
-
-[[package]]
-name = "tangle"
-version = "0.1.0"
-dependencies = [
- "futures-util",
- "serde_json",
- "tangle_protocol",
- "tangle_runtime",
- "tangle_store_surreal",
- "tangle_test_support",
- "tokio",
- "tokio-tungstenite 0.29.0",
- "tracing",
- "tracing-subscriber",
-]
-
-[[package]]
-name = "tangle_bench"
-version = "0.1.0"
-dependencies = [
- "serde_json",
- "sha2",
- "tangle_groups",
- "tangle_protocol",
- "tangle_runtime",
- "tangle_store_pocket",
- "tangle_test_support",
-]
-
-[[package]]
-name = "tangle_core"
-version = "0.1.0"
-dependencies = [
- "serde_json",
- "tangle_crypto",
- "tangle_nips",
- "tangle_protocol",
- "tangle_store",
- "tangle_test_support",
-]
-
-[[package]]
-name = "tangle_crypto"
-version = "0.1.0"
-dependencies = [
- "k256",
- "sha2",
- "tangle_protocol",
- "tokio",
-]
-
-[[package]]
-name = "tangle_groups"
-version = "0.1.0"
-dependencies = [
- "serde",
- "serde_json",
- "tangle_crypto",
- "tangle_protocol",
-]
-
-[[package]]
-name = "tangle_nips"
-version = "0.1.0"
-dependencies = [
- "serde_json",
- "tangle_protocol",
-]
-
-[[package]]
-name = "tangle_protocol"
-version = "0.1.0"
-dependencies = [
- "serde_json",
-]
-
-[[package]]
-name = "tangle_runtime"
-version = "0.1.0"
-dependencies = [
- "axum",
- "futures-util",
- "http",
- "serde",
- "serde_json",
- "sha2",
- "tangle_core",
- "tangle_crypto",
- "tangle_groups",
- "tangle_nips",
- "tangle_protocol",
- "tangle_store",
- "tangle_store_pocket",
- "tangle_store_surreal",
- "tangle_test_support",
- "tokio",
- "tokio-tungstenite 0.28.0",
- "tower",
- "tracing",
- "url",
-]
-
-[[package]]
-name = "tangle_store"
-version = "0.1.0"
-dependencies = [
- "tangle_nips",
- "tangle_protocol",
-]
-
-[[package]]
-name = "tangle_store_pocket"
-version = "0.1.0"
-dependencies = [
- "pocket-db",
- "pocket-types",
-]
-
-[[package]]
-name = "tangle_store_surreal"
-version = "0.1.0"
-dependencies = [
- "serde_json",
- "sha2",
- "surrealdb",
- "tangle_nips",
- "tangle_protocol",
- "tangle_store",
- "tangle_test_support",
- "tokio",
-]
-
-[[package]]
-name = "tangle_test_support"
-version = "0.1.0"
-dependencies = [
- "k256",
- "serde",
- "serde_json",
- "tangle_crypto",
- "tangle_groups",
- "tangle_nips",
- "tangle_protocol",
- "tangle_store",
-]
-
-[[package]]
-name = "tap"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
-
-[[package]]
-name = "tempfile"
-version = "3.27.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
-dependencies = [
- "fastrand",
- "getrandom 0.4.2",
- "once_cell",
- "rustix",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "tendril"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
-dependencies = [
- "futf",
- "mac",
- "utf-8",
-]
-
-[[package]]
-name = "thiserror"
-version = "2.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
-dependencies = [
- "thiserror-impl",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "2.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "thread_local"
-version = "1.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
-dependencies = [
- "cfg-if",
-]
-
-[[package]]
-name = "time"
-version = "0.3.47"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
-dependencies = [
- "deranged",
- "itoa",
- "num-conv",
- "powerfmt",
- "serde_core",
- "time-core",
- "time-macros",
-]
-
-[[package]]
-name = "time-core"
-version = "0.1.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
-
-[[package]]
-name = "time-macros"
-version = "0.2.27"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
-dependencies = [
- "num-conv",
- "time-core",
-]
-
-[[package]]
-name = "tinystr"
-version = "0.8.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
-dependencies = [
- "displaydoc",
- "zerovec",
-]
-
-[[package]]
-name = "tinyvec"
-version = "1.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
-dependencies = [
- "tinyvec_macros",
-]
-
-[[package]]
-name = "tinyvec_macros"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
-
-[[package]]
-name = "tokio"
-version = "1.52.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
-dependencies = [
- "bytes",
- "libc",
- "mio",
- "pin-project-lite",
- "signal-hook-registry",
- "socket2",
- "tokio-macros",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "tokio-macros"
-version = "2.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "tokio-stream"
-version = "0.1.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
-dependencies = [
- "futures-core",
- "pin-project-lite",
- "tokio",
-]
-
-[[package]]
-name = "tokio-tungstenite"
-version = "0.28.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
-dependencies = [
- "futures-util",
- "log",
- "tokio",
- "tungstenite 0.28.0",
-]
-
-[[package]]
-name = "tokio-tungstenite"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
-dependencies = [
- "futures-util",
- "log",
- "tokio",
- "tungstenite 0.29.0",
-]
-
-[[package]]
-name = "tokio-tungstenite-wasm"
-version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccecee909c02b8863f9bda87253127eb4da0e7e1342330b2583fbc4d1795c2f8"
-dependencies = [
- "bytes",
- "futures-channel",
- "futures-util",
- "http",
- "httparse",
- "js-sys",
- "thiserror",
- "tokio",
- "tokio-tungstenite 0.28.0",
- "wasm-bindgen",
- "web-sys",
-]
-
-[[package]]
-name = "tokio-util"
-version = "0.7.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
-dependencies = [
- "bytes",
- "futures-core",
- "futures-io",
- "futures-sink",
- "pin-project-lite",
- "tokio",
-]
-
-[[package]]
-name = "toml_datetime"
-version = "1.1.1+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
-dependencies = [
- "serde_core",
-]
-
-[[package]]
-name = "toml_edit"
-version = "0.25.12+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
-dependencies = [
- "indexmap",
- "toml_datetime",
- "toml_parser",
- "winnow",
-]
-
-[[package]]
-name = "toml_parser"
-version = "1.1.2+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
-dependencies = [
- "winnow",
-]
-
-[[package]]
-name = "tonic"
-version = "0.14.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
-dependencies = [
- "async-trait",
- "axum",
- "base64",
- "bytes",
- "h2",
- "http",
- "http-body",
- "http-body-util",
- "hyper",
- "hyper-timeout",
- "hyper-util",
- "percent-encoding",
- "pin-project",
- "socket2",
- "sync_wrapper",
- "tokio",
- "tokio-stream",
- "tower",
- "tower-layer",
- "tower-service",
- "tracing",
-]
-
-[[package]]
-name = "tonic-prost"
-version = "0.14.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
-dependencies = [
- "bytes",
- "prost",
- "tonic",
-]
-
-[[package]]
-name = "tower"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
-dependencies = [
- "futures-core",
- "futures-util",
- "indexmap",
- "pin-project-lite",
- "slab",
- "sync_wrapper",
- "tokio",
- "tokio-util",
- "tower-layer",
- "tower-service",
- "tracing",
-]
-
-[[package]]
-name = "tower-http"
-version = "0.6.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
-dependencies = [
- "bitflags",
- "bytes",
- "futures-util",
- "http",
- "http-body",
- "pin-project-lite",
- "tower",
- "tower-layer",
- "tower-service",
- "url",
-]
-
-[[package]]
-name = "tower-layer"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
-
-[[package]]
-name = "tower-service"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
-
-[[package]]
-name = "tracing"
-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",
-]
-
-[[package]]
-name = "tracing-attributes"
-version = "0.1.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "tracing-core"
-version = "0.1.36"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
-dependencies = [
- "once_cell",
- "valuable",
-]
-
-[[package]]
-name = "tracing-log"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
-dependencies = [
- "log",
- "once_cell",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-serde"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
-dependencies = [
- "serde",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-subscriber"
-version = "0.3.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
-dependencies = [
- "matchers",
- "nu-ansi-term",
- "once_cell",
- "regex-automata",
- "serde",
- "serde_json",
- "sharded-slab",
- "smallvec",
- "thread_local",
- "tracing",
- "tracing-core",
- "tracing-log",
- "tracing-serde",
-]
-
-[[package]]
-name = "trice"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3aaab10ae9fac0b10f392752bf56f0fd20845f39037fec931e8537b105b515a"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
- "web-sys",
-]
-
-[[package]]
-name = "try-lock"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
-
-[[package]]
-name = "tungstenite"
-version = "0.28.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
-dependencies = [
- "bytes",
- "data-encoding",
- "http",
- "httparse",
- "log",
- "rand 0.9.4",
- "sha1",
- "thiserror",
- "url",
- "utf-8",
-]
-
-[[package]]
-name = "tungstenite"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
-dependencies = [
- "bytes",
- "data-encoding",
- "http",
- "httparse",
- "log",
- "rand 0.9.4",
- "sha1",
- "thiserror",
-]
-
-[[package]]
-name = "typenum"
-version = "1.20.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
-
-[[package]]
-name = "ulid"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
-dependencies = [
- "rand 0.9.4",
- "serde",
- "web-time",
+ "unicode-ident",
]
[[package]]
-name = "unicase"
-version = "2.9.0"
+name = "quote"
+version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
[[package]]
-name = "unicode-ident"
-version = "1.0.24"
+name = "r-efi"
+version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
-name = "unicode-xid"
-version = "0.2.6"
+name = "rand"
+version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "rand_core 0.6.4",
+]
[[package]]
-name = "untrusted"
-version = "0.7.1"
+name = "rand"
+version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha",
+ "rand_core 0.9.5",
+]
[[package]]
-name = "untrusted"
+name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
[[package]]
-name = "unty"
-version = "0.0.4"
+name = "rand_core"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
[[package]]
-name = "url"
-version = "2.5.8"
+name = "rand_core"
+version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
- "form_urlencoded",
- "idna",
- "percent-encoding",
- "serde",
+ "getrandom 0.3.4",
]
[[package]]
-name = "urlencoding"
-version = "2.1.3"
+name = "rfc6979"
+version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
[[package]]
-name = "utf-8"
-version = "0.7.6"
+name = "rustc_version"
+version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
[[package]]
-name = "utf8_iter"
-version = "1.0.4"
+name = "ryu"
+version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
-name = "uuid"
-version = "1.23.2"
+name = "sec1"
+version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
- "getrandom 0.4.2",
- "js-sys",
- "serde_core",
- "wasm-bindgen",
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
]
[[package]]
-name = "valuable"
-version = "0.1.1"
+name = "secp256k1"
+version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2"
+dependencies = [
+ "bitcoin_hashes",
+ "rand 0.9.4",
+ "secp256k1-sys",
+]
[[package]]
-name = "vart"
-version = "0.9.3"
+name = "secp256k1-sys"
+version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1982d899e57d646498709735f16e9224cf1e8680676ad687f930cf8b5b555ae"
+checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38"
+dependencies = [
+ "cc",
+]
[[package]]
-name = "vcpkg"
-version = "0.2.15"
+name = "semver"
+version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
-name = "version_check"
-version = "0.9.5"
+name = "serde"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
[[package]]
-name = "virtue"
-version = "0.0.18"
+name = "serde_core"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
[[package]]
-name = "walkdir"
-version = "2.5.0"
+name = "serde_derive"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
- "same-file",
- "winapi-util",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
-name = "want"
-version = "0.3.1"
+name = "serde_json"
+version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
- "try-lock",
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
]
[[package]]
-name = "wasi"
-version = "0.11.1+wasi-snapshot-preview1"
+name = "serde_path_to_error"
+version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
[[package]]
-name = "wasip2"
-version = "1.0.3+wasi-0.2.9"
+name = "serde_urlencoded"
+version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
- "wit-bindgen 0.57.1",
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
]
[[package]]
-name = "wasip3"
-version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+name = "sha1"
+version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
- "wit-bindgen 0.51.0",
+ "cfg-if",
+ "cpufeatures",
+ "digest",
]
[[package]]
-name = "wasm-bindgen"
-version = "0.2.122"
+name = "sha2"
+version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
- "once_cell",
- "rustversion",
- "serde",
- "wasm-bindgen-macro",
- "wasm-bindgen-shared",
+ "cpufeatures",
+ "digest",
]
[[package]]
-name = "wasm-bindgen-futures"
-version = "0.4.72"
+name = "shlex"
+version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
-name = "wasm-bindgen-macro"
-version = "0.2.122"
+name = "signature"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
- "quote",
- "wasm-bindgen-macro-support",
+ "digest",
+ "rand_core 0.6.4",
]
[[package]]
-name = "wasm-bindgen-macro-support"
-version = "0.2.122"
+name = "siphasher"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
-dependencies = [
- "bumpalo",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
- "wasm-bindgen-shared",
-]
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
-name = "wasm-bindgen-shared"
-version = "0.2.122"
+name = "slab"
+version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
-dependencies = [
- "unicode-ident",
-]
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
-name = "wasm-encoder"
-version = "0.244.0"
+name = "smallvec"
+version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
-dependencies = [
- "leb128fmt",
- "wasmparser",
-]
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
-name = "wasm-metadata"
-version = "0.244.0"
+name = "socket2"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
- "anyhow",
- "indexmap",
- "wasm-encoder",
- "wasmparser",
+ "libc",
+ "windows-sys",
]
[[package]]
-name = "wasm-streams"
-version = "0.5.0"
+name = "spki"
+version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
- "futures-util",
- "js-sys",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
+ "base64ct",
+ "der",
]
[[package]]
-name = "wasmparser"
-version = "0.244.0"
+name = "stable_deref_trait"
+version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
-dependencies = [
- "bitflags",
- "hashbrown 0.15.5",
- "indexmap",
- "semver",
-]
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
-name = "wasmtimer"
-version = "0.4.3"
+name = "subtle"
+version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b"
-dependencies = [
- "futures",
- "js-sys",
- "parking_lot",
- "pin-utils",
- "wasm-bindgen",
-]
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
-name = "web-sys"
-version = "0.3.99"
+name = "syn"
+version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
- "js-sys",
- "wasm-bindgen",
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
]
[[package]]
-name = "web-time"
-version = "1.1.0"
+name = "sync_wrapper"
+version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
-name = "web_atoms"
-version = "0.1.3"
+name = "synchronoise"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
+checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2"
dependencies = [
- "phf 0.11.3",
- "phf_codegen",
- "string_cache",
- "string_cache_codegen",
+ "crossbeam-queue",
]
[[package]]
-name = "winapi"
-version = "0.3.9"
+name = "synstructure"
+version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
- "winapi-i686-pc-windows-gnu",
- "winapi-x86_64-pc-windows-gnu",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
-name = "winapi-i686-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+name = "tangle"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+ "tangle_protocol",
+ "tangle_runtime",
+ "tangle_test_support",
+]
[[package]]
-name = "winapi-util"
-version = "0.1.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+name = "tangle_bench"
+version = "0.1.0"
dependencies = [
- "windows-sys 0.61.2",
+ "serde_json",
+ "sha2",
+ "tangle_groups",
+ "tangle_protocol",
+ "tangle_runtime",
+ "tangle_store_pocket",
+ "tangle_test_support",
]
[[package]]
-name = "winapi-x86_64-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+name = "tangle_crypto"
+version = "0.1.0"
+dependencies = [
+ "k256",
+ "sha2",
+ "tangle_protocol",
+ "tokio",
+]
[[package]]
-name = "windows"
-version = "0.61.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
+name = "tangle_groups"
+version = "0.1.0"
dependencies = [
- "windows-collections",
- "windows-core 0.61.2",
- "windows-future",
- "windows-link 0.1.3",
- "windows-numerics",
+ "serde",
+ "serde_json",
+ "tangle_crypto",
+ "tangle_protocol",
]
[[package]]
-name = "windows-collections"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
+name = "tangle_protocol"
+version = "0.1.0"
dependencies = [
- "windows-core 0.61.2",
+ "serde_json",
]
[[package]]
-name = "windows-core"
-version = "0.61.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+name = "tangle_runtime"
+version = "0.1.0"
dependencies = [
- "windows-implement",
- "windows-interface",
- "windows-link 0.1.3",
- "windows-result 0.3.4",
- "windows-strings 0.4.2",
+ "axum",
+ "http",
+ "serde",
+ "serde_json",
+ "tangle_crypto",
+ "tangle_groups",
+ "tangle_protocol",
+ "tangle_store_pocket",
+ "tangle_test_support",
+ "tokio",
+ "tower",
+ "tracing",
]
[[package]]
-name = "windows-core"
-version = "0.62.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+name = "tangle_store_pocket"
+version = "0.1.0"
dependencies = [
- "windows-implement",
- "windows-interface",
- "windows-link 0.2.1",
- "windows-result 0.4.1",
- "windows-strings 0.5.1",
+ "pocket-db",
+ "pocket-types",
]
[[package]]
-name = "windows-future"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
+name = "tangle_test_support"
+version = "0.1.0"
dependencies = [
- "windows-core 0.61.2",
- "windows-link 0.1.3",
- "windows-threading",
+ "k256",
+ "serde_json",
+ "tangle_crypto",
+ "tangle_groups",
+ "tangle_protocol",
]
[[package]]
-name = "windows-implement"
-version = "0.60.2"
+name = "thiserror"
+version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
+ "thiserror-impl",
]
[[package]]
-name = "windows-interface"
-version = "0.59.3"
+name = "thiserror-impl"
+version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.117",
+ "syn",
]
[[package]]
-name = "windows-link"
-version = "0.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
-
-[[package]]
-name = "windows-link"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
-
-[[package]]
-name = "windows-numerics"
-version = "0.2.0"
+name = "tinystr"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
- "windows-core 0.61.2",
- "windows-link 0.1.3",
+ "displaydoc",
+ "zerovec",
]
[[package]]
-name = "windows-result"
-version = "0.3.4"
+name = "tokio"
+version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
- "windows-link 0.1.3",
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "tokio-macros",
+ "windows-sys",
]
[[package]]
-name = "windows-result"
-version = "0.4.1"
+name = "tokio-macros"
+version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
- "windows-link 0.2.1",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
-name = "windows-strings"
-version = "0.4.2"
+name = "tokio-tungstenite"
+version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
- "windows-link 0.1.3",
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
]
[[package]]
-name = "windows-strings"
-version = "0.5.1"
+name = "tower"
+version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
- "windows-link 0.2.1",
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
]
[[package]]
-name = "windows-sys"
-version = "0.52.0"
+name = "tower-layer"
+version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
-dependencies = [
- "windows-targets",
-]
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
-name = "windows-sys"
-version = "0.61.2"
+name = "tower-service"
+version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
-dependencies = [
- "windows-link 0.2.1",
-]
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
-name = "windows-targets"
-version = "0.52.6"
+name = "tracing"
+version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
- "windows_aarch64_gnullvm",
- "windows_aarch64_msvc",
- "windows_i686_gnu",
- "windows_i686_gnullvm",
- "windows_i686_msvc",
- "windows_x86_64_gnu",
- "windows_x86_64_gnullvm",
- "windows_x86_64_msvc",
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
]
[[package]]
-name = "windows-threading"
-version = "0.1.0"
+name = "tracing-attributes"
+version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
- "windows-link 0.1.3",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.52.6"
+name = "tracing-core"
+version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
[[package]]
-name = "windows_aarch64_msvc"
-version = "0.52.6"
+name = "tungstenite"
+version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.9.4",
+ "sha1",
+ "thiserror",
+]
[[package]]
-name = "windows_i686_gnu"
-version = "0.52.6"
+name = "typenum"
+version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
-name = "windows_i686_gnullvm"
-version = "0.52.6"
+name = "unicode-ident"
+version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
-name = "windows_i686_msvc"
-version = "0.52.6"
+name = "url"
+version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
[[package]]
-name = "windows_x86_64_gnu"
-version = "0.52.6"
+name = "utf8_iter"
+version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.52.6"
+name = "version_check"
+version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
-name = "windows_x86_64_msvc"
-version = "0.52.6"
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
-name = "winnow"
-version = "1.0.3"
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
- "memchr",
+ "wit-bindgen",
]
[[package]]
-name = "wit-bindgen"
-version = "0.51.0"
+name = "winapi"
+version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
- "wit-bindgen-rust-macro",
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
]
[[package]]
-name = "wit-bindgen"
-version = "0.57.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
-
-[[package]]
-name = "wit-bindgen-core"
-version = "0.51.0"
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
-dependencies = [
- "anyhow",
- "heck 0.5.0",
- "wit-parser",
-]
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
-name = "wit-bindgen-rust"
-version = "0.51.0"
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
-dependencies = [
- "anyhow",
- "heck 0.5.0",
- "indexmap",
- "prettyplease",
- "syn 2.0.117",
- "wasm-metadata",
- "wit-bindgen-core",
- "wit-component",
-]
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
-name = "wit-bindgen-rust-macro"
-version = "0.51.0"
+name = "windows-link"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
-dependencies = [
- "anyhow",
- "prettyplease",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
- "wit-bindgen-core",
- "wit-bindgen-rust",
-]
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
-name = "wit-component"
-version = "0.244.0"
+name = "windows-sys"
+version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
- "anyhow",
- "bitflags",
- "indexmap",
- "log",
- "serde",
- "serde_derive",
- "serde_json",
- "wasm-encoder",
- "wasm-metadata",
- "wasmparser",
- "wit-parser",
+ "windows-link",
]
[[package]]
-name = "wit-parser"
-version = "0.244.0"
+name = "wit-bindgen"
+version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
-dependencies = [
- "anyhow",
- "id-arena",
- "indexmap",
- "log",
- "semver",
- "serde",
- "serde_derive",
- "serde_json",
- "unicode-xid",
- "wasmparser",
-]
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
@@ -5587,15 +1534,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
-name = "wyz"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
-dependencies = [
- "tap",
-]
-
-[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5614,7 +1552,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.117",
+ "syn",
"synstructure",
]
@@ -5635,7 +1573,7 @@ checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.117",
+ "syn",
]
[[package]]
@@ -5655,7 +1593,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.117",
+ "syn",
"synstructure",
]
@@ -5664,20 +1602,6 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
-dependencies = [
- "zeroize_derive",
-]
-
-[[package]]
-name = "zeroize_derive"
-version = "1.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
[[package]]
name = "zerotrie"
@@ -5709,7 +1633,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.117",
+ "syn",
]
[[package]]
@@ -5717,13 +1641,3 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
-
-[[package]]
-name = "zstd-sys"
-version = "2.0.16+zstd.1.5.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
-dependencies = [
- "cc",
- "pkg-config",
-]
diff --git a/Cargo.toml b/Cargo.toml
@@ -2,15 +2,11 @@
members = [
"crates/tangle",
"crates/tangle_bench",
- "crates/tangle_core",
"crates/tangle_crypto",
"crates/tangle_groups",
- "crates/tangle_nips",
"crates/tangle_protocol",
"crates/tangle_runtime",
- "crates/tangle_store",
"crates/tangle_store_pocket",
- "crates/tangle_store_surreal",
"crates/tangle_test_support",
]
resolver = "2"
diff --git a/crates/tangle/Cargo.toml b/crates/tangle/Cargo.toml
@@ -10,18 +10,11 @@ readme = "../../README"
[dependencies]
tangle_runtime = { path = "../tangle_runtime" }
-tokio = { version = "1", features = ["rt", "signal"] }
-tracing = "0.1"
-tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
[dev-dependencies]
-futures-util = "0.3"
serde_json = "1"
tangle_protocol = { path = "../tangle_protocol" }
-tangle_store_surreal = { path = "../tangle_store_surreal" }
tangle_test_support = { path = "../tangle_test_support" }
-tokio = { version = "1", features = ["macros", "rt", "time"] }
-tokio-tungstenite = "0.29"
[lints]
workspace = true
diff --git a/crates/tangle/src/lib.rs b/crates/tangle/src/lib.rs
@@ -7,25 +7,13 @@ pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const USAGE: &str = "\
usage:
tangle [--version]
- tangle migrate --config PATH
- tangle run --config PATH
- tangle event import --config PATH --input PATH
- tangle event export --config PATH --output PATH
- tangle projection rebuild --config PATH
- tangle ops backup --config PATH --output DIR
- tangle ops restore --config PATH --input DIR";
+ tangle run --config PATH";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TangleCommand {
Version,
Help,
- Migrate,
Run,
- EventImport,
- EventExport,
- ProjectionRebuild,
- OpsBackup,
- OpsRestore,
}
impl TangleCommand {
@@ -33,27 +21,15 @@ impl TangleCommand {
match self {
Self::Version => "version",
Self::Help => "help",
- Self::Migrate => "migrate",
Self::Run => "run",
- Self::EventImport => "event import",
- Self::EventExport => "event export",
- Self::ProjectionRebuild => "projection rebuild",
- Self::OpsBackup => "ops backup",
- Self::OpsRestore => "ops restore",
}
}
-
- pub fn implemented(self) -> bool {
- true
- }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TangleInvocation {
command: TangleCommand,
config_path: Option<String>,
- input_path: Option<String>,
- output_path: Option<String>,
}
impl TangleInvocation {
@@ -61,21 +37,9 @@ impl TangleInvocation {
Self {
command,
config_path,
- input_path: None,
- output_path: None,
}
}
- pub fn with_input_path(mut self, input_path: Option<String>) -> Self {
- self.input_path = input_path;
- self
- }
-
- pub fn with_output_path(mut self, output_path: Option<String>) -> Self {
- self.output_path = output_path;
- self
- }
-
pub fn command(&self) -> TangleCommand {
self.command
}
@@ -83,20 +47,11 @@ impl TangleInvocation {
pub fn config_path(&self) -> Option<&str> {
self.config_path.as_deref()
}
-
- pub fn input_path(&self) -> Option<&str> {
- self.input_path.as_deref()
- }
-
- pub fn output_path(&self) -> Option<&str> {
- self.output_path.as_deref()
- }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TangleCliError {
UnknownCommand(String),
- MissingNestedCommand(&'static str),
MissingOptionValue(&'static str),
RepeatedOption(&'static str),
UnexpectedArgument { command: String, argument: String },
@@ -106,9 +61,6 @@ impl fmt::Display for TangleCliError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownCommand(command) => write!(formatter, "unknown command: {command}"),
- Self::MissingNestedCommand(command) => {
- write!(formatter, "{command} command requires a nested command")
- }
Self::MissingOptionValue(option) => {
write!(formatter, "{option} requires a value")
}
@@ -155,46 +107,10 @@ where
let command = match first.as_str() {
"--version" | "-V" => TangleCommand::Version,
"--help" | "-h" | "help" => TangleCommand::Help,
- "migrate" => TangleCommand::Migrate,
"run" => TangleCommand::Run,
- "event" => {
- let Some(nested) = args.next() else {
- return Err(TangleCliError::MissingNestedCommand("event"));
- };
- match nested.as_str() {
- "import" => TangleCommand::EventImport,
- "export" => TangleCommand::EventExport,
- _ => return Err(TangleCliError::UnknownCommand(format!("event {nested}"))),
- }
- }
- "projection" => {
- let Some(nested) = args.next() else {
- return Err(TangleCliError::MissingNestedCommand("projection"));
- };
- match nested.as_str() {
- "rebuild" => TangleCommand::ProjectionRebuild,
- _ => {
- return Err(TangleCliError::UnknownCommand(format!(
- "projection {nested}"
- )));
- }
- }
- }
- "ops" => {
- let Some(nested) = args.next() else {
- return Err(TangleCliError::MissingNestedCommand("ops"));
- };
- match nested.as_str() {
- "backup" => TangleCommand::OpsBackup,
- "restore" => TangleCommand::OpsRestore,
- _ => return Err(TangleCliError::UnknownCommand(format!("ops {nested}"))),
- }
- }
_ => return Err(TangleCliError::UnknownCommand(first)),
};
let mut config_path = None;
- let mut input_path = None;
- let mut output_path = None;
while let Some(argument) = args.next() {
match argument.as_str() {
"--config" => {
@@ -206,24 +122,6 @@ where
};
config_path = Some(path);
}
- "--input" => {
- if input_path.is_some() {
- return Err(TangleCliError::RepeatedOption("--input"));
- }
- let Some(path) = args.next() else {
- return Err(TangleCliError::MissingOptionValue("--input"));
- };
- input_path = Some(path);
- }
- "--output" => {
- if output_path.is_some() {
- return Err(TangleCliError::RepeatedOption("--output"));
- }
- let Some(path) = args.next() else {
- return Err(TangleCliError::MissingOptionValue("--output"));
- };
- output_path = Some(path);
- }
_ => {
return Err(TangleCliError::UnexpectedArgument {
command: command.as_str().to_owned(),
@@ -232,31 +130,13 @@ where
}
}
}
- if input_path.is_some()
- && !matches!(
- command,
- TangleCommand::EventImport | TangleCommand::OpsRestore
- )
- {
- return Err(TangleCliError::UnexpectedArgument {
- command: command.as_str().to_owned(),
- argument: "--input".to_owned(),
- });
- }
- if output_path.is_some()
- && !matches!(
- command,
- TangleCommand::EventExport | TangleCommand::OpsBackup
- )
- {
+ if config_path.is_some() && command != TangleCommand::Run {
return Err(TangleCliError::UnexpectedArgument {
command: command.as_str().to_owned(),
- argument: "--output".to_owned(),
+ argument: "--config".to_owned(),
});
}
- Ok(TangleInvocation::new(command, config_path)
- .with_input_path(input_path)
- .with_output_path(output_path))
+ Ok(TangleInvocation::new(command, config_path))
}
pub fn require_config_path(invocation: &TangleInvocation) -> Result<&str, TangleCliError> {
@@ -265,782 +145,102 @@ pub fn require_config_path(invocation: &TangleInvocation) -> Result<&str, Tangle
.ok_or(TangleCliError::MissingOptionValue("--config"))
}
-pub fn require_input_path(invocation: &TangleInvocation) -> Result<&str, TangleCliError> {
- invocation
- .input_path()
- .ok_or(TangleCliError::MissingOptionValue("--input"))
-}
-
-pub fn require_output_path(invocation: &TangleInvocation) -> Result<&str, TangleCliError> {
- invocation
- .output_path()
- .ok_or(TangleCliError::MissingOptionValue("--output"))
-}
-
-pub fn migrate_output(report: tangle_runtime::RuntimeMigrationReport) -> String {
- format!(
- "migrations applied: {}\nmigrations already applied: {}\nmigrations total: {}",
- report.applied(),
- report.already_applied(),
- report.total()
- )
-}
-
-pub fn event_import_output(report: tangle_runtime::RuntimeEventImportReport) -> String {
- format!(
- "events total: {}\nevents inserted: {}\nevents duplicate: {}\nevents projected: {}\nevents skipped: {}",
- report.total(),
- report.inserted(),
- report.duplicate(),
- report.projected(),
- report.skipped()
- )
-}
-
-pub fn event_export_output(report: tangle_runtime::RuntimeEventExportReport) -> String {
- format!("events exported: {}", report.exported())
-}
-
-pub fn projection_rebuild_output(report: tangle_runtime::RuntimeProjectionRebuildReport) -> String {
- format!(
- "events scanned: {}\nevents rebuilt: {}\nlistings projected: {}\nevents skipped: {}",
- report.scanned(),
- report.rebuilt(),
- report.projected(),
- report.skipped()
- )
-}
-
-pub fn ops_backup_output(report: &tangle_runtime::RuntimeBackupReport) -> String {
- format!(
- "backup directory: {}\nraw events: {}\nraw events sha256: {}\nsurrealdb export available: {}\nmanifest: {}\nmanifest sha256: {}",
- report.output_dir().display(),
- report.raw_event_count(),
- report.raw_events_sha256(),
- report.surrealdb_export_available(),
- report.manifest_path().display(),
- report.manifest_sha256()
- )
-}
-
-pub fn ops_restore_output(report: &tangle_runtime::RuntimeRestoreReport) -> String {
- format!(
- "restore directory: {}\nraw events: {}\nraw events sha256: {}\nevents inserted: {}\nevents duplicate: {}\nevents rebuilt: {}\nlistings projected: {}\nevents skipped: {}",
- report.input_dir().display(),
- report.raw_event_count(),
- report.raw_events_sha256(),
- report.import_report().inserted(),
- report.import_report().duplicate(),
- report.rebuild_report().rebuilt(),
- report.rebuild_report().projected(),
- report.import_report().skipped() + report.rebuild_report().skipped()
- )
-}
-
-pub async fn migrate_with_config(path: &str) -> Result<String, String> {
- let config = tangle_runtime::load_runtime_config(path).map_err(|error| error.to_string())?;
- initialize_tracing(config.tracing_config())?;
- let report = tangle_runtime::migrate_runtime_database(&config)
- .await
- .map_err(|error| error.to_string())?;
- Ok(migrate_output(report))
-}
-
-pub async fn event_import_with_config(
- config_path: &str,
- input_path: &str,
-) -> Result<String, String> {
- let config =
- tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?;
- initialize_tracing(config.tracing_config())?;
- let report = tangle_runtime::import_events_from_path(&config, input_path)
- .await
+pub fn run_with_config(config_path: &str) -> Result<String, String> {
+ let report = tangle_runtime::open_base_relay_from_config_path(config_path)
.map_err(|error| error.to_string())?;
- Ok(event_import_output(report))
-}
-
-pub async fn event_export_with_config(
- config_path: &str,
- output_path: &str,
-) -> Result<String, String> {
- let config =
- tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?;
- initialize_tracing(config.tracing_config())?;
- let report = tangle_runtime::export_events_to_path(&config, output_path)
- .await
- .map_err(|error| error.to_string())?;
- Ok(event_export_output(report))
-}
-
-pub async fn projection_rebuild_with_config(config_path: &str) -> Result<String, String> {
- let config =
- tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?;
- initialize_tracing(config.tracing_config())?;
- let report = tangle_runtime::rebuild_projections(&config)
- .await
- .map_err(|error| error.to_string())?;
- Ok(projection_rebuild_output(report))
-}
-
-pub async fn ops_backup_with_config(config_path: &str, output_dir: &str) -> Result<String, String> {
- let config =
- tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?;
- initialize_tracing(config.tracing_config())?;
- let report = tangle_runtime::backup_runtime_database(&config, output_dir)
- .await
- .map_err(|error| error.to_string())?;
- Ok(ops_backup_output(&report))
-}
-
-pub async fn ops_restore_with_config(config_path: &str, input_dir: &str) -> Result<String, String> {
- let config =
- tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?;
- initialize_tracing(config.tracing_config())?;
- let report = tangle_runtime::restore_runtime_database(&config, input_dir)
- .await
- .map_err(|error| error.to_string())?;
- Ok(ops_restore_output(&report))
-}
-
-pub async fn run_with_config(path: &str) -> Result<(), String> {
- let config = tangle_runtime::load_runtime_config(path).map_err(|error| error.to_string())?;
- initialize_tracing(config.tracing_config())?;
- let (shutdown, _) = tangle_runtime::GracefulShutdownSignal::new();
- let signal = shutdown.clone();
- #[rustfmt::skip]
- tokio::spawn(async move { if tokio::signal::ctrl_c().await.is_ok() { signal.request_shutdown(); } });
- tangle_runtime::run_runtime_server(config, shutdown)
- .await
- .map(|_| ())
- .map_err(|error| error.to_string())
-}
-
-fn initialize_tracing(config: &tangle_runtime::RuntimeTracingConfig) -> Result<(), String> {
- if !config.enabled() {
- return Ok(());
- }
- let filter = tracing_subscriber::EnvFilter::try_new(config.filter())
- .map_err(|error| format!("tracing filter is invalid: {error}"))?;
- match config.format() {
- tangle_runtime::RuntimeTracingFormat::Compact => {
- let _ = tracing_subscriber::fmt()
- .with_env_filter(filter)
- .with_writer(std::io::stderr)
- .try_init();
- }
- tangle_runtime::RuntimeTracingFormat::Json => {
- let _ = tracing_subscriber::fmt()
- .with_env_filter(filter)
- .with_writer(std::io::stderr)
- .json()
- .try_init();
- }
- }
- #[rustfmt::skip]
- tracing::info!(filter = config.filter(), format = config.format().as_str(), "tracing initialized");
- Ok(())
+ Ok(format!(
+ "relay url: {}\npocket data directory: {}\ngroups enabled: {}\nreadiness: {}",
+ report.relay_url(),
+ report.data_directory().display(),
+ report.groups_enabled(),
+ report.readiness().response().status
+ ))
}
#[cfg(test)]
mod tests {
use super::{
PACKAGE_NAME, PACKAGE_VERSION, TangleCliError, TangleCommand, TangleInvocation,
- event_export_output, event_export_with_config, event_import_output,
- event_import_with_config, initialize_tracing, migrate_output, migrate_with_config,
- ops_backup_output, ops_backup_with_config, ops_restore_output, ops_restore_with_config,
- parse_tangle_command, parse_tangle_invocation, projection_rebuild_output,
- projection_rebuild_with_config, require_config_path, require_input_path,
- require_output_path, run_with_config, usage_output, version_output,
- };
- use std::{
- fs,
- path::{Path, PathBuf},
- };
- use tangle_runtime::{
- RuntimeBackupReport, RuntimeEventExportReport, RuntimeEventImportReport,
- RuntimeMigrationReport, RuntimeProjectionRebuildReport, RuntimeRestoreReport,
- RuntimeTracingConfig, RuntimeTracingFormat,
+ parse_tangle_invocation, require_config_path, usage_output, version_output,
};
#[test]
- fn package_name_is_tangle() {
+ fn package_constants_track_cargo_metadata() {
assert_eq!(PACKAGE_NAME, "tangle");
- }
-
- #[test]
- fn package_version_matches_manifest() {
assert_eq!(PACKAGE_VERSION, "0.1.0");
- }
-
- #[test]
- fn version_output_contains_package_and_version() {
assert_eq!(version_output(), "tangle 0.1.0");
}
#[test]
- fn tracing_setup_ignores_disabled_config_and_rejects_bad_filters() {
- assert_eq!(
- initialize_tracing(&RuntimeTracingConfig::disabled()),
- Ok(())
- );
- let invalid =
- RuntimeTracingConfig::new(true, "bad[", RuntimeTracingFormat::Compact).expect("config");
- assert!(
- initialize_tracing(&invalid)
- .expect_err("invalid filter")
- .starts_with("tracing filter is invalid:")
- );
- }
-
- #[test]
- fn tracing_setup_accepts_compact_format() {
- let config =
- RuntimeTracingConfig::new(true, "info,tangle=info", RuntimeTracingFormat::Compact)
- .expect("compact tracing config");
-
- assert_eq!(initialize_tracing(&config), Ok(()));
- }
-
- #[tokio::test]
- async fn runtime_command_wrappers_report_config_load_errors() {
- let missing = "missing-runtime-config.json";
-
- for error in [
- migrate_with_config(missing).await.expect_err("migrate"),
- event_import_with_config(missing, "events.jsonl")
- .await
- .expect_err("import"),
- event_export_with_config(missing, "events.jsonl")
- .await
- .expect_err("export"),
- projection_rebuild_with_config(missing)
- .await
- .expect_err("rebuild"),
- ops_backup_with_config(missing, "backup")
- .await
- .expect_err("backup"),
- ops_restore_with_config(missing, "backup")
- .await
- .expect_err("restore"),
- run_with_config(missing).await.expect_err("run"),
- ] {
- assert!(error.contains("failed to read runtime config"));
- }
- }
-
- #[tokio::test]
- async fn runtime_command_wrappers_report_tracing_errors() {
- let root = temp_root("tracing-errors");
- let config_path = root.join("runtime.json");
- fs::create_dir_all(&root).expect("runtime root");
- write_memory_config(&config_path, "tangle_cli_tracing", Some("bad["));
-
- for error in [
- migrate_with_config(path_str(&config_path))
- .await
- .expect_err("migrate"),
- event_import_with_config(path_str(&config_path), "events.jsonl")
- .await
- .expect_err("import"),
- event_export_with_config(path_str(&config_path), "events.jsonl")
- .await
- .expect_err("export"),
- projection_rebuild_with_config(path_str(&config_path))
- .await
- .expect_err("rebuild"),
- ops_backup_with_config(path_str(&config_path), path_str(&root.join("backup")))
- .await
- .expect_err("backup"),
- ops_restore_with_config(path_str(&config_path), path_str(&root.join("backup")))
- .await
- .expect_err("restore"),
- run_with_config(path_str(&config_path))
- .await
- .expect_err("run"),
- ] {
- assert!(error.starts_with("tracing filter is invalid:"));
- }
-
- fs::remove_dir_all(&root).expect("remove runtime root");
- }
-
- #[tokio::test]
- async fn runtime_command_wrappers_report_operation_errors() {
- let root = temp_root("operation-errors");
- let memory_config_path = root.join("memory-runtime.json");
- let store_error_config_path = root.join("store-error-runtime.json");
- let db_file = root.join("not-a-database-dir");
- let backup_file = root.join("backup-file");
- fs::create_dir_all(&root).expect("runtime root");
- fs::write(&db_file, "db").expect("db file");
- fs::write(&backup_file, "backup").expect("backup file");
- write_memory_config(&memory_config_path, "tangle_cli_operation", None);
- write_rocksdb_config(
- &store_error_config_path,
- &db_file,
- "tangle_cli_operation_error",
- );
-
- let missing_events = root.join("missing-events.jsonl");
- let missing_restore = root.join("missing-restore");
- let output_events = root.join("events.jsonl");
- let file_errors = [
- event_import_with_config(path_str(&memory_config_path), path_str(&missing_events))
- .await
- .expect_err("import"),
- ops_backup_with_config(path_str(&memory_config_path), path_str(&backup_file))
- .await
- .expect_err("backup"),
- ops_restore_with_config(path_str(&memory_config_path), path_str(&missing_restore))
- .await
- .expect_err("restore"),
- ];
- let store_errors = [
- migrate_with_config(path_str(&store_error_config_path))
- .await
- .expect_err("migrate"),
- event_export_with_config(path_str(&store_error_config_path), path_str(&output_events))
- .await
- .expect_err("export"),
- projection_rebuild_with_config(path_str(&store_error_config_path))
- .await
- .expect_err("rebuild"),
- run_with_config(path_str(&store_error_config_path))
- .await
- .expect_err("run"),
- ];
-
- assert!(
- file_errors
- .iter()
- .any(|error| error.contains("failed to read event import file"))
- );
- assert!(
- file_errors
- .iter()
- .any(|error| error.contains("failed to read backup manifest file"))
- );
- assert!(
- file_errors
- .iter()
- .any(|error| error.contains("failed to create backup directory"))
- );
- assert!(!store_errors.is_empty());
-
- fs::remove_dir_all(&root).expect("remove runtime root");
- }
-
- #[test]
- fn usage_output_lists_supported_command_model() {
+ fn usage_lists_only_v2_command_surface() {
assert_eq!(
usage_output(),
- "usage:\n tangle [--version]\n tangle migrate --config PATH\n tangle run --config PATH\n tangle event import --config PATH --input PATH\n tangle event export --config PATH --output PATH\n tangle projection rebuild --config PATH\n tangle ops backup --config PATH --output DIR\n tangle ops restore --config PATH --input DIR"
+ "usage:\n tangle [--version]\n tangle run --config PATH"
);
}
#[test]
- fn command_model_parses_known_commands() {
- let cases = [
- (Vec::<&str>::new(), TangleCommand::Help, "help"),
- (vec!["--version"], TangleCommand::Version, "version"),
- (vec!["-V"], TangleCommand::Version, "version"),
- (vec!["--help"], TangleCommand::Help, "help"),
- (vec!["help"], TangleCommand::Help, "help"),
- (vec!["migrate"], TangleCommand::Migrate, "migrate"),
- (vec!["run"], TangleCommand::Run, "run"),
- (
- vec!["event", "import"],
- TangleCommand::EventImport,
- "event import",
- ),
- (
- vec!["event", "export"],
- TangleCommand::EventExport,
- "event export",
- ),
- (
- vec!["projection", "rebuild"],
- TangleCommand::ProjectionRebuild,
- "projection rebuild",
- ),
- (
- vec!["ops", "backup"],
- TangleCommand::OpsBackup,
- "ops backup",
- ),
- (
- vec!["ops", "restore"],
- TangleCommand::OpsRestore,
- "ops restore",
- ),
- ];
-
- for (args, expected, label) in cases {
- assert_eq!(parse_tangle_command(args).expect("command"), expected);
- assert_eq!(expected.as_str(), label);
- assert!(expected.implemented());
- }
- }
-
- #[test]
- fn command_model_parses_ops_backup_output_option() {
- let invocation = parse_tangle_invocation([
- "ops",
- "backup",
- "--config",
- "runtime.json",
- "--output",
- "backup-dir",
- ])
- .expect("invocation");
- assert_eq!(invocation.command(), TangleCommand::OpsBackup);
+ fn parse_tangle_invocation_accepts_help_version_and_run() {
assert_eq!(
- require_config_path(&invocation).expect("config"),
- "runtime.json"
+ parse_tangle_invocation(Vec::<&str>::new()).expect("empty"),
+ TangleInvocation::new(TangleCommand::Help, None)
);
assert_eq!(
- require_output_path(&invocation).expect("output"),
- "backup-dir"
+ parse_tangle_invocation(["--version"]).expect("version"),
+ TangleInvocation::new(TangleCommand::Version, None)
);
- }
-
- #[test]
- fn command_model_parses_ops_restore_input_option() {
- let invocation = parse_tangle_invocation([
- "ops",
- "restore",
- "--config",
- "runtime.json",
- "--input",
- "backup-dir",
- ])
- .expect("invocation");
- assert_eq!(invocation.command(), TangleCommand::OpsRestore);
assert_eq!(
- require_config_path(&invocation).expect("config"),
- "runtime.json"
- );
- assert_eq!(
- require_input_path(&invocation).expect("input"),
- "backup-dir"
- );
- }
-
- #[test]
- fn command_model_parses_export_output_option() {
- let invocation = parse_tangle_invocation([
- "event",
- "export",
- "--config",
- "runtime.json",
- "--output",
- "events.jsonl",
- ])
- .expect("invocation");
- assert_eq!(invocation.command(), TangleCommand::EventExport);
- assert_eq!(
- require_config_path(&invocation).expect("config"),
- "runtime.json"
- );
- assert_eq!(
- require_output_path(&invocation).expect("output"),
- "events.jsonl"
- );
- assert_eq!(
- require_output_path(&TangleInvocation::new(TangleCommand::EventExport, None))
- .expect_err("output"),
- TangleCliError::MissingOptionValue("--output")
- );
- }
-
- #[test]
- fn command_model_parses_common_config_option() {
- assert_eq!(
- parse_tangle_invocation(["migrate", "--config", "runtime.json"]).expect("invocation"),
- TangleInvocation::new(TangleCommand::Migrate, Some("runtime.json".to_owned()))
- );
- assert_eq!(
- require_config_path(&TangleInvocation::new(
- TangleCommand::Migrate,
- Some("runtime.json".to_owned())
- ))
- .expect("config"),
- "runtime.json"
- );
- assert_eq!(
- require_config_path(&TangleInvocation::new(TangleCommand::Migrate, None))
- .expect_err("config"),
- TangleCliError::MissingOptionValue("--config")
+ parse_tangle_invocation(["run", "--config", "ops/production/tangle-v2.example.json"])
+ .expect("run"),
+ TangleInvocation::new(
+ TangleCommand::Run,
+ Some("ops/production/tangle-v2.example.json".to_owned())
+ )
);
}
#[test]
- fn command_model_parses_import_input_option() {
- let invocation = parse_tangle_invocation([
- "event",
- "import",
- "--config",
- "runtime.json",
- "--input",
- "events.jsonl",
- ])
- .expect("invocation");
- assert_eq!(invocation.command(), TangleCommand::EventImport);
- assert_eq!(
- require_config_path(&invocation).expect("config"),
- "runtime.json"
- );
- assert_eq!(
- require_input_path(&invocation).expect("input"),
- "events.jsonl"
- );
- assert_eq!(
- require_input_path(&TangleInvocation::new(TangleCommand::EventImport, None))
- .expect_err("input"),
- TangleCliError::MissingOptionValue("--input")
- );
+ fn parse_tangle_invocation_rejects_removed_command_surface() {
+ for args in [
+ vec!["migrate"],
+ vec!["event", "import"],
+ vec!["event", "export"],
+ vec!["projection", "rebuild"],
+ vec!["ops", "backup"],
+ vec!["ops", "restore"],
+ ] {
+ assert!(matches!(
+ parse_tangle_invocation(args).expect_err("removed"),
+ TangleCliError::UnknownCommand(_)
+ ));
+ }
}
#[test]
- fn command_model_rejects_unknown_or_extra_arguments() {
- assert_eq!(
- parse_tangle_command(["unknown"]).expect_err("unknown"),
- TangleCliError::UnknownCommand("unknown".to_owned())
- );
- assert_eq!(
- parse_tangle_command(["event"]).expect_err("nested"),
- TangleCliError::MissingNestedCommand("event")
- );
- assert_eq!(
- parse_tangle_command(["event", "bad"]).expect_err("event bad"),
- TangleCliError::UnknownCommand("event bad".to_owned())
- );
- assert_eq!(
- parse_tangle_command(["projection"]).expect_err("projection nested"),
- TangleCliError::MissingNestedCommand("projection")
- );
- assert_eq!(
- parse_tangle_command(["projection", "bad"]).expect_err("projection"),
- TangleCliError::UnknownCommand("projection bad".to_owned())
- );
- assert_eq!(
- parse_tangle_command(["ops"]).expect_err("ops nested"),
- TangleCliError::MissingNestedCommand("ops")
- );
- assert_eq!(
- parse_tangle_command(["ops", "bad"]).expect_err("ops bad"),
- TangleCliError::UnknownCommand("ops bad".to_owned())
- );
+ fn parse_tangle_invocation_validates_config_option() {
assert_eq!(
- parse_tangle_command(["run", "--extra"]).expect_err("extra"),
- TangleCliError::UnexpectedArgument {
- command: "run".to_owned(),
- argument: "--extra".to_owned()
- }
- );
- assert_eq!(
- parse_tangle_invocation(["migrate", "--config"]).expect_err("missing config"),
+ parse_tangle_invocation(["run", "--config"]).expect_err("missing"),
TangleCliError::MissingOptionValue("--config")
);
assert_eq!(
- parse_tangle_invocation(["migrate", "--config", "a", "--config", "b"])
- .expect_err("repeated config"),
+ parse_tangle_invocation(["run", "--config", "a", "--config", "b"]).expect_err("repeat"),
TangleCliError::RepeatedOption("--config")
);
assert_eq!(
- parse_tangle_invocation(["migrate", "--input", "events.jsonl"]).expect_err("input"),
- TangleCliError::UnexpectedArgument {
- command: "migrate".to_owned(),
- argument: "--input".to_owned()
- }
- );
- assert_eq!(
- parse_tangle_invocation(["event", "import", "--input"]).expect_err("missing input"),
- TangleCliError::MissingOptionValue("--input")
- );
- assert_eq!(
- parse_tangle_invocation(["event", "import", "--input", "a", "--input", "b"])
- .expect_err("repeated input"),
- TangleCliError::RepeatedOption("--input")
- );
- assert_eq!(
- parse_tangle_invocation(["run", "--output", "events.jsonl"]).expect_err("output"),
- TangleCliError::UnexpectedArgument {
- command: "run".to_owned(),
- argument: "--output".to_owned()
- }
- );
- assert_eq!(
- parse_tangle_invocation(["event", "export", "--output"]).expect_err("missing output"),
- TangleCliError::MissingOptionValue("--output")
- );
- assert_eq!(
- parse_tangle_invocation(["event", "export", "--output", "a", "--output", "b"])
- .expect_err("repeated output"),
- TangleCliError::RepeatedOption("--output")
- );
- assert_eq!(
- TangleCliError::MissingNestedCommand("event").to_string(),
- "event command requires a nested command"
- );
- assert_eq!(
- TangleCliError::RepeatedOption("--config").to_string(),
- "--config must not be repeated"
- );
- assert_eq!(
+ parse_tangle_invocation(["--version", "--config", "runtime.json"]).expect_err("config"),
TangleCliError::UnexpectedArgument {
- command: "run".to_owned(),
- argument: "--extra".to_owned()
+ command: "version".to_owned(),
+ argument: "--config".to_owned(),
}
- .to_string(),
- "run command does not accept argument: --extra"
- );
- }
-
- #[test]
- fn migrate_output_reports_outcome_counts() {
- assert_eq!(
- migrate_output(RuntimeMigrationReport::new(8, 2, 10)),
- "migrations applied: 8\nmigrations already applied: 2\nmigrations total: 10"
);
}
#[test]
- fn event_import_output_reports_outcome_counts() {
+ fn require_config_path_reports_missing_value() {
assert_eq!(
- event_import_output(RuntimeEventImportReport::new(5, 2, 1, 2, 2)),
- "events total: 5\nevents inserted: 2\nevents duplicate: 1\nevents projected: 2\nevents skipped: 2"
- );
- }
-
- #[test]
- fn event_export_output_reports_outcome_counts() {
- assert_eq!(
- event_export_output(RuntimeEventExportReport::new(3)),
- "events exported: 3"
- );
- }
-
- #[test]
- fn projection_rebuild_output_reports_outcome_counts() {
- assert_eq!(
- projection_rebuild_output(RuntimeProjectionRebuildReport::new(4, 3, 2, 1)),
- "events scanned: 4\nevents rebuilt: 3\nlistings projected: 2\nevents skipped: 1"
- );
- }
-
- #[test]
- fn ops_backup_output_reports_paths_counts_and_checksums() {
- let report = RuntimeBackupReport::new(
- PathBuf::from("backup"),
- PathBuf::from("backup/raw-events.jsonl"),
- 3,
- "a".repeat(64),
- PathBuf::from("backup/manifest.json"),
- "b".repeat(64),
- false,
- );
-
- assert_eq!(
- ops_backup_output(&report),
- format!(
- "backup directory: backup\nraw events: 3\nraw events sha256: {}\nsurrealdb export available: false\nmanifest: backup/manifest.json\nmanifest sha256: {}",
- "a".repeat(64),
- "b".repeat(64)
- )
- );
- }
-
- #[test]
- fn ops_restore_output_reports_import_and_rebuild_counts() {
- let report = RuntimeRestoreReport::new(
- PathBuf::from("backup"),
- 3,
- "c".repeat(64),
- RuntimeEventImportReport::new(3, 2, 1, 2, 0),
- RuntimeProjectionRebuildReport::new(3, 3, 2, 0),
- );
-
- assert_eq!(
- ops_restore_output(&report),
- format!(
- "restore directory: backup\nraw events: 3\nraw events sha256: {}\nevents inserted: 2\nevents duplicate: 1\nevents rebuilt: 3\nlistings projected: 2\nevents skipped: 0",
- "c".repeat(64)
- )
+ require_config_path(&TangleInvocation::new(TangleCommand::Run, None))
+ .expect_err("config"),
+ TangleCliError::MissingOptionValue("--config")
);
}
-
- fn temp_root(label: &str) -> PathBuf {
- std::env::temp_dir().join(format!("tangle-lib-{label}-{}", std::process::id()))
- }
-
- fn path_str(path: &Path) -> &str {
- path.to_str().expect("utf8 path")
- }
-
- fn write_memory_config(path: &Path, namespace: &str, tracing_filter: Option<&str>) {
- let mut config = serde_json::json!({
- "server": {
- "listen_addr": "127.0.0.1:0",
- "relay_url": "wss://relay.radroots.test"
- },
- "database": {
- "mode": "memory",
- "namespace": namespace,
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- }
- });
- if let Some(filter) = tracing_filter {
- config["observability"] = serde_json::json!({
- "tracing": {
- "enabled": true,
- "filter": filter,
- "format": "compact"
- }
- });
- }
- fs::write(
- path,
- serde_json::to_string_pretty(&config).expect("runtime config JSON"),
- )
- .expect("write runtime config");
- }
-
- fn write_rocksdb_config(path: &Path, db_path: &Path, namespace: &str) {
- let config = serde_json::json!({
- "server": {
- "listen_addr": "127.0.0.1:0",
- "relay_url": "wss://relay.radroots.test"
- },
- "database": {
- "mode": "rocks_db",
- "path": path_str(db_path),
- "namespace": namespace,
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- }
- });
- fs::write(
- path,
- serde_json::to_string_pretty(&config).expect("runtime config JSON"),
- )
- .expect("write runtime config");
- }
}
diff --git a/crates/tangle/src/main.rs b/crates/tangle/src/main.rs
@@ -21,64 +21,7 @@ fn main() -> ExitCode {
println!("{}", tangle::usage_output());
ExitCode::SUCCESS
}
- tangle::TangleCommand::Migrate => match run_migrate(&invocation) {
- Ok(output) => {
- println!("{output}");
- ExitCode::SUCCESS
- }
- Err(error) => {
- eprintln!("{error}");
- ExitCode::from(2)
- }
- },
tangle::TangleCommand::Run => match run_server(&invocation) {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- eprintln!("{error}");
- ExitCode::from(2)
- }
- },
- tangle::TangleCommand::EventImport => match run_event_import(&invocation) {
- Ok(output) => {
- println!("{output}");
- ExitCode::SUCCESS
- }
- Err(error) => {
- eprintln!("{error}");
- ExitCode::from(2)
- }
- },
- tangle::TangleCommand::EventExport => match run_event_export(&invocation) {
- Ok(output) => {
- println!("{output}");
- ExitCode::SUCCESS
- }
- Err(error) => {
- eprintln!("{error}");
- ExitCode::from(2)
- }
- },
- tangle::TangleCommand::ProjectionRebuild => match run_projection_rebuild(&invocation) {
- Ok(output) => {
- println!("{output}");
- ExitCode::SUCCESS
- }
- Err(error) => {
- eprintln!("{error}");
- ExitCode::from(2)
- }
- },
- tangle::TangleCommand::OpsBackup => match run_ops_backup(&invocation) {
- Ok(output) => {
- println!("{output}");
- ExitCode::SUCCESS
- }
- Err(error) => {
- eprintln!("{error}");
- ExitCode::from(2)
- }
- },
- tangle::TangleCommand::OpsRestore => match run_ops_restore(&invocation) {
Ok(output) => {
println!("{output}");
ExitCode::SUCCESS
@@ -91,139 +34,21 @@ fn main() -> ExitCode {
}
}
-fn run_migrate(invocation: &tangle::TangleInvocation) -> Result<String, String> {
+fn run_server(invocation: &tangle::TangleInvocation) -> Result<String, String> {
let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?;
- let runtime = tangle_runtime();
- runtime.block_on(tangle::migrate_with_config(config_path))
-}
-
-fn run_server(invocation: &tangle::TangleInvocation) -> Result<(), String> {
- let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?;
- let runtime = tangle_runtime();
- runtime.block_on(tangle::run_with_config(config_path))
-}
-
-fn run_event_import(invocation: &tangle::TangleInvocation) -> Result<String, String> {
- let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?;
- let input_path = tangle::require_input_path(invocation).map_err(|error| error.to_string())?;
- let runtime = tangle_runtime();
- runtime.block_on(tangle::event_import_with_config(config_path, input_path))
-}
-
-fn run_event_export(invocation: &tangle::TangleInvocation) -> Result<String, String> {
- let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?;
- let output_path = tangle::require_output_path(invocation).map_err(|error| error.to_string())?;
- let runtime = tangle_runtime();
- runtime.block_on(tangle::event_export_with_config(config_path, output_path))
-}
-
-fn run_projection_rebuild(invocation: &tangle::TangleInvocation) -> Result<String, String> {
- let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?;
- let runtime = tangle_runtime();
- runtime.block_on(tangle::projection_rebuild_with_config(config_path))
-}
-
-fn run_ops_backup(invocation: &tangle::TangleInvocation) -> Result<String, String> {
- let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?;
- let output_path = tangle::require_output_path(invocation).map_err(|error| error.to_string())?;
- let runtime = tangle_runtime();
- runtime.block_on(tangle::ops_backup_with_config(config_path, output_path))
-}
-
-fn run_ops_restore(invocation: &tangle::TangleInvocation) -> Result<String, String> {
- let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?;
- let input_path = tangle::require_input_path(invocation).map_err(|error| error.to_string())?;
- let runtime = tangle_runtime();
- runtime.block_on(tangle::ops_restore_with_config(config_path, input_path))
-}
-
-fn tangle_runtime() -> tokio::runtime::Runtime {
- tokio::runtime::Builder::new_current_thread()
- .enable_all()
- .build()
- .expect("failed to start tangle Tokio runtime")
+ tangle::run_with_config(config_path)
}
#[cfg(test)]
mod tests {
- use super::{
- run_event_export, run_event_import, run_ops_backup, run_ops_restore, run_server,
- tangle_runtime,
- };
+ use super::run_server;
#[test]
- fn command_runners_report_missing_options_in_process() {
+ fn command_runner_reports_missing_config_in_process() {
let run = tangle::TangleInvocation::new(tangle::TangleCommand::Run, None);
assert_eq!(
run_server(&run).expect_err("run config"),
"--config requires a value"
);
-
- let import_missing_config =
- tangle::TangleInvocation::new(tangle::TangleCommand::EventImport, None);
- assert_eq!(
- run_event_import(&import_missing_config).expect_err("import config"),
- "--config requires a value"
- );
- let import_missing_input = tangle::TangleInvocation::new(
- tangle::TangleCommand::EventImport,
- Some("runtime.json".to_owned()),
- );
- assert_eq!(
- run_event_import(&import_missing_input).expect_err("import input"),
- "--input requires a value"
- );
-
- let export_missing_config =
- tangle::TangleInvocation::new(tangle::TangleCommand::EventExport, None);
- assert_eq!(
- run_event_export(&export_missing_config).expect_err("export config"),
- "--config requires a value"
- );
- let export_missing_output = tangle::TangleInvocation::new(
- tangle::TangleCommand::EventExport,
- Some("runtime.json".to_owned()),
- );
- assert_eq!(
- run_event_export(&export_missing_output).expect_err("export output"),
- "--output requires a value"
- );
-
- let backup_missing_config =
- tangle::TangleInvocation::new(tangle::TangleCommand::OpsBackup, None);
- assert_eq!(
- run_ops_backup(&backup_missing_config).expect_err("backup config"),
- "--config requires a value"
- );
- let backup_missing_output = tangle::TangleInvocation::new(
- tangle::TangleCommand::OpsBackup,
- Some("runtime.json".to_owned()),
- );
- assert_eq!(
- run_ops_backup(&backup_missing_output).expect_err("backup output"),
- "--output requires a value"
- );
-
- let restore_missing_config =
- tangle::TangleInvocation::new(tangle::TangleCommand::OpsRestore, None);
- assert_eq!(
- run_ops_restore(&restore_missing_config).expect_err("restore config"),
- "--config requires a value"
- );
- let restore_missing_input = tangle::TangleInvocation::new(
- tangle::TangleCommand::OpsRestore,
- Some("runtime.json".to_owned()),
- );
- assert_eq!(
- run_ops_restore(&restore_missing_input).expect_err("restore input"),
- "--input requires a value"
- );
- }
-
- #[test]
- fn command_runner_runtime_builds_current_thread_executor() {
- let runtime = tangle_runtime();
-
- assert_eq!(runtime.block_on(async { 42 }), 42);
}
}
diff --git a/crates/tangle/tests/abuse_conformance.rs b/crates/tangle/tests/abuse_conformance.rs
@@ -1,379 +0,0 @@
-#![forbid(unsafe_code)]
-
-mod support;
-
-use std::fs;
-use support::{
- RelayHarness, assert_ok, connect_client, http_get, http_post_json, reopen_store, send_auth,
- send_event, send_req, send_text,
-};
-use tangle_protocol::{Event, event_to_value};
-use tangle_test_support::{
- FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts,
- valid_public_listing_spec,
-};
-
-#[tokio::test]
-async fn abuse_conformance_rejects_malformed_invalid_and_replayed_messages() {
- let seller = FixtureKey::Seller.public_key();
- let harness = RelayHarness::start(
- "abuse_invalid_messages",
- serde_json::json!({
- "approved_sellers": [seller.as_str()]
- }),
- );
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let mut client = connect_client(harness.port).await;
-
- let malformed = send_text(&mut client, "not json").await;
- assert_eq!(malformed[0], "NOTICE");
- assert!(
- malformed[1]
- .as_str()
- .expect("notice")
- .contains("client message JSON is invalid")
- );
- let bad_id = send_text(&mut client, r#"["REQ","bad-id",{"ids":["bad"]}]"#).await;
- assert_eq!(bad_id[0], "NOTICE");
- assert!(bad_id[1].as_str().expect("notice").contains("event id"));
-
- assert_ok(&send_auth(&mut client, &auth).await, true);
- let replay = send_auth(&mut client, &auth).await;
- assert_ok(&replay, false);
- assert!(
- replay[3]
- .as_str()
- .expect("replay rejection")
- .contains("auth challenge is missing")
- );
-
- let mut bad_sig = event_to_value(&listing);
- bad_sig["sig"] = serde_json::Value::String("f".repeat(128));
- let rejected_sig = send_text(
- &mut client,
- &serde_json::json!(["EVENT", bad_sig]).to_string(),
- )
- .await;
- assert_ok(&rejected_sig, false);
- assert!(
- rejected_sig[3]
- .as_str()
- .expect("signature rejection")
- .contains("crypto")
- );
- let mut bad_event_id = event_to_value(&listing);
- bad_event_id["id"] = serde_json::Value::String("f".repeat(64));
- let rejected_id = send_text(
- &mut client,
- &serde_json::json!(["EVENT", bad_event_id]).to_string(),
- )
- .await;
- assert_ok(&rejected_id, false);
-
- let store_config = harness.store_config();
- let root = harness.root.clone();
- drop(client);
- harness.stop();
- let store = reopen_store(&store_config).await;
- assert!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("raw listing")
- .is_none()
- );
- drop(store);
- fs::remove_dir_all(root).expect("remove runtime root");
-}
-
-#[tokio::test]
-async fn abuse_conformance_enforces_runtime_limits_for_events_subscriptions_and_search() {
- let seller = FixtureKey::Seller.public_key();
- let harness = RelayHarness::start_with_runtime_limits(
- "abuse_runtime_limits",
- serde_json::json!({
- "approved_sellers": [seller.as_str()]
- }),
- serde_json::json!({
- "max_tags_per_event": 1,
- "max_filters_per_subscription": 1,
- "max_subscriptions_per_connection": 1,
- "max_search_tokens": 1
- }),
- );
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
-
- let mut writer = connect_client(harness.port).await;
- assert_ok(&send_auth(&mut writer, &auth).await, true);
- let too_many_tags = send_event(&mut writer, &listing).await;
- assert_ok(&too_many_tags, false);
- assert!(
- too_many_tags[3]
- .as_str()
- .expect("tag rejection")
- .contains("tags per event")
- );
- drop(writer);
-
- let mut reader = connect_client(harness.port).await;
- let too_many_filters = send_text(
- &mut reader,
- r#"["REQ","too-many-filters",{"ids":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]},{"ids":["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]}]"#,
- )
- .await;
- assert_eq!(too_many_filters[0], "CLOSED");
- assert!(
- too_many_filters[2]
- .as_str()
- .expect("filter rejection")
- .contains("filters per subscription")
- );
- let first_subscription = send_req(
- &mut reader,
- "sub-one",
- serde_json::json!({
- "ids": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
- }),
- )
- .await;
- assert_eq!(first_subscription[0], "EOSE");
- let too_many_subscriptions = send_req(
- &mut reader,
- "sub-two",
- serde_json::json!({
- "ids": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]
- }),
- )
- .await;
- assert_eq!(too_many_subscriptions[0], "CLOSED");
- assert!(
- too_many_subscriptions[2]
- .as_str()
- .expect("subscription rejection")
- .contains("subscriptions per connection")
- );
- drop(reader);
-
- let mut searcher = connect_client(harness.port).await;
- let abusive_search = send_req(
- &mut searcher,
- "search-abuse",
- serde_json::json!({
- "search": "fresh carrots",
- "limit": 5
- }),
- )
- .await;
- assert_eq!(abusive_search[0], "CLOSED");
- assert!(
- abusive_search[2]
- .as_str()
- .expect("search rejection")
- .contains("search tokens")
- );
-
- let store_config = harness.store_config();
- let root = harness.root.clone();
- drop(searcher);
- harness.stop();
- let store = reopen_store(&store_config).await;
- assert!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("raw listing")
- .is_none()
- );
- drop(store);
- fs::remove_dir_all(root).expect("remove runtime root");
-}
-
-#[tokio::test]
-async fn abuse_conformance_excludes_blocked_pubkey_writes_from_public_surfaces() {
- let seller = FixtureKey::Seller.public_key();
- let harness = RelayHarness::start(
- "abuse_blocked_pubkey",
- serde_json::json!({
- "approved_sellers": [seller.as_str()],
- "blocked_pubkeys": [seller.as_str()]
- }),
- );
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let mut client = connect_client(harness.port).await;
-
- assert_ok(&send_auth(&mut client, &auth).await, true);
- assert_ok(&send_event(&mut client, &listing).await, true);
- assert!(!http_get(harness.port, "/api/listings?limit=5").contains(listing.id().as_str()));
-
- let store_config = harness.store_config();
- let root = harness.root.clone();
- drop(client);
- harness.stop();
- let store = reopen_store(&store_config).await;
- assert!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("raw listing")
- .is_some()
- );
- let listing_key = format!("30402:{}:listing-a", seller.as_str());
- assert!(
- store
- .listing_current_row(&listing_key)
- .await
- .expect("listing row")
- .is_none()
- );
- assert!(
- store
- .search_document_row(&listing_key)
- .await
- .expect("search row")
- .is_none()
- );
- drop(store);
- fs::remove_dir_all(root).expect("remove runtime root");
-}
-
-#[tokio::test]
-async fn abuse_conformance_excludes_hidden_and_deleted_listings_from_public_surfaces() {
- let hidden = publish_listing_for_visibility("abuse_hidden_listing").await;
- let hide = http_post_json(
- hidden.harness.port,
- &format!("/api/admin/events/{}/hide", hidden.listing.id().as_str()),
- Some(hidden.admin.as_str()),
- serde_json::json!({
- "reason": "abuse visibility test"
- }),
- );
- assert!(hide.contains("200 OK"));
- assert!(hide.contains("\"status\":\"hidden\""));
- assert!(
- !http_get(hidden.harness.port, "/api/listings?limit=5")
- .contains(hidden.listing.id().as_str())
- );
- let hidden_store_config = hidden.harness.store_config();
- let hidden_root = hidden.harness.root.clone();
- drop(hidden.client);
- hidden.harness.stop();
- let store = reopen_store(&hidden_store_config).await;
- let listing_key = format!("30402:{}:listing-a", hidden.seller.as_str());
- assert_eq!(
- store
- .raw_event_row(hidden.listing.id())
- .await
- .expect("raw row")
- .expect("raw row exists")["hidden"],
- true
- );
- assert_eq!(
- store
- .listing_current_row(&listing_key)
- .await
- .expect("listing row")
- .expect("listing row exists")["hidden"],
- true
- );
- assert_eq!(
- store
- .search_document_row(&listing_key)
- .await
- .expect("search row")
- .expect("search row exists")["visible"],
- false
- );
- drop(store);
- fs::remove_dir_all(hidden_root).expect("remove hidden runtime root");
-
- let mut deleted = publish_listing_for_visibility("abuse_deleted_listing").await;
- let listing_key = format!("30402:{}:listing-a", deleted.seller.as_str());
- let deletion = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_124_460,
- 5,
- vec![vec!["a".to_owned(), listing_key.clone()]],
- "delete listing",
- )
- .expect("deletion");
- assert_ok(&send_event(&mut deleted.client, &deletion).await, true);
- let deleted_lookup = send_req(
- &mut deleted.client,
- "deleted-listing",
- serde_json::json!({
- "ids": [deleted.listing.id().as_str()]
- }),
- )
- .await;
- assert_eq!(deleted_lookup[0], "EOSE");
- assert!(
- !http_get(deleted.harness.port, "/api/listings?limit=5")
- .contains(deleted.listing.id().as_str())
- );
- let deleted_store_config = deleted.harness.store_config();
- let deleted_root = deleted.harness.root.clone();
- drop(deleted.client);
- deleted.harness.stop();
- let store = reopen_store(&deleted_store_config).await;
- assert_eq!(
- store
- .raw_event_row(deleted.listing.id())
- .await
- .expect("raw row")
- .expect("raw row exists")["deleted"],
- true
- );
- assert_eq!(
- store
- .search_document_row(&listing_key)
- .await
- .expect("search row")
- .expect("search row exists")["visible"],
- false
- );
- assert_eq!(
- store
- .deletion_marker_rows(deletion.id())
- .await
- .expect("markers")[0]["target_type"],
- "address"
- );
- drop(store);
- fs::remove_dir_all(deleted_root).expect("remove deleted runtime root");
-}
-
-struct VisibilityHarness {
- harness: RelayHarness,
- client: support::RelayClient,
- listing: Event,
- seller: tangle_protocol::PublicKeyHex,
- admin: tangle_protocol::PublicKeyHex,
-}
-
-async fn publish_listing_for_visibility(namespace: &str) -> VisibilityHarness {
- let seller = FixtureKey::Seller.public_key();
- let admin = FixtureKey::Relay.public_key();
- let harness = RelayHarness::start(
- namespace,
- serde_json::json!({
- "admin_pubkeys": [admin.as_str()],
- "approved_sellers": [seller.as_str()]
- }),
- );
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let mut client = connect_client(harness.port).await;
- assert_ok(&send_auth(&mut client, &auth).await, true);
- assert_ok(&send_event(&mut client, &listing).await, true);
- assert!(http_get(harness.port, "/api/listings?limit=5").contains(listing.id().as_str()));
- VisibilityHarness {
- harness,
- client,
- listing,
- seller,
- admin,
- }
-}
diff --git a/crates/tangle/tests/commerce_privacy_conformance.rs b/crates/tangle/tests/commerce_privacy_conformance.rs
@@ -1,168 +0,0 @@
-#![forbid(unsafe_code)]
-
-mod support;
-
-use std::fs;
-use support::{
- RelayHarness, assert_ok, connect_client, http_get, next_label, reopen_store,
- request_event_by_id, send_auth, send_event,
-};
-use tangle_protocol::Event;
-use tangle_test_support::{
- FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts,
- valid_public_listing_spec,
-};
-
-const PRIVATE_VALUES: &[&str] = &[
- "fixture-order-001",
- "buyer.contact@privacy.test",
- "100 Privacy Fixture Way",
- "fixture-payment-token",
- "fixture-refund-token",
- "fixture-dispute-evidence",
- "private order note fixture",
- "5550100",
-];
-
-#[tokio::test]
-async fn commerce_privacy_conformance_rejects_private_order_plaintext() {
- let seller = FixtureKey::Seller.public_key();
- let harness = RelayHarness::start(
- "commerce_privacy_conformance",
- serde_json::json!({
- "approved_sellers": [seller.as_str()]
- }),
- );
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let private_events = private_commerce_events();
- let mut client = connect_client(harness.port).await;
-
- assert_ok(&send_auth(&mut client, &auth).await, true);
- for event in &private_events {
- let rejection = send_event(&mut client, event).await;
- assert_ok(&rejection, false);
- assert_eq!(rejection[1], event.id().as_str());
- assert!(
- rejection[3]
- .as_str()
- .expect("privacy rejection")
- .contains("privacy: private commerce plaintext field")
- );
- }
- assert_ok(&send_event(&mut client, &listing).await, true);
-
- let rejected_lookup =
- request_event_by_id(&mut client, "private-order-rejected", &private_events[0]).await;
- assert_eq!(rejected_lookup[0], "EOSE");
- assert_eq!(rejected_lookup[1], "private-order-rejected");
- let accepted_lookup =
- request_event_by_id(&mut client, "public-listing-accepted", &listing).await;
- assert_eq!(accepted_lookup[0], "EVENT");
- assert_eq!(accepted_lookup[1], "public-listing-accepted");
- assert_eq!(accepted_lookup[2]["id"], listing.id().as_str());
- assert_eq!(next_label(&mut client).await, "EOSE");
-
- let listings = http_get(harness.port, "/api/listings?limit=5");
- assert!(listings.contains("200 OK"));
- assert!(listings.contains(listing.id().as_str()));
- for value in PRIVATE_VALUES {
- assert!(!listings.contains(value));
- }
- let detail = http_get(
- harness.port,
- &format!("/api/listings/{}/listing-a", seller.as_str()),
- );
- assert!(detail.contains("200 OK"));
- assert!(detail.contains(listing.id().as_str()));
- for value in PRIVATE_VALUES {
- assert!(!detail.contains(value));
- }
-
- let store_config = harness.store_config();
- let root = harness.root.clone();
- drop(client);
- harness.stop();
- let store = reopen_store(&store_config).await;
- for event in &private_events {
- assert!(
- store
- .raw_event_row(event.id())
- .await
- .expect("private raw row")
- .is_none()
- );
- }
- assert!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("listing raw row")
- .is_some()
- );
- let listing_key = format!("30402:{}:listing-a", seller.as_str());
- assert!(
- store
- .listing_current_row(&listing_key)
- .await
- .expect("listing row")
- .is_some()
- );
- assert!(
- store
- .search_document_row(&listing_key)
- .await
- .expect("search row")
- .is_some()
- );
- drop(store);
- fs::remove_dir_all(root).expect("remove runtime root");
-}
-
-fn private_commerce_events() -> Vec<Event> {
- let mut events = PRIVATE_FIELDS
- .iter()
- .enumerate()
- .map(|(index, (field, value))| {
- let mut private = serde_json::Map::new();
- private.insert(
- (*field).to_owned(),
- serde_json::Value::String((*value).to_owned()),
- );
- let mut root = serde_json::Map::new();
- root.insert(
- "private_commerce".to_owned(),
- serde_json::Value::Object(private),
- );
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_124_450 + index as u64,
- 1,
- vec![vec!["t".to_owned(), "commerce-privacy".to_owned()]],
- &serde_json::Value::Object(root).to_string(),
- )
- .expect("private commerce event")
- })
- .collect::<Vec<_>>();
- events.push(
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_124_470,
- 1,
- vec![vec!["phone".to_owned(), "5550100".to_owned()]],
- "private phone detail",
- )
- .expect("phone tag event"),
- );
- events
-}
-
-const PRIVATE_FIELDS: &[(&str, &str)] = &[
- ("order_id", "fixture-order-001"),
- ("buyer_contact", "buyer.contact@privacy.test"),
- ("delivery_address", "100 Privacy Fixture Way"),
- ("payment_details", "fixture-payment-token"),
- ("refund_details", "fixture-refund-token"),
- ("dispute_evidence", "fixture-dispute-evidence"),
- ("private_note", "private order note fixture"),
-];
diff --git a/crates/tangle/tests/discussion_conformance.rs b/crates/tangle/tests/discussion_conformance.rs
@@ -1,231 +0,0 @@
-#![forbid(unsafe_code)]
-
-mod support;
-
-use std::fs;
-use support::{
- RelayHarness, assert_ok, connect_client, http_get, next_label, reopen_store,
- request_event_by_id, send_auth, send_event,
-};
-use tangle_protocol::{Event, EventId};
-use tangle_test_support::{
- FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts,
- valid_public_listing_spec,
-};
-
-#[tokio::test]
-async fn discussion_conformance_projects_listing_comments_and_forum_threads() {
- let seller = FixtureKey::Seller.public_key();
- let harness = RelayHarness::start(
- "discussion_conformance",
- serde_json::json!({
- "approved_sellers": [seller.as_str()]
- }),
- );
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_comment = listing_comment(&listing, 1_714_124_436, "Can I pickup Saturday?");
- let thread = forum_thread(1_714_124_438, Some("Market day thread"), &["market", "csa"]);
- let thread_comment = forum_thread_comment(&thread, 1_714_124_439, "I can bring greens.");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
-
- let mut client = connect_client(harness.port).await;
- assert_ok(&send_auth(&mut client, &auth).await, true);
- for event in [&listing, &listing_comment, &thread, &thread_comment] {
- assert_ok(&send_event(&mut client, event).await, true);
- }
-
- let fetched_comment =
- request_event_by_id(&mut client, "discussion-comment", &listing_comment).await;
- assert_eq!(fetched_comment[0], "EVENT");
- assert_eq!(fetched_comment[1], "discussion-comment");
- assert_eq!(fetched_comment[2]["id"], listing_comment.id().as_str());
- assert_eq!(next_label(&mut client).await, "EOSE");
- let fetched_thread = request_event_by_id(&mut client, "discussion-thread", &thread).await;
- assert_eq!(fetched_thread[0], "EVENT");
- assert_eq!(fetched_thread[1], "discussion-thread");
- assert_eq!(fetched_thread[2]["id"], thread.id().as_str());
- assert_eq!(next_label(&mut client).await, "EOSE");
-
- let listing_comments = http_get(
- harness.port,
- &format!(
- "/api/listings/{}/listing-a/comments?limit=5",
- seller.as_str()
- ),
- );
- assert!(listing_comments.contains("200 OK"));
- assert!(listing_comments.contains(listing_comment.id().as_str()));
- assert!(listing_comments.contains("Can I pickup Saturday?"));
- let forum_threads = http_get(harness.port, "/api/forum/threads?topic=market&limit=5");
- assert!(forum_threads.contains("200 OK"));
- assert!(forum_threads.contains(thread.id().as_str()));
- assert!(forum_threads.contains("Market day thread"));
- let forum_detail = http_get(
- harness.port,
- &format!("/api/forum/threads/{}", thread.id().as_str()),
- );
- assert!(forum_detail.contains("200 OK"));
- assert!(forum_detail.contains(thread.id().as_str()));
- assert!(forum_detail.contains("Market day thread"));
- let forum_comments = http_get(
- harness.port,
- &format!(
- "/api/forum/threads/{}/comments?limit=5",
- thread.id().as_str()
- ),
- );
- assert!(forum_comments.contains("200 OK"));
- assert!(forum_comments.contains(thread_comment.id().as_str()));
- assert!(forum_comments.contains("I can bring greens."));
-
- let store_config = harness.store_config();
- let root = harness.root.clone();
- drop(client);
- harness.stop();
- let store = reopen_store(&store_config).await;
- for event in [&listing, &listing_comment, &thread, &thread_comment] {
- assert!(
- store
- .raw_event_row(event.id())
- .await
- .expect("raw row")
- .is_some()
- );
- }
- let listing_key = format!("30402:{}:listing-a", seller.as_str());
- let listing_comment_row = store
- .comment_projection_row(listing_comment.id())
- .await
- .expect("listing comment row")
- .expect("listing comment row exists");
- assert_eq!(
- listing_comment_row["event_id"],
- listing_comment.id().as_str()
- );
- assert_eq!(listing_comment_row["root_target_type"], "address");
- assert_eq!(listing_comment_row["root_ref"], listing_key);
- assert_eq!(listing_comment_row["root_kind"], "30402");
- assert_eq!(listing_comment_row["content"], "Can I pickup Saturday?");
- let thread_row = store
- .forum_thread_row(thread.id())
- .await
- .expect("thread row")
- .expect("thread row exists");
- assert_eq!(thread_row["event_id"], thread.id().as_str());
- assert_eq!(thread_row["title"], "Market day thread");
- assert_eq!(
- thread_row["content"],
- "What is everyone bringing this weekend?"
- );
- let topics = store
- .forum_thread_topic_rows(thread.id())
- .await
- .expect("topic rows");
- assert_eq!(topics.len(), 2);
- assert_eq!(topics[0]["topic"], "csa");
- assert_eq!(topics[1]["topic"], "market");
- let thread_comment_row = store
- .comment_projection_row(thread_comment.id())
- .await
- .expect("thread comment row")
- .expect("thread comment row exists");
- assert_eq!(thread_comment_row["event_id"], thread_comment.id().as_str());
- assert_eq!(thread_comment_row["root_target_type"], "event");
- assert_eq!(thread_comment_row["root_ref"], thread.id().as_str());
- assert_eq!(thread_comment_row["root_kind"], "11");
- assert_eq!(thread_comment_row["content"], "I can bring greens.");
- assert!(
- store
- .search_document_row(thread.id().as_str())
- .await
- .expect("thread search row")
- .is_some()
- );
- drop(store);
- fs::remove_dir_all(root).expect("remove runtime root");
-}
-
-fn listing_comment(listing: &Event, created_at: u64, content: &str) -> Event {
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- 1_111,
- vec![
- vec!["A".to_owned(), listing_key.clone()],
- vec!["K".to_owned(), "30402".to_owned()],
- vec![
- "P".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- vec!["a".to_owned(), listing_key],
- vec!["k".to_owned(), "30402".to_owned()],
- vec![
- "p".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- ],
- content,
- )
- .expect("comment event")
-}
-
-fn forum_thread(created_at: u64, title: Option<&str>, topics: &[&str]) -> Event {
- let mut tags = vec![
- vec!["e".to_owned(), "5".repeat(EventId::HEX_LENGTH)],
- vec![
- "p".to_owned(),
- FixtureKey::Buyer.public_key().as_str().to_owned(),
- ],
- ];
- if let Some(title) = title {
- tags.push(vec!["title".to_owned(), title.to_owned()]);
- }
- tags.extend(
- topics
- .iter()
- .map(|topic| vec!["t".to_owned(), (*topic).to_owned()]),
- );
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- 11,
- tags,
- "What is everyone bringing this weekend?",
- )
- .expect("forum thread")
-}
-
-fn forum_thread_comment(thread: &Event, created_at: u64, content: &str) -> Event {
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- 1_111,
- vec![
- vec![
- "E".to_owned(),
- thread.id().as_str().to_owned(),
- "wss://relay.radroots.test".to_owned(),
- thread.unsigned().pubkey().as_str().to_owned(),
- ],
- vec!["K".to_owned(), "11".to_owned()],
- vec![
- "P".to_owned(),
- thread.unsigned().pubkey().as_str().to_owned(),
- ],
- vec![
- "e".to_owned(),
- thread.id().as_str().to_owned(),
- "wss://relay.radroots.test".to_owned(),
- thread.unsigned().pubkey().as_str().to_owned(),
- ],
- vec!["k".to_owned(), "11".to_owned()],
- vec![
- "p".to_owned(),
- thread.unsigned().pubkey().as_str().to_owned(),
- ],
- ],
- content,
- )
- .expect("forum comment event")
-}
diff --git a/crates/tangle/tests/moderation_conformance.rs b/crates/tangle/tests/moderation_conformance.rs
@@ -1,168 +0,0 @@
-#![forbid(unsafe_code)]
-
-mod support;
-
-use std::fs;
-use support::{
- RelayHarness, assert_ok, connect_client, http_get, http_get_admin, next_label, reopen_store,
- request_event_by_id, send_auth, send_event,
-};
-use tangle_protocol::Event;
-use tangle_test_support::{
- FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts,
- valid_public_listing_spec,
-};
-
-#[tokio::test]
-async fn moderation_conformance_projects_labels_reports_and_admin_queries() {
- let seller = FixtureKey::Seller.public_key();
- let admin = FixtureKey::Relay.public_key();
- let harness = RelayHarness::start(
- "moderation_conformance",
- serde_json::json!({
- "admin_pubkeys": [admin.as_str()],
- "approved_sellers": [seller.as_str()]
- }),
- );
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let label = listing_label(&listing, 1_714_124_440, "reviewed");
- let report = listing_report(&listing, 1_714_124_441, "spam");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
-
- let mut client = connect_client(harness.port).await;
- assert_ok(&send_auth(&mut client, &auth).await, true);
- for event in [&listing, &label, &report] {
- assert_ok(&send_event(&mut client, event).await, true);
- }
- let fetched_label = request_event_by_id(&mut client, "moderation-label", &label).await;
- assert_eq!(fetched_label[0], "EVENT");
- assert_eq!(fetched_label[1], "moderation-label");
- assert_eq!(fetched_label[2]["id"], label.id().as_str());
- assert_eq!(next_label(&mut client).await, "EOSE");
- let fetched_report = request_event_by_id(&mut client, "moderation-report", &report).await;
- assert_eq!(fetched_report[0], "EVENT");
- assert_eq!(fetched_report[1], "moderation-report");
- assert_eq!(fetched_report[2]["id"], report.id().as_str());
- assert_eq!(next_label(&mut client).await, "EOSE");
-
- let unauthenticated_labels = http_get(
- harness.port,
- &format!(
- "/api/admin/moderation/labels?target_type=event&target_ref={}&namespace=com.radroots.moderation&label=reviewed&limit=5",
- listing.id().as_str()
- ),
- );
- assert!(unauthenticated_labels.contains("401 Unauthorized"));
- let labels = http_get_admin(
- harness.port,
- &format!(
- "/api/admin/moderation/labels?target_type=event&target_ref={}&namespace=com.radroots.moderation&label=reviewed&limit=5",
- listing.id().as_str()
- ),
- admin.as_str(),
- );
- assert!(labels.contains("200 OK"));
- assert!(labels.contains(label.id().as_str()));
- assert!(labels.contains("\"label\":\"reviewed\""));
- let reports = http_get_admin(
- harness.port,
- &format!(
- "/api/admin/moderation/reports?target_type=event&target_ref={}&report_type=spam&limit=5",
- listing.id().as_str()
- ),
- admin.as_str(),
- );
- assert!(reports.contains("200 OK"));
- assert!(reports.contains(report.id().as_str()));
- assert!(reports.contains("\"report_type\":\"spam\""));
-
- let store_config = harness.store_config();
- let root = harness.root.clone();
- drop(client);
- harness.stop();
- let store = reopen_store(&store_config).await;
- for event in [&listing, &label, &report] {
- assert!(
- store
- .raw_event_row(event.id())
- .await
- .expect("raw row")
- .is_some()
- );
- }
- let label_rows = store
- .label_projection_rows(label.id())
- .await
- .expect("label rows");
- assert_eq!(label_rows.len(), 2);
- let event_label = label_rows
- .iter()
- .find(|row| row["target_type"] == "event")
- .expect("event label");
- assert_eq!(event_label["event_id"], label.id().as_str());
- assert_eq!(event_label["target_ref"], listing.id().as_str());
- assert_eq!(event_label["namespace"], "com.radroots.moderation");
- assert_eq!(event_label["label"], "reviewed");
- let address_label = label_rows
- .iter()
- .find(|row| row["target_type"] == "address")
- .expect("address label");
- assert_eq!(
- address_label["target_ref"],
- format!("30402:{}:listing-a", seller.as_str())
- );
- let report_rows = store
- .report_projection_rows(report.id())
- .await
- .expect("report rows");
- let event_report = report_rows
- .iter()
- .find(|row| {
- row["target_type"] == "event"
- && row["target_ref"] == listing.id().as_str()
- && row["report_type"] == "spam"
- })
- .expect("event report");
- assert_eq!(event_report["reported_pubkeys"][0], seller.as_str());
- drop(store);
- fs::remove_dir_all(root).expect("remove runtime root");
-}
-
-fn listing_label(listing: &Event, created_at: u64, label: &str) -> Event {
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- let namespace = "com.radroots.moderation";
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- 1_985,
- vec![
- vec!["L".to_owned(), namespace.to_owned()],
- vec!["l".to_owned(), label.to_owned(), namespace.to_owned()],
- vec!["e".to_owned(), listing.id().as_str().to_owned()],
- vec!["a".to_owned(), listing_key],
- ],
- "moderator label",
- )
- .expect("label event")
-}
-
-fn listing_report(listing: &Event, created_at: u64, report_type: &str) -> Event {
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- 1_984,
- vec![
- vec![
- "p".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- vec![
- "e".to_owned(),
- listing.id().as_str().to_owned(),
- report_type.to_owned(),
- ],
- ],
- "moderator report",
- )
- .expect("report event")
-}
diff --git a/crates/tangle/tests/nip01_conformance.rs b/crates/tangle/tests/nip01_conformance.rs
@@ -1,62 +0,0 @@
-#![forbid(unsafe_code)]
-
-mod support;
-
-use std::fs;
-use support::{
- RelayHarness, assert_ok, close_subscription, connect_client, http_get, next_label,
- reopen_store, request_event_by_id, send_auth, send_event, send_text,
-};
-use tangle_test_support::{
- FixtureKey, auth_event_spec, build_fixture_event, valid_public_listing_spec,
-};
-
-#[tokio::test]
-async fn nip01_conformance_event_req_eose_and_close_round_trip() {
- let seller = FixtureKey::Seller.public_key();
- let harness = RelayHarness::start(
- "nip01_conformance",
- serde_json::json!({
- "approved_sellers": [seller.as_str()]
- }),
- );
- let readiness = http_get(harness.port, "/readyz");
- assert!(readiness.contains("200 OK"));
- assert!(readiness.contains("\"status\":\"ready\""));
-
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let mut client = connect_client(harness.port).await;
- let notice = send_text(&mut client, "not json").await;
- assert_eq!(notice[0], "NOTICE");
- assert!(
- notice[1]
- .as_str()
- .expect("notice")
- .starts_with("invalid: client message JSON is invalid:")
- );
-
- assert_ok(&send_auth(&mut client, &auth).await, true);
- assert_ok(&send_event(&mut client, &listing).await, true);
- let fetched = request_event_by_id(&mut client, "nip01-fetch", &listing).await;
- assert_eq!(fetched[0], "EVENT");
- assert_eq!(fetched[1], "nip01-fetch");
- assert_eq!(fetched[2]["id"], listing.id().as_str());
- assert_eq!(next_label(&mut client).await, "EOSE");
- close_subscription(&mut client, "nip01-fetch").await;
-
- let store_config = harness.store_config();
- let root = harness.root.clone();
- drop(client);
- harness.stop();
- let store = reopen_store(&store_config).await;
- assert!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("raw row")
- .is_some()
- );
- drop(store);
- fs::remove_dir_all(root).expect("remove runtime root");
-}
diff --git a/crates/tangle/tests/nip09_conformance.rs b/crates/tangle/tests/nip09_conformance.rs
@@ -1,70 +0,0 @@
-#![forbid(unsafe_code)]
-
-mod support;
-
-use std::fs;
-use support::{
- RelayHarness, assert_ok, connect_client, reopen_store, request_event_by_id, send_auth,
- send_event,
-};
-use tangle_test_support::{
- FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts,
- valid_public_listing_spec,
-};
-
-#[tokio::test]
-async fn nip09_conformance_deletes_event_and_hides_it_from_req_results() {
- let seller = FixtureKey::Seller.public_key();
- let harness = RelayHarness::start(
- "nip09_conformance",
- serde_json::json!({
- "approved_sellers": [seller.as_str()]
- }),
- );
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let deletion = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_124_460,
- 5,
- vec![vec!["e".to_owned(), listing.id().as_str().to_owned()]],
- "delete listing",
- )
- .expect("deletion");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
-
- let mut client = connect_client(harness.port).await;
- assert_ok(&send_auth(&mut client, &auth).await, true);
- assert_ok(&send_event(&mut client, &listing).await, true);
- assert_ok(&send_event(&mut client, &deletion).await, true);
- let deleted_lookup = request_event_by_id(&mut client, "nip09-deleted", &listing).await;
- assert_eq!(deleted_lookup[0], "EOSE");
- assert_eq!(deleted_lookup[1], "nip09-deleted");
-
- let store_config = harness.store_config();
- let root = harness.root.clone();
- drop(client);
- harness.stop();
- let store = reopen_store(&store_config).await;
- let raw_listing = store
- .raw_event_row(listing.id())
- .await
- .expect("listing row")
- .expect("listing exists");
- assert_eq!(raw_listing["deleted"], true);
- assert!(
- store
- .raw_event_row(deletion.id())
- .await
- .expect("deletion row")
- .is_some()
- );
- let markers = store
- .deletion_marker_rows(deletion.id())
- .await
- .expect("markers");
- assert_eq!(markers.len(), 1);
- assert_eq!(markers[0]["target_type"], "event");
- assert_eq!(markers[0]["target_ref"], listing.id().as_str());
- drop(store);
- fs::remove_dir_all(root).expect("remove runtime root");
-}
diff --git a/crates/tangle/tests/nip42_conformance.rs b/crates/tangle/tests/nip42_conformance.rs
@@ -1,71 +0,0 @@
-#![forbid(unsafe_code)]
-
-mod support;
-
-use std::fs;
-use support::{
- RelayHarness, assert_ok, connect_client_with_challenge, reopen_store, send_auth, send_event,
-};
-use tangle_test_support::{
- FixtureKey, auth_event_spec, build_fixture_event, valid_public_listing_spec,
-};
-
-#[tokio::test]
-async fn nip42_conformance_challenges_authenticates_and_gates_writes() {
- let seller = FixtureKey::Seller.public_key();
- let harness = RelayHarness::start(
- "nip42_conformance",
- serde_json::json!({
- "require_write_auth": true,
- "approved_sellers": [seller.as_str()]
- }),
- );
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
-
- let (mut client, challenge) = connect_client_with_challenge(harness.port).await;
- assert_eq!(challenge[0], "AUTH");
- assert_eq!(challenge[1], "challenge-001");
-
- let auth_as_event = send_event(&mut client, &auth).await;
- assert_ok(&auth_as_event, false);
- assert!(
- auth_as_event[3]
- .as_str()
- .expect("auth rejection")
- .contains("auth events must use AUTH")
- );
- let unauthenticated_write = send_event(&mut client, &listing).await;
- assert_ok(&unauthenticated_write, false);
- assert!(
- unauthenticated_write[3]
- .as_str()
- .expect("write rejection")
- .contains("write authentication required")
- );
-
- assert_ok(&send_auth(&mut client, &auth).await, true);
- assert_ok(&send_event(&mut client, &listing).await, true);
-
- let store_config = harness.store_config();
- let root = harness.root.clone();
- drop(client);
- harness.stop();
- let store = reopen_store(&store_config).await;
- assert!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("raw row")
- .is_some()
- );
- assert!(
- store
- .raw_event_row(auth.id())
- .await
- .expect("auth raw row")
- .is_none()
- );
- drop(store);
- fs::remove_dir_all(root).expect("remove runtime root");
-}
diff --git a/crates/tangle/tests/nip50_conformance.rs b/crates/tangle/tests/nip50_conformance.rs
@@ -1,77 +0,0 @@
-#![forbid(unsafe_code)]
-
-mod support;
-
-use std::fs;
-use support::{
- RelayHarness, assert_ok, connect_client, next_label, reopen_store, send_auth, send_event,
- send_req,
-};
-use tangle_test_support::{
- FixtureKey, auth_event_spec, build_fixture_event, valid_public_listing_spec,
-};
-
-#[tokio::test]
-async fn nip50_conformance_searches_persisted_listing_documents() {
- let seller = FixtureKey::Seller.public_key();
- let harness = RelayHarness::start(
- "nip50_conformance",
- serde_json::json!({
- "approved_sellers": [seller.as_str()]
- }),
- );
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
-
- let mut client = connect_client(harness.port).await;
- assert_ok(&send_auth(&mut client, &auth).await, true);
- assert_ok(&send_event(&mut client, &listing).await, true);
-
- let found = send_req(
- &mut client,
- "nip50-search",
- serde_json::json!({
- "search": "carrot",
- "kinds": [30402],
- "authors": [seller.as_str()],
- "limit": 5
- }),
- )
- .await;
- assert_eq!(found[0], "EVENT");
- assert_eq!(found[1], "nip50-search");
- assert_eq!(found[2]["id"], listing.id().as_str());
- assert_eq!(next_label(&mut client).await, "EOSE");
-
- let miss = send_req(
- &mut client,
- "nip50-miss",
- serde_json::json!({
- "search": "rutabaga",
- "kinds": [30402],
- "authors": [seller.as_str()],
- "limit": 5
- }),
- )
- .await;
- assert_eq!(miss[0], "EOSE");
- assert_eq!(miss[1], "nip50-miss");
-
- let store_config = harness.store_config();
- let root = harness.root.clone();
- drop(client);
- harness.stop();
- let store = reopen_store(&store_config).await;
- let listing_key = format!("30402:{}:listing-a", seller.as_str());
- let search_row = store
- .search_document_row(&listing_key)
- .await
- .expect("search row")
- .expect("search row exists");
- assert_eq!(search_row["event_id"], listing.id().as_str());
- assert_eq!(search_row["doc_type"], "listing");
- assert_eq!(search_row["title"], "Carrot bunches");
- assert_eq!(search_row["visible"], true);
- drop(store);
- fs::remove_dir_all(root).expect("remove runtime root");
-}
diff --git a/crates/tangle/tests/nip99_conformance.rs b/crates/tangle/tests/nip99_conformance.rs
@@ -1,97 +0,0 @@
-#![forbid(unsafe_code)]
-
-mod support;
-
-use std::fs;
-use support::{
- RelayHarness, assert_ok, connect_client, http_get, next_label, reopen_store, send_auth,
- send_event, send_req,
-};
-use tangle_test_support::{
- FixtureKey, auth_event_spec, build_fixture_event, valid_public_listing_spec,
-};
-
-#[tokio::test]
-async fn nip99_conformance_projects_and_serves_public_listings() {
- let seller = FixtureKey::Seller.public_key();
- let harness = RelayHarness::start(
- "nip99_conformance",
- serde_json::json!({
- "approved_sellers": [seller.as_str()]
- }),
- );
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
-
- let mut client = connect_client(harness.port).await;
- assert_ok(&send_auth(&mut client, &auth).await, true);
- assert_ok(&send_event(&mut client, &listing).await, true);
- let by_address = send_req(
- &mut client,
- "nip99-address",
- serde_json::json!({
- "kinds": [30402],
- "authors": [seller.as_str()],
- "#d": ["listing-a"],
- "limit": 5
- }),
- )
- .await;
- assert_eq!(by_address[0], "EVENT");
- assert_eq!(by_address[1], "nip99-address");
- assert_eq!(by_address[2]["id"], listing.id().as_str());
- assert_eq!(by_address[2]["kind"], 30402);
- assert_eq!(next_label(&mut client).await, "EOSE");
-
- let list_response = http_get(
- harness.port,
- &format!(
- "/api/listings?status=active&seller={}&unit=lb¤cy=usd&limit=5",
- seller.as_str()
- ),
- );
- assert!(list_response.contains("200 OK"));
- assert!(list_response.contains(listing.id().as_str()));
- assert!(list_response.contains("\"title\":\"Carrot bunches\""));
- assert!(list_response.contains("\"unit\":\"lb\""));
- let detail_response = http_get(
- harness.port,
- &format!("/api/listings/{}/listing-a", seller.as_str()),
- );
- assert!(detail_response.contains("200 OK"));
- assert!(detail_response.contains(listing.id().as_str()));
- assert!(detail_response.contains("\"content\":\"Sweet storage carrots.\""));
-
- let store_config = harness.store_config();
- let root = harness.root.clone();
- drop(client);
- harness.stop();
- let store = reopen_store(&store_config).await;
- let listing_key = format!("30402:{}:listing-a", seller.as_str());
- assert!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("raw row")
- .is_some()
- );
- let row = store
- .listing_current_row(&listing_key)
- .await
- .expect("listing row")
- .expect("listing row exists");
- assert_eq!(row["listing_key"], listing_key);
- assert_eq!(row["event_id"], listing.id().as_str());
- assert_eq!(row["seller_pubkey"], seller.as_str());
- assert_eq!(row["d"], "listing-a");
- assert_eq!(row["title"], "Carrot bunches");
- assert_eq!(row["content"], "Sweet storage carrots.");
- assert_eq!(row["price_decimal"], "12.50");
- assert_eq!(row["price_minor"], 1_250_u64);
- assert_eq!(row["currency_norm"], "USD");
- assert_eq!(row["unit"], "lb");
- assert_eq!(row["effective_status"], "active");
- assert_eq!(row["hidden"], false);
- drop(store);
- fs::remove_dir_all(root).expect("remove runtime root");
-}
diff --git a/crates/tangle/tests/release_acceptance.rs b/crates/tangle/tests/release_acceptance.rs
@@ -14,17 +14,9 @@ fn release_acceptance_script_covers_release_candidate_validation_ladder() {
"scripts/check.sh",
"scripts/test.sh",
"cargo nextest run --workspace",
- "cargo test -p tangle --test nip01_conformance",
- "cargo test -p tangle --test nip09_conformance",
- "cargo test -p tangle --test nip42_conformance",
- "cargo test -p tangle --test nip50_conformance",
- "cargo test -p tangle --test nip99_conformance",
- "cargo test -p tangle --test discussion_conformance",
- "cargo test -p tangle --test moderation_conformance",
- "cargo test -p tangle --test commerce_privacy_conformance",
- "cargo test -p tangle --test abuse_conformance",
- "cargo test -p tangle --test run_integration",
- "cargo test -p tangle_runtime runtime_restore_command_imports_backup_and_rebuilds_projection_state",
+ "cargo test -p tangle_runtime --test base_relay_v2",
+ "cargo test -p tangle_groups",
+ "cargo test -p tangle_store_pocket",
"cargo test -p tangle_bench",
"scripts/benchmark_report.sh",
"cargo test -p tangle --test source_comments",
@@ -40,6 +32,21 @@ fn release_acceptance_script_covers_release_candidate_validation_ladder() {
!script.contains("scripts/coverage.sh"),
"release acceptance must not depend on strict line coverage"
);
+ for removed in [
+ "nip50_conformance",
+ "nip99_conformance",
+ &["discuss", "ion_conformance"].concat(),
+ "moderation_conformance",
+ &["comm", "erce_privacy_conformance"].concat(),
+ "abuse_conformance",
+ "run_integration",
+ "runtime_restore_command_imports_backup_and_rebuilds_projection_state",
+ ] {
+ assert!(
+ !script.contains(removed),
+ "release acceptance still references `{removed}`"
+ );
+ }
#[cfg(unix)]
{
diff --git a/crates/tangle/tests/run_integration.rs b/crates/tangle/tests/run_integration.rs
@@ -1,1364 +0,0 @@
-#![forbid(unsafe_code)]
-
-use futures_util::{SinkExt, StreamExt};
-use serde_json::Value;
-use std::fs;
-use std::io::{Read, Write};
-use std::net::{TcpListener, TcpStream};
-use std::path::Path;
-use std::process::{Child, Command, Stdio};
-use std::time::{Duration, Instant};
-use tangle_protocol::{EventId, event_to_value};
-use tangle_store_surreal::{SurrealConnectionConfig, SurrealStore};
-use tangle_test_support::{
- FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts,
- valid_public_listing_spec,
-};
-use tokio_tungstenite::connect_async;
-use tokio_tungstenite::tungstenite::Message;
-
-#[tokio::test]
-async fn tangle_run_serves_relay_clients_and_persists_surreal_state() {
- let port = free_port();
- let root = std::env::temp_dir().join(format!(
- "tangle-run-integration-{}-{port}",
- std::process::id()
- ));
- let db_path = root.join("surrealdb");
- let config_path = root.join("runtime.json");
- fs::create_dir_all(&root).expect("runtime root");
- let admin = FixtureKey::Relay.public_key();
- write_runtime_config(
- &config_path,
- &db_path,
- port,
- "tangle_it",
- serde_json::json!({
- "admin_pubkeys": [admin.as_str()],
- "approved_sellers": [FixtureKey::Seller.public_key().as_str()],
- "write_rate_limit": {
- "limit": 10,
- "window_seconds": 60
- }
- }),
- Some(serde_json::json!({
- "tracing": {
- "enabled": true,
- "filter": "info,tangle=info,tangle_runtime=info",
- "format": "json"
- }
- })),
- );
-
- let mut relay = Command::new(env!("CARGO_BIN_EXE_tangle"))
- .args(["run", "--config"])
- .arg(&config_path)
- .stdout(Stdio::null())
- .stderr(Stdio::piped())
- .spawn()
- .expect("spawn tangle run");
-
- wait_for_http(port, &mut relay);
- let nip11 = http_get(port, "/");
- assert!(nip11.contains("200 OK"));
- assert!(nip11.contains("application/nostr+json"));
- assert!(nip11.contains("\"supported_nips\""));
-
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let comment = listing_comment(&listing, 1_714_124_436, "Can I pickup Saturday?");
- let reaction = listing_reaction(&listing, 1_714_124_437, "+");
- let thread = forum_thread(1_714_124_438, Some("Market day thread"), &["market", "csa"]);
- let thread_comment = forum_thread_comment(&thread, 1_714_124_439, "I can bring greens.");
- let label = listing_label(&listing, 1_714_124_440, "reviewed");
- let report = listing_report(&listing, 1_714_124_441, "spam");
- let profile = seller_profile(1_714_124_442);
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let seller = FixtureKey::Seller.public_key();
-
- let (mut subscriber, _) = connect_async(format!("ws://127.0.0.1:{port}/ws"))
- .await
- .expect("subscriber connect");
- assert_eq!(next_label(&mut subscriber).await, "AUTH");
- subscriber
- .send(Message::Text(
- serde_json::json!([
- "REQ",
- "sub-live",
- {
- "kinds": [30402],
- "authors": [seller.as_str()]
- }
- ])
- .to_string()
- .into(),
- ))
- .await
- .expect("subscribe");
- assert_eq!(next_label(&mut subscriber).await, "EOSE");
-
- let (mut unauthenticated, _) = connect_async(format!("ws://127.0.0.1:{port}/ws"))
- .await
- .expect("unauthenticated connect");
- assert_eq!(next_label(&mut unauthenticated).await, "AUTH");
- unauthenticated
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(&listing)])
- .to_string()
- .into(),
- ))
- .await
- .expect("unauthenticated event send");
- let unauthenticated_rejection = next_json(&mut unauthenticated).await;
- assert_ok(&unauthenticated_rejection, false);
- assert!(
- unauthenticated_rejection[3]
- .as_str()
- .expect("rejection message")
- .contains("write authentication required")
- );
-
- let (mut publisher, _) = connect_async(format!("ws://127.0.0.1:{port}/ws"))
- .await
- .expect("publisher connect");
- assert_eq!(next_label(&mut publisher).await, "AUTH");
- publisher
- .send(Message::Text(
- serde_json::json!(["AUTH", event_to_value(&auth)])
- .to_string()
- .into(),
- ))
- .await
- .expect("auth send");
- assert_ok(&next_json(&mut publisher).await, true);
-
- publisher
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(&profile)])
- .to_string()
- .into(),
- ))
- .await
- .expect("profile send");
- assert_ok(&next_json(&mut publisher).await, true);
- publisher
- .send(Message::Text(
- serde_json::json!(["REQ", "sub-profile", { "ids": [profile.id().as_str()] }])
- .to_string()
- .into(),
- ))
- .await
- .expect("profile fetch send");
- let fetched_profile = next_json(&mut publisher).await;
- assert_eq!(fetched_profile[0], "EVENT");
- assert_eq!(fetched_profile[1], "sub-profile");
- assert_eq!(fetched_profile[2]["id"], profile.id().as_str());
- assert_eq!(next_label(&mut publisher).await, "EOSE");
-
- publisher
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(&listing)])
- .to_string()
- .into(),
- ))
- .await
- .expect("event send");
- assert_ok(&next_json(&mut publisher).await, true);
- let live = next_json(&mut subscriber).await;
- assert_eq!(live[0], "EVENT");
- assert_eq!(live[1], "sub-live");
- assert_eq!(live[2]["id"], listing.id().as_str());
-
- publisher
- .send(Message::Text(
- serde_json::json!(["REQ", "sub-fetch", { "ids": [listing.id().as_str()] }])
- .to_string()
- .into(),
- ))
- .await
- .expect("fetch send");
- let fetched = next_json(&mut publisher).await;
- assert_eq!(fetched[0], "EVENT");
- assert_eq!(fetched[1], "sub-fetch");
- assert_eq!(fetched[2]["id"], listing.id().as_str());
- assert_eq!(next_label(&mut publisher).await, "EOSE");
-
- publisher
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(&comment)])
- .to_string()
- .into(),
- ))
- .await
- .expect("comment send");
- assert_ok(&next_json(&mut publisher).await, true);
- publisher
- .send(Message::Text(
- serde_json::json!(["REQ", "sub-comment", { "ids": [comment.id().as_str()] }])
- .to_string()
- .into(),
- ))
- .await
- .expect("comment fetch send");
- let fetched_comment = next_json(&mut publisher).await;
- assert_eq!(fetched_comment[0], "EVENT");
- assert_eq!(fetched_comment[1], "sub-comment");
- assert_eq!(fetched_comment[2]["id"], comment.id().as_str());
- assert_eq!(next_label(&mut publisher).await, "EOSE");
-
- publisher
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(&reaction)])
- .to_string()
- .into(),
- ))
- .await
- .expect("reaction send");
- assert_ok(&next_json(&mut publisher).await, true);
- publisher
- .send(Message::Text(
- serde_json::json!(["REQ", "sub-reaction", { "ids": [reaction.id().as_str()] }])
- .to_string()
- .into(),
- ))
- .await
- .expect("reaction fetch send");
- let fetched_reaction = next_json(&mut publisher).await;
- assert_eq!(fetched_reaction[0], "EVENT");
- assert_eq!(fetched_reaction[1], "sub-reaction");
- assert_eq!(fetched_reaction[2]["id"], reaction.id().as_str());
- assert_eq!(next_label(&mut publisher).await, "EOSE");
-
- publisher
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(&thread)])
- .to_string()
- .into(),
- ))
- .await
- .expect("thread send");
- assert_ok(&next_json(&mut publisher).await, true);
- publisher
- .send(Message::Text(
- serde_json::json!(["REQ", "sub-thread", { "ids": [thread.id().as_str()] }])
- .to_string()
- .into(),
- ))
- .await
- .expect("thread fetch send");
- let fetched_thread = next_json(&mut publisher).await;
- assert_eq!(fetched_thread[0], "EVENT");
- assert_eq!(fetched_thread[1], "sub-thread");
- assert_eq!(fetched_thread[2]["id"], thread.id().as_str());
- assert_eq!(next_label(&mut publisher).await, "EOSE");
-
- publisher
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(&thread_comment)])
- .to_string()
- .into(),
- ))
- .await
- .expect("thread comment send");
- assert_ok(&next_json(&mut publisher).await, true);
- publisher
- .send(Message::Text(
- serde_json::json!(["REQ", "sub-thread-comment", { "ids": [thread_comment.id().as_str()] }])
- .to_string()
- .into(),
- ))
- .await
- .expect("thread comment fetch send");
- let fetched_thread_comment = next_json(&mut publisher).await;
- assert_eq!(fetched_thread_comment[0], "EVENT");
- assert_eq!(fetched_thread_comment[1], "sub-thread-comment");
- assert_eq!(
- fetched_thread_comment[2]["id"],
- thread_comment.id().as_str()
- );
- assert_eq!(next_label(&mut publisher).await, "EOSE");
-
- publisher
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(&label)])
- .to_string()
- .into(),
- ))
- .await
- .expect("label send");
- assert_ok(&next_json(&mut publisher).await, true);
- publisher
- .send(Message::Text(
- serde_json::json!(["REQ", "sub-label", { "ids": [label.id().as_str()] }])
- .to_string()
- .into(),
- ))
- .await
- .expect("label fetch send");
- let fetched_label = next_json(&mut publisher).await;
- assert_eq!(fetched_label[0], "EVENT");
- assert_eq!(fetched_label[1], "sub-label");
- assert_eq!(fetched_label[2]["id"], label.id().as_str());
- assert_eq!(next_label(&mut publisher).await, "EOSE");
-
- publisher
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(&report)])
- .to_string()
- .into(),
- ))
- .await
- .expect("report send");
- assert_ok(&next_json(&mut publisher).await, true);
- publisher
- .send(Message::Text(
- serde_json::json!(["REQ", "sub-report", { "ids": [report.id().as_str()] }])
- .to_string()
- .into(),
- ))
- .await
- .expect("report fetch send");
- let fetched_report = next_json(&mut publisher).await;
- assert_eq!(fetched_report[0], "EVENT");
- assert_eq!(fetched_report[1], "sub-report");
- assert_eq!(fetched_report[2]["id"], report.id().as_str());
- assert_eq!(next_label(&mut publisher).await, "EOSE");
-
- subscriber
- .send(Message::Text(
- serde_json::json!(["CLOSE", "sub-live"]).to_string().into(),
- ))
- .await
- .expect("close send");
-
- let listings = http_get(port, "/api/listings?limit=5");
- assert!(listings.contains("200 OK"));
- assert!(listings.contains("Carrot bunches"));
- assert!(listings.contains(listing.id().as_str()));
- let detail = http_get(
- port,
- &format!("/api/listings/{}/listing-a", seller.as_str()),
- );
- assert!(detail.contains("200 OK"));
- assert!(detail.contains("listing-a"));
- assert!(detail.contains("Carrot bunches"));
- let comments = http_get(
- port,
- &format!(
- "/api/listings/{}/listing-a/comments?limit=5",
- seller.as_str()
- ),
- );
- assert!(comments.contains("200 OK"));
- assert!(comments.contains(comment.id().as_str()));
- assert!(comments.contains("Can I pickup Saturday?"));
- let reactions = http_get(
- port,
- &format!("/api/listings/{}/listing-a/reactions", seller.as_str()),
- );
- assert!(reactions.contains("200 OK"));
- assert!(reactions.contains("\"like_count\":1"));
- assert!(reactions.contains("\"total_count\":1"));
- let forum_threads = http_get(port, "/api/forum/threads?topic=market&limit=5");
- assert!(forum_threads.contains("200 OK"));
- assert!(forum_threads.contains(thread.id().as_str()));
- assert!(forum_threads.contains("Market day thread"));
- let forum_detail = http_get(
- port,
- &format!("/api/forum/threads/{}", thread.id().as_str()),
- );
- assert!(forum_detail.contains("200 OK"));
- assert!(forum_detail.contains(thread.id().as_str()));
- assert!(forum_detail.contains("Market day thread"));
- let forum_comments = http_get(
- port,
- &format!(
- "/api/forum/threads/{}/comments?limit=5",
- thread.id().as_str()
- ),
- );
- assert!(forum_comments.contains("200 OK"));
- assert!(forum_comments.contains(thread_comment.id().as_str()));
- assert!(forum_comments.contains("I can bring greens."));
- let moderation_labels = http_get_admin(
- port,
- &format!(
- "/api/admin/moderation/labels?target_type=event&target_ref={}&namespace=com.radroots.moderation&label=reviewed&limit=5",
- listing.id().as_str()
- ),
- Some(admin.as_str()),
- );
- assert!(moderation_labels.contains("200 OK"));
- assert!(moderation_labels.contains(label.id().as_str()));
- assert!(moderation_labels.contains("\"label\":\"reviewed\""));
- let moderation_reports = http_get_admin(
- port,
- &format!(
- "/api/admin/moderation/reports?target_type=event&target_ref={}&report_type=spam&limit=5",
- listing.id().as_str()
- ),
- Some(admin.as_str()),
- );
- assert!(moderation_reports.contains("200 OK"));
- assert!(moderation_reports.contains(report.id().as_str()));
- assert!(moderation_reports.contains("\"report_type\":\"spam\""));
- let search = http_get(port, "/api/search?q=carrots&limit=5");
- assert!(search.contains("200 OK"));
- assert!(search.contains(listing.id().as_str()));
- let seller_detail = http_get(port, &format!("/api/sellers/{}", seller.as_str()));
- assert!(seller_detail.contains("200 OK"));
- assert!(seller_detail.contains(seller.as_str()));
- assert!(seller_detail.contains(profile.id().as_str()));
- assert!(seller_detail.contains("\"display_name\":\"Radroots Market\""));
- assert!(seller_detail.contains("\"regions\":[\"cascadia\",\"pnw\"]"));
- let readiness = http_get(port, "/readyz");
- assert!(readiness.contains("200 OK"));
- assert!(readiness.contains("\"status\":\"ready\""));
- assert!(readiness.contains("\"database\":\"ready\""));
- assert!(readiness.contains("\"migrations\":\"ready\""));
- assert!(readiness.contains("\"repository\":\"ready\""));
- let metrics = http_get(port, "/metrics");
- assert!(metrics.contains("200 OK"));
- assert!(metrics.contains("text/plain; version=0.0.4"));
- assert!(metrics.contains("tangle_info{"));
- assert!(metrics.contains("tangle_relay_ready 1"));
- assert!(metrics.contains("tangle_store_events{state=\"stored\"} 8"));
- assert!(metrics.contains("tangle_store_events{state=\"visible\"} 8"));
- assert!(metrics.contains("tangle_store_listings{state=\"active\"} 1"));
- assert!(metrics.contains("tangle_store_seller_profiles{state=\"visible\"} 1"));
- assert!(metrics.contains("tangle_store_sellers{state=\"approved\"} 0"));
-
- let trace_output = stop_relay_with_stderr(relay);
- assert!(trace_output.contains("tracing initialized"));
- assert!(trace_output.contains("starting runtime server"));
- assert!(trace_output.contains("\"format\":\"json\""));
-
- let store_config =
- SurrealConnectionConfig::rocksdb(db_path.to_str().expect("db path"), "tangle_it", "relay")
- .expect("store config");
- let store = reopen_store(&store_config).await;
- let listing_key = format!("30402:{}:listing-a", seller.as_str());
- assert!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("raw row")
- .is_some()
- );
- assert!(
- store
- .raw_event_row(auth.id())
- .await
- .expect("auth raw row")
- .is_none()
- );
- assert!(
- store
- .raw_event_row(profile.id())
- .await
- .expect("profile raw row")
- .is_some()
- );
- let metrics_snapshot = store.metrics_snapshot().await.expect("metrics snapshot");
- assert_eq!(metrics_snapshot.stored_events(), 8);
- assert_eq!(metrics_snapshot.visible_events(), 8);
- assert_eq!(metrics_snapshot.current_listings(), 1);
- assert_eq!(metrics_snapshot.active_listings(), 1);
- assert_eq!(metrics_snapshot.seller_profiles(), 1);
- assert_eq!(metrics_snapshot.visible_seller_profiles(), 1);
- assert_eq!(metrics_snapshot.approved_sellers(), 0);
- let profile_row = store
- .seller_profile_row(seller.as_str())
- .await
- .expect("profile row")
- .expect("profile row exists");
- assert_eq!(profile_row["event_id"], profile.id().as_str());
- assert_eq!(profile_row["name"], "radroots-market");
- assert_eq!(profile_row["display_name"], "Radroots Market");
- assert_eq!(
- profile_row["regions"],
- serde_json::json!(["cascadia", "pnw"])
- );
- assert_eq!(profile_row["categories"], serde_json::json!(["produce"]));
- assert_eq!(
- profile_row["trust_markers"],
- serde_json::json!(["csa", "regenerative"])
- );
- assert!(
- store
- .listing_current_row(&listing_key)
- .await
- .expect("listing row")
- .is_some()
- );
- let comment_row = store
- .comment_projection_row(comment.id())
- .await
- .expect("comment row")
- .expect("comment row exists");
- assert_eq!(comment_row["event_id"], comment.id().as_str());
- assert_eq!(comment_row["root_ref"], listing_key);
- assert_eq!(comment_row["content"], "Can I pickup Saturday?");
- let reaction_count = store
- .reaction_count_row(listing.id())
- .await
- .expect("reaction count")
- .expect("reaction count exists");
- assert_eq!(reaction_count["target_event_id"], listing.id().as_str());
- assert_eq!(reaction_count["like_count"], 1_i64);
- assert_eq!(reaction_count["total_count"], 1_i64);
- let thread_row = store
- .forum_thread_row(thread.id())
- .await
- .expect("thread row")
- .expect("thread row exists");
- assert_eq!(thread_row["event_id"], thread.id().as_str());
- assert_eq!(thread_row["title"], "Market day thread");
- let thread_comment_row = store
- .comment_projection_row(thread_comment.id())
- .await
- .expect("thread comment row")
- .expect("thread comment row exists");
- assert_eq!(thread_comment_row["root_ref"], thread.id().as_str());
- assert_eq!(thread_comment_row["content"], "I can bring greens.");
- assert!(
- store
- .search_document_row(thread.id().as_str())
- .await
- .expect("thread search row")
- .is_some()
- );
- let label_rows = store
- .label_projection_rows(label.id())
- .await
- .expect("label rows");
- assert_eq!(label_rows.len(), 2);
- let event_label = label_rows
- .iter()
- .find(|row| row["target_type"] == "event")
- .expect("event label row");
- assert_eq!(event_label["event_id"], label.id().as_str());
- assert_eq!(event_label["target_ref"], listing.id().as_str());
- assert_eq!(event_label["namespace"], "com.radroots.moderation");
- assert_eq!(event_label["label"], "reviewed");
- let report_rows = store
- .report_projection_rows(report.id())
- .await
- .expect("report rows");
- assert_eq!(report_rows.len(), 1);
- assert_eq!(report_rows[0]["event_id"], report.id().as_str());
- assert_eq!(report_rows[0]["target_type"], "event");
- assert_eq!(report_rows[0]["target_ref"], listing.id().as_str());
- assert_eq!(report_rows[0]["report_type"], "spam");
- assert_eq!(report_rows[0]["reported_pubkeys"][0], seller.as_str());
- assert!(
- store
- .search_document_row(&listing_key)
- .await
- .expect("search row")
- .is_some()
- );
-
- drop(store);
- fs::remove_dir_all(&root).expect("remove runtime root");
-}
-
-#[tokio::test]
-async fn tangle_run_enforces_seller_projection_policy() {
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let seller = FixtureKey::Seller.public_key();
- let listing_key = format!("30402:{}:listing-a", seller.as_str());
-
- let raw_only = run_policy_write_scenario(
- "raw-only",
- "tangle_policy_raw_only",
- serde_json::json!({}),
- &listing,
- &auth,
- )
- .await;
- assert_ok(&raw_only.event_response, true);
- assert!(raw_only.listing_response.contains("200 OK"));
- assert!(!raw_only.listing_response.contains(listing.id().as_str()));
- let raw_only_store = reopen_store(&raw_only.store_config).await;
- assert!(
- raw_only_store
- .raw_event_row(listing.id())
- .await
- .expect("raw row")
- .is_some()
- );
- assert!(
- raw_only_store
- .listing_current_row(&listing_key)
- .await
- .expect("listing row")
- .is_none()
- );
- assert!(
- raw_only_store
- .search_document_row(&listing_key)
- .await
- .expect("search row")
- .is_none()
- );
- drop(raw_only_store);
- fs::remove_dir_all(&raw_only.root).expect("remove raw-only root");
-
- let reject_write = run_policy_write_scenario(
- "reject-write",
- "tangle_policy_reject_write",
- serde_json::json!({
- "unapproved_seller_action": "reject_write"
- }),
- &listing,
- &auth,
- )
- .await;
- assert_ok(&reject_write.event_response, false);
- assert!(
- reject_write.event_response[3]
- .as_str()
- .expect("rejection message")
- .contains("seller is not approved")
- );
- assert!(reject_write.listing_response.contains("200 OK"));
- assert!(
- !reject_write
- .listing_response
- .contains(listing.id().as_str())
- );
- let reject_store = reopen_store(&reject_write.store_config).await;
- assert!(
- reject_store
- .raw_event_row(listing.id())
- .await
- .expect("raw row")
- .is_none()
- );
- assert!(
- reject_store
- .listing_current_row(&listing_key)
- .await
- .expect("listing row")
- .is_none()
- );
- drop(reject_store);
- fs::remove_dir_all(&reject_write.root).expect("remove reject root");
-}
-
-#[tokio::test]
-async fn tangle_run_persists_durable_write_rate_limits() {
- let port = free_port();
- let root = std::env::temp_dir().join(format!(
- "tangle-rate-limit-integration-{}-{port}",
- std::process::id()
- ));
- let db_path = root.join("surrealdb");
- let config_path = root.join("runtime.json");
- fs::create_dir_all(&root).expect("runtime root");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let seller = FixtureKey::Seller.public_key();
- write_runtime_config(
- &config_path,
- &db_path,
- port,
- "tangle_rate_limit",
- serde_json::json!({
- "approved_sellers": [seller.as_str()],
- "write_rate_limit": {
- "limit": 1,
- "window_seconds": 60
- }
- }),
- None,
- );
- let mut relay = spawn_relay(&config_path);
- wait_for_http(port, &mut relay);
- let (mut client, _) = connect_async(format!("ws://127.0.0.1:{port}/ws"))
- .await
- .expect("client connect");
- assert_eq!(next_label(&mut client).await, "AUTH");
- client
- .send(Message::Text(
- serde_json::json!(["AUTH", event_to_value(&auth)])
- .to_string()
- .into(),
- ))
- .await
- .expect("auth send");
- assert_ok(&next_json(&mut client).await, true);
-
- client
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(&listing)])
- .to_string()
- .into(),
- ))
- .await
- .expect("first event send");
- assert_ok(&next_json(&mut client).await, true);
- client
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(&listing)])
- .to_string()
- .into(),
- ))
- .await
- .expect("second event send");
- let rejected = next_json(&mut client).await;
- assert_ok(&rejected, false);
- assert!(
- rejected[3]
- .as_str()
- .expect("rate rejection")
- .contains("rate-limited: retry after")
- );
- stop_relay(relay);
-
- let store_config = SurrealConnectionConfig::rocksdb(
- db_path.to_str().expect("db path"),
- "tangle_rate_limit",
- "relay",
- )
- .expect("store config");
- let store = reopen_store(&store_config).await;
- let key = format!("event_write:{}", seller.as_str());
- let row = store
- .rate_limit_state_row(&key)
- .await
- .expect("rate row")
- .expect("rate row exists");
- assert_eq!(row["key"], key);
- assert_eq!(
- serde_json::from_str::<serde_json::Value>(row["state"].as_str().expect("state"))
- .expect("state json")["used"],
- 1_u64
- );
- assert!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("raw row")
- .is_some()
- );
- drop(store);
- fs::remove_dir_all(&root).expect("remove runtime root");
-}
-
-#[tokio::test]
-async fn tangle_run_serves_admin_policy_api() {
- let port = free_port();
- let root = std::env::temp_dir().join(format!(
- "tangle-admin-policy-integration-{}-{port}",
- std::process::id()
- ));
- let db_path = root.join("surrealdb");
- let config_path = root.join("runtime.json");
- fs::create_dir_all(&root).expect("runtime root");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let seller = FixtureKey::Seller.public_key();
- let admin = FixtureKey::Relay.public_key();
- let listing_key = format!("30402:{}:listing-a", seller.as_str());
- write_runtime_config(
- &config_path,
- &db_path,
- port,
- "tangle_admin_policy",
- serde_json::json!({
- "admin_pubkeys": [admin.as_str()]
- }),
- None,
- );
- let mut relay = spawn_relay(&config_path);
- wait_for_http(port, &mut relay);
-
- let unauthorized = http_post_json(
- port,
- &format!("/api/admin/sellers/{}/approve", seller.as_str()),
- None,
- serde_json::json!({}),
- );
- assert!(unauthorized.contains("401 Unauthorized"));
- let approve = http_post_json(
- port,
- &format!("/api/admin/sellers/{}/approve", seller.as_str()),
- Some(admin.as_str()),
- serde_json::json!({}),
- );
- assert!(approve.contains("200 OK"));
- assert!(approve.contains("\"status\":\"approved\""));
- let seller_detail = http_get(port, &format!("/api/sellers/{}", seller.as_str()));
- assert!(seller_detail.contains("\"approved\":true"));
-
- let (mut client, _) = connect_async(format!("ws://127.0.0.1:{port}/ws"))
- .await
- .expect("client connect");
- assert_eq!(next_label(&mut client).await, "AUTH");
- client
- .send(Message::Text(
- serde_json::json!(["AUTH", event_to_value(&auth)])
- .to_string()
- .into(),
- ))
- .await
- .expect("auth send");
- assert_ok(&next_json(&mut client).await, true);
- client
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(&listing)])
- .to_string()
- .into(),
- ))
- .await
- .expect("event send");
- assert_ok(&next_json(&mut client).await, true);
- assert!(http_get(port, "/api/listings?limit=5").contains(listing.id().as_str()));
-
- let hide = http_post_json(
- port,
- &format!("/api/admin/events/{}/hide", listing.id().as_str()),
- Some(admin.as_str()),
- serde_json::json!({
- "reason": "admin policy integration"
- }),
- );
- assert!(hide.contains("200 OK"));
- assert!(hide.contains("\"status\":\"hidden\""));
- assert!(!http_get(port, "/api/listings?limit=5").contains(listing.id().as_str()));
- let unhide = http_post_json(
- port,
- &format!("/api/admin/events/{}/unhide", listing.id().as_str()),
- Some(admin.as_str()),
- serde_json::json!({
- "reason": "admin policy integration complete"
- }),
- );
- assert!(unhide.contains("200 OK"));
- assert!(unhide.contains("\"status\":\"unhidden\""));
- assert!(http_get(port, "/api/listings?limit=5").contains(listing.id().as_str()));
- let block = http_post_json(
- port,
- &format!("/api/admin/pubkeys/{}/block", seller.as_str()),
- Some(admin.as_str()),
- serde_json::json!({}),
- );
- assert!(block.contains("200 OK"));
- assert!(block.contains("\"status\":\"blocked\""));
- stop_relay(relay);
-
- let store_config = SurrealConnectionConfig::rocksdb(
- db_path.to_str().expect("db path"),
- "tangle_admin_policy",
- "relay",
- )
- .expect("store config");
- let store = reopen_store(&store_config).await;
- let user = store
- .relay_user_row(seller.as_str())
- .await
- .expect("relay user")
- .expect("relay user exists");
- assert_eq!(user["seller_approved"], true);
- assert_eq!(user["blocked"], true);
- assert!(
- store
- .hidden_event_row(listing.id())
- .await
- .expect("hidden row")
- .is_none()
- );
- assert_eq!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("raw row")
- .expect("raw row exists")["hidden"],
- false
- );
- assert_eq!(
- store
- .listing_current_row(&listing_key)
- .await
- .expect("listing row")
- .expect("listing row exists")["hidden"],
- false
- );
- let actions = store
- .moderation_action_rows("event", listing.id().as_str())
- .await
- .expect("moderation actions");
- assert_eq!(actions.len(), 2);
- let action_labels = actions
- .iter()
- .map(|action| action["action"].as_str().expect("action label"))
- .collect::<std::collections::BTreeSet<_>>();
- assert!(action_labels.contains("hide"));
- assert!(action_labels.contains("unhide"));
- drop(store);
- fs::remove_dir_all(&root).expect("remove runtime root");
-}
-
-struct PolicyWriteScenario {
- root: std::path::PathBuf,
- store_config: SurrealConnectionConfig,
- event_response: Value,
- listing_response: String,
-}
-
-async fn run_policy_write_scenario(
- name: &str,
- namespace: &str,
- policy: Value,
- listing: &tangle_protocol::Event,
- auth: &tangle_protocol::Event,
-) -> PolicyWriteScenario {
- let port = free_port();
- let root = std::env::temp_dir().join(format!(
- "tangle-policy-{name}-{}-{port}",
- std::process::id()
- ));
- let db_path = root.join("surrealdb");
- let config_path = root.join("runtime.json");
- fs::create_dir_all(&root).expect("runtime root");
- write_runtime_config(&config_path, &db_path, port, namespace, policy, None);
- let mut relay = spawn_relay(&config_path);
- wait_for_http(port, &mut relay);
- let (mut client, _) = connect_async(format!("ws://127.0.0.1:{port}/ws"))
- .await
- .expect("client connect");
- assert_eq!(next_label(&mut client).await, "AUTH");
- client
- .send(Message::Text(
- serde_json::json!(["AUTH", event_to_value(auth)])
- .to_string()
- .into(),
- ))
- .await
- .expect("auth send");
- assert_ok(&next_json(&mut client).await, true);
- client
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(listing)])
- .to_string()
- .into(),
- ))
- .await
- .expect("event send");
- let event_response = next_json(&mut client).await;
- let listing_response = http_get(port, "/api/listings?limit=5");
- stop_relay(relay);
- let store_config =
- SurrealConnectionConfig::rocksdb(db_path.to_str().expect("db path"), namespace, "relay")
- .expect("store config");
- PolicyWriteScenario {
- root,
- store_config,
- event_response,
- listing_response,
- }
-}
-
-fn spawn_relay(config_path: &Path) -> Child {
- Command::new(env!("CARGO_BIN_EXE_tangle"))
- .args(["run", "--config"])
- .arg(config_path)
- .stdout(Stdio::null())
- .stderr(Stdio::piped())
- .spawn()
- .expect("spawn tangle run")
-}
-
-fn write_runtime_config(
- path: &Path,
- db_path: &Path,
- port: u16,
- namespace: &str,
- policy: Value,
- observability: Option<Value>,
-) {
- let mut config = serde_json::json!({
- "server": {
- "listen_addr": format!("127.0.0.1:{port}"),
- "relay_url": "wss://relay.radroots.test"
- },
- "database": {
- "mode": "rocks_db",
- "path": db_path.to_str().expect("db path"),
- "namespace": namespace,
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- },
- "policy": policy
- });
- if let Some(observability) = observability {
- config["observability"] = observability;
- }
- fs::write(
- path,
- serde_json::to_string_pretty(&config).expect("config JSON"),
- )
- .expect("write config");
-}
-
-fn free_port() -> u16 {
- TcpListener::bind("127.0.0.1:0")
- .expect("bind port")
- .local_addr()
- .expect("local addr")
- .port()
-}
-
-fn wait_for_http(port: u16, child: &mut Child) {
- let started = Instant::now();
- loop {
- if let Ok(response) = try_http_get(port, "/healthz")
- && response.contains("200 OK")
- {
- return;
- }
- if let Some(status) = child.try_wait().expect("child status") {
- panic!("relay exited before readiness: {status}");
- }
- assert!(
- started.elapsed() < Duration::from_secs(10),
- "relay did not open port {port}"
- );
- std::thread::sleep(Duration::from_millis(50));
- }
-}
-
-fn http_get(port: u16, path: &str) -> String {
- try_http_get(port, path).expect("http get")
-}
-
-fn http_get_admin(port: u16, path: &str, admin_pubkey: Option<&str>) -> String {
- let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("http connect");
- stream
- .set_read_timeout(Some(Duration::from_secs(2)))
- .expect("read timeout");
- stream
- .set_write_timeout(Some(Duration::from_secs(2)))
- .expect("write timeout");
- let admin_header = admin_pubkey
- .map(|pubkey| format!("x-tangle-admin-pubkey: {pubkey}\r\n"))
- .unwrap_or_default();
- write!(
- stream,
- "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/json\r\n{admin_header}Connection: close\r\n\r\n"
- )
- .expect("http get");
- let mut response = String::new();
- stream.read_to_string(&mut response).expect("http read");
- response
-}
-
-fn http_post_json(port: u16, path: &str, admin_pubkey: Option<&str>, body: Value) -> String {
- let body = body.to_string();
- let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("http connect");
- stream
- .set_read_timeout(Some(Duration::from_secs(2)))
- .expect("read timeout");
- stream
- .set_write_timeout(Some(Duration::from_secs(2)))
- .expect("write timeout");
- let admin_header = admin_pubkey
- .map(|pubkey| format!("x-tangle-admin-pubkey: {pubkey}\r\n"))
- .unwrap_or_default();
- write!(
- stream,
- "POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{admin_header}Connection: close\r\n\r\n{body}",
- body.len()
- )
- .expect("http post");
- let mut response = String::new();
- stream.read_to_string(&mut response).expect("http read");
- response
-}
-
-fn try_http_get(port: u16, path: &str) -> Result<String, std::io::Error> {
- let mut stream = TcpStream::connect(("127.0.0.1", port))?;
- stream.set_read_timeout(Some(Duration::from_secs(2)))?;
- stream.set_write_timeout(Some(Duration::from_secs(2)))?;
- write!(
- stream,
- "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/nostr+json\r\nConnection: close\r\n\r\n"
- )?;
- let mut response = String::new();
- stream.read_to_string(&mut response)?;
- Ok(response)
-}
-
-async fn reopen_store(config: &SurrealConnectionConfig) -> SurrealStore {
- let started = Instant::now();
- loop {
- match SurrealStore::connect_local(config).await {
- Ok(store) => return store,
- Err(error) if started.elapsed() < Duration::from_secs(5) => {
- let _ = error;
- tokio::time::sleep(Duration::from_millis(50)).await;
- }
- Err(error) => panic!("store reopen failed: {error}"),
- }
- }
-}
-
-async fn next_json(
- socket: &mut tokio_tungstenite::WebSocketStream<
- tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
- >,
-) -> Value {
- let message = socket
- .next()
- .await
- .expect("websocket message")
- .expect("websocket frame");
- let text = message.into_text().expect("text frame");
- serde_json::from_str(&text).expect("relay JSON")
-}
-
-async fn next_label(
- socket: &mut tokio_tungstenite::WebSocketStream<
- tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
- >,
-) -> String {
- next_json(socket).await[0]
- .as_str()
- .expect("label")
- .to_owned()
-}
-
-fn assert_ok(message: &Value, accepted: bool) {
- assert_eq!(message[0], "OK");
- assert_eq!(message[2], accepted, "relay OK frame: {message}");
-}
-
-fn seller_profile(created_at: u64) -> tangle_protocol::Event {
- let content = serde_json::json!({
- "name": "radroots-market",
- "display_name": "Radroots Market",
- "about": "Local food seller profile",
- "picture": "https://fixtures.radroots.test/seller.png",
- "website": "https://seller.radroots.test",
- "nip05": "seller@radroots.test",
- "lud16": "seller@pay.radroots.test"
- });
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- 0,
- vec![
- vec!["region".to_owned(), "PNW".to_owned()],
- vec!["region".to_owned(), "Cascadia".to_owned()],
- vec!["category".to_owned(), "Produce".to_owned()],
- vec!["trust".to_owned(), "CSA".to_owned()],
- vec!["trust".to_owned(), "regenerative".to_owned()],
- ],
- &content.to_string(),
- )
- .expect("seller profile")
-}
-
-fn listing_comment(
- listing: &tangle_protocol::Event,
- created_at: u64,
- content: &str,
-) -> tangle_protocol::Event {
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- 1_111,
- vec![
- vec!["A".to_owned(), listing_key.clone()],
- vec!["K".to_owned(), "30402".to_owned()],
- vec![
- "P".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- vec!["a".to_owned(), listing_key],
- vec!["k".to_owned(), "30402".to_owned()],
- vec![
- "p".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- ],
- content,
- )
- .expect("comment event")
-}
-
-fn listing_reaction(
- listing: &tangle_protocol::Event,
- created_at: u64,
- content: &str,
-) -> tangle_protocol::Event {
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- 7,
- vec![
- vec![
- "e".to_owned(),
- listing.id().as_str().to_owned(),
- "wss://relay.radroots.test".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- vec![
- "p".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- vec!["a".to_owned(), listing_key],
- vec!["k".to_owned(), "30402".to_owned()],
- ],
- content,
- )
- .expect("reaction event")
-}
-
-fn listing_label(
- listing: &tangle_protocol::Event,
- created_at: u64,
- label: &str,
-) -> tangle_protocol::Event {
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- let namespace = "com.radroots.moderation";
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- 1_985,
- vec![
- vec!["L".to_owned(), namespace.to_owned()],
- vec!["l".to_owned(), label.to_owned(), namespace.to_owned()],
- vec!["e".to_owned(), listing.id().as_str().to_owned()],
- vec!["a".to_owned(), listing_key],
- ],
- "moderator label",
- )
- .expect("label event")
-}
-
-fn listing_report(
- listing: &tangle_protocol::Event,
- created_at: u64,
- report_type: &str,
-) -> tangle_protocol::Event {
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- 1_984,
- vec![
- vec![
- "p".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- vec![
- "e".to_owned(),
- listing.id().as_str().to_owned(),
- report_type.to_owned(),
- ],
- ],
- "moderator report",
- )
- .expect("report event")
-}
-
-fn forum_thread(created_at: u64, title: Option<&str>, topics: &[&str]) -> tangle_protocol::Event {
- let mut tags = vec![
- vec!["e".to_owned(), "5".repeat(EventId::HEX_LENGTH)],
- vec![
- "p".to_owned(),
- FixtureKey::Buyer.public_key().as_str().to_owned(),
- ],
- ];
- if let Some(title) = title {
- tags.push(vec!["title".to_owned(), title.to_owned()]);
- }
- tags.extend(
- topics
- .iter()
- .map(|topic| vec!["t".to_owned(), (*topic).to_owned()]),
- );
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- 11,
- tags,
- "What is everyone bringing this weekend?",
- )
- .expect("forum thread")
-}
-
-fn forum_thread_comment(
- thread: &tangle_protocol::Event,
- created_at: u64,
- content: &str,
-) -> tangle_protocol::Event {
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- 1_111,
- vec![
- vec![
- "E".to_owned(),
- thread.id().as_str().to_owned(),
- "wss://relay.radroots.test".to_owned(),
- thread.unsigned().pubkey().as_str().to_owned(),
- ],
- vec!["K".to_owned(), "11".to_owned()],
- vec![
- "P".to_owned(),
- thread.unsigned().pubkey().as_str().to_owned(),
- ],
- vec![
- "e".to_owned(),
- thread.id().as_str().to_owned(),
- "wss://relay.radroots.test".to_owned(),
- thread.unsigned().pubkey().as_str().to_owned(),
- ],
- vec!["k".to_owned(), "11".to_owned()],
- vec![
- "p".to_owned(),
- thread.unsigned().pubkey().as_str().to_owned(),
- ],
- ],
- content,
- )
- .expect("forum comment event")
-}
-
-fn stop_relay(relay: Child) {
- let _ = stop_relay_with_stderr(relay);
-}
-
-fn stop_relay_with_stderr(mut relay: Child) -> String {
- stop_child(&mut relay);
- let output = relay.wait_with_output().expect("relay exit");
- assert!(output.status.success());
- String::from_utf8_lossy(&output.stderr).to_string()
-}
-
-#[cfg(unix)]
-fn stop_child(relay: &mut Child) {
- let status = Command::new("kill")
- .args(["-INT", &relay.id().to_string()])
- .status()
- .expect("send interrupt");
- assert!(status.success());
-}
-
-#[cfg(not(unix))]
-fn stop_child(relay: &mut Child) {
- relay.kill().expect("kill relay");
-}
diff --git a/crates/tangle/tests/support/mod.rs b/crates/tangle/tests/support/mod.rs
@@ -1,347 +0,0 @@
-#![allow(dead_code)]
-
-use futures_util::{SinkExt, StreamExt};
-use serde_json::Value;
-use std::fs;
-use std::io::{Read, Write};
-use std::net::{TcpListener, TcpStream};
-use std::path::{Path, PathBuf};
-use std::process::{Child, Command, Stdio};
-use std::time::{Duration, Instant};
-use tangle_protocol::{Event, event_to_value};
-use tangle_store_surreal::{SurrealConnectionConfig, SurrealStore};
-use tokio_tungstenite::tungstenite::Message;
-
-pub type RelayClient =
- tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
-
-pub struct RelayHarness {
- pub port: u16,
- pub root: PathBuf,
- pub db_path: PathBuf,
- pub namespace: String,
- child: Child,
-}
-
-impl RelayHarness {
- pub fn start(namespace: &str, policy: Value) -> Self {
- Self::start_with_runtime_limits(namespace, policy, serde_json::json!({}))
- }
-
- pub fn start_with_runtime_limits(
- namespace: &str,
- policy: Value,
- runtime_limits: Value,
- ) -> Self {
- let port = free_port();
- let root = std::env::temp_dir().join(format!(
- "tangle-conformance-{namespace}-{}-{port}",
- std::process::id()
- ));
- let db_path = root.join("surrealdb");
- let config_path = root.join("runtime.json");
- fs::create_dir_all(&root).expect("runtime root");
- write_runtime_config(
- &config_path,
- &db_path,
- port,
- namespace,
- policy,
- runtime_limits,
- );
- let mut child = Command::new(env!("CARGO_BIN_EXE_tangle"))
- .args(["run", "--config"])
- .arg(&config_path)
- .stdout(Stdio::null())
- .stderr(Stdio::piped())
- .spawn()
- .expect("spawn tangle run");
- wait_for_http(port, &mut child);
- Self {
- port,
- root,
- db_path,
- namespace: namespace.to_owned(),
- child,
- }
- }
-
- pub fn store_config(&self) -> SurrealConnectionConfig {
- SurrealConnectionConfig::rocksdb(
- self.db_path.to_str().expect("db path"),
- &self.namespace,
- "relay",
- )
- .expect("store config")
- }
-
- pub fn stop(self) {
- stop_relay(self.child);
- }
-}
-
-pub async fn connect_client(port: u16) -> RelayClient {
- let (client, challenge) = connect_client_with_challenge(port).await;
- assert_eq!(challenge[0], "AUTH");
- client
-}
-
-pub async fn connect_client_with_challenge(port: u16) -> (RelayClient, Value) {
- let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/ws"))
- .await
- .expect("client connect");
- let challenge = next_json(&mut client).await;
- (client, challenge)
-}
-
-pub async fn send_event(client: &mut RelayClient, event: &Event) -> Value {
- client
- .send(Message::Text(
- serde_json::json!(["EVENT", event_to_value(event)])
- .to_string()
- .into(),
- ))
- .await
- .expect("event send");
- next_json(client).await
-}
-
-pub async fn send_auth(client: &mut RelayClient, event: &Event) -> Value {
- client
- .send(Message::Text(
- serde_json::json!(["AUTH", event_to_value(event)])
- .to_string()
- .into(),
- ))
- .await
- .expect("auth send");
- next_json(client).await
-}
-
-pub async fn request_event_by_id(
- client: &mut RelayClient,
- subscription: &str,
- event: &Event,
-) -> Value {
- send_req(
- client,
- subscription,
- serde_json::json!({ "ids": [event.id().as_str()] }),
- )
- .await
-}
-
-pub async fn send_req(client: &mut RelayClient, subscription: &str, filter: Value) -> Value {
- client
- .send(Message::Text(
- serde_json::json!(["REQ", subscription, filter])
- .to_string()
- .into(),
- ))
- .await
- .expect("req send");
- next_json(client).await
-}
-
-pub async fn close_subscription(client: &mut RelayClient, subscription: &str) {
- client
- .send(Message::Text(
- serde_json::json!(["CLOSE", subscription])
- .to_string()
- .into(),
- ))
- .await
- .expect("close send");
-}
-
-pub async fn send_text(client: &mut RelayClient, text: &str) -> Value {
- client
- .send(Message::Text(text.to_owned().into()))
- .await
- .expect("text send");
- next_json(client).await
-}
-
-pub async fn next_json(client: &mut RelayClient) -> Value {
- let message = client
- .next()
- .await
- .expect("websocket message")
- .expect("websocket frame");
- let text = message.into_text().expect("text frame");
- serde_json::from_str(&text).expect("relay JSON")
-}
-
-pub async fn next_label(client: &mut RelayClient) -> String {
- next_json(client).await[0]
- .as_str()
- .expect("label")
- .to_owned()
-}
-
-pub fn assert_ok(message: &Value, accepted: bool) {
- assert_eq!(message[0], "OK");
- assert_eq!(message[2], accepted, "relay OK frame: {message}");
-}
-
-pub fn http_get(port: u16, path: &str) -> String {
- try_http_get(port, path).expect("http get")
-}
-
-pub fn http_get_admin(port: u16, path: &str, admin_pubkey: &str) -> String {
- let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("http connect");
- stream
- .set_read_timeout(Some(Duration::from_secs(2)))
- .expect("read timeout");
- stream
- .set_write_timeout(Some(Duration::from_secs(2)))
- .expect("write timeout");
- write!(
- stream,
- "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/json\r\nx-tangle-admin-pubkey: {admin_pubkey}\r\nConnection: close\r\n\r\n"
- )
- .expect("http get");
- let mut response = String::new();
- stream.read_to_string(&mut response).expect("http read");
- response
-}
-
-pub fn http_post_json(port: u16, path: &str, admin_pubkey: Option<&str>, body: Value) -> String {
- let body = body.to_string();
- let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("http connect");
- stream
- .set_read_timeout(Some(Duration::from_secs(2)))
- .expect("read timeout");
- stream
- .set_write_timeout(Some(Duration::from_secs(2)))
- .expect("write timeout");
- let admin_header = admin_pubkey
- .map(|pubkey| format!("x-tangle-admin-pubkey: {pubkey}\r\n"))
- .unwrap_or_default();
- write!(
- stream,
- "POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{admin_header}Connection: close\r\n\r\n{body}",
- body.len()
- )
- .expect("http post");
- let mut response = String::new();
- stream.read_to_string(&mut response).expect("http read");
- response
-}
-
-pub async fn reopen_store(config: &SurrealConnectionConfig) -> SurrealStore {
- let started = Instant::now();
- loop {
- match SurrealStore::connect_local(config).await {
- Ok(store) => return store,
- Err(error) if started.elapsed() < Duration::from_secs(5) => {
- let _ = error;
- tokio::time::sleep(Duration::from_millis(50)).await;
- }
- Err(error) => panic!("store reopen failed: {error}"),
- }
- }
-}
-
-fn write_runtime_config(
- path: &Path,
- db_path: &Path,
- port: u16,
- namespace: &str,
- policy: Value,
- runtime_limits: Value,
-) {
- let config = serde_json::json!({
- "server": {
- "listen_addr": format!("127.0.0.1:{port}"),
- "relay_url": "wss://relay.radroots.test"
- },
- "database": {
- "mode": "rocks_db",
- "path": db_path.to_str().expect("db path"),
- "namespace": namespace,
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- },
- "runtime": runtime_limits
- },
- "policy": policy
- });
- fs::write(
- path,
- serde_json::to_string_pretty(&config).expect("config JSON"),
- )
- .expect("write config");
-}
-
-fn free_port() -> u16 {
- TcpListener::bind("127.0.0.1:0")
- .expect("bind port")
- .local_addr()
- .expect("local addr")
- .port()
-}
-
-fn wait_for_http(port: u16, child: &mut Child) {
- let started = Instant::now();
- loop {
- if let Ok(response) = try_http_get(port, "/healthz")
- && response.contains("200 OK")
- {
- return;
- }
- if let Some(status) = child.try_wait().expect("child status") {
- panic!("relay exited before readiness: {status}");
- }
- assert!(
- started.elapsed() < Duration::from_secs(10),
- "relay did not open port {port}"
- );
- std::thread::sleep(Duration::from_millis(50));
- }
-}
-
-fn try_http_get(port: u16, path: &str) -> Result<String, std::io::Error> {
- let mut stream = TcpStream::connect(("127.0.0.1", port))?;
- stream.set_read_timeout(Some(Duration::from_secs(2)))?;
- stream.set_write_timeout(Some(Duration::from_secs(2)))?;
- write!(
- stream,
- "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/nostr+json\r\nConnection: close\r\n\r\n"
- )?;
- let mut response = String::new();
- stream.read_to_string(&mut response)?;
- Ok(response)
-}
-
-fn stop_relay(relay: Child) {
- let _ = stop_relay_with_stderr(relay);
-}
-
-fn stop_relay_with_stderr(mut relay: Child) -> String {
- stop_child(&mut relay);
- let output = relay.wait_with_output().expect("relay exit");
- assert!(output.status.success());
- String::from_utf8_lossy(&output.stderr).to_string()
-}
-
-#[cfg(unix)]
-fn stop_child(relay: &mut Child) {
- let status = Command::new("kill")
- .args(["-INT", &relay.id().to_string()])
- .status()
- .expect("send interrupt");
- assert!(status.success());
-}
-
-#[cfg(not(unix))]
-fn stop_child(relay: &mut Child) {
- relay.kill().expect("kill relay");
-}
diff --git a/crates/tangle/tests/version.rs b/crates/tangle/tests/version.rs
@@ -1,10 +1,7 @@
#![forbid(unsafe_code)]
use std::process::Command;
-use std::time::{Duration, Instant};
-use tangle_protocol::event_to_value;
-use tangle_store_surreal::{SurrealConnectionConfig, SurrealStore, base_migration_plan};
-use tangle_test_support::{FixtureKey, build_fixture_event, valid_public_listing_spec};
+use tangle_test_support::{FixtureKey, TANGLE_V2_RELAY_SECRET_HEX};
#[test]
fn tangle_version_command_reports_package_version() {
@@ -27,7 +24,7 @@ fn tangle_without_args_reports_usage() {
assert!(output.status.success());
assert_eq!(
String::from_utf8_lossy(&output.stdout),
- "usage:\n tangle [--version]\n tangle migrate --config PATH\n tangle run --config PATH\n tangle event import --config PATH --input PATH\n tangle event export --config PATH --output PATH\n tangle projection rebuild --config PATH\n tangle ops backup --config PATH --output DIR\n tangle ops restore --config PATH --input DIR\n"
+ "usage:\n tangle [--version]\n tangle run --config PATH\n"
);
assert!(output.stderr.is_empty());
}
@@ -43,386 +40,110 @@ fn tangle_unknown_arg_reports_usage_error() {
assert!(output.stdout.is_empty());
assert_eq!(
String::from_utf8_lossy(&output.stderr),
- "unknown command: --unknown\nusage:\n tangle [--version]\n tangle migrate --config PATH\n tangle run --config PATH\n tangle event import --config PATH --input PATH\n tangle event export --config PATH --output PATH\n tangle projection rebuild --config PATH\n tangle ops backup --config PATH --output DIR\n tangle ops restore --config PATH --input DIR\n"
+ "unknown command: --unknown\nusage:\n tangle [--version]\n tangle run --config PATH\n"
);
}
#[test]
-fn tangle_runtime_commands_report_config_load_failures() {
- let root =
- std::env::temp_dir().join(format!("tangle-cli-config-errors-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&root);
- std::fs::create_dir_all(&root).expect("runtime root");
- let config_path = root.join("missing-runtime.json");
- let output_path = root.join("output");
- let cases: Vec<(Vec<&str>, Option<&str>)> = vec![
- (vec!["run", "--config"], None),
- (vec!["event", "import", "--config"], Some("--input")),
- (vec!["event", "export", "--config"], Some("--output")),
- (vec!["ops", "backup", "--config"], Some("--output")),
- (vec!["ops", "restore", "--config"], Some("--input")),
- ];
-
- for (args, path_option) in cases {
- let mut command = Command::new(env!("CARGO_BIN_EXE_tangle"));
- command.args(&args).arg(&config_path);
- if let Some(path_option) = path_option {
- command.arg(path_option).arg(&output_path);
- }
- let output = command.output().expect("run tangle command");
+fn tangle_removed_commands_are_not_accepted() {
+ for args in [
+ vec!["migrate"],
+ vec!["event", "import"],
+ vec!["event", "export"],
+ vec!["projection", "rebuild"],
+ vec!["ops", "backup"],
+ vec!["ops", "restore"],
+ ] {
+ let output = Command::new(env!("CARGO_BIN_EXE_tangle"))
+ .args(args)
+ .output()
+ .expect("run tangle removed command");
assert_eq!(output.status.code(), Some(2));
assert!(output.stdout.is_empty());
- let stderr = String::from_utf8_lossy(&output.stderr);
- assert!(stderr.starts_with("Read: failed to read runtime config `"));
- assert!(stderr.contains("missing-runtime.json"));
+ assert!(String::from_utf8_lossy(&output.stderr).contains("unknown command"));
}
+}
- std::fs::remove_dir_all(&root).expect("remove runtime root");
+#[test]
+fn tangle_run_reports_missing_config() {
+ let output = Command::new(env!("CARGO_BIN_EXE_tangle"))
+ .args(["run"])
+ .output()
+ .expect("run tangle without config");
+
+ assert_eq!(output.status.code(), Some(2));
+ assert!(output.stdout.is_empty());
+ assert_eq!(
+ String::from_utf8_lossy(&output.stderr),
+ "--config requires a value\n"
+ );
}
#[test]
-fn tangle_migrate_command_applies_configured_migrations() {
- let path = std::env::temp_dir().join(format!("tangle-cli-migrate-{}.json", std::process::id()));
+fn tangle_run_smoke_opens_v2_config() {
+ let root = std::env::temp_dir().join(format!("tangle-cli-run-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(&root).expect("runtime root");
+ let data_dir = root.join("pocket");
+ let config_path = root.join("runtime.json");
std::fs::write(
- &path,
- r#"{
+ &config_path,
+ serde_json::json!({
"server": {
- "listen_addr": "127.0.0.1:7400",
- "relay_url": "ws://127.0.0.1:7400"
+ "listen_addr": "127.0.0.1:0",
+ "relay_url": "wss://relay.radroots.test"
},
- "database": {
- "mode": "memory",
- "namespace": "tangle_cli_migrate",
- "database": "relay"
+ "pocket": {
+ "data_directory": data_dir,
+ "map_size_bytes": 10485760,
+ "reader_slots": 32,
+ "sync_policy": "flush_on_shutdown"
+ },
+ "groups": {
+ "enabled": true,
+ "canonical_relay_url": "wss://relay.radroots.test",
+ "relay_secret": TANGLE_V2_RELAY_SECRET_HEX,
+ "owner_pubkeys": [FixtureKey::Owner.public_key().as_str()],
+ "admin_pubkeys": [FixtureKey::Admin.public_key().as_str()],
+ "redaction": {
+ "redact_private_tags": true,
+ "redact_invite_codes": true
+ },
+ "limits": {
+ "max_group_id_bytes": 128,
+ "max_group_tags_per_event": 8,
+ "max_supported_kinds": 512,
+ "max_member_list_pubkeys": 100000,
+ "max_outbox_replay_batch": 1000
+ }
},
"auth": {
"challenge_ttl_seconds": 300
},
"limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
+ "max_pending_events": 1024
}
- }"#,
+ })
+ .to_string(),
)
.expect("write config");
let output = Command::new(env!("CARGO_BIN_EXE_tangle"))
- .args(["migrate", "--config"])
- .arg(&path)
+ .args(["run", "--config"])
+ .arg(&config_path)
.output()
- .expect("run tangle migrate");
- std::fs::remove_file(&path).expect("remove config");
+ .expect("run tangle");
assert!(output.status.success());
- let migration_count = base_migration_plan().migrations().len();
assert_eq!(
String::from_utf8_lossy(&output.stdout),
format!(
- "migrations applied: {migration_count}\nmigrations already applied: 0\nmigrations total: {migration_count}\n"
+ "relay url: wss://relay.radroots.test\npocket data directory: {}\ngroups enabled: true\nreadiness: ready\n",
+ data_dir.display()
)
);
assert!(output.stderr.is_empty());
-}
-
-#[tokio::test]
-async fn tangle_event_import_command_imports_canonical_jsonl() {
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let root = std::env::temp_dir().join(format!(
- "tangle-cli-import-{}-{}",
- std::process::id(),
- &listing.id().as_str()[..8]
- ));
- let _ = std::fs::remove_dir_all(&root);
- let db_path = root.join("db");
- let config_path = root.join("runtime.json");
- let input_path = root.join("events.jsonl");
- let output_path = root.join("exported.jsonl");
- let backup_path = root.join("backup");
- let restore_db_path = root.join("restore-db");
- let restore_config_path = root.join("restore-runtime.json");
- std::fs::create_dir_all(&root).expect("runtime root");
- write_rocksdb_config(&config_path, &db_path, "tangle_cli_import");
- std::fs::write(&input_path, format!("{}\n", event_to_value(&listing)))
- .expect("write import file");
-
- let first = Command::new(env!("CARGO_BIN_EXE_tangle"))
- .args(["event", "import", "--config"])
- .arg(&config_path)
- .args(["--input"])
- .arg(&input_path)
- .output()
- .expect("run tangle event import");
-
- assert!(first.status.success());
- assert_eq!(
- String::from_utf8_lossy(&first.stdout),
- "events total: 1\nevents inserted: 1\nevents duplicate: 0\nevents projected: 1\nevents skipped: 0\n"
- );
- assert!(first.stderr.is_empty());
-
- let second = Command::new(env!("CARGO_BIN_EXE_tangle"))
- .args(["event", "import", "--config"])
- .arg(&config_path)
- .args(["--input"])
- .arg(&input_path)
- .output()
- .expect("rerun tangle event import");
-
- assert!(second.status.success());
- assert_eq!(
- String::from_utf8_lossy(&second.stdout),
- "events total: 1\nevents inserted: 0\nevents duplicate: 1\nevents projected: 0\nevents skipped: 0\n"
- );
- assert!(second.stderr.is_empty());
-
- let export = Command::new(env!("CARGO_BIN_EXE_tangle"))
- .args(["event", "export", "--config"])
- .arg(&config_path)
- .args(["--output"])
- .arg(&output_path)
- .output()
- .expect("run tangle event export");
-
- assert!(export.status.success());
- assert_eq!(
- String::from_utf8_lossy(&export.stdout),
- "events exported: 1\n"
- );
- assert!(export.stderr.is_empty());
- assert_eq!(
- std::fs::read_to_string(&output_path).expect("export file"),
- format!("{}\n", event_to_value(&listing))
- );
-
- let backup = Command::new(env!("CARGO_BIN_EXE_tangle"))
- .args(["ops", "backup", "--config"])
- .arg(&config_path)
- .args(["--output"])
- .arg(&backup_path)
- .output()
- .expect("run tangle ops backup");
-
- assert!(backup.status.success());
- assert!(backup.stderr.is_empty());
- let backup_stdout = String::from_utf8_lossy(&backup.stdout);
- assert!(backup_stdout.starts_with(&format!(
- "backup directory: {}\nraw events: 1\nraw events sha256: ",
- backup_path.display()
- )));
- assert!(backup_stdout.contains(&format!(
- "\nsurrealdb export available: false\nmanifest: {}\nmanifest sha256: ",
- backup_path.join("manifest.json").display()
- )));
- assert_eq!(
- std::fs::read_to_string(backup_path.join("raw-events.jsonl")).expect("backup raw events"),
- format!("{}\n", event_to_value(&listing))
- );
- let manifest: serde_json::Value = serde_json::from_str(
- &std::fs::read_to_string(backup_path.join("manifest.json")).expect("backup manifest"),
- )
- .expect("manifest JSON");
- assert_eq!(manifest["format"], "tangle-backup-v1");
- assert_eq!(manifest["database"]["namespace"], "tangle_cli_import");
- assert_eq!(manifest["database"]["database"], "relay");
- assert_eq!(manifest["raw_events"]["path"], "raw-events.jsonl");
- assert_eq!(manifest["raw_events"]["count"], 1);
- assert_eq!(
- manifest["raw_events"]["sha256"]
- .as_str()
- .expect("raw sha")
- .len(),
- 64
- );
- assert_eq!(manifest["surrealdb_export"]["available"], false);
- assert!(manifest["surrealdb_export"]["path"].is_null());
- assert!(manifest["surrealdb_export"]["sha256"].is_null());
-
- write_rocksdb_config(&restore_config_path, &restore_db_path, "tangle_cli_restore");
- let restore = Command::new(env!("CARGO_BIN_EXE_tangle"))
- .args(["ops", "restore", "--config"])
- .arg(&restore_config_path)
- .args(["--input"])
- .arg(&backup_path)
- .output()
- .expect("run tangle ops restore");
- assert!(restore.status.success());
- assert!(restore.stderr.is_empty());
- let restore_stdout = String::from_utf8_lossy(&restore.stdout);
- assert!(restore_stdout.starts_with(&format!(
- "restore directory: {}\nraw events: 1\nraw events sha256: ",
- backup_path.display()
- )));
- assert!(restore_stdout.contains(
- "\nevents inserted: 1\nevents duplicate: 0\nevents rebuilt: 1\nlistings projected: 1\nevents skipped: 0\n"
- ));
-
- let rebuild = Command::new(env!("CARGO_BIN_EXE_tangle"))
- .args(["projection", "rebuild", "--config"])
- .arg(&config_path)
- .output()
- .expect("run tangle projection rebuild");
-
- assert!(rebuild.status.success());
- assert_eq!(
- String::from_utf8_lossy(&rebuild.stdout),
- "events scanned: 1\nevents rebuilt: 1\nlistings projected: 1\nevents skipped: 0\n"
- );
- assert!(rebuild.stderr.is_empty());
-
- let second_rebuild = Command::new(env!("CARGO_BIN_EXE_tangle"))
- .args(["projection", "rebuild", "--config"])
- .arg(&config_path)
- .output()
- .expect("rerun tangle projection rebuild");
-
- assert!(second_rebuild.status.success());
- assert_eq!(
- String::from_utf8_lossy(&second_rebuild.stdout),
- "events scanned: 1\nevents rebuilt: 1\nlistings projected: 1\nevents skipped: 0\n"
- );
- assert!(second_rebuild.stderr.is_empty());
-
- let seller = FixtureKey::Seller.public_key();
- let listing_key = format!("30402:{}:listing-a", seller.as_str());
- let restore_store_config = SurrealConnectionConfig::rocksdb(
- restore_db_path.to_str().expect("restore db path"),
- "tangle_cli_restore",
- "relay",
- )
- .expect("restore store config");
- let restore_store = reopen_store(&restore_store_config).await;
- assert!(
- restore_store
- .raw_event_row(listing.id())
- .await
- .expect("restore raw row")
- .is_some()
- );
- assert!(
- restore_store
- .listing_current_row(&listing_key)
- .await
- .expect("restore listing row")
- .is_some()
- );
- assert!(
- restore_store
- .search_document_row(&listing_key)
- .await
- .expect("restore search row")
- .is_some()
- );
- drop(restore_store);
-
- let store_config = SurrealConnectionConfig::rocksdb(
- db_path.to_str().expect("db path"),
- "tangle_cli_import",
- "relay",
- )
- .expect("store config");
- let store = reopen_store(&store_config).await;
- assert!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("raw row")
- .is_some()
- );
- assert!(
- store
- .listing_current_row(&listing_key)
- .await
- .expect("listing row")
- .is_some()
- );
- assert!(
- store
- .search_document_row(&listing_key)
- .await
- .expect("search row")
- .is_some()
- );
-
- drop(store);
std::fs::remove_dir_all(&root).expect("remove runtime root");
}
-
-#[test]
-fn tangle_migrate_requires_config_path() {
- let output = Command::new(env!("CARGO_BIN_EXE_tangle"))
- .arg("migrate")
- .output()
- .expect("run tangle migrate without config");
-
- assert_eq!(output.status.code(), Some(2));
- assert!(output.stdout.is_empty());
- assert_eq!(
- String::from_utf8_lossy(&output.stderr),
- "--config requires a value\n"
- );
-}
-
-#[test]
-fn tangle_projection_rebuild_requires_config_path() {
- let output = Command::new(env!("CARGO_BIN_EXE_tangle"))
- .args(["projection", "rebuild"])
- .output()
- .expect("run tangle projection rebuild without config");
-
- assert_eq!(output.status.code(), Some(2));
- assert!(output.stdout.is_empty());
- assert_eq!(
- String::from_utf8_lossy(&output.stderr),
- "--config requires a value\n"
- );
-}
-
-fn write_rocksdb_config(path: &std::path::Path, db_path: &std::path::Path, namespace: &str) {
- let config = serde_json::json!({
- "server": {
- "listen_addr": "127.0.0.1:0",
- "relay_url": "wss://relay.radroots.test"
- },
- "database": {
- "mode": "rocks_db",
- "path": db_path.to_str().expect("db path"),
- "namespace": namespace,
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- },
- "policy": {
- "approved_sellers": [FixtureKey::Seller.public_key().as_str()]
- }
- });
- std::fs::write(
- path,
- serde_json::to_string_pretty(&config).expect("config JSON"),
- )
- .expect("write config");
-}
-
-async fn reopen_store(config: &SurrealConnectionConfig) -> SurrealStore {
- let started = Instant::now();
- loop {
- match SurrealStore::connect_local(config).await {
- Ok(store) => return store,
- Err(error) if started.elapsed() < Duration::from_secs(5) => {
- let _ = error;
- tokio::time::sleep(Duration::from_millis(50)).await;
- }
- Err(error) => panic!("store reopen failed: {error}"),
- }
- }
-}
diff --git a/crates/tangle_core/Cargo.toml b/crates/tangle_core/Cargo.toml
@@ -1,21 +0,0 @@
-[package]
-name = "tangle_core"
-version.workspace = true
-edition.workspace = true
-authors.workspace = true
-rust-version.workspace = true
-license.workspace = true
-description = "Transport-independent relay core policy for tangle"
-
-[dependencies]
-serde_json = "1"
-tangle_crypto = { path = "../tangle_crypto" }
-tangle_nips = { path = "../tangle_nips" }
-tangle_protocol = { path = "../tangle_protocol" }
-tangle_store = { path = "../tangle_store" }
-
-[dev-dependencies]
-tangle_test_support = { path = "../tangle_test_support" }
-
-[lints]
-workspace = true
diff --git a/crates/tangle_core/src/lib.rs b/crates/tangle_core/src/lib.rs
@@ -1,6177 +0,0 @@
-#![forbid(unsafe_code)]
-
-use core::fmt;
-use std::collections::{BTreeMap, BTreeSet};
-use tangle_crypto::verify_event_signature;
-use tangle_nips::{
- DeletionRequest, FulfillmentMethod, ListingProjectionEvaluation, ListingUnit, RelayAuthEvent,
- evaluate_listing_projection, parse_deletion_request, parse_nip50_filter_search,
- parse_relay_auth_event,
-};
-use tangle_protocol::{
- Event, EventId, Filter, PublicKeyHex, SubscriptionId, UnixTimestamp, event_to_value,
-};
-use tangle_store::{
- DeletionMarker, DeletionMarkerRepository, ListingProjectionRepository, RawEventRepository,
- RepositoryError, StoreEventOutcome, StoreProjectionOutcome, StoredEvent,
-};
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct RuntimeLimitValues {
- pub max_event_bytes: u64,
- pub max_content_bytes: u64,
- pub max_tags_per_event: u64,
- pub max_tag_values_per_tag: u64,
- pub max_tag_value_bytes: u64,
- pub max_filters_per_subscription: u64,
- pub max_subscriptions_per_connection: u64,
- pub max_search_query_bytes: u64,
- pub max_search_tokens: u64,
- pub max_filter_complexity: u64,
- pub max_future_seconds: u64,
- pub live_event_buffer: u64,
- pub pending_store_events: u64,
-}
-
-impl Default for RuntimeLimitValues {
- fn default() -> Self {
- Self {
- max_event_bytes: 131_072,
- max_content_bytes: 65_536,
- max_tags_per_event: 128,
- max_tag_values_per_tag: 16,
- max_tag_value_bytes: 1_024,
- max_filters_per_subscription: 16,
- max_subscriptions_per_connection: 64,
- max_search_query_bytes: 256,
- max_search_tokens: 16,
- max_filter_complexity: 512,
- max_future_seconds: 900,
- live_event_buffer: 1_024,
- pending_store_events: 4_096,
- }
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct RuntimeLimits {
- values: RuntimeLimitValues,
-}
-
-impl Default for RuntimeLimits {
- fn default() -> Self {
- Self::from_values(RuntimeLimitValues::default()).expect("default runtime limits are valid")
- }
-}
-
-impl RuntimeLimits {
- pub fn from_values(values: RuntimeLimitValues) -> Result<Self, RuntimeLimitConfigError> {
- require_positive("max_event_bytes", values.max_event_bytes)?;
- require_positive("max_content_bytes", values.max_content_bytes)?;
- require_positive("max_tags_per_event", values.max_tags_per_event)?;
- require_positive("max_tag_values_per_tag", values.max_tag_values_per_tag)?;
- require_positive("max_tag_value_bytes", values.max_tag_value_bytes)?;
- require_positive(
- "max_filters_per_subscription",
- values.max_filters_per_subscription,
- )?;
- require_positive(
- "max_subscriptions_per_connection",
- values.max_subscriptions_per_connection,
- )?;
- require_positive("max_search_query_bytes", values.max_search_query_bytes)?;
- require_positive("max_search_tokens", values.max_search_tokens)?;
- require_positive("max_filter_complexity", values.max_filter_complexity)?;
- require_positive("live_event_buffer", values.live_event_buffer)?;
- require_positive("pending_store_events", values.pending_store_events)?;
- if values.max_content_bytes > values.max_event_bytes {
- return Err(RuntimeLimitConfigError::Inconsistent {
- field: "max_content_bytes",
- maximum_field: "max_event_bytes",
- value: values.max_content_bytes,
- maximum: values.max_event_bytes,
- });
- }
- Ok(Self { values })
- }
-
- pub fn values(self) -> RuntimeLimitValues {
- self.values
- }
-
- pub fn max_event_bytes(self) -> u64 {
- self.values.max_event_bytes
- }
-
- pub fn max_content_bytes(self) -> u64 {
- self.values.max_content_bytes
- }
-
- pub fn max_tags_per_event(self) -> u64 {
- self.values.max_tags_per_event
- }
-
- pub fn max_tag_values_per_tag(self) -> u64 {
- self.values.max_tag_values_per_tag
- }
-
- pub fn max_tag_value_bytes(self) -> u64 {
- self.values.max_tag_value_bytes
- }
-
- pub fn max_filters_per_subscription(self) -> u64 {
- self.values.max_filters_per_subscription
- }
-
- pub fn max_subscriptions_per_connection(self) -> u64 {
- self.values.max_subscriptions_per_connection
- }
-
- pub fn max_search_query_bytes(self) -> u64 {
- self.values.max_search_query_bytes
- }
-
- pub fn max_search_tokens(self) -> u64 {
- self.values.max_search_tokens
- }
-
- pub fn max_filter_complexity(self) -> u64 {
- self.values.max_filter_complexity
- }
-
- pub fn max_future_seconds(self) -> u64 {
- self.values.max_future_seconds
- }
-
- pub fn live_event_buffer(self) -> u64 {
- self.values.live_event_buffer
- }
-
- pub fn pending_store_events(self) -> u64 {
- self.values.pending_store_events
- }
-
- pub fn validate_event(&self, event: &Event) -> Result<(), RuntimeLimitViolation> {
- let event_bytes = event_to_value(event).to_string().len() as u64;
- require_within(
- RuntimeLimitKind::EventBytes,
- event_bytes,
- self.values.max_event_bytes,
- )?;
- let content_bytes = event.unsigned().content().len() as u64;
- require_within(
- RuntimeLimitKind::ContentBytes,
- content_bytes,
- self.values.max_content_bytes,
- )?;
- let tag_count = event.unsigned().tags().len() as u64;
- require_within(
- RuntimeLimitKind::TagsPerEvent,
- tag_count,
- self.values.max_tags_per_event,
- )?;
- for tag in event.unsigned().tags() {
- let value_count = tag.values().len() as u64;
- require_within(
- RuntimeLimitKind::TagValuesPerTag,
- value_count,
- self.values.max_tag_values_per_tag,
- )?;
- for value in tag.values() {
- require_within(
- RuntimeLimitKind::TagValueBytes,
- value.len() as u64,
- self.values.max_tag_value_bytes,
- )?;
- }
- }
- Ok(())
- }
-
- pub fn validate_filters(
- &self,
- filter_count: u64,
- complexity: u64,
- ) -> Result<(), RuntimeLimitViolation> {
- require_within(
- RuntimeLimitKind::FiltersPerSubscription,
- filter_count,
- self.values.max_filters_per_subscription,
- )?;
- require_within(
- RuntimeLimitKind::FilterComplexity,
- complexity,
- self.values.max_filter_complexity,
- )
- }
-
- pub fn validate_search_query(&self, query: &str) -> Result<(), RuntimeLimitViolation> {
- require_within(
- RuntimeLimitKind::SearchQueryBytes,
- query.len() as u64,
- self.values.max_search_query_bytes,
- )?;
- require_within(
- RuntimeLimitKind::SearchTokens,
- query.split_whitespace().count() as u64,
- self.values.max_search_tokens,
- )
- }
-
- pub fn validate_subscription_count(
- &self,
- active_subscriptions: u64,
- ) -> Result<(), RuntimeLimitViolation> {
- require_within(
- RuntimeLimitKind::SubscriptionsPerConnection,
- active_subscriptions,
- self.values.max_subscriptions_per_connection,
- )
- }
-
- pub fn validate_event_timestamp(
- &self,
- event: &Event,
- now: UnixTimestamp,
- ) -> Result<(), RuntimeLimitViolation> {
- let created_at = event.unsigned().created_at().as_u64();
- let now = now.as_u64();
- if created_at <= now {
- return Ok(());
- }
- require_within(
- RuntimeLimitKind::FutureSeconds,
- created_at - now,
- self.values.max_future_seconds,
- )
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum RuntimeLimitConfigError {
- Zero {
- field: &'static str,
- },
- Inconsistent {
- field: &'static str,
- maximum_field: &'static str,
- value: u64,
- maximum: u64,
- },
-}
-
-impl fmt::Display for RuntimeLimitConfigError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Zero { field } => write!(formatter, "`{field}` must be greater than zero"),
- Self::Inconsistent {
- field,
- maximum_field,
- value,
- maximum,
- } => write!(
- formatter,
- "`{field}` must not exceed `{maximum_field}` ({value} > {maximum})"
- ),
- }
- }
-}
-
-impl std::error::Error for RuntimeLimitConfigError {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct RuntimeLimitViolation {
- kind: RuntimeLimitKind,
- actual: u64,
- maximum: u64,
-}
-
-impl RuntimeLimitViolation {
- pub fn new(kind: RuntimeLimitKind, actual: u64, maximum: u64) -> Self {
- Self {
- kind,
- actual,
- maximum,
- }
- }
-
- pub fn kind(self) -> RuntimeLimitKind {
- self.kind
- }
-
- pub fn actual(self) -> u64 {
- self.actual
- }
-
- pub fn maximum(self) -> u64 {
- self.maximum
- }
-}
-
-impl fmt::Display for RuntimeLimitViolation {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(
- formatter,
- "{} exceeded: {} > {}",
- self.kind.as_str(),
- self.actual,
- self.maximum
- )
- }
-}
-
-impl std::error::Error for RuntimeLimitViolation {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum RuntimeLimitKind {
- EventBytes,
- ContentBytes,
- TagsPerEvent,
- TagValuesPerTag,
- TagValueBytes,
- FiltersPerSubscription,
- SubscriptionsPerConnection,
- SearchQueryBytes,
- SearchTokens,
- FilterComplexity,
- FutureSeconds,
-}
-
-impl RuntimeLimitKind {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::EventBytes => "event bytes",
- Self::ContentBytes => "content bytes",
- Self::TagsPerEvent => "tags per event",
- Self::TagValuesPerTag => "tag values per tag",
- Self::TagValueBytes => "tag value bytes",
- Self::FiltersPerSubscription => "filters per subscription",
- Self::SubscriptionsPerConnection => "subscriptions per connection",
- Self::SearchQueryBytes => "search query bytes",
- Self::SearchTokens => "search tokens",
- Self::FilterComplexity => "filter complexity",
- Self::FutureSeconds => "future seconds",
- }
- }
-}
-
-impl fmt::Display for RuntimeLimitKind {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct AdmissionPolicy {
- require_write_auth: bool,
- unapproved_seller_action: UnapprovedSellerAction,
- approved_sellers: BTreeSet<PublicKeyHex>,
- blocked_pubkeys: BTreeSet<PublicKeyHex>,
-}
-
-impl Default for AdmissionPolicy {
- fn default() -> Self {
- Self {
- require_write_auth: true,
- unapproved_seller_action: UnapprovedSellerAction::StoreRawOnly,
- approved_sellers: BTreeSet::new(),
- blocked_pubkeys: BTreeSet::new(),
- }
- }
-}
-
-impl AdmissionPolicy {
- pub fn new() -> Self {
- Self::default()
- }
-
- pub fn require_write_auth(&self) -> bool {
- self.require_write_auth
- }
-
- pub fn unapproved_seller_action(&self) -> UnapprovedSellerAction {
- self.unapproved_seller_action
- }
-
- pub fn approved_sellers(&self) -> &BTreeSet<PublicKeyHex> {
- &self.approved_sellers
- }
-
- pub fn blocked_pubkeys(&self) -> &BTreeSet<PublicKeyHex> {
- &self.blocked_pubkeys
- }
-
- pub fn with_write_auth_required(mut self, required: bool) -> Self {
- self.require_write_auth = required;
- self
- }
-
- pub fn with_unapproved_seller_action(mut self, action: UnapprovedSellerAction) -> Self {
- self.unapproved_seller_action = action;
- self
- }
-
- pub fn approve_seller(mut self, pubkey: PublicKeyHex) -> Self {
- self.approved_sellers.insert(pubkey);
- self
- }
-
- pub fn block_pubkey(mut self, pubkey: PublicKeyHex) -> Self {
- self.blocked_pubkeys.insert(pubkey);
- self
- }
-
- pub fn is_seller_approved(&self, pubkey: &PublicKeyHex) -> bool {
- self.approved_sellers.contains(pubkey)
- }
-
- pub fn is_pubkey_blocked(&self, pubkey: &PublicKeyHex) -> bool {
- self.blocked_pubkeys.contains(pubkey)
- }
-
- pub fn admit(&self, event: &AdmissionEvent, context: &AdmissionContext) -> AdmissionDecision {
- if event.kind() == AdmissionEventKind::RelayAuth {
- return AdmissionDecision::Accepted(AdmissionAcceptance::new(
- AdmissionEffect::AuthenticateOnly,
- None,
- ));
- }
- if let Some(rejection) = self.write_auth_rejection(event.author_pubkey(), context) {
- return AdmissionDecision::Rejected(rejection);
- }
- if self.is_pubkey_blocked(event.author_pubkey()) {
- if event.kind() == AdmissionEventKind::PublicListing {
- return AdmissionDecision::Accepted(AdmissionAcceptance::new(
- AdmissionEffect::StoreRawWithoutPublicListingProjection,
- Some(ProjectionExclusionReason::BlockedSeller),
- ));
- }
- return AdmissionDecision::Rejected(AdmissionRejection::new(
- AdmissionRejectionKind::BlockedPubkey,
- "blocked pubkey",
- ));
- }
- if event.kind() == AdmissionEventKind::PublicListing {
- return self.admit_public_listing(event.author_pubkey());
- }
- AdmissionDecision::Accepted(AdmissionAcceptance::new(AdmissionEffect::StoreRaw, None))
- }
-
- fn write_auth_rejection(
- &self,
- author_pubkey: &PublicKeyHex,
- context: &AdmissionContext,
- ) -> Option<AdmissionRejection> {
- if !self.require_write_auth {
- return None;
- }
- match context.authenticated_pubkey() {
- Some(authenticated_pubkey) if authenticated_pubkey == author_pubkey => None,
- Some(_) => Some(AdmissionRejection::new(
- AdmissionRejectionKind::AuthenticatedPubkeyMismatch,
- "authenticated pubkey does not match event author",
- )),
- None => Some(AdmissionRejection::new(
- AdmissionRejectionKind::AuthenticationRequired,
- "write authentication required",
- )),
- }
- }
-
- fn admit_public_listing(&self, seller_pubkey: &PublicKeyHex) -> AdmissionDecision {
- if self.is_seller_approved(seller_pubkey) {
- return AdmissionDecision::Accepted(AdmissionAcceptance::new(
- AdmissionEffect::StoreRawAndProjectPublicListing,
- None,
- ));
- }
- match self.unapproved_seller_action {
- UnapprovedSellerAction::StoreRawOnly => {
- AdmissionDecision::Accepted(AdmissionAcceptance::new(
- AdmissionEffect::StoreRawWithoutPublicListingProjection,
- Some(ProjectionExclusionReason::UnapprovedSeller),
- ))
- }
- UnapprovedSellerAction::RejectWrite => {
- AdmissionDecision::Rejected(AdmissionRejection::new(
- AdmissionRejectionKind::UnapprovedSeller,
- "seller is not approved",
- ))
- }
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct AdmissionContext {
- authenticated_pubkey: Option<PublicKeyHex>,
-}
-
-impl AdmissionContext {
- pub fn unauthenticated() -> Self {
- Self {
- authenticated_pubkey: None,
- }
- }
-
- pub fn authenticated(pubkey: PublicKeyHex) -> Self {
- Self {
- authenticated_pubkey: Some(pubkey),
- }
- }
-
- pub fn authenticated_pubkey(&self) -> Option<&PublicKeyHex> {
- self.authenticated_pubkey.as_ref()
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct AdmissionEvent {
- author_pubkey: PublicKeyHex,
- kind: AdmissionEventKind,
-}
-
-impl AdmissionEvent {
- pub fn new(author_pubkey: PublicKeyHex, kind: AdmissionEventKind) -> Self {
- Self {
- author_pubkey,
- kind,
- }
- }
-
- pub fn author_pubkey(&self) -> &PublicKeyHex {
- &self.author_pubkey
- }
-
- pub fn kind(&self) -> AdmissionEventKind {
- self.kind
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum AdmissionEventKind {
- RelayAuth,
- Write,
- PublicListing,
- DraftListing,
-}
-
-impl AdmissionEventKind {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::RelayAuth => "relay auth",
- Self::Write => "write",
- Self::PublicListing => "public listing",
- Self::DraftListing => "draft listing",
- }
- }
-}
-
-impl fmt::Display for AdmissionEventKind {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum UnapprovedSellerAction {
- StoreRawOnly,
- RejectWrite,
-}
-
-impl UnapprovedSellerAction {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::StoreRawOnly => "store raw only",
- Self::RejectWrite => "reject write",
- }
- }
-}
-
-impl fmt::Display for UnapprovedSellerAction {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum AdmissionDecision {
- Accepted(AdmissionAcceptance),
- Rejected(AdmissionRejection),
-}
-
-impl AdmissionDecision {
- pub fn accepted(&self) -> Option<&AdmissionAcceptance> {
- match self {
- Self::Accepted(acceptance) => Some(acceptance),
- Self::Rejected(_) => None,
- }
- }
-
- pub fn rejection(&self) -> Option<&AdmissionRejection> {
- match self {
- Self::Accepted(_) => None,
- Self::Rejected(rejection) => Some(rejection),
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct AdmissionAcceptance {
- effect: AdmissionEffect,
- projection_exclusion: Option<ProjectionExclusionReason>,
-}
-
-impl AdmissionAcceptance {
- pub fn new(
- effect: AdmissionEffect,
- projection_exclusion: Option<ProjectionExclusionReason>,
- ) -> Self {
- Self {
- effect,
- projection_exclusion,
- }
- }
-
- pub fn effect(&self) -> AdmissionEffect {
- self.effect
- }
-
- pub fn projection_exclusion(&self) -> Option<ProjectionExclusionReason> {
- self.projection_exclusion
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum AdmissionEffect {
- AuthenticateOnly,
- StoreRaw,
- StoreRawAndProjectPublicListing,
- StoreRawWithoutPublicListingProjection,
-}
-
-impl AdmissionEffect {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::AuthenticateOnly => "authenticate only",
- Self::StoreRaw => "store raw",
- Self::StoreRawAndProjectPublicListing => "store raw and project public listing",
- Self::StoreRawWithoutPublicListingProjection => {
- "store raw without public listing projection"
- }
- }
- }
-}
-
-impl fmt::Display for AdmissionEffect {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum ProjectionExclusionReason {
- UnapprovedSeller,
- BlockedSeller,
-}
-
-impl ProjectionExclusionReason {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::UnapprovedSeller => "unapproved seller",
- Self::BlockedSeller => "blocked seller",
- }
- }
-}
-
-impl fmt::Display for ProjectionExclusionReason {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct AdmissionRejection {
- kind: AdmissionRejectionKind,
- message: String,
-}
-
-impl AdmissionRejection {
- pub fn new(kind: AdmissionRejectionKind, message: &str) -> Self {
- Self {
- kind,
- message: message.to_owned(),
- }
- }
-
- pub fn kind(&self) -> AdmissionRejectionKind {
- self.kind
- }
-
- pub fn message(&self) -> &str {
- &self.message
- }
-}
-
-impl fmt::Display for AdmissionRejection {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(formatter, "{}: {}", self.kind, self.message)
- }
-}
-
-impl std::error::Error for AdmissionRejection {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum AdmissionRejectionKind {
- AuthenticationRequired,
- AuthenticatedPubkeyMismatch,
- BlockedPubkey,
- UnapprovedSeller,
-}
-
-impl AdmissionRejectionKind {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::AuthenticationRequired => "authentication required",
- Self::AuthenticatedPubkeyMismatch => "authenticated pubkey mismatch",
- Self::BlockedPubkey => "blocked pubkey",
- Self::UnapprovedSeller => "unapproved seller",
- }
- }
-}
-
-impl fmt::Display for AdmissionRejectionKind {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EventValidator {
- limits: RuntimeLimits,
- admission_policy: AdmissionPolicy,
-}
-
-impl EventValidator {
- pub fn new(limits: RuntimeLimits, admission_policy: AdmissionPolicy) -> Self {
- Self {
- limits,
- admission_policy,
- }
- }
-
- pub fn limits(&self) -> RuntimeLimits {
- self.limits
- }
-
- pub fn admission_policy(&self) -> &AdmissionPolicy {
- &self.admission_policy
- }
-
- pub fn validate(
- &self,
- event: &Event,
- context: &AdmissionContext,
- now: UnixTimestamp,
- ) -> Result<ValidatedEvent, EventValidationRejection> {
- self.limits
- .validate_event(event)
- .map_err(EventValidationRejection::RuntimeLimit)?;
- self.limits
- .validate_event_timestamp(event, now)
- .map_err(EventValidationRejection::RuntimeLimit)?;
- verify_event_signature(event).map_err(EventValidationRejection::Crypto)?;
- validate_private_commerce_plaintext(event)?;
- let payload = validation_payload(event)?;
- let admission_event =
- AdmissionEvent::new(event.unsigned().pubkey().clone(), payload.admission_kind());
- let admission = match self.admission_policy.admit(&admission_event, context) {
- AdmissionDecision::Accepted(acceptance) => acceptance,
- AdmissionDecision::Rejected(rejection) => {
- return Err(EventValidationRejection::Admission(rejection));
- }
- };
- Ok(ValidatedEvent {
- event_id: event.id().clone(),
- author_pubkey: event.unsigned().pubkey().clone(),
- admission_kind: admission_event.kind(),
- admission,
- payload,
- })
- }
-}
-
-impl Default for EventValidator {
- fn default() -> Self {
- Self::new(RuntimeLimits::default(), AdmissionPolicy::default())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ValidatedEvent {
- event_id: EventId,
- author_pubkey: PublicKeyHex,
- admission_kind: AdmissionEventKind,
- admission: AdmissionAcceptance,
- payload: ValidatedEventPayload,
-}
-
-impl ValidatedEvent {
- pub fn event_id(&self) -> &EventId {
- &self.event_id
- }
-
- pub fn author_pubkey(&self) -> &PublicKeyHex {
- &self.author_pubkey
- }
-
- pub fn admission_kind(&self) -> AdmissionEventKind {
- self.admission_kind
- }
-
- pub fn admission(&self) -> &AdmissionAcceptance {
- &self.admission
- }
-
- pub fn payload(&self) -> &ValidatedEventPayload {
- &self.payload
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum ValidatedEventPayload {
- RelayAuth(Box<RelayAuthEvent>),
- Deletion(Box<DeletionRequest>),
- Listing {
- admission_kind: AdmissionEventKind,
- evaluation: Box<ListingProjectionEvaluation>,
- },
- Other,
-}
-
-impl ValidatedEventPayload {
- pub fn admission_kind(&self) -> AdmissionEventKind {
- match self {
- Self::RelayAuth(_) => AdmissionEventKind::RelayAuth,
- Self::Deletion(_) | Self::Other => AdmissionEventKind::Write,
- Self::Listing { admission_kind, .. } => *admission_kind,
- }
- }
-
- pub fn relay_auth(&self) -> Option<&RelayAuthEvent> {
- match self {
- Self::RelayAuth(event) => Some(event),
- Self::Deletion(_) | Self::Listing { .. } | Self::Other => None,
- }
- }
-
- pub fn deletion_request(&self) -> Option<&DeletionRequest> {
- match self {
- Self::Deletion(request) => Some(request),
- Self::RelayAuth(_) | Self::Listing { .. } | Self::Other => None,
- }
- }
-
- pub fn listing_evaluation(&self) -> Option<&ListingProjectionEvaluation> {
- match self {
- Self::Listing { evaluation, .. } => Some(evaluation),
- Self::RelayAuth(_) | Self::Deletion(_) | Self::Other => None,
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum EventValidationRejection {
- RuntimeLimit(RuntimeLimitViolation),
- Crypto(String),
- Privacy(String),
- Parser(EventParserRejection),
- Admission(AdmissionRejection),
-}
-
-impl EventValidationRejection {
- pub fn kind(&self) -> EventValidationRejectionKind {
- match self {
- Self::RuntimeLimit(_) => EventValidationRejectionKind::RuntimeLimit,
- Self::Crypto(_) => EventValidationRejectionKind::Crypto,
- Self::Privacy(_) => EventValidationRejectionKind::Privacy,
- Self::Parser(_) => EventValidationRejectionKind::Parser,
- Self::Admission(_) => EventValidationRejectionKind::Admission,
- }
- }
-}
-
-impl fmt::Display for EventValidationRejection {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::RuntimeLimit(violation) => write!(formatter, "runtime limit: {violation}"),
- Self::Crypto(message) => write!(formatter, "crypto: {message}"),
- Self::Privacy(field) => write!(
- formatter,
- "privacy: private commerce plaintext field `{field}` is not allowed"
- ),
- Self::Parser(rejection) => write!(formatter, "parser: {rejection}"),
- Self::Admission(rejection) => write!(formatter, "admission: {rejection}"),
- }
- }
-}
-
-impl std::error::Error for EventValidationRejection {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum EventValidationRejectionKind {
- RuntimeLimit,
- Crypto,
- Privacy,
- Parser,
- Admission,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EventParserRejection {
- parser: EventParser,
- message: String,
-}
-
-impl EventParserRejection {
- pub fn new(parser: EventParser, message: String) -> Self {
- Self { parser, message }
- }
-
- pub fn parser(&self) -> EventParser {
- self.parser
- }
-
- pub fn message(&self) -> &str {
- &self.message
- }
-}
-
-impl fmt::Display for EventParserRejection {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(formatter, "{}: {}", self.parser, self.message)
- }
-}
-
-impl std::error::Error for EventParserRejection {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum EventParser {
- RelayAuth,
- Deletion,
-}
-
-impl EventParser {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::RelayAuth => "relay auth",
- Self::Deletion => "deletion",
- }
- }
-}
-
-impl fmt::Display for EventParser {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-fn validate_private_commerce_plaintext(event: &Event) -> Result<(), EventValidationRejection> {
- for tag in event.unsigned().tags() {
- let field = tag.name().as_str().to_owned();
- if private_commerce_plaintext_field(&field) {
- return Err(EventValidationRejection::Privacy(field));
- }
- }
- if let Ok(content) = serde_json::from_str::<serde_json::Value>(event.unsigned().content())
- && let Some(field) = private_commerce_plaintext_json_field(&content)
- {
- return Err(EventValidationRejection::Privacy(field));
- }
- Ok(())
-}
-
-fn private_commerce_plaintext_json_field(value: &serde_json::Value) -> Option<String> {
- match value {
- serde_json::Value::Object(fields) => {
- for (field, value) in fields {
- if private_commerce_plaintext_field(field) {
- return Some(field.clone());
- }
- if let Some(field) = private_commerce_plaintext_json_field(value) {
- return Some(field);
- }
- }
- None
- }
- serde_json::Value::Array(values) => values
- .iter()
- .find_map(private_commerce_plaintext_json_field),
- _ => None,
- }
-}
-
-fn private_commerce_plaintext_field(field: &str) -> bool {
- matches!(
- normalized_privacy_field(field).as_str(),
- "buyercontact"
- | "contact"
- | "deliveryaddress"
- | "dispute"
- | "disputeevidence"
- | "order"
- | "orderid"
- | "ordernote"
- | "payment"
- | "paymentdetails"
- | "phone"
- | "privatenote"
- | "refund"
- | "refunddetails"
- )
-}
-
-fn normalized_privacy_field(field: &str) -> String {
- field
- .chars()
- .filter(|character| character.is_ascii_alphanumeric())
- .flat_map(char::to_lowercase)
- .collect()
-}
-
-fn validation_payload(event: &Event) -> Result<ValidatedEventPayload, EventValidationRejection> {
- if event.unsigned().kind().as_u32() == 22_242 {
- let auth = parse_relay_auth_event(event)
- .map_err(|message| {
- EventValidationRejection::Parser(EventParserRejection::new(
- EventParser::RelayAuth,
- message,
- ))
- })?
- .expect("relay auth kind must parse as relay auth");
- return Ok(ValidatedEventPayload::RelayAuth(Box::new(auth)));
- }
- if event.unsigned().kind().as_u32() == 5 {
- let deletion = parse_deletion_request(event)
- .map_err(|message| {
- EventValidationRejection::Parser(EventParserRejection::new(
- EventParser::Deletion,
- message,
- ))
- })?
- .expect("deletion kind must parse as deletion request");
- return Ok(ValidatedEventPayload::Deletion(Box::new(deletion)));
- }
- match event.unsigned().kind().as_u32() {
- 30_402 => Ok(ValidatedEventPayload::Listing {
- admission_kind: AdmissionEventKind::PublicListing,
- evaluation: Box::new(evaluate_listing_projection(event)),
- }),
- 30_403 => Ok(ValidatedEventPayload::Listing {
- admission_kind: AdmissionEventKind::DraftListing,
- evaluation: Box::new(evaluate_listing_projection(event)),
- }),
- _ => Ok(ValidatedEventPayload::Other),
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EventIngestor {
- validator: EventValidator,
-}
-
-impl EventIngestor {
- pub fn new(validator: EventValidator) -> Self {
- Self { validator }
- }
-
- pub fn validator(&self) -> &EventValidator {
- &self.validator
- }
-
- pub fn ingest<R>(
- &self,
- repository: &mut R,
- event: Event,
- context: &AdmissionContext,
- received_at: UnixTimestamp,
- now: UnixTimestamp,
- ) -> Result<EventIngestion, EventIngestionRejection>
- where
- R: RawEventRepository + ListingProjectionRepository + DeletionMarkerRepository,
- {
- let validated = self
- .validator
- .validate(&event, context, now)
- .map_err(EventIngestionRejection::Validation)?;
- if validated.admission().effect() == AdmissionEffect::AuthenticateOnly {
- return Ok(EventIngestion::new(
- validated.event_id().clone(),
- EventIngestionEffect::Authenticated,
- None,
- None,
- 0,
- ));
- }
- if event.unsigned().kind().is_ephemeral() {
- return Ok(EventIngestion::new(
- validated.event_id().clone(),
- EventIngestionEffect::EphemeralAccepted,
- None,
- None,
- 0,
- ));
- }
- let raw_outcome = repository
- .put_event(StoredEvent::new(event.clone(), received_at))
- .map_err(EventIngestionRejection::Repository)?;
- if raw_outcome == StoreEventOutcome::Duplicate {
- return Ok(EventIngestion::new(
- validated.event_id().clone(),
- EventIngestionEffect::Duplicate,
- Some(raw_outcome),
- None,
- 0,
- ));
- }
- let projection_outcome = ingest_projection(repository, &validated)?;
- let deletion_marker_count = ingest_deletion_markers(repository, &validated, &event)?;
- Ok(EventIngestion::new(
- validated.event_id().clone(),
- EventIngestionEffect::Stored,
- Some(raw_outcome),
- projection_outcome,
- deletion_marker_count,
- ))
- }
-}
-
-impl Default for EventIngestor {
- fn default() -> Self {
- Self::new(EventValidator::default())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EventIngestion {
- event_id: EventId,
- effect: EventIngestionEffect,
- raw_event_outcome: Option<StoreEventOutcome>,
- projection_outcome: Option<StoreProjectionOutcome>,
- deletion_marker_count: usize,
-}
-
-impl EventIngestion {
- pub fn new(
- event_id: EventId,
- effect: EventIngestionEffect,
- raw_event_outcome: Option<StoreEventOutcome>,
- projection_outcome: Option<StoreProjectionOutcome>,
- deletion_marker_count: usize,
- ) -> Self {
- Self {
- event_id,
- effect,
- raw_event_outcome,
- projection_outcome,
- deletion_marker_count,
- }
- }
-
- pub fn event_id(&self) -> &EventId {
- &self.event_id
- }
-
- pub fn effect(&self) -> EventIngestionEffect {
- self.effect
- }
-
- pub fn raw_event_outcome(&self) -> Option<StoreEventOutcome> {
- self.raw_event_outcome
- }
-
- pub fn projection_outcome(&self) -> Option<StoreProjectionOutcome> {
- self.projection_outcome
- }
-
- pub fn deletion_marker_count(&self) -> usize {
- self.deletion_marker_count
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum EventIngestionEffect {
- Authenticated,
- EphemeralAccepted,
- Stored,
- Duplicate,
-}
-
-impl EventIngestionEffect {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::Authenticated => "authenticated",
- Self::EphemeralAccepted => "ephemeral accepted",
- Self::Stored => "stored",
- Self::Duplicate => "duplicate",
- }
- }
-}
-
-impl fmt::Display for EventIngestionEffect {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum EventIngestionRejection {
- Validation(EventValidationRejection),
- Repository(RepositoryError),
-}
-
-impl EventIngestionRejection {
- pub fn kind(&self) -> EventIngestionRejectionKind {
- match self {
- Self::Validation(_) => EventIngestionRejectionKind::Validation,
- Self::Repository(_) => EventIngestionRejectionKind::Repository,
- }
- }
-}
-
-impl fmt::Display for EventIngestionRejection {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Validation(rejection) => write!(formatter, "validation: {rejection}"),
- Self::Repository(rejection) => write!(formatter, "repository: {rejection}"),
- }
- }
-}
-
-impl std::error::Error for EventIngestionRejection {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum EventIngestionRejectionKind {
- Validation,
- Repository,
-}
-
-fn ingest_projection<R>(
- repository: &mut R,
- validated: &ValidatedEvent,
-) -> Result<Option<StoreProjectionOutcome>, EventIngestionRejection>
-where
- R: ListingProjectionRepository,
-{
- if validated.admission().effect() != AdmissionEffect::StoreRawAndProjectPublicListing {
- return Ok(None);
- }
- let Some(ListingProjectionEvaluation::Eligible(projection)) =
- validated.payload().listing_evaluation()
- else {
- return Ok(None);
- };
- repository
- .put_listing_projection(projection.as_ref().clone())
- .map(Some)
- .map_err(EventIngestionRejection::Repository)
-}
-
-fn ingest_deletion_markers<R>(
- repository: &mut R,
- validated: &ValidatedEvent,
- event: &Event,
-) -> Result<usize, EventIngestionRejection>
-where
- R: DeletionMarkerRepository,
-{
- let Some(request) = validated.payload().deletion_request() else {
- return Ok(0);
- };
- for target in request.targets() {
- repository
- .put_deletion_marker(DeletionMarker::new(
- request.event_id().clone(),
- event.unsigned().pubkey().clone(),
- target.clone(),
- event.unsigned().created_at(),
- ))
- .map_err(EventIngestionRejection::Repository)?;
- }
- Ok(request.targets().len())
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct QueryPlan {
- source: QuerySource,
- mode: QueryExecutionMode,
- sort: QuerySort,
- branches: Vec<QueryPlanBranch>,
-}
-
-impl QueryPlan {
- pub fn new(
- source: QuerySource,
- mode: QueryExecutionMode,
- sort: QuerySort,
- branches: Vec<QueryPlanBranch>,
- ) -> Result<Self, QueryPlanError> {
- if branches.is_empty() {
- return Err(QueryPlanError::EmptyBranches);
- }
- Ok(Self {
- source,
- mode,
- sort,
- branches,
- })
- }
-
- pub fn source(&self) -> QuerySource {
- self.source
- }
-
- pub fn mode(&self) -> QueryExecutionMode {
- self.mode
- }
-
- pub fn sort(&self) -> QuerySort {
- self.sort
- }
-
- pub fn branches(&self) -> &[QueryPlanBranch] {
- &self.branches
- }
-
- pub fn requires_historical_query(&self) -> bool {
- self.mode != QueryExecutionMode::Live
- && self.branches.iter().any(|branch| branch.limit() != Some(0))
- }
-
- pub fn subscribes_to_live_events(&self) -> bool {
- self.mode != QueryExecutionMode::Historical
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct QueryPlanBranch {
- ids: Vec<EventId>,
- authors: Vec<PublicKeyHex>,
- kinds: Vec<tangle_protocol::Kind>,
- tag_filters: BTreeMap<char, Vec<String>>,
- since: Option<UnixTimestamp>,
- until: Option<UnixTimestamp>,
- limit: Option<u64>,
- search: Option<QuerySearch>,
-}
-
-impl QueryPlanBranch {
- pub fn from_spec(spec: QueryPlanBranchSpec) -> Result<Self, QueryPlanError> {
- if let (Some(since), Some(until)) = (spec.since, spec.until)
- && since > until
- {
- return Err(QueryPlanError::InvalidTimeRange { since, until });
- }
- let mut tag_filters = BTreeMap::new();
- for filter in spec.tag_filters {
- tag_filters
- .entry(filter.name())
- .or_insert_with(Vec::new)
- .extend(filter.values().iter().cloned());
- }
- for values in tag_filters.values_mut() {
- let unique = values.drain(..).collect::<BTreeSet<_>>();
- values.extend(unique);
- }
- Ok(Self {
- ids: unique_sorted(spec.ids),
- authors: unique_sorted(spec.authors),
- kinds: unique_sorted(spec.kinds),
- tag_filters,
- since: spec.since,
- until: spec.until,
- limit: spec.limit,
- search: spec.search,
- })
- }
-
- pub fn ids(&self) -> &[EventId] {
- &self.ids
- }
-
- pub fn authors(&self) -> &[PublicKeyHex] {
- &self.authors
- }
-
- pub fn kinds(&self) -> &[tangle_protocol::Kind] {
- &self.kinds
- }
-
- pub fn tag_filters(&self) -> &BTreeMap<char, Vec<String>> {
- &self.tag_filters
- }
-
- pub fn since(&self) -> Option<UnixTimestamp> {
- self.since
- }
-
- pub fn until(&self) -> Option<UnixTimestamp> {
- self.until
- }
-
- pub fn limit(&self) -> Option<u64> {
- self.limit
- }
-
- pub fn search(&self) -> Option<&QuerySearch> {
- self.search.as_ref()
- }
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct QueryPlanBranchSpec {
- pub ids: Vec<EventId>,
- pub authors: Vec<PublicKeyHex>,
- pub kinds: Vec<tangle_protocol::Kind>,
- pub tag_filters: Vec<QueryTagFilter>,
- pub since: Option<UnixTimestamp>,
- pub until: Option<UnixTimestamp>,
- pub limit: Option<u64>,
- pub search: Option<QuerySearch>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct QueryTagFilter {
- name: char,
- values: Vec<String>,
-}
-
-impl QueryTagFilter {
- pub fn new(name: char, values: Vec<String>) -> Result<Self, QueryPlanError> {
- if !name.is_ascii_alphabetic() {
- return Err(QueryPlanError::InvalidTagName { name });
- }
- if values.is_empty() {
- return Err(QueryPlanError::EmptyTagValues { name });
- }
- if values.iter().any(String::is_empty) {
- return Err(QueryPlanError::EmptyTagValue { name });
- }
- Ok(Self {
- name,
- values: unique_sorted(values),
- })
- }
-
- pub fn name(&self) -> char {
- self.name
- }
-
- pub fn values(&self) -> &[String] {
- &self.values
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct QuerySearch {
- raw: String,
- terms: Vec<String>,
-}
-
-impl QuerySearch {
- pub fn new(raw: &str, terms: Vec<String>) -> Result<Self, QueryPlanError> {
- let raw = raw.trim();
- if raw.is_empty() || terms.is_empty() || terms.iter().any(String::is_empty) {
- return Err(QueryPlanError::EmptySearch);
- }
- Ok(Self {
- raw: raw.to_owned(),
- terms: unique_sorted(terms),
- })
- }
-
- pub fn raw(&self) -> &str {
- &self.raw
- }
-
- pub fn terms(&self) -> &[String] {
- &self.terms
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum QuerySource {
- RawEvents,
- ListingProjections,
- SearchDocuments,
-}
-
-impl QuerySource {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::RawEvents => "raw events",
- Self::ListingProjections => "listing projections",
- Self::SearchDocuments => "search documents",
- }
- }
-}
-
-impl fmt::Display for QuerySource {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum QueryExecutionMode {
- Historical,
- Live,
- HistoricalThenLive,
-}
-
-impl QueryExecutionMode {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::Historical => "historical",
- Self::Live => "live",
- Self::HistoricalThenLive => "historical then live",
- }
- }
-}
-
-impl fmt::Display for QueryExecutionMode {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum QuerySort {
- CreatedAtDescEventIdAsc,
- ScoreDescCreatedAtDescEventIdAsc,
-}
-
-impl QuerySort {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::CreatedAtDescEventIdAsc => "created_at desc event_id asc",
- Self::ScoreDescCreatedAtDescEventIdAsc => "score desc created_at desc event_id asc",
- }
- }
-}
-
-impl fmt::Display for QuerySort {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum QueryPlanError {
- EmptyBranches,
- InvalidTimeRange {
- since: UnixTimestamp,
- until: UnixTimestamp,
- },
- InvalidTagName {
- name: char,
- },
- EmptyTagValues {
- name: char,
- },
- EmptyTagValue {
- name: char,
- },
- EmptySearch,
-}
-
-impl fmt::Display for QueryPlanError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::EmptyBranches => {
- formatter.write_str("query plan must include at least one branch")
- }
- Self::InvalidTimeRange { since, until } => {
- write!(
- formatter,
- "query time range is invalid: since {since} > until {until}"
- )
- }
- Self::InvalidTagName { name } => {
- write!(
- formatter,
- "tag filter name must be ASCII alphabetic, got `{name}`"
- )
- }
- Self::EmptyTagValues { name } => {
- write!(
- formatter,
- "tag filter `{name}` must include at least one value"
- )
- }
- Self::EmptyTagValue { name } => {
- write!(formatter, "tag filter `{name}` values must not be empty")
- }
- Self::EmptySearch => formatter.write_str("search query must include terms"),
- }
- }
-}
-
-impl std::error::Error for QueryPlanError {}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct MarketplaceQuerySpec {
- pub q: Option<String>,
- pub categories: Vec<String>,
- pub seller: Option<PublicKeyHex>,
- pub statuses: Vec<MarketplaceListingStatus>,
- pub currencies: Vec<String>,
- pub units: Vec<ListingUnit>,
- pub min_price: Option<String>,
- pub max_price: Option<String>,
- pub fulfillment: Vec<FulfillmentMethod>,
- pub delivery_only: Option<bool>,
- pub pickup: Option<bool>,
- pub latitude_microdegrees: Option<i32>,
- pub longitude_microdegrees: Option<i32>,
- pub radius_meters: Option<u64>,
- pub near: Option<String>,
- pub sort: MarketplaceSort,
- pub limit: Option<u64>,
- pub cursor: Option<MarketplaceCursor>,
-}
-
-impl Default for MarketplaceQuerySpec {
- fn default() -> Self {
- Self {
- q: None,
- categories: Vec::new(),
- seller: None,
- statuses: Vec::new(),
- currencies: Vec::new(),
- units: Vec::new(),
- min_price: None,
- max_price: None,
- fulfillment: Vec::new(),
- delivery_only: None,
- pickup: None,
- latitude_microdegrees: None,
- longitude_microdegrees: None,
- radius_meters: None,
- near: None,
- sort: MarketplaceSort::Relevance,
- limit: None,
- cursor: None,
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct MarketplaceQuery {
- pub text: Option<MarketplaceSearchText>,
- pub categories: Vec<String>,
- pub seller: Option<PublicKeyHex>,
- pub statuses: Vec<MarketplaceListingStatus>,
- pub currencies: Vec<String>,
- pub units: Vec<ListingUnit>,
- pub min_price: Option<MarketplaceDecimal>,
- pub max_price: Option<MarketplaceDecimal>,
- pub fulfillment: Vec<FulfillmentMethod>,
- pub delivery_only: Option<bool>,
- pub pickup: Option<bool>,
- pub location: MarketplaceLocationFilter,
- pub sort: MarketplaceSort,
- pub limit: u64,
- pub cursor: Option<MarketplaceCursor>,
-}
-
-impl MarketplaceQuery {
- pub const DEFAULT_LIMIT: u64 = 50;
- pub const MAX_LIMIT: u64 = 100;
-
- pub fn from_spec(
- spec: MarketplaceQuerySpec,
- limits: RuntimeLimits,
- ) -> Result<Self, MarketplaceQueryError> {
- let text = marketplace_search_text(spec.q, limits)?;
- let min_price = spec
- .min_price
- .as_deref()
- .map(|value| MarketplaceDecimal::new("min_price", value))
- .transpose()?;
- let max_price = spec
- .max_price
- .as_deref()
- .map(|value| MarketplaceDecimal::new("max_price", value))
- .transpose()?;
- if let (Some(min_price), Some(max_price)) = (&min_price, &max_price)
- && decimal_greater_than(min_price, max_price)
- {
- return Err(MarketplaceQueryError::new(
- MarketplaceQueryErrorKind::InvalidPriceRange,
- "min_price must not exceed max_price",
- ));
- }
- let location = MarketplaceLocationFilter::from_spec(
- spec.latitude_microdegrees,
- spec.longitude_microdegrees,
- spec.radius_meters,
- spec.near,
- )?;
- if spec.sort == MarketplaceSort::Distance && !location.has_distance_reference() {
- return Err(MarketplaceQueryError::new(
- MarketplaceQueryErrorKind::MissingDistanceReference,
- "distance sort requires a point or near filter",
- ));
- }
- let limit = spec.limit.unwrap_or(Self::DEFAULT_LIMIT);
- if limit == 0 || limit > Self::MAX_LIMIT {
- return Err(MarketplaceQueryError::new(
- MarketplaceQueryErrorKind::LimitOutOfRange,
- format!("limit must be between 1 and {}", Self::MAX_LIMIT),
- ));
- }
- if spec
- .cursor
- .as_ref()
- .is_some_and(|cursor| cursor.sort != spec.sort)
- {
- return Err(MarketplaceQueryError::new(
- MarketplaceQueryErrorKind::CursorSortMismatch,
- "cursor sort must match query sort",
- ));
- }
- Ok(Self {
- text,
- categories: normalized_text_filters("category", spec.categories)?,
- seller: spec.seller,
- statuses: unique_sorted(spec.statuses),
- currencies: normalized_currencies(spec.currencies)?,
- units: unique_listing_units(spec.units),
- min_price,
- max_price,
- fulfillment: unique_sorted(spec.fulfillment),
- delivery_only: spec.delivery_only,
- pickup: spec.pickup,
- location,
- sort: spec.sort,
- limit,
- cursor: spec.cursor,
- })
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct MarketplaceSearchText {
- pub raw: String,
- pub terms: Vec<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct MarketplaceDecimal {
- pub raw: String,
- whole: String,
- fraction: String,
-}
-
-impl MarketplaceDecimal {
- pub fn new(field: &'static str, value: &str) -> Result<Self, MarketplaceQueryError> {
- let raw = value.trim();
- let Some((whole, fraction)) = normalized_decimal_parts(raw) else {
- return Err(MarketplaceQueryError::new(
- MarketplaceQueryErrorKind::InvalidDecimal,
- format!("{field} must be an exact unsigned decimal"),
- ));
- };
- Ok(Self {
- raw: raw.to_owned(),
- whole,
- fraction,
- })
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct MarketplaceLocationFilter {
- pub point: Option<MarketplaceGeoPoint>,
- pub radius_meters: Option<u64>,
- pub near: Option<String>,
-}
-
-impl MarketplaceLocationFilter {
- pub fn from_spec(
- latitude_microdegrees: Option<i32>,
- longitude_microdegrees: Option<i32>,
- radius_meters: Option<u64>,
- near: Option<String>,
- ) -> Result<Self, MarketplaceQueryError> {
- let point = match (latitude_microdegrees, longitude_microdegrees) {
- (Some(latitude_microdegrees), Some(longitude_microdegrees)) => Some(
- MarketplaceGeoPoint::new(latitude_microdegrees, longitude_microdegrees)?,
- ),
- (None, None) => None,
- _ => {
- return Err(MarketplaceQueryError::new(
- MarketplaceQueryErrorKind::InvalidLocation,
- "lat and lon must be provided together",
- ));
- }
- };
- let radius_meters = match radius_meters {
- Some(0) => {
- return Err(MarketplaceQueryError::new(
- MarketplaceQueryErrorKind::InvalidLocation,
- "radius_meters must be greater than zero",
- ));
- }
- Some(_) if point.is_none() => {
- return Err(MarketplaceQueryError::new(
- MarketplaceQueryErrorKind::InvalidLocation,
- "radius_meters requires lat and lon",
- ));
- }
- value => value,
- };
- let near = normalize_optional_text("near", near)?;
- Ok(Self {
- point,
- radius_meters,
- near,
- })
- }
-
- pub fn has_distance_reference(&self) -> bool {
- self.point.is_some() || self.near.is_some()
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct MarketplaceGeoPoint {
- pub latitude_microdegrees: i32,
- pub longitude_microdegrees: i32,
-}
-
-impl MarketplaceGeoPoint {
- pub fn new(
- latitude_microdegrees: i32,
- longitude_microdegrees: i32,
- ) -> Result<Self, MarketplaceQueryError> {
- if !(-90_000_000..=90_000_000).contains(&latitude_microdegrees) {
- return Err(MarketplaceQueryError::new(
- MarketplaceQueryErrorKind::InvalidLocation,
- "lat must be between -90 and 90 degrees",
- ));
- }
- if !(-180_000_000..=180_000_000).contains(&longitude_microdegrees) {
- return Err(MarketplaceQueryError::new(
- MarketplaceQueryErrorKind::InvalidLocation,
- "lon must be between -180 and 180 degrees",
- ));
- }
- Ok(Self {
- latitude_microdegrees,
- longitude_microdegrees,
- })
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct MarketplaceCursorSpec {
- pub version: u16,
- pub sort: MarketplaceSort,
- pub score: Option<i64>,
- pub distance_meters: Option<u64>,
- pub price: Option<String>,
- pub updated_at: UnixTimestamp,
- pub event_id: EventId,
- pub filter_hash: String,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct MarketplaceCursor {
- pub version: u16,
- pub sort: MarketplaceSort,
- pub score: Option<i64>,
- pub distance_meters: Option<u64>,
- pub price: Option<MarketplaceDecimal>,
- pub updated_at: UnixTimestamp,
- pub event_id: EventId,
- pub filter_hash: String,
-}
-
-impl MarketplaceCursor {
- pub fn from_spec(spec: MarketplaceCursorSpec) -> Result<Self, MarketplaceQueryError> {
- if spec.version == 0 {
- return Err(invalid_cursor("cursor version must be greater than zero"));
- }
- let filter_hash = spec.filter_hash.trim();
- if filter_hash.is_empty() {
- return Err(invalid_cursor("cursor filter_hash must not be empty"));
- }
- let price = spec
- .price
- .as_deref()
- .map(|value| MarketplaceDecimal::new("cursor price", value))
- .transpose()?;
- match spec.sort {
- MarketplaceSort::Relevance if spec.score.is_none() => {
- return Err(invalid_cursor("relevance cursor requires score"));
- }
- MarketplaceSort::Distance if spec.distance_meters.is_none() => {
- return Err(invalid_cursor("distance cursor requires distance"));
- }
- MarketplaceSort::PriceAsc | MarketplaceSort::PriceDesc if price.is_none() => {
- return Err(invalid_cursor("price cursor requires price"));
- }
- _ => {}
- }
- Ok(Self {
- version: spec.version,
- sort: spec.sort,
- score: spec.score,
- distance_meters: spec.distance_meters,
- price,
- updated_at: spec.updated_at,
- event_id: spec.event_id,
- filter_hash: filter_hash.to_owned(),
- })
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
-pub enum MarketplaceListingStatus {
- Active,
- Sold,
- Draft,
- Inactive,
- Expired,
- Deleted,
- Hidden,
- Rejected,
-}
-
-impl MarketplaceListingStatus {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::Active => "active",
- Self::Sold => "sold",
- Self::Draft => "draft",
- Self::Inactive => "inactive",
- Self::Expired => "expired",
- Self::Deleted => "deleted",
- Self::Hidden => "hidden",
- Self::Rejected => "rejected",
- }
- }
-}
-
-impl fmt::Display for MarketplaceListingStatus {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum MarketplaceSort {
- Relevance,
- Freshness,
- PriceAsc,
- PriceDesc,
- Distance,
- SellerTrust,
-}
-
-impl MarketplaceSort {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::Relevance => "relevance",
- Self::Freshness => "freshness",
- Self::PriceAsc => "price_asc",
- Self::PriceDesc => "price_desc",
- Self::Distance => "distance",
- Self::SellerTrust => "seller_trust",
- }
- }
-}
-
-impl fmt::Display for MarketplaceSort {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct MarketplaceQueryError {
- kind: MarketplaceQueryErrorKind,
- message: String,
-}
-
-impl MarketplaceQueryError {
- pub fn new(kind: MarketplaceQueryErrorKind, message: impl Into<String>) -> Self {
- Self {
- kind,
- message: message.into(),
- }
- }
-
- pub fn runtime_limit(violation: RuntimeLimitViolation) -> Self {
- Self::new(
- MarketplaceQueryErrorKind::RuntimeLimit,
- format!("runtime limit: {violation}"),
- )
- }
-
- pub fn kind(&self) -> MarketplaceQueryErrorKind {
- self.kind
- }
-
- pub fn message(&self) -> &str {
- &self.message
- }
-}
-
-impl fmt::Display for MarketplaceQueryError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(&self.message)
- }
-}
-
-impl std::error::Error for MarketplaceQueryError {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum MarketplaceQueryErrorKind {
- RuntimeLimit,
- EmptyFilterValue,
- InvalidDecimal,
- InvalidPriceRange,
- InvalidLocation,
- LimitOutOfRange,
- MissingDistanceReference,
- InvalidCursor,
- CursorSortMismatch,
-}
-
-fn marketplace_search_text(
- q: Option<String>,
- limits: RuntimeLimits,
-) -> Result<Option<MarketplaceSearchText>, MarketplaceQueryError> {
- let Some(q) = q else {
- return Ok(None);
- };
- limits
- .validate_search_query(&q)
- .map_err(MarketplaceQueryError::runtime_limit)?;
- let raw = q.trim();
- if raw.is_empty() {
- return Ok(None);
- }
- let terms = unique_sorted(
- raw.split_whitespace()
- .map(|term| term.to_ascii_lowercase())
- .collect(),
- );
- Ok(Some(MarketplaceSearchText {
- raw: raw.to_owned(),
- terms,
- }))
-}
-
-fn normalized_text_filters(
- field: &'static str,
- values: Vec<String>,
-) -> Result<Vec<String>, MarketplaceQueryError> {
- let values = values
- .into_iter()
- .map(|value| normalize_required_text(field, value))
- .collect::<Result<Vec<_>, _>>()?;
- Ok(unique_sorted(values))
-}
-
-fn normalized_currencies(values: Vec<String>) -> Result<Vec<String>, MarketplaceQueryError> {
- let values = values
- .into_iter()
- .map(|value| {
- normalize_required_text("currency", value).map(|value| value.to_ascii_uppercase())
- })
- .collect::<Result<Vec<_>, _>>()?;
- Ok(unique_sorted(values))
-}
-
-fn normalize_required_text(
- field: &'static str,
- value: String,
-) -> Result<String, MarketplaceQueryError> {
- let normalized = value.trim().to_ascii_lowercase();
- if normalized.is_empty() {
- return Err(MarketplaceQueryError::new(
- MarketplaceQueryErrorKind::EmptyFilterValue,
- format!("{field} filter value must not be empty"),
- ));
- }
- Ok(normalized)
-}
-
-fn normalize_optional_text(
- field: &'static str,
- value: Option<String>,
-) -> Result<Option<String>, MarketplaceQueryError> {
- value
- .map(|value| normalize_required_text(field, value))
- .transpose()
-}
-
-fn unique_listing_units(mut values: Vec<ListingUnit>) -> Vec<ListingUnit> {
- values.sort_by_key(|unit| unit.canonical());
- values.dedup();
- values
-}
-
-fn normalized_decimal_parts(value: &str) -> Option<(String, String)> {
- let mut parts = value.split('.');
- let whole = parts.next().unwrap_or_default();
- let fraction = parts.next();
- if parts.next().is_some()
- || whole.is_empty()
- || !whole.bytes().all(|byte| byte.is_ascii_digit())
- {
- return None;
- }
- let fraction = match fraction {
- Some(value) if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) => {
- return None;
- }
- Some(value) => value.trim_end_matches('0').to_owned(),
- None => String::new(),
- };
- let whole = whole.trim_start_matches('0');
- let whole = if whole.is_empty() { "0" } else { whole };
- Some((whole.to_owned(), fraction))
-}
-
-fn decimal_greater_than(left: &MarketplaceDecimal, right: &MarketplaceDecimal) -> bool {
- match left.whole.len().cmp(&right.whole.len()) {
- std::cmp::Ordering::Equal => {}
- ordering => return ordering == std::cmp::Ordering::Greater,
- }
- match left.whole.cmp(&right.whole) {
- std::cmp::Ordering::Equal => {}
- ordering => return ordering == std::cmp::Ordering::Greater,
- }
- left.fraction > right.fraction
-}
-
-fn invalid_cursor(message: &'static str) -> MarketplaceQueryError {
- MarketplaceQueryError::new(MarketplaceQueryErrorKind::InvalidCursor, message)
-}
-
-fn unique_sorted<T>(values: Vec<T>) -> Vec<T>
-where
- T: Ord,
-{
- values
- .into_iter()
- .collect::<BTreeSet<_>>()
- .into_iter()
- .collect()
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct NostrFilterCompiler {
- limits: RuntimeLimits,
-}
-
-impl NostrFilterCompiler {
- pub fn new(limits: RuntimeLimits) -> Self {
- Self { limits }
- }
-
- pub fn limits(self) -> RuntimeLimits {
- self.limits
- }
-
- pub fn compile(
- &self,
- filters: &[Filter],
- mode: QueryExecutionMode,
- ) -> Result<QueryPlan, NostrFilterCompileError> {
- self.limits
- .validate_filters(filters.len() as u64, filter_complexity(filters))
- .map_err(NostrFilterCompileError::RuntimeLimit)?;
- let branches = filters
- .iter()
- .map(|filter| compile_filter_branch(filter, self.limits))
- .collect::<Result<Vec<_>, _>>()?;
- let source = if branches.iter().any(|branch| branch.search().is_some()) {
- QuerySource::SearchDocuments
- } else {
- QuerySource::RawEvents
- };
- let sort = if source == QuerySource::SearchDocuments {
- QuerySort::ScoreDescCreatedAtDescEventIdAsc
- } else {
- QuerySort::CreatedAtDescEventIdAsc
- };
- QueryPlan::new(source, mode, sort, branches).map_err(NostrFilterCompileError::QueryPlan)
- }
-}
-
-impl Default for NostrFilterCompiler {
- fn default() -> Self {
- Self::new(RuntimeLimits::default())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum NostrFilterCompileError {
- RuntimeLimit(RuntimeLimitViolation),
- QueryPlan(QueryPlanError),
-}
-
-impl NostrFilterCompileError {
- pub fn kind(&self) -> NostrFilterCompileErrorKind {
- match self {
- Self::RuntimeLimit(_) => NostrFilterCompileErrorKind::RuntimeLimit,
- Self::QueryPlan(_) => NostrFilterCompileErrorKind::QueryPlan,
- }
- }
-}
-
-impl fmt::Display for NostrFilterCompileError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::RuntimeLimit(violation) => write!(formatter, "runtime limit: {violation}"),
- Self::QueryPlan(error) => write!(formatter, "query plan: {error}"),
- }
- }
-}
-
-impl std::error::Error for NostrFilterCompileError {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum NostrFilterCompileErrorKind {
- RuntimeLimit,
- QueryPlan,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct Nip50QueryCompiler {
- limits: RuntimeLimits,
-}
-
-impl Nip50QueryCompiler {
- pub fn new(limits: RuntimeLimits) -> Self {
- Self { limits }
- }
-
- pub fn limits(self) -> RuntimeLimits {
- self.limits
- }
-
- pub fn compile(
- &self,
- filters: &[Filter],
- mode: QueryExecutionMode,
- ) -> Result<QueryPlan, Nip50QueryCompileError> {
- self.limits
- .validate_filters(filters.len() as u64, filter_complexity(filters))
- .map_err(Nip50QueryCompileError::RuntimeLimit)?;
- let branches = filters
- .iter()
- .map(|filter| compile_nip50_filter_branch(filter, self.limits))
- .collect::<Result<Vec<_>, _>>()?
- .into_iter()
- .flatten()
- .collect::<Vec<_>>();
- if branches.is_empty() {
- return Err(Nip50QueryCompileError::MissingSearchTerms);
- }
- QueryPlan::new(
- QuerySource::SearchDocuments,
- mode,
- QuerySort::ScoreDescCreatedAtDescEventIdAsc,
- branches,
- )
- .map_err(Nip50QueryCompileError::QueryPlan)
- }
-}
-
-impl Default for Nip50QueryCompiler {
- fn default() -> Self {
- Self::new(RuntimeLimits::default())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum Nip50QueryCompileError {
- RuntimeLimit(RuntimeLimitViolation),
- QueryPlan(QueryPlanError),
- MissingSearchTerms,
-}
-
-impl Nip50QueryCompileError {
- pub fn kind(&self) -> Nip50QueryCompileErrorKind {
- match self {
- Self::RuntimeLimit(_) => Nip50QueryCompileErrorKind::RuntimeLimit,
- Self::QueryPlan(_) => Nip50QueryCompileErrorKind::QueryPlan,
- Self::MissingSearchTerms => Nip50QueryCompileErrorKind::MissingSearchTerms,
- }
- }
-}
-
-impl fmt::Display for Nip50QueryCompileError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::RuntimeLimit(violation) => write!(formatter, "runtime limit: {violation}"),
- Self::QueryPlan(error) => write!(formatter, "query plan: {error}"),
- Self::MissingSearchTerms => {
- formatter.write_str("nip50 query must include plain search terms")
- }
- }
- }
-}
-
-impl std::error::Error for Nip50QueryCompileError {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum Nip50QueryCompileErrorKind {
- RuntimeLimit,
- QueryPlan,
- MissingSearchTerms,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct SubscriptionMatcher {
- live_search_policy: LiveSearchPolicy,
-}
-
-impl SubscriptionMatcher {
- pub fn new(live_search_policy: LiveSearchPolicy) -> Self {
- Self { live_search_policy }
- }
-
- pub fn live_search_policy(self) -> LiveSearchPolicy {
- self.live_search_policy
- }
-
- pub fn match_event(&self, plan: &QueryPlan, event: &Event) -> SubscriptionMatch {
- if !plan.subscribes_to_live_events() {
- return SubscriptionMatch::empty();
- }
- let branch_indexes = plan
- .branches()
- .iter()
- .enumerate()
- .filter_map(|(index, branch)| {
- branch_matches_event(branch, event, self.live_search_policy).then_some(index)
- })
- .collect();
- SubscriptionMatch { branch_indexes }
- }
-}
-
-impl Default for SubscriptionMatcher {
- fn default() -> Self {
- Self::new(LiveSearchPolicy::BestEffortTokenMatch)
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum LiveSearchPolicy {
- BestEffortTokenMatch,
- DisabledLiveSearch,
-}
-
-impl LiveSearchPolicy {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::BestEffortTokenMatch => "best_effort_token_match",
- Self::DisabledLiveSearch => "disabled_live_search",
- }
- }
-}
-
-impl fmt::Display for LiveSearchPolicy {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SubscriptionMatch {
- branch_indexes: Vec<usize>,
-}
-
-impl SubscriptionMatch {
- pub fn empty() -> Self {
- Self {
- branch_indexes: Vec::new(),
- }
- }
-
- pub fn matched(&self) -> bool {
- !self.branch_indexes.is_empty()
- }
-
- pub fn branch_indexes(&self) -> &[usize] {
- &self.branch_indexes
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SubscriptionManager {
- limits: RuntimeLimits,
- matcher: SubscriptionMatcher,
- subscriptions: BTreeMap<SubscriptionId, QueryPlan>,
-}
-
-impl SubscriptionManager {
- pub fn new(limits: RuntimeLimits, matcher: SubscriptionMatcher) -> Self {
- Self {
- limits,
- matcher,
- subscriptions: BTreeMap::new(),
- }
- }
-
- pub fn limits(&self) -> RuntimeLimits {
- self.limits
- }
-
- pub fn matcher(&self) -> SubscriptionMatcher {
- self.matcher
- }
-
- pub fn active_count(&self) -> usize {
- self.subscriptions.len()
- }
-
- pub fn plan(&self, subscription_id: &SubscriptionId) -> Option<&QueryPlan> {
- self.subscriptions.get(subscription_id)
- }
-
- pub fn subscribe(
- &mut self,
- subscription_id: SubscriptionId,
- plan: QueryPlan,
- ) -> Result<SubscriptionAddOutcome, SubscriptionManagerError> {
- let replacing = self.subscriptions.contains_key(&subscription_id);
- let active_count = self.subscriptions.len() + usize::from(!replacing);
- self.limits
- .validate_subscription_count(active_count as u64)
- .map_err(SubscriptionManagerError::RuntimeLimit)?;
- let outcome = if replacing {
- SubscriptionAddOutcome::Replaced
- } else {
- SubscriptionAddOutcome::Inserted
- };
- self.subscriptions.insert(subscription_id, plan);
- Ok(outcome)
- }
-
- pub fn close(&mut self, subscription_id: &SubscriptionId) -> SubscriptionCloseOutcome {
- match self.subscriptions.remove(subscription_id) {
- Some(_) => SubscriptionCloseOutcome::Closed,
- None => SubscriptionCloseOutcome::NotFound,
- }
- }
-
- pub fn match_event(&self, event: &Event) -> Vec<SubscriptionEventMatch> {
- self.subscriptions
- .iter()
- .filter_map(|(subscription_id, plan)| {
- let subscription_match = self.matcher.match_event(plan, event);
- subscription_match
- .matched()
- .then(|| SubscriptionEventMatch {
- subscription_id: subscription_id.clone(),
- branch_indexes: subscription_match.branch_indexes().to_vec(),
- })
- })
- .collect()
- }
-}
-
-impl Default for SubscriptionManager {
- fn default() -> Self {
- Self::new(RuntimeLimits::default(), SubscriptionMatcher::default())
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum SubscriptionAddOutcome {
- Inserted,
- Replaced,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum SubscriptionCloseOutcome {
- Closed,
- NotFound,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SubscriptionEventMatch {
- pub subscription_id: SubscriptionId,
- pub branch_indexes: Vec<usize>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum SubscriptionManagerError {
- RuntimeLimit(RuntimeLimitViolation),
-}
-
-impl SubscriptionManagerError {
- pub fn kind(&self) -> SubscriptionManagerErrorKind {
- match self {
- Self::RuntimeLimit(_) => SubscriptionManagerErrorKind::RuntimeLimit,
- }
- }
-}
-
-impl fmt::Display for SubscriptionManagerError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::RuntimeLimit(violation) => write!(formatter, "runtime limit: {violation}"),
- }
- }
-}
-
-impl std::error::Error for SubscriptionManagerError {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum SubscriptionManagerErrorKind {
- RuntimeLimit,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct AuthChallengeState {
- relay_url: String,
- ttl_seconds: u64,
- active_challenge: Option<AuthChallenge>,
- authenticated_pubkey: Option<PublicKeyHex>,
-}
-
-impl AuthChallengeState {
- pub fn new(relay_url: &str, ttl_seconds: u64) -> Result<Self, AuthChallengeStateError> {
- let relay_url = relay_url.trim();
- if relay_url.is_empty() {
- return Err(AuthChallengeStateError::InvalidRelayUrl);
- }
- if ttl_seconds == 0 {
- return Err(AuthChallengeStateError::InvalidTtl);
- }
- Ok(Self {
- relay_url: relay_url.to_owned(),
- ttl_seconds,
- active_challenge: None,
- authenticated_pubkey: None,
- })
- }
-
- pub fn relay_url(&self) -> &str {
- &self.relay_url
- }
-
- pub fn ttl_seconds(&self) -> u64 {
- self.ttl_seconds
- }
-
- pub fn active_challenge(&self) -> Option<&AuthChallenge> {
- self.active_challenge.as_ref()
- }
-
- pub fn authenticated_pubkey(&self) -> Option<&PublicKeyHex> {
- self.authenticated_pubkey.as_ref()
- }
-
- pub fn issue_challenge(
- &mut self,
- challenge: &str,
- issued_at: UnixTimestamp,
- ) -> Result<AuthChallenge, AuthChallengeStateError> {
- let challenge = challenge.trim();
- if challenge.is_empty() {
- return Err(AuthChallengeStateError::EmptyChallenge);
- }
- let challenge = AuthChallenge {
- value: challenge.to_owned(),
- relay_url: self.relay_url.clone(),
- issued_at,
- expires_at: UnixTimestamp::new(issued_at.as_u64().saturating_add(self.ttl_seconds)),
- };
- self.active_challenge = Some(challenge.clone());
- self.authenticated_pubkey = None;
- Ok(challenge)
- }
-
- pub fn authenticate(
- &mut self,
- auth: &RelayAuthEvent,
- now: UnixTimestamp,
- ) -> Result<AuthChallengeAuthentication, AuthChallengeStateError> {
- let challenge = self
- .active_challenge
- .as_ref()
- .ok_or(AuthChallengeStateError::MissingChallenge)?;
- if now > challenge.expires_at {
- return Err(AuthChallengeStateError::Expired {
- expired_at: challenge.expires_at,
- now,
- });
- }
- if auth.relay() != challenge.relay_url {
- return Err(AuthChallengeStateError::RelayMismatch {
- expected: challenge.relay_url.clone(),
- actual: auth.relay().to_owned(),
- });
- }
- if auth.challenge() != challenge.value {
- return Err(AuthChallengeStateError::ChallengeMismatch);
- }
- if auth.created_at() < challenge.issued_at {
- return Err(AuthChallengeStateError::CreatedBeforeChallenge {
- created_at: auth.created_at(),
- issued_at: challenge.issued_at,
- });
- }
- let authentication = AuthChallengeAuthentication {
- pubkey: auth.pubkey().clone(),
- };
- self.authenticated_pubkey = Some(authentication.pubkey.clone());
- self.active_challenge = None;
- Ok(authentication)
- }
-
- pub fn clear_authentication(&mut self) {
- self.authenticated_pubkey = None;
- }
-}
-
-impl Default for AuthChallengeState {
- fn default() -> Self {
- Self::new("wss://relay.radroots.test", 300).expect("default auth challenge state")
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct AuthChallenge {
- pub value: String,
- pub relay_url: String,
- pub issued_at: UnixTimestamp,
- pub expires_at: UnixTimestamp,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct AuthChallengeAuthentication {
- pub pubkey: PublicKeyHex,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum AuthChallengeStateError {
- InvalidRelayUrl,
- InvalidTtl,
- EmptyChallenge,
- MissingChallenge,
- Expired {
- expired_at: UnixTimestamp,
- now: UnixTimestamp,
- },
- RelayMismatch {
- expected: String,
- actual: String,
- },
- ChallengeMismatch,
- CreatedBeforeChallenge {
- created_at: UnixTimestamp,
- issued_at: UnixTimestamp,
- },
-}
-
-impl AuthChallengeStateError {
- pub fn kind(&self) -> AuthChallengeStateErrorKind {
- match self {
- Self::InvalidRelayUrl => AuthChallengeStateErrorKind::InvalidRelayUrl,
- Self::InvalidTtl => AuthChallengeStateErrorKind::InvalidTtl,
- Self::EmptyChallenge => AuthChallengeStateErrorKind::EmptyChallenge,
- Self::MissingChallenge => AuthChallengeStateErrorKind::MissingChallenge,
- Self::Expired { .. } => AuthChallengeStateErrorKind::Expired,
- Self::RelayMismatch { .. } => AuthChallengeStateErrorKind::RelayMismatch,
- Self::ChallengeMismatch => AuthChallengeStateErrorKind::ChallengeMismatch,
- Self::CreatedBeforeChallenge { .. } => {
- AuthChallengeStateErrorKind::CreatedBeforeChallenge
- }
- }
- }
-}
-
-impl fmt::Display for AuthChallengeStateError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::InvalidRelayUrl => formatter.write_str("relay url must not be empty"),
- Self::InvalidTtl => formatter.write_str("auth challenge ttl must be greater than zero"),
- Self::EmptyChallenge => formatter.write_str("auth challenge must not be empty"),
- Self::MissingChallenge => formatter.write_str("auth challenge is missing"),
- Self::Expired { expired_at, now } => {
- write!(
- formatter,
- "auth challenge expired at {expired_at}, now {now}"
- )
- }
- Self::RelayMismatch { expected, actual } => {
- write!(
- formatter,
- "auth relay mismatch: expected {expected}, got {actual}"
- )
- }
- Self::ChallengeMismatch => formatter.write_str("auth challenge mismatch"),
- Self::CreatedBeforeChallenge {
- created_at,
- issued_at,
- } => write!(
- formatter,
- "auth event created_at {created_at} is before challenge issued_at {issued_at}"
- ),
- }
- }
-}
-
-impl std::error::Error for AuthChallengeStateError {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum AuthChallengeStateErrorKind {
- InvalidRelayUrl,
- InvalidTtl,
- EmptyChallenge,
- MissingChallenge,
- Expired,
- RelayMismatch,
- ChallengeMismatch,
- CreatedBeforeChallenge,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct RateLimitConfig {
- pub limit: u64,
- pub window_seconds: u64,
-}
-
-impl RateLimitConfig {
- pub fn new(limit: u64, window_seconds: u64) -> Result<Self, RateLimitConfigError> {
- if limit == 0 {
- return Err(RateLimitConfigError::ZeroLimit);
- }
- if window_seconds == 0 {
- return Err(RateLimitConfigError::ZeroWindowSeconds);
- }
- Ok(Self {
- limit,
- window_seconds,
- })
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum RateLimitConfigError {
- ZeroLimit,
- ZeroWindowSeconds,
-}
-
-impl fmt::Display for RateLimitConfigError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::ZeroLimit => formatter.write_str("rate limit must be greater than zero"),
- Self::ZeroWindowSeconds => {
- formatter.write_str("rate limit window must be greater than zero seconds")
- }
- }
- }
-}
-
-impl std::error::Error for RateLimitConfigError {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum RateLimitDecision {
- Accepted {
- remaining: u64,
- reset_at: UnixTimestamp,
- },
- Rejected {
- retry_after_seconds: u64,
- reset_at: UnixTimestamp,
- },
-}
-
-impl RateLimitDecision {
- pub fn allowed(self) -> bool {
- matches!(self, Self::Accepted { .. })
- }
-
- pub fn remaining(self) -> u64 {
- match self {
- Self::Accepted { remaining, .. } => remaining,
- Self::Rejected { .. } => 0,
- }
- }
-
- pub fn reset_at(self) -> UnixTimestamp {
- match self {
- Self::Accepted { reset_at, .. } | Self::Rejected { reset_at, .. } => reset_at,
- }
- }
-
- pub fn retry_after_seconds(self) -> Option<u64> {
- match self {
- Self::Accepted { .. } => None,
- Self::Rejected {
- retry_after_seconds,
- ..
- } => Some(retry_after_seconds),
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct FixedWindowRateLimiter {
- config: RateLimitConfig,
- windows: BTreeMap<String, RateLimitWindow>,
-}
-
-impl FixedWindowRateLimiter {
- pub fn new(config: RateLimitConfig) -> Self {
- Self {
- config,
- windows: BTreeMap::new(),
- }
- }
-
- pub fn config(&self) -> RateLimitConfig {
- self.config
- }
-
- pub fn tracked_key_count(&self) -> usize {
- self.windows.len()
- }
-
- pub fn check(
- &mut self,
- key: &str,
- now: UnixTimestamp,
- cost: u64,
- ) -> Result<RateLimitDecision, RateLimitError> {
- let key = key.trim();
- if key.is_empty() {
- return Err(RateLimitError::EmptyKey);
- }
- if cost == 0 {
- return Err(RateLimitError::ZeroCost);
- }
- if cost > self.config.limit {
- return Err(RateLimitError::CostExceedsLimit {
- cost,
- limit: self.config.limit,
- });
- }
- let limit = self.config.limit;
- let window_seconds = self.config.window_seconds;
- let window = self
- .windows
- .entry(key.to_owned())
- .and_modify(|window| window.reset_if_elapsed(now, window_seconds))
- .or_insert_with(|| RateLimitWindow::new(now));
- let reset_at = window.reset_at(window_seconds);
- if window.used + cost > limit {
- return Ok(RateLimitDecision::Rejected {
- retry_after_seconds: reset_at.as_u64().saturating_sub(now.as_u64()),
- reset_at,
- });
- }
- window.used += cost;
- Ok(RateLimitDecision::Accepted {
- remaining: limit - window.used,
- reset_at,
- })
- }
-
- pub fn prune_expired(&mut self, now: UnixTimestamp) -> usize {
- let before = self.windows.len();
- let window_seconds = self.config.window_seconds;
- self.windows
- .retain(|_, window| window.reset_at(window_seconds) > now);
- before - self.windows.len()
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-struct RateLimitWindow {
- started_at: UnixTimestamp,
- used: u64,
-}
-
-impl RateLimitWindow {
- fn new(started_at: UnixTimestamp) -> Self {
- Self {
- started_at,
- used: 0,
- }
- }
-
- fn reset_at(self, window_seconds: u64) -> UnixTimestamp {
- UnixTimestamp::new(self.started_at.as_u64().saturating_add(window_seconds))
- }
-
- fn reset_if_elapsed(&mut self, now: UnixTimestamp, window_seconds: u64) {
- if now >= self.reset_at(window_seconds) || now < self.started_at {
- self.started_at = now;
- self.used = 0;
- }
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum RateLimitError {
- EmptyKey,
- ZeroCost,
- CostExceedsLimit { cost: u64, limit: u64 },
-}
-
-impl RateLimitError {
- pub fn kind(self) -> RateLimitErrorKind {
- match self {
- Self::EmptyKey => RateLimitErrorKind::EmptyKey,
- Self::ZeroCost => RateLimitErrorKind::ZeroCost,
- Self::CostExceedsLimit { .. } => RateLimitErrorKind::CostExceedsLimit,
- }
- }
-}
-
-impl fmt::Display for RateLimitError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::EmptyKey => formatter.write_str("rate limit key must not be empty"),
- Self::ZeroCost => formatter.write_str("rate limit cost must be greater than zero"),
- Self::CostExceedsLimit { cost, limit } => {
- write!(formatter, "rate limit cost {cost} exceeds limit {limit}")
- }
- }
- }
-}
-
-impl std::error::Error for RateLimitError {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum RateLimitErrorKind {
- EmptyKey,
- ZeroCost,
- CostExceedsLimit,
-}
-
-fn compile_filter_branch(
- filter: &Filter,
- limits: RuntimeLimits,
-) -> Result<QueryPlanBranch, NostrFilterCompileError> {
- let tag_filters =
- compile_filter_tag_constraints(filter).map_err(NostrFilterCompileError::QueryPlan)?;
- let search = filter
- .search()
- .map(|raw| {
- limits
- .validate_search_query(raw)
- .map_err(NostrFilterCompileError::RuntimeLimit)?;
- QuerySearch::new(
- raw,
- raw.split_whitespace()
- .map(str::to_owned)
- .collect::<Vec<_>>(),
- )
- .map_err(NostrFilterCompileError::QueryPlan)
- })
- .transpose()?;
- QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- ids: filter.ids().to_vec(),
- authors: filter.authors().to_vec(),
- kinds: filter.kinds().to_vec(),
- tag_filters,
- since: filter.since(),
- until: filter.until(),
- limit: filter.limit(),
- search,
- })
- .map_err(NostrFilterCompileError::QueryPlan)
-}
-
-fn compile_nip50_filter_branch(
- filter: &Filter,
- limits: RuntimeLimits,
-) -> Result<Option<QueryPlanBranch>, Nip50QueryCompileError> {
- if let Some(raw) = filter.search() {
- limits
- .validate_search_query(raw)
- .map_err(Nip50QueryCompileError::RuntimeLimit)?;
- }
- let search = match parse_nip50_filter_search(filter)
- .expect("validated protocol filters are valid nip50 parser input")
- {
- Some(search) => search,
- None => return Ok(None),
- };
- let search = QuerySearch::new(search.text(), search.terms().to_vec())
- .expect("nip50 parser only returns search queries with plain terms");
- let tag_filters =
- compile_filter_tag_constraints(filter).map_err(Nip50QueryCompileError::QueryPlan)?;
- QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- ids: filter.ids().to_vec(),
- authors: filter.authors().to_vec(),
- kinds: filter.kinds().to_vec(),
- tag_filters,
- since: filter.since(),
- until: filter.until(),
- limit: filter.limit(),
- search: Some(search),
- })
- .map(Some)
- .map_err(Nip50QueryCompileError::QueryPlan)
-}
-
-fn branch_matches_event(
- branch: &QueryPlanBranch,
- event: &Event,
- live_search_policy: LiveSearchPolicy,
-) -> bool {
- if !branch.ids().is_empty() && !branch.ids().iter().any(|id| id == event.id()) {
- return false;
- }
- if !branch.authors().is_empty()
- && !branch
- .authors()
- .iter()
- .any(|author| author == event.unsigned().pubkey())
- {
- return false;
- }
- if !branch.kinds().is_empty()
- && !branch
- .kinds()
- .iter()
- .any(|kind| *kind == event.unsigned().kind())
- {
- return false;
- }
- if let Some(since) = branch.since()
- && event.unsigned().created_at() < since
- {
- return false;
- }
- if let Some(until) = branch.until()
- && event.unsigned().created_at() > until
- {
- return false;
- }
- for (name, values) in branch.tag_filters() {
- let matched = event.unsigned().tags().iter().any(|tag| {
- tag.indexed_pair().is_some_and(|(tag_name, tag_value)| {
- tag_name == name.to_string() && values.iter().any(|value| value == tag_value)
- })
- });
- if !matched {
- return false;
- }
- }
- match branch.search() {
- Some(search) => live_search_matches(search, event, live_search_policy),
- None => true,
- }
-}
-
-fn live_search_matches(
- search: &QuerySearch,
- event: &Event,
- live_search_policy: LiveSearchPolicy,
-) -> bool {
- match live_search_policy {
- LiveSearchPolicy::DisabledLiveSearch => false,
- LiveSearchPolicy::BestEffortTokenMatch => {
- let tokens = event_search_tokens(event);
- search
- .terms()
- .iter()
- .all(|term| tokens.contains(&term.to_ascii_lowercase()))
- }
- }
-}
-
-fn event_search_tokens(event: &Event) -> BTreeSet<String> {
- let mut tokens = BTreeSet::new();
- collect_search_tokens(event.unsigned().content(), &mut tokens);
- for tag in event.unsigned().tags() {
- for value in tag.values() {
- collect_search_tokens(value, &mut tokens);
- }
- }
- tokens
-}
-
-fn collect_search_tokens(value: &str, tokens: &mut BTreeSet<String>) {
- tokens.extend(
- value
- .split(|character: char| !character.is_ascii_alphanumeric())
- .filter(|term| !term.is_empty())
- .map(|term| term.to_ascii_lowercase()),
- );
-}
-
-fn compile_filter_tag_constraints(filter: &Filter) -> Result<Vec<QueryTagFilter>, QueryPlanError> {
- filter
- .tag_filters()
- .iter()
- .map(|(name, values)| {
- let name = name
- .as_str()
- .chars()
- .next()
- .expect("protocol tag filters are non-empty");
- QueryTagFilter::new(
- name,
- values
- .iter()
- .map(|value| value.as_str().to_owned())
- .collect(),
- )
- })
- .collect()
-}
-
-fn filter_complexity(filters: &[Filter]) -> u64 {
- filters
- .iter()
- .map(|filter| {
- let tag_value_count = filter.tag_filters().values().map(Vec::len).sum::<usize>();
- let search_terms = filter
- .search()
- .map_or(0, |search| search.split_whitespace().count());
- 1 + filter.ids().len()
- + filter.authors().len()
- + filter.kinds().len()
- + filter.tag_filters().len()
- + tag_value_count
- + usize::from(filter.since().is_some())
- + usize::from(filter.until().is_some())
- + usize::from(filter.limit().is_some())
- + search_terms
- })
- .sum::<usize>() as u64
-}
-
-fn require_positive(field: &'static str, value: u64) -> Result<(), RuntimeLimitConfigError> {
- if value == 0 {
- Err(RuntimeLimitConfigError::Zero { field })
- } else {
- Ok(())
- }
-}
-
-fn require_within(
- kind: RuntimeLimitKind,
- actual: u64,
- maximum: u64,
-) -> Result<(), RuntimeLimitViolation> {
- if actual > maximum {
- Err(RuntimeLimitViolation::new(kind, actual, maximum))
- } else {
- Ok(())
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{
- AdmissionContext, AdmissionEffect, AdmissionEvent, AdmissionEventKind, AdmissionPolicy,
- AdmissionRejectionKind, AuthChallengeState, AuthChallengeStateErrorKind,
- EventIngestionEffect, EventIngestionRejectionKind, EventIngestor, EventParser,
- EventValidationRejection, EventValidationRejectionKind, EventValidator,
- FixedWindowRateLimiter, LiveSearchPolicy, MarketplaceCursor, MarketplaceCursorSpec,
- MarketplaceDecimal, MarketplaceGeoPoint, MarketplaceListingStatus,
- MarketplaceLocationFilter, MarketplaceQuery, MarketplaceQueryErrorKind,
- MarketplaceQuerySpec, MarketplaceSort, Nip50QueryCompileErrorKind, Nip50QueryCompiler,
- NostrFilterCompileErrorKind, NostrFilterCompiler, ProjectionExclusionReason,
- QueryExecutionMode, QueryPlan, QueryPlanBranch, QueryPlanBranchSpec, QueryPlanError,
- QuerySearch, QuerySort, QuerySource, QueryTagFilter, RateLimitConfig, RateLimitConfigError,
- RateLimitDecision, RateLimitErrorKind, RuntimeLimitConfigError, RuntimeLimitKind,
- RuntimeLimitValues, RuntimeLimits, SubscriptionAddOutcome, SubscriptionCloseOutcome,
- SubscriptionManager, SubscriptionManagerErrorKind, SubscriptionMatch, SubscriptionMatcher,
- UnapprovedSellerAction,
- };
- use tangle_nips::{
- FulfillmentMethod, ListingProjection, ListingUnit, RelayAuthEvent,
- evaluate_listing_projection, parse_deletion_request, parse_relay_auth_event,
- };
- use tangle_protocol::{
- AddressCoordinate, Event, EventId, Kind, PublicKeyHex, SignatureHex, SubscriptionId, Tag,
- UnixTimestamp, UnsignedEvent, filter_from_value,
- };
- use tangle_store::{
- DeletionMarker, DeletionMarkerRepository, ListingProjectionRepository, RawEventRepository,
- RepositoryError, StoreEventOutcome, StoreProjectionOutcome, StoredEvent,
- };
- use tangle_test_support::{
- FixtureKey, InMemoryRepository, auth_event_spec, build_fixture_event,
- build_fixture_event_from_parts, deletion_event_spec, fixture_spec_from_json,
- projection_ineligible_listing_spec, valid_public_listing_spec,
- };
-
- #[test]
- fn default_runtime_limits_expose_reference_aligned_boundaries() {
- let limits = RuntimeLimits::default();
- let values = limits.values();
-
- assert_eq!(limits.max_event_bytes(), 131_072);
- assert_eq!(limits.max_content_bytes(), 65_536);
- assert_eq!(limits.max_tags_per_event(), 128);
- assert_eq!(limits.max_tag_values_per_tag(), 16);
- assert_eq!(limits.max_tag_value_bytes(), 1_024);
- assert_eq!(limits.max_filters_per_subscription(), 16);
- assert_eq!(limits.max_subscriptions_per_connection(), 64);
- assert_eq!(limits.max_search_query_bytes(), 256);
- assert_eq!(limits.max_search_tokens(), 16);
- assert_eq!(limits.max_filter_complexity(), 512);
- assert_eq!(limits.max_future_seconds(), 900);
- assert_eq!(limits.live_event_buffer(), 1_024);
- assert_eq!(limits.pending_store_events(), 4_096);
- assert_eq!(values.max_event_bytes, 131_072);
- }
-
- #[test]
- fn runtime_limit_config_rejects_zero_and_inconsistent_values() {
- let zero_cases = [
- (
- "max_event_bytes",
- RuntimeLimitValues {
- max_event_bytes: 0,
- ..RuntimeLimitValues::default()
- },
- ),
- (
- "max_content_bytes",
- RuntimeLimitValues {
- max_content_bytes: 0,
- ..RuntimeLimitValues::default()
- },
- ),
- (
- "max_tags_per_event",
- RuntimeLimitValues {
- max_tags_per_event: 0,
- ..RuntimeLimitValues::default()
- },
- ),
- (
- "max_tag_values_per_tag",
- RuntimeLimitValues {
- max_tag_values_per_tag: 0,
- ..RuntimeLimitValues::default()
- },
- ),
- (
- "max_tag_value_bytes",
- RuntimeLimitValues {
- max_tag_value_bytes: 0,
- ..RuntimeLimitValues::default()
- },
- ),
- (
- "max_filters_per_subscription",
- RuntimeLimitValues {
- max_filters_per_subscription: 0,
- ..RuntimeLimitValues::default()
- },
- ),
- (
- "max_subscriptions_per_connection",
- RuntimeLimitValues {
- max_subscriptions_per_connection: 0,
- ..RuntimeLimitValues::default()
- },
- ),
- (
- "max_search_query_bytes",
- RuntimeLimitValues {
- max_search_query_bytes: 0,
- ..RuntimeLimitValues::default()
- },
- ),
- (
- "max_search_tokens",
- RuntimeLimitValues {
- max_search_tokens: 0,
- ..RuntimeLimitValues::default()
- },
- ),
- (
- "max_filter_complexity",
- RuntimeLimitValues {
- max_filter_complexity: 0,
- ..RuntimeLimitValues::default()
- },
- ),
- (
- "live_event_buffer",
- RuntimeLimitValues {
- live_event_buffer: 0,
- ..RuntimeLimitValues::default()
- },
- ),
- (
- "pending_store_events",
- RuntimeLimitValues {
- pending_store_events: 0,
- ..RuntimeLimitValues::default()
- },
- ),
- ];
- let inconsistent = RuntimeLimitValues {
- max_event_bytes: 10,
- max_content_bytes: 11,
- ..RuntimeLimitValues::default()
- };
-
- for (field, values) in zero_cases {
- assert_eq!(
- RuntimeLimits::from_values(values).expect_err(field),
- RuntimeLimitConfigError::Zero { field }
- );
- }
- assert_eq!(
- RuntimeLimits::from_values(zero_cases[0].1)
- .expect_err("zero")
- .to_string(),
- "`max_event_bytes` must be greater than zero"
- );
- assert_eq!(
- RuntimeLimits::from_values(inconsistent).expect_err("inconsistent"),
- RuntimeLimitConfigError::Inconsistent {
- field: "max_content_bytes",
- maximum_field: "max_event_bytes",
- value: 11,
- maximum: 10,
- }
- );
- assert_eq!(
- RuntimeLimits::from_values(inconsistent)
- .expect_err("inconsistent")
- .to_string(),
- "`max_content_bytes` must not exceed `max_event_bytes` (11 > 10)"
- );
- }
-
- #[test]
- fn runtime_limits_accept_fixture_event_inside_boundaries() {
- let event = build_fixture_event(&valid_public_listing_spec()).expect("event");
-
- assert_eq!(RuntimeLimits::default().validate_event(&event), Ok(()));
- assert_eq!(
- RuntimeLimits::default()
- .validate_event_timestamp(&event, UnixTimestamp::new(1_714_124_433)),
- Ok(())
- );
- }
-
- #[test]
- fn runtime_limits_reject_event_shape_boundaries() {
- assert_eq!(
- limits_with(|values| {
- values.max_event_bytes = 10;
- values.max_content_bytes = 10;
- })
- .validate_event(&event_with(vec![], "small", UnixTimestamp::new(10)))
- .expect_err("event bytes")
- .kind(),
- RuntimeLimitKind::EventBytes
- );
- assert_eq!(
- limits_with(|values| values.max_content_bytes = 3)
- .validate_event(&event_with(vec![], "large", UnixTimestamp::new(10)))
- .expect_err("content bytes")
- .kind(),
- RuntimeLimitKind::ContentBytes
- );
- assert_eq!(
- limits_with(|values| values.max_tags_per_event = 1)
- .validate_event(&event_with(
- vec![
- Tag::from_parts("d", &["one"]).expect("d"),
- Tag::from_parts("t", &["two"]).expect("t"),
- ],
- "",
- UnixTimestamp::new(10),
- ))
- .expect_err("tag count")
- .kind(),
- RuntimeLimitKind::TagsPerEvent
- );
- assert_eq!(
- limits_with(|values| values.max_tag_values_per_tag = 1)
- .validate_event(&event_with(
- vec![Tag::from_parts("t", &["one"]).expect("t")],
- "",
- UnixTimestamp::new(10),
- ))
- .expect_err("tag values")
- .kind(),
- RuntimeLimitKind::TagValuesPerTag
- );
- assert_eq!(
- limits_with(|values| values.max_tag_value_bytes = 1)
- .validate_event(&event_with(
- vec![Tag::from_parts("t", &["two"]).expect("t")],
- "",
- UnixTimestamp::new(10),
- ))
- .expect_err("tag value bytes")
- .kind(),
- RuntimeLimitKind::TagValueBytes
- );
- }
-
- #[test]
- fn runtime_limits_reject_filter_subscription_search_and_future_boundaries() {
- let limits = limits_with(|values| {
- values.max_filters_per_subscription = 2;
- values.max_filter_complexity = 3;
- values.max_subscriptions_per_connection = 4;
- values.max_search_query_bytes = 32;
- values.max_search_tokens = 2;
- values.max_future_seconds = 10;
- });
-
- assert_eq!(limits.validate_filters(2, 3), Ok(()));
- assert_eq!(
- limits.validate_filters(3, 3).expect_err("filters").kind(),
- RuntimeLimitKind::FiltersPerSubscription
- );
- assert_eq!(
- limits
- .validate_filters(2, 4)
- .expect_err("complexity")
- .kind(),
- RuntimeLimitKind::FilterComplexity
- );
- assert_eq!(limits.validate_subscription_count(4), Ok(()));
- assert_eq!(
- limits
- .validate_subscription_count(5)
- .expect_err("subscriptions")
- .kind(),
- RuntimeLimitKind::SubscriptionsPerConnection
- );
- assert_eq!(limits.validate_search_query("one two"), Ok(()));
- assert_eq!(
- limits
- .validate_search_query("123456789012345678901234567890123")
- .expect_err("search bytes")
- .kind(),
- RuntimeLimitKind::SearchQueryBytes
- );
- assert_eq!(
- limits
- .validate_search_query("a b c")
- .expect_err("search tokens")
- .kind(),
- RuntimeLimitKind::SearchTokens
- );
- assert_eq!(
- limits
- .validate_event_timestamp(
- &event_with(vec![], "", UnixTimestamp::new(111)),
- UnixTimestamp::new(100),
- )
- .expect_err("future")
- .kind(),
- RuntimeLimitKind::FutureSeconds
- );
- assert_eq!(
- limits.validate_event_timestamp(
- &event_with(vec![], "", UnixTimestamp::new(100)),
- UnixTimestamp::new(100),
- ),
- Ok(())
- );
- }
-
- #[test]
- fn runtime_limit_violation_reports_stable_values() {
- let violation = limits_with(|values| values.max_search_tokens = 1)
- .validate_search_query("one two")
- .expect_err("tokens");
-
- assert_eq!(violation.kind(), RuntimeLimitKind::SearchTokens);
- assert_eq!(violation.actual(), 2);
- assert_eq!(violation.maximum(), 1);
- assert_eq!(violation.to_string(), "search tokens exceeded: 2 > 1");
- assert_eq!(
- [
- RuntimeLimitKind::EventBytes.as_str(),
- RuntimeLimitKind::ContentBytes.as_str(),
- RuntimeLimitKind::TagsPerEvent.as_str(),
- RuntimeLimitKind::TagValuesPerTag.as_str(),
- RuntimeLimitKind::TagValueBytes.as_str(),
- RuntimeLimitKind::FiltersPerSubscription.as_str(),
- RuntimeLimitKind::SubscriptionsPerConnection.as_str(),
- RuntimeLimitKind::SearchQueryBytes.as_str(),
- RuntimeLimitKind::SearchTokens.as_str(),
- RuntimeLimitKind::FilterComplexity.as_str(),
- RuntimeLimitKind::FutureSeconds.as_str(),
- ],
- [
- "event bytes",
- "content bytes",
- "tags per event",
- "tag values per tag",
- "tag value bytes",
- "filters per subscription",
- "subscriptions per connection",
- "search query bytes",
- "search tokens",
- "filter complexity",
- "future seconds",
- ]
- );
- assert_eq!(
- RuntimeLimitKind::FutureSeconds.to_string(),
- "future seconds"
- );
- }
-
- #[test]
- fn admission_policy_defaults_require_matching_write_auth() {
- let author = pubkey("1");
- let other = pubkey("2");
- let event = AdmissionEvent::new(author.clone(), AdmissionEventKind::Write);
- let policy = AdmissionPolicy::new();
-
- assert!(policy.require_write_auth());
- assert_eq!(
- policy.unapproved_seller_action(),
- UnapprovedSellerAction::StoreRawOnly
- );
- assert!(policy.approved_sellers().is_empty());
- assert!(policy.blocked_pubkeys().is_empty());
- assert_eq!(
- policy
- .admit(&event, &AdmissionContext::unauthenticated())
- .rejection()
- .expect("unauthenticated")
- .kind(),
- AdmissionRejectionKind::AuthenticationRequired
- );
- assert_eq!(
- policy
- .admit(&event, &AdmissionContext::authenticated(other))
- .rejection()
- .expect("mismatch")
- .kind(),
- AdmissionRejectionKind::AuthenticatedPubkeyMismatch
- );
- assert_eq!(
- policy
- .admit(&event, &AdmissionContext::authenticated(author.clone()))
- .accepted()
- .expect("accepted")
- .effect(),
- AdmissionEffect::StoreRaw
- );
- assert_eq!(event.author_pubkey(), &author);
- assert_eq!(event.kind(), AdmissionEventKind::Write);
- }
-
- #[test]
- fn admission_policy_accepts_auth_events_without_prior_authentication() {
- let event = AdmissionEvent::new(pubkey("1"), AdmissionEventKind::RelayAuth);
- let decision = AdmissionPolicy::new().admit(&event, &AdmissionContext::unauthenticated());
-
- assert_eq!(
- decision.accepted().expect("accepted").effect(),
- AdmissionEffect::AuthenticateOnly
- );
- assert_eq!(
- decision
- .accepted()
- .expect("accepted")
- .projection_exclusion(),
- None
- );
- assert!(decision.rejection().is_none());
- }
-
- #[test]
- fn admission_policy_projects_public_listings_for_approved_sellers() {
- let seller = pubkey("3");
- let event = AdmissionEvent::new(seller.clone(), AdmissionEventKind::PublicListing);
- let policy = AdmissionPolicy::new().approve_seller(seller.clone());
- let decision = policy.admit(&event, &AdmissionContext::authenticated(seller.clone()));
-
- assert!(policy.is_seller_approved(&seller));
- assert_eq!(
- decision.accepted().expect("accepted").effect(),
- AdmissionEffect::StoreRawAndProjectPublicListing
- );
- assert_eq!(
- decision
- .accepted()
- .expect("accepted")
- .projection_exclusion(),
- None
- );
- }
-
- #[test]
- fn admission_policy_handles_unapproved_sellers_by_configured_action() {
- let seller = pubkey("4");
- let event = AdmissionEvent::new(seller.clone(), AdmissionEventKind::PublicListing);
- let context = AdmissionContext::authenticated(seller.clone());
- let raw_only = AdmissionPolicy::new().admit(&event, &context);
- let reject = AdmissionPolicy::new()
- .with_unapproved_seller_action(UnapprovedSellerAction::RejectWrite)
- .admit(&event, &context);
-
- assert_eq!(
- raw_only.accepted().expect("raw only").effect(),
- AdmissionEffect::StoreRawWithoutPublicListingProjection
- );
- assert_eq!(
- raw_only
- .accepted()
- .expect("raw only")
- .projection_exclusion(),
- Some(ProjectionExclusionReason::UnapprovedSeller)
- );
- assert_eq!(
- reject.rejection().expect("reject").kind(),
- AdmissionRejectionKind::UnapprovedSeller
- );
- assert!(reject.accepted().is_none());
- assert_eq!(
- reject.rejection().expect("reject").message(),
- "seller is not approved"
- );
- }
-
- #[test]
- fn admission_policy_applies_blocked_pubkey_policy() {
- let seller = pubkey("5");
- let write = AdmissionEvent::new(seller.clone(), AdmissionEventKind::Write);
- let listing = AdmissionEvent::new(seller.clone(), AdmissionEventKind::PublicListing);
- let context = AdmissionContext::authenticated(seller.clone());
- let policy = AdmissionPolicy::new()
- .approve_seller(seller.clone())
- .block_pubkey(seller.clone());
-
- assert!(policy.is_pubkey_blocked(&seller));
- assert_eq!(policy.blocked_pubkeys().len(), 1);
- assert_eq!(
- policy
- .admit(&write, &context)
- .rejection()
- .expect("blocked")
- .kind(),
- AdmissionRejectionKind::BlockedPubkey
- );
- assert_eq!(
- policy
- .admit(&listing, &context)
- .accepted()
- .expect("listing")
- .effect(),
- AdmissionEffect::StoreRawWithoutPublicListingProjection
- );
- assert_eq!(
- policy
- .admit(&listing, &context)
- .accepted()
- .expect("listing")
- .projection_exclusion(),
- Some(ProjectionExclusionReason::BlockedSeller)
- );
- }
-
- #[test]
- fn admission_policy_can_disable_write_auth_for_internal_tests() {
- let event = AdmissionEvent::new(pubkey("6"), AdmissionEventKind::DraftListing);
- let decision = AdmissionPolicy::new()
- .with_write_auth_required(false)
- .admit(&event, &AdmissionContext::unauthenticated());
-
- assert_eq!(
- decision.accepted().expect("accepted").effect(),
- AdmissionEffect::StoreRaw
- );
- }
-
- #[test]
- fn admission_policy_labels_and_rejections_are_stable() {
- let rejection = AdmissionPolicy::new()
- .admit(
- &AdmissionEvent::new(pubkey("7"), AdmissionEventKind::Write),
- &AdmissionContext::unauthenticated(),
- )
- .rejection()
- .expect("rejection")
- .clone();
-
- assert_eq!(
- [
- AdmissionEventKind::RelayAuth.as_str(),
- AdmissionEventKind::Write.as_str(),
- AdmissionEventKind::PublicListing.as_str(),
- AdmissionEventKind::DraftListing.as_str(),
- ],
- ["relay auth", "write", "public listing", "draft listing"]
- );
- assert_eq!(
- [
- UnapprovedSellerAction::StoreRawOnly.as_str(),
- UnapprovedSellerAction::RejectWrite.as_str(),
- ],
- ["store raw only", "reject write"]
- );
- assert_eq!(
- [
- AdmissionEffect::AuthenticateOnly.as_str(),
- AdmissionEffect::StoreRaw.as_str(),
- AdmissionEffect::StoreRawAndProjectPublicListing.as_str(),
- AdmissionEffect::StoreRawWithoutPublicListingProjection.as_str(),
- ],
- [
- "authenticate only",
- "store raw",
- "store raw and project public listing",
- "store raw without public listing projection",
- ]
- );
- assert_eq!(
- [
- ProjectionExclusionReason::UnapprovedSeller.as_str(),
- ProjectionExclusionReason::BlockedSeller.as_str(),
- ],
- ["unapproved seller", "blocked seller"]
- );
- assert_eq!(
- [
- AdmissionRejectionKind::AuthenticationRequired.as_str(),
- AdmissionRejectionKind::AuthenticatedPubkeyMismatch.as_str(),
- AdmissionRejectionKind::BlockedPubkey.as_str(),
- AdmissionRejectionKind::UnapprovedSeller.as_str(),
- ],
- [
- "authentication required",
- "authenticated pubkey mismatch",
- "blocked pubkey",
- "unapproved seller",
- ]
- );
- assert_eq!(AdmissionEventKind::Write.to_string(), "write");
- assert_eq!(
- UnapprovedSellerAction::RejectWrite.to_string(),
- "reject write"
- );
- assert_eq!(AdmissionEffect::StoreRaw.to_string(), "store raw");
- assert_eq!(
- ProjectionExclusionReason::BlockedSeller.to_string(),
- "blocked seller"
- );
- assert_eq!(
- AdmissionRejectionKind::BlockedPubkey.to_string(),
- "blocked pubkey"
- );
- assert_eq!(
- rejection.to_string(),
- "authentication required: write authentication required"
- );
- }
-
- #[test]
- fn event_validator_accepts_approved_public_listing_with_projection_payload() {
- let event = build_fixture_event(&valid_public_listing_spec()).expect("event");
- let seller = FixtureKey::Seller.public_key();
- let validator = EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(seller.clone()),
- );
- let validated = validator
- .validate(
- &event,
- &AdmissionContext::authenticated(seller.clone()),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect("validated");
-
- assert_eq!(validator.limits(), RuntimeLimits::default());
- assert!(validator.admission_policy().is_seller_approved(&seller));
- assert_eq!(validated.event_id(), event.id());
- assert_eq!(validated.author_pubkey(), &seller);
- assert_eq!(
- validated.admission_kind(),
- AdmissionEventKind::PublicListing
- );
- assert_eq!(
- validated.admission().effect(),
- AdmissionEffect::StoreRawAndProjectPublicListing
- );
- assert!(
- validated
- .payload()
- .listing_evaluation()
- .expect("listing")
- .is_eligible()
- );
- assert!(validated.payload().relay_auth().is_none());
- assert!(validated.payload().deletion_request().is_none());
- }
-
- #[test]
- fn event_validator_accepts_projection_ineligible_listing_as_raw_store_candidate() {
- let event = build_fixture_event(&projection_ineligible_listing_spec()).expect("event");
- let seller = FixtureKey::Seller.public_key();
- let validated = EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(seller.clone()),
- )
- .validate(
- &event,
- &AdmissionContext::authenticated(seller),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect("validated");
- let rejection = validated
- .payload()
- .listing_evaluation()
- .expect("listing")
- .rejection()
- .expect("projection rejection");
-
- assert_eq!(
- validated.admission_kind(),
- AdmissionEventKind::PublicListing
- );
- assert_eq!(rejection.reasons(), &["tag `title` is required".to_owned()]);
- }
-
- #[test]
- fn event_validator_accepts_auth_deletion_and_other_write_payloads() {
- let seller = FixtureKey::Seller.public_key();
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let deletion = build_fixture_event(&deletion_event_spec()).expect("deletion");
- let draft_listing = build_fixture_event(
- &fixture_spec_from_json(
- r#"{"name":"draft_listing","key":"seller","created_at":1714124438,"kind":30403,"tags":[["d","draft-carrots"],["title","Draft carrots"],["price","3.25","USD"],["unit","lb"],["fulfillment","pickup"]],"content":"Draft storage carrots."}"#,
- )
- .expect("draft listing"),
- )
- .expect("draft listing event");
- let note = build_fixture_event(
- &fixture_spec_from_json(
- r#"{"name":"note","key":"seller","created_at":1714124437,"kind":1,"tags":[],"content":"hello"}"#,
- )
- .expect("note"),
- )
- .expect("note event");
- let validator = EventValidator::default();
- let auth = validator
- .validate(
- &auth,
- &AdmissionContext::unauthenticated(),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect("auth validated");
- let deletion = validator
- .validate(
- &deletion,
- &AdmissionContext::authenticated(seller.clone()),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect("deletion validated");
- let draft_listing = validator
- .validate(
- &draft_listing,
- &AdmissionContext::authenticated(seller.clone()),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect("draft listing validated");
- let note = validator
- .validate(
- ¬e,
- &AdmissionContext::authenticated(seller),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect("note validated");
-
- assert_eq!(auth.admission_kind(), AdmissionEventKind::RelayAuth);
- assert_eq!(
- auth.payload().relay_auth().expect("auth").challenge(),
- "challenge-001"
- );
- assert!(auth.payload().listing_evaluation().is_none());
- assert_eq!(deletion.admission_kind(), AdmissionEventKind::Write);
- assert_eq!(
- deletion
- .payload()
- .deletion_request()
- .expect("deletion")
- .targets()
- .len(),
- 1
- );
- assert_eq!(
- draft_listing.admission_kind(),
- AdmissionEventKind::DraftListing
- );
- assert_eq!(
- draft_listing.admission().effect(),
- AdmissionEffect::StoreRaw
- );
- assert!(
- draft_listing
- .payload()
- .listing_evaluation()
- .expect("draft listing")
- .rejection()
- .is_some()
- );
- assert_eq!(note.admission_kind(), AdmissionEventKind::Write);
- assert_eq!(note.payload(), &super::ValidatedEventPayload::Other);
- }
-
- #[test]
- fn event_validator_rejects_private_commerce_plaintext_before_storage() {
- let seller = FixtureKey::Seller.public_key();
- let validator = EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(seller.clone()),
- );
- let delivery_payload = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_124_437,
- 1,
- vec![vec!["t".to_owned(), "commerce-privacy".to_owned()]],
- r#"{"private_commerce":{"delivery_address":"100 Privacy Fixture Way","payment_details":"fixture-payment-token"}}"#,
- )
- .expect("delivery payload");
- let phone_tag = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_124_438,
- 1,
- vec![vec!["phone".to_owned(), "5550100".to_owned()]],
- "private phone detail",
- )
- .expect("phone tag");
- let public_listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
-
- let delivery_rejection = validator
- .validate(
- &delivery_payload,
- &AdmissionContext::authenticated(seller.clone()),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect_err("delivery privacy rejection");
- let tag_rejection = validator
- .validate(
- &phone_tag,
- &AdmissionContext::authenticated(seller.clone()),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect_err("phone privacy rejection");
-
- assert_eq!(
- delivery_rejection.kind(),
- EventValidationRejectionKind::Privacy
- );
- assert_eq!(
- delivery_rejection.to_string(),
- "privacy: private commerce plaintext field `delivery_address` is not allowed"
- );
- assert_eq!(
- tag_rejection,
- EventValidationRejection::Privacy("phone".to_owned())
- );
- validator
- .validate(
- &public_listing,
- &AdmissionContext::authenticated(seller),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect("public listing remains valid");
- }
-
- #[test]
- fn event_validator_rejects_private_commerce_plaintext_inside_arrays() {
- let seller = FixtureKey::Seller.public_key();
- let validator = EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(seller.clone()),
- );
- let event = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_124_439,
- 1,
- vec![vec!["t".to_owned(), "commerce-privacy".to_owned()]],
- r#"{"items":[{"note":"public"},{"delivery_address":"100 Privacy Fixture Way"}]}"#,
- )
- .expect("array privacy event");
-
- let rejection = validator
- .validate(
- &event,
- &AdmissionContext::authenticated(seller),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect_err("privacy rejection");
-
- assert_eq!(
- rejection,
- EventValidationRejection::Privacy("delivery_address".to_owned())
- );
- }
-
- #[test]
- fn event_validator_rejects_limits_crypto_parser_and_admission_failures() {
- let seller = FixtureKey::Seller.public_key();
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let bad_id = Event::new(
- EventId::new(&"f".repeat(EventId::HEX_LENGTH)).expect("id"),
- listing.unsigned().clone(),
- listing.sig().clone(),
- );
- let bad_auth = build_fixture_event(
- &fixture_spec_from_json(
- r#"{"name":"bad_auth","key":"seller","created_at":1714124435,"kind":22242,"tags":[["relay","wss://relay.radroots.test"]],"content":""}"#,
- )
- .expect("bad auth"),
- )
- .expect("bad auth event");
- let note = build_fixture_event(
- &fixture_spec_from_json(
- r#"{"name":"note","key":"seller","created_at":1714124437,"kind":1,"tags":[],"content":"hello"}"#,
- )
- .expect("note"),
- )
- .expect("note event");
- let limit_rejection = EventValidator::new(
- limits_with(|values| {
- values.max_event_bytes = 1;
- values.max_content_bytes = 1;
- }),
- AdmissionPolicy::new(),
- )
- .validate(
- &listing,
- &AdmissionContext::authenticated(seller.clone()),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect_err("limit");
- let crypto_rejection = EventValidator::default()
- .validate(
- &bad_id,
- &AdmissionContext::authenticated(seller.clone()),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect_err("crypto");
- let parser_rejection = EventValidator::default()
- .validate(
- &bad_auth,
- &AdmissionContext::unauthenticated(),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect_err("parser");
- let admission_rejection = EventValidator::default()
- .validate(
- ¬e,
- &AdmissionContext::unauthenticated(),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect_err("admission");
-
- assert_eq!(
- limit_rejection.kind(),
- EventValidationRejectionKind::RuntimeLimit
- );
- assert_eq!(
- crypto_rejection.kind(),
- EventValidationRejectionKind::Crypto
- );
- assert_eq!(
- parser_rejection.kind(),
- EventValidationRejectionKind::Parser
- );
- assert_eq!(
- admission_rejection.kind(),
- EventValidationRejectionKind::Admission
- );
- assert!(limit_rejection.to_string().starts_with("runtime limit:"));
- assert!(crypto_rejection.to_string().starts_with("crypto:"));
- assert_eq!(
- parser_rejection.to_string(),
- "parser: relay auth: tag `challenge` is required"
- );
- assert_eq!(
- admission_rejection.to_string(),
- "admission: authentication required: write authentication required"
- );
- let expected_parser = super::EventParserRejection::new(
- EventParser::RelayAuth,
- "tag `challenge` is required".to_owned(),
- );
- assert_eq!(expected_parser.parser(), EventParser::RelayAuth);
- assert_eq!(expected_parser.message(), "tag `challenge` is required");
- assert_eq!(
- parser_rejection,
- EventValidationRejection::Parser(expected_parser)
- );
- }
-
- #[test]
- fn event_validator_rejects_malformed_deletion_and_future_timestamp() {
- let seller = FixtureKey::Seller.public_key();
- let bad_deletion = build_fixture_event(
- &fixture_spec_from_json(
- r#"{"name":"bad_deletion","key":"seller","created_at":1714124436,"kind":5,"tags":[],"content":""}"#,
- )
- .expect("bad deletion"),
- )
- .expect("bad deletion event");
- let future_note = build_fixture_event(
- &fixture_spec_from_json(
- r#"{"name":"future_note","key":"seller","created_at":1714125400,"kind":1,"tags":[],"content":"hello"}"#,
- )
- .expect("future note"),
- )
- .expect("future note event");
- let deletion_rejection = EventValidator::default()
- .validate(
- &bad_deletion,
- &AdmissionContext::authenticated(seller.clone()),
- UnixTimestamp::new(1_714_124_500),
- )
- .expect_err("deletion");
- let future_rejection = EventValidator::default()
- .validate(
- &future_note,
- &AdmissionContext::authenticated(seller),
- UnixTimestamp::new(1_714_124_433),
- )
- .expect_err("future");
-
- assert_eq!(
- deletion_rejection.kind(),
- EventValidationRejectionKind::Parser
- );
- assert_eq!(
- deletion_rejection.to_string(),
- "parser: deletion: deletion event must target at least one e or a tag"
- );
- assert_eq!(
- future_rejection.kind(),
- EventValidationRejectionKind::RuntimeLimit
- );
- assert_eq!(EventParser::RelayAuth.as_str(), "relay auth");
- assert_eq!(EventParser::Deletion.to_string(), "deletion");
- }
-
- #[test]
- fn event_ingestor_keeps_auth_and_ephemeral_events_out_of_raw_storage() {
- let seller = FixtureKey::Seller.public_key();
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let ephemeral = build_fixture_event(
- &fixture_spec_from_json(
- r#"{"name":"ephemeral","key":"seller","created_at":1714124440,"kind":20000,"tags":[],"content":"typing"}"#,
- )
- .expect("ephemeral"),
- )
- .expect("ephemeral event");
- let mut repository = InMemoryRepository::new();
- let ingestor = EventIngestor::default();
- let auth = ingestor
- .ingest(
- &mut repository,
- auth,
- &AdmissionContext::unauthenticated(),
- UnixTimestamp::new(1_714_124_501),
- UnixTimestamp::new(1_714_124_501),
- )
- .expect("auth");
- let ephemeral = ingestor
- .ingest(
- &mut repository,
- ephemeral,
- &AdmissionContext::authenticated(seller),
- UnixTimestamp::new(1_714_124_502),
- UnixTimestamp::new(1_714_124_502),
- )
- .expect("ephemeral");
-
- assert_eq!(ingestor.validator().limits(), RuntimeLimits::default());
- assert_eq!(auth.effect(), EventIngestionEffect::Authenticated);
- assert_eq!(ephemeral.effect(), EventIngestionEffect::EphemeralAccepted);
- assert_eq!(auth.raw_event_outcome(), None);
- assert_eq!(ephemeral.raw_event_outcome(), None);
- assert_eq!(repository.events().expect("events"), Vec::new());
- }
-
- #[test]
- fn event_ingestor_stores_raw_events_and_projects_approved_listings() {
- let event = build_fixture_event(&valid_public_listing_spec()).expect("event");
- let seller = FixtureKey::Seller.public_key();
- let projection_address = AddressCoordinate::from_event(&event)
- .expect("address")
- .expect("address");
- let mut repository = InMemoryRepository::new();
- let ingestor = EventIngestor::new(EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(seller.clone()),
- ));
- let ingestion = ingestor
- .ingest(
- &mut repository,
- event.clone(),
- &AdmissionContext::authenticated(seller),
- UnixTimestamp::new(1_714_124_501),
- UnixTimestamp::new(1_714_124_501),
- )
- .expect("ingestion");
-
- assert_eq!(ingestion.event_id(), event.id());
- assert_eq!(ingestion.effect(), EventIngestionEffect::Stored);
- assert_eq!(
- ingestion.raw_event_outcome(),
- Some(StoreEventOutcome::Inserted)
- );
- assert_eq!(
- ingestion.projection_outcome(),
- Some(StoreProjectionOutcome::Inserted)
- );
- assert_eq!(ingestion.deletion_marker_count(), 0);
- assert_eq!(
- repository
- .event_by_id(event.id())
- .expect("event")
- .expect("stored")
- .event(),
- &event
- );
- assert!(
- repository
- .listing_projection(&projection_address)
- .expect("projection")
- .is_some()
- );
- }
-
- #[test]
- fn event_ingestor_stores_projection_ineligible_listing_without_projection() {
- let event = build_fixture_event(&projection_ineligible_listing_spec()).expect("event");
- let seller = FixtureKey::Seller.public_key();
- let mut repository = InMemoryRepository::new();
- let ingestor = EventIngestor::new(EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(seller.clone()),
- ));
- let ingestion = ingestor
- .ingest(
- &mut repository,
- event,
- &AdmissionContext::authenticated(seller),
- UnixTimestamp::new(1_714_124_501),
- UnixTimestamp::new(1_714_124_501),
- )
- .expect("ingestion");
-
- assert_eq!(ingestion.effect(), EventIngestionEffect::Stored);
- assert_eq!(
- ingestion.raw_event_outcome(),
- Some(StoreEventOutcome::Inserted)
- );
- assert_eq!(ingestion.projection_outcome(), None);
- }
-
- #[test]
- fn event_ingestor_creates_deletion_markers_after_raw_insert() {
- let event = build_fixture_event(&deletion_event_spec()).expect("deletion");
- let seller = FixtureKey::Seller.public_key();
- let mut repository = InMemoryRepository::new();
- let ingestion = EventIngestor::default()
- .ingest(
- &mut repository,
- event.clone(),
- &AdmissionContext::authenticated(seller.clone()),
- UnixTimestamp::new(1_714_124_501),
- UnixTimestamp::new(1_714_124_501),
- )
- .expect("ingestion");
- let markers = repository.deletion_markers().expect("markers");
-
- assert_eq!(ingestion.effect(), EventIngestionEffect::Stored);
- assert_eq!(ingestion.deletion_marker_count(), 1);
- assert_eq!(markers.len(), 1);
- assert_eq!(markers[0].deletion_event_id(), event.id());
- assert_eq!(markers[0].author_pubkey(), &seller);
- assert_eq!(markers[0].deleted_at(), event.unsigned().created_at());
- }
-
- #[test]
- fn event_ingestor_skips_duplicate_side_effects() {
- let event = build_fixture_event(&valid_public_listing_spec()).expect("event");
- let seller = FixtureKey::Seller.public_key();
- let mut repository = InMemoryRepository::new();
- let ingestor = EventIngestor::new(EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(seller.clone()),
- ));
- let first = ingestor
- .ingest(
- &mut repository,
- event.clone(),
- &AdmissionContext::authenticated(seller.clone()),
- UnixTimestamp::new(1_714_124_501),
- UnixTimestamp::new(1_714_124_501),
- )
- .expect("first");
- let duplicate = ingestor
- .ingest(
- &mut repository,
- event,
- &AdmissionContext::authenticated(seller),
- UnixTimestamp::new(1_714_124_502),
- UnixTimestamp::new(1_714_124_502),
- )
- .expect("duplicate");
-
- assert_eq!(
- first.projection_outcome(),
- Some(StoreProjectionOutcome::Inserted)
- );
- assert_eq!(duplicate.effect(), EventIngestionEffect::Duplicate);
- assert_eq!(
- duplicate.raw_event_outcome(),
- Some(StoreEventOutcome::Duplicate)
- );
- assert_eq!(duplicate.projection_outcome(), None);
- }
-
- #[test]
- fn event_ingestor_reports_validation_and_repository_rejections() {
- let seller = FixtureKey::Seller.public_key();
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let deletion = build_fixture_event(&deletion_event_spec()).expect("deletion");
- let note = build_fixture_event(
- &fixture_spec_from_json(
- r#"{"name":"note","key":"seller","created_at":1714124437,"kind":1,"tags":[],"content":"hello"}"#,
- )
- .expect("note"),
- )
- .expect("note event");
- let validation_rejection = EventIngestor::default()
- .ingest(
- &mut InMemoryRepository::new(),
- note.clone(),
- &AdmissionContext::unauthenticated(),
- UnixTimestamp::new(1_714_124_501),
- UnixTimestamp::new(1_714_124_501),
- )
- .expect_err("validation");
- let repository_rejection = EventIngestor::default()
- .ingest(
- &mut RawFailingRepository,
- note,
- &AdmissionContext::authenticated(seller),
- UnixTimestamp::new(1_714_124_501),
- UnixTimestamp::new(1_714_124_501),
- )
- .expect_err("repository");
- let projection_rejection = EventIngestor::new(EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(FixtureKey::Seller.public_key()),
- ))
- .ingest(
- &mut ProjectionFailingRepository::new(),
- listing,
- &AdmissionContext::authenticated(FixtureKey::Seller.public_key()),
- UnixTimestamp::new(1_714_124_501),
- UnixTimestamp::new(1_714_124_501),
- )
- .expect_err("projection repository");
- let deletion_rejection = EventIngestor::default()
- .ingest(
- &mut DeletionFailingRepository::new(),
- deletion,
- &AdmissionContext::authenticated(FixtureKey::Seller.public_key()),
- UnixTimestamp::new(1_714_124_501),
- UnixTimestamp::new(1_714_124_501),
- )
- .expect_err("deletion repository");
-
- assert_eq!(
- validation_rejection.kind(),
- EventIngestionRejectionKind::Validation
- );
- assert_eq!(
- repository_rejection.kind(),
- EventIngestionRejectionKind::Repository
- );
- assert!(validation_rejection.to_string().starts_with("validation:"));
- assert_eq!(
- repository_rejection.to_string(),
- "repository: repository unavailable"
- );
- assert_eq!(
- projection_rejection.kind(),
- EventIngestionRejectionKind::Repository
- );
- assert_eq!(
- projection_rejection.to_string(),
- "repository: projection unavailable"
- );
- assert_eq!(
- deletion_rejection.kind(),
- EventIngestionRejectionKind::Repository
- );
- assert_eq!(
- deletion_rejection.to_string(),
- "repository: deletion unavailable"
- );
- assert_eq!(EventIngestionEffect::Stored.to_string(), "stored");
- assert_eq!(
- [
- EventIngestionEffect::Authenticated.as_str(),
- EventIngestionEffect::EphemeralAccepted.as_str(),
- EventIngestionEffect::Stored.as_str(),
- EventIngestionEffect::Duplicate.as_str(),
- ],
- ["authenticated", "ephemeral accepted", "stored", "duplicate",]
- );
- }
-
- #[test]
- fn failing_repository_helpers_cover_trait_surfaces() {
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let deletion = build_fixture_event(&deletion_event_spec()).expect("deletion");
- let projection = evaluate_listing_projection(&listing)
- .projection()
- .expect("projection")
- .clone();
- let address = projection.identity().address().clone();
- let deletion_request = parse_deletion_request(&deletion)
- .expect("deletion parse")
- .expect("deletion request");
- let marker = DeletionMarker::new(
- deletion.id().clone(),
- deletion.unsigned().pubkey().clone(),
- deletion_request.targets()[0].clone(),
- deletion.unsigned().created_at(),
- );
- let stored = StoredEvent::new(listing.clone(), UnixTimestamp::new(1_714_124_501));
- let mut raw = RawFailingRepository;
-
- assert!(raw.event_by_id(listing.id()).is_err());
- assert!(raw.events().is_err());
- assert!(raw.put_listing_projection(projection.clone()).is_err());
- assert!(raw.listing_projection(&address).is_err());
- assert!(raw.put_deletion_marker(marker.clone()).is_err());
- assert!(raw.deletion_markers().is_err());
-
- let mut projection_failing = ProjectionFailingRepository::new();
- assert_eq!(
- projection_failing.put_event(stored.clone()).expect("raw"),
- StoreEventOutcome::Inserted
- );
- assert_eq!(
- projection_failing
- .event_by_id(listing.id())
- .expect("event")
- .expect("stored")
- .event(),
- &listing
- );
- assert_eq!(projection_failing.events().expect("events").len(), 1);
- assert!(
- projection_failing
- .put_listing_projection(projection.clone())
- .is_err()
- );
- assert_eq!(
- projection_failing
- .listing_projection(&address)
- .expect("projection"),
- None
- );
- assert_eq!(
- projection_failing.put_deletion_marker(marker.clone()),
- Ok(())
- );
- assert_eq!(
- projection_failing.deletion_markers().expect("markers"),
- vec![marker.clone()]
- );
-
- let mut deletion_failing = DeletionFailingRepository::new();
- assert_eq!(
- deletion_failing.put_event(stored).expect("raw"),
- StoreEventOutcome::Inserted
- );
- assert_eq!(
- deletion_failing
- .put_listing_projection(projection.clone())
- .expect("projection"),
- StoreProjectionOutcome::Inserted
- );
- assert_eq!(
- deletion_failing
- .listing_projection(&address)
- .expect("projection"),
- Some(projection)
- );
- assert!(deletion_failing.put_deletion_marker(marker).is_err());
- assert_eq!(
- deletion_failing.deletion_markers().expect("markers"),
- Vec::new()
- );
- assert!(
- deletion_failing
- .event_by_id(listing.id())
- .expect("event")
- .is_some()
- );
- assert_eq!(deletion_failing.events().expect("events").len(), 1);
- }
-
- #[test]
- fn query_plan_model_accepts_multi_branch_historical_live_plans() {
- let search = QuerySearch::new(
- " carrots local ",
- vec![
- "local".to_owned(),
- "carrots".to_owned(),
- "carrots".to_owned(),
- ],
- )
- .expect("search");
- let tag_filter = QueryTagFilter::new(
- 't',
- vec![
- "vegetables".to_owned(),
- "carrots".to_owned(),
- "vegetables".to_owned(),
- ],
- )
- .expect("tag");
- let branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- ids: vec![
- EventId::new(&"b".repeat(EventId::HEX_LENGTH)).expect("id"),
- EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"),
- EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"),
- ],
- authors: vec![pubkey("2"), pubkey("1"), pubkey("1")],
- kinds: vec![
- Kind::new(30_402).expect("kind"),
- Kind::new(1).expect("kind"),
- Kind::new(1).expect("kind"),
- ],
- tag_filters: vec![
- tag_filter.clone(),
- QueryTagFilter::new('t', vec!["local".to_owned()]).expect("tag"),
- ],
- since: Some(UnixTimestamp::new(10)),
- until: Some(UnixTimestamp::new(20)),
- limit: Some(50),
- search: Some(search.clone()),
- })
- .expect("branch");
- let second_branch =
- QueryPlanBranch::from_spec(QueryPlanBranchSpec::default()).expect("second branch");
- let plan = QueryPlan::new(
- QuerySource::RawEvents,
- QueryExecutionMode::HistoricalThenLive,
- QuerySort::CreatedAtDescEventIdAsc,
- vec![branch.clone(), second_branch],
- )
- .expect("plan");
-
- assert_eq!(search.raw(), "carrots local");
- assert_eq!(search.terms(), &["carrots".to_owned(), "local".to_owned()]);
- assert_eq!(tag_filter.name(), 't');
- assert_eq!(
- tag_filter.values(),
- &["carrots".to_owned(), "vegetables".to_owned()]
- );
- assert_eq!(plan.source(), QuerySource::RawEvents);
- assert_eq!(plan.mode(), QueryExecutionMode::HistoricalThenLive);
- assert_eq!(plan.sort(), QuerySort::CreatedAtDescEventIdAsc);
- assert_eq!(plan.branches().len(), 2);
- assert!(plan.requires_historical_query());
- assert!(plan.subscribes_to_live_events());
- assert_eq!(branch.ids()[0].as_str(), &"a".repeat(EventId::HEX_LENGTH));
- assert_eq!(branch.authors()[0], pubkey("1"));
- assert_eq!(branch.kinds()[0], Kind::new(1).expect("kind"));
- assert_eq!(
- branch.tag_filters().get(&'t').expect("tag values"),
- &[
- "carrots".to_owned(),
- "local".to_owned(),
- "vegetables".to_owned(),
- ]
- );
- assert_eq!(branch.since(), Some(UnixTimestamp::new(10)));
- assert_eq!(branch.until(), Some(UnixTimestamp::new(20)));
- assert_eq!(branch.limit(), Some(50));
- assert_eq!(branch.search(), Some(&search));
- }
-
- #[test]
- fn query_plan_model_distinguishes_historical_and_live_execution() {
- let zero_limit_branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- limit: Some(0),
- ..QueryPlanBranchSpec::default()
- })
- .expect("branch");
- let historical_then_live = QueryPlan::new(
- QuerySource::RawEvents,
- QueryExecutionMode::HistoricalThenLive,
- QuerySort::CreatedAtDescEventIdAsc,
- vec![zero_limit_branch.clone()],
- )
- .expect("historical then live");
- let live = QueryPlan::new(
- QuerySource::RawEvents,
- QueryExecutionMode::Live,
- QuerySort::CreatedAtDescEventIdAsc,
- vec![zero_limit_branch.clone()],
- )
- .expect("live");
- let historical = QueryPlan::new(
- QuerySource::ListingProjections,
- QueryExecutionMode::Historical,
- QuerySort::ScoreDescCreatedAtDescEventIdAsc,
- vec![zero_limit_branch],
- )
- .expect("historical");
-
- assert!(!historical_then_live.requires_historical_query());
- assert!(historical_then_live.subscribes_to_live_events());
- assert!(!live.requires_historical_query());
- assert!(live.subscribes_to_live_events());
- assert!(!historical.requires_historical_query());
- assert!(!historical.subscribes_to_live_events());
- assert_eq!(historical.source(), QuerySource::ListingProjections);
- assert_eq!(
- historical.sort(),
- QuerySort::ScoreDescCreatedAtDescEventIdAsc
- );
- }
-
- #[test]
- fn query_plan_model_rejects_invalid_shapes_and_has_stable_labels() {
- let empty_branches = QueryPlan::new(
- QuerySource::RawEvents,
- QueryExecutionMode::Historical,
- QuerySort::CreatedAtDescEventIdAsc,
- Vec::new(),
- )
- .expect_err("empty");
- let invalid_time = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- since: Some(UnixTimestamp::new(20)),
- until: Some(UnixTimestamp::new(10)),
- ..QueryPlanBranchSpec::default()
- })
- .expect_err("time");
- let invalid_tag = QueryTagFilter::new('1', vec!["value".to_owned()]).expect_err("tag");
- let empty_tag_values = QueryTagFilter::new('t', Vec::new()).expect_err("tag values");
- let empty_tag_value = QueryTagFilter::new('t', vec![String::new()]).expect_err("tag value");
- let empty_search = QuerySearch::new(" ", vec!["carrots".to_owned()]).expect_err("search");
- let empty_search_terms = QuerySearch::new("carrots", Vec::new()).expect_err("terms");
- let empty_search_term = QuerySearch::new("carrots", vec![String::new()]).expect_err("term");
-
- assert_eq!(empty_branches, QueryPlanError::EmptyBranches);
- assert_eq!(
- invalid_time,
- QueryPlanError::InvalidTimeRange {
- since: UnixTimestamp::new(20),
- until: UnixTimestamp::new(10),
- }
- );
- assert_eq!(invalid_tag, QueryPlanError::InvalidTagName { name: '1' });
- assert_eq!(
- empty_tag_values,
- QueryPlanError::EmptyTagValues { name: 't' }
- );
- assert_eq!(empty_tag_value, QueryPlanError::EmptyTagValue { name: 't' });
- assert_eq!(empty_search, QueryPlanError::EmptySearch);
- assert_eq!(empty_search_terms, QueryPlanError::EmptySearch);
- assert_eq!(empty_search_term, QueryPlanError::EmptySearch);
- assert_eq!(
- empty_branches.to_string(),
- "query plan must include at least one branch"
- );
- assert_eq!(
- invalid_time.to_string(),
- "query time range is invalid: since 20 > until 10"
- );
- assert_eq!(
- invalid_tag.to_string(),
- "tag filter name must be ASCII alphabetic, got `1`"
- );
- assert_eq!(
- empty_tag_values.to_string(),
- "tag filter `t` must include at least one value"
- );
- assert_eq!(
- empty_tag_value.to_string(),
- "tag filter `t` values must not be empty"
- );
- assert_eq!(empty_search.to_string(), "search query must include terms");
- assert_eq!(
- [
- QuerySource::RawEvents.as_str(),
- QuerySource::ListingProjections.as_str(),
- QuerySource::SearchDocuments.as_str(),
- ],
- ["raw events", "listing projections", "search documents"]
- );
- assert_eq!(
- [
- QueryExecutionMode::Historical.as_str(),
- QueryExecutionMode::Live.as_str(),
- QueryExecutionMode::HistoricalThenLive.as_str(),
- ],
- ["historical", "live", "historical then live"]
- );
- assert_eq!(
- [
- QuerySort::CreatedAtDescEventIdAsc.as_str(),
- QuerySort::ScoreDescCreatedAtDescEventIdAsc.as_str(),
- ],
- [
- "created_at desc event_id asc",
- "score desc created_at desc event_id asc",
- ]
- );
- assert_eq!(QuerySource::SearchDocuments.to_string(), "search documents");
- assert_eq!(QueryExecutionMode::Live.to_string(), "live");
- assert_eq!(
- QuerySort::ScoreDescCreatedAtDescEventIdAsc.to_string(),
- "score desc created_at desc event_id asc"
- );
- }
-
- #[test]
- fn marketplace_query_model_normalizes_http_search_constraints() {
- let event_id = EventId::new(&"c".repeat(EventId::HEX_LENGTH)).expect("id");
- let cursor = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
- version: 1,
- sort: MarketplaceSort::Distance,
- score: None,
- distance_meters: Some(1234),
- price: None,
- updated_at: UnixTimestamp::new(50),
- event_id: event_id.clone(),
- filter_hash: " hash ".to_owned(),
- })
- .expect("cursor");
- let query = MarketplaceQuery::from_spec(
- MarketplaceQuerySpec {
- q: Some(" Fresh carrots fresh ".to_owned()),
- categories: vec![
- " Vegetables ".to_owned(),
- "csa".to_owned(),
- "vegetables".to_owned(),
- ],
- seller: Some(pubkey("1")),
- statuses: vec![
- MarketplaceListingStatus::Sold,
- MarketplaceListingStatus::Active,
- MarketplaceListingStatus::Active,
- ],
- currencies: vec!["usd".to_owned(), " CAD ".to_owned(), "USD".to_owned()],
- units: vec![ListingUnit::Lb, ListingUnit::Kg, ListingUnit::Lb],
- min_price: Some("001.500".to_owned()),
- max_price: Some("10.0".to_owned()),
- fulfillment: vec![
- FulfillmentMethod::Delivery,
- FulfillmentMethod::Pickup,
- FulfillmentMethod::Delivery,
- ],
- delivery_only: Some(false),
- pickup: Some(true),
- latitude_microdegrees: Some(47_606_200),
- longitude_microdegrees: Some(-122_332_100),
- radius_meters: Some(25_000),
- near: Some(" Ballard ".to_owned()),
- sort: MarketplaceSort::Distance,
- limit: Some(25),
- cursor: Some(cursor.clone()),
- },
- RuntimeLimits::default(),
- )
- .expect("query");
-
- assert_eq!(
- query.text.as_ref().expect("text").raw,
- "Fresh carrots fresh"
- );
- assert_eq!(
- query.text.as_ref().expect("text").terms,
- ["carrots".to_owned(), "fresh".to_owned()]
- );
- assert_eq!(
- query.categories,
- ["csa".to_owned(), "vegetables".to_owned()]
- );
- assert_eq!(query.seller, Some(pubkey("1")));
- assert_eq!(
- query.statuses,
- [
- MarketplaceListingStatus::Active,
- MarketplaceListingStatus::Sold
- ]
- );
- assert_eq!(query.currencies, ["CAD".to_owned(), "USD".to_owned()]);
- assert_eq!(query.units, [ListingUnit::Kg, ListingUnit::Lb]);
- assert_eq!(query.min_price.as_ref().expect("min").raw, "001.500");
- assert_eq!(query.min_price.as_ref().expect("min").whole, "1");
- assert_eq!(query.min_price.as_ref().expect("min").fraction, "5");
- assert_eq!(query.max_price.as_ref().expect("max").whole, "10");
- assert_eq!(query.max_price.as_ref().expect("max").fraction, "");
- assert_eq!(
- query.fulfillment,
- [FulfillmentMethod::Pickup, FulfillmentMethod::Delivery]
- );
- assert_eq!(query.delivery_only, Some(false));
- assert_eq!(query.pickup, Some(true));
- assert_eq!(
- query.location.point,
- Some(MarketplaceGeoPoint {
- latitude_microdegrees: 47_606_200,
- longitude_microdegrees: -122_332_100,
- })
- );
- assert_eq!(query.location.radius_meters, Some(25_000));
- assert_eq!(query.location.near, Some("ballard".to_owned()));
- assert!(query.location.has_distance_reference());
- assert_eq!(query.sort, MarketplaceSort::Distance);
- assert_eq!(query.limit, 25);
- assert_eq!(query.cursor, Some(cursor));
- assert_eq!(event_id.as_str(), &"c".repeat(EventId::HEX_LENGTH));
- }
-
- #[test]
- fn marketplace_query_model_handles_defaults_labels_and_cursors() {
- let default_query =
- MarketplaceQuery::from_spec(MarketplaceQuerySpec::default(), RuntimeLimits::default())
- .expect("default query");
- let blank_query = MarketplaceQuery::from_spec(
- MarketplaceQuerySpec {
- q: Some(" ".to_owned()),
- ..MarketplaceQuerySpec::default()
- },
- RuntimeLimits::default(),
- )
- .expect("blank query");
- let relevance_cursor = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
- version: 1,
- sort: MarketplaceSort::Relevance,
- score: Some(9),
- distance_meters: None,
- price: None,
- updated_at: UnixTimestamp::new(60),
- event_id: EventId::new(&"d".repeat(EventId::HEX_LENGTH)).expect("id"),
- filter_hash: "filter".to_owned(),
- })
- .expect("relevance cursor");
- let price_cursor = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
- version: 1,
- sort: MarketplaceSort::PriceAsc,
- score: None,
- distance_meters: None,
- price: Some("009.9900".to_owned()),
- updated_at: UnixTimestamp::new(61),
- event_id: EventId::new(&"e".repeat(EventId::HEX_LENGTH)).expect("id"),
- filter_hash: "price".to_owned(),
- })
- .expect("price cursor");
- let freshness_cursor = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
- version: 1,
- sort: MarketplaceSort::Freshness,
- score: None,
- distance_meters: None,
- price: None,
- updated_at: UnixTimestamp::new(62),
- event_id: EventId::new(&"f".repeat(EventId::HEX_LENGTH)).expect("id"),
- filter_hash: "freshness".to_owned(),
- })
- .expect("freshness cursor");
- let zero_decimal = MarketplaceDecimal::new("price", "000").expect("zero");
- let fraction_decimal = MarketplaceDecimal::new("price", "1.2300").expect("fraction");
-
- assert_eq!(default_query.text, None);
- assert_eq!(default_query.limit, MarketplaceQuery::DEFAULT_LIMIT);
- assert_eq!(default_query.sort, MarketplaceSort::Relevance);
- assert_eq!(blank_query.text, None);
- assert_eq!(relevance_cursor.score, Some(9));
- assert_eq!(price_cursor.price.as_ref().expect("price").whole, "9");
- assert_eq!(price_cursor.price.as_ref().expect("price").fraction, "99");
- assert_eq!(freshness_cursor.filter_hash, "freshness");
- assert_eq!(zero_decimal.whole, "0");
- assert_eq!(zero_decimal.fraction, "");
- assert_eq!(fraction_decimal.whole, "1");
- assert_eq!(fraction_decimal.fraction, "23");
- assert_eq!(
- [
- MarketplaceListingStatus::Active.as_str(),
- MarketplaceListingStatus::Sold.as_str(),
- MarketplaceListingStatus::Draft.as_str(),
- MarketplaceListingStatus::Inactive.as_str(),
- MarketplaceListingStatus::Expired.as_str(),
- MarketplaceListingStatus::Deleted.as_str(),
- MarketplaceListingStatus::Hidden.as_str(),
- MarketplaceListingStatus::Rejected.as_str(),
- ],
- [
- "active", "sold", "draft", "inactive", "expired", "deleted", "hidden", "rejected",
- ]
- );
- assert_eq!(
- [
- MarketplaceSort::Relevance.as_str(),
- MarketplaceSort::Freshness.as_str(),
- MarketplaceSort::PriceAsc.as_str(),
- MarketplaceSort::PriceDesc.as_str(),
- MarketplaceSort::Distance.as_str(),
- MarketplaceSort::SellerTrust.as_str(),
- ],
- [
- "relevance",
- "freshness",
- "price_asc",
- "price_desc",
- "distance",
- "seller_trust",
- ]
- );
- assert_eq!(MarketplaceListingStatus::Hidden.to_string(), "hidden");
- assert_eq!(MarketplaceSort::SellerTrust.to_string(), "seller_trust");
- }
-
- #[test]
- fn marketplace_query_model_rejects_invalid_constraints() {
- let runtime_limit = MarketplaceQuery::from_spec(
- MarketplaceQuerySpec {
- q: Some("fresh carrots".to_owned()),
- ..MarketplaceQuerySpec::default()
- },
- limits_with(|values| values.max_search_tokens = 1),
- )
- .expect_err("runtime");
- let empty_category = MarketplaceQuery::from_spec(
- MarketplaceQuerySpec {
- categories: vec![" ".to_owned()],
- ..MarketplaceQuerySpec::default()
- },
- RuntimeLimits::default(),
- )
- .expect_err("category");
- let empty_near =
- MarketplaceLocationFilter::from_spec(None, None, None, Some(" ".to_owned()))
- .expect_err("near");
- let invalid_decimal = MarketplaceDecimal::new("min_price", "1..2").expect_err("decimal");
- let empty_whole = MarketplaceDecimal::new("min_price", ".2").expect_err("decimal");
- let bad_whole = MarketplaceDecimal::new("min_price", "a.2").expect_err("decimal");
- let empty_fraction = MarketplaceDecimal::new("min_price", "1.").expect_err("decimal");
- let bad_fraction = MarketplaceDecimal::new("min_price", "1.a").expect_err("decimal");
- let invalid_price_range = MarketplaceQuery::from_spec(
- MarketplaceQuerySpec {
- min_price: Some("2".to_owned()),
- max_price: Some("1.99".to_owned()),
- ..MarketplaceQuerySpec::default()
- },
- RuntimeLimits::default(),
- )
- .expect_err("price range");
- let invalid_fraction_range = MarketplaceQuery::from_spec(
- MarketplaceQuerySpec {
- min_price: Some("1.2".to_owned()),
- max_price: Some("1.10".to_owned()),
- ..MarketplaceQuerySpec::default()
- },
- RuntimeLimits::default(),
- )
- .expect_err("fraction range");
- let missing_lon =
- MarketplaceLocationFilter::from_spec(Some(1), None, None, None).expect_err("location");
- let query_missing_lon = MarketplaceQuery::from_spec(
- MarketplaceQuerySpec {
- latitude_microdegrees: Some(1),
- ..MarketplaceQuerySpec::default()
- },
- RuntimeLimits::default(),
- )
- .expect_err("query location");
- let zero_radius = MarketplaceLocationFilter::from_spec(Some(1), Some(2), Some(0), None)
- .expect_err("radius");
- let radius_without_point = MarketplaceLocationFilter::from_spec(None, None, Some(1), None)
- .expect_err("radius point");
- let bad_latitude = MarketplaceGeoPoint::new(90_000_001, 0).expect_err("lat");
- let bad_longitude = MarketplaceGeoPoint::new(0, 180_000_001).expect_err("lon");
- let missing_distance_reference = MarketplaceQuery::from_spec(
- MarketplaceQuerySpec {
- sort: MarketplaceSort::Distance,
- ..MarketplaceQuerySpec::default()
- },
- RuntimeLimits::default(),
- )
- .expect_err("distance");
- let bad_limit = MarketplaceQuery::from_spec(
- MarketplaceQuerySpec {
- limit: Some(MarketplaceQuery::MAX_LIMIT + 1),
- ..MarketplaceQuerySpec::default()
- },
- RuntimeLimits::default(),
- )
- .expect_err("limit");
- let cursor_sort = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
- version: 1,
- sort: MarketplaceSort::Relevance,
- score: Some(1),
- distance_meters: None,
- price: None,
- updated_at: UnixTimestamp::new(70),
- event_id: EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"),
- filter_hash: "cursor".to_owned(),
- })
- .expect("cursor");
- let cursor_sort_mismatch = MarketplaceQuery::from_spec(
- MarketplaceQuerySpec {
- sort: MarketplaceSort::Freshness,
- cursor: Some(cursor_sort),
- ..MarketplaceQuerySpec::default()
- },
- RuntimeLimits::default(),
- )
- .expect_err("cursor sort");
-
- assert_eq!(
- runtime_limit.kind(),
- MarketplaceQueryErrorKind::RuntimeLimit
- );
- assert!(runtime_limit.message().starts_with("runtime limit:"));
- assert_eq!(runtime_limit.to_string(), runtime_limit.message());
- assert_eq!(
- empty_category.kind(),
- MarketplaceQueryErrorKind::EmptyFilterValue
- );
- assert_eq!(
- empty_category.message(),
- "category filter value must not be empty"
- );
- assert_eq!(
- empty_near.kind(),
- MarketplaceQueryErrorKind::EmptyFilterValue
- );
- assert_eq!(empty_near.message(), "near filter value must not be empty");
- for error in [
- invalid_decimal,
- empty_whole,
- bad_whole,
- empty_fraction,
- bad_fraction,
- ] {
- assert_eq!(error.kind(), MarketplaceQueryErrorKind::InvalidDecimal);
- assert_eq!(
- error.message(),
- "min_price must be an exact unsigned decimal"
- );
- }
- assert_eq!(
- invalid_price_range.kind(),
- MarketplaceQueryErrorKind::InvalidPriceRange
- );
- assert_eq!(
- invalid_fraction_range.kind(),
- MarketplaceQueryErrorKind::InvalidPriceRange
- );
- for error in [
- missing_lon,
- query_missing_lon,
- zero_radius,
- radius_without_point,
- bad_latitude,
- bad_longitude,
- ] {
- assert_eq!(error.kind(), MarketplaceQueryErrorKind::InvalidLocation);
- }
- assert_eq!(
- missing_distance_reference.kind(),
- MarketplaceQueryErrorKind::MissingDistanceReference
- );
- assert_eq!(bad_limit.kind(), MarketplaceQueryErrorKind::LimitOutOfRange);
- assert_eq!(
- cursor_sort_mismatch.kind(),
- MarketplaceQueryErrorKind::CursorSortMismatch
- );
- }
-
- #[test]
- fn marketplace_cursor_model_rejects_invalid_payloads() {
- let base = || MarketplaceCursorSpec {
- version: 1,
- sort: MarketplaceSort::Freshness,
- score: None,
- distance_meters: None,
- price: None,
- updated_at: UnixTimestamp::new(80),
- event_id: EventId::new(&"b".repeat(EventId::HEX_LENGTH)).expect("id"),
- filter_hash: "filter".to_owned(),
- };
- let zero_version = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
- version: 0,
- ..base()
- })
- .expect_err("version");
- let empty_hash = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
- filter_hash: " ".to_owned(),
- ..base()
- })
- .expect_err("hash");
- let missing_score = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
- sort: MarketplaceSort::Relevance,
- ..base()
- })
- .expect_err("score");
- let missing_distance = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
- sort: MarketplaceSort::Distance,
- ..base()
- })
- .expect_err("distance");
- let missing_price = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
- sort: MarketplaceSort::PriceDesc,
- ..base()
- })
- .expect_err("price");
- let invalid_price = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
- sort: MarketplaceSort::PriceAsc,
- price: Some("bad".to_owned()),
- ..base()
- })
- .expect_err("price decimal");
-
- for error in [
- zero_version,
- empty_hash,
- missing_score,
- missing_distance,
- missing_price,
- ] {
- assert_eq!(error.kind(), MarketplaceQueryErrorKind::InvalidCursor);
- }
- assert_eq!(
- invalid_price.kind(),
- MarketplaceQueryErrorKind::InvalidDecimal
- );
- }
-
- #[test]
- fn nostr_filter_compiler_builds_search_backed_query_plans() {
- let filter = filter_from_value(&serde_json::json!({
- "ids": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
- "authors": ["1111111111111111111111111111111111111111111111111111111111111111"],
- "kinds": [1, 30402],
- "#t": ["vegetables", "carrots", "vegetables"],
- "since": 10,
- "until": 20,
- "limit": 25,
- "search": "fresh carrots"
- }))
- .expect("filter");
- let compiler = NostrFilterCompiler::default();
- let plan = compiler
- .compile(&[filter], QueryExecutionMode::HistoricalThenLive)
- .expect("plan");
- let branch = &plan.branches()[0];
-
- assert_eq!(compiler.limits(), RuntimeLimits::default());
- assert_eq!(plan.source(), QuerySource::SearchDocuments);
- assert_eq!(plan.sort(), QuerySort::ScoreDescCreatedAtDescEventIdAsc);
- assert_eq!(plan.mode(), QueryExecutionMode::HistoricalThenLive);
- assert!(plan.requires_historical_query());
- assert!(plan.subscribes_to_live_events());
- assert_eq!(branch.ids()[0].as_str(), &"b".repeat(EventId::HEX_LENGTH));
- assert_eq!(branch.authors()[0], pubkey("1"));
- assert_eq!(
- branch.kinds(),
- &[
- Kind::new(1).expect("kind"),
- Kind::new(30_402).expect("kind")
- ]
- );
- assert_eq!(
- branch.tag_filters().get(&'t').expect("tag"),
- &["carrots".to_owned(), "vegetables".to_owned()]
- );
- assert_eq!(branch.since(), Some(UnixTimestamp::new(10)));
- assert_eq!(branch.until(), Some(UnixTimestamp::new(20)));
- assert_eq!(branch.limit(), Some(25));
- assert_eq!(
- branch.search().expect("search").terms(),
- &["carrots".to_owned(), "fresh".to_owned()]
- );
- }
-
- #[test]
- fn nostr_filter_compiler_preserves_limit_zero_historical_skip() {
- let filter = filter_from_value(&serde_json::json!({
- "limit": 0,
- "#p": ["1111111111111111111111111111111111111111111111111111111111111111"]
- }))
- .expect("filter");
- let plan = NostrFilterCompiler::default()
- .compile(&[filter], QueryExecutionMode::HistoricalThenLive)
- .expect("plan");
-
- assert_eq!(plan.source(), QuerySource::RawEvents);
- assert_eq!(plan.sort(), QuerySort::CreatedAtDescEventIdAsc);
- assert!(!plan.requires_historical_query());
- assert!(plan.subscribes_to_live_events());
- assert_eq!(
- plan.branches()[0].tag_filters().get(&'p').expect("p"),
- &["1111111111111111111111111111111111111111111111111111111111111111".to_owned()]
- );
- }
-
- #[test]
- fn nostr_filter_compiler_rejects_limit_and_plan_errors() {
- let empty_filters = NostrFilterCompiler::default()
- .compile(&[], QueryExecutionMode::Historical)
- .expect_err("empty filters");
- let too_many_filters = NostrFilterCompiler::new(limits_with(|values| {
- values.max_filters_per_subscription = 1;
- }))
- .compile(
- &[
- tangle_protocol::Filter::empty(),
- tangle_protocol::Filter::empty(),
- ],
- QueryExecutionMode::Historical,
- )
- .expect_err("filter count");
- let too_complex = NostrFilterCompiler::new(limits_with(|values| {
- values.max_filter_complexity = 1;
- }))
- .compile(
- &[filter_from_value(&serde_json::json!({
- "ids": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
- "authors": ["1111111111111111111111111111111111111111111111111111111111111111"]
- }))
- .expect("filter")],
- QueryExecutionMode::Historical,
- )
- .expect_err("complexity");
- let blank_search = NostrFilterCompiler::default()
- .compile(
- &[filter_from_value(&serde_json::json!({ "search": " " })).expect("filter")],
- QueryExecutionMode::Historical,
- )
- .expect_err("blank search");
- let too_many_search_tokens = NostrFilterCompiler::new(limits_with(|values| {
- values.max_search_tokens = 1;
- }))
- .compile(
- &[
- filter_from_value(&serde_json::json!({ "search": "fresh carrots" }))
- .expect("filter"),
- ],
- QueryExecutionMode::Historical,
- )
- .expect_err("search tokens");
- let empty_tag = NostrFilterCompiler::default()
- .compile(
- &[filter_from_value(&serde_json::json!({ "#t": [""] })).expect("filter")],
- QueryExecutionMode::Historical,
- )
- .expect_err("empty tag");
-
- assert_eq!(empty_filters.kind(), NostrFilterCompileErrorKind::QueryPlan);
- assert_eq!(
- too_many_filters.kind(),
- NostrFilterCompileErrorKind::RuntimeLimit
- );
- assert_eq!(
- too_complex.kind(),
- NostrFilterCompileErrorKind::RuntimeLimit
- );
- assert_eq!(blank_search.kind(), NostrFilterCompileErrorKind::QueryPlan);
- assert_eq!(
- too_many_search_tokens.kind(),
- NostrFilterCompileErrorKind::RuntimeLimit
- );
- assert_eq!(empty_tag.kind(), NostrFilterCompileErrorKind::QueryPlan);
- assert_eq!(
- empty_filters.to_string(),
- "query plan: query plan must include at least one branch"
- );
- assert!(too_many_filters.to_string().starts_with("runtime limit:"));
- assert!(too_complex.to_string().starts_with("runtime limit:"));
- assert_eq!(
- blank_search.to_string(),
- "query plan: search query must include terms"
- );
- assert!(too_many_search_tokens.to_string().contains("search tokens"));
- assert_eq!(
- empty_tag.to_string(),
- "query plan: tag filter `t` values must not be empty"
- );
- }
-
- #[test]
- fn nip50_query_compiler_builds_search_document_plan_from_plain_terms() {
- let filter = filter_from_value(&serde_json::json!({
- "authors": ["1111111111111111111111111111111111111111111111111111111111111111"],
- "kinds": [30402],
- "#t": ["carrots", "vegetables"],
- "since": 10,
- "until": 20,
- "limit": 10,
- "search": "fresh seller:ignored carrots status:ignored carrots"
- }))
- .expect("filter");
- let compiler = Nip50QueryCompiler::default();
- let plan = compiler
- .compile(&[filter], QueryExecutionMode::Historical)
- .expect("plan");
- let branch = &plan.branches()[0];
-
- assert_eq!(compiler.limits(), RuntimeLimits::default());
- assert_eq!(plan.source(), QuerySource::SearchDocuments);
- assert_eq!(plan.sort(), QuerySort::ScoreDescCreatedAtDescEventIdAsc);
- assert_eq!(plan.mode(), QueryExecutionMode::Historical);
- assert!(plan.requires_historical_query());
- assert!(!plan.subscribes_to_live_events());
- assert_eq!(branch.authors()[0], pubkey("1"));
- assert_eq!(branch.kinds(), &[Kind::new(30_402).expect("kind")]);
- assert_eq!(
- branch.tag_filters().get(&'t').expect("tag"),
- &["carrots".to_owned(), "vegetables".to_owned()]
- );
- assert_eq!(branch.since(), Some(UnixTimestamp::new(10)));
- assert_eq!(branch.until(), Some(UnixTimestamp::new(20)));
- assert_eq!(branch.limit(), Some(10));
- assert_eq!(
- branch.search().expect("search").raw(),
- "fresh carrots carrots"
- );
- assert_eq!(
- branch.search().expect("search").terms(),
- &["carrots".to_owned(), "fresh".to_owned()]
- );
- }
-
- #[test]
- fn nip50_query_compiler_ignores_extension_only_filters() {
- let extension_only = filter_from_value(&serde_json::json!({
- "search": "seller:ignored status:ignored",
- "limit": 0
- }))
- .expect("extension");
- let searchable = filter_from_value(&serde_json::json!({
- "search": "greens",
- "kinds": [1]
- }))
- .expect("search");
- let plan = Nip50QueryCompiler::default()
- .compile(
- &[extension_only, tangle_protocol::Filter::empty(), searchable],
- QueryExecutionMode::HistoricalThenLive,
- )
- .expect("plan");
-
- assert_eq!(plan.branches().len(), 1);
- assert_eq!(plan.branches()[0].search().expect("search").raw(), "greens");
- assert_eq!(plan.branches()[0].kinds(), &[Kind::new(1).expect("kind")]);
- assert!(plan.requires_historical_query());
- assert!(plan.subscribes_to_live_events());
- }
-
- #[test]
- fn nip50_query_compiler_rejects_missing_terms_limits_and_bad_plans() {
- let empty = Nip50QueryCompiler::default()
- .compile(&[], QueryExecutionMode::Historical)
- .expect_err("empty");
- let extension_only = Nip50QueryCompiler::default()
- .compile(
- &[filter_from_value(&serde_json::json!({
- "search": "seller:ignored status:ignored"
- }))
- .expect("filter")],
- QueryExecutionMode::Historical,
- )
- .expect_err("extension only");
- let too_many_filters = Nip50QueryCompiler::new(limits_with(|values| {
- values.max_filters_per_subscription = 1;
- }))
- .compile(
- &[
- filter_from_value(&serde_json::json!({ "search": "carrots" })).expect("filter"),
- filter_from_value(&serde_json::json!({ "search": "greens" })).expect("filter"),
- ],
- QueryExecutionMode::Historical,
- )
- .expect_err("filter count");
- let too_many_tokens = Nip50QueryCompiler::new(limits_with(|values| {
- values.max_search_tokens = 1;
- }))
- .compile(
- &[
- filter_from_value(&serde_json::json!({ "search": "fresh carrots" }))
- .expect("filter"),
- ],
- QueryExecutionMode::Historical,
- )
- .expect_err("tokens");
- let bad_plan = Nip50QueryCompiler::default()
- .compile(
- &[filter_from_value(&serde_json::json!({
- "search": "carrots",
- "since": 20,
- "until": 10
- }))
- .expect("filter")],
- QueryExecutionMode::Historical,
- )
- .expect_err("plan");
- let empty_tag = Nip50QueryCompiler::default()
- .compile(
- &[filter_from_value(&serde_json::json!({
- "search": "carrots",
- "#t": [""]
- }))
- .expect("filter")],
- QueryExecutionMode::Historical,
- )
- .expect_err("tag");
-
- assert_eq!(empty.kind(), Nip50QueryCompileErrorKind::MissingSearchTerms);
- assert_eq!(
- extension_only.kind(),
- Nip50QueryCompileErrorKind::MissingSearchTerms
- );
- assert_eq!(
- too_many_filters.kind(),
- Nip50QueryCompileErrorKind::RuntimeLimit
- );
- assert_eq!(
- too_many_tokens.kind(),
- Nip50QueryCompileErrorKind::RuntimeLimit
- );
- assert_eq!(bad_plan.kind(), Nip50QueryCompileErrorKind::QueryPlan);
- assert_eq!(empty_tag.kind(), Nip50QueryCompileErrorKind::QueryPlan);
- assert_eq!(
- empty.to_string(),
- "nip50 query must include plain search terms"
- );
- assert!(
- too_many_filters
- .to_string()
- .contains("filters per subscription")
- );
- assert!(too_many_tokens.to_string().contains("search tokens"));
- assert_eq!(
- bad_plan.to_string(),
- "query plan: query time range is invalid: since 20 > until 10"
- );
- assert_eq!(
- empty_tag.to_string(),
- "query plan: tag filter `t` values must not be empty"
- );
- }
-
- #[test]
- fn subscription_matcher_matches_live_query_plan_branches() {
- let event = event_with(
- vec![
- Tag::new(vec!["t".to_owned(), "carrots".to_owned()]).expect("tag"),
- Tag::new(vec!["title".to_owned(), "Sweet carrots".to_owned()]).expect("tag"),
- ],
- "Sweet storage carrots.",
- UnixTimestamp::new(100),
- );
- let matching_branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- ids: vec![event.id().clone()],
- authors: vec![event.unsigned().pubkey().clone()],
- kinds: vec![event.unsigned().kind()],
- tag_filters: vec![QueryTagFilter::new('t', vec!["carrots".to_owned()]).expect("tag")],
- since: Some(UnixTimestamp::new(99)),
- until: Some(UnixTimestamp::new(101)),
- search: Some(
- QuerySearch::new(
- "sweet carrots",
- vec!["sweet".to_owned(), "carrots".to_owned()],
- )
- .expect("search"),
- ),
- ..QueryPlanBranchSpec::default()
- })
- .expect("matching");
- let id_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- ids: vec![EventId::new(&"c".repeat(EventId::HEX_LENGTH)).expect("id")],
- ..QueryPlanBranchSpec::default()
- })
- .expect("id");
- let author_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- authors: vec![pubkey("2")],
- ..QueryPlanBranchSpec::default()
- })
- .expect("author");
- let kind_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- kinds: vec![Kind::new(1).expect("kind")],
- ..QueryPlanBranchSpec::default()
- })
- .expect("kind");
- let since_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- since: Some(UnixTimestamp::new(101)),
- ..QueryPlanBranchSpec::default()
- })
- .expect("since");
- let until_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- until: Some(UnixTimestamp::new(99)),
- ..QueryPlanBranchSpec::default()
- })
- .expect("until");
- let tag_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- tag_filters: vec![QueryTagFilter::new('t', vec!["greens".to_owned()]).expect("tag")],
- ..QueryPlanBranchSpec::default()
- })
- .expect("tag");
- let search_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- search: Some(QuerySearch::new("missing", vec!["missing".to_owned()]).expect("search")),
- ..QueryPlanBranchSpec::default()
- })
- .expect("search");
- let no_search_match = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- limit: Some(0),
- ..QueryPlanBranchSpec::default()
- })
- .expect("no search");
- let plan = QueryPlan::new(
- QuerySource::SearchDocuments,
- QueryExecutionMode::HistoricalThenLive,
- QuerySort::ScoreDescCreatedAtDescEventIdAsc,
- vec![
- id_miss,
- author_miss,
- kind_miss,
- since_miss,
- until_miss,
- tag_miss,
- search_miss,
- matching_branch,
- no_search_match,
- ],
- )
- .expect("plan");
- let matcher = SubscriptionMatcher::default();
- let matched = matcher.match_event(&plan, &event);
-
- assert_eq!(
- matcher.live_search_policy(),
- LiveSearchPolicy::BestEffortTokenMatch
- );
- assert!(matched.matched());
- assert_eq!(matched.branch_indexes(), &[7, 8]);
- }
-
- #[test]
- fn subscription_matcher_respects_historical_mode_and_live_search_policy() {
- let event = event_with(
- vec![Tag::new(vec!["t".to_owned(), "carrots".to_owned()]).expect("tag")],
- "Sweet storage carrots.",
- UnixTimestamp::new(100),
- );
- let search_branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- search: Some(
- QuerySearch::new(
- "storage carrots",
- vec!["storage".to_owned(), "carrots".to_owned()],
- )
- .expect("search"),
- ),
- ..QueryPlanBranchSpec::default()
- })
- .expect("search branch");
- let historical = QueryPlan::new(
- QuerySource::RawEvents,
- QueryExecutionMode::Historical,
- QuerySort::CreatedAtDescEventIdAsc,
- vec![search_branch.clone()],
- )
- .expect("historical");
- let live_search = QueryPlan::new(
- QuerySource::SearchDocuments,
- QueryExecutionMode::Live,
- QuerySort::ScoreDescCreatedAtDescEventIdAsc,
- vec![search_branch],
- )
- .expect("live search");
- let disabled = SubscriptionMatcher::new(LiveSearchPolicy::DisabledLiveSearch);
- let historical_match = SubscriptionMatcher::default().match_event(&historical, &event);
- let disabled_match = disabled.match_event(&live_search, &event);
- let empty = SubscriptionMatch::empty();
-
- assert_eq!(
- disabled.live_search_policy(),
- LiveSearchPolicy::DisabledLiveSearch
- );
- assert!(!historical_match.matched());
- assert_eq!(historical_match.branch_indexes(), &[] as &[usize]);
- assert!(!disabled_match.matched());
- assert!(!empty.matched());
- assert_eq!(empty.branch_indexes(), &[] as &[usize]);
- assert_eq!(
- LiveSearchPolicy::BestEffortTokenMatch.as_str(),
- "best_effort_token_match"
- );
- assert_eq!(
- LiveSearchPolicy::DisabledLiveSearch.to_string(),
- "disabled_live_search"
- );
- }
-
- #[test]
- fn subscription_manager_inserts_replaces_closes_and_fans_out() {
- let event = event_with(
- vec![Tag::new(vec!["t".to_owned(), "carrots".to_owned()]).expect("tag")],
- "Sweet storage carrots.",
- UnixTimestamp::new(100),
- );
- let matching_branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- tag_filters: vec![QueryTagFilter::new('t', vec!["carrots".to_owned()]).expect("tag")],
- search: Some(QuerySearch::new("carrots", vec!["carrots".to_owned()]).expect("search")),
- ..QueryPlanBranchSpec::default()
- })
- .expect("matching");
- let no_match_branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec {
- ids: vec![EventId::new(&"c".repeat(EventId::HEX_LENGTH)).expect("id")],
- ..QueryPlanBranchSpec::default()
- })
- .expect("no match");
- let matching_plan = QueryPlan::new(
- QuerySource::SearchDocuments,
- QueryExecutionMode::Live,
- QuerySort::ScoreDescCreatedAtDescEventIdAsc,
- vec![matching_branch],
- )
- .expect("matching plan");
- let no_match_plan = QueryPlan::new(
- QuerySource::RawEvents,
- QueryExecutionMode::Live,
- QuerySort::CreatedAtDescEventIdAsc,
- vec![no_match_branch],
- )
- .expect("no match plan");
- let mut manager = SubscriptionManager::default();
- let id_a = SubscriptionId::new("a").expect("id");
- let id_b = SubscriptionId::new("b").expect("id");
-
- assert_eq!(manager.limits(), RuntimeLimits::default());
- assert_eq!(manager.matcher(), SubscriptionMatcher::default());
- assert_eq!(
- manager.subscribe(id_b.clone(), matching_plan.clone()),
- Ok(SubscriptionAddOutcome::Inserted)
- );
- assert_eq!(
- manager.subscribe(id_a.clone(), no_match_plan),
- Ok(SubscriptionAddOutcome::Inserted)
- );
- assert_eq!(manager.active_count(), 2);
- assert!(manager.plan(&id_a).is_some());
- assert_eq!(manager.match_event(&event).len(), 1);
- assert_eq!(manager.match_event(&event)[0].subscription_id, id_b);
- assert_eq!(manager.match_event(&event)[0].branch_indexes, [0]);
- assert_eq!(
- manager.subscribe(id_a.clone(), matching_plan),
- Ok(SubscriptionAddOutcome::Replaced)
- );
- let matches = manager.match_event(&event);
-
- assert_eq!(matches.len(), 2);
- assert_eq!(matches[0].subscription_id, id_a);
- assert_eq!(matches[0].branch_indexes, [0]);
- assert_eq!(
- matches[1].subscription_id,
- SubscriptionId::new("b").expect("id")
- );
- assert_eq!(
- manager.close(&SubscriptionId::new("b").expect("id")),
- SubscriptionCloseOutcome::Closed
- );
- assert_eq!(
- manager.close(&SubscriptionId::new("b").expect("id")),
- SubscriptionCloseOutcome::NotFound
- );
- assert_eq!(manager.active_count(), 1);
- assert!(
- manager
- .plan(&SubscriptionId::new("b").expect("id"))
- .is_none()
- );
- }
-
- #[test]
- fn subscription_manager_enforces_subscription_count_limits() {
- let branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec::default()).expect("branch");
- let plan = QueryPlan::new(
- QuerySource::RawEvents,
- QueryExecutionMode::Live,
- QuerySort::CreatedAtDescEventIdAsc,
- vec![branch],
- )
- .expect("plan");
- let mut manager = SubscriptionManager::new(
- limits_with(|values| values.max_subscriptions_per_connection = 1),
- SubscriptionMatcher::new(LiveSearchPolicy::DisabledLiveSearch),
- );
- let id_a = SubscriptionId::new("a").expect("id");
- let id_b = SubscriptionId::new("b").expect("id");
-
- assert_eq!(
- manager.subscribe(id_a.clone(), plan.clone()),
- Ok(SubscriptionAddOutcome::Inserted)
- );
- assert_eq!(
- manager.subscribe(id_a, plan.clone()),
- Ok(SubscriptionAddOutcome::Replaced)
- );
- let too_many = manager.subscribe(id_b, plan).expect_err("limit");
-
- assert_eq!(too_many.kind(), SubscriptionManagerErrorKind::RuntimeLimit);
- assert_eq!(
- too_many.to_string(),
- "runtime limit: subscriptions per connection exceeded: 2 > 1"
- );
- }
-
- #[test]
- fn auth_challenge_state_issues_and_authenticates_nip42_events() {
- let mut state =
- AuthChallengeState::new(" wss://relay.radroots.test ", 10).expect("auth state");
- let default_state = AuthChallengeState::default();
- let challenge = state
- .issue_challenge(" challenge-001 ", UnixTimestamp::new(100))
- .expect("challenge");
- let auth = relay_auth_event("wss://relay.radroots.test", "challenge-001", 105);
- let authenticated = state
- .authenticate(&auth, UnixTimestamp::new(105))
- .expect("authenticated");
-
- assert_eq!(default_state.relay_url(), "wss://relay.radroots.test");
- assert_eq!(default_state.ttl_seconds(), 300);
- assert_eq!(state.relay_url(), "wss://relay.radroots.test");
- assert_eq!(state.ttl_seconds(), 10);
- assert_eq!(challenge.value, "challenge-001");
- assert_eq!(challenge.relay_url, "wss://relay.radroots.test");
- assert_eq!(challenge.issued_at, UnixTimestamp::new(100));
- assert_eq!(challenge.expires_at, UnixTimestamp::new(110));
- assert_eq!(authenticated.pubkey, FixtureKey::Seller.public_key());
- assert_eq!(state.authenticated_pubkey(), Some(auth.pubkey()));
- assert_eq!(state.active_challenge(), None);
-
- state.clear_authentication();
- assert_eq!(state.authenticated_pubkey(), None);
- state
- .issue_challenge("challenge-002", UnixTimestamp::new(120))
- .expect("challenge");
- assert_eq!(state.authenticated_pubkey(), None);
- assert_eq!(
- state.active_challenge().expect("active").expires_at,
- UnixTimestamp::new(130)
- );
- }
-
- #[test]
- fn auth_challenge_state_rejects_invalid_and_mismatched_auth() {
- let invalid_relay = AuthChallengeState::new(" ", 10).expect_err("relay");
- let invalid_ttl = AuthChallengeState::new("wss://relay.radroots.test", 0).expect_err("ttl");
- let mut empty_challenge =
- AuthChallengeState::new("wss://relay.radroots.test", 10).expect("state");
- let empty_challenge = empty_challenge
- .issue_challenge(" ", UnixTimestamp::new(1))
- .expect_err("challenge");
- let missing_challenge = AuthChallengeState::new("wss://relay.radroots.test", 10)
- .expect("state")
- .authenticate(
- &relay_auth_event("wss://relay.radroots.test", "challenge-001", 10),
- UnixTimestamp::new(10),
- )
- .expect_err("missing");
- let mut expired = AuthChallengeState::new("wss://relay.radroots.test", 5).expect("state");
- expired
- .issue_challenge("challenge-001", UnixTimestamp::new(10))
- .expect("challenge");
- let expired = expired
- .authenticate(
- &relay_auth_event("wss://relay.radroots.test", "challenge-001", 11),
- UnixTimestamp::new(16),
- )
- .expect_err("expired");
- let mut relay_mismatch =
- AuthChallengeState::new("wss://relay.radroots.test", 10).expect("state");
- relay_mismatch
- .issue_challenge("challenge-001", UnixTimestamp::new(10))
- .expect("challenge");
- let relay_mismatch = relay_mismatch
- .authenticate(
- &relay_auth_event("wss://other.radroots.test", "challenge-001", 11),
- UnixTimestamp::new(11),
- )
- .expect_err("relay");
- let mut challenge_mismatch =
- AuthChallengeState::new("wss://relay.radroots.test", 10).expect("state");
- challenge_mismatch
- .issue_challenge("challenge-001", UnixTimestamp::new(10))
- .expect("challenge");
- let challenge_mismatch = challenge_mismatch
- .authenticate(
- &relay_auth_event("wss://relay.radroots.test", "challenge-002", 11),
- UnixTimestamp::new(11),
- )
- .expect_err("challenge");
- let mut created_before =
- AuthChallengeState::new("wss://relay.radroots.test", 10).expect("state");
- created_before
- .issue_challenge("challenge-001", UnixTimestamp::new(20))
- .expect("challenge");
- let created_before = created_before
- .authenticate(
- &relay_auth_event("wss://relay.radroots.test", "challenge-001", 19),
- UnixTimestamp::new(21),
- )
- .expect_err("created before");
-
- assert_eq!(
- invalid_relay.kind(),
- AuthChallengeStateErrorKind::InvalidRelayUrl
- );
- assert_eq!(invalid_relay.to_string(), "relay url must not be empty");
- assert_eq!(invalid_ttl.kind(), AuthChallengeStateErrorKind::InvalidTtl);
- assert_eq!(
- invalid_ttl.to_string(),
- "auth challenge ttl must be greater than zero"
- );
- assert_eq!(
- empty_challenge.kind(),
- AuthChallengeStateErrorKind::EmptyChallenge
- );
- assert_eq!(
- empty_challenge.to_string(),
- "auth challenge must not be empty"
- );
- assert_eq!(
- missing_challenge.kind(),
- AuthChallengeStateErrorKind::MissingChallenge
- );
- assert_eq!(missing_challenge.to_string(), "auth challenge is missing");
- assert_eq!(expired.kind(), AuthChallengeStateErrorKind::Expired);
- assert_eq!(expired.to_string(), "auth challenge expired at 15, now 16");
- assert_eq!(
- relay_mismatch.kind(),
- AuthChallengeStateErrorKind::RelayMismatch
- );
- assert_eq!(
- relay_mismatch.to_string(),
- "auth relay mismatch: expected wss://relay.radroots.test, got wss://other.radroots.test"
- );
- assert_eq!(
- challenge_mismatch.kind(),
- AuthChallengeStateErrorKind::ChallengeMismatch
- );
- assert_eq!(challenge_mismatch.to_string(), "auth challenge mismatch");
- assert_eq!(
- created_before.kind(),
- AuthChallengeStateErrorKind::CreatedBeforeChallenge
- );
- assert_eq!(
- created_before.to_string(),
- "auth event created_at 19 is before challenge issued_at 20"
- );
- }
-
- #[test]
- fn fixed_window_rate_limiter_accepts_rejects_resets_and_prunes() {
- let config = RateLimitConfig::new(3, 60).expect("config");
- let mut limiter = FixedWindowRateLimiter::new(config);
- let first = limiter
- .check(" ip:1 ", UnixTimestamp::new(100), 1)
- .expect("first");
- let second = limiter
- .check("ip:1", UnixTimestamp::new(110), 2)
- .expect("second");
- let rejected = limiter
- .check("ip:1", UnixTimestamp::new(110), 1)
- .expect("rejected");
- let other_key = limiter
- .check("ip:2", UnixTimestamp::new(110), 1)
- .expect("other");
- let reset = limiter
- .check("ip:1", UnixTimestamp::new(160), 1)
- .expect("reset");
- let rewind = limiter
- .check("ip:1", UnixTimestamp::new(150), 1)
- .expect("rewind");
- let pruned = limiter.prune_expired(UnixTimestamp::new(170));
-
- assert_eq!(limiter.config(), config);
- assert!(first.allowed());
- assert_eq!(first.remaining(), 2);
- assert_eq!(first.reset_at(), UnixTimestamp::new(160));
- assert_eq!(first.retry_after_seconds(), None);
- assert!(second.allowed());
- assert_eq!(second.remaining(), 0);
- assert!(!rejected.allowed());
- assert_eq!(rejected.remaining(), 0);
- assert_eq!(rejected.reset_at(), UnixTimestamp::new(160));
- assert_eq!(rejected.retry_after_seconds(), Some(50));
- assert_eq!(
- rejected,
- RateLimitDecision::Rejected {
- retry_after_seconds: 50,
- reset_at: UnixTimestamp::new(160),
- }
- );
- assert_eq!(other_key.remaining(), 2);
- assert_eq!(reset.remaining(), 2);
- assert_eq!(reset.reset_at(), UnixTimestamp::new(220));
- assert_eq!(rewind.remaining(), 2);
- assert_eq!(rewind.reset_at(), UnixTimestamp::new(210));
- assert_eq!(pruned, 1);
- assert_eq!(limiter.tracked_key_count(), 1);
- }
-
- #[test]
- fn fixed_window_rate_limiter_rejects_invalid_config_keys_and_costs() {
- let zero_limit = RateLimitConfig::new(0, 60).expect_err("limit");
- let zero_window = RateLimitConfig::new(1, 0).expect_err("window");
- let mut limiter = FixedWindowRateLimiter::new(RateLimitConfig::new(2, 60).expect("config"));
- let empty_key = limiter
- .check(" ", UnixTimestamp::new(1), 1)
- .expect_err("key");
- let zero_cost = limiter
- .check("ip:1", UnixTimestamp::new(1), 0)
- .expect_err("cost");
- let cost_exceeds_limit = limiter
- .check("ip:1", UnixTimestamp::new(1), 3)
- .expect_err("limit");
-
- assert_eq!(zero_limit, RateLimitConfigError::ZeroLimit);
- assert_eq!(
- zero_limit.to_string(),
- "rate limit must be greater than zero"
- );
- assert_eq!(zero_window, RateLimitConfigError::ZeroWindowSeconds);
- assert_eq!(
- zero_window.to_string(),
- "rate limit window must be greater than zero seconds"
- );
- assert_eq!(empty_key.kind(), RateLimitErrorKind::EmptyKey);
- assert_eq!(empty_key.to_string(), "rate limit key must not be empty");
- assert_eq!(zero_cost.kind(), RateLimitErrorKind::ZeroCost);
- assert_eq!(
- zero_cost.to_string(),
- "rate limit cost must be greater than zero"
- );
- assert_eq!(
- cost_exceeds_limit.kind(),
- RateLimitErrorKind::CostExceedsLimit
- );
- assert_eq!(
- cost_exceeds_limit.to_string(),
- "rate limit cost 3 exceeds limit 2"
- );
- assert_eq!(limiter.tracked_key_count(), 0);
- }
-
- fn limits_with(update: impl FnOnce(&mut RuntimeLimitValues)) -> RuntimeLimits {
- let mut values = RuntimeLimitValues::default();
- update(&mut values);
- RuntimeLimits::from_values(values).expect("limits")
- }
-
- fn event_with(tags: Vec<Tag>, content: &str, created_at: UnixTimestamp) -> Event {
- Event::new(
- EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"),
- UnsignedEvent::new(
- PublicKeyHex::new(&"1".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"),
- created_at,
- Kind::new(30_402).expect("kind"),
- tags,
- content,
- ),
- SignatureHex::new(&"b".repeat(SignatureHex::HEX_LENGTH)).expect("sig"),
- )
- }
-
- fn relay_auth_event(relay: &str, challenge: &str, created_at: u64) -> RelayAuthEvent {
- let spec = fixture_spec_from_json(&format!(
- r#"{{"name":"auth","key":"seller","created_at":{created_at},"kind":22242,"tags":[["relay","{relay}"],["challenge","{challenge}"]],"content":""}}"#
- ))
- .expect("auth spec");
- let event = build_fixture_event(&spec).expect("auth event");
- parse_relay_auth_event(&event)
- .expect("auth parse")
- .expect("auth event")
- }
-
- struct RawFailingRepository;
-
- impl RawEventRepository for RawFailingRepository {
- fn put_event(
- &mut self,
- _record: StoredEvent,
- ) -> Result<StoreEventOutcome, RepositoryError> {
- Err(RepositoryError::new("repository unavailable"))
- }
-
- fn event_by_id(&self, _event_id: &EventId) -> Result<Option<StoredEvent>, RepositoryError> {
- Err(RepositoryError::new("repository unavailable"))
- }
-
- fn events(&self) -> Result<Vec<StoredEvent>, RepositoryError> {
- Err(RepositoryError::new("repository unavailable"))
- }
- }
-
- impl ListingProjectionRepository for RawFailingRepository {
- fn put_listing_projection(
- &mut self,
- _projection: ListingProjection,
- ) -> Result<StoreProjectionOutcome, RepositoryError> {
- Err(RepositoryError::new("repository unavailable"))
- }
-
- fn listing_projection(
- &self,
- _address: &AddressCoordinate,
- ) -> Result<Option<ListingProjection>, RepositoryError> {
- Err(RepositoryError::new("repository unavailable"))
- }
- }
-
- impl DeletionMarkerRepository for RawFailingRepository {
- fn put_deletion_marker(&mut self, _marker: DeletionMarker) -> Result<(), RepositoryError> {
- Err(RepositoryError::new("repository unavailable"))
- }
-
- fn deletion_markers(&self) -> Result<Vec<DeletionMarker>, RepositoryError> {
- Err(RepositoryError::new("repository unavailable"))
- }
- }
-
- struct ProjectionFailingRepository {
- inner: InMemoryRepository,
- }
-
- impl ProjectionFailingRepository {
- fn new() -> Self {
- Self {
- inner: InMemoryRepository::new(),
- }
- }
- }
-
- impl RawEventRepository for ProjectionFailingRepository {
- fn put_event(&mut self, record: StoredEvent) -> Result<StoreEventOutcome, RepositoryError> {
- self.inner.put_event(record)
- }
-
- fn event_by_id(&self, event_id: &EventId) -> Result<Option<StoredEvent>, RepositoryError> {
- self.inner.event_by_id(event_id)
- }
-
- fn events(&self) -> Result<Vec<StoredEvent>, RepositoryError> {
- self.inner.events()
- }
- }
-
- impl ListingProjectionRepository for ProjectionFailingRepository {
- fn put_listing_projection(
- &mut self,
- _projection: ListingProjection,
- ) -> Result<StoreProjectionOutcome, RepositoryError> {
- Err(RepositoryError::new("projection unavailable"))
- }
-
- fn listing_projection(
- &self,
- address: &AddressCoordinate,
- ) -> Result<Option<ListingProjection>, RepositoryError> {
- self.inner.listing_projection(address)
- }
- }
-
- impl DeletionMarkerRepository for ProjectionFailingRepository {
- fn put_deletion_marker(&mut self, marker: DeletionMarker) -> Result<(), RepositoryError> {
- self.inner.put_deletion_marker(marker)
- }
-
- fn deletion_markers(&self) -> Result<Vec<DeletionMarker>, RepositoryError> {
- self.inner.deletion_markers()
- }
- }
-
- struct DeletionFailingRepository {
- inner: InMemoryRepository,
- }
-
- impl DeletionFailingRepository {
- fn new() -> Self {
- Self {
- inner: InMemoryRepository::new(),
- }
- }
- }
-
- impl RawEventRepository for DeletionFailingRepository {
- fn put_event(&mut self, record: StoredEvent) -> Result<StoreEventOutcome, RepositoryError> {
- self.inner.put_event(record)
- }
-
- fn event_by_id(&self, event_id: &EventId) -> Result<Option<StoredEvent>, RepositoryError> {
- self.inner.event_by_id(event_id)
- }
-
- fn events(&self) -> Result<Vec<StoredEvent>, RepositoryError> {
- self.inner.events()
- }
- }
-
- impl ListingProjectionRepository for DeletionFailingRepository {
- fn put_listing_projection(
- &mut self,
- projection: ListingProjection,
- ) -> Result<StoreProjectionOutcome, RepositoryError> {
- self.inner.put_listing_projection(projection)
- }
-
- fn listing_projection(
- &self,
- address: &AddressCoordinate,
- ) -> Result<Option<ListingProjection>, RepositoryError> {
- self.inner.listing_projection(address)
- }
- }
-
- impl DeletionMarkerRepository for DeletionFailingRepository {
- fn put_deletion_marker(&mut self, _marker: DeletionMarker) -> Result<(), RepositoryError> {
- Err(RepositoryError::new("deletion unavailable"))
- }
-
- fn deletion_markers(&self) -> Result<Vec<DeletionMarker>, RepositoryError> {
- self.inner.deletion_markers()
- }
- }
-
- fn pubkey(hex: &str) -> PublicKeyHex {
- PublicKeyHex::new(&hex.repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey")
- }
-}
diff --git a/crates/tangle_nips/Cargo.toml b/crates/tangle_nips/Cargo.toml
@@ -1,15 +0,0 @@
-[package]
-name = "tangle_nips"
-version.workspace = true
-edition.workspace = true
-authors.workspace = true
-rust-version.workspace = true
-license.workspace = true
-description = "MVP NIP parsers and projection contracts for tangle"
-
-[dependencies]
-serde_json = "1"
-tangle_protocol = { path = "../tangle_protocol" }
-
-[lints]
-workspace = true
diff --git a/crates/tangle_nips/src/lib.rs b/crates/tangle_nips/src/lib.rs
@@ -1,4760 +0,0 @@
-#![forbid(unsafe_code)]
-
-use core::str::FromStr;
-use tangle_protocol::{
- AddressCoordinate, DTag, Event, EventId, Filter, PublicKeyHex, TagName, UnixTimestamp,
-};
-
-pub const NIP01_METADATA_KIND: u32 = 0;
-pub const NIP99_PUBLIC_LISTING_KIND: u32 = 30_402;
-pub const NIP99_DRAFT_LISTING_KIND: u32 = 30_403;
-pub const NIP22_COMMENT_KIND: u32 = 1_111;
-pub const NIP25_REACTION_KIND: u32 = 7;
-pub const NIP23_LONG_FORM_KIND: u32 = 30_023;
-pub const NIP23_LONG_FORM_DRAFT_KIND: u32 = 30_024;
-pub const NIP7D_THREAD_KIND: u32 = 11;
-pub const NIP56_REPORT_KIND: u32 = 1_984;
-pub const NIP32_LABEL_KIND: u32 = 1_985;
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ParsedTag {
- name: String,
- values: Vec<String>,
-}
-
-impl ParsedTag {
- pub fn name(&self) -> &str {
- &self.name
- }
-
- pub fn values(&self) -> &[String] {
- &self.values
- }
-
- pub fn first_value(&self) -> Option<&str> {
- self.values.first().map(String::as_str)
- }
-}
-
-pub fn matching_tags(event: &Event, name: &str) -> Vec<ParsedTag> {
- event
- .unsigned()
- .tags()
- .iter()
- .filter(|tag| tag.name().as_str() == name)
- .map(|tag| ParsedTag {
- name: tag.name().to_string(),
- values: tag.values().iter().skip(1).cloned().collect(),
- })
- .collect()
-}
-
-pub fn tag_count(event: &Event, name: &str) -> usize {
- matching_tags(event, name).len()
-}
-
-pub fn optional_tag_value(event: &Event, name: &str) -> Result<Option<String>, String> {
- let tags = matching_tags(event, name);
- match tags.as_slice() {
- [] => Ok(None),
- [tag] => tag
- .first_value()
- .map(|value| Some(value.to_owned()))
- .ok_or_else(|| format!("tag `{name}` must include a value")),
- _ => Err(format!("tag `{name}` must not be repeated")),
- }
-}
-
-pub fn required_tag_value(event: &Event, name: &str) -> Result<String, String> {
- optional_tag_value(event, name)?.ok_or_else(|| format!("tag `{name}` is required"))
-}
-
-pub fn optional_tag_values(event: &Event, name: &str) -> Result<Option<Vec<String>>, String> {
- let tags = matching_tags(event, name);
- match tags.as_slice() {
- [] => Ok(None),
- [tag] => Ok(Some(tag.values().to_vec())),
- _ => Err(format!("tag `{name}` must not be repeated")),
- }
-}
-
-pub fn required_tag_values(event: &Event, name: &str) -> Result<Vec<String>, String> {
- optional_tag_values(event, name)?.ok_or_else(|| format!("tag `{name}` is required"))
-}
-
-pub fn parse_u64_field(field: &str, value: &str) -> Result<u64, String> {
- value
- .parse::<u64>()
- .map_err(|_| format!("field `{field}` must be an unsigned integer"))
-}
-
-pub fn parse_required_u64_tag(event: &Event, name: &str) -> Result<u64, String> {
- parse_u64_field(name, &required_tag_value(event, name)?)
-}
-
-pub fn repeated_or_missing_policy_boundary(
- event: &Event,
- name: &str,
-) -> Result<Option<String>, String> {
- optional_tag_value(event, name)
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SingleLetterTagValue {
- name: String,
- value: String,
-}
-
-impl SingleLetterTagValue {
- pub fn name(&self) -> &str {
- &self.name
- }
-
- pub fn value(&self) -> &str {
- &self.value
- }
-}
-
-pub fn single_letter_tag_values(event: &Event) -> Vec<SingleLetterTagValue> {
- event
- .unsigned()
- .tags()
- .iter()
- .filter_map(|tag| {
- tag.indexed_pair()
- .map(|(name, value)| SingleLetterTagValue {
- name: name.to_owned(),
- value: value.to_owned(),
- })
- })
- .collect()
-}
-
-pub fn single_letter_values_for(event: &Event, name: &str) -> Result<Vec<String>, String> {
- if !TagName::is_indexable_name(name) {
- return Err(format!(
- "single-letter tag name `{name}` must be one ASCII letter"
- ));
- }
- Ok(single_letter_tag_values(event)
- .into_iter()
- .filter(|tag| tag.name() == name)
- .map(|tag| tag.value)
- .collect())
-}
-
-pub fn first_single_letter_value(event: &Event, name: &str) -> Result<Option<String>, String> {
- Ok(single_letter_values_for(event, name)?.into_iter().next())
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum DeletionTarget {
- Event(EventId),
- Address(AddressCoordinate),
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct DeletionRequest {
- event_id: EventId,
- targets: Vec<DeletionTarget>,
-}
-
-impl DeletionRequest {
- pub fn event_id(&self) -> &EventId {
- &self.event_id
- }
-
- pub fn targets(&self) -> &[DeletionTarget] {
- &self.targets
- }
-}
-
-pub fn parse_deletion_request(event: &Event) -> Result<Option<DeletionRequest>, String> {
- if event.unsigned().kind().as_u32() != 5 {
- return Ok(None);
- }
- let mut targets = single_letter_values_for(event, "e")?
- .into_iter()
- .map(|value| EventId::new(&value).map(DeletionTarget::Event))
- .collect::<Result<Vec<_>, _>>()?;
- let address_targets = single_letter_values_for(event, "a")?
- .into_iter()
- .map(|value| AddressCoordinate::from_str(&value).map(DeletionTarget::Address))
- .collect::<Result<Vec<_>, _>>()?;
- targets.extend(address_targets);
- if targets.is_empty() {
- return Err("deletion event must target at least one e or a tag".to_owned());
- }
- Ok(Some(DeletionRequest {
- event_id: event.id().clone(),
- targets,
- }))
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RelayAuthEvent {
- event_id: EventId,
- pubkey: PublicKeyHex,
- created_at: UnixTimestamp,
- relay: String,
- challenge: String,
-}
-
-impl RelayAuthEvent {
- pub fn event_id(&self) -> &EventId {
- &self.event_id
- }
-
- pub fn pubkey(&self) -> &PublicKeyHex {
- &self.pubkey
- }
-
- pub fn created_at(&self) -> UnixTimestamp {
- self.created_at
- }
-
- pub fn relay(&self) -> &str {
- &self.relay
- }
-
- pub fn challenge(&self) -> &str {
- &self.challenge
- }
-}
-
-pub fn parse_relay_auth_event(event: &Event) -> Result<Option<RelayAuthEvent>, String> {
- if event.unsigned().kind().as_u32() != 22_242 {
- return Ok(None);
- }
- let relay = required_tag_value(event, "relay")?;
- let challenge = required_tag_value(event, "challenge")?;
- if relay.is_empty() {
- return Err("relay auth relay tag must not be empty".to_owned());
- }
- if challenge.is_empty() {
- return Err("relay auth challenge tag must not be empty".to_owned());
- }
- Ok(Some(RelayAuthEvent {
- event_id: event.id().clone(),
- pubkey: event.unsigned().pubkey().clone(),
- created_at: event.unsigned().created_at(),
- relay,
- challenge,
- }))
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct Nip50SearchQuery {
- text: String,
- terms: Vec<String>,
-}
-
-impl Nip50SearchQuery {
- pub fn text(&self) -> &str {
- &self.text
- }
-
- pub fn terms(&self) -> &[String] {
- &self.terms
- }
-}
-
-pub fn parse_nip50_search(search: &str) -> Result<Option<Nip50SearchQuery>, String> {
- let terms = search
- .split_whitespace()
- .filter(|term| !term.contains(':'))
- .map(str::to_owned)
- .collect::<Vec<_>>();
- if terms.is_empty() {
- return Ok(None);
- }
- Ok(Some(Nip50SearchQuery {
- text: terms.join(" "),
- terms,
- }))
-}
-
-pub fn parse_nip50_filter_search(filter: &Filter) -> Result<Option<Nip50SearchQuery>, String> {
- match filter.search() {
- Some(search) => parse_nip50_search(search),
- None => Ok(None),
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum CommentTarget {
- Event {
- event_id: EventId,
- relay_hint: Option<String>,
- pubkey_hint: Option<PublicKeyHex>,
- },
- Address {
- address: AddressCoordinate,
- relay_hint: Option<String>,
- },
- External {
- identity: String,
- relay_hint: Option<String>,
- },
-}
-
-impl CommentTarget {
- pub fn target_type(&self) -> &'static str {
- match self {
- Self::Event { .. } => "event",
- Self::Address { .. } => "address",
- Self::External { .. } => "external",
- }
- }
-
- pub fn target_ref(&self) -> String {
- match self {
- Self::Event { event_id, .. } => event_id.as_str().to_owned(),
- Self::Address { address, .. } => address.key().to_string(),
- Self::External { identity, .. } => identity.clone(),
- }
- }
-
- pub fn relay_hint(&self) -> Option<&str> {
- match self {
- Self::Event { relay_hint, .. }
- | Self::Address { relay_hint, .. }
- | Self::External { relay_hint, .. } => relay_hint.as_deref(),
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct CommentReference {
- target: CommentTarget,
- kind: String,
- author: Option<PublicKeyHex>,
-}
-
-impl CommentReference {
- pub fn target(&self) -> &CommentTarget {
- &self.target
- }
-
- pub fn kind(&self) -> &str {
- &self.kind
- }
-
- pub fn author(&self) -> Option<&PublicKeyHex> {
- self.author.as_ref()
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct CommentEvent {
- event_id: EventId,
- pubkey: PublicKeyHex,
- created_at: UnixTimestamp,
- content: String,
- root: CommentReference,
- parent: CommentReference,
- cited_events: Vec<String>,
- mentioned_pubkeys: Vec<PublicKeyHex>,
-}
-
-impl CommentEvent {
- pub fn event_id(&self) -> &EventId {
- &self.event_id
- }
-
- pub fn pubkey(&self) -> &PublicKeyHex {
- &self.pubkey
- }
-
- pub fn created_at(&self) -> UnixTimestamp {
- self.created_at
- }
-
- pub fn content(&self) -> &str {
- &self.content
- }
-
- pub fn root(&self) -> &CommentReference {
- &self.root
- }
-
- pub fn parent(&self) -> &CommentReference {
- &self.parent
- }
-
- pub fn cited_events(&self) -> &[String] {
- &self.cited_events
- }
-
- pub fn mentioned_pubkeys(&self) -> &[PublicKeyHex] {
- &self.mentioned_pubkeys
- }
-}
-
-pub fn parse_comment_event(event: &Event) -> Result<Option<CommentEvent>, String> {
- if event.unsigned().kind().as_u32() != NIP22_COMMENT_KIND {
- return Ok(None);
- }
- let root_kind = required_tag_value(event, "K")?;
- let parent_kind = required_tag_value(event, "k")?;
- if root_kind.is_empty() {
- return Err("comment root kind tag must not be empty".to_owned());
- }
- if parent_kind.is_empty() {
- return Err("comment parent kind tag must not be empty".to_owned());
- }
- if root_kind == "1" || parent_kind == "1" {
- return Err("NIP-22 comments must not reply to kind 1 notes".to_owned());
- }
- Ok(Some(CommentEvent {
- event_id: event.id().clone(),
- pubkey: event.unsigned().pubkey().clone(),
- created_at: event.unsigned().created_at(),
- content: event.unsigned().content().to_owned(),
- root: CommentReference {
- target: parse_scoped_comment_target(event, &["A", "E", "I"], "root")?,
- kind: root_kind,
- author: optional_single_pubkey(event, "P", "root author")?,
- },
- parent: CommentReference {
- target: parse_scoped_comment_target(event, &["a", "e", "i"], "parent")?,
- kind: parent_kind,
- author: first_optional_pubkey(event, "p", "parent author")?,
- },
- cited_events: single_letter_values_for(event, "q")?,
- mentioned_pubkeys: parse_pubkey_values(event, "p", "mentioned pubkey")?,
- }))
-}
-
-fn parse_scoped_comment_target(
- event: &Event,
- names: &[&str],
- scope: &str,
-) -> Result<CommentTarget, String> {
- let mut found = Vec::new();
- for name in names {
- for tag in matching_tags(event, name) {
- found.push((*name, tag));
- }
- }
- match found.len() {
- 0 => Err(format!("comment {scope} target tag is required")),
- 1 => {
- let (name, tag) = found.remove(0);
- parse_comment_target_tag(name, &tag, scope)
- }
- _ => Err(format!("comment {scope} target tag must not be repeated")),
- }
-}
-
-fn parse_comment_target_tag(
- name: &str,
- tag: &ParsedTag,
- scope: &str,
-) -> Result<CommentTarget, String> {
- let values = tag.values();
- let target = values
- .first()
- .ok_or_else(|| format!("comment {scope} target tag `{name}` must include a value"))?;
- if target.is_empty() {
- return Err(format!(
- "comment {scope} target tag `{name}` must not be empty"
- ));
- }
- let relay_hint = normalized_optional_hint(values.get(1), scope, "relay")?;
- match name {
- "E" | "e" => {
- if values.len() > 3 {
- return Err(format!(
- "comment {scope} event target tag `{name}` must include at most event relay and pubkey values"
- ));
- }
- let pubkey_hint = values
- .get(2)
- .map(|value| parse_pubkey_value(value, scope, "event pubkey hint"))
- .transpose()?;
- Ok(CommentTarget::Event {
- event_id: EventId::new(target)?,
- relay_hint,
- pubkey_hint,
- })
- }
- "A" | "a" => {
- if values.len() > 2 {
- return Err(format!(
- "comment {scope} address target tag `{name}` must include at most address and relay values"
- ));
- }
- Ok(CommentTarget::Address {
- address: AddressCoordinate::from_str(target)?,
- relay_hint,
- })
- }
- "I" | "i" => {
- if values.len() > 2 {
- return Err(format!(
- "comment {scope} external target tag `{name}` must include at most identity and relay values"
- ));
- }
- Ok(CommentTarget::External {
- identity: target.to_owned(),
- relay_hint,
- })
- }
- _ => Err(format!(
- "comment {scope} target tag `{name}` is unsupported"
- )),
- }
-}
-
-fn optional_single_pubkey(
- event: &Event,
- name: &str,
- description: &str,
-) -> Result<Option<PublicKeyHex>, String> {
- let values = single_letter_values_for(event, name)?;
- match values.as_slice() {
- [] => Ok(None),
- [value] => Ok(Some(parse_pubkey_value(value, description, "pubkey")?)),
- _ => Err(format!(
- "comment {description} tag `{name}` must not be repeated"
- )),
- }
-}
-
-fn first_optional_pubkey(
- event: &Event,
- name: &str,
- description: &str,
-) -> Result<Option<PublicKeyHex>, String> {
- match single_letter_values_for(event, name)?.first() {
- Some(value) => Ok(Some(parse_pubkey_value(value, description, "pubkey")?)),
- None => Ok(None),
- }
-}
-
-fn parse_pubkey_values(
- event: &Event,
- name: &str,
- description: &str,
-) -> Result<Vec<PublicKeyHex>, String> {
- single_letter_values_for(event, name)?
- .into_iter()
- .map(|value| parse_pubkey_value(&value, description, "pubkey"))
- .collect()
-}
-
-fn parse_pubkey_value(value: &str, description: &str, field: &str) -> Result<PublicKeyHex, String> {
- PublicKeyHex::new(value).map_err(|source| format!("{description} {field} is invalid: {source}"))
-}
-
-fn normalized_optional_hint(
- value: Option<&String>,
- scope: &str,
- field: &str,
-) -> Result<Option<String>, String> {
- match value {
- Some(value) if value.is_empty() => Err(format!(
- "comment {scope} target {field} hint must not be empty"
- )),
- Some(value) => Ok(Some(value.clone())),
- None => Ok(None),
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum ReactionValue {
- Like,
- Dislike,
- Emoji(String),
- Text(String),
-}
-
-impl ReactionValue {
- pub fn canonical(&self) -> &str {
- match self {
- Self::Like => "like",
- Self::Dislike => "dislike",
- Self::Emoji(_) => "emoji",
- Self::Text(_) => "text",
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ReactionEvent {
- event_id: EventId,
- pubkey: PublicKeyHex,
- created_at: UnixTimestamp,
- content: String,
- value: ReactionValue,
- target_event_id: EventId,
- target_relay_hint: Option<String>,
- target_pubkey_hint: Option<PublicKeyHex>,
- target_pubkey: Option<PublicKeyHex>,
- target_address: Option<AddressCoordinate>,
- target_kind: Option<String>,
-}
-
-impl ReactionEvent {
- pub fn event_id(&self) -> &EventId {
- &self.event_id
- }
-
- pub fn pubkey(&self) -> &PublicKeyHex {
- &self.pubkey
- }
-
- pub fn created_at(&self) -> UnixTimestamp {
- self.created_at
- }
-
- pub fn content(&self) -> &str {
- &self.content
- }
-
- pub fn value(&self) -> &ReactionValue {
- &self.value
- }
-
- pub fn target_event_id(&self) -> &EventId {
- &self.target_event_id
- }
-
- pub fn target_relay_hint(&self) -> Option<&str> {
- self.target_relay_hint.as_deref()
- }
-
- pub fn target_pubkey_hint(&self) -> Option<&PublicKeyHex> {
- self.target_pubkey_hint.as_ref()
- }
-
- pub fn target_pubkey(&self) -> Option<&PublicKeyHex> {
- self.target_pubkey.as_ref()
- }
-
- pub fn target_address(&self) -> Option<&AddressCoordinate> {
- self.target_address.as_ref()
- }
-
- pub fn target_kind(&self) -> Option<&str> {
- self.target_kind.as_deref()
- }
-}
-
-pub fn parse_reaction_event(event: &Event) -> Result<Option<ReactionEvent>, String> {
- if event.unsigned().kind().as_u32() != NIP25_REACTION_KIND {
- return Ok(None);
- }
- let target = last_matching_tag(event, "e")
- .ok_or_else(|| "reaction event must include an e target tag".to_owned())?;
- let target_event_id = target
- .first_value()
- .ok_or_else(|| "reaction e target tag must include an event id".to_owned())
- .and_then(EventId::new)?;
- let target_relay_hint = normalized_optional_hint(target.values().get(1), "reaction", "relay")?;
- let target_pubkey_hint = target
- .values()
- .get(2)
- .map(|value| parse_pubkey_value(value, "reaction target hint", "pubkey"))
- .transpose()?;
- let target_pubkey = last_matching_tag(event, "p")
- .and_then(|tag| tag.first_value().map(str::to_owned))
- .map(|value| parse_pubkey_value(&value, "reaction target", "pubkey"))
- .transpose()?;
- let target_address = last_matching_tag(event, "a")
- .and_then(|tag| tag.first_value().map(str::to_owned))
- .map(|value| AddressCoordinate::from_str(&value))
- .transpose()?;
- let target_kind = optional_tag_value(event, "k")?;
- if target_kind.as_ref().is_some_and(String::is_empty) {
- return Err("reaction k tag must not be empty".to_owned());
- }
- Ok(Some(ReactionEvent {
- event_id: event.id().clone(),
- pubkey: event.unsigned().pubkey().clone(),
- created_at: event.unsigned().created_at(),
- content: event.unsigned().content().to_owned(),
- value: reaction_value(event.unsigned().content()),
- target_event_id,
- target_relay_hint,
- target_pubkey_hint,
- target_pubkey,
- target_address,
- target_kind,
- }))
-}
-
-fn reaction_value(content: &str) -> ReactionValue {
- match content {
- "" | "+" => ReactionValue::Like,
- "-" => ReactionValue::Dislike,
- value if looks_like_single_emoji(value) => ReactionValue::Emoji(value.to_owned()),
- value => ReactionValue::Text(value.to_owned()),
- }
-}
-
-fn looks_like_single_emoji(value: &str) -> bool {
- let mut chars = value.chars();
- match (chars.next(), chars.next()) {
- (Some(character), None) => !character.is_ascii() && !character.is_alphanumeric(),
- _ => false,
- }
-}
-
-fn last_matching_tag(event: &Event, name: &str) -> Option<ParsedTag> {
- matching_tags(event, name).into_iter().last()
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum LongFormKind {
- Published,
- Draft,
-}
-
-impl LongFormKind {
- pub fn canonical(self) -> &'static str {
- match self {
- Self::Published => "published",
- Self::Draft => "draft",
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct LongFormEvent {
- event_id: EventId,
- pubkey: PublicKeyHex,
- created_at: UnixTimestamp,
- content: String,
- long_form_kind: LongFormKind,
- address: AddressCoordinate,
- d: DTag,
- title: Option<String>,
- image: Option<String>,
- summary: Option<String>,
- published_at: Option<u64>,
- topics: Vec<String>,
- referenced_events: Vec<EventId>,
- referenced_addresses: Vec<AddressCoordinate>,
- referenced_pubkeys: Vec<PublicKeyHex>,
-}
-
-impl LongFormEvent {
- pub fn event_id(&self) -> &EventId {
- &self.event_id
- }
-
- pub fn pubkey(&self) -> &PublicKeyHex {
- &self.pubkey
- }
-
- pub fn created_at(&self) -> UnixTimestamp {
- self.created_at
- }
-
- pub fn content(&self) -> &str {
- &self.content
- }
-
- pub fn long_form_kind(&self) -> LongFormKind {
- self.long_form_kind
- }
-
- pub fn address(&self) -> &AddressCoordinate {
- &self.address
- }
-
- pub fn d(&self) -> &DTag {
- &self.d
- }
-
- pub fn title(&self) -> Option<&str> {
- self.title.as_deref()
- }
-
- pub fn image(&self) -> Option<&str> {
- self.image.as_deref()
- }
-
- pub fn summary(&self) -> Option<&str> {
- self.summary.as_deref()
- }
-
- pub fn published_at(&self) -> Option<u64> {
- self.published_at
- }
-
- pub fn topics(&self) -> &[String] {
- &self.topics
- }
-
- pub fn referenced_events(&self) -> &[EventId] {
- &self.referenced_events
- }
-
- pub fn referenced_addresses(&self) -> &[AddressCoordinate] {
- &self.referenced_addresses
- }
-
- pub fn referenced_pubkeys(&self) -> &[PublicKeyHex] {
- &self.referenced_pubkeys
- }
-}
-
-pub fn parse_long_form_event(event: &Event) -> Result<Option<LongFormEvent>, String> {
- let Some(long_form_kind) = long_form_kind_for_event(event) else {
- return Ok(None);
- };
- let d = required_tag_value(event, "d")?;
- if d.is_empty() {
- return Err("long-form d tag must not be empty".to_owned());
- }
- let title = optional_non_empty_tag_value(event, "title", "long-form title")?;
- let image = optional_non_empty_tag_value(event, "image", "long-form image")?;
- let summary = optional_non_empty_tag_value(event, "summary", "long-form summary")?;
- let published_at = optional_tag_value(event, "published_at")?
- .map(|value| parse_u64_field("published_at", &value))
- .transpose()?;
- Ok(Some(LongFormEvent {
- event_id: event.id().clone(),
- pubkey: event.unsigned().pubkey().clone(),
- created_at: event.unsigned().created_at(),
- content: event.unsigned().content().to_owned(),
- long_form_kind,
- address: AddressCoordinate::new(
- event.unsigned().kind(),
- event.unsigned().pubkey().clone(),
- DTag::new(&d),
- )
- .expect("long-form kind must be addressable"),
- d: DTag::new(&d),
- title,
- image,
- summary,
- published_at,
- topics: collect_normalized_tag_values(event, "t", "long-form topic")?,
- referenced_events: single_letter_values_for(event, "e")?
- .into_iter()
- .map(|value| EventId::new(&value))
- .collect::<Result<Vec<_>, _>>()?,
- referenced_addresses: single_letter_values_for(event, "a")?
- .into_iter()
- .map(|value| AddressCoordinate::from_str(&value))
- .collect::<Result<Vec<_>, _>>()?,
- referenced_pubkeys: parse_pubkey_values(event, "p", "long-form reference")?,
- }))
-}
-
-fn long_form_kind_for_event(event: &Event) -> Option<LongFormKind> {
- match event.unsigned().kind().as_u32() {
- NIP23_LONG_FORM_KIND => Some(LongFormKind::Published),
- NIP23_LONG_FORM_DRAFT_KIND => Some(LongFormKind::Draft),
- _ => None,
- }
-}
-
-fn optional_non_empty_tag_value(
- event: &Event,
- name: &str,
- description: &str,
-) -> Result<Option<String>, String> {
- let value = optional_tag_value(event, name)?;
- if value.as_ref().is_some_and(String::is_empty) {
- return Err(format!("{description} tag must not be empty"));
- }
- Ok(value)
-}
-
-fn collect_normalized_tag_values(
- event: &Event,
- name: &str,
- description: &str,
-) -> Result<Vec<String>, String> {
- let mut values = Vec::new();
- for tag in matching_tags(event, name) {
- match tag.values() {
- [] => return Err(format!("{description} tag must include a value")),
- [value] => {
- let normalized = value.trim().to_ascii_lowercase();
- if normalized.is_empty() {
- return Err(format!("{description} value must not be empty"));
- }
- values.push(normalized);
- }
- _ => return Err(format!("{description} tag must include exactly one value")),
- }
- }
- values.sort();
- values.dedup();
- Ok(values)
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ForumThreadEvent {
- event_id: EventId,
- pubkey: PublicKeyHex,
- created_at: UnixTimestamp,
- title: Option<String>,
- content: String,
- topics: Vec<String>,
- referenced_events: Vec<EventId>,
- referenced_pubkeys: Vec<PublicKeyHex>,
-}
-
-impl ForumThreadEvent {
- pub fn event_id(&self) -> &EventId {
- &self.event_id
- }
-
- pub fn pubkey(&self) -> &PublicKeyHex {
- &self.pubkey
- }
-
- pub fn created_at(&self) -> UnixTimestamp {
- self.created_at
- }
-
- pub fn title(&self) -> Option<&str> {
- self.title.as_deref()
- }
-
- pub fn content(&self) -> &str {
- &self.content
- }
-
- pub fn topics(&self) -> &[String] {
- &self.topics
- }
-
- pub fn referenced_events(&self) -> &[EventId] {
- &self.referenced_events
- }
-
- pub fn referenced_pubkeys(&self) -> &[PublicKeyHex] {
- &self.referenced_pubkeys
- }
-}
-
-pub fn parse_forum_thread_event(event: &Event) -> Result<Option<ForumThreadEvent>, String> {
- if event.unsigned().kind().as_u32() != NIP7D_THREAD_KIND {
- return Ok(None);
- }
- Ok(Some(ForumThreadEvent {
- event_id: event.id().clone(),
- pubkey: event.unsigned().pubkey().clone(),
- created_at: event.unsigned().created_at(),
- title: optional_non_empty_tag_value(event, "title", "forum thread title")?,
- content: event.unsigned().content().to_owned(),
- topics: collect_normalized_tag_values(event, "t", "forum thread topic")?,
- referenced_events: single_letter_values_for(event, "e")?
- .into_iter()
- .map(|value| EventId::new(&value))
- .collect::<Result<Vec<_>, _>>()?,
- referenced_pubkeys: parse_pubkey_values(event, "p", "forum thread reference")?,
- }))
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum ReportType {
- Nudity,
- Malware,
- Profanity,
- Illegal,
- Spam,
- Impersonation,
- Other,
-}
-
-impl ReportType {
- pub fn canonical(self) -> &'static str {
- match self {
- Self::Nudity => "nudity",
- Self::Malware => "malware",
- Self::Profanity => "profanity",
- Self::Illegal => "illegal",
- Self::Spam => "spam",
- Self::Impersonation => "impersonation",
- Self::Other => "other",
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum ReportTarget {
- Pubkey {
- pubkey: PublicKeyHex,
- report_type: ReportType,
- },
- Event {
- event_id: EventId,
- report_type: ReportType,
- },
- Blob {
- hash: String,
- report_type: ReportType,
- },
-}
-
-impl ReportTarget {
- pub fn target_type(&self) -> &'static str {
- match self {
- Self::Pubkey { .. } => "pubkey",
- Self::Event { .. } => "event",
- Self::Blob { .. } => "blob",
- }
- }
-
- pub fn target_ref(&self) -> &str {
- match self {
- Self::Pubkey { pubkey, .. } => pubkey.as_str(),
- Self::Event { event_id, .. } => event_id.as_str(),
- Self::Blob { hash, .. } => hash,
- }
- }
-
- pub fn report_type(&self) -> ReportType {
- match self {
- Self::Pubkey { report_type, .. }
- | Self::Event { report_type, .. }
- | Self::Blob { report_type, .. } => *report_type,
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ReportEvent {
- event_id: EventId,
- pubkey: PublicKeyHex,
- created_at: UnixTimestamp,
- content: String,
- reported_pubkeys: Vec<PublicKeyHex>,
- targets: Vec<ReportTarget>,
- server_urls: Vec<String>,
-}
-
-impl ReportEvent {
- pub fn event_id(&self) -> &EventId {
- &self.event_id
- }
-
- pub fn pubkey(&self) -> &PublicKeyHex {
- &self.pubkey
- }
-
- pub fn created_at(&self) -> UnixTimestamp {
- self.created_at
- }
-
- pub fn content(&self) -> &str {
- &self.content
- }
-
- pub fn reported_pubkeys(&self) -> &[PublicKeyHex] {
- &self.reported_pubkeys
- }
-
- pub fn targets(&self) -> &[ReportTarget] {
- &self.targets
- }
-
- pub fn server_urls(&self) -> &[String] {
- &self.server_urls
- }
-}
-
-pub fn parse_report_event(event: &Event) -> Result<Option<ReportEvent>, String> {
- if event.unsigned().kind().as_u32() != NIP56_REPORT_KIND {
- return Ok(None);
- }
- let mut reported_pubkeys = Vec::new();
- let mut targets = Vec::new();
- for tag in matching_tags(event, "p") {
- let pubkey = tag
- .first_value()
- .ok_or_else(|| "report p tag must include a pubkey".to_owned())
- .and_then(|value| parse_pubkey_value(value, "report p target", "pubkey"))?;
- if let Some(report_type) = optional_report_type(&tag)? {
- targets.push(ReportTarget::Pubkey {
- pubkey: pubkey.clone(),
- report_type,
- });
- }
- reported_pubkeys.push(pubkey);
- }
- if reported_pubkeys.is_empty() {
- return Err("report event must include at least one p tag".to_owned());
- }
- let mut event_context_count = 0;
- for tag in matching_tags(event, "e") {
- let event_id = tag
- .first_value()
- .ok_or_else(|| "report e tag must include an event id".to_owned())
- .and_then(EventId::new)?;
- event_context_count += 1;
- if let Some(report_type) = optional_report_type(&tag)? {
- targets.push(ReportTarget::Event {
- event_id,
- report_type,
- });
- }
- }
- let mut blob_count = 0;
- for tag in matching_tags(event, "x") {
- let hash = tag
- .first_value()
- .ok_or_else(|| "report x tag must include a hash".to_owned())?;
- if hash.is_empty() {
- return Err("report x hash must not be empty".to_owned());
- }
- blob_count += 1;
- targets.push(ReportTarget::Blob {
- hash: hash.to_owned(),
- report_type: required_report_type(&tag, "x")?,
- });
- }
- if blob_count > 0 && event_context_count == 0 {
- return Err("report x target requires an e tag context".to_owned());
- }
- if targets.is_empty() {
- return Err(
- "report event must include at least one p e or x tag with a report type".to_owned(),
- );
- }
- let mut server_urls = parse_report_server_urls(event)?;
- reported_pubkeys.sort_by(|left, right| left.as_str().cmp(right.as_str()));
- reported_pubkeys.dedup();
- server_urls.sort();
- server_urls.dedup();
- Ok(Some(ReportEvent {
- event_id: event.id().clone(),
- pubkey: event.unsigned().pubkey().clone(),
- created_at: event.unsigned().created_at(),
- content: event.unsigned().content().to_owned(),
- reported_pubkeys,
- targets,
- server_urls,
- }))
-}
-
-fn optional_report_type(tag: &ParsedTag) -> Result<Option<ReportType>, String> {
- tag.values()
- .get(1)
- .map(|value| parse_report_type(value))
- .transpose()
-}
-
-fn required_report_type(tag: &ParsedTag, name: &str) -> Result<ReportType, String> {
- match optional_report_type(tag)? {
- Some(report_type) => Ok(report_type),
- None => Err(format!("report {name} tag must include a report type")),
- }
-}
-
-fn parse_report_type(value: &str) -> Result<ReportType, String> {
- match value {
- "nudity" => Ok(ReportType::Nudity),
- "malware" => Ok(ReportType::Malware),
- "profanity" => Ok(ReportType::Profanity),
- "illegal" => Ok(ReportType::Illegal),
- "spam" => Ok(ReportType::Spam),
- "impersonation" => Ok(ReportType::Impersonation),
- "other" => Ok(ReportType::Other),
- "" => Err("report type must not be empty".to_owned()),
- _ => Err(format!("report type `{value}` is unsupported")),
- }
-}
-
-fn parse_report_server_urls(event: &Event) -> Result<Vec<String>, String> {
- let mut urls = Vec::new();
- for tag in matching_tags(event, "server") {
- match tag.values() {
- [value] if !value.is_empty() => urls.push(value.clone()),
- [..] => {
- return Err("report server tag must include exactly one non-empty URL".to_owned());
- }
- }
- }
- Ok(urls)
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum LabelTarget {
- Event(EventId),
- Pubkey(PublicKeyHex),
- Address(AddressCoordinate),
- Relay(String),
- Topic(String),
-}
-
-impl LabelTarget {
- pub fn target_type(&self) -> &'static str {
- match self {
- Self::Event(_) => "event",
- Self::Pubkey(_) => "pubkey",
- Self::Address(_) => "address",
- Self::Relay(_) => "relay",
- Self::Topic(_) => "topic",
- }
- }
-
- pub fn target_ref(&self) -> String {
- match self {
- Self::Event(event_id) => event_id.as_str().to_owned(),
- Self::Pubkey(pubkey) => pubkey.as_str().to_owned(),
- Self::Address(address) => address.key().to_string(),
- Self::Relay(relay) | Self::Topic(relay) => relay.clone(),
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct LabelValue {
- value: String,
- namespace: String,
-}
-
-impl LabelValue {
- pub fn value(&self) -> &str {
- &self.value
- }
-
- pub fn namespace(&self) -> &str {
- &self.namespace
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct LabelEvent {
- event_id: EventId,
- pubkey: PublicKeyHex,
- created_at: UnixTimestamp,
- content: String,
- namespaces: Vec<String>,
- labels: Vec<LabelValue>,
- targets: Vec<LabelTarget>,
-}
-
-impl LabelEvent {
- pub fn event_id(&self) -> &EventId {
- &self.event_id
- }
-
- pub fn pubkey(&self) -> &PublicKeyHex {
- &self.pubkey
- }
-
- pub fn created_at(&self) -> UnixTimestamp {
- self.created_at
- }
-
- pub fn content(&self) -> &str {
- &self.content
- }
-
- pub fn namespaces(&self) -> &[String] {
- &self.namespaces
- }
-
- pub fn labels(&self) -> &[LabelValue] {
- &self.labels
- }
-
- pub fn targets(&self) -> &[LabelTarget] {
- &self.targets
- }
-}
-
-pub fn parse_label_event(event: &Event) -> Result<Option<LabelEvent>, String> {
- if event.unsigned().kind().as_u32() != NIP32_LABEL_KIND {
- return Ok(None);
- }
- let namespaces = parse_label_namespaces(event)?;
- let labels = parse_label_values(event, &namespaces)?;
- if labels.is_empty() {
- return Err("label event must include at least one l tag".to_owned());
- }
- let targets = parse_label_targets(event)?;
- if targets.is_empty() {
- return Err("label event must target at least one e p a r or t tag".to_owned());
- }
- Ok(Some(LabelEvent {
- event_id: event.id().clone(),
- pubkey: event.unsigned().pubkey().clone(),
- created_at: event.unsigned().created_at(),
- content: event.unsigned().content().to_owned(),
- namespaces,
- labels,
- targets,
- }))
-}
-
-fn parse_label_namespaces(event: &Event) -> Result<Vec<String>, String> {
- let mut namespaces = Vec::new();
- for tag in matching_tags(event, "L") {
- match tag.values() {
- [value] if !value.is_empty() => namespaces.push(value.clone()),
- [..] => {
- return Err(
- "label namespace L tag must include exactly one non-empty value".to_owned(),
- );
- }
- }
- }
- namespaces.sort();
- namespaces.dedup();
- Ok(namespaces)
-}
-
-fn parse_label_values(event: &Event, namespaces: &[String]) -> Result<Vec<LabelValue>, String> {
- let mut labels = Vec::new();
- for tag in matching_tags(event, "l") {
- let values = tag.values();
- let value = values
- .first()
- .ok_or_else(|| "label l tag must include a value".to_owned())?;
- if value.is_empty() {
- return Err("label l value must not be empty".to_owned());
- }
- if values.len() > 2 {
- return Err("label l tag must include at most value and namespace".to_owned());
- }
- let namespace = match values.get(1) {
- Some(namespace) if namespace.is_empty() => {
- return Err("label l namespace must not be empty".to_owned());
- }
- Some(namespace) => namespace.clone(),
- None if namespaces.is_empty() => "ugc".to_owned(),
- None => return Err("label l tag must include a namespace matching an L tag".to_owned()),
- };
- if !namespaces.is_empty() && !namespaces.contains(&namespace) {
- return Err("label l namespace must match an L tag".to_owned());
- }
- labels.push(LabelValue {
- value: value.clone(),
- namespace,
- });
- }
- Ok(labels)
-}
-
-fn parse_label_targets(event: &Event) -> Result<Vec<LabelTarget>, String> {
- let mut targets = Vec::new();
- for value in single_letter_values_for(event, "e")? {
- targets.push(LabelTarget::Event(EventId::new(&value)?));
- }
- for value in single_letter_values_for(event, "p")? {
- targets.push(LabelTarget::Pubkey(parse_pubkey_value(
- &value,
- "label target",
- "pubkey",
- )?));
- }
- for value in single_letter_values_for(event, "a")? {
- targets.push(LabelTarget::Address(AddressCoordinate::from_str(&value)?));
- }
- for value in single_letter_values_for(event, "r")? {
- if value.is_empty() {
- return Err("label relay target must not be empty".to_owned());
- }
- targets.push(LabelTarget::Relay(value));
- }
- for value in single_letter_values_for(event, "t")? {
- if value.is_empty() {
- return Err("label topic target must not be empty".to_owned());
- }
- targets.push(LabelTarget::Topic(value));
- }
- Ok(targets)
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SellerProfileMetadata {
- name: Option<String>,
- display_name: Option<String>,
- about: Option<String>,
- picture: Option<String>,
- website: Option<String>,
- nip05: Option<String>,
- lud16: Option<String>,
-}
-
-impl SellerProfileMetadata {
- pub fn name(&self) -> Option<&str> {
- self.name.as_deref()
- }
-
- pub fn display_name(&self) -> Option<&str> {
- self.display_name.as_deref()
- }
-
- pub fn about(&self) -> Option<&str> {
- self.about.as_deref()
- }
-
- pub fn picture(&self) -> Option<&str> {
- self.picture.as_deref()
- }
-
- pub fn website(&self) -> Option<&str> {
- self.website.as_deref()
- }
-
- pub fn nip05(&self) -> Option<&str> {
- self.nip05.as_deref()
- }
-
- pub fn lud16(&self) -> Option<&str> {
- self.lud16.as_deref()
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SellerProfileEvent {
- event_id: EventId,
- pubkey: PublicKeyHex,
- created_at: UnixTimestamp,
- metadata: SellerProfileMetadata,
- regions: Vec<String>,
- categories: Vec<String>,
- trust_markers: Vec<String>,
-}
-
-impl SellerProfileEvent {
- pub fn event_id(&self) -> &EventId {
- &self.event_id
- }
-
- pub fn pubkey(&self) -> &PublicKeyHex {
- &self.pubkey
- }
-
- pub fn created_at(&self) -> UnixTimestamp {
- self.created_at
- }
-
- pub fn metadata(&self) -> &SellerProfileMetadata {
- &self.metadata
- }
-
- pub fn regions(&self) -> &[String] {
- &self.regions
- }
-
- pub fn categories(&self) -> &[String] {
- &self.categories
- }
-
- pub fn trust_markers(&self) -> &[String] {
- &self.trust_markers
- }
-}
-
-pub fn parse_seller_profile_event(event: &Event) -> Result<Option<SellerProfileEvent>, String> {
- if event.unsigned().kind().as_u32() != NIP01_METADATA_KIND {
- return Ok(None);
- }
- let metadata = parse_seller_profile_metadata(event.unsigned().content())?;
- Ok(Some(SellerProfileEvent {
- event_id: event.id().clone(),
- pubkey: event.unsigned().pubkey().clone(),
- created_at: event.unsigned().created_at(),
- metadata,
- regions: collect_normalized_tag_values(event, "region", "seller profile region")?,
- categories: collect_normalized_tag_values(event, "category", "seller profile category")?,
- trust_markers: collect_normalized_tag_values(event, "trust", "seller profile trust")?,
- }))
-}
-
-fn parse_seller_profile_metadata(content: &str) -> Result<SellerProfileMetadata, String> {
- let value = serde_json::from_str::<serde_json::Value>(content)
- .map_err(|error| format!("seller profile metadata JSON is invalid: {error}"))?;
- let object = value
- .as_object()
- .ok_or_else(|| "seller profile metadata must be a JSON object".to_owned())?;
- Ok(SellerProfileMetadata {
- name: optional_metadata_string(object, "name")?,
- display_name: optional_metadata_string(object, "display_name")?,
- about: optional_metadata_string(object, "about")?,
- picture: optional_metadata_string(object, "picture")?,
- website: optional_metadata_string(object, "website")?,
- nip05: optional_metadata_string(object, "nip05")?,
- lud16: optional_metadata_string(object, "lud16")?,
- })
-}
-
-fn optional_metadata_string(
- object: &serde_json::Map<String, serde_json::Value>,
- field: &'static str,
-) -> Result<Option<String>, String> {
- match object.get(field) {
- Some(value) if value.is_null() => Ok(None),
- Some(value) => {
- let value = value
- .as_str()
- .ok_or_else(|| format!("seller profile metadata `{field}` must be a string"))?
- .trim();
- if value.is_empty() {
- return Ok(None);
- }
- Ok(Some(value.to_owned()))
- }
- None => Ok(None),
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum ListingKind {
- Public,
- Draft,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ListingIdentity {
- event_id: EventId,
- listing_kind: ListingKind,
- address: AddressCoordinate,
-}
-
-impl ListingIdentity {
- pub fn event_id(&self) -> &EventId {
- &self.event_id
- }
-
- pub fn listing_kind(&self) -> ListingKind {
- self.listing_kind
- }
-
- pub fn address(&self) -> &AddressCoordinate {
- &self.address
- }
-
- pub fn seller_pubkey(&self) -> &PublicKeyHex {
- self.address.pubkey()
- }
-
- pub fn d(&self) -> &DTag {
- self.address.d()
- }
-}
-
-pub fn parse_listing_identity(event: &Event) -> Result<Option<ListingIdentity>, String> {
- let Some(listing_kind) = listing_kind_for_event(event) else {
- return Ok(None);
- };
- let d = required_tag_value(event, "d")?;
- if d.is_empty() {
- return Err("listing d tag must not be empty".to_owned());
- }
- let address = AddressCoordinate::new(
- event.unsigned().kind(),
- event.unsigned().pubkey().clone(),
- DTag::new(&d),
- )
- .expect("listing kind must be addressable");
- Ok(Some(ListingIdentity {
- event_id: event.id().clone(),
- listing_kind,
- address,
- }))
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ListingText {
- title: String,
- summary: Option<String>,
- body: String,
-}
-
-impl ListingText {
- pub fn title(&self) -> &str {
- &self.title
- }
-
- pub fn summary(&self) -> Option<&str> {
- self.summary.as_deref()
- }
-
- pub fn body(&self) -> &str {
- &self.body
- }
-}
-
-pub fn parse_listing_text(event: &Event) -> Result<Option<ListingText>, String> {
- if listing_kind_for_event(event).is_none() {
- return Ok(None);
- }
- let title = required_tag_value(event, "title")?;
- if title.is_empty() {
- return Err("listing title tag must not be empty".to_owned());
- }
- let summary = optional_tag_value(event, "summary")?;
- if summary.as_ref().is_some_and(String::is_empty) {
- return Err("listing summary tag must not be empty".to_owned());
- }
- Ok(Some(ListingText {
- title,
- summary,
- body: event.unsigned().content().to_owned(),
- }))
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct PriceAmount {
- raw: String,
-}
-
-impl PriceAmount {
- pub fn raw(&self) -> &str {
- &self.raw
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ListingPrice {
- amount: PriceAmount,
- currency: String,
- display_currency: String,
- frequency: Option<String>,
-}
-
-impl ListingPrice {
- pub fn amount(&self) -> &PriceAmount {
- &self.amount
- }
-
- pub fn currency(&self) -> &str {
- &self.currency
- }
-
- pub fn display_currency(&self) -> &str {
- &self.display_currency
- }
-
- pub fn frequency(&self) -> Option<&str> {
- self.frequency.as_deref()
- }
-}
-
-pub fn parse_listing_price(event: &Event) -> Result<Option<ListingPrice>, String> {
- if listing_kind_for_event(event).is_none() {
- return Ok(None);
- }
- let values = required_tag_values(event, "price")?;
- match values.len() {
- 0 | 1 => Err("price tag must include amount and currency".to_owned()),
- 2 | 3 => {
- let amount = parse_price_amount(&values[0])?;
- let currency = values[1].clone();
- if currency.is_empty() {
- return Err("price currency must not be empty".to_owned());
- }
- let frequency = values.get(2).cloned();
- if frequency.as_ref().is_some_and(String::is_empty) {
- return Err("price frequency must not be empty".to_owned());
- }
- Ok(Some(ListingPrice {
- amount,
- display_currency: currency.to_ascii_uppercase(),
- currency,
- frequency,
- }))
- }
- _ => Err("price tag must not include more than amount currency and frequency".to_owned()),
- }
-}
-
-fn parse_price_amount(value: &str) -> Result<PriceAmount, String> {
- if is_exact_unsigned_decimal(value) {
- return Ok(PriceAmount {
- raw: value.to_owned(),
- });
- }
- Err("price amount must be an exact unsigned decimal".to_owned())
-}
-
-fn is_exact_unsigned_decimal(value: &str) -> bool {
- let mut parts = value.split('.');
- let whole = parts.next().unwrap_or_default();
- let fraction = parts.next();
- if parts.next().is_some()
- || whole.is_empty()
- || !whole.bytes().all(|byte| byte.is_ascii_digit())
- {
- return false;
- }
- match fraction {
- Some(value) => !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()),
- None => true,
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum ListingUnit {
- Lb,
- Oz,
- Each,
- Bunch,
- Dozen,
- Kg,
- G,
- Share,
- Pint,
- Quart,
- Box,
- Crate,
- Flat,
-}
-
-impl ListingUnit {
- pub fn canonical(self) -> &'static str {
- match self {
- Self::Lb => "lb",
- Self::Oz => "oz",
- Self::Each => "each",
- Self::Bunch => "bunch",
- Self::Dozen => "dozen",
- Self::Kg => "kg",
- Self::G => "g",
- Self::Share => "share",
- Self::Pint => "pint",
- Self::Quart => "quart",
- Self::Box => "box",
- Self::Crate => "crate",
- Self::Flat => "flat",
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ListingUnitTag {
- raw: String,
- unit: ListingUnit,
-}
-
-impl ListingUnitTag {
- pub fn raw(&self) -> &str {
- &self.raw
- }
-
- pub fn unit(&self) -> ListingUnit {
- self.unit
- }
-
- pub fn canonical(&self) -> &'static str {
- self.unit.canonical()
- }
-}
-
-pub fn parse_listing_unit(event: &Event) -> Result<Option<ListingUnitTag>, String> {
- if listing_kind_for_event(event).is_none() {
- return Ok(None);
- }
- let raw = required_tag_value(event, "unit")?;
- let normalized = raw.trim().to_ascii_lowercase();
- if normalized.is_empty() {
- return Err("listing unit tag must not be empty".to_owned());
- }
- let unit = parse_unit_value(&normalized)
- .ok_or_else(|| format!("listing unit `{raw}` is unsupported"))?;
- Ok(Some(ListingUnitTag { raw, unit }))
-}
-
-fn parse_unit_value(value: &str) -> Option<ListingUnit> {
- match value {
- "lb" | "lbs" | "pound" | "pounds" => Some(ListingUnit::Lb),
- "oz" | "ounce" | "ounces" => Some(ListingUnit::Oz),
- "each" | "ea" => Some(ListingUnit::Each),
- "bunch" | "bunches" => Some(ListingUnit::Bunch),
- "dozen" => Some(ListingUnit::Dozen),
- "kg" | "kilogram" | "kilograms" => Some(ListingUnit::Kg),
- "g" | "gram" | "grams" => Some(ListingUnit::G),
- "share" | "shares" => Some(ListingUnit::Share),
- "pint" | "pints" => Some(ListingUnit::Pint),
- "quart" | "quarts" => Some(ListingUnit::Quart),
- "box" | "boxes" => Some(ListingUnit::Box),
- "crate" | "crates" => Some(ListingUnit::Crate),
- "flat" | "flats" => Some(ListingUnit::Flat),
- _ => None,
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
-pub enum FulfillmentMethod {
- Pickup,
- Delivery,
- Shipping,
-}
-
-impl FulfillmentMethod {
- pub fn canonical(self) -> &'static str {
- match self {
- Self::Pickup => "pickup",
- Self::Delivery => "delivery",
- Self::Shipping => "shipping",
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ListingFulfillment {
- methods: Vec<FulfillmentMethod>,
-}
-
-impl ListingFulfillment {
- pub fn methods(&self) -> &[FulfillmentMethod] {
- &self.methods
- }
-
- pub fn pickup_available(&self) -> bool {
- self.methods.contains(&FulfillmentMethod::Pickup)
- }
-
- pub fn delivery_available(&self) -> bool {
- self.methods.contains(&FulfillmentMethod::Delivery)
- }
-
- pub fn shipping_available(&self) -> bool {
- self.methods.contains(&FulfillmentMethod::Shipping)
- }
-
- pub fn delivery_only(&self) -> bool {
- self.delivery_available() && !self.pickup_available() && !self.shipping_available()
- }
-}
-
-pub fn parse_listing_fulfillment(event: &Event) -> Result<Option<ListingFulfillment>, String> {
- if listing_kind_for_event(event).is_none() {
- return Ok(None);
- }
- let tags = matching_tags(event, "fulfillment");
- if tags.is_empty() {
- return Err("tag `fulfillment` is required".to_owned());
- }
- let mut methods = Vec::new();
- for tag in tags {
- let values = tag.values();
- let raw = values
- .first()
- .ok_or_else(|| "tag `fulfillment` must include a value".to_owned())?;
- if values.len() > 1 {
- return Err("fulfillment tag must include exactly one method".to_owned());
- }
- let normalized = raw.trim().to_ascii_lowercase();
- if normalized.is_empty() {
- return Err("fulfillment tag method must not be empty".to_owned());
- }
- let method = parse_fulfillment_method(&normalized)
- .ok_or_else(|| format!("fulfillment method `{raw}` is unsupported"))?;
- if !methods.contains(&method) {
- methods.push(method);
- }
- }
- methods.sort_unstable();
- Ok(Some(ListingFulfillment { methods }))
-}
-
-fn parse_fulfillment_method(value: &str) -> Option<FulfillmentMethod> {
- match value {
- "pickup" => Some(FulfillmentMethod::Pickup),
- "delivery" => Some(FulfillmentMethod::Delivery),
- "shipping" => Some(FulfillmentMethod::Shipping),
- _ => None,
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum ListingEffectiveStatus {
- Active,
- Sold,
- Draft,
-}
-
-impl ListingEffectiveStatus {
- pub fn canonical(self) -> &'static str {
- match self {
- Self::Active => "active",
- Self::Sold => "sold",
- Self::Draft => "draft",
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ListingStatus {
- raw_status: Option<String>,
- effective_status: ListingEffectiveStatus,
-}
-
-impl ListingStatus {
- pub fn raw_status(&self) -> Option<&str> {
- self.raw_status.as_deref()
- }
-
- pub fn effective_status(&self) -> ListingEffectiveStatus {
- self.effective_status
- }
-}
-
-pub fn parse_listing_status(event: &Event) -> Result<Option<ListingStatus>, String> {
- let Some(listing_kind) = listing_kind_for_event(event) else {
- return Ok(None);
- };
- let raw_status = optional_tag_value(event, "status")?;
- let parsed_status = match raw_status.as_deref() {
- Some("") => return Err("listing status tag must not be empty".to_owned()),
- Some("active") => Some(ListingEffectiveStatus::Active),
- Some("sold") => Some(ListingEffectiveStatus::Sold),
- Some(value) => return Err(format!("listing status `{value}` is unsupported")),
- None => None,
- };
- let effective_status = match listing_kind {
- ListingKind::Draft => ListingEffectiveStatus::Draft,
- ListingKind::Public => parsed_status.unwrap_or(ListingEffectiveStatus::Active),
- };
- Ok(Some(ListingStatus {
- raw_status,
- effective_status,
- }))
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ListingLocation {
- location_text: Option<String>,
- geohash: Option<String>,
- geohash4: Option<String>,
- geohash5: Option<String>,
- geohash6: Option<String>,
- geohash7: Option<String>,
-}
-
-impl ListingLocation {
- pub fn location_text(&self) -> Option<&str> {
- self.location_text.as_deref()
- }
-
- pub fn geohash(&self) -> Option<&str> {
- self.geohash.as_deref()
- }
-
- pub fn geohash4(&self) -> Option<&str> {
- self.geohash4.as_deref()
- }
-
- pub fn geohash5(&self) -> Option<&str> {
- self.geohash5.as_deref()
- }
-
- pub fn geohash6(&self) -> Option<&str> {
- self.geohash6.as_deref()
- }
-
- pub fn geohash7(&self) -> Option<&str> {
- self.geohash7.as_deref()
- }
-
- pub fn location_precision(&self) -> Option<usize> {
- self.geohash.as_ref().map(String::len)
- }
-}
-
-pub fn parse_listing_location(event: &Event) -> Result<Option<ListingLocation>, String> {
- if listing_kind_for_event(event).is_none() {
- return Ok(None);
- }
- let location_text = optional_tag_value(event, "location")?;
- if location_text.as_ref().is_some_and(String::is_empty) {
- return Err("listing location tag must not be empty".to_owned());
- }
- let geohash = optional_exact_tag_value(event, "g")?
- .map(|value| parse_geohash(&value))
- .transpose()?;
- Ok(Some(ListingLocation {
- location_text,
- geohash4: geohash_prefix(&geohash, 4),
- geohash5: geohash_prefix(&geohash, 5),
- geohash6: geohash_prefix(&geohash, 6),
- geohash7: geohash_prefix(&geohash, 7),
- geohash,
- }))
-}
-
-fn optional_exact_tag_value(event: &Event, name: &str) -> Result<Option<String>, String> {
- let tags = matching_tags(event, name);
- match tags.as_slice() {
- [] => Ok(None),
- [tag] => match tag.values() {
- [] => Err(format!("tag `{name}` must include a value")),
- [value] => Ok(Some(value.clone())),
- _ => Err(format!("tag `{name}` must include exactly one value")),
- },
- _ => Err(format!("tag `{name}` must not be repeated")),
- }
-}
-
-fn parse_geohash(value: &str) -> Result<String, String> {
- let normalized = value.trim().to_ascii_lowercase();
- if normalized.len() < 4
- || normalized.len() > 12
- || !normalized
- .bytes()
- .all(|byte| b"0123456789bcdefghjkmnpqrstuvwxyz".contains(&byte))
- {
- return Err("geohash must be 4 to 12 geohash characters".to_owned());
- }
- Ok(normalized)
-}
-
-fn geohash_prefix(geohash: &Option<String>, length: usize) -> Option<String> {
- geohash
- .as_ref()
- .filter(|value| value.len() >= length)
- .map(|value| value[..length].to_owned())
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ListingTaxonomy {
- categories: Vec<String>,
- topics: Vec<String>,
- practices: Vec<String>,
- certifications: Vec<String>,
-}
-
-impl ListingTaxonomy {
- pub fn categories(&self) -> &[String] {
- &self.categories
- }
-
- pub fn topics(&self) -> &[String] {
- &self.topics
- }
-
- pub fn practices(&self) -> &[String] {
- &self.practices
- }
-
- pub fn certifications(&self) -> &[String] {
- &self.certifications
- }
-}
-
-pub fn parse_listing_taxonomy(event: &Event) -> Result<Option<ListingTaxonomy>, String> {
- if listing_kind_for_event(event).is_none() {
- return Ok(None);
- }
- Ok(Some(ListingTaxonomy {
- categories: collect_taxonomy_values(event, "category")?,
- topics: collect_taxonomy_values(event, "t")?,
- practices: collect_taxonomy_values(event, "practice")?,
- certifications: collect_taxonomy_values(event, "certification")?,
- }))
-}
-
-fn collect_taxonomy_values(event: &Event, name: &str) -> Result<Vec<String>, String> {
- let mut values = Vec::new();
- for tag in matching_tags(event, name) {
- match tag.values() {
- [] => return Err(format!("tag `{name}` must include a value")),
- [value] => {
- let normalized = normalize_taxonomy_value(name, value)?;
- values.push(normalized);
- }
- _ => return Err(format!("tag `{name}` must include exactly one value")),
- }
- }
- values.sort();
- values.dedup();
- Ok(values)
-}
-
-fn normalize_taxonomy_value(name: &str, value: &str) -> Result<String, String> {
- let normalized = value.trim().to_ascii_lowercase();
- if normalized.is_empty() {
- return Err(format!("listing taxonomy `{name}` value must not be empty"));
- }
- Ok(normalized)
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ListingProjection {
- identity: ListingIdentity,
- text: ListingText,
- price: ListingPrice,
- unit: ListingUnitTag,
- fulfillment: ListingFulfillment,
- status: ListingStatus,
- location: ListingLocation,
- taxonomy: ListingTaxonomy,
-}
-
-impl ListingProjection {
- pub fn identity(&self) -> &ListingIdentity {
- &self.identity
- }
-
- pub fn text(&self) -> &ListingText {
- &self.text
- }
-
- pub fn price(&self) -> &ListingPrice {
- &self.price
- }
-
- pub fn unit(&self) -> &ListingUnitTag {
- &self.unit
- }
-
- pub fn fulfillment(&self) -> &ListingFulfillment {
- &self.fulfillment
- }
-
- pub fn status(&self) -> &ListingStatus {
- &self.status
- }
-
- pub fn location(&self) -> &ListingLocation {
- &self.location
- }
-
- pub fn taxonomy(&self) -> &ListingTaxonomy {
- &self.taxonomy
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ListingProjectionRejection {
- event_id: EventId,
- reasons: Vec<String>,
-}
-
-impl ListingProjectionRejection {
- pub fn event_id(&self) -> &EventId {
- &self.event_id
- }
-
- pub fn reasons(&self) -> &[String] {
- &self.reasons
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum ListingProjectionEvaluation {
- NotListing,
- Eligible(Box<ListingProjection>),
- Ineligible(ListingProjectionRejection),
-}
-
-impl ListingProjectionEvaluation {
- pub fn is_eligible(&self) -> bool {
- matches!(self, Self::Eligible(_))
- }
-
- pub fn projection(&self) -> Option<&ListingProjection> {
- match self {
- Self::Eligible(projection) => Some(projection),
- Self::NotListing | Self::Ineligible(_) => None,
- }
- }
-
- pub fn rejection(&self) -> Option<&ListingProjectionRejection> {
- match self {
- Self::Ineligible(rejection) => Some(rejection),
- Self::NotListing | Self::Eligible(_) => None,
- }
- }
-}
-
-pub fn evaluate_listing_projection(event: &Event) -> ListingProjectionEvaluation {
- if listing_kind_for_event(event).is_none() {
- return ListingProjectionEvaluation::NotListing;
- }
- let mut reasons = Vec::new();
- let identity = parse_listing_identity(event)
- .map_err(|reason| reasons.push(reason))
- .ok()
- .flatten();
- let text = parse_listing_text(event)
- .map_err(|reason| reasons.push(reason))
- .ok()
- .flatten();
- let price = parse_listing_price(event)
- .map_err(|reason| reasons.push(reason))
- .ok()
- .flatten();
- let unit = parse_listing_unit(event)
- .map_err(|reason| reasons.push(reason))
- .ok()
- .flatten();
- let fulfillment = parse_listing_fulfillment(event)
- .map_err(|reason| reasons.push(reason))
- .ok()
- .flatten();
- let status = parse_listing_status(event)
- .map_err(|reason| reasons.push(reason))
- .ok()
- .flatten();
- let location = parse_listing_location(event)
- .map_err(|reason| reasons.push(reason))
- .ok()
- .flatten();
- let taxonomy = parse_listing_taxonomy(event)
- .map_err(|reason| reasons.push(reason))
- .ok()
- .flatten();
- if identity
- .as_ref()
- .is_some_and(|identity| identity.listing_kind() == ListingKind::Draft)
- {
- reasons.push("draft listing is not public projection eligible".to_owned());
- }
- match (
- identity,
- text,
- price,
- unit,
- fulfillment,
- status,
- location,
- taxonomy,
- ) {
- (
- Some(identity),
- Some(text),
- Some(price),
- Some(unit),
- Some(fulfillment),
- Some(status),
- Some(location),
- Some(taxonomy),
- ) if reasons.is_empty() => {
- ListingProjectionEvaluation::Eligible(Box::new(ListingProjection {
- identity,
- text,
- price,
- unit,
- fulfillment,
- status,
- location,
- taxonomy,
- }))
- }
- _ => ListingProjectionEvaluation::Ineligible(ListingProjectionRejection {
- event_id: event.id().clone(),
- reasons,
- }),
- }
-}
-
-fn listing_kind_for_event(event: &Event) -> Option<ListingKind> {
- match event.unsigned().kind().as_u32() {
- NIP99_PUBLIC_LISTING_KIND => Some(ListingKind::Public),
- NIP99_DRAFT_LISTING_KIND => Some(ListingKind::Draft),
- _ => None,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{
- CommentTarget, DeletionTarget, FulfillmentMethod, LabelTarget, ListingEffectiveStatus,
- ListingKind, ListingProjectionEvaluation, ListingUnit, LongFormKind, NIP01_METADATA_KIND,
- NIP7D_THREAD_KIND, NIP22_COMMENT_KIND, NIP23_LONG_FORM_DRAFT_KIND, NIP23_LONG_FORM_KIND,
- NIP25_REACTION_KIND, NIP32_LABEL_KIND, NIP56_REPORT_KIND, NIP99_PUBLIC_LISTING_KIND,
- ParsedTag, ReactionValue, ReportTarget, ReportType, evaluate_listing_projection,
- matching_tags, optional_tag_value, optional_tag_values, parse_comment_event,
- parse_deletion_request, parse_forum_thread_event, parse_label_event,
- parse_listing_fulfillment, parse_listing_identity, parse_listing_location,
- parse_listing_price, parse_listing_status, parse_listing_taxonomy, parse_listing_text,
- parse_listing_unit, parse_long_form_event, parse_nip50_filter_search, parse_nip50_search,
- parse_reaction_event, parse_relay_auth_event, parse_report_event, parse_required_u64_tag,
- parse_seller_profile_event, parse_u64_field, repeated_or_missing_policy_boundary,
- required_tag_value, required_tag_values, single_letter_tag_values,
- single_letter_values_for, tag_count,
- };
- use tangle_protocol::{
- Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent,
- filter_from_value,
- };
-
- #[test]
- fn shared_parser_utilities_extract_matching_tags() {
- let event = event_with_tags(vec![
- Tag::from_parts("d", &["listing-a"]).expect("d"),
- Tag::from_parts("title", &["Carrots"]).expect("title"),
- Tag::from_parts("price", &["12.50", "USD"]).expect("price"),
- ]);
- let price = matching_tags(&event, "price");
-
- assert_eq!(tag_count(&event, "d"), 1);
- assert_eq!(tag_count(&event, "missing"), 0);
- assert_eq!(price[0].name(), "price");
- assert_eq!(price[0].first_value(), Some("12.50"));
- assert_eq!(price[0].values(), &["12.50".to_owned(), "USD".to_owned()]);
- assert_eq!(
- optional_tag_value(&event, "d"),
- Ok(Some("listing-a".to_owned()))
- );
- assert_eq!(optional_tag_value(&event, "missing"), Ok(None));
- assert_eq!(
- required_tag_value(&event, "title"),
- Ok("Carrots".to_owned())
- );
- assert_eq!(
- optional_tag_values(&event, "price"),
- Ok(Some(vec!["12.50".to_owned(), "USD".to_owned()]))
- );
- assert_eq!(
- required_tag_values(&event, "price"),
- Ok(vec!["12.50".to_owned(), "USD".to_owned()])
- );
- }
-
- #[test]
- fn shared_parser_utilities_reject_missing_repeated_and_malformed_values() {
- let repeated = event_with_tags(vec![
- Tag::from_parts("d", &["one"]).expect("d"),
- Tag::from_parts("d", &["two"]).expect("d"),
- ]);
- let missing_value = event_with_tags(vec![Tag::from_parts("d", &[]).expect("d")]);
- let missing = event_with_tags(Vec::new());
-
- assert_eq!(
- optional_tag_value(&repeated, "d").expect_err("repeated"),
- "tag `d` must not be repeated"
- );
- assert_eq!(
- optional_tag_values(&repeated, "d").expect_err("repeated values"),
- "tag `d` must not be repeated"
- );
- assert_eq!(
- optional_tag_value(&missing_value, "d").expect_err("value"),
- "tag `d` must include a value"
- );
- assert_eq!(
- required_tag_value(&missing, "d").expect_err("missing"),
- "tag `d` is required"
- );
- assert_eq!(
- required_tag_values(&missing, "d").expect_err("missing values"),
- "tag `d` is required"
- );
- assert_eq!(
- parse_u64_field("published_at", "now").expect_err("number"),
- "field `published_at` must be an unsigned integer"
- );
- }
-
- #[test]
- fn shared_parser_utilities_parse_numeric_tags_and_policy_boundaries() {
- let event = event_with_tags(vec![Tag::from_parts("published_at", &["42"]).expect("tag")]);
- let missing = event_with_tags(Vec::new());
-
- assert_eq!(parse_u64_field("published_at", "42"), Ok(42));
- assert_eq!(parse_required_u64_tag(&event, "published_at"), Ok(42));
- assert_eq!(repeated_or_missing_policy_boundary(&missing, "d"), Ok(None));
- }
-
- #[test]
- fn single_letter_tag_extraction_indexes_first_values_only() {
- let event = event_with_tags(vec![
- Tag::from_parts("e", &["root", "relay"]).expect("e"),
- Tag::from_parts("p", &["peer"]).expect("p"),
- Tag::from_parts("E", &["uppercase-root"]).expect("E"),
- Tag::from_parts("t", &["carrots"]).expect("t"),
- Tag::from_parts("alt", &["not indexed"]).expect("alt"),
- Tag::from_parts("1", &["not indexed"]).expect("number"),
- Tag::from_parts("g", &[]).expect("missing value"),
- ]);
- let values = single_letter_tag_values(&event);
-
- assert_eq!(values.len(), 4);
- assert_eq!(values[0].name(), "e");
- assert_eq!(values[0].value(), "root");
- assert_eq!(values[2].name(), "E");
- assert_eq!(
- single_letter_values_for(&event, "e"),
- Ok(vec!["root".to_owned()])
- );
- assert_eq!(
- single_letter_values_for(&event, "E"),
- Ok(vec!["uppercase-root".to_owned()])
- );
- assert_eq!(single_letter_values_for(&event, "g"), Ok(Vec::new()));
- }
-
- #[test]
- fn single_letter_tag_extraction_handles_repeated_missing_and_malformed_names() {
- let repeated = event_with_tags(vec![
- Tag::from_parts("t", &["carrots"]).expect("t"),
- Tag::from_parts("t", &["greens"]).expect("t"),
- ]);
- let missing = event_with_tags(Vec::new());
-
- assert_eq!(
- single_letter_values_for(&repeated, "t"),
- Ok(vec!["carrots".to_owned(), "greens".to_owned()])
- );
- assert_eq!(
- super::first_single_letter_value(&repeated, "t"),
- Ok(Some("carrots".to_owned()))
- );
- assert_eq!(super::first_single_letter_value(&missing, "t"), Ok(None));
- assert_eq!(
- single_letter_values_for(&missing, "topic").expect_err("long"),
- "single-letter tag name `topic` must be one ASCII letter"
- );
- assert_eq!(
- single_letter_values_for(&missing, "é").expect_err("non ascii"),
- "single-letter tag name `é` must be one ASCII letter"
- );
- }
-
- #[test]
- fn comment_parser_extracts_root_parent_authors_and_references() {
- let root_pubkey = "2".repeat(PublicKeyHex::HEX_LENGTH);
- let parent_pubkey = "3".repeat(PublicKeyHex::HEX_LENGTH);
- let comment_event = "4".repeat(EventId::HEX_LENGTH);
- let mentioned_pubkey = "5".repeat(PublicKeyHex::HEX_LENGTH);
- let address = format!("30023:{root_pubkey}:article-a");
- let event = event_with_kind_tags_and_content(
- NIP22_COMMENT_KIND.into(),
- vec![
- Tag::from_parts("A", &[&address, "wss://relay.radroots.test"]).expect("A"),
- Tag::from_parts("K", &["30023"]).expect("K"),
- Tag::from_parts("P", &[&root_pubkey]).expect("P"),
- Tag::from_parts(
- "e",
- &[&comment_event, "wss://relay.radroots.test", &parent_pubkey],
- )
- .expect("e"),
- Tag::from_parts("k", &["1111"]).expect("k"),
- Tag::from_parts("p", &[&parent_pubkey]).expect("p"),
- Tag::from_parts("p", &[&mentioned_pubkey]).expect("mention"),
- Tag::from_parts("q", &[&comment_event]).expect("q"),
- ],
- "That harvest note helped.",
- );
-
- let comment = parse_comment_event(&event)
- .expect("parse")
- .expect("comment");
-
- assert_eq!(comment.event_id(), event.id());
- assert_eq!(comment.pubkey(), event.unsigned().pubkey());
- assert_eq!(comment.created_at(), event.unsigned().created_at());
- assert_eq!(comment.content(), "That harvest note helped.");
- assert_eq!(comment.root().kind(), "30023");
- assert_eq!(
- comment.root().author().expect("root author").as_str(),
- root_pubkey
- );
- assert_eq!(comment.parent().kind(), "1111");
- assert_eq!(
- comment.parent().author().expect("parent author").as_str(),
- parent_pubkey
- );
- assert_eq!(comment.cited_events(), std::slice::from_ref(&comment_event));
- assert_eq!(comment.mentioned_pubkeys()[0].as_str(), parent_pubkey);
- assert_eq!(comment.mentioned_pubkeys()[1].as_str(), mentioned_pubkey);
- assert!(matches!(
- comment.root().target(),
- CommentTarget::Address { .. }
- ));
- assert_eq!(comment.root().target().target_type(), "address");
- assert_eq!(comment.root().target().target_ref(), address);
- assert_eq!(
- comment.root().target().relay_hint(),
- Some("wss://relay.radroots.test")
- );
- assert!(matches!(
- comment.parent().target(),
- CommentTarget::Event { .. }
- ));
- assert_eq!(comment.parent().target().target_type(), "event");
- assert_eq!(comment.parent().target().target_ref(), comment_event);
- assert_eq!(
- comment.parent().target().relay_hint(),
- Some("wss://relay.radroots.test")
- );
- }
-
- #[test]
- fn comment_parser_extracts_external_scope_and_ignores_other_kinds() {
- let event = event_with_kind_and_tags(
- NIP22_COMMENT_KIND.into(),
- vec![
- Tag::from_parts("I", &["https://radroots.test/posts/harvest"]).expect("I"),
- Tag::from_parts("K", &["web"]).expect("K"),
- Tag::from_parts("i", &["https://radroots.test/posts/harvest"]).expect("i"),
- Tag::from_parts("k", &["web"]).expect("k"),
- ],
- );
- let note = event_with_kind_and_tags(1, Vec::new());
-
- let comment = parse_comment_event(&event)
- .expect("parse")
- .expect("comment");
-
- assert_eq!(parse_comment_event(¬e), Ok(None));
- assert!(matches!(
- comment.root().target(),
- CommentTarget::External { .. }
- ));
- assert_eq!(comment.root().target().target_type(), "external");
- assert_eq!(
- comment.root().target().target_ref(),
- "https://radroots.test/posts/harvest"
- );
- assert_eq!(comment.root().target().relay_hint(), None);
- }
-
- #[test]
- fn comment_parser_rejects_missing_repeated_empty_and_kind_one_targets() {
- let target = "2".repeat(EventId::HEX_LENGTH);
- let valid = vec![
- Tag::from_parts("E", &[&target]).expect("E"),
- Tag::from_parts("K", &["30023"]).expect("K"),
- Tag::from_parts("e", &[&target]).expect("e"),
- Tag::from_parts("k", &["30023"]).expect("k"),
- ];
- let missing_root = event_with_kind_tags_and_content(
- NIP22_COMMENT_KIND.into(),
- valid
- .iter()
- .filter(|tag| tag.name().as_str() != "E")
- .cloned()
- .collect(),
- "",
- );
- let repeated_root = event_with_kind_tags_and_content(
- NIP22_COMMENT_KIND.into(),
- [valid.clone(), vec![Tag::from_parts("A", &["30023:1111111111111111111111111111111111111111111111111111111111111111:article"]).expect("A")]].concat(),
- "",
- );
- let empty_parent = event_with_kind_tags_and_content(
- NIP22_COMMENT_KIND.into(),
- vec![
- Tag::from_parts("E", &[&target]).expect("E"),
- Tag::from_parts("K", &["30023"]).expect("K"),
- Tag::from_parts("e", &[""]).expect("e"),
- Tag::from_parts("k", &["30023"]).expect("k"),
- ],
- "",
- );
- let kind_one = event_with_kind_tags_and_content(
- NIP22_COMMENT_KIND.into(),
- vec![
- Tag::from_parts("E", &[&target]).expect("E"),
- Tag::from_parts("K", &["1"]).expect("K"),
- Tag::from_parts("e", &[&target]).expect("e"),
- Tag::from_parts("k", &["30023"]).expect("k"),
- ],
- "",
- );
-
- assert_eq!(
- parse_comment_event(&missing_root).expect_err("missing"),
- "comment root target tag is required"
- );
- assert_eq!(
- parse_comment_event(&repeated_root).expect_err("repeated"),
- "comment root target tag must not be repeated"
- );
- assert_eq!(
- parse_comment_event(&empty_parent).expect_err("empty"),
- "comment parent target tag `e` must not be empty"
- );
- assert_eq!(
- parse_comment_event(&kind_one).expect_err("kind one"),
- "NIP-22 comments must not reply to kind 1 notes"
- );
- }
-
- #[test]
- fn comment_parser_rejects_empty_kinds_authors_and_malformed_targets() {
- let event_id = "2".repeat(EventId::HEX_LENGTH);
- let pubkey = "3".repeat(PublicKeyHex::HEX_LENGTH);
- let address = format!("30023:{pubkey}:article-a");
- let empty_root_kind = event_with_kind_and_tags(
- NIP22_COMMENT_KIND.into(),
- vec![
- Tag::from_parts("E", &[&event_id]).expect("E"),
- Tag::from_parts("K", &[""]).expect("K"),
- Tag::from_parts("e", &[&event_id]).expect("e"),
- Tag::from_parts("k", &["30023"]).expect("k"),
- ],
- );
- let empty_parent_kind = event_with_kind_and_tags(
- NIP22_COMMENT_KIND.into(),
- vec![
- Tag::from_parts("E", &[&event_id]).expect("E"),
- Tag::from_parts("K", &["30023"]).expect("K"),
- Tag::from_parts("e", &[&event_id]).expect("e"),
- Tag::from_parts("k", &[""]).expect("k"),
- ],
- );
- let repeated_root_author = event_with_kind_and_tags(
- NIP22_COMMENT_KIND.into(),
- vec![
- Tag::from_parts("E", &[&event_id]).expect("E"),
- Tag::from_parts("K", &["30023"]).expect("K"),
- Tag::from_parts("P", &[&pubkey]).expect("P"),
- Tag::from_parts("P", &[&pubkey]).expect("P2"),
- Tag::from_parts("e", &[&event_id]).expect("e"),
- Tag::from_parts("k", &["30023"]).expect("k"),
- ],
- );
- let empty_relay = event_with_kind_and_tags(
- NIP22_COMMENT_KIND.into(),
- vec![
- Tag::from_parts("E", &[&event_id, ""]).expect("E"),
- Tag::from_parts("K", &["30023"]).expect("K"),
- Tag::from_parts("e", &[&event_id]).expect("e"),
- Tag::from_parts("k", &["30023"]).expect("k"),
- ],
- );
-
- assert_eq!(
- parse_comment_event(&empty_root_kind).expect_err("root kind"),
- "comment root kind tag must not be empty"
- );
- assert_eq!(
- parse_comment_event(&empty_parent_kind).expect_err("parent kind"),
- "comment parent kind tag must not be empty"
- );
- assert_eq!(
- parse_comment_event(&repeated_root_author).expect_err("root author"),
- "comment root author tag `P` must not be repeated"
- );
- assert_eq!(
- parse_comment_event(&empty_relay).expect_err("relay"),
- "comment root target relay hint must not be empty"
- );
- assert_eq!(
- super::parse_comment_target_tag(
- "E",
- &ParsedTag {
- name: "E".to_owned(),
- values: vec![
- event_id.clone(),
- "relay".to_owned(),
- pubkey.clone(),
- "extra".to_owned(),
- ],
- },
- "root",
- )
- .expect_err("event target"),
- "comment root event target tag `E` must include at most event relay and pubkey values"
- );
- assert_eq!(
- super::parse_comment_target_tag(
- "A",
- &ParsedTag {
- name: "A".to_owned(),
- values: vec![address, "relay".to_owned(), "extra".to_owned()],
- },
- "root",
- )
- .expect_err("address target"),
- "comment root address target tag `A` must include at most address and relay values"
- );
- assert_eq!(
- super::parse_comment_target_tag(
- "I",
- &ParsedTag {
- name: "I".to_owned(),
- values: vec![
- "https://radroots.test/post".to_owned(),
- "relay".to_owned(),
- "extra".to_owned(),
- ],
- },
- "root",
- )
- .expect_err("external target"),
- "comment root external target tag `I` must include at most identity and relay values"
- );
- assert_eq!(
- super::parse_comment_target_tag(
- "x",
- &ParsedTag {
- name: "x".to_owned(),
- values: vec!["value".to_owned()],
- },
- "root",
- )
- .expect_err("unsupported target"),
- "comment root target tag `x` is unsupported"
- );
- }
-
- #[test]
- fn reaction_parser_extracts_addressable_target_and_like_reaction() {
- let target_event = "2".repeat(EventId::HEX_LENGTH);
- let previous_event = "3".repeat(EventId::HEX_LENGTH);
- let target_pubkey = "4".repeat(PublicKeyHex::HEX_LENGTH);
- let address = format!("30023:{target_pubkey}:article-a");
- let event = event_with_kind_tags_and_content(
- NIP25_REACTION_KIND.into(),
- vec![
- Tag::from_parts("e", &[&previous_event]).expect("old e"),
- Tag::from_parts(
- "e",
- &[&target_event, "wss://relay.radroots.test", &target_pubkey],
- )
- .expect("e"),
- Tag::from_parts("p", &[&target_pubkey]).expect("p"),
- Tag::from_parts("a", &[&address]).expect("a"),
- Tag::from_parts("k", &["30023"]).expect("k"),
- ],
- "+",
- );
-
- let reaction = parse_reaction_event(&event)
- .expect("parse")
- .expect("reaction");
-
- assert_eq!(reaction.event_id(), event.id());
- assert_eq!(reaction.pubkey(), event.unsigned().pubkey());
- assert_eq!(reaction.created_at(), event.unsigned().created_at());
- assert_eq!(reaction.content(), "+");
- assert_eq!(reaction.value(), &ReactionValue::Like);
- assert_eq!(reaction.value().canonical(), "like");
- assert_eq!(reaction.target_event_id().as_str(), target_event);
- assert_eq!(
- reaction.target_relay_hint(),
- Some("wss://relay.radroots.test")
- );
- assert_eq!(
- reaction.target_pubkey_hint().expect("pubkey hint").as_str(),
- target_pubkey
- );
- assert_eq!(
- reaction.target_pubkey().expect("target pubkey").as_str(),
- target_pubkey
- );
- assert_eq!(
- reaction
- .target_address()
- .expect("target address")
- .key()
- .to_string(),
- address
- );
- assert_eq!(reaction.target_kind(), Some("30023"));
- }
-
- #[test]
- fn reaction_parser_classifies_empty_dislike_emoji_and_text_reactions() {
- let target_event = "2".repeat(EventId::HEX_LENGTH);
- let cases = [
- ("", ReactionValue::Like),
- ("-", ReactionValue::Dislike),
- ("⭐", ReactionValue::Emoji("⭐".to_owned())),
- ("agree", ReactionValue::Text("agree".to_owned())),
- ];
- for (content, expected) in cases {
- let event = event_with_kind_tags_and_content(
- NIP25_REACTION_KIND.into(),
- vec![Tag::from_parts("e", &[&target_event]).expect("e")],
- content,
- );
- let reaction = parse_reaction_event(&event)
- .expect("parse")
- .expect("reaction");
-
- assert_eq!(reaction.value(), &expected);
- assert_eq!(reaction.value().canonical(), expected.canonical());
- }
- let note = event_with_kind_and_tags(1, Vec::new());
- assert_eq!(parse_reaction_event(¬e), Ok(None));
- }
-
- #[test]
- fn reaction_parser_rejects_missing_and_malformed_targets() {
- let bad_event_id = event_with_kind_tags_and_content(
- NIP25_REACTION_KIND.into(),
- vec![Tag::from_parts("e", &["bad"]).expect("e")],
- "+",
- );
- let missing_event_id = event_with_kind_tags_and_content(
- NIP25_REACTION_KIND.into(),
- vec![Tag::from_parts("e", &[]).expect("e")],
- "+",
- );
- let missing_target = event_with_kind_and_tags(NIP25_REACTION_KIND.into(), Vec::new());
- let empty_kind = event_with_kind_tags_and_content(
- NIP25_REACTION_KIND.into(),
- vec![
- Tag::from_parts("e", &[&"2".repeat(EventId::HEX_LENGTH)]).expect("e"),
- Tag::from_parts("k", &[""]).expect("k"),
- ],
- "+",
- );
-
- assert_eq!(
- parse_reaction_event(&missing_target).expect_err("missing target"),
- "reaction event must include an e target tag"
- );
- assert!(
- parse_reaction_event(&bad_event_id)
- .expect_err("bad event id")
- .contains("event id")
- );
- assert_eq!(
- parse_reaction_event(&missing_event_id).expect_err("missing event id"),
- "reaction e target tag must include an event id"
- );
- assert_eq!(
- parse_reaction_event(&empty_kind).expect_err("empty kind"),
- "reaction k tag must not be empty"
- );
- }
-
- #[test]
- fn long_form_parser_extracts_metadata_topics_and_references() {
- let referenced_event = "2".repeat(EventId::HEX_LENGTH);
- let referenced_pubkey = "3".repeat(PublicKeyHex::HEX_LENGTH);
- let referenced_address = format!("30023:{referenced_pubkey}:soil-notes");
- let event = event_with_kind_tags_and_content(
- NIP23_LONG_FORM_KIND.into(),
- vec![
- Tag::from_parts("d", &["harvest-notes"]).expect("d"),
- Tag::from_parts("title", &["Harvest notes"]).expect("title"),
- Tag::from_parts("summary", &["What changed this week."]).expect("summary"),
- Tag::from_parts("image", &["https://radroots.test/harvest.jpg"]).expect("image"),
- Tag::from_parts("published_at", &["1714124400"]).expect("published_at"),
- Tag::from_parts("t", &["Carrots"]).expect("topic"),
- Tag::from_parts("t", &[" carrots "]).expect("topic duplicate"),
- Tag::from_parts("t", &["CSA"]).expect("topic csa"),
- Tag::from_parts("e", &[&referenced_event]).expect("e"),
- Tag::from_parts("a", &[&referenced_address]).expect("a"),
- Tag::from_parts("p", &[&referenced_pubkey]).expect("p"),
- ],
- "## Harvest notes\n\nThe storage carrots held well.",
- );
-
- let article = parse_long_form_event(&event)
- .expect("parse")
- .expect("article");
-
- assert_eq!(article.event_id(), event.id());
- assert_eq!(article.pubkey(), event.unsigned().pubkey());
- assert_eq!(article.created_at(), event.unsigned().created_at());
- assert_eq!(
- article.content(),
- "## Harvest notes\n\nThe storage carrots held well."
- );
- assert_eq!(article.long_form_kind(), LongFormKind::Published);
- assert_eq!(article.long_form_kind().canonical(), "published");
- assert_eq!(
- article.address().key().to_string(),
- format!("30023:{}:harvest-notes", event.unsigned().pubkey())
- );
- assert_eq!(article.d().as_str(), "harvest-notes");
- assert_eq!(article.title(), Some("Harvest notes"));
- assert_eq!(article.summary(), Some("What changed this week."));
- assert_eq!(article.image(), Some("https://radroots.test/harvest.jpg"));
- assert_eq!(article.published_at(), Some(1_714_124_400));
- assert_eq!(article.topics(), &["carrots".to_owned(), "csa".to_owned()]);
- assert_eq!(article.referenced_events()[0].as_str(), referenced_event);
- assert_eq!(
- article.referenced_addresses()[0].key().to_string(),
- referenced_address
- );
- assert_eq!(article.referenced_pubkeys()[0].as_str(), referenced_pubkey);
- }
-
- #[test]
- fn long_form_parser_extracts_drafts_and_ignores_other_kinds() {
- let draft = event_with_kind_tags_and_content(
- NIP23_LONG_FORM_DRAFT_KIND.into(),
- vec![Tag::from_parts("d", &["draft-a"]).expect("d")],
- "Draft body.",
- );
- let note = event_with_kind_and_tags(1, vec![Tag::from_parts("d", &["note"]).expect("d")]);
-
- let article = parse_long_form_event(&draft)
- .expect("parse")
- .expect("draft");
-
- assert_eq!(article.long_form_kind(), LongFormKind::Draft);
- assert_eq!(article.long_form_kind().canonical(), "draft");
- assert_eq!(article.title(), None);
- assert_eq!(article.published_at(), None);
- assert_eq!(article.topics(), &[] as &[String]);
- assert_eq!(parse_long_form_event(¬e), Ok(None));
- }
-
- #[test]
- fn long_form_parser_rejects_malformed_metadata_and_references() {
- let repeated_d = event_with_kind_and_tags(
- NIP23_LONG_FORM_KIND.into(),
- vec![
- Tag::from_parts("d", &["one"]).expect("d"),
- Tag::from_parts("d", &["two"]).expect("d"),
- ],
- );
- let missing_d = event_with_kind_and_tags(NIP23_LONG_FORM_KIND.into(), Vec::new());
- let empty_d = event_with_kind_and_tags(
- NIP23_LONG_FORM_KIND.into(),
- vec![Tag::from_parts("d", &[""]).expect("d")],
- );
- let empty_title = event_with_kind_and_tags(
- NIP23_LONG_FORM_KIND.into(),
- vec![
- Tag::from_parts("d", &["article"]).expect("d"),
- Tag::from_parts("title", &[""]).expect("title"),
- ],
- );
- let bad_published_at = event_with_kind_and_tags(
- NIP23_LONG_FORM_KIND.into(),
- vec![
- Tag::from_parts("d", &["article"]).expect("d"),
- Tag::from_parts("published_at", &["now"]).expect("published_at"),
- ],
- );
- let empty_topic = event_with_kind_and_tags(
- NIP23_LONG_FORM_KIND.into(),
- vec![
- Tag::from_parts("d", &["article"]).expect("d"),
- Tag::from_parts("t", &[" "]).expect("topic"),
- ],
- );
- let bad_reference = event_with_kind_and_tags(
- NIP23_LONG_FORM_KIND.into(),
- vec![
- Tag::from_parts("d", &["article"]).expect("d"),
- Tag::from_parts("e", &["bad"]).expect("e"),
- ],
- );
-
- assert_eq!(
- parse_long_form_event(&repeated_d).expect_err("repeated"),
- "tag `d` must not be repeated"
- );
- assert_eq!(
- parse_long_form_event(&missing_d).expect_err("missing"),
- "tag `d` is required"
- );
- assert_eq!(
- parse_long_form_event(&empty_d).expect_err("empty d"),
- "long-form d tag must not be empty"
- );
- assert_eq!(
- parse_long_form_event(&empty_title).expect_err("empty title"),
- "long-form title tag must not be empty"
- );
- assert_eq!(
- parse_long_form_event(&bad_published_at).expect_err("published_at"),
- "field `published_at` must be an unsigned integer"
- );
- assert_eq!(
- parse_long_form_event(&empty_topic).expect_err("empty topic"),
- "long-form topic value must not be empty"
- );
- assert_eq!(
- parse_long_form_event(&bad_reference).expect_err("bad reference"),
- "event id must be 64 characters, got 3"
- );
- }
-
- #[test]
- fn forum_thread_parser_extracts_title_topics_and_references() {
- let referenced_event = "6".repeat(EventId::HEX_LENGTH);
- let referenced_pubkey = "7".repeat(PublicKeyHex::HEX_LENGTH);
- let event = event_with_kind_tags_and_content(
- NIP7D_THREAD_KIND.into(),
- vec![
- Tag::from_parts("title", &["Market day thread"]).expect("title"),
- Tag::from_parts("t", &["Market"]).expect("topic"),
- Tag::from_parts("t", &[" market "]).expect("topic duplicate"),
- Tag::from_parts("t", &["Carrots"]).expect("topic carrots"),
- Tag::from_parts("e", &[&referenced_event]).expect("e"),
- Tag::from_parts("p", &[&referenced_pubkey]).expect("p"),
- ],
- "What is everyone bringing this weekend?",
- );
-
- let thread = parse_forum_thread_event(&event)
- .expect("parse")
- .expect("thread");
-
- assert_eq!(thread.event_id(), event.id());
- assert_eq!(thread.pubkey(), event.unsigned().pubkey());
- assert_eq!(thread.created_at(), event.unsigned().created_at());
- assert_eq!(thread.title(), Some("Market day thread"));
- assert_eq!(thread.content(), "What is everyone bringing this weekend?");
- assert_eq!(
- thread.topics(),
- &["carrots".to_owned(), "market".to_owned()]
- );
- assert_eq!(thread.referenced_events()[0].as_str(), referenced_event);
- assert_eq!(thread.referenced_pubkeys()[0].as_str(), referenced_pubkey);
- }
-
- #[test]
- fn forum_thread_parser_allows_missing_title_and_ignores_other_kinds() {
- let thread = event_with_kind_tags_and_content(
- NIP7D_THREAD_KIND.into(),
- vec![Tag::from_parts("t", &["market"]).expect("topic")],
- "Open thread.",
- );
- let note = event_with_kind_and_tags(1, vec![Tag::from_parts("title", &["GM"]).expect("t")]);
-
- let thread = parse_forum_thread_event(&thread)
- .expect("parse")
- .expect("thread");
-
- assert_eq!(thread.title(), None);
- assert_eq!(thread.topics(), &["market".to_owned()]);
- assert_eq!(parse_forum_thread_event(¬e), Ok(None));
- }
-
- #[test]
- fn forum_thread_parser_rejects_malformed_title_topics_and_references() {
- let empty_title = event_with_kind_and_tags(
- NIP7D_THREAD_KIND.into(),
- vec![Tag::from_parts("title", &[""]).expect("title")],
- );
- let repeated_title = event_with_kind_and_tags(
- NIP7D_THREAD_KIND.into(),
- vec![
- Tag::from_parts("title", &["one"]).expect("title"),
- Tag::from_parts("title", &["two"]).expect("title"),
- ],
- );
- let empty_topic = event_with_kind_and_tags(
- NIP7D_THREAD_KIND.into(),
- vec![Tag::from_parts("t", &[" "]).expect("topic")],
- );
- let extra_topic = event_with_kind_and_tags(
- NIP7D_THREAD_KIND.into(),
- vec![Tag::from_parts("t", &["market", "extra"]).expect("topic")],
- );
- let bad_event_reference = event_with_kind_and_tags(
- NIP7D_THREAD_KIND.into(),
- vec![Tag::from_parts("e", &["bad"]).expect("e")],
- );
- let bad_pubkey_reference = event_with_kind_and_tags(
- NIP7D_THREAD_KIND.into(),
- vec![Tag::from_parts("p", &["bad"]).expect("p")],
- );
-
- assert_eq!(
- parse_forum_thread_event(&empty_title).expect_err("empty title"),
- "forum thread title tag must not be empty"
- );
- assert_eq!(
- parse_forum_thread_event(&repeated_title).expect_err("repeated title"),
- "tag `title` must not be repeated"
- );
- assert_eq!(
- parse_forum_thread_event(&empty_topic).expect_err("empty topic"),
- "forum thread topic value must not be empty"
- );
- assert_eq!(
- parse_forum_thread_event(&extra_topic).expect_err("extra topic"),
- "forum thread topic tag must include exactly one value"
- );
- assert_eq!(
- parse_forum_thread_event(&bad_event_reference).expect_err("bad event"),
- "event id must be 64 characters, got 3"
- );
- assert_eq!(
- parse_forum_thread_event(&bad_pubkey_reference).expect_err("bad pubkey"),
- "forum thread reference pubkey is invalid: public key must be 64 characters, got 3"
- );
- }
-
- #[test]
- fn report_parser_extracts_pubkey_event_blob_targets_and_servers() {
- let reported_pubkey = "7".repeat(PublicKeyHex::HEX_LENGTH);
- let reported_event = "8".repeat(EventId::HEX_LENGTH);
- let blob_hash = "9".repeat(64);
- let event = event_with_kind_tags_and_content(
- NIP56_REPORT_KIND.into(),
- vec![
- Tag::from_parts("p", &[&reported_pubkey, "spam"]).expect("p"),
- Tag::from_parts("e", &[&reported_event, "illegal"]).expect("e"),
- Tag::from_parts("x", &[&blob_hash, "malware"]).expect("x"),
- Tag::from_parts("server", &["https://media.radroots.test/blob.jpg"])
- .expect("server"),
- ],
- "moderator report",
- );
-
- let report = parse_report_event(&event).expect("parse").expect("report");
-
- assert_eq!(report.event_id(), event.id());
- assert_eq!(report.pubkey(), event.unsigned().pubkey());
- assert_eq!(report.created_at(), event.unsigned().created_at());
- assert_eq!(report.content(), "moderator report");
- assert_eq!(report.reported_pubkeys()[0].as_str(), reported_pubkey);
- assert_eq!(
- report.server_urls(),
- &["https://media.radroots.test/blob.jpg".to_owned()]
- );
- assert_eq!(report.targets().len(), 3);
- assert_eq!(report.targets()[0].target_type(), "pubkey");
- assert_eq!(report.targets()[0].target_ref(), reported_pubkey);
- assert_eq!(report.targets()[0].report_type(), ReportType::Spam);
- assert_eq!(ReportType::Spam.canonical(), "spam");
- assert_eq!(report.targets()[1].target_type(), "event");
- assert_eq!(report.targets()[1].target_ref(), reported_event);
- assert_eq!(report.targets()[1].report_type(), ReportType::Illegal);
- assert_eq!(report.targets()[2].target_type(), "blob");
- assert_eq!(report.targets()[2].target_ref(), blob_hash);
- assert_eq!(
- [
- ReportType::Nudity.canonical(),
- ReportType::Malware.canonical(),
- ReportType::Profanity.canonical(),
- ReportType::Illegal.canonical(),
- ReportType::Spam.canonical(),
- ReportType::Impersonation.canonical(),
- ReportType::Other.canonical(),
- ],
- [
- "nudity",
- "malware",
- "profanity",
- "illegal",
- "spam",
- "impersonation",
- "other",
- ]
- );
- assert!(
- matches!(&report.targets()[2], ReportTarget::Blob { hash, report_type } if hash == &blob_hash && *report_type == ReportType::Malware)
- );
- }
-
- #[test]
- fn report_parser_accepts_note_report_pubkey_context_and_ignores_other_kinds() {
- let reported_pubkey = "7".repeat(PublicKeyHex::HEX_LENGTH);
- let reported_event = "8".repeat(EventId::HEX_LENGTH);
- let event = event_with_kind_tags_and_content(
- NIP56_REPORT_KIND.into(),
- vec![
- Tag::from_parts("p", &[&reported_pubkey]).expect("p"),
- Tag::from_parts("e", &[&reported_event, "profanity"]).expect("e"),
- ],
- "note report",
- );
- let note = event_with_kind_and_tags(
- 1,
- vec![Tag::from_parts("p", &[&reported_pubkey, "spam"]).expect("p")],
- );
-
- let report = parse_report_event(&event).expect("parse").expect("report");
-
- assert_eq!(report.reported_pubkeys()[0].as_str(), reported_pubkey);
- assert_eq!(report.targets().len(), 1);
- assert_eq!(report.targets()[0].target_type(), "event");
- assert_eq!(report.targets()[0].target_ref(), reported_event);
- assert_eq!(report.targets()[0].report_type(), ReportType::Profanity);
- assert_eq!(parse_report_event(¬e), Ok(None));
- }
-
- #[test]
- fn report_parser_rejects_missing_context_type_and_bad_tags() {
- let reported_pubkey = "7".repeat(PublicKeyHex::HEX_LENGTH);
- let reported_event = "8".repeat(EventId::HEX_LENGTH);
- let blob_hash = "9".repeat(64);
- let missing_pubkey = event_with_kind_and_tags(
- NIP56_REPORT_KIND.into(),
- vec![Tag::from_parts("e", &[&reported_event, "spam"]).expect("e")],
- );
- let missing_report_type = event_with_kind_and_tags(
- NIP56_REPORT_KIND.into(),
- vec![Tag::from_parts("p", &[&reported_pubkey]).expect("p")],
- );
- let unsupported_report_type = event_with_kind_and_tags(
- NIP56_REPORT_KIND.into(),
- vec![Tag::from_parts("p", &[&reported_pubkey, "scam"]).expect("p")],
- );
- let x_without_event = event_with_kind_and_tags(
- NIP56_REPORT_KIND.into(),
- vec![
- Tag::from_parts("p", &[&reported_pubkey]).expect("p"),
- Tag::from_parts("x", &[&blob_hash, "malware"]).expect("x"),
- ],
- );
- let empty_x_hash = event_with_kind_and_tags(
- NIP56_REPORT_KIND.into(),
- vec![
- Tag::from_parts("p", &[&reported_pubkey]).expect("p"),
- Tag::from_parts("e", &[&reported_event]).expect("e"),
- Tag::from_parts("x", &["", "malware"]).expect("x"),
- ],
- );
- let missing_x_report_type = event_with_kind_and_tags(
- NIP56_REPORT_KIND.into(),
- vec![
- Tag::from_parts("p", &[&reported_pubkey]).expect("p"),
- Tag::from_parts("e", &[&reported_event]).expect("e"),
- Tag::from_parts("x", &[&blob_hash]).expect("x"),
- ],
- );
- let empty_report_type = event_with_kind_and_tags(
- NIP56_REPORT_KIND.into(),
- vec![Tag::from_parts("p", &[&reported_pubkey, ""]).expect("p")],
- );
- let malformed_pubkey = event_with_kind_and_tags(
- NIP56_REPORT_KIND.into(),
- vec![Tag::from_parts("p", &["bad", "spam"]).expect("p")],
- );
- let empty_server = event_with_kind_and_tags(
- NIP56_REPORT_KIND.into(),
- vec![
- Tag::from_parts("p", &[&reported_pubkey, "spam"]).expect("p"),
- Tag::from_parts("server", &[""]).expect("server"),
- ],
- );
-
- assert_eq!(
- parse_report_event(&missing_pubkey).expect_err("missing p"),
- "report event must include at least one p tag"
- );
- assert_eq!(
- parse_report_event(&missing_report_type).expect_err("missing type"),
- "report event must include at least one p e or x tag with a report type"
- );
- assert_eq!(
- parse_report_event(&unsupported_report_type).expect_err("bad type"),
- "report type `scam` is unsupported"
- );
- assert_eq!(
- parse_report_event(&x_without_event).expect_err("x context"),
- "report x target requires an e tag context"
- );
- assert_eq!(
- parse_report_event(&empty_x_hash).expect_err("x hash"),
- "report x hash must not be empty"
- );
- assert_eq!(
- parse_report_event(&missing_x_report_type).expect_err("x type"),
- "report x tag must include a report type"
- );
- assert_eq!(
- parse_report_event(&empty_report_type).expect_err("empty type"),
- "report type must not be empty"
- );
- assert_eq!(
- parse_report_event(&malformed_pubkey).expect_err("bad pubkey"),
- "report p target pubkey is invalid: public key must be 64 characters, got 3"
- );
- assert_eq!(
- parse_report_event(&empty_server).expect_err("empty server"),
- "report server tag must include exactly one non-empty URL"
- );
- }
-
- #[test]
- fn label_parser_extracts_namespaced_labels_and_targets() {
- let event_id = "8".repeat(EventId::HEX_LENGTH);
- let pubkey = "9".repeat(PublicKeyHex::HEX_LENGTH);
- let address = format!("30023:{pubkey}:harvest-notes");
- let event = event_with_kind_tags_and_content(
- NIP32_LABEL_KIND.into(),
- vec![
- Tag::from_parts("L", &["com.radroots.moderation"]).expect("L"),
- Tag::from_parts("l", &["approve", "com.radroots.moderation"]).expect("l"),
- Tag::from_parts("e", &[&event_id, "wss://relay.radroots.test"]).expect("e"),
- Tag::from_parts("p", &[&pubkey, "wss://relay.radroots.test"]).expect("p"),
- Tag::from_parts("a", &[&address]).expect("a"),
- Tag::from_parts("r", &["wss://relay.radroots.test"]).expect("r"),
- Tag::from_parts("t", &["market"]).expect("t"),
- ],
- "moderator note",
- );
-
- let label = parse_label_event(&event).expect("parse").expect("label");
-
- assert_eq!(label.event_id(), event.id());
- assert_eq!(label.pubkey(), event.unsigned().pubkey());
- assert_eq!(label.created_at(), event.unsigned().created_at());
- assert_eq!(label.content(), "moderator note");
- assert_eq!(label.namespaces(), &["com.radroots.moderation".to_owned()]);
- assert_eq!(label.labels()[0].value(), "approve");
- assert_eq!(label.labels()[0].namespace(), "com.radroots.moderation");
- assert_eq!(label.targets().len(), 5);
- assert_eq!(label.targets()[0].target_type(), "event");
- assert_eq!(label.targets()[0].target_ref(), event_id);
- assert_eq!(label.targets()[1].target_type(), "pubkey");
- assert_eq!(label.targets()[1].target_ref(), pubkey);
- assert_eq!(label.targets()[2].target_type(), "address");
- assert_eq!(label.targets()[2].target_ref(), address);
- assert!(
- matches!(&label.targets()[2], LabelTarget::Address(parsed) if parsed.key().to_string() == address)
- );
- assert_eq!(label.targets()[3].target_type(), "relay");
- assert_eq!(label.targets()[3].target_ref(), "wss://relay.radroots.test");
- assert_eq!(label.targets()[4].target_type(), "topic");
- assert_eq!(label.targets()[4].target_ref(), "market");
- }
-
- #[test]
- fn label_parser_defaults_to_ugc_namespace_and_ignores_other_kinds() {
- let target = "8".repeat(EventId::HEX_LENGTH);
- let event = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![
- Tag::from_parts("l", &["needs-review"]).expect("l"),
- Tag::from_parts("e", &[&target]).expect("e"),
- ],
- );
- let note = event_with_kind_and_tags(1, vec![Tag::from_parts("l", &["topic"]).expect("l")]);
-
- let label = parse_label_event(&event).expect("parse").expect("label");
-
- assert_eq!(label.namespaces(), &[] as &[String]);
- assert_eq!(label.labels()[0].namespace(), "ugc");
- assert_eq!(parse_label_event(¬e), Ok(None));
- }
-
- #[test]
- fn label_parser_rejects_missing_labels_targets_and_bad_namespaces() {
- let target = "8".repeat(EventId::HEX_LENGTH);
- let missing_label = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![Tag::from_parts("e", &[&target]).expect("e")],
- );
- let missing_target = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![Tag::from_parts("l", &["approve"]).expect("l")],
- );
- let unmatched_namespace = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![
- Tag::from_parts("L", &["one"]).expect("L"),
- Tag::from_parts("l", &["approve", "two"]).expect("l"),
- Tag::from_parts("e", &[&target]).expect("e"),
- ],
- );
- let missing_label_namespace = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![
- Tag::from_parts("L", &["one"]).expect("L"),
- Tag::from_parts("l", &["approve"]).expect("l"),
- Tag::from_parts("e", &[&target]).expect("e"),
- ],
- );
- let empty_namespace = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![
- Tag::from_parts("L", &[""]).expect("L"),
- Tag::from_parts("l", &["approve"]).expect("l"),
- Tag::from_parts("e", &[&target]).expect("e"),
- ],
- );
- let missing_label_value = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![
- Tag::from_parts("l", &[]).expect("l"),
- Tag::from_parts("e", &[&target]).expect("e"),
- ],
- );
- let empty_label_value = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![
- Tag::from_parts("l", &[""]).expect("l"),
- Tag::from_parts("e", &[&target]).expect("e"),
- ],
- );
- let empty_label_namespace = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![
- Tag::from_parts("l", &["approve", ""]).expect("l"),
- Tag::from_parts("e", &[&target]).expect("e"),
- ],
- );
- let extra_label_value = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![
- Tag::from_parts("l", &["approve", "ugc", "extra"]).expect("l"),
- Tag::from_parts("e", &[&target]).expect("e"),
- ],
- );
- let bad_target = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![
- Tag::from_parts("l", &["approve"]).expect("l"),
- Tag::from_parts("e", &["bad"]).expect("e"),
- ],
- );
- let bad_pubkey_target = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![
- Tag::from_parts("l", &["approve"]).expect("l"),
- Tag::from_parts("p", &["bad"]).expect("p"),
- ],
- );
- let empty_relay = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![
- Tag::from_parts("l", &["approve"]).expect("l"),
- Tag::from_parts("r", &[""]).expect("r"),
- ],
- );
- let empty_topic = event_with_kind_and_tags(
- NIP32_LABEL_KIND.into(),
- vec![
- Tag::from_parts("l", &["approve"]).expect("l"),
- Tag::from_parts("t", &[""]).expect("t"),
- ],
- );
-
- assert_eq!(
- parse_label_event(&missing_label).expect_err("missing label"),
- "label event must include at least one l tag"
- );
- assert_eq!(
- parse_label_event(&missing_target).expect_err("missing target"),
- "label event must target at least one e p a r or t tag"
- );
- assert_eq!(
- parse_label_event(&unmatched_namespace).expect_err("unmatched namespace"),
- "label l namespace must match an L tag"
- );
- assert_eq!(
- parse_label_event(&missing_label_namespace).expect_err("missing namespace"),
- "label l tag must include a namespace matching an L tag"
- );
- assert_eq!(
- parse_label_event(&empty_namespace).expect_err("empty namespace"),
- "label namespace L tag must include exactly one non-empty value"
- );
- assert_eq!(
- parse_label_event(&missing_label_value).expect_err("missing value"),
- "label l tag must include a value"
- );
- assert_eq!(
- parse_label_event(&empty_label_value).expect_err("empty value"),
- "label l value must not be empty"
- );
- assert_eq!(
- parse_label_event(&empty_label_namespace).expect_err("empty label namespace"),
- "label l namespace must not be empty"
- );
- assert_eq!(
- parse_label_event(&extra_label_value).expect_err("extra label value"),
- "label l tag must include at most value and namespace"
- );
- assert_eq!(
- parse_label_event(&bad_target).expect_err("bad target"),
- "event id must be 64 characters, got 3"
- );
- assert_eq!(
- parse_label_event(&bad_pubkey_target).expect_err("bad pubkey target"),
- "label target pubkey is invalid: public key must be 64 characters, got 3"
- );
- assert_eq!(
- parse_label_event(&empty_relay).expect_err("empty relay"),
- "label relay target must not be empty"
- );
- assert_eq!(
- parse_label_event(&empty_topic).expect_err("empty topic"),
- "label topic target must not be empty"
- );
- }
-
- #[test]
- fn deletion_request_parser_extracts_event_and_address_targets() {
- let target_event_id = "2".repeat(EventId::HEX_LENGTH);
- let target_pubkey = "3".repeat(PublicKeyHex::HEX_LENGTH);
- let address = format!("30402:{target_pubkey}:listing-a");
- let event = event_with_kind_and_tags(
- 5,
- vec![
- Tag::from_parts("e", &[&target_event_id]).expect("e"),
- Tag::from_parts("a", &[&address]).expect("a"),
- ],
- );
-
- let request = parse_deletion_request(&event)
- .expect("parse")
- .expect("request");
-
- assert_eq!(request.event_id(), event.id());
- assert_eq!(request.targets().len(), 2);
- assert_eq!(
- request.targets()[0],
- DeletionTarget::Event(EventId::new(&target_event_id).expect("event id"))
- );
- assert!(matches!(
- &request.targets()[1],
- DeletionTarget::Address(address) if address.to_string() == format!("30402:{target_pubkey}:listing-a")
- ));
- }
-
- #[test]
- fn deletion_request_parser_ignores_non_deletion_kinds() {
- let event = event_with_tags(vec![Tag::from_parts("e", &["ignored"]).expect("e")]);
-
- assert_eq!(parse_deletion_request(&event), Ok(None));
- }
-
- #[test]
- fn deletion_request_parser_rejects_missing_and_malformed_targets() {
- let missing = event_with_kind_and_tags(5, Vec::new());
- let malformed_event =
- event_with_kind_and_tags(5, vec![Tag::from_parts("e", &["not-hex"]).expect("e")]);
- let malformed_address = event_with_kind_and_tags(
- 5,
- vec![Tag::from_parts("a", &["30402:not-a-pubkey:listing"]).expect("a")],
- );
-
- assert_eq!(
- parse_deletion_request(&missing).expect_err("missing"),
- "deletion event must target at least one e or a tag"
- );
- assert_eq!(
- parse_deletion_request(&malformed_event).expect_err("event"),
- "event id must be 64 characters, got 7"
- );
- assert_eq!(
- parse_deletion_request(&malformed_address).expect_err("address"),
- "public key must be 64 characters, got 12"
- );
- }
-
- #[test]
- fn deletion_request_parser_keeps_repeated_targets_in_order() {
- let first = "4".repeat(EventId::HEX_LENGTH);
- let second = "5".repeat(EventId::HEX_LENGTH);
- let event = event_with_kind_and_tags(
- 5,
- vec![
- Tag::from_parts("e", &[&first]).expect("e"),
- Tag::from_parts("e", &[&second]).expect("e"),
- ],
- );
- let request = parse_deletion_request(&event)
- .expect("parse")
- .expect("request");
-
- assert_eq!(
- request.targets(),
- &[
- DeletionTarget::Event(EventId::new(&first).expect("first")),
- DeletionTarget::Event(EventId::new(&second).expect("second")),
- ]
- );
- }
-
- #[test]
- fn relay_auth_parser_extracts_mandatory_fields() {
- let event = event_with_kind_and_tags(
- 22_242,
- vec![
- Tag::from_parts("relay", &["wss://relay.radroots.test"]).expect("relay"),
- Tag::from_parts("challenge", &["auth-challenge-001"]).expect("challenge"),
- ],
- );
-
- let auth = parse_relay_auth_event(&event)
- .expect("parse")
- .expect("auth");
-
- assert_eq!(auth.event_id(), event.id());
- assert_eq!(auth.pubkey(), event.unsigned().pubkey());
- assert_eq!(auth.created_at(), event.unsigned().created_at());
- assert_eq!(auth.relay(), "wss://relay.radroots.test");
- assert_eq!(auth.challenge(), "auth-challenge-001");
- }
-
- #[test]
- fn relay_auth_parser_ignores_non_auth_kinds() {
- let event = event_with_tags(vec![
- Tag::from_parts("relay", &["wss://relay.radroots.test"]).expect("relay"),
- Tag::from_parts("challenge", &["auth-challenge-001"]).expect("challenge"),
- ]);
-
- assert_eq!(parse_relay_auth_event(&event), Ok(None));
- }
-
- #[test]
- fn relay_auth_parser_rejects_missing_and_repeated_fields() {
- let missing_relay = event_with_kind_and_tags(
- 22_242,
- vec![Tag::from_parts("challenge", &["challenge"]).expect("challenge")],
- );
- let missing_challenge = event_with_kind_and_tags(
- 22_242,
- vec![Tag::from_parts("relay", &["wss://relay.radroots.test"]).expect("relay")],
- );
- let repeated_relay = event_with_kind_and_tags(
- 22_242,
- vec![
- Tag::from_parts("relay", &["wss://relay-a.radroots.test"]).expect("relay"),
- Tag::from_parts("relay", &["wss://relay-b.radroots.test"]).expect("relay"),
- Tag::from_parts("challenge", &["challenge"]).expect("challenge"),
- ],
- );
-
- assert_eq!(
- parse_relay_auth_event(&missing_relay).expect_err("relay"),
- "tag `relay` is required"
- );
- assert_eq!(
- parse_relay_auth_event(&missing_challenge).expect_err("challenge"),
- "tag `challenge` is required"
- );
- assert_eq!(
- parse_relay_auth_event(&repeated_relay).expect_err("repeated"),
- "tag `relay` must not be repeated"
- );
- }
-
- #[test]
- fn relay_auth_parser_rejects_empty_fields() {
- let empty_relay = event_with_kind_and_tags(
- 22_242,
- vec![
- Tag::from_parts("relay", &[""]).expect("relay"),
- Tag::from_parts("challenge", &["challenge"]).expect("challenge"),
- ],
- );
- let empty_challenge = event_with_kind_and_tags(
- 22_242,
- vec![
- Tag::from_parts("relay", &["wss://relay.radroots.test"]).expect("relay"),
- Tag::from_parts("challenge", &[""]).expect("challenge"),
- ],
- );
-
- assert_eq!(
- parse_relay_auth_event(&empty_relay).expect_err("empty relay"),
- "relay auth relay tag must not be empty"
- );
- assert_eq!(
- parse_relay_auth_event(&empty_challenge).expect_err("empty challenge"),
- "relay auth challenge tag must not be empty"
- );
- }
-
- #[test]
- fn nip50_search_parser_extracts_plain_terms_and_ignores_extensions() {
- let query = parse_nip50_search(" fresh seller:ignored carrots status:ignored greens ")
- .expect("parse")
- .expect("query");
-
- assert_eq!(query.text(), "fresh carrots greens");
- assert_eq!(
- query.terms(),
- &[
- "fresh".to_owned(),
- "carrots".to_owned(),
- "greens".to_owned()
- ]
- );
- }
-
- #[test]
- fn nip50_search_parser_treats_empty_and_extension_only_queries_as_absent() {
- assert_eq!(parse_nip50_search(" "), Ok(None));
- assert_eq!(
- parse_nip50_search("seller:ignored status:ignored"),
- Ok(None)
- );
- }
-
- #[test]
- fn nip50_search_parser_reads_filter_search_field() {
- let filter = filter_from_value(&serde_json::json!({
- "search": "farmstand tomatoes",
- "kinds": [1]
- }))
- .expect("filter");
- let missing = filter_from_value(&serde_json::json!({
- "kinds": [1]
- }))
- .expect("missing");
-
- assert_eq!(
- parse_nip50_filter_search(&filter)
- .expect("filter")
- .expect("query")
- .text(),
- "farmstand tomatoes"
- );
- assert_eq!(parse_nip50_filter_search(&missing), Ok(None));
- }
-
- #[test]
- fn seller_profile_parser_extracts_metadata_tags_and_trust_markers() {
- let event = event_with_kind_tags_and_content(
- u64::from(NIP01_METADATA_KIND),
- vec![
- Tag::from_parts("region", &[" Vancouver Island "]).expect("region"),
- Tag::from_parts("category", &["Vegetables"]).expect("category"),
- Tag::from_parts("category", &[" vegetables "]).expect("category duplicate"),
- Tag::from_parts("trust", &["verified-local"]).expect("trust"),
- ],
- r#"{
- "name": "sunrise-farm",
- "display_name": "Sunrise Farm",
- "about": "Certified organic farm stand.",
- "picture": "https://radroots.test/sunrise.jpg",
- "website": "https://sunrise.radroots.test",
- "nip05": "sunrise@radroots.test",
- "lud16": "sunrise@wallet.radroots.test"
- }"#,
- );
-
- let profile = parse_seller_profile_event(&event)
- .expect("parse")
- .expect("profile");
-
- assert_eq!(profile.event_id(), event.id());
- assert_eq!(profile.pubkey(), event.unsigned().pubkey());
- assert_eq!(profile.created_at(), event.unsigned().created_at());
- assert_eq!(profile.metadata().name(), Some("sunrise-farm"));
- assert_eq!(profile.metadata().display_name(), Some("Sunrise Farm"));
- assert_eq!(
- profile.metadata().about(),
- Some("Certified organic farm stand.")
- );
- assert_eq!(
- profile.metadata().picture(),
- Some("https://radroots.test/sunrise.jpg")
- );
- assert_eq!(
- profile.metadata().website(),
- Some("https://sunrise.radroots.test")
- );
- assert_eq!(profile.metadata().nip05(), Some("sunrise@radroots.test"));
- assert_eq!(
- profile.metadata().lud16(),
- Some("sunrise@wallet.radroots.test")
- );
- assert_eq!(profile.regions(), &["vancouver island".to_owned()]);
- assert_eq!(profile.categories(), &["vegetables".to_owned()]);
- assert_eq!(profile.trust_markers(), &["verified-local".to_owned()]);
- }
-
- #[test]
- fn seller_profile_parser_ignores_other_kinds_and_trims_empty_metadata() {
- let profile = event_with_kind_tags_and_content(
- u64::from(NIP01_METADATA_KIND),
- Vec::new(),
- r#"{"name":" ","display_name":null,"about":" Market seller. "}"#,
- );
- let note = event_with_kind_tags_and_content(1, Vec::new(), r#"{"name":"not-a-profile"}"#);
-
- let profile = parse_seller_profile_event(&profile)
- .expect("parse")
- .expect("profile");
-
- assert_eq!(profile.metadata().name(), None);
- assert_eq!(profile.metadata().display_name(), None);
- assert_eq!(profile.metadata().about(), Some("Market seller."));
- assert_eq!(parse_seller_profile_event(¬e), Ok(None));
- }
-
- #[test]
- fn seller_profile_parser_rejects_bad_metadata_and_tags() {
- let invalid_json =
- event_with_kind_tags_and_content(u64::from(NIP01_METADATA_KIND), Vec::new(), "{");
- let non_object =
- event_with_kind_tags_and_content(u64::from(NIP01_METADATA_KIND), Vec::new(), "[]");
- let non_string = event_with_kind_tags_and_content(
- u64::from(NIP01_METADATA_KIND),
- Vec::new(),
- r#"{"name":1}"#,
- );
- let empty_region = event_with_kind_tags_and_content(
- u64::from(NIP01_METADATA_KIND),
- vec![Tag::from_parts("region", &[" "]).expect("region")],
- "{}",
- );
-
- assert!(
- parse_seller_profile_event(&invalid_json)
- .expect_err("invalid json")
- .contains("seller profile metadata JSON is invalid")
- );
- assert_eq!(
- parse_seller_profile_event(&non_object).expect_err("non object"),
- "seller profile metadata must be a JSON object"
- );
- assert_eq!(
- parse_seller_profile_event(&non_string).expect_err("non string"),
- "seller profile metadata `name` must be a string"
- );
- assert_eq!(
- parse_seller_profile_event(&empty_region).expect_err("empty region"),
- "seller profile region value must not be empty"
- );
- }
-
- #[test]
- fn listing_identity_parser_extracts_public_listing_address() {
- let event = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("d", &["listing-a"]).expect("d")],
- );
-
- let identity = parse_listing_identity(&event)
- .expect("parse")
- .expect("identity");
-
- assert_eq!(identity.event_id(), event.id());
- assert_eq!(identity.listing_kind(), ListingKind::Public);
- assert_eq!(identity.seller_pubkey(), event.unsigned().pubkey());
- assert_eq!(identity.d().as_str(), "listing-a");
- assert_eq!(
- identity.address().to_string(),
- format!("30402:{}:listing-a", event.unsigned().pubkey().as_str())
- );
- }
-
- #[test]
- fn listing_identity_parser_extracts_draft_listing_address() {
- let event =
- event_with_kind_and_tags(30_403, vec![Tag::from_parts("d", &["draft-a"]).expect("d")]);
-
- let identity = parse_listing_identity(&event)
- .expect("parse")
- .expect("identity");
-
- assert_eq!(identity.listing_kind(), ListingKind::Draft);
- assert_eq!(identity.address().kind().as_u32(), 30_403);
- }
-
- #[test]
- fn listing_identity_parser_ignores_non_listing_kinds() {
- let event = event_with_kind_and_tags(1, vec![Tag::from_parts("d", &["note"]).expect("d")]);
-
- assert_eq!(parse_listing_identity(&event), Ok(None));
- }
-
- #[test]
- fn listing_identity_parser_rejects_missing_repeated_and_empty_d_tags() {
- let missing = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new());
- let repeated = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("d", &["listing-a"]).expect("d"),
- Tag::from_parts("d", &["listing-b"]).expect("d"),
- ],
- );
- let empty = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("d", &[""]).expect("d")],
- );
-
- assert_eq!(
- parse_listing_identity(&missing).expect_err("missing"),
- "tag `d` is required"
- );
- assert_eq!(
- parse_listing_identity(&repeated).expect_err("repeated"),
- "tag `d` must not be repeated"
- );
- assert_eq!(
- parse_listing_identity(&empty).expect_err("empty"),
- "listing d tag must not be empty"
- );
- }
-
- #[test]
- fn listing_text_parser_extracts_title_summary_and_body() {
- let event = event_with_kind_tags_and_content(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("title", &["Carrot bunches"]).expect("title"),
- Tag::from_parts("summary", &["Fresh field bunches"]).expect("summary"),
- ],
- "Harvested this morning.",
- );
-
- let text = parse_listing_text(&event).expect("parse").expect("text");
-
- assert_eq!(text.title(), "Carrot bunches");
- assert_eq!(text.summary(), Some("Fresh field bunches"));
- assert_eq!(text.body(), "Harvested this morning.");
- }
-
- #[test]
- fn listing_text_parser_parses_draft_text_and_ignores_non_listings() {
- let draft = event_with_kind_and_tags(
- 30_403,
- vec![Tag::from_parts("title", &["Draft carrots"]).expect("title")],
- );
- let note = event_with_kind_and_tags(
- 1,
- vec![Tag::from_parts("title", &["Note title"]).expect("title")],
- );
-
- assert_eq!(
- parse_listing_text(&draft)
- .expect("draft")
- .expect("text")
- .title(),
- "Draft carrots"
- );
- assert_eq!(parse_listing_text(¬e), Ok(None));
- }
-
- #[test]
- fn listing_text_parser_rejects_missing_repeated_and_empty_titles() {
- let missing = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new());
- let repeated = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("title", &["Carrots"]).expect("title"),
- Tag::from_parts("title", &["Greens"]).expect("title"),
- ],
- );
- let missing_value = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("title", &[]).expect("title")],
- );
- let empty = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("title", &[""]).expect("title")],
- );
-
- assert_eq!(
- parse_listing_text(&missing).expect_err("missing"),
- "tag `title` is required"
- );
- assert_eq!(
- parse_listing_text(&repeated).expect_err("repeated"),
- "tag `title` must not be repeated"
- );
- assert_eq!(
- parse_listing_text(&missing_value).expect_err("value"),
- "tag `title` must include a value"
- );
- assert_eq!(
- parse_listing_text(&empty).expect_err("empty"),
- "listing title tag must not be empty"
- );
- }
-
- #[test]
- fn listing_text_parser_rejects_malformed_summary_tags() {
- let repeated = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("title", &["Carrots"]).expect("title"),
- Tag::from_parts("summary", &["Fresh"]).expect("summary"),
- Tag::from_parts("summary", &["Sweet"]).expect("summary"),
- ],
- );
- let missing_value = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("title", &["Carrots"]).expect("title"),
- Tag::from_parts("summary", &[]).expect("summary"),
- ],
- );
- let empty = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("title", &["Carrots"]).expect("title"),
- Tag::from_parts("summary", &[""]).expect("summary"),
- ],
- );
-
- assert_eq!(
- parse_listing_text(&repeated).expect_err("repeated"),
- "tag `summary` must not be repeated"
- );
- assert_eq!(
- parse_listing_text(&missing_value).expect_err("value"),
- "tag `summary` must include a value"
- );
- assert_eq!(
- parse_listing_text(&empty).expect_err("empty"),
- "listing summary tag must not be empty"
- );
- }
-
- #[test]
- fn listing_price_parser_extracts_exact_decimal_currency_and_frequency() {
- let event = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("price", &["12.50", "usd", "weekly"]).expect("price")],
- );
-
- let price = parse_listing_price(&event).expect("parse").expect("price");
-
- assert_eq!(price.amount().raw(), "12.50");
- assert_eq!(price.currency(), "usd");
- assert_eq!(price.display_currency(), "USD");
- assert_eq!(price.frequency(), Some("weekly"));
- }
-
- #[test]
- fn listing_price_parser_accepts_integer_amount_without_frequency() {
- let event = event_with_kind_and_tags(
- 30_403,
- vec![Tag::from_parts("price", &["7", "CAD"]).expect("price")],
- );
-
- let price = parse_listing_price(&event).expect("parse").expect("price");
-
- assert_eq!(price.amount().raw(), "7");
- assert_eq!(price.currency(), "CAD");
- assert_eq!(price.display_currency(), "CAD");
- assert_eq!(price.frequency(), None);
- }
-
- #[test]
- fn listing_price_parser_ignores_non_listing_kinds() {
- let event =
- event_with_kind_and_tags(1, vec![Tag::from_parts("price", &["3", "USD"]).expect("p")]);
-
- assert_eq!(parse_listing_price(&event), Ok(None));
- }
-
- #[test]
- fn listing_price_parser_rejects_missing_repeated_and_bad_shape() {
- let missing = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new());
- let repeated = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("price", &["3", "USD"]).expect("price"),
- Tag::from_parts("price", &["4", "USD"]).expect("price"),
- ],
- );
- let no_values = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("price", &[]).expect("price")],
- );
- let no_currency = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("price", &["3"]).expect("price")],
- );
- let extra = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("price", &["3", "USD", "weekly", "extra"]).expect("price")],
- );
-
- assert_eq!(
- parse_listing_price(&missing).expect_err("missing"),
- "tag `price` is required"
- );
- assert_eq!(
- parse_listing_price(&repeated).expect_err("repeated"),
- "tag `price` must not be repeated"
- );
- assert_eq!(
- parse_listing_price(&no_values).expect_err("values"),
- "price tag must include amount and currency"
- );
- assert_eq!(
- parse_listing_price(&no_currency).expect_err("currency"),
- "price tag must include amount and currency"
- );
- assert_eq!(
- parse_listing_price(&extra).expect_err("extra"),
- "price tag must not include more than amount currency and frequency"
- );
- }
-
- #[test]
- fn listing_price_parser_rejects_malformed_amount_currency_and_frequency() {
- let bad_amounts = ["", ".50", "12.", "12.5.0", "-12", "1e3", "12 usd"];
- for amount in bad_amounts {
- let event = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("price", &[amount, "USD"]).expect("price")],
- );
- assert_eq!(
- parse_listing_price(&event).expect_err("amount"),
- "price amount must be an exact unsigned decimal"
- );
- }
- let empty_currency = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("price", &["3", ""]).expect("price")],
- );
- let empty_frequency = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("price", &["3", "USD", ""]).expect("price")],
- );
-
- assert_eq!(
- parse_listing_price(&empty_currency).expect_err("currency"),
- "price currency must not be empty"
- );
- assert_eq!(
- parse_listing_price(&empty_frequency).expect_err("frequency"),
- "price frequency must not be empty"
- );
- }
-
- #[test]
- fn listing_unit_parser_normalizes_supported_units_and_aliases() {
- let cases = [
- ("LB", ListingUnit::Lb, "lb"),
- ("ounces", ListingUnit::Oz, "oz"),
- ("ea", ListingUnit::Each, "each"),
- ("bunches", ListingUnit::Bunch, "bunch"),
- ("dozen", ListingUnit::Dozen, "dozen"),
- ("kilograms", ListingUnit::Kg, "kg"),
- ("grams", ListingUnit::G, "g"),
- ("shares", ListingUnit::Share, "share"),
- ("pints", ListingUnit::Pint, "pint"),
- ("quarts", ListingUnit::Quart, "quart"),
- ("boxes", ListingUnit::Box, "box"),
- ("crates", ListingUnit::Crate, "crate"),
- ("flats", ListingUnit::Flat, "flat"),
- ];
-
- for (raw, unit, canonical) in cases {
- let event = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("unit", &[raw]).expect("unit")],
- );
- let parsed = parse_listing_unit(&event).expect("parse").expect("unit");
-
- assert_eq!(parsed.raw(), raw);
- assert_eq!(parsed.unit(), unit);
- assert_eq!(parsed.canonical(), canonical);
- assert_eq!(unit.canonical(), canonical);
- }
- }
-
- #[test]
- fn listing_unit_parser_trims_input_and_ignores_non_listing_kinds() {
- let listing = event_with_kind_and_tags(
- 30_403,
- vec![Tag::from_parts("unit", &[" pound "]).expect("unit")],
- );
- let note =
- event_with_kind_and_tags(1, vec![Tag::from_parts("unit", &["lb"]).expect("unit")]);
-
- assert_eq!(
- parse_listing_unit(&listing)
- .expect("listing")
- .expect("unit")
- .canonical(),
- "lb"
- );
- assert_eq!(parse_listing_unit(¬e), Ok(None));
- }
-
- #[test]
- fn listing_unit_parser_rejects_missing_repeated_and_empty_units() {
- let missing = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new());
- let repeated = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("unit", &["lb"]).expect("unit"),
- Tag::from_parts("unit", &["kg"]).expect("unit"),
- ],
- );
- let missing_value = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("unit", &[]).expect("unit")],
- );
- let empty = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("unit", &[" "]).expect("unit")],
- );
-
- assert_eq!(
- parse_listing_unit(&missing).expect_err("missing"),
- "tag `unit` is required"
- );
- assert_eq!(
- parse_listing_unit(&repeated).expect_err("repeated"),
- "tag `unit` must not be repeated"
- );
- assert_eq!(
- parse_listing_unit(&missing_value).expect_err("value"),
- "tag `unit` must include a value"
- );
- assert_eq!(
- parse_listing_unit(&empty).expect_err("empty"),
- "listing unit tag must not be empty"
- );
- }
-
- #[test]
- fn listing_unit_parser_rejects_unsupported_units() {
- let event = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("unit", &["bushel"]).expect("unit")],
- );
-
- assert_eq!(
- parse_listing_unit(&event).expect_err("unsupported"),
- "listing unit `bushel` is unsupported"
- );
- }
-
- #[test]
- fn listing_fulfillment_parser_extracts_methods_and_availability_flags() {
- let event = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("fulfillment", &["shipping"]).expect("shipping"),
- Tag::from_parts("fulfillment", &["pickup"]).expect("pickup"),
- Tag::from_parts("fulfillment", &["delivery"]).expect("delivery"),
- Tag::from_parts("fulfillment", &["pickup"]).expect("pickup"),
- ],
- );
-
- let fulfillment = parse_listing_fulfillment(&event)
- .expect("parse")
- .expect("fulfillment");
-
- assert_eq!(
- fulfillment.methods(),
- &[
- FulfillmentMethod::Pickup,
- FulfillmentMethod::Delivery,
- FulfillmentMethod::Shipping
- ]
- );
- assert_eq!(FulfillmentMethod::Pickup.canonical(), "pickup");
- assert_eq!(FulfillmentMethod::Delivery.canonical(), "delivery");
- assert_eq!(FulfillmentMethod::Shipping.canonical(), "shipping");
- assert!(fulfillment.pickup_available());
- assert!(fulfillment.delivery_available());
- assert!(fulfillment.shipping_available());
- assert!(!fulfillment.delivery_only());
- }
-
- #[test]
- fn listing_fulfillment_parser_derives_delivery_only_and_ignores_non_listings() {
- let delivery = event_with_kind_and_tags(
- 30_403,
- vec![Tag::from_parts("fulfillment", &[" delivery "]).expect("delivery")],
- );
- let note = event_with_kind_and_tags(
- 1,
- vec![Tag::from_parts("fulfillment", &["pickup"]).expect("pickup")],
- );
-
- let fulfillment = parse_listing_fulfillment(&delivery)
- .expect("delivery")
- .expect("fulfillment");
-
- assert_eq!(fulfillment.methods(), &[FulfillmentMethod::Delivery]);
- assert!(fulfillment.delivery_only());
- assert_eq!(parse_listing_fulfillment(¬e), Ok(None));
- }
-
- #[test]
- fn listing_fulfillment_parser_rejects_missing_and_malformed_tags() {
- let missing = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new());
- let missing_value = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("fulfillment", &[]).expect("fulfillment")],
- );
- let extra_value = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("fulfillment", &["pickup", "delivery"]).expect("fulfillment")],
- );
- let empty = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("fulfillment", &[" "]).expect("fulfillment")],
- );
-
- assert_eq!(
- parse_listing_fulfillment(&missing).expect_err("missing"),
- "tag `fulfillment` is required"
- );
- assert_eq!(
- parse_listing_fulfillment(&missing_value).expect_err("value"),
- "tag `fulfillment` must include a value"
- );
- assert_eq!(
- parse_listing_fulfillment(&extra_value).expect_err("extra"),
- "fulfillment tag must include exactly one method"
- );
- assert_eq!(
- parse_listing_fulfillment(&empty).expect_err("empty"),
- "fulfillment tag method must not be empty"
- );
- }
-
- #[test]
- fn listing_fulfillment_parser_rejects_unsupported_methods() {
- let event = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("fulfillment", &["drone"]).expect("fulfillment")],
- );
-
- assert_eq!(
- parse_listing_fulfillment(&event).expect_err("unsupported"),
- "fulfillment method `drone` is unsupported"
- );
- }
-
- #[test]
- fn listing_status_parser_defaults_public_listings_to_active() {
- let event = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new());
-
- let status = parse_listing_status(&event)
- .expect("parse")
- .expect("status");
-
- assert_eq!(status.raw_status(), None);
- assert_eq!(status.effective_status(), ListingEffectiveStatus::Active);
- assert_eq!(ListingEffectiveStatus::Active.canonical(), "active");
- }
-
- #[test]
- fn listing_status_parser_extracts_sold_and_active_status_tags() {
- let sold = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("status", &["sold"]).expect("status")],
- );
- let active = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("status", &["active"]).expect("status")],
- );
-
- let sold_status = parse_listing_status(&sold).expect("sold").expect("status");
- let active_status = parse_listing_status(&active)
- .expect("active")
- .expect("status");
-
- assert_eq!(sold_status.raw_status(), Some("sold"));
- assert_eq!(sold_status.effective_status(), ListingEffectiveStatus::Sold);
- assert_eq!(ListingEffectiveStatus::Sold.canonical(), "sold");
- assert_eq!(active_status.raw_status(), Some("active"));
- assert_eq!(
- active_status.effective_status(),
- ListingEffectiveStatus::Active
- );
- }
-
- #[test]
- fn listing_status_parser_derives_draft_from_kind_and_ignores_non_listings() {
- let draft = event_with_kind_and_tags(
- 30_403,
- vec![Tag::from_parts("status", &["sold"]).expect("status")],
- );
- let note =
- event_with_kind_and_tags(1, vec![Tag::from_parts("status", &["active"]).expect("s")]);
-
- let status = parse_listing_status(&draft)
- .expect("draft")
- .expect("status");
-
- assert_eq!(status.raw_status(), Some("sold"));
- assert_eq!(status.effective_status(), ListingEffectiveStatus::Draft);
- assert_eq!(ListingEffectiveStatus::Draft.canonical(), "draft");
- assert_eq!(parse_listing_status(¬e), Ok(None));
- }
-
- #[test]
- fn listing_status_parser_rejects_repeated_missing_empty_and_unsupported_tags() {
- let repeated = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("status", &["active"]).expect("status"),
- Tag::from_parts("status", &["sold"]).expect("status"),
- ],
- );
- let missing_value = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("status", &[]).expect("status")],
- );
- let empty = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("status", &[""]).expect("status")],
- );
- let unsupported = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("status", &["inactive"]).expect("status")],
- );
-
- assert_eq!(
- parse_listing_status(&repeated).expect_err("repeated"),
- "tag `status` must not be repeated"
- );
- assert_eq!(
- parse_listing_status(&missing_value).expect_err("value"),
- "tag `status` must include a value"
- );
- assert_eq!(
- parse_listing_status(&empty).expect_err("empty"),
- "listing status tag must not be empty"
- );
- assert_eq!(
- parse_listing_status(&unsupported).expect_err("unsupported"),
- "listing status `inactive` is unsupported"
- );
- }
-
- #[test]
- fn listing_location_parser_extracts_text_geohash_and_prefixes() {
- let event = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("location", &["Olympia Farmers Market"]).expect("location"),
- Tag::from_parts("g", &[" C22YZUG "]).expect("g"),
- ],
- );
-
- let location = parse_listing_location(&event)
- .expect("parse")
- .expect("location");
-
- assert_eq!(location.location_text(), Some("Olympia Farmers Market"));
- assert_eq!(location.geohash(), Some("c22yzug"));
- assert_eq!(location.geohash4(), Some("c22y"));
- assert_eq!(location.geohash5(), Some("c22yz"));
- assert_eq!(location.geohash6(), Some("c22yzu"));
- assert_eq!(location.geohash7(), Some("c22yzug"));
- assert_eq!(location.location_precision(), Some(7));
- }
-
- #[test]
- fn listing_location_parser_allows_missing_location_fields_and_ignores_non_listings() {
- let listing = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new());
- let note = event_with_kind_and_tags(
- 1,
- vec![Tag::from_parts("location", &["Somewhere"]).expect("location")],
- );
-
- let location = parse_listing_location(&listing)
- .expect("listing")
- .expect("location");
-
- assert_eq!(location.location_text(), None);
- assert_eq!(location.geohash(), None);
- assert_eq!(location.geohash4(), None);
- assert_eq!(location.location_precision(), None);
- assert_eq!(parse_listing_location(¬e), Ok(None));
- }
-
- #[test]
- fn listing_location_parser_rejects_malformed_location_tags() {
- let repeated = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("location", &["A"]).expect("location"),
- Tag::from_parts("location", &["B"]).expect("location"),
- ],
- );
- let missing_value = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("location", &[]).expect("location")],
- );
- let empty = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("location", &[""]).expect("location")],
- );
-
- assert_eq!(
- parse_listing_location(&repeated).expect_err("repeated"),
- "tag `location` must not be repeated"
- );
- assert_eq!(
- parse_listing_location(&missing_value).expect_err("value"),
- "tag `location` must include a value"
- );
- assert_eq!(
- parse_listing_location(&empty).expect_err("empty"),
- "listing location tag must not be empty"
- );
- }
-
- #[test]
- fn listing_location_parser_rejects_malformed_geohash_tags() {
- let repeated = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("g", &["c22y"]).expect("g"),
- Tag::from_parts("g", &["c23n"]).expect("g"),
- ],
- );
- let missing_value = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("g", &[]).expect("g")],
- );
- let extra_value = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("g", &["c22y", "extra"]).expect("g")],
- );
- let invalid = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("g", &["c22a"]).expect("g")],
- );
- let too_short = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("g", &["c22"]).expect("g")],
- );
-
- assert_eq!(
- parse_listing_location(&repeated).expect_err("repeated"),
- "tag `g` must not be repeated"
- );
- assert_eq!(
- parse_listing_location(&missing_value).expect_err("value"),
- "tag `g` must include a value"
- );
- assert_eq!(
- parse_listing_location(&extra_value).expect_err("extra"),
- "tag `g` must include exactly one value"
- );
- assert_eq!(
- parse_listing_location(&invalid).expect_err("invalid"),
- "geohash must be 4 to 12 geohash characters"
- );
- assert_eq!(
- parse_listing_location(&too_short).expect_err("short"),
- "geohash must be 4 to 12 geohash characters"
- );
- }
-
- #[test]
- fn listing_taxonomy_parser_extracts_normalized_distinct_values() {
- let event = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![
- Tag::from_parts("category", &["Vegetables"]).expect("category"),
- Tag::from_parts("category", &[" vegetables "]).expect("category"),
- Tag::from_parts("t", &["Carrots"]).expect("topic"),
- Tag::from_parts("t", &["CSA"]).expect("topic"),
- Tag::from_parts("practice", &["No Spray"]).expect("practice"),
- Tag::from_parts("certification", &["Organic"]).expect("certification"),
- ],
- );
-
- let taxonomy = parse_listing_taxonomy(&event)
- .expect("parse")
- .expect("taxonomy");
-
- assert_eq!(taxonomy.categories(), &["vegetables".to_owned()]);
- assert_eq!(taxonomy.topics(), &["carrots".to_owned(), "csa".to_owned()]);
- assert_eq!(taxonomy.practices(), &["no spray".to_owned()]);
- assert_eq!(taxonomy.certifications(), &["organic".to_owned()]);
- }
-
- #[test]
- fn listing_taxonomy_parser_allows_missing_taxonomy_and_ignores_non_listings() {
- let listing = event_with_kind_and_tags(30_403, Vec::new());
- let note = event_with_kind_and_tags(
- 1,
- vec![Tag::from_parts("category", &["vegetables"]).expect("category")],
- );
-
- let taxonomy = parse_listing_taxonomy(&listing)
- .expect("listing")
- .expect("taxonomy");
-
- assert_eq!(taxonomy.categories(), &[] as &[String]);
- assert_eq!(taxonomy.topics(), &[] as &[String]);
- assert_eq!(taxonomy.practices(), &[] as &[String]);
- assert_eq!(taxonomy.certifications(), &[] as &[String]);
- assert_eq!(parse_listing_taxonomy(¬e), Ok(None));
- }
-
- #[test]
- fn listing_taxonomy_parser_rejects_malformed_category_and_topic_tags() {
- let category_missing_value = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("category", &[]).expect("category")],
- );
- let category_extra = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("category", &["vegetables", "extra"]).expect("category")],
- );
- let topic_empty = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("t", &[" "]).expect("topic")],
- );
-
- assert_eq!(
- parse_listing_taxonomy(&category_missing_value).expect_err("value"),
- "tag `category` must include a value"
- );
- assert_eq!(
- parse_listing_taxonomy(&category_extra).expect_err("extra"),
- "tag `category` must include exactly one value"
- );
- assert_eq!(
- parse_listing_taxonomy(&topic_empty).expect_err("empty"),
- "listing taxonomy `t` value must not be empty"
- );
- }
-
- #[test]
- fn listing_taxonomy_parser_rejects_malformed_practice_and_certification_tags() {
- let practice_empty = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("practice", &[""]).expect("practice")],
- );
- let certification_extra = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("certification", &["organic", "extra"]).expect("certification")],
- );
-
- assert_eq!(
- parse_listing_taxonomy(&practice_empty).expect_err("practice"),
- "listing taxonomy `practice` value must not be empty"
- );
- assert_eq!(
- parse_listing_taxonomy(&certification_extra).expect_err("certification"),
- "tag `certification` must include exactly one value"
- );
- }
-
- #[test]
- fn listing_projection_contract_accepts_complete_public_listing() {
- let event = event_with_kind_tags_and_content(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- complete_listing_tags(),
- "Sweet storage carrots.",
- );
-
- let evaluation = evaluate_listing_projection(&event);
-
- assert!(evaluation.is_eligible());
- assert_eq!(evaluation.rejection(), None);
- let projection = evaluation.projection().expect("projection");
-
- assert_eq!(projection.identity().d().as_str(), "listing-a");
- assert_eq!(projection.text().title(), "Carrot bunches");
- assert_eq!(projection.price().amount().raw(), "12.50");
- assert_eq!(projection.unit().unit(), ListingUnit::Lb);
- assert_eq!(projection.unit().canonical(), "lb");
- assert!(projection.fulfillment().pickup_available());
- assert_eq!(
- projection.status().effective_status(),
- ListingEffectiveStatus::Active
- );
- assert_eq!(projection.location().geohash4(), Some("c22y"));
- assert_eq!(
- projection.taxonomy().categories(),
- &["vegetables".to_owned()]
- );
- }
-
- #[test]
- fn listing_projection_contract_ignores_non_listing_events() {
- let event = event_with_kind_and_tags(1, complete_listing_tags());
- let evaluation = evaluate_listing_projection(&event);
-
- assert_eq!(evaluation, ListingProjectionEvaluation::NotListing);
- assert!(!evaluation.is_eligible());
- assert_eq!(evaluation.projection(), None);
- assert_eq!(evaluation.rejection(), None);
- }
-
- #[test]
- fn listing_projection_contract_rejects_draft_projection() {
- let event = event_with_kind_and_tags(30_403, complete_listing_tags());
- let evaluation = evaluate_listing_projection(&event);
-
- assert!(!evaluation.is_eligible());
- let rejection = evaluation.rejection().expect("rejection");
-
- assert_eq!(rejection.event_id(), event.id());
- assert_eq!(
- rejection.reasons(),
- &["draft listing is not public projection eligible".to_owned()]
- );
- }
-
- #[test]
- fn listing_projection_contract_accumulates_required_parser_failures() {
- let event = event_with_kind_and_tags(
- u64::from(NIP99_PUBLIC_LISTING_KIND),
- vec![Tag::from_parts("d", &["listing-a"]).expect("d")],
- );
- let evaluation = evaluate_listing_projection(&event);
-
- let rejection = evaluation.rejection().expect("rejection");
-
- assert_eq!(
- rejection.reasons(),
- &[
- "tag `title` is required".to_owned(),
- "tag `price` is required".to_owned(),
- "tag `unit` is required".to_owned(),
- "tag `fulfillment` is required".to_owned(),
- ]
- );
- }
-
- #[test]
- fn listing_projection_contract_accumulates_optional_parser_failures() {
- let mut tags = complete_listing_tags();
- tags.push(Tag::from_parts("location", &[""]).expect("location"));
- tags.push(Tag::from_parts("category", &["vegetables", "extra"]).expect("category"));
- let event = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), tags);
- let evaluation = evaluate_listing_projection(&event);
-
- let rejection = evaluation.rejection().expect("rejection");
-
- assert_eq!(
- rejection.reasons(),
- &[
- "listing location tag must not be empty".to_owned(),
- "tag `category` must include exactly one value".to_owned(),
- ]
- );
- }
-
- #[test]
- fn listing_projection_contract_accumulates_identity_and_status_failures() {
- let mut tags = complete_listing_tags();
- tags.retain(|tag| tag.name().as_str() != "d");
- tags.push(Tag::from_parts("status", &["inactive"]).expect("status"));
- let event = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), tags);
- let evaluation = evaluate_listing_projection(&event);
-
- let rejection = evaluation.rejection().expect("rejection");
-
- assert_eq!(
- rejection.reasons(),
- &[
- "tag `d` is required".to_owned(),
- "listing status `inactive` is unsupported".to_owned(),
- ]
- );
- }
-
- fn event_with_tags(tags: Vec<Tag>) -> Event {
- event_with_kind_and_tags(30_402, tags)
- }
-
- fn event_with_kind_and_tags(kind: u64, tags: Vec<Tag>) -> Event {
- event_with_kind_tags_and_content(kind, tags, "")
- }
-
- fn event_with_kind_tags_and_content(kind: u64, tags: Vec<Tag>, content: &str) -> Event {
- Event::new(
- EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"),
- UnsignedEvent::new(
- PublicKeyHex::new(&"1".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"),
- UnixTimestamp::new(1_714_124_433),
- Kind::new(kind).expect("kind"),
- tags,
- content,
- ),
- SignatureHex::new(&"b".repeat(SignatureHex::HEX_LENGTH)).expect("sig"),
- )
- }
-
- fn complete_listing_tags() -> Vec<Tag> {
- vec![
- Tag::from_parts("d", &["listing-a"]).expect("d"),
- Tag::from_parts("title", &["Carrot bunches"]).expect("title"),
- Tag::from_parts("price", &["12.50", "USD"]).expect("price"),
- Tag::from_parts("unit", &["lb"]).expect("unit"),
- Tag::from_parts("fulfillment", &["pickup"]).expect("fulfillment"),
- Tag::from_parts("g", &["c22yzug"]).expect("g"),
- Tag::from_parts("category", &["vegetables"]).expect("category"),
- Tag::from_parts("t", &["carrots"]).expect("topic"),
- Tag::from_parts("practice", &["no spray"]).expect("practice"),
- Tag::from_parts("certification", &["organic"]).expect("certification"),
- ]
- }
-}
diff --git a/crates/tangle_runtime/Cargo.toml b/crates/tangle_runtime/Cargo.toml
@@ -12,24 +12,16 @@ axum = { version = "0.8", features = ["ws"] }
http = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
-sha2 = "0.10"
tangle_crypto = { path = "../tangle_crypto" }
-tangle_core = { path = "../tangle_core" }
tangle_groups = { path = "../tangle_groups" }
-tangle_nips = { path = "../tangle_nips" }
tangle_protocol = { path = "../tangle_protocol" }
-tangle_store = { path = "../tangle_store" }
tangle_store_pocket = { path = "../tangle_store_pocket" }
-tangle_store_surreal = { path = "../tangle_store_surreal" }
tokio = { version = "1", features = ["net", "sync"] }
tracing = "0.1"
-url = "2"
[dev-dependencies]
-futures-util = "0.3"
tangle_test_support = { path = "../tangle_test_support" }
tokio = { version = "1", features = ["macros", "rt", "time"] }
-tokio-tungstenite = "0.28"
tower = { version = "0.5", features = ["util"] }
[lints]
diff --git a/crates/tangle_runtime/src/base_relay.rs b/crates/tangle_runtime/src/base_relay.rs
@@ -21,7 +21,6 @@ use tangle_groups::{
projection_checkpoint_key, role_current_key, tombstone_key,
validate_client_group_event_structure,
};
-use tangle_nips::parse_relay_auth_event;
use tangle_protocol::{
ClientMessage, Event, EventId, Filter, PublicKeyHex, RelayMessage, SubscriptionId,
UnixTimestamp, event_to_value, filter_to_value, parse_event_json,
@@ -586,7 +585,7 @@ impl BaseAuthState {
now: UnixTimestamp,
) -> Result<PublicKeyHex, BaseRelayError> {
verify_event_signature(event).map_err(BaseRelayError::invalid)?;
- let auth = parse_relay_auth_event(event)
+ let auth = parse_base_relay_auth_event(event)
.map_err(BaseRelayError::invalid)?
.ok_or_else(|| BaseRelayError::invalid("AUTH message must contain kind 22242"))?;
let challenge = self
@@ -627,6 +626,64 @@ struct BaseAuthChallenge {
issued_at: UnixTimestamp,
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct BaseRelayAuthEvent {
+ pubkey: PublicKeyHex,
+ relay: String,
+ challenge: String,
+}
+
+impl BaseRelayAuthEvent {
+ fn pubkey(&self) -> &PublicKeyHex {
+ &self.pubkey
+ }
+
+ fn relay(&self) -> &str {
+ &self.relay
+ }
+
+ fn challenge(&self) -> &str {
+ &self.challenge
+ }
+}
+
+fn parse_base_relay_auth_event(event: &Event) -> Result<Option<BaseRelayAuthEvent>, String> {
+ if event.unsigned().kind().as_u32() != 22_242 {
+ return Ok(None);
+ }
+ let relay = required_single_tag_value(event, "relay")?;
+ let challenge = required_single_tag_value(event, "challenge")?;
+ if relay.is_empty() {
+ return Err("relay auth relay tag must not be empty".to_owned());
+ }
+ if challenge.is_empty() {
+ return Err("relay auth challenge tag must not be empty".to_owned());
+ }
+ Ok(Some(BaseRelayAuthEvent {
+ pubkey: event.unsigned().pubkey().clone(),
+ relay,
+ challenge,
+ }))
+}
+
+fn required_single_tag_value(event: &Event, name: &str) -> Result<String, String> {
+ let mut matches = event
+ .unsigned()
+ .tags()
+ .iter()
+ .filter(|tag| tag.name().as_str() == name);
+ let tag = matches
+ .next()
+ .ok_or_else(|| format!("tag `{name}` is required"))?;
+ if matches.next().is_some() {
+ return Err(format!("tag `{name}` must not be repeated"));
+ }
+ tag.values()
+ .get(1)
+ .cloned()
+ .ok_or_else(|| format!("tag `{name}` must include a value"))
+}
+
pub struct BaseRelay {
store: PocketStoreHandle,
subscriptions: LiveSubscriptionSet,
diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs
@@ -3,9677 +3,99 @@
pub mod base_relay;
pub mod chorus_pocket;
-use axum::{
- Json, Router,
- extract::ws::{Message, WebSocket, WebSocketUpgrade},
- extract::{Path, RawQuery, State},
- response::{IntoResponse, Response},
- routing::{get, post},
-};
-use core::fmt;
-use http::{HeaderMap, HeaderValue, StatusCode, header};
-use serde::{Deserialize, Serialize};
-use sha2::{Digest, Sha256};
-use std::{
- collections::BTreeSet,
- fs,
- future::Future,
- net::SocketAddr,
- path::{Component, Path as FsPath, PathBuf},
- sync::{
- Arc,
- atomic::{AtomicU64, Ordering},
- },
- time::{SystemTime, UNIX_EPOCH},
-};
-use tangle_core::{
- AdmissionContext, AdmissionEffect, AdmissionPolicy, AuthChallengeState, EventValidator,
- FixedWindowRateLimiter, MarketplaceListingStatus, MarketplaceQuery, MarketplaceQueryError,
- MarketplaceQuerySpec, MarketplaceSort, NostrFilterCompiler, QueryExecutionMode,
- RateLimitConfig, RateLimitDecision, RuntimeLimitValues, RuntimeLimits,
- SubscriptionCloseOutcome, SubscriptionManager, SubscriptionMatcher, UnapprovedSellerAction,
-};
-use tangle_nips::{FulfillmentMethod, ListingUnit, parse_relay_auth_event};
-use tangle_protocol::{
- ClientMessage, Event, EventId, Filter, PublicKeyHex, RawEventJson, RelayMessage,
- SubscriptionId, UnixTimestamp, parse_client_message, parse_event_json,
-};
-use tangle_store::{StoreEventOutcome, StoredEvent};
-use tangle_store_surreal::{
- CommentProjectionOutcome, CommentProjectionQuery, DurableRateLimitDecision,
- ForumThreadProjectionOutcome, ForumThreadProjectionQuery, LabelProjectionOutcome,
- LabelProjectionQuery, ListingProjectionQuery, LongFormProjectionOutcome, MigrationApplyOutcome,
- ReactionProjectionOutcome, ReportProjectionOutcome, ReportProjectionQuery, SearchDocumentQuery,
- SellerProfileProjectionOutcome, SurrealConnectionConfig, SurrealMetricsSnapshot, SurrealStore,
- base_migration_plan,
+use std::{fmt, fs, path::Path, path::PathBuf};
+
+use base_relay::{
+ BaseRelayError, BaseRelayReadinessState, BaseRelayRuntimeConfig,
+ parse_base_relay_runtime_config_json,
};
-use tokio::net::TcpListener;
-use tokio::sync::broadcast;
-use url::form_urlencoded;
-pub const TANGLE_SUPPORTED_NIPS: [u16; 13] = [1, 9, 11, 16, 22, 23, 25, 32, 33, 42, 50, 56, 99];
+pub const TANGLE_SUPPORTED_NIPS: [u16; 6] = [1, 11, 29, 42, 45, 70];
pub const TANGLE_RELAY_SOFTWARE: &str = "https://github.com/radrootslabs/tangle";
pub const TANGLE_RELAY_VERSION: &str = env!("CARGO_PKG_VERSION");
-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub struct RelayConnectionId(String);
-
-impl RelayConnectionId {
- pub const MAX_LENGTH: usize = 128;
-
- pub fn new(value: &str) -> Result<Self, String> {
- let value = value.trim();
- if value.is_empty() {
- return Err("connection id must not be empty".to_owned());
- }
- if value.len() > Self::MAX_LENGTH {
- return Err(format!(
- "connection id must be at most {} bytes, got {}",
- Self::MAX_LENGTH,
- value.len()
- ));
- }
- Ok(Self(value.to_owned()))
- }
-
- pub fn as_str(&self) -> &str {
- &self.0
- }
-}
-
-impl fmt::Display for RelayConnectionId {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RelayConnectionConfig {
+pub struct TangleRuntimeStartupReport {
relay_url: String,
- auth_ttl_seconds: u64,
- message_rate_limit: RateLimitConfig,
- runtime_limits: RuntimeLimits,
+ data_directory: PathBuf,
+ groups_enabled: bool,
+ readiness: BaseRelayReadinessState,
}
-impl RelayConnectionConfig {
- pub fn new(
- relay_url: impl Into<String>,
- auth_ttl_seconds: u64,
- message_rate_limit: RateLimitConfig,
- runtime_limits: RuntimeLimits,
- ) -> Result<Self, String> {
- let relay_url = relay_url.into();
- let auth = AuthChallengeState::new(&relay_url, auth_ttl_seconds)
- .map_err(|error| error.to_string())?;
- Ok(Self {
- relay_url: auth.relay_url().to_owned(),
- auth_ttl_seconds,
- message_rate_limit,
- runtime_limits,
- })
- }
-
+impl TangleRuntimeStartupReport {
pub fn relay_url(&self) -> &str {
&self.relay_url
}
- pub fn auth_ttl_seconds(&self) -> u64 {
- self.auth_ttl_seconds
- }
-
- pub fn message_rate_limit(&self) -> RateLimitConfig {
- self.message_rate_limit
- }
-
- pub fn runtime_limits(&self) -> RuntimeLimits {
- self.runtime_limits
- }
-}
-
-impl Default for RelayConnectionConfig {
- fn default() -> Self {
- Self::new(
- "wss://relay.radroots.test",
- 300,
- RateLimitConfig::new(120, 60).expect("default message rate limit is valid"),
- RuntimeLimits::default(),
- )
- .expect("default relay connection config is valid")
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RelayConnection {
- id: RelayConnectionId,
- remote_addr: Option<String>,
- subscriptions: SubscriptionManager,
- auth: AuthChallengeState,
- rate_limiter: FixedWindowRateLimiter,
-}
-
-impl RelayConnection {
- pub fn new(id: RelayConnectionId, config: RelayConnectionConfig) -> Self {
- Self {
- id,
- remote_addr: None,
- subscriptions: SubscriptionManager::new(
- config.runtime_limits(),
- SubscriptionMatcher::default(),
- ),
- auth: AuthChallengeState::new(config.relay_url(), config.auth_ttl_seconds())
- .expect("connection config validates auth state"),
- rate_limiter: FixedWindowRateLimiter::new(config.message_rate_limit()),
- }
- }
-
- pub fn id(&self) -> &RelayConnectionId {
- &self.id
- }
-
- pub fn remote_addr(&self) -> Option<&str> {
- self.remote_addr.as_deref()
- }
-
- pub fn set_remote_addr(&mut self, remote_addr: impl Into<String>) {
- self.remote_addr = Some(remote_addr.into());
- }
-
- pub fn subscriptions(&self) -> &SubscriptionManager {
- &self.subscriptions
- }
-
- pub fn subscriptions_mut(&mut self) -> &mut SubscriptionManager {
- &mut self.subscriptions
- }
-
- pub fn auth(&self) -> &AuthChallengeState {
- &self.auth
- }
-
- pub fn auth_mut(&mut self) -> &mut AuthChallengeState {
- &mut self.auth
- }
-
- pub fn rate_limiter(&self) -> &FixedWindowRateLimiter {
- &self.rate_limiter
- }
-
- pub fn rate_limiter_mut(&mut self) -> &mut FixedWindowRateLimiter {
- &mut self.rate_limiter
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct WebSocketHttpState {
- connection_config: RelayConnectionConfig,
- shutdown_signal: GracefulShutdownSignal,
-}
-
-impl WebSocketHttpState {
- pub fn new(connection_config: RelayConnectionConfig) -> Self {
- let (shutdown_signal, _) = GracefulShutdownSignal::new();
- Self::with_shutdown(connection_config, shutdown_signal)
- }
-
- pub fn with_shutdown(
- connection_config: RelayConnectionConfig,
- shutdown_signal: GracefulShutdownSignal,
- ) -> Self {
- Self {
- connection_config,
- shutdown_signal,
- }
- }
-
- pub fn connection_config(&self) -> &RelayConnectionConfig {
- &self.connection_config
- }
-
- pub fn shutdown_signal(&self) -> &GracefulShutdownSignal {
- &self.shutdown_signal
- }
-}
-
-impl Default for WebSocketHttpState {
- fn default() -> Self {
- Self::new(RelayConnectionConfig::default())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct TangleRuntimeConfig {
- listen_addr: SocketAddr,
- relay_connection: RelayConnectionConfig,
- database: SurrealConnectionConfig,
- admission_policy: AdmissionPolicy,
- durable_write_rate_limit: Option<RateLimitConfig>,
- admin_pubkeys: BTreeSet<PublicKeyHex>,
- limits: RuntimeLimits,
- tracing: RuntimeTracingConfig,
-}
-
-impl TangleRuntimeConfig {
- pub fn listen_addr(&self) -> SocketAddr {
- self.listen_addr
- }
-
- pub fn relay_connection_config(&self) -> &RelayConnectionConfig {
- &self.relay_connection
- }
-
- pub fn database_config(&self) -> &SurrealConnectionConfig {
- &self.database
- }
-
- pub fn admission_policy(&self) -> &AdmissionPolicy {
- &self.admission_policy
- }
-
- pub fn durable_write_rate_limit(&self) -> Option<RateLimitConfig> {
- self.durable_write_rate_limit
- }
-
- pub fn admin_pubkeys(&self) -> &BTreeSet<PublicKeyHex> {
- &self.admin_pubkeys
- }
-
- pub fn limits(&self) -> RuntimeLimits {
- self.limits
- }
-
- pub fn tracing_config(&self) -> &RuntimeTracingConfig {
- &self.tracing
- }
-
- pub fn websocket_state(&self, shutdown_signal: GracefulShutdownSignal) -> WebSocketHttpState {
- WebSocketHttpState::with_shutdown(self.relay_connection.clone(), shutdown_signal)
- }
-
- pub fn listings_state(&self, store: SurrealStore) -> ListingsHttpState {
- ListingsHttpState::new(store, self.limits)
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum RuntimeTracingFormat {
- Compact,
- Json,
-}
-
-impl RuntimeTracingFormat {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::Compact => "compact",
- Self::Json => "json",
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RuntimeTracingConfig {
- enabled: bool,
- filter: String,
- format: RuntimeTracingFormat,
-}
-
-impl RuntimeTracingConfig {
- pub fn new(
- enabled: bool,
- filter: impl Into<String>,
- format: RuntimeTracingFormat,
- ) -> Result<Self, RuntimeConfigError> {
- let filter = filter.into();
- if filter.trim().is_empty() {
- return Err(RuntimeConfigError::invalid(
- "observability.tracing.filter must not be empty",
- ));
- }
- Ok(Self {
- enabled,
- filter: filter.trim().to_owned(),
- format,
- })
- }
-
- pub fn disabled() -> Self {
- Self {
- enabled: false,
- filter: "info,tangle=info,tangle_runtime=info".to_owned(),
- format: RuntimeTracingFormat::Compact,
- }
- }
-
- pub fn enabled(&self) -> bool {
- self.enabled
- }
-
- pub fn filter(&self) -> &str {
- &self.filter
- }
-
- pub fn format(&self) -> RuntimeTracingFormat {
- self.format
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum RuntimeConfigErrorKind {
- Read,
- Parse,
- Invalid,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RuntimeConfigError {
- kind: RuntimeConfigErrorKind,
- message: String,
-}
-
-impl RuntimeConfigError {
- pub fn new(kind: RuntimeConfigErrorKind, message: impl Into<String>) -> Self {
- Self {
- kind,
- message: message.into(),
- }
- }
-
- pub fn read(message: impl Into<String>) -> Self {
- Self::new(RuntimeConfigErrorKind::Read, message)
- }
-
- pub fn parse(message: impl Into<String>) -> Self {
- Self::new(RuntimeConfigErrorKind::Parse, message)
+ pub fn data_directory(&self) -> &Path {
+ &self.data_directory
}
- pub fn invalid(message: impl Into<String>) -> Self {
- Self::new(RuntimeConfigErrorKind::Invalid, message)
+ pub fn groups_enabled(&self) -> bool {
+ self.groups_enabled
}
- pub fn kind(&self) -> RuntimeConfigErrorKind {
- self.kind
+ pub fn readiness(&self) -> &BaseRelayReadinessState {
+ &self.readiness
}
-
- pub fn message(&self) -> &str {
- &self.message
- }
-}
-
-impl fmt::Display for RuntimeConfigError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(formatter, "{:?}: {}", self.kind, self.message)
- }
-}
-
-impl std::error::Error for RuntimeConfigError {}
-
-pub fn load_runtime_config(
- path: impl AsRef<FsPath>,
-) -> Result<TangleRuntimeConfig, RuntimeConfigError> {
- let path = path.as_ref();
- let raw = fs::read_to_string(path).map_err(|error| {
- RuntimeConfigError::read(format!(
- "failed to read runtime config `{}`: {error}",
- path.display()
- ))
- })?;
- parse_runtime_config_json(&raw)
-}
-
-pub fn parse_runtime_config_json(raw: &str) -> Result<TangleRuntimeConfig, RuntimeConfigError> {
- let document = serde_json::from_str::<RuntimeConfigDocument>(raw).map_err(|error| {
- RuntimeConfigError::parse(format!("runtime config JSON is invalid: {error}"))
- })?;
- runtime_config_from_document(document)
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct RuntimeMigrationReport {
- applied: u64,
- already_applied: u64,
- total: u64,
}
-impl RuntimeMigrationReport {
- pub fn new(applied: u64, already_applied: u64, total: u64) -> Self {
- Self {
- applied,
- already_applied,
- total,
- }
- }
-
- pub fn applied(self) -> u64 {
- self.applied
- }
-
- pub fn already_applied(self) -> u64 {
- self.already_applied
- }
-
- pub fn total(self) -> u64 {
- self.total
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum RuntimeCommandErrorKind {
- Unsupported,
- Input,
- Store,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RuntimeCommandError {
- kind: RuntimeCommandErrorKind,
- message: String,
-}
-
-impl RuntimeCommandError {
- pub fn new(kind: RuntimeCommandErrorKind, message: impl Into<String>) -> Self {
- Self {
- kind,
- message: message.into(),
- }
- }
-
- pub fn unsupported(message: impl Into<String>) -> Self {
- Self::new(RuntimeCommandErrorKind::Unsupported, message)
- }
-
- pub fn input(message: impl Into<String>) -> Self {
- Self::new(RuntimeCommandErrorKind::Input, message)
- }
-
- pub fn store(message: impl Into<String>) -> Self {
- Self::new(RuntimeCommandErrorKind::Store, message)
- }
-
- pub fn kind(&self) -> RuntimeCommandErrorKind {
- self.kind
- }
-
- pub fn message(&self) -> &str {
- &self.message
- }
+#[derive(Debug)]
+pub enum TangleRuntimeLoadError {
+ ReadConfig {
+ path: PathBuf,
+ source: std::io::Error,
+ },
+ ParseConfig(BaseRelayError),
+ OpenRelay(BaseRelayError),
+ ShutdownRelay(BaseRelayError),
}
-impl fmt::Display for RuntimeCommandError {
+impl fmt::Display for TangleRuntimeLoadError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(formatter, "{:?}: {}", self.kind, self.message)
- }
-}
-
-impl std::error::Error for RuntimeCommandError {}
-
-pub async fn migrate_runtime_database(
- config: &TangleRuntimeConfig,
-) -> Result<RuntimeMigrationReport, RuntimeCommandError> {
- tracing::info!("starting runtime database migration");
- let store = connect_runtime_store(config).await?;
- let outcomes = store
- .apply_plan(&base_migration_plan())
- .await
- .map_err(|error| RuntimeCommandError::store(error.to_string()))?;
- let applied = outcomes
- .iter()
- .filter(|outcome| **outcome == MigrationApplyOutcome::Applied)
- .count() as u64;
- let already_applied = outcomes
- .iter()
- .filter(|outcome| **outcome == MigrationApplyOutcome::AlreadyApplied)
- .count() as u64;
- tracing::info!("finished runtime database migration");
- Ok(RuntimeMigrationReport::new(
- applied,
- already_applied,
- outcomes.len() as u64,
- ))
-}
-
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
-pub struct RuntimeEventImportReport {
- total: u64,
- inserted: u64,
- duplicate: u64,
- projected: u64,
- skipped: u64,
-}
-
-impl RuntimeEventImportReport {
- pub fn new(total: u64, inserted: u64, duplicate: u64, projected: u64, skipped: u64) -> Self {
- Self {
- total,
- inserted,
- duplicate,
- projected,
- skipped,
- }
- }
-
- pub fn total(self) -> u64 {
- self.total
- }
-
- pub fn inserted(self) -> u64 {
- self.inserted
- }
-
- pub fn duplicate(self) -> u64 {
- self.duplicate
- }
-
- pub fn projected(self) -> u64 {
- self.projected
- }
-
- pub fn skipped(self) -> u64 {
- self.skipped
- }
-
- fn record(&mut self, outcome: RuntimeEventImportOutcome) {
- self.total += 1;
- match outcome {
- RuntimeEventImportOutcome::Inserted { projected } => {
- self.inserted += 1;
- if projected {
- self.projected += 1;
- }
- }
- RuntimeEventImportOutcome::Duplicate => {
- self.duplicate += 1;
- }
- RuntimeEventImportOutcome::Skipped => {
- self.skipped += 1;
+ match self {
+ Self::ReadConfig { path, source } => {
+ write!(
+ formatter,
+ "failed to read tangle runtime config `{}`: {source}",
+ path.display()
+ )
}
+ Self::ParseConfig(error) => write!(formatter, "{error}"),
+ Self::OpenRelay(error) => write!(formatter, "{error}"),
+ Self::ShutdownRelay(error) => write!(formatter, "{error}"),
}
}
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum RuntimeEventImportOutcome {
- Inserted { projected: bool },
- Duplicate,
- Skipped,
-}
+impl std::error::Error for TangleRuntimeLoadError {}
-pub async fn import_events_from_path(
- config: &TangleRuntimeConfig,
- path: impl AsRef<FsPath>,
-) -> Result<RuntimeEventImportReport, RuntimeCommandError> {
+pub fn load_base_relay_runtime_config(
+ path: impl AsRef<Path>,
+) -> Result<BaseRelayRuntimeConfig, TangleRuntimeLoadError> {
let path = path.as_ref();
- tracing::info!("starting event import");
- let raw = fs::read_to_string(path).map_err(|error| {
- RuntimeCommandError::input(format!(
- "failed to read event import file `{}`: {error}",
- path.display()
- ))
+ let raw = fs::read_to_string(path).map_err(|source| TangleRuntimeLoadError::ReadConfig {
+ path: path.to_path_buf(),
+ source,
})?;
- let events = parse_event_import_document(&raw)?;
- let store = connect_runtime_store(config).await?;
- let report = import_events_into_store(config, &store, events).await?;
- tracing::info!("finished event import");
- Ok(report)
-}
-
-async fn import_events_into_store(
- config: &TangleRuntimeConfig,
- store: &SurrealStore,
- events: Vec<Event>,
-) -> Result<RuntimeEventImportReport, RuntimeCommandError> {
- store
- .apply_plan(&base_migration_plan())
- .await
- .map_err(|error| RuntimeCommandError::store(error.to_string()))?;
- let validator = EventValidator::new(
- config.limits(),
- config
- .admission_policy()
- .clone()
- .with_write_auth_required(false),
- );
- let mut report = RuntimeEventImportReport::default();
- let now = now_timestamp();
- for event in events {
- let outcome = import_single_event(store, &validator, event, now).await?;
- report.record(outcome);
- }
- Ok(report)
-}
-
-async fn import_single_event(
- store: &SurrealStore,
- validator: &EventValidator,
- event: Event,
- now: UnixTimestamp,
-) -> Result<RuntimeEventImportOutcome, RuntimeCommandError> {
- if is_non_auth_ephemeral(&event) {
- return Ok(RuntimeEventImportOutcome::Skipped);
- }
- let validated = match validator.validate(&event, &AdmissionContext::unauthenticated(), now) {
- Ok(validated) => validated,
- Err(_) => return Ok(RuntimeEventImportOutcome::Skipped),
- };
- if validated.admission().effect() != AdmissionEffect::AuthenticateOnly {
- let raw_outcome = store
- .store_raw_event(&StoredEvent::new(event.clone(), now))
- .await
- .map_err(|error| RuntimeCommandError::store(error.to_string()))?;
- if raw_outcome == StoreEventOutcome::Duplicate {
- return Ok(RuntimeEventImportOutcome::Duplicate);
- }
- let projected =
- project_stored_event(store, &event, validated.admission().effect(), now).await?;
- return Ok(RuntimeEventImportOutcome::Inserted { projected });
- }
- Ok(RuntimeEventImportOutcome::Skipped)
-}
-
-async fn project_stored_event(
- store: &SurrealStore,
- event: &Event,
- effect: AdmissionEffect,
- now: UnixTimestamp,
-) -> Result<bool, RuntimeCommandError> {
- store
- .index_event_tags(event)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?;
- store
- .maintain_current_event(event)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?;
- store
- .apply_deletion_markers(event)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?;
- store
- .store_listing_revision(event, now)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?;
- let comment_projected = matches!(
- store
- .project_comment(event, now)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?,
- CommentProjectionOutcome::Projected
- );
- let reaction_projected = matches!(
- store
- .project_reaction(event, now)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?,
- ReactionProjectionOutcome::Projected
- );
- let long_form_projected = matches!(
- store
- .project_long_form(event, now)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?,
- LongFormProjectionOutcome::Projected
- );
- let forum_thread_projected = matches!(
- store
- .project_forum_thread(event, now)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?,
- ForumThreadProjectionOutcome::Projected
- );
- let label_projected = matches!(
- store
- .project_label(event, now)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?,
- LabelProjectionOutcome::Projected
- );
- let report_projected = matches!(
- store
- .project_report(event, now)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?,
- ReportProjectionOutcome::Projected
- );
- let seller_profile_projected = matches!(
- store
- .project_seller_profile(event, now)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?,
- SellerProfileProjectionOutcome::Projected
- );
- if effect == AdmissionEffect::StoreRawAndProjectPublicListing {
- store
- .project_current_listing(event, now)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?;
- store
- .project_listing_helpers(event)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?;
- store
- .index_listing_search_document(event)
- .await
- .map_err(|_| RuntimeCommandError::store("event projection failed"))?;
- return Ok(true);
- }
- Ok(comment_projected
- || reaction_projected
- || long_form_projected
- || forum_thread_projected
- || label_projected
- || report_projected
- || seller_profile_projected)
-}
-
-fn parse_event_import_document(raw: &str) -> Result<Vec<Event>, RuntimeCommandError> {
- let trimmed = raw.trim();
- if trimmed.is_empty() {
- return Ok(Vec::new());
- }
- match serde_json::from_str::<serde_json::Value>(trimmed) {
- Ok(serde_json::Value::Array(events)) => events
- .iter()
- .enumerate()
- .map(|(index, value)| event_from_import_value(value, index + 1))
- .collect(),
- Ok(value @ serde_json::Value::Object(_)) => {
- event_from_import_value(&value, 1).map(|event| vec![event])
- }
- Ok(_) => Err(RuntimeCommandError::input(
- "event import file must contain event objects",
- )),
- Err(_) => trimmed
- .lines()
- .enumerate()
- .filter_map(|(index, line)| {
- let line = line.trim();
- if line.is_empty() {
- None
- } else {
- Some(event_from_import_line(line, index + 1))
- }
- })
- .collect(),
- }
-}
-
-fn event_from_import_value(
- value: &serde_json::Value,
- index: usize,
-) -> Result<Event, RuntimeCommandError> {
- let raw = RawEventJson::new(&value.to_string()).expect("serialized JSON value is non-empty");
- parse_event_json(&raw).map_err(|error| {
- RuntimeCommandError::input(format!("event import item {index} is invalid: {error}"))
+ parse_base_relay_runtime_config_json(&raw).map_err(TangleRuntimeLoadError::ParseConfig)
+}
+
+pub fn open_base_relay_from_config_path(
+ path: impl AsRef<Path>,
+) -> Result<TangleRuntimeStartupReport, TangleRuntimeLoadError> {
+ let config = load_base_relay_runtime_config(path)?;
+ let mut relay = config
+ .open_relay()
+ .map_err(TangleRuntimeLoadError::OpenRelay)?;
+ let readiness = relay.readiness_state();
+ relay
+ .shutdown()
+ .map_err(TangleRuntimeLoadError::ShutdownRelay)?;
+ Ok(TangleRuntimeStartupReport {
+ relay_url: config.relay_url().to_owned(),
+ data_directory: config.pocket_config().data_directory().to_path_buf(),
+ groups_enabled: config.groups().enabled(),
+ readiness,
})
}
-
-fn event_from_import_line(line: &str, index: usize) -> Result<Event, RuntimeCommandError> {
- let raw = RawEventJson::new(line).expect("import lines are non-empty after trimming");
- parse_event_json(&raw).map_err(|error| {
- RuntimeCommandError::input(format!("event import line {index} is invalid: {error}"))
- })
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct RuntimeEventExportReport {
- exported: u64,
-}
-
-impl RuntimeEventExportReport {
- pub fn new(exported: u64) -> Self {
- Self { exported }
- }
-
- pub fn exported(self) -> u64 {
- self.exported
- }
-}
-
-pub async fn export_events_to_path(
- config: &TangleRuntimeConfig,
- path: impl AsRef<FsPath>,
-) -> Result<RuntimeEventExportReport, RuntimeCommandError> {
- let path = path.as_ref();
- tracing::info!("starting event export");
- let store = connect_runtime_store(config).await?;
- store
- .apply_plan(&base_migration_plan())
- .await
- .map_err(|error| RuntimeCommandError::store(error.to_string()))?;
- let rows = store
- .query_raw_events(&Filter::empty())
- .await
- .map_err(|error| RuntimeCommandError::store(error.to_string()))?;
- let mut output = String::new();
- for row in &rows {
- output.push_str(&runtime_row_string(row, "raw_json")?);
- output.push('\n');
- }
- fs::write(path, output).map_err(|error| {
- RuntimeCommandError::input(format!(
- "failed to write event export file `{}`: {error}",
- path.display()
- ))
- })?;
- tracing::info!("finished event export");
- Ok(RuntimeEventExportReport::new(rows.len() as u64))
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RuntimeBackupReport {
- output_dir: PathBuf,
- raw_events_path: PathBuf,
- raw_event_count: u64,
- raw_events_sha256: String,
- manifest_path: PathBuf,
- manifest_sha256: String,
- surrealdb_export_available: bool,
-}
-
-impl RuntimeBackupReport {
- pub fn new(
- output_dir: PathBuf,
- raw_events_path: PathBuf,
- raw_event_count: u64,
- raw_events_sha256: String,
- manifest_path: PathBuf,
- manifest_sha256: String,
- surrealdb_export_available: bool,
- ) -> Self {
- Self {
- output_dir,
- raw_events_path,
- raw_event_count,
- raw_events_sha256,
- manifest_path,
- manifest_sha256,
- surrealdb_export_available,
- }
- }
-
- pub fn output_dir(&self) -> &FsPath {
- &self.output_dir
- }
-
- pub fn raw_events_path(&self) -> &FsPath {
- &self.raw_events_path
- }
-
- pub fn raw_event_count(&self) -> u64 {
- self.raw_event_count
- }
-
- pub fn raw_events_sha256(&self) -> &str {
- &self.raw_events_sha256
- }
-
- pub fn manifest_path(&self) -> &FsPath {
- &self.manifest_path
- }
-
- pub fn manifest_sha256(&self) -> &str {
- &self.manifest_sha256
- }
-
- pub fn surrealdb_export_available(&self) -> bool {
- self.surrealdb_export_available
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-struct RuntimeBackupManifestDocument {
- format: String,
- database: RuntimeBackupDatabaseDocument,
- raw_events: RuntimeBackupArtifactDocument,
- surrealdb_export: RuntimeBackupOptionalArtifactDocument,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-struct RuntimeBackupDatabaseDocument {
- namespace: String,
- database: String,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-struct RuntimeBackupArtifactDocument {
- path: String,
- count: u64,
- sha256: String,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-struct RuntimeBackupOptionalArtifactDocument {
- available: bool,
- path: Option<String>,
- sha256: Option<String>,
-}
-
-pub async fn backup_runtime_database(
- config: &TangleRuntimeConfig,
- output_dir: impl AsRef<FsPath>,
-) -> Result<RuntimeBackupReport, RuntimeCommandError> {
- let output_dir = output_dir.as_ref();
- tracing::info!("starting runtime backup");
- let store = connect_runtime_store(config).await?;
- let report = backup_runtime_store(config, &store, output_dir).await?;
- tracing::info!("finished runtime backup");
- Ok(report)
-}
-
-async fn backup_runtime_store(
- config: &TangleRuntimeConfig,
- store: &SurrealStore,
- output_dir: &FsPath,
-) -> Result<RuntimeBackupReport, RuntimeCommandError> {
- fs::create_dir_all(output_dir).map_err(|error| {
- RuntimeCommandError::input(format!(
- "failed to create backup directory `{}`: {error}",
- output_dir.display()
- ))
- })?;
- store
- .apply_plan(&base_migration_plan())
- .await
- .map_err(|error| RuntimeCommandError::store(error.to_string()))?;
- let rows = store
- .backup_raw_events()
- .await
- .map_err(|error| RuntimeCommandError::store(error.to_string()))?;
- let mut raw_events = String::new();
- for row in &rows {
- raw_events.push_str(&runtime_row_string(row, "raw_json")?);
- raw_events.push('\n');
- }
- let raw_events_path = output_dir.join("raw-events.jsonl");
- fs::write(&raw_events_path, raw_events.as_bytes()).map_err(|error| {
- RuntimeCommandError::input(format!(
- "failed to write backup raw events file `{}`: {error}",
- raw_events_path.display()
- ))
- })?;
- let raw_events_sha256 = sha256_hex(raw_events.as_bytes());
- let manifest = RuntimeBackupManifestDocument {
- format: "tangle-backup-v1".to_owned(),
- database: RuntimeBackupDatabaseDocument {
- namespace: config.database_config().namespace().to_owned(),
- database: config.database_config().database().to_owned(),
- },
- raw_events: RuntimeBackupArtifactDocument {
- path: "raw-events.jsonl".to_owned(),
- count: rows.len() as u64,
- sha256: raw_events_sha256.clone(),
- },
- surrealdb_export: RuntimeBackupOptionalArtifactDocument {
- available: false,
- path: None,
- sha256: None,
- },
- };
- let mut manifest_json =
- serde_json::to_vec_pretty(&manifest).expect("backup manifest is serializable");
- manifest_json.push(b'\n');
- let manifest_path = output_dir.join("manifest.json");
- fs::write(&manifest_path, &manifest_json).map_err(|error| {
- RuntimeCommandError::input(format!(
- "failed to write backup manifest file `{}`: {error}",
- manifest_path.display()
- ))
- })?;
- let manifest_sha256 = sha256_hex(&manifest_json);
- Ok(RuntimeBackupReport::new(
- output_dir.to_path_buf(),
- raw_events_path,
- rows.len() as u64,
- raw_events_sha256,
- manifest_path,
- manifest_sha256,
- false,
- ))
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RuntimeRestoreReport {
- input_dir: PathBuf,
- raw_event_count: u64,
- raw_events_sha256: String,
- import_report: RuntimeEventImportReport,
- rebuild_report: RuntimeProjectionRebuildReport,
-}
-
-impl RuntimeRestoreReport {
- pub fn new(
- input_dir: PathBuf,
- raw_event_count: u64,
- raw_events_sha256: String,
- import_report: RuntimeEventImportReport,
- rebuild_report: RuntimeProjectionRebuildReport,
- ) -> Self {
- Self {
- input_dir,
- raw_event_count,
- raw_events_sha256,
- import_report,
- rebuild_report,
- }
- }
-
- pub fn input_dir(&self) -> &FsPath {
- &self.input_dir
- }
-
- pub fn raw_event_count(&self) -> u64 {
- self.raw_event_count
- }
-
- pub fn raw_events_sha256(&self) -> &str {
- &self.raw_events_sha256
- }
-
- pub fn import_report(&self) -> RuntimeEventImportReport {
- self.import_report
- }
-
- pub fn rebuild_report(&self) -> RuntimeProjectionRebuildReport {
- self.rebuild_report
- }
-}
-
-pub async fn restore_runtime_database(
- config: &TangleRuntimeConfig,
- input_dir: impl AsRef<FsPath>,
-) -> Result<RuntimeRestoreReport, RuntimeCommandError> {
- let input_dir = input_dir.as_ref();
- tracing::info!("starting runtime restore");
- let store = connect_runtime_store(config).await?;
- let report = restore_runtime_store(config, &store, input_dir).await?;
- tracing::info!("finished runtime restore");
- Ok(report)
-}
-
-async fn restore_runtime_store(
- config: &TangleRuntimeConfig,
- store: &SurrealStore,
- input_dir: &FsPath,
-) -> Result<RuntimeRestoreReport, RuntimeCommandError> {
- let manifest_path = input_dir.join("manifest.json");
- let manifest_raw = fs::read_to_string(&manifest_path).map_err(|error| {
- RuntimeCommandError::input(format!(
- "failed to read backup manifest file `{}`: {error}",
- manifest_path.display()
- ))
- })?;
- let manifest: RuntimeBackupManifestDocument =
- serde_json::from_str(&manifest_raw).map_err(|error| {
- RuntimeCommandError::input(format!("backup manifest JSON is invalid: {error}"))
- })?;
- validate_backup_manifest(&manifest)?;
- let raw_events_path = backup_artifact_path(input_dir, &manifest.raw_events.path)?;
- let raw_events = fs::read_to_string(&raw_events_path).map_err(|error| {
- RuntimeCommandError::input(format!(
- "failed to read backup raw events file `{}`: {error}",
- raw_events_path.display()
- ))
- })?;
- let raw_events_sha256 = sha256_hex(raw_events.as_bytes());
- if raw_events_sha256 != manifest.raw_events.sha256 {
- return Err(RuntimeCommandError::input(format!(
- "backup raw events checksum mismatch: expected {}, got {}",
- manifest.raw_events.sha256, raw_events_sha256
- )));
- }
- let events = parse_event_import_document(&raw_events)?;
- if events.len() as u64 != manifest.raw_events.count {
- return Err(RuntimeCommandError::input(format!(
- "backup raw events count mismatch: expected {}, got {}",
- manifest.raw_events.count,
- events.len()
- )));
- }
- let import_report = import_events_into_store(config, store, events).await?;
- let rebuild_report = rebuild_projections_in_store(config, store).await?;
- Ok(RuntimeRestoreReport::new(
- input_dir.to_path_buf(),
- manifest.raw_events.count,
- raw_events_sha256,
- import_report,
- rebuild_report,
- ))
-}
-
-fn validate_backup_manifest(
- manifest: &RuntimeBackupManifestDocument,
-) -> Result<(), RuntimeCommandError> {
- if manifest.format != "tangle-backup-v1" {
- return Err(RuntimeCommandError::input(format!(
- "backup manifest format is unsupported: {}",
- manifest.format
- )));
- }
- if manifest.raw_events.path.trim().is_empty() {
- return Err(RuntimeCommandError::input(
- "backup manifest raw_events.path must not be empty",
- ));
- }
- Ok(())
-}
-
-fn backup_artifact_path(
- input_dir: &FsPath,
- artifact: &str,
-) -> Result<PathBuf, RuntimeCommandError> {
- let path = FsPath::new(artifact);
- if path.is_absolute()
- || path
- .components()
- .any(|component| matches!(component, Component::ParentDir))
- {
- return Err(RuntimeCommandError::input(
- "backup manifest artifact paths must be relative to the backup directory",
- ));
- }
- Ok(input_dir.join(path))
-}
-
-fn runtime_row_string(
- row: &serde_json::Value,
- field: &'static str,
-) -> Result<String, RuntimeCommandError> {
- row.get(field)
- .and_then(serde_json::Value::as_str)
- .map(str::to_owned)
- .ok_or_else(|| RuntimeCommandError::store(format!("stored row field `{field}` is invalid")))
-}
-
-fn sha256_hex(bytes: &[u8]) -> String {
- Sha256::digest(bytes)
- .iter()
- .map(|byte| format!("{byte:02x}"))
- .collect()
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct RuntimeProjectionRebuildReport {
- scanned: u64,
- rebuilt: u64,
- projected: u64,
- skipped: u64,
-}
-
-impl RuntimeProjectionRebuildReport {
- pub fn new(scanned: u64, rebuilt: u64, projected: u64, skipped: u64) -> Self {
- Self {
- scanned,
- rebuilt,
- projected,
- skipped,
- }
- }
-
- pub fn scanned(self) -> u64 {
- self.scanned
- }
-
- pub fn rebuilt(self) -> u64 {
- self.rebuilt
- }
-
- pub fn projected(self) -> u64 {
- self.projected
- }
-
- pub fn skipped(self) -> u64 {
- self.skipped
- }
-
- fn record(&mut self, outcome: RuntimeProjectionRebuildOutcome) {
- self.scanned += 1;
- match outcome {
- RuntimeProjectionRebuildOutcome::Rebuilt { projected } => {
- self.rebuilt += 1;
- if projected {
- self.projected += 1;
- }
- }
- RuntimeProjectionRebuildOutcome::Skipped => {
- self.skipped += 1;
- }
- }
- }
-}
-
-impl Default for RuntimeProjectionRebuildReport {
- fn default() -> Self {
- Self::new(0, 0, 0, 0)
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum RuntimeProjectionRebuildOutcome {
- Rebuilt { projected: bool },
- Skipped,
-}
-
-pub async fn rebuild_projections(
- config: &TangleRuntimeConfig,
-) -> Result<RuntimeProjectionRebuildReport, RuntimeCommandError> {
- tracing::info!("starting projection rebuild");
- let store = connect_runtime_store(config).await?;
- let report = rebuild_projections_in_store(config, &store).await?;
- tracing::info!("finished projection rebuild");
- Ok(report)
-}
-
-async fn rebuild_projections_in_store(
- config: &TangleRuntimeConfig,
- store: &SurrealStore,
-) -> Result<RuntimeProjectionRebuildReport, RuntimeCommandError> {
- store
- .apply_plan(&base_migration_plan())
- .await
- .map_err(|error| RuntimeCommandError::store(error.to_string()))?;
- let rows = store
- .query_raw_events(&Filter::empty())
- .await
- .map_err(|error| RuntimeCommandError::store(error.to_string()))?;
- let validator = EventValidator::new(
- config.limits(),
- config
- .admission_policy()
- .clone()
- .with_write_auth_required(false),
- );
- let now = now_timestamp();
- let mut report = RuntimeProjectionRebuildReport::default();
- for row in rows {
- let raw = RawEventJson::new(&runtime_row_string(&row, "raw_json")?)
- .map_err(|error| RuntimeCommandError::store(error.to_string()))?;
- let event = parse_event_json(&raw)
- .map_err(|error| RuntimeCommandError::store(error.to_string()))?;
- let outcome = rebuild_single_event_projection(store, &validator, event, now).await?;
- report.record(outcome);
- }
- Ok(report)
-}
-
-async fn rebuild_single_event_projection(
- store: &SurrealStore,
- validator: &EventValidator,
- event: Event,
- now: UnixTimestamp,
-) -> Result<RuntimeProjectionRebuildOutcome, RuntimeCommandError> {
- if is_non_auth_ephemeral(&event) {
- return Ok(RuntimeProjectionRebuildOutcome::Skipped);
- }
- let validated = match validator.validate(&event, &AdmissionContext::unauthenticated(), now) {
- Ok(validated) => validated,
- Err(_) => return Ok(RuntimeProjectionRebuildOutcome::Skipped),
- };
- if validated.admission().effect() != AdmissionEffect::AuthenticateOnly {
- let projected =
- project_stored_event(store, &event, validated.admission().effect(), now).await?;
- return Ok(RuntimeProjectionRebuildOutcome::Rebuilt { projected });
- }
- Ok(RuntimeProjectionRebuildOutcome::Skipped)
-}
-
-fn is_non_auth_ephemeral(event: &Event) -> bool {
- event.unsigned().kind().is_ephemeral() && event.unsigned().kind().as_u32() != 22_242
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct RuntimeServerReport {
- listen_addr: SocketAddr,
-}
-
-impl RuntimeServerReport {
- pub fn new(listen_addr: SocketAddr) -> Self {
- Self { listen_addr }
- }
-
- pub fn listen_addr(self) -> SocketAddr {
- self.listen_addr
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct RuntimeServer {
- config: TangleRuntimeConfig,
- shutdown_signal: GracefulShutdownSignal,
-}
-
-impl RuntimeServer {
- pub fn new(config: TangleRuntimeConfig, shutdown_signal: GracefulShutdownSignal) -> Self {
- Self {
- config,
- shutdown_signal,
- }
- }
-
- pub async fn run(&self) -> Result<RuntimeServerReport, RuntimeCommandError> {
- tracing::info!("starting runtime server");
- let store = connect_runtime_store(&self.config).await?;
- store
- .apply_plan(&base_migration_plan())
- .await
- .map_err(|error| RuntimeCommandError::store(error.to_string()))?;
- let listener = TcpListener::bind(self.config.listen_addr())
- .await
- .map_err(|error| RuntimeCommandError::store(format!("listen failed: {error}")))?;
- let listen_addr = listener
- .local_addr()
- .map_err(|error| RuntimeCommandError::store(format!("listen addr failed: {error}")))?;
- let mut shutdown = self.shutdown_signal.subscribe();
- let app = runtime_router(self.config.clone(), store, self.shutdown_signal.clone());
- axum::serve(listener, app)
- .with_graceful_shutdown(async move {
- shutdown.wait_for_shutdown().await;
- })
- .await
- .map_err(|error| RuntimeCommandError::store(format!("server failed: {error}")))?;
- tracing::info!("runtime server stopped");
- Ok(RuntimeServerReport::new(listen_addr))
- }
-}
-
-pub async fn run_runtime_server(
- config: TangleRuntimeConfig,
- shutdown_signal: GracefulShutdownSignal,
-) -> Result<RuntimeServerReport, RuntimeCommandError> {
- RuntimeServer::new(config, shutdown_signal).run().await
-}
-
-async fn connect_runtime_store(
- config: &TangleRuntimeConfig,
-) -> Result<SurrealStore, RuntimeCommandError> {
- SurrealStore::connect(config.database_config())
- .await
- .map_err(|error| RuntimeCommandError::store(error.to_string()))
-}
-
-#[derive(Clone)]
-struct RuntimeRelayState {
- config: TangleRuntimeConfig,
- store: SurrealStore,
- shutdown_signal: GracefulShutdownSignal,
- event_tx: broadcast::Sender<Event>,
- next_connection_id: Arc<AtomicU64>,
-}
-
-impl RuntimeRelayState {
- fn new(
- config: TangleRuntimeConfig,
- store: SurrealStore,
- shutdown_signal: GracefulShutdownSignal,
- ) -> Self {
- let (event_tx, _) = broadcast::channel(config.limits().values().live_event_buffer as usize);
- Self {
- config,
- store,
- shutdown_signal,
- event_tx,
- next_connection_id: Arc::new(AtomicU64::new(1)),
- }
- }
-
- fn next_connection(&self) -> RelayConnection {
- let id = self.next_connection_id.fetch_add(1, Ordering::Relaxed);
- RelayConnection::new(
- RelayConnectionId::new(&format!("conn-{id}"))
- .expect("generated connection id is valid"),
- self.config.relay_connection_config().clone(),
- )
- }
-
- fn validator(&self) -> EventValidator {
- EventValidator::new(self.config.limits(), self.config.admission_policy().clone())
- }
-}
-
-fn runtime_router(
- config: TangleRuntimeConfig,
- store: SurrealStore,
- shutdown_signal: GracefulShutdownSignal,
-) -> Router {
- let state = RuntimeRelayState::new(config, store, shutdown_signal);
- Router::new()
- .route("/", get(runtime_relay_info))
- .route("/ws", get(runtime_websocket_upgrade))
- .route("/healthz", get(runtime_healthz))
- .route("/readyz", get(runtime_readyz))
- .route("/metrics", get(runtime_metrics))
- .route("/api/listings", get(runtime_listings))
- .route("/api/listings/{pubkey}/{d}", get(runtime_listing_detail))
- .route(
- "/api/listings/{pubkey}/{d}/comments",
- get(runtime_listing_comments),
- )
- .route(
- "/api/listings/{pubkey}/{d}/reactions",
- get(runtime_listing_reactions),
- )
- .route("/api/forum/threads", get(runtime_forum_threads))
- .route(
- "/api/forum/threads/{event_id}",
- get(runtime_forum_thread_detail),
- )
- .route(
- "/api/forum/threads/{event_id}/comments",
- get(runtime_forum_thread_comments),
- )
- .route("/api/search", get(runtime_marketplace_search))
- .route("/api/sellers/{pubkey}", get(runtime_seller_detail))
- .route(
- "/api/admin/sellers/{pubkey}/approve",
- post(runtime_admin_approve_seller),
- )
- .route(
- "/api/admin/pubkeys/{pubkey}/block",
- post(runtime_admin_block_pubkey),
- )
- .route(
- "/api/admin/events/{event_id}/hide",
- post(runtime_admin_hide_event),
- )
- .route(
- "/api/admin/events/{event_id}/unhide",
- post(runtime_admin_unhide_event),
- )
- .route(
- "/api/admin/moderation/labels",
- get(runtime_admin_moderation_labels),
- )
- .route(
- "/api/admin/moderation/reports",
- get(runtime_admin_moderation_reports),
- )
- .with_state(state)
-}
-
-async fn runtime_relay_info(headers: HeaderMap) -> Response {
- relay_info(State(RelayInfoDocument::tangle_default()), headers).await
-}
-
-async fn runtime_websocket_upgrade(
- State(state): State<RuntimeRelayState>,
- websocket: WebSocketUpgrade,
-) -> Response {
- websocket
- .on_upgrade(move |socket| async move {
- handle_websocket(socket, state).await;
- })
- .into_response()
-}
-
-async fn runtime_healthz() -> Json<HealthDocument> {
- healthz().await
-}
-
-async fn runtime_readyz(
- State(state): State<RuntimeRelayState>,
-) -> (StatusCode, Json<ReadinessDocument>) {
- readyz(State(runtime_readiness_state(&state.store).await)).await
-}
-
-async fn runtime_readiness_state(store: &SurrealStore) -> ReadinessState {
- let database = readiness_status(store.ping().await);
- let migrations =
- readiness_status_after(database.is_ready(), runtime_migrations_ready(store)).await;
- let repository = readiness_status_after(database.is_ready() && migrations.is_ready(), async {
- store.metrics_snapshot().await.map(|_| ())
- })
- .await;
- ReadinessState::new(database, migrations, repository)
-}
-
-async fn runtime_migrations_ready(store: &SurrealStore) -> Result<(), RuntimeCommandError> {
- let applied = store
- .applied_migrations()
- .await
- .map_err(|error| RuntimeCommandError::store(error.to_string()))?;
- let plan = base_migration_plan();
- if applied.len() != plan.migrations().len() {
- return Err(RuntimeCommandError::store(
- "runtime migrations are incomplete",
- ));
- }
- for (applied, expected) in applied.iter().zip(plan.migrations()) {
- if applied.name() != expected.name() || applied.checksum() != expected.checksum() {
- return Err(RuntimeCommandError::store(
- "runtime migrations do not match",
- ));
- }
- }
- Ok(())
-}
-
-fn readiness_status<E>(result: Result<(), E>) -> ReadinessCheckStatus {
- if result.is_ok() {
- ReadinessCheckStatus::Ready
- } else {
- ReadinessCheckStatus::NotReady
- }
-}
-
-async fn readiness_status_after<F, E>(dependencies_ready: bool, result: F) -> ReadinessCheckStatus
-where
- F: Future<Output = Result<(), E>>,
-{
- if dependencies_ready {
- readiness_status(result.await)
- } else {
- ReadinessCheckStatus::NotReady
- }
-}
-
-async fn runtime_metrics(State(state): State<RuntimeRelayState>) -> Result<Response, ApiError> {
- metrics(State(MetricsHttpState::new(state.store))).await
-}
-
-async fn runtime_listings(
- State(state): State<RuntimeRelayState>,
- RawQuery(query): RawQuery,
-) -> Result<Json<ListingsDocument>, ApiError> {
- listings(
- State(ListingsHttpState::new(
- state.store.clone(),
- state.config.limits(),
- )),
- RawQuery(query),
- )
- .await
-}
-
-async fn runtime_listing_detail(
- State(state): State<RuntimeRelayState>,
- Path((pubkey, d)): Path<(String, String)>,
-) -> Result<Json<ListingDetailDocument>, ApiError> {
- listing_detail(
- State(ListingsHttpState::new(
- state.store.clone(),
- state.config.limits(),
- )),
- Path((pubkey, d)),
- )
- .await
-}
-
-async fn runtime_listing_comments(
- State(state): State<RuntimeRelayState>,
- Path((pubkey, d)): Path<(String, String)>,
- RawQuery(query): RawQuery,
-) -> Result<Json<ListingCommentsDocument>, ApiError> {
- listing_comments(
- State(ListingsHttpState::new(
- state.store.clone(),
- state.config.limits(),
- )),
- Path((pubkey, d)),
- RawQuery(query),
- )
- .await
-}
-
-async fn runtime_listing_reactions(
- State(state): State<RuntimeRelayState>,
- Path((pubkey, d)): Path<(String, String)>,
-) -> Result<Json<ReactionCountsDocument>, ApiError> {
- listing_reactions(
- State(ListingsHttpState::new(
- state.store.clone(),
- state.config.limits(),
- )),
- Path((pubkey, d)),
- )
- .await
-}
-
-async fn runtime_forum_threads(
- State(state): State<RuntimeRelayState>,
- RawQuery(query): RawQuery,
-) -> Result<Json<ForumThreadsDocument>, ApiError> {
- forum_threads(
- State(ListingsHttpState::new(
- state.store.clone(),
- state.config.limits(),
- )),
- RawQuery(query),
- )
- .await
-}
-
-async fn runtime_forum_thread_detail(
- State(state): State<RuntimeRelayState>,
- Path(event_id): Path<String>,
-) -> Result<Json<ForumThreadDetailDocument>, ApiError> {
- forum_thread_detail(
- State(ListingsHttpState::new(
- state.store.clone(),
- state.config.limits(),
- )),
- Path(event_id),
- )
- .await
-}
-
-async fn runtime_forum_thread_comments(
- State(state): State<RuntimeRelayState>,
- Path(event_id): Path<String>,
- RawQuery(query): RawQuery,
-) -> Result<Json<ListingCommentsDocument>, ApiError> {
- forum_thread_comments(
- State(ListingsHttpState::new(
- state.store.clone(),
- state.config.limits(),
- )),
- Path(event_id),
- RawQuery(query),
- )
- .await
-}
-
-async fn runtime_marketplace_search(
- State(state): State<RuntimeRelayState>,
- RawQuery(query): RawQuery,
-) -> Result<Json<ListingsDocument>, ApiError> {
- marketplace_search(
- State(ListingsHttpState::new(
- state.store.clone(),
- state.config.limits(),
- )),
- RawQuery(query),
- )
- .await
-}
-
-async fn runtime_seller_detail(
- State(state): State<RuntimeRelayState>,
- Path(pubkey): Path<String>,
-) -> Result<Json<SellerDocument>, ApiError> {
- seller_detail(
- State(ListingsHttpState::new(
- state.store.clone(),
- state.config.limits(),
- )),
- Path(pubkey),
- )
- .await
-}
-
-async fn runtime_admin_approve_seller(
- State(state): State<RuntimeRelayState>,
- headers: HeaderMap,
- Path(pubkey): Path<String>,
-) -> Result<Json<AdminPolicyDocument>, ApiError> {
- let _admin = require_admin_pubkey(&state.config, &headers)?;
- let pubkey = parse_pubkey("pubkey", &pubkey)?;
- state
- .store
- .set_seller_approved(pubkey.as_str(), true, now_timestamp())
- .await
- .map_err(|_| ApiError::internal())?;
- Ok(Json(AdminPolicyDocument::new(
- "approved",
- "seller",
- pubkey.as_str(),
- )))
-}
-
-async fn runtime_admin_block_pubkey(
- State(state): State<RuntimeRelayState>,
- headers: HeaderMap,
- Path(pubkey): Path<String>,
-) -> Result<Json<AdminPolicyDocument>, ApiError> {
- let _admin = require_admin_pubkey(&state.config, &headers)?;
- let pubkey = parse_pubkey("pubkey", &pubkey)?;
- state
- .store
- .set_pubkey_blocked(pubkey.as_str(), true, now_timestamp())
- .await
- .map_err(|_| ApiError::internal())?;
- Ok(Json(AdminPolicyDocument::new(
- "blocked",
- "pubkey",
- pubkey.as_str(),
- )))
-}
-
-async fn runtime_admin_hide_event(
- State(state): State<RuntimeRelayState>,
- headers: HeaderMap,
- Path(event_id): Path<String>,
- Json(request): Json<AdminEventPolicyRequest>,
-) -> Result<Json<AdminPolicyDocument>, ApiError> {
- let admin = require_admin_pubkey(&state.config, &headers)?;
- let event_id = EventId::new(&event_id)
- .map_err(|_| invalid_parameter("event_id", "must be a 64-character hex event id"))?;
- let reason = request.reason.unwrap_or_else(|| "admin policy".to_owned());
- let outcome = state
- .store
- .hide_event(
- &event_id,
- &reason,
- "admin_api",
- admin.as_str(),
- now_timestamp(),
- )
- .await
- .map_err(|_| ApiError::internal())?;
- if outcome == tangle_store_surreal::HiddenEventOutcome::NotFound {
- return Err(ApiError::not_found("event not found"));
- }
- Ok(Json(AdminPolicyDocument::new(
- "hidden",
- "event",
- event_id.as_str(),
- )))
-}
-
-async fn runtime_admin_unhide_event(
- State(state): State<RuntimeRelayState>,
- headers: HeaderMap,
- Path(event_id): Path<String>,
- Json(request): Json<AdminEventPolicyRequest>,
-) -> Result<Json<AdminPolicyDocument>, ApiError> {
- let admin = require_admin_pubkey(&state.config, &headers)?;
- let event_id = EventId::new(&event_id)
- .map_err(|_| invalid_parameter("event_id", "must be a 64-character hex event id"))?;
- let reason = request.reason.unwrap_or_else(|| "admin policy".to_owned());
- let outcome = state
- .store
- .unhide_event(&event_id, &reason, admin.as_str(), now_timestamp())
- .await
- .map_err(|_| ApiError::internal())?;
- if outcome == tangle_store_surreal::HiddenEventOutcome::NotFound {
- return Err(ApiError::not_found("event not found"));
- }
- Ok(Json(AdminPolicyDocument::new(
- "unhidden",
- "event",
- event_id.as_str(),
- )))
-}
-
-async fn runtime_admin_moderation_labels(
- State(state): State<RuntimeRelayState>,
- headers: HeaderMap,
- RawQuery(query): RawQuery,
-) -> Result<Json<ModerationLabelsDocument>, ApiError> {
- let _admin = require_admin_pubkey(&state.config, &headers)?;
- let query = label_projection_query(query.as_deref().unwrap_or_default())?;
- let rows = state
- .store
- .query_label_projections(&query)
- .await
- .map_err(|_| ApiError::internal())?;
- let items = rows
- .iter()
- .map(moderation_label_document)
- .collect::<Result<Vec<_>, _>>()?;
- Ok(Json(ModerationLabelsDocument {
- items,
- next_cursor: None,
- }))
-}
-
-async fn runtime_admin_moderation_reports(
- State(state): State<RuntimeRelayState>,
- headers: HeaderMap,
- RawQuery(query): RawQuery,
-) -> Result<Json<ModerationReportsDocument>, ApiError> {
- let _admin = require_admin_pubkey(&state.config, &headers)?;
- let query = report_projection_query(query.as_deref().unwrap_or_default())?;
- let rows = state
- .store
- .query_report_projections(&query)
- .await
- .map_err(|_| ApiError::internal())?;
- let items = rows
- .iter()
- .map(moderation_report_document)
- .collect::<Result<Vec<_>, _>>()?;
- Ok(Json(ModerationReportsDocument {
- items,
- next_cursor: None,
- }))
-}
-
-async fn handle_websocket(mut socket: WebSocket, state: RuntimeRelayState) {
- let mut shutdown = state.shutdown_signal.subscribe();
- let mut event_rx = state.event_tx.subscribe();
- let mut loop_state = ClientMessageLoop::new(state.next_connection());
- let event_handler = EventMessageHandler::new(state.store.clone(), state.validator())
- .with_durable_write_rate_limit(state.config.durable_write_rate_limit());
- let auth_handler = AuthMessageHandler;
- let req_handler = ReqMessageHandler::new(
- state.store.clone(),
- NostrFilterCompiler::new(state.config.limits()),
- );
- let close_handler = CloseMessageHandler;
- let fanout = LiveEventFanout;
- let challenge = auth_handler.issue_challenge(
- loop_state.connection_mut(),
- "challenge-001",
- UnixTimestamp::new(1_714_124_430),
- );
- let _ = send_relay_message(&mut socket, &challenge).await;
- loop {
- tokio::select! {
- _ = shutdown.wait_for_shutdown() => {
- let _ = socket.send(Message::Close(None)).await;
- break;
- }
- event = event_rx.recv() => {
- let messages = event
- .ok()
- .into_iter()
- .flat_map(|event| fanout.fanout(loop_state.connection(), &event));
- for message in messages {
- let fanout_failed = send_relay_message(&mut socket, &message).await.is_err();
- if fanout_failed { return; }
- }
- }
- frame = socket.recv() => {
- let Some(frame) = frame else { break; };
- let Ok(frame) = frame else { break; };
- match loop_state.handle_frame_at(client_frame_from_message(frame), now_timestamp()) {
- ClientFrameOutcome::Message(message) => {
- let message_failed = handle_client_message(
- &mut socket,
- &mut loop_state,
- ClientMessageHandlers {
- event: &event_handler,
- auth: &auth_handler,
- req: &req_handler,
- close: &close_handler,
- event_tx: &state.event_tx,
- },
- message,
- )
- .await
- .is_err();
- if message_failed { break; }
- }
- ClientFrameOutcome::Reject(message) => {
- let reject_failed = send_relay_message(&mut socket, &message).await.is_err();
- if reject_failed { break; }
- }
- ClientFrameOutcome::Ignore => {}
- ClientFrameOutcome::Close => break,
- }
- }
- }
- }
-}
-
-#[derive(Clone, Copy)]
-struct ClientMessageHandlers<'a> {
- event: &'a EventMessageHandler,
- auth: &'a AuthMessageHandler,
- req: &'a ReqMessageHandler,
- close: &'a CloseMessageHandler,
- event_tx: &'a broadcast::Sender<Event>,
-}
-
-async fn handle_client_message(
- socket: &mut WebSocket,
- loop_state: &mut ClientMessageLoop,
- handlers: ClientMessageHandlers<'_>,
- message: ClientMessage,
-) -> Result<(), axum::Error> {
- match message {
- ClientMessage::Event(event) => {
- let accepted_event = event.clone();
- let response = handlers
- .event
- .handle_event(
- loop_state.connection(),
- event,
- now_timestamp(),
- now_timestamp(),
- )
- .await;
- let accepted = matches!(response, RelayMessage::Ok { accepted: true, .. });
- send_relay_message(socket, &response).await?;
- if accepted {
- let _ = handlers.event_tx.send(accepted_event);
- }
- }
- ClientMessage::Auth(event) => {
- let response = handlers.auth.handle_auth(
- loop_state.connection_mut(),
- event.clone(),
- event.unsigned().created_at(),
- );
- send_relay_message(socket, &response).await?;
- }
- ClientMessage::Req {
- subscription_id,
- filters,
- } => {
- for response in handlers
- .req
- .handle_req(loop_state.connection_mut(), subscription_id, filters)
- .await
- {
- send_relay_message(socket, &response).await?;
- }
- }
- ClientMessage::Count {
- subscription_id,
- filters,
- } => {
- let response = handlers.req.handle_count(subscription_id, filters).await;
- send_relay_message(socket, &response).await?;
- }
- ClientMessage::Close(subscription_id) => {
- handlers
- .close
- .handle_close(loop_state.connection_mut(), &subscription_id);
- }
- }
- Ok(())
-}
-
-fn client_frame_from_message(message: Message) -> ClientFrame {
- match message {
- Message::Text(value) => ClientFrame::Text(value.to_string()),
- Message::Binary(value) => ClientFrame::Binary(value.to_vec()),
- Message::Ping(value) => ClientFrame::Ping(value.to_vec()),
- Message::Pong(value) => ClientFrame::Pong(value.to_vec()),
- Message::Close(_) => ClientFrame::Close,
- }
-}
-
-async fn send_relay_message(
- socket: &mut WebSocket,
- message: &RelayMessage,
-) -> Result<(), axum::Error> {
- socket.send(Message::Text(message.encode().into())).await
-}
-
-fn now_timestamp() -> UnixTimestamp {
- UnixTimestamp::new(
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map(|duration| duration.as_secs())
- .unwrap_or(0),
- )
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
-struct RuntimeConfigDocument {
- server: RuntimeServerConfigDocument,
- database: RuntimeDatabaseConfigDocument,
- auth: RuntimeAuthConfigDocument,
- limits: RuntimeLimitsConfigDocument,
- #[serde(default)]
- policy: RuntimePolicyConfigDocument,
- #[serde(default)]
- observability: RuntimeObservabilityConfigDocument,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
-struct RuntimeServerConfigDocument {
- listen_addr: String,
- relay_url: String,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
-struct RuntimeDatabaseConfigDocument {
- mode: RuntimeDatabaseModeDocument,
- endpoint: Option<String>,
- path: Option<String>,
- username: Option<String>,
- password: Option<String>,
- namespace: String,
- database: String,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "snake_case")]
-enum RuntimeDatabaseModeDocument {
- Memory,
- RocksDb,
- Http,
- #[serde(alias = "websocket")]
- WebSocket,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
-struct RuntimeAuthConfigDocument {
- challenge_ttl_seconds: u64,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
-struct RuntimeLimitsConfigDocument {
- message_rate_limit: RuntimeRateLimitConfigDocument,
- #[serde(default)]
- runtime: RuntimeLimitValuesDocument,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
-struct RuntimeRateLimitConfigDocument {
- limit: u64,
- window_seconds: u64,
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
-struct RuntimeLimitValuesDocument {
- max_event_bytes: Option<u64>,
- max_content_bytes: Option<u64>,
- max_tags_per_event: Option<u64>,
- max_tag_values_per_tag: Option<u64>,
- max_tag_value_bytes: Option<u64>,
- max_filters_per_subscription: Option<u64>,
- max_subscriptions_per_connection: Option<u64>,
- max_search_query_bytes: Option<u64>,
- max_search_tokens: Option<u64>,
- max_filter_complexity: Option<u64>,
- max_future_seconds: Option<u64>,
- live_event_buffer: Option<u64>,
- pending_store_events: Option<u64>,
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
-struct RuntimePolicyConfigDocument {
- require_write_auth: Option<bool>,
- unapproved_seller_action: Option<RuntimeUnapprovedSellerActionDocument>,
- write_rate_limit: Option<RuntimeRateLimitConfigDocument>,
- #[serde(default)]
- admin_pubkeys: Vec<String>,
- #[serde(default)]
- approved_sellers: Vec<String>,
- #[serde(default)]
- blocked_pubkeys: Vec<String>,
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
-struct RuntimeObservabilityConfigDocument {
- #[serde(default)]
- tracing: RuntimeTracingConfigDocument,
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
-struct RuntimeTracingConfigDocument {
- enabled: Option<bool>,
- filter: Option<String>,
- format: Option<RuntimeTracingFormatDocument>,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "snake_case")]
-enum RuntimeTracingFormatDocument {
- Compact,
- Json,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "snake_case")]
-enum RuntimeUnapprovedSellerActionDocument {
- StoreRawOnly,
- RejectWrite,
-}
-
-fn runtime_config_from_document(
- document: RuntimeConfigDocument,
-) -> Result<TangleRuntimeConfig, RuntimeConfigError> {
- let listen_addr = document
- .server
- .listen_addr
- .parse::<SocketAddr>()
- .map_err(|error| {
- RuntimeConfigError::invalid(format!("server.listen_addr is invalid: {error}"))
- })?;
- let limits = runtime_limits_from_document(document.limits)?;
- let relay_connection = RelayConnectionConfig::new(
- document.server.relay_url,
- document.auth.challenge_ttl_seconds,
- limits.message_rate_limit,
- limits.runtime,
- )
- .map_err(RuntimeConfigError::invalid)?;
- let database = database_config_from_document(document.database)?;
- let durable_write_rate_limit = durable_write_rate_limit_from_document(&document.policy)?;
- let admin_pubkeys = admin_pubkeys_from_document(&document.policy)?;
- let admission_policy = admission_policy_from_document(&document.policy)?;
- let tracing = tracing_config_from_document(document.observability.tracing)?;
- Ok(TangleRuntimeConfig {
- listen_addr,
- relay_connection,
- database,
- admission_policy,
- durable_write_rate_limit,
- admin_pubkeys,
- limits: limits.runtime,
- tracing,
- })
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-struct ResolvedRuntimeLimits {
- message_rate_limit: RateLimitConfig,
- runtime: RuntimeLimits,
-}
-
-fn runtime_limits_from_document(
- document: RuntimeLimitsConfigDocument,
-) -> Result<ResolvedRuntimeLimits, RuntimeConfigError> {
- let message_rate_limit = RateLimitConfig::new(
- document.message_rate_limit.limit,
- document.message_rate_limit.window_seconds,
- )
- .map_err(|error| RuntimeConfigError::invalid(error.to_string()))?;
- let runtime = RuntimeLimits::from_values(document.runtime.apply(RuntimeLimitValues::default()))
- .map_err(|error| RuntimeConfigError::invalid(error.to_string()))?;
- Ok(ResolvedRuntimeLimits {
- message_rate_limit,
- runtime,
- })
-}
-
-fn database_config_from_document(
- document: RuntimeDatabaseConfigDocument,
-) -> Result<SurrealConnectionConfig, RuntimeConfigError> {
- match document.mode {
- RuntimeDatabaseModeDocument::Memory => {
- if document.endpoint.is_some() {
- return Err(RuntimeConfigError::invalid(
- "database.endpoint must be omitted for memory mode",
- ));
- }
- if document.path.is_some() {
- return Err(RuntimeConfigError::invalid(
- "database.path must be omitted for memory mode",
- ));
- }
- if document.username.is_some() || document.password.is_some() {
- return Err(RuntimeConfigError::invalid(
- "database credentials must be omitted for memory mode",
- ));
- }
- SurrealConnectionConfig::memory(&document.namespace, &document.database)
- }
- RuntimeDatabaseModeDocument::RocksDb => {
- if document.endpoint.is_some() {
- return Err(RuntimeConfigError::invalid(
- "database.endpoint must be omitted for rocksdb mode",
- ));
- }
- if document.username.is_some() || document.password.is_some() {
- return Err(RuntimeConfigError::invalid(
- "database credentials must be omitted for rocksdb mode",
- ));
- }
- SurrealConnectionConfig::rocksdb(
- &required_path(document.path, "rocksdb")?,
- &document.namespace,
- &document.database,
- )
- }
- RuntimeDatabaseModeDocument::Http => {
- let endpoint = required_endpoint(document.endpoint, "http")?;
- let username = required_database_credential(document.username, "username", "http")?;
- let password = required_database_credential(document.password, "password", "http")?;
- SurrealConnectionConfig::http(&endpoint, &document.namespace, &document.database)
- .and_then(|config| config.with_root_credentials(&username, &password))
- }
- RuntimeDatabaseModeDocument::WebSocket => {
- let endpoint = required_endpoint(document.endpoint, "websocket")?;
- let username =
- required_database_credential(document.username, "username", "websocket")?;
- let password =
- required_database_credential(document.password, "password", "websocket")?;
- SurrealConnectionConfig::websocket(&endpoint, &document.namespace, &document.database)
- .and_then(|config| config.with_root_credentials(&username, &password))
- }
- }
- .map_err(|error| RuntimeConfigError::invalid(error.to_string()))
-}
-
-fn required_endpoint(value: Option<String>, mode: &str) -> Result<String, RuntimeConfigError> {
- value.ok_or_else(|| {
- RuntimeConfigError::invalid(format!("database.endpoint is required for {mode} mode"))
- })
-}
-
-fn required_path(value: Option<String>, mode: &str) -> Result<String, RuntimeConfigError> {
- value.ok_or_else(|| {
- RuntimeConfigError::invalid(format!("database.path is required for {mode} mode"))
- })
-}
-
-fn required_database_credential(
- value: Option<String>,
- field: &str,
- mode: &str,
-) -> Result<String, RuntimeConfigError> {
- value.ok_or_else(|| {
- RuntimeConfigError::invalid(format!("database.{field} is required for {mode} mode"))
- })
-}
-
-fn durable_write_rate_limit_from_document(
- document: &RuntimePolicyConfigDocument,
-) -> Result<Option<RateLimitConfig>, RuntimeConfigError> {
- document
- .write_rate_limit
- .as_ref()
- .map(|value| {
- RateLimitConfig::new(value.limit, value.window_seconds)
- .map_err(|error| RuntimeConfigError::invalid(error.to_string()))
- })
- .transpose()
-}
-
-fn tracing_config_from_document(
- document: RuntimeTracingConfigDocument,
-) -> Result<RuntimeTracingConfig, RuntimeConfigError> {
- let default = RuntimeTracingConfig::disabled();
- let format = match document.format {
- Some(RuntimeTracingFormatDocument::Compact) => RuntimeTracingFormat::Compact,
- Some(RuntimeTracingFormatDocument::Json) => RuntimeTracingFormat::Json,
- None => default.format(),
- };
- RuntimeTracingConfig::new(
- document.enabled.unwrap_or(default.enabled()),
- document
- .filter
- .unwrap_or_else(|| default.filter().to_owned()),
- format,
- )
-}
-
-fn admin_pubkeys_from_document(
- document: &RuntimePolicyConfigDocument,
-) -> Result<BTreeSet<PublicKeyHex>, RuntimeConfigError> {
- document
- .admin_pubkeys
- .iter()
- .map(|pubkey| {
- PublicKeyHex::new(pubkey.as_str()).map_err(|error| {
- RuntimeConfigError::invalid(format!(
- "policy.admin_pubkeys contains invalid pubkey: {error}"
- ))
- })
- })
- .collect()
-}
-
-fn admission_policy_from_document(
- document: &RuntimePolicyConfigDocument,
-) -> Result<AdmissionPolicy, RuntimeConfigError> {
- let action = match document.unapproved_seller_action {
- Some(RuntimeUnapprovedSellerActionDocument::StoreRawOnly) | None => {
- UnapprovedSellerAction::StoreRawOnly
- }
- Some(RuntimeUnapprovedSellerActionDocument::RejectWrite) => {
- UnapprovedSellerAction::RejectWrite
- }
- };
- let mut policy = AdmissionPolicy::new()
- .with_write_auth_required(document.require_write_auth.unwrap_or(true))
- .with_unapproved_seller_action(action);
- for pubkey in &document.approved_sellers {
- policy = policy.approve_seller(PublicKeyHex::new(pubkey.as_str()).map_err(|error| {
- RuntimeConfigError::invalid(format!(
- "policy.approved_sellers contains invalid pubkey: {error}"
- ))
- })?);
- }
- for pubkey in &document.blocked_pubkeys {
- policy = policy.block_pubkey(PublicKeyHex::new(pubkey.as_str()).map_err(|error| {
- RuntimeConfigError::invalid(format!(
- "policy.blocked_pubkeys contains invalid pubkey: {error}"
- ))
- })?);
- }
- Ok(policy)
-}
-
-impl RuntimeLimitValuesDocument {
- fn apply(self, mut values: RuntimeLimitValues) -> RuntimeLimitValues {
- if let Some(value) = self.max_event_bytes {
- values.max_event_bytes = value;
- }
- if let Some(value) = self.max_content_bytes {
- values.max_content_bytes = value;
- }
- if let Some(value) = self.max_tags_per_event {
- values.max_tags_per_event = value;
- }
- if let Some(value) = self.max_tag_values_per_tag {
- values.max_tag_values_per_tag = value;
- }
- if let Some(value) = self.max_tag_value_bytes {
- values.max_tag_value_bytes = value;
- }
- if let Some(value) = self.max_filters_per_subscription {
- values.max_filters_per_subscription = value;
- }
- if let Some(value) = self.max_subscriptions_per_connection {
- values.max_subscriptions_per_connection = value;
- }
- if let Some(value) = self.max_search_query_bytes {
- values.max_search_query_bytes = value;
- }
- if let Some(value) = self.max_search_tokens {
- values.max_search_tokens = value;
- }
- if let Some(value) = self.max_filter_complexity {
- values.max_filter_complexity = value;
- }
- if let Some(value) = self.max_future_seconds {
- values.max_future_seconds = value;
- }
- if let Some(value) = self.live_event_buffer {
- values.live_event_buffer = value;
- }
- if let Some(value) = self.pending_store_events {
- values.pending_store_events = value;
- }
- values
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct GracefulShutdownSignal {
- sender: tokio::sync::watch::Sender<bool>,
-}
-
-impl GracefulShutdownSignal {
- pub fn new() -> (Self, GracefulShutdownListener) {
- let (sender, receiver) = tokio::sync::watch::channel(false);
- (Self { sender }, GracefulShutdownListener { receiver })
- }
-
- pub fn subscribe(&self) -> GracefulShutdownListener {
- GracefulShutdownListener {
- receiver: self.sender.subscribe(),
- }
- }
-
- pub fn request_shutdown(&self) -> bool {
- self.sender.send(true).is_ok()
- }
-
- pub fn is_shutdown_requested(&self) -> bool {
- *self.sender.borrow()
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct GracefulShutdownListener {
- receiver: tokio::sync::watch::Receiver<bool>,
-}
-
-impl GracefulShutdownListener {
- pub fn is_shutdown_requested(&self) -> bool {
- *self.receiver.borrow()
- }
-
- pub async fn wait_for_shutdown(&mut self) {
- while !self.is_shutdown_requested() && self.receiver.changed().await.is_ok() {}
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum ClientFrame {
- Text(String),
- Binary(Vec<u8>),
- Ping(Vec<u8>),
- Pong(Vec<u8>),
- Close,
-}
-
-#[derive(Debug, Clone, PartialEq)]
-pub enum ClientFrameOutcome {
- Message(ClientMessage),
- Reject(RelayMessage),
- Ignore,
- Close,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ClientMessageLoop {
- connection: RelayConnection,
-}
-
-impl ClientMessageLoop {
- pub fn new(connection: RelayConnection) -> Self {
- Self { connection }
- }
-
- pub fn connection(&self) -> &RelayConnection {
- &self.connection
- }
-
- pub fn connection_mut(&mut self) -> &mut RelayConnection {
- &mut self.connection
- }
-
- pub fn handle_frame(&mut self, frame: ClientFrame) -> ClientFrameOutcome {
- self.handle_frame_at(frame, UnixTimestamp::new(0))
- }
-
- pub fn handle_frame_at(
- &mut self,
- frame: ClientFrame,
- now: UnixTimestamp,
- ) -> ClientFrameOutcome {
- if matches!(frame, ClientFrame::Text(_) | ClientFrame::Binary(_)) {
- let key = self.connection.id().as_str().to_owned();
- match self
- .connection
- .rate_limiter_mut()
- .check(&key, now, 1)
- .unwrap_or(RateLimitDecision::Rejected {
- retry_after_seconds: 0,
- reset_at: now,
- }) {
- RateLimitDecision::Accepted { .. } => {}
- RateLimitDecision::Rejected {
- retry_after_seconds,
- ..
- } => {
- return ClientFrameOutcome::Reject(RelayMessage::Notice(format!(
- "rate-limited: retry after {retry_after_seconds} seconds"
- )));
- }
- }
- }
- match frame {
- ClientFrame::Text(raw) => parse_client_message(&raw)
- .map(ClientFrameOutcome::Message)
- .unwrap_or_else(|error| {
- ClientFrameOutcome::Reject(RelayMessage::Notice(format!("invalid: {error}")))
- }),
- ClientFrame::Binary(_) => ClientFrameOutcome::Reject(RelayMessage::Notice(
- "unsupported: binary websocket messages are not supported".to_owned(),
- )),
- ClientFrame::Ping(_) | ClientFrame::Pong(_) => ClientFrameOutcome::Ignore,
- ClientFrame::Close => ClientFrameOutcome::Close,
- }
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct EventMessageHandler {
- store: SurrealStore,
- validator: EventValidator,
- durable_write_rate_limit: Option<RateLimitConfig>,
-}
-
-impl EventMessageHandler {
- pub fn new(store: SurrealStore, validator: EventValidator) -> Self {
- Self {
- store,
- validator,
- durable_write_rate_limit: None,
- }
- }
-
- pub fn store(&self) -> &SurrealStore {
- &self.store
- }
-
- pub fn validator(&self) -> &EventValidator {
- &self.validator
- }
-
- pub fn durable_write_rate_limit(&self) -> Option<RateLimitConfig> {
- self.durable_write_rate_limit
- }
-
- pub fn with_durable_write_rate_limit(mut self, config: Option<RateLimitConfig>) -> Self {
- self.durable_write_rate_limit = config;
- self
- }
-
- pub async fn handle_event(
- &self,
- connection: &RelayConnection,
- event: Event,
- received_at: UnixTimestamp,
- now: UnixTimestamp,
- ) -> RelayMessage {
- let event_id = event.id().clone();
- let context = admission_context(connection);
- let validated = match self.validator.validate(&event, &context, now) {
- Ok(validated) => validated,
- Err(error) => return ok_rejected(event_id, format!("invalid: {error}")),
- };
- if validated.admission().effect() == AdmissionEffect::AuthenticateOnly {
- return ok_rejected(event_id, "invalid: auth events must use AUTH".to_owned());
- }
- let effect = match self
- .effective_admission_effect(&event, validated.admission().effect())
- .await
- {
- Ok(effect) => effect,
- Err(_) => return ok_rejected(event_id, "error: policy unavailable".to_owned()),
- };
- if let Some(config) = self.durable_write_rate_limit {
- match self
- .store
- .check_durable_rate_limit(
- &durable_write_rate_limit_key(validated.author_pubkey()),
- config.limit,
- config.window_seconds,
- 1,
- now,
- )
- .await
- {
- Ok(DurableRateLimitDecision::Accepted { .. }) => {}
- Ok(DurableRateLimitDecision::Rejected {
- retry_after_seconds,
- ..
- }) => {
- return ok_rejected(
- event_id,
- format!("rate-limited: retry after {retry_after_seconds} seconds"),
- );
- }
- Err(_) => {
- return ok_rejected(event_id, "error: rate limit unavailable".to_owned());
- }
- }
- }
- if event.unsigned().kind().is_ephemeral() {
- return ok_accepted(event_id);
- }
- let raw_outcome = match self
- .store
- .store_raw_event(&StoredEvent::new(event.clone(), received_at))
- .await
- {
- Ok(outcome) => outcome,
- Err(_) => return ok_rejected(event_id, "error: store unavailable".to_owned()),
- };
- if raw_outcome == StoreEventOutcome::Duplicate {
- return ok_accepted(event_id);
- }
- if project_stored_event(&self.store, &event, effect, now)
- .await
- .is_err()
- {
- return ok_rejected(event_id, "error: projection failed".to_owned());
- }
- ok_accepted(event_id)
- }
-
- async fn effective_admission_effect(
- &self,
- event: &Event,
- fallback: AdmissionEffect,
- ) -> Result<AdmissionEffect, tangle_store_surreal::SurrealStoreError> {
- if event.unsigned().kind().as_u32() != 30_402 {
- return Ok(fallback);
- }
- let Some(row) = self
- .store
- .relay_user_row(event.unsigned().pubkey().as_str())
- .await?
- else {
- return Ok(fallback);
- };
- if row
- .get("blocked")
- .and_then(serde_json::Value::as_bool)
- .unwrap_or(false)
- {
- return Ok(AdmissionEffect::StoreRawWithoutPublicListingProjection);
- }
- if row
- .get("seller_approved")
- .and_then(serde_json::Value::as_bool)
- .unwrap_or(false)
- {
- return Ok(AdmissionEffect::StoreRawAndProjectPublicListing);
- }
- Ok(fallback)
- }
-}
-
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
-pub struct AuthMessageHandler;
-
-impl AuthMessageHandler {
- pub fn issue_challenge(
- &self,
- connection: &mut RelayConnection,
- challenge: &str,
- issued_at: UnixTimestamp,
- ) -> RelayMessage {
- match connection.auth_mut().issue_challenge(challenge, issued_at) {
- Ok(challenge) => RelayMessage::Auth(challenge.value),
- Err(error) => RelayMessage::Notice(format!("error: {error}")),
- }
- }
-
- pub fn handle_auth(
- &self,
- connection: &mut RelayConnection,
- event: Event,
- now: UnixTimestamp,
- ) -> RelayMessage {
- let event_id = event.id().clone();
- let auth = match parse_relay_auth_event(&event) {
- Ok(Some(auth)) => auth,
- Ok(None) => {
- return ok_rejected(
- event_id,
- "invalid: AUTH message must contain kind 22242".to_owned(),
- );
- }
- Err(error) => return ok_rejected(event_id, format!("invalid: {error}")),
- };
- match connection.auth_mut().authenticate(&auth, now) {
- Ok(_) => ok_accepted(event_id),
- Err(error) => ok_rejected(event_id, format!("auth-required: {error}")),
- }
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct ReqMessageHandler {
- store: SurrealStore,
- compiler: NostrFilterCompiler,
-}
-
-impl ReqMessageHandler {
- pub fn new(store: SurrealStore, compiler: NostrFilterCompiler) -> Self {
- Self { store, compiler }
- }
-
- pub fn store(&self) -> &SurrealStore {
- &self.store
- }
-
- pub fn compiler(&self) -> NostrFilterCompiler {
- self.compiler
- }
-
- pub async fn handle_req(
- &self,
- connection: &mut RelayConnection,
- subscription_id: SubscriptionId,
- filters: Vec<Filter>,
- ) -> Vec<RelayMessage> {
- let plan = match self
- .compiler
- .compile(&filters, QueryExecutionMode::HistoricalThenLive)
- {
- Ok(plan) => plan,
- Err(error) => {
- return vec![RelayMessage::Closed {
- subscription_id,
- message: format!("unsupported: {error}"),
- }];
- }
- };
- if let Err(error) = connection
- .subscriptions_mut()
- .subscribe(subscription_id.clone(), plan)
- {
- return vec![RelayMessage::Closed {
- subscription_id,
- message: format!("error: {error}"),
- }];
- }
- let events = match self.query_historical_events(&filters).await {
- Ok(events) => events,
- Err(error) => {
- return vec![RelayMessage::Closed {
- subscription_id,
- message: error.message().to_owned(),
- }];
- }
- };
- let mut messages = events
- .into_iter()
- .map(|event| RelayMessage::Event {
- subscription_id: subscription_id.clone(),
- event,
- })
- .collect::<Vec<_>>();
- messages.push(RelayMessage::Eose(subscription_id));
- messages
- }
-
- pub async fn handle_count(
- &self,
- subscription_id: SubscriptionId,
- filters: Vec<Filter>,
- ) -> RelayMessage {
- if let Err(error) = self
- .compiler
- .compile(&filters, QueryExecutionMode::Historical)
- {
- return RelayMessage::Closed {
- subscription_id,
- message: format!("unsupported: {error}"),
- };
- }
- match self.query_historical_events(&filters).await {
- Ok(events) => RelayMessage::Count {
- subscription_id,
- count: events.len() as u64,
- },
- Err(error) => RelayMessage::Closed {
- subscription_id,
- message: error.message().to_owned(),
- },
- }
- }
-
- async fn query_historical_events(&self, filters: &[Filter]) -> Result<Vec<Event>, ApiError> {
- let mut seen = BTreeSet::new();
- let mut events = Vec::new();
- for filter in filters {
- for event in self.query_single_filter_events(filter).await? {
- if seen.insert(event.id().clone()) {
- events.push(event);
- }
- }
- }
- Ok(events)
- }
-
- async fn query_single_filter_events(&self, filter: &Filter) -> Result<Vec<Event>, ApiError> {
- let rows = if filter.search().is_some() {
- self.query_search_filter_rows(filter).await?
- } else if filter.ids().is_empty()
- && filter
- .kinds()
- .iter()
- .any(|kind| kind.is_replaceable() || kind.is_addressable())
- {
- self.store
- .query_current_events(filter)
- .await
- .map_err(|_| ApiError::internal())?
- } else {
- self.store
- .query_raw_events(filter)
- .await
- .map_err(|_| ApiError::internal())?
- };
- rows.iter().map(event_from_store_row).collect()
- }
-
- async fn query_search_filter_rows(
- &self,
- filter: &Filter,
- ) -> Result<Vec<serde_json::Value>, ApiError> {
- let mut query = SearchDocumentQuery::new()
- .with_doc_type("listing")
- .with_visible(true);
- if let Some(search) = filter.search() {
- query = query.with_text(search);
- }
- if filter.kinds().len() == 1 {
- query = query.with_kind(filter.kinds()[0].as_u32());
- }
- if filter.authors().len() == 1 {
- query = query.with_pubkey(filter.authors()[0].as_str());
- }
- if let Some(limit) = filter.limit() {
- query = query.with_limit(limit);
- }
- let docs = self
- .store
- .query_search_documents(&query)
- .await
- .map_err(|_| ApiError::internal())?;
- let mut rows = Vec::new();
- for doc in docs {
- let event_id =
- EventId::new(&string_field(&doc, "event_id")?).map_err(|_| ApiError::internal())?;
- if let Some(row) = self
- .store
- .raw_event_row(&event_id)
- .await
- .map_err(|_| ApiError::internal())?
- {
- rows.push(row);
- }
- }
- Ok(rows)
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum CloseMessageOutcome {
- Closed,
- NotFound,
-}
-
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
-pub struct CloseMessageHandler;
-
-impl CloseMessageHandler {
- pub fn handle_close(
- &self,
- connection: &mut RelayConnection,
- subscription_id: &SubscriptionId,
- ) -> CloseMessageOutcome {
- match connection.subscriptions_mut().close(subscription_id) {
- SubscriptionCloseOutcome::Closed => CloseMessageOutcome::Closed,
- SubscriptionCloseOutcome::NotFound => CloseMessageOutcome::NotFound,
- }
- }
-}
-
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
-pub struct LiveEventFanout;
-
-impl LiveEventFanout {
- pub fn fanout(&self, connection: &RelayConnection, event: &Event) -> Vec<RelayMessage> {
- connection
- .subscriptions()
- .match_event(event)
- .into_iter()
- .map(|matched| RelayMessage::Event {
- subscription_id: matched.subscription_id,
- event: event.clone(),
- })
- .collect()
- }
-}
-
-fn admission_context(connection: &RelayConnection) -> AdmissionContext {
- connection
- .auth()
- .authenticated_pubkey()
- .cloned()
- .map(AdmissionContext::authenticated)
- .unwrap_or_else(AdmissionContext::unauthenticated)
-}
-
-fn durable_write_rate_limit_key(pubkey: &PublicKeyHex) -> String {
- format!("event_write:{}", pubkey.as_str())
-}
-
-fn ok_accepted(event_id: EventId) -> RelayMessage {
- RelayMessage::Ok {
- event_id,
- accepted: true,
- message: String::new(),
- }
-}
-
-fn ok_rejected(event_id: EventId, message: String) -> RelayMessage {
- RelayMessage::Ok {
- event_id,
- accepted: false,
- message,
- }
-}
-
-fn event_from_store_row(row: &serde_json::Value) -> Result<Event, ApiError> {
- let raw =
- RawEventJson::new(&string_field(row, "raw_json")?).map_err(|_| ApiError::internal())?;
- parse_event_json(&raw).map_err(|_| ApiError::internal())
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum ApiErrorCode {
- InvalidRequest,
- Unauthorized,
- Forbidden,
- NotFound,
- Conflict,
- Internal,
-}
-
-impl ApiErrorCode {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::InvalidRequest => "invalid_request",
- Self::Unauthorized => "unauthorized",
- Self::Forbidden => "forbidden",
- Self::NotFound => "not_found",
- Self::Conflict => "conflict",
- Self::Internal => "internal_error",
- }
- }
-
- pub fn http_status(self) -> u16 {
- match self {
- Self::InvalidRequest => 400,
- Self::Unauthorized => 401,
- Self::Forbidden => 403,
- Self::NotFound => 404,
- Self::Conflict => 409,
- Self::Internal => 500,
- }
- }
-}
-
-impl fmt::Display for ApiErrorCode {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.as_str())
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ApiError {
- code: ApiErrorCode,
- message: String,
-}
-
-impl ApiError {
- pub fn new(code: ApiErrorCode, message: impl Into<String>) -> Self {
- Self {
- code,
- message: message.into(),
- }
- }
-
- pub fn invalid_request(message: impl Into<String>) -> Self {
- Self::new(ApiErrorCode::InvalidRequest, message)
- }
-
- pub fn unauthorized(message: impl Into<String>) -> Self {
- Self::new(ApiErrorCode::Unauthorized, message)
- }
-
- pub fn forbidden(message: impl Into<String>) -> Self {
- Self::new(ApiErrorCode::Forbidden, message)
- }
-
- pub fn not_found(message: impl Into<String>) -> Self {
- Self::new(ApiErrorCode::NotFound, message)
- }
-
- pub fn conflict(message: impl Into<String>) -> Self {
- Self::new(ApiErrorCode::Conflict, message)
- }
-
- pub fn internal() -> Self {
- Self::new(ApiErrorCode::Internal, "internal server error")
- }
-
- pub fn code(&self) -> ApiErrorCode {
- self.code
- }
-
- pub fn message(&self) -> &str {
- &self.message
- }
-
- pub fn http_status(&self) -> u16 {
- self.code.http_status()
- }
-
- pub fn envelope(&self) -> ApiErrorEnvelope {
- ApiErrorEnvelope {
- error: ApiErrorBody {
- code: self.code.as_str().to_owned(),
- message: self.message.clone(),
- },
- }
- }
-}
-
-impl fmt::Display for ApiError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(formatter, "{}: {}", self.code, self.message)
- }
-}
-
-impl std::error::Error for ApiError {}
-
-impl IntoResponse for ApiError {
- fn into_response(self) -> Response {
- let status =
- StatusCode::from_u16(self.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
- (status, Json(self.envelope())).into_response()
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ApiErrorEnvelope {
- pub error: ApiErrorBody,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ApiErrorBody {
- pub code: String,
- pub message: String,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum ReadinessCheckStatus {
- Ready,
- NotReady,
-}
-
-impl ReadinessCheckStatus {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::Ready => "ready",
- Self::NotReady => "not_ready",
- }
- }
-
- pub fn is_ready(self) -> bool {
- self == Self::Ready
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct ReadinessState {
- pub database: ReadinessCheckStatus,
- pub migrations: ReadinessCheckStatus,
- pub repository: ReadinessCheckStatus,
-}
-
-impl ReadinessState {
- pub fn new(
- database: ReadinessCheckStatus,
- migrations: ReadinessCheckStatus,
- repository: ReadinessCheckStatus,
- ) -> Self {
- Self {
- database,
- migrations,
- repository,
- }
- }
-
- pub fn ready() -> Self {
- Self::new(
- ReadinessCheckStatus::Ready,
- ReadinessCheckStatus::Ready,
- ReadinessCheckStatus::Ready,
- )
- }
-
- pub fn is_ready(self) -> bool {
- self.database.is_ready() && self.migrations.is_ready() && self.repository.is_ready()
- }
-
- pub fn response(self) -> ReadinessDocument {
- ReadinessDocument {
- status: if self.is_ready() {
- "ready".to_owned()
- } else {
- "not_ready".to_owned()
- },
- checks: ReadinessChecksDocument {
- database: self.database.as_str().to_owned(),
- migrations: self.migrations.as_str().to_owned(),
- repository: self.repository.as_str().to_owned(),
- },
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct HealthDocument {
- pub status: String,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ReadinessDocument {
- pub status: String,
- pub checks: ReadinessChecksDocument,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ReadinessChecksDocument {
- pub database: String,
- pub migrations: String,
- pub repository: String,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct RelayInfoDocument {
- #[serde(skip_serializing_if = "Option::is_none")]
- pub id: Option<String>,
- pub name: String,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub description: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub pubkey: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub contact: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub icon: Option<String>,
- pub supported_nips: Vec<u16>,
- pub software: String,
- pub version: String,
- pub limitation: RelayInfoLimitationDocument,
-}
-
-impl RelayInfoDocument {
- pub fn tangle_default() -> Self {
- Self {
- id: None,
- name: "tangle".to_owned(),
- description: Some("SurrealDB-backed Nostr relay for NIP-99 marketplaces".to_owned()),
- pubkey: None,
- contact: None,
- icon: None,
- supported_nips: TANGLE_SUPPORTED_NIPS.to_vec(),
- software: TANGLE_RELAY_SOFTWARE.to_owned(),
- version: TANGLE_RELAY_VERSION.to_owned(),
- limitation: RelayInfoLimitationDocument {
- payment_required: false,
- restricted_writes: true,
- },
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct RelayInfoLimitationDocument {
- pub payment_required: bool,
- pub restricted_writes: bool,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ListingHttpQuery {
- marketplace: MarketplaceQuery,
- geohash: Option<String>,
-}
-
-impl ListingHttpQuery {
- pub fn marketplace(&self) -> &MarketplaceQuery {
- &self.marketplace
- }
-
- pub fn geohash(&self) -> Option<&str> {
- self.geohash.as_deref()
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct MarketplaceSearchHttpQuery {
- text: Option<String>,
- seller: Option<PublicKeyHex>,
- limit: u64,
-}
-
-impl MarketplaceSearchHttpQuery {
- pub fn text(&self) -> Option<&str> {
- self.text.as_deref()
- }
-
- pub fn seller(&self) -> Option<&PublicKeyHex> {
- self.seller.as_ref()
- }
-
- pub fn limit(&self) -> u64 {
- self.limit
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct ListingsHttpState {
- store: SurrealStore,
- limits: RuntimeLimits,
-}
-
-impl ListingsHttpState {
- pub fn new(store: SurrealStore, limits: RuntimeLimits) -> Self {
- Self { store, limits }
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct MetricsHttpState {
- store: SurrealStore,
-}
-
-impl MetricsHttpState {
- pub fn new(store: SurrealStore) -> Self {
- Self { store }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ListingsDocument {
- pub items: Vec<ListingItemDocument>,
- pub next_cursor: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ListingItemDocument {
- pub listing_key: String,
- pub event_id: String,
- pub seller_pubkey: String,
- pub d: String,
- pub title: String,
- pub summary: Option<String>,
- pub price: ListingPriceDocument,
- pub location: ListingLocationDocument,
- pub fulfillment: Vec<String>,
- pub status: String,
- pub updated_at: u64,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ListingPriceDocument {
- pub amount: String,
- pub currency: String,
- pub unit: String,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ListingLocationDocument {
- pub text: Option<String>,
- pub geohash: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ListingDetailDocument {
- pub listing: ListingItemDocument,
- pub raw_event: serde_json::Value,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ListingCommentsDocument {
- pub items: Vec<CommentItemDocument>,
- pub next_cursor: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct CommentItemDocument {
- pub event_id: String,
- pub pubkey: String,
- pub created_at: u64,
- pub content: String,
- pub root: CommentReferenceDocument,
- pub parent: CommentReferenceDocument,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct CommentReferenceDocument {
- pub target_type: String,
- pub target_ref: String,
- pub kind: String,
- pub author: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ReactionCountsDocument {
- pub target_event_id: String,
- pub target_kind: Option<String>,
- pub like_count: u64,
- pub dislike_count: u64,
- pub emoji_count: u64,
- pub text_count: u64,
- pub total_count: u64,
- pub updated_at: u64,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ForumThreadsDocument {
- pub items: Vec<ForumThreadItemDocument>,
- pub next_cursor: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ForumThreadItemDocument {
- pub event_id: String,
- pub pubkey: String,
- pub created_at: u64,
- pub updated_at: u64,
- pub title: Option<String>,
- pub content: String,
- pub tags: Vec<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ForumThreadDetailDocument {
- pub thread: ForumThreadItemDocument,
- pub raw_event: serde_json::Value,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct SellerDocument {
- pub pubkey: String,
- pub event_id: Option<String>,
- pub name: Option<String>,
- pub display_name: Option<String>,
- pub about: Option<String>,
- pub picture: Option<String>,
- pub website: Option<String>,
- pub nip05: Option<String>,
- pub lud16: Option<String>,
- pub regions: Vec<String>,
- pub categories: Vec<String>,
- pub trust_markers: Vec<String>,
- pub approved: bool,
- pub blocked: bool,
- pub active_listing_count: u64,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct AdminPolicyDocument {
- pub status: String,
- pub target_type: String,
- pub target_ref: String,
-}
-
-impl AdminPolicyDocument {
- pub fn new(status: &str, target_type: &str, target_ref: &str) -> Self {
- Self {
- status: status.to_owned(),
- target_type: target_type.to_owned(),
- target_ref: target_ref.to_owned(),
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ModerationLabelsDocument {
- pub items: Vec<ModerationLabelDocument>,
- pub next_cursor: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ModerationLabelDocument {
- pub label_id: String,
- pub event_id: String,
- pub pubkey: String,
- pub created_at: u64,
- pub content: String,
- pub namespace: String,
- pub label: String,
- pub target_type: String,
- pub target_ref: String,
- pub projected_at: u64,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ModerationReportsDocument {
- pub items: Vec<ModerationReportDocument>,
- pub next_cursor: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ModerationReportDocument {
- pub report_id: String,
- pub event_id: String,
- pub pubkey: String,
- pub created_at: u64,
- pub content: String,
- pub target_type: String,
- pub target_ref: String,
- pub report_type: String,
- pub reported_pubkeys: Vec<String>,
- pub server_urls: Vec<String>,
- pub projected_at: u64,
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
-pub struct AdminEventPolicyRequest {
- pub reason: Option<String>,
-}
-
-pub fn parse_listing_query(
- query: &str,
- limits: RuntimeLimits,
-) -> Result<ListingHttpQuery, ApiError> {
- let mut spec = MarketplaceQuerySpec {
- statuses: vec![MarketplaceListingStatus::Active],
- ..MarketplaceQuerySpec::default()
- };
- let mut geohash = None;
- let mut saw_status = false;
- let mut saw_sort = false;
- for (key, value) in form_urlencoded::parse(query.as_bytes()) {
- let value = value.into_owned();
- match key.as_ref() {
- "category" => push_text_values("category", &value, &mut spec.categories)?,
- "seller" => set_once("seller", &mut spec.seller, parse_pubkey("seller", &value)?)?,
- "status" => {
- if !saw_status {
- spec.statuses.clear();
- saw_status = true;
- }
- push_status_values(&value, &mut spec.statuses)?;
- }
- "currency" => push_text_values("currency", &value, &mut spec.currencies)?,
- "unit" => push_unit_values(&value, &mut spec.units)?,
- "min_price" => {
- let value = required_value("min_price", &value)?;
- set_once("min_price", &mut spec.min_price, value)?;
- }
- "max_price" => {
- let value = required_value("max_price", &value)?;
- set_once("max_price", &mut spec.max_price, value)?;
- }
- "fulfillment" => push_fulfillment_values(&value, &mut spec.fulfillment)?,
- "delivery_only" => {
- let value = parse_bool("delivery_only", &value)?;
- set_once("delivery_only", &mut spec.delivery_only, value)?;
- }
- "pickup" => set_once("pickup", &mut spec.pickup, parse_bool("pickup", &value)?)?,
- "geohash" => set_once("geohash", &mut geohash, parse_geohash_query_value(&value)?)?,
- "lat" => {
- let value = parse_microdegrees("lat", &value, -90_000_000, 90_000_000)?;
- set_once("lat", &mut spec.latitude_microdegrees, value)?;
- }
- "lon" => {
- let value = parse_microdegrees("lon", &value, -180_000_000, 180_000_000)?;
- set_once("lon", &mut spec.longitude_microdegrees, value)?;
- }
- "radius_km" => {
- let value = parse_radius_meters(&value)?;
- set_once("radius_km", &mut spec.radius_meters, value)?;
- }
- "near" => set_once("near", &mut spec.near, required_value("near", &value)?)?,
- "sort" => {
- if saw_sort {
- return Err(invalid_parameter("sort", "must not be repeated"));
- }
- saw_sort = true;
- spec.sort = parse_sort(&value)?;
- }
- "limit" => set_once("limit", &mut spec.limit, parse_limit(&value)?)?,
- "cursor" => {
- return Err(invalid_parameter(
- "cursor",
- "signed cursor decoding is not implemented",
- ));
- }
- unsupported => {
- return Err(ApiError::invalid_request(format!(
- "query parameter `{unsupported}` is unsupported"
- )));
- }
- }
- }
- let marketplace = MarketplaceQuery::from_spec(spec, limits).map_err(ApiError::from)?;
- Ok(ListingHttpQuery {
- marketplace,
- geohash,
- })
-}
-
-pub fn parse_marketplace_search_query(
- query: &str,
- limits: RuntimeLimits,
-) -> Result<MarketplaceSearchHttpQuery, ApiError> {
- let mut text = None;
- let mut seller = None;
- let mut status = None;
- let mut sort = None;
- let mut limit = None;
- for (key, value) in form_urlencoded::parse(query.as_bytes()) {
- let value = value.into_owned();
- match key.as_ref() {
- "q" => set_once("q", &mut text, required_value("q", &value)?)?,
- "seller" => set_once("seller", &mut seller, parse_pubkey("seller", &value)?)?,
- "status" => set_once("status", &mut status, parse_status(&value)?)?,
- "sort" => set_once("sort", &mut sort, parse_sort(&value)?)?,
- "limit" => set_once("limit", &mut limit, parse_limit(&value)?)?,
- "category" | "currency" | "unit" | "min_price" | "max_price" | "fulfillment"
- | "delivery_only" | "pickup" | "lat" | "lon" | "radius_km" | "near" | "cursor" => {
- return Err(ApiError::invalid_request(format!(
- "{} is not supported by marketplace search",
- key.as_ref()
- )));
- }
- unsupported => {
- return Err(ApiError::invalid_request(format!(
- "query parameter `{unsupported}` is unsupported"
- )));
- }
- }
- }
- limits
- .validate_search_query(text.as_deref().unwrap_or_default())
- .map_err(|violation| ApiError::invalid_request(format!("runtime limit: {violation}")))?;
- let status = status.unwrap_or(MarketplaceListingStatus::Active);
- if status != MarketplaceListingStatus::Active {
- return Err(invalid_parameter(
- "status",
- "must be active for marketplace search",
- ));
- }
- let expected_sort = if text.is_some() {
- MarketplaceSort::Relevance
- } else {
- MarketplaceSort::Freshness
- };
- if sort.is_some_and(|sort| sort != expected_sort) {
- return Err(invalid_parameter(
- "sort",
- "does not match marketplace search mode",
- ));
- }
- let limit = limit.unwrap_or(MarketplaceQuery::DEFAULT_LIMIT);
- if limit == 0 || limit > MarketplaceQuery::MAX_LIMIT {
- return Err(invalid_parameter("limit", "must be between 1 and 100"));
- }
- Ok(MarketplaceSearchHttpQuery {
- text,
- seller,
- limit,
- })
-}
-
-pub fn health_router(readiness: ReadinessState) -> Router {
- Router::new()
- .route("/healthz", get(healthz))
- .route("/readyz", get(readyz))
- .with_state(readiness)
-}
-
-pub fn metrics_router(state: MetricsHttpState) -> Router {
- Router::new()
- .route("/metrics", get(metrics))
- .with_state(state)
-}
-
-pub fn relay_info_router(document: RelayInfoDocument) -> Router {
- Router::new()
- .route("/", get(relay_info))
- .with_state(document)
-}
-
-pub fn listings_router(state: ListingsHttpState) -> Router {
- Router::new()
- .route("/api/listings", get(listings))
- .route("/api/listings/{pubkey}/{d}", get(listing_detail))
- .route("/api/listings/{pubkey}/{d}/comments", get(listing_comments))
- .route(
- "/api/listings/{pubkey}/{d}/reactions",
- get(listing_reactions),
- )
- .route("/api/forum/threads", get(forum_threads))
- .route("/api/forum/threads/{event_id}", get(forum_thread_detail))
- .route(
- "/api/forum/threads/{event_id}/comments",
- get(forum_thread_comments),
- )
- .route("/api/search", get(marketplace_search))
- .route("/api/sellers/{pubkey}", get(seller_detail))
- .with_state(state)
-}
-
-async fn healthz() -> Json<HealthDocument> {
- Json(HealthDocument {
- status: "ok".to_owned(),
- })
-}
-
-async fn readyz(State(readiness): State<ReadinessState>) -> (StatusCode, Json<ReadinessDocument>) {
- let status = if readiness.is_ready() {
- StatusCode::OK
- } else {
- StatusCode::SERVICE_UNAVAILABLE
- };
- (status, Json(readiness.response()))
-}
-
-async fn metrics(State(state): State<MetricsHttpState>) -> Result<Response, ApiError> {
- let snapshot = state
- .store
- .metrics_snapshot()
- .await
- .map_err(|_| ApiError::internal())?;
- Ok((
- StatusCode::OK,
- [(
- header::CONTENT_TYPE,
- HeaderValue::from_static("text/plain; version=0.0.4"),
- )],
- metrics_text(snapshot),
- )
- .into_response())
-}
-
-fn metrics_text(snapshot: SurrealMetricsSnapshot) -> String {
- let mut output = String::new();
- output.push_str("# HELP tangle_info Tangle relay build information\n");
- output.push_str("# TYPE tangle_info gauge\n");
- output.push_str(&format!(
- "tangle_info{{software=\"{}\",version=\"{}\"}} 1\n",
- prometheus_label_value(TANGLE_RELAY_SOFTWARE),
- prometheus_label_value(TANGLE_RELAY_VERSION)
- ));
- output.push_str("# HELP tangle_relay_ready Relay readiness gauge\n");
- output.push_str("# TYPE tangle_relay_ready gauge\n");
- output.push_str("tangle_relay_ready 1\n");
- output.push_str("# HELP tangle_store_events Stored Nostr event gauges\n");
- output.push_str("# TYPE tangle_store_events gauge\n");
- append_labeled_gauge(
- &mut output,
- "tangle_store_events",
- "stored",
- snapshot.stored_events(),
- );
- append_labeled_gauge(
- &mut output,
- "tangle_store_events",
- "visible",
- snapshot.visible_events(),
- );
- append_labeled_gauge(
- &mut output,
- "tangle_store_events",
- "hidden",
- snapshot.hidden_events(),
- );
- append_labeled_gauge(
- &mut output,
- "tangle_store_events",
- "deleted",
- snapshot.deleted_events(),
- );
- output.push_str("# HELP tangle_store_listings Current listing projection gauges\n");
- output.push_str("# TYPE tangle_store_listings gauge\n");
- append_labeled_gauge(
- &mut output,
- "tangle_store_listings",
- "current",
- snapshot.current_listings(),
- );
- append_labeled_gauge(
- &mut output,
- "tangle_store_listings",
- "active",
- snapshot.active_listings(),
- );
- output.push_str("# HELP tangle_store_seller_profiles Seller profile projection gauges\n");
- output.push_str("# TYPE tangle_store_seller_profiles gauge\n");
- append_labeled_gauge(
- &mut output,
- "tangle_store_seller_profiles",
- "stored",
- snapshot.seller_profiles(),
- );
- append_labeled_gauge(
- &mut output,
- "tangle_store_seller_profiles",
- "visible",
- snapshot.visible_seller_profiles(),
- );
- output.push_str("# HELP tangle_store_sellers Seller policy gauges\n");
- output.push_str("# TYPE tangle_store_sellers gauge\n");
- append_labeled_gauge(
- &mut output,
- "tangle_store_sellers",
- "approved",
- snapshot.approved_sellers(),
- );
- output.push_str("# HELP tangle_store_pubkeys Relay pubkey policy gauges\n");
- output.push_str("# TYPE tangle_store_pubkeys gauge\n");
- append_labeled_gauge(
- &mut output,
- "tangle_store_pubkeys",
- "blocked",
- snapshot.blocked_pubkeys(),
- );
- output
-}
-
-fn append_labeled_gauge(output: &mut String, metric: &str, state: &str, value: u64) {
- output.push_str(&format!(
- "{metric}{{state=\"{}\"}} {value}\n",
- prometheus_label_value(state)
- ));
-}
-
-fn prometheus_label_value(value: &str) -> String {
- value
- .replace('\\', r"\\")
- .replace('"', r#"\""#)
- .replace('\n', r"\n")
-}
-
-async fn relay_info(State(relay_info): State<RelayInfoDocument>, headers: HeaderMap) -> Response {
- if !accepts_nostr_json(headers.get(header::ACCEPT)) {
- return ApiError::not_found("relay information requires application/nostr+json")
- .into_response();
- }
- (
- StatusCode::OK,
- [(
- header::CONTENT_TYPE,
- HeaderValue::from_static("application/nostr+json"),
- )],
- Json(relay_info),
- )
- .into_response()
-}
-
-async fn listings(
- State(state): State<ListingsHttpState>,
- RawQuery(query): RawQuery,
-) -> Result<Json<ListingsDocument>, ApiError> {
- let parsed = parse_listing_query(query.as_deref().unwrap_or_default(), state.limits)?;
- let store_query = listing_projection_query(&parsed)?;
- let rows = state
- .store
- .query_current_listings(&store_query)
- .await
- .map_err(|_| ApiError::internal())?;
- let items = rows
- .iter()
- .map(listing_item_document)
- .collect::<Result<Vec<_>, _>>()?;
- Ok(Json(ListingsDocument {
- items,
- next_cursor: None,
- }))
-}
-
-async fn listing_detail(
- State(state): State<ListingsHttpState>,
- Path((pubkey, d)): Path<(String, String)>,
-) -> Result<Json<ListingDetailDocument>, ApiError> {
- let pubkey = parse_pubkey("pubkey", &pubkey)?;
- let d = required_value("d", &d)?;
- let listing_key = format!("30402:{}:{d}", pubkey.as_str());
- let row = state
- .store
- .listing_current_row(&listing_key)
- .await
- .map_err(|_| ApiError::internal())?
- .ok_or_else(|| ApiError::not_found("listing not found"))?;
- if bool_field(&row, "hidden")? || bool_field(&row, "deleted")? {
- return Err(ApiError::not_found("listing not found"));
- }
- let event_id =
- EventId::new(&string_field(&row, "event_id")?).map_err(|_| ApiError::internal())?;
- let raw_row = state
- .store
- .raw_event_row(&event_id)
- .await
- .map_err(|_| ApiError::internal())?
- .ok_or_else(ApiError::internal)?;
- if bool_field(&raw_row, "hidden")? || bool_field(&raw_row, "deleted")? {
- return Err(ApiError::not_found("listing not found"));
- }
- let raw_event = serde_json::from_str(&string_field(&raw_row, "raw_json")?)
- .map_err(|_| ApiError::internal())?;
- Ok(Json(ListingDetailDocument {
- listing: listing_item_document(&row)?,
- raw_event,
- }))
-}
-
-async fn listing_comments(
- State(state): State<ListingsHttpState>,
- Path((pubkey, d)): Path<(String, String)>,
- RawQuery(query): RawQuery,
-) -> Result<Json<ListingCommentsDocument>, ApiError> {
- let pubkey = parse_pubkey("pubkey", &pubkey)?;
- let d = required_value("d", &d)?;
- let limit = parse_comment_query(query.as_deref().unwrap_or_default())?;
- let listing_key = format!("30402:{}:{d}", pubkey.as_str());
- let listing = state
- .store
- .listing_current_row(&listing_key)
- .await
- .map_err(|_| ApiError::internal())?
- .ok_or_else(|| ApiError::not_found("listing not found"))?;
- if bool_field(&listing, "hidden")? || bool_field(&listing, "deleted")? {
- return Err(ApiError::not_found("listing not found"));
- }
- let rows = state
- .store
- .query_comment_projections(
- &CommentProjectionQuery::new()
- .with_root("address", &listing_key)
- .with_limit(limit),
- )
- .await
- .map_err(|_| ApiError::internal())?;
- let items = rows
- .iter()
- .map(comment_item_document)
- .collect::<Result<Vec<_>, _>>()?;
- Ok(Json(ListingCommentsDocument {
- items,
- next_cursor: None,
- }))
-}
-
-async fn listing_reactions(
- State(state): State<ListingsHttpState>,
- Path((pubkey, d)): Path<(String, String)>,
-) -> Result<Json<ReactionCountsDocument>, ApiError> {
- let pubkey = parse_pubkey("pubkey", &pubkey)?;
- let d = required_value("d", &d)?;
- let listing_key = format!("30402:{}:{d}", pubkey.as_str());
- let listing = state
- .store
- .listing_current_row(&listing_key)
- .await
- .map_err(|_| ApiError::internal())?
- .ok_or_else(|| ApiError::not_found("listing not found"))?;
- if bool_field(&listing, "hidden")? || bool_field(&listing, "deleted")? {
- return Err(ApiError::not_found("listing not found"));
- }
- let event_id =
- EventId::new(&string_field(&listing, "event_id")?).map_err(|_| ApiError::internal())?;
- let row = state
- .store
- .reaction_count_row(&event_id)
- .await
- .map_err(|_| ApiError::internal())?;
- let document = reaction_counts_document(row.as_ref(), event_id.as_str(), Some("30402"))?;
- Ok(Json(document))
-}
-
-async fn forum_threads(
- State(state): State<ListingsHttpState>,
- RawQuery(query): RawQuery,
-) -> Result<Json<ForumThreadsDocument>, ApiError> {
- let query = forum_thread_query(query.as_deref().unwrap_or_default())?;
- let rows = state
- .store
- .query_forum_threads(&query)
- .await
- .map_err(|_| ApiError::internal())?;
- let items = rows
- .iter()
- .map(forum_thread_item_document)
- .collect::<Result<Vec<_>, _>>()?;
- Ok(Json(ForumThreadsDocument {
- items,
- next_cursor: None,
- }))
-}
-
-async fn forum_thread_detail(
- State(state): State<ListingsHttpState>,
- Path(event_id): Path<String>,
-) -> Result<Json<ForumThreadDetailDocument>, ApiError> {
- let event_id =
- EventId::new(&event_id).map_err(|_| invalid_parameter("event_id", "is invalid"))?;
- let row = state
- .store
- .forum_thread_row(&event_id)
- .await
- .map_err(|_| ApiError::internal())?
- .ok_or_else(|| ApiError::not_found("forum thread not found"))?;
- if bool_field(&row, "hidden")? || bool_field(&row, "deleted")? {
- return Err(ApiError::not_found("forum thread not found"));
- }
- let raw_row = state
- .store
- .raw_event_row(&event_id)
- .await
- .map_err(|_| ApiError::internal())?
- .ok_or_else(ApiError::internal)?;
- if bool_field(&raw_row, "hidden")? || bool_field(&raw_row, "deleted")? {
- return Err(ApiError::not_found("forum thread not found"));
- }
- let raw_event = serde_json::from_str(&string_field(&raw_row, "raw_json")?)
- .map_err(|_| ApiError::internal())?;
- Ok(Json(ForumThreadDetailDocument {
- thread: forum_thread_item_document(&row)?,
- raw_event,
- }))
-}
-
-async fn forum_thread_comments(
- State(state): State<ListingsHttpState>,
- Path(event_id): Path<String>,
- RawQuery(query): RawQuery,
-) -> Result<Json<ListingCommentsDocument>, ApiError> {
- let event_id =
- EventId::new(&event_id).map_err(|_| invalid_parameter("event_id", "is invalid"))?;
- let limit = parse_comment_query(query.as_deref().unwrap_or_default())?;
- let row = state
- .store
- .forum_thread_row(&event_id)
- .await
- .map_err(|_| ApiError::internal())?
- .ok_or_else(|| ApiError::not_found("forum thread not found"))?;
- if bool_field(&row, "hidden")? || bool_field(&row, "deleted")? {
- return Err(ApiError::not_found("forum thread not found"));
- }
- let rows = state
- .store
- .query_comment_projections(
- &CommentProjectionQuery::new()
- .with_root("event", event_id.as_str())
- .with_limit(limit),
- )
- .await
- .map_err(|_| ApiError::internal())?;
- let items = rows
- .iter()
- .map(comment_item_document)
- .collect::<Result<Vec<_>, _>>()?;
- Ok(Json(ListingCommentsDocument {
- items,
- next_cursor: None,
- }))
-}
-
-async fn marketplace_search(
- State(state): State<ListingsHttpState>,
- RawQuery(query): RawQuery,
-) -> Result<Json<ListingsDocument>, ApiError> {
- let parsed =
- parse_marketplace_search_query(query.as_deref().unwrap_or_default(), state.limits)?;
- let search_query = search_document_query(&parsed);
- let docs = state
- .store
- .query_search_documents(&search_query)
- .await
- .map_err(|_| ApiError::internal())?;
- let mut items = Vec::new();
- for doc in docs {
- let Some(address_key) = optional_string_field(&doc, "address_key")? else {
- continue;
- };
- let Some(row) = state
- .store
- .listing_current_row(&address_key)
- .await
- .map_err(|_| ApiError::internal())?
- else {
- continue;
- };
- if bool_field(&row, "hidden")? || bool_field(&row, "deleted")? {
- continue;
- }
- items.push(listing_item_document(&row)?);
- }
- Ok(Json(ListingsDocument {
- items,
- next_cursor: None,
- }))
-}
-
-async fn seller_detail(
- State(state): State<ListingsHttpState>,
- Path(pubkey): Path<String>,
-) -> Result<Json<SellerDocument>, ApiError> {
- let pubkey = parse_pubkey("pubkey", &pubkey)?;
- let seller = state
- .store
- .relay_user_row(pubkey.as_str())
- .await
- .map_err(|_| ApiError::internal())?;
- let profile = state
- .store
- .seller_profile_row(pubkey.as_str())
- .await
- .map_err(|_| ApiError::internal())?;
- let visible_profile = match profile.as_ref() {
- Some(row) if !bool_field(row, "hidden")? && !bool_field(row, "deleted")? => Some(row),
- _ => None,
- };
- let listings = state
- .store
- .query_current_listings(
- &ListingProjectionQuery::new()
- .with_effective_status("active")
- .with_seller_pubkey(pubkey.as_str()),
- )
- .await
- .map_err(|_| ApiError::internal())?;
- Ok(Json(SellerDocument {
- pubkey: pubkey.as_str().to_owned(),
- event_id: visible_profile
- .map(|row| string_field(row, "event_id"))
- .transpose()?,
- name: visible_profile
- .map(|row| optional_string_field(row, "name"))
- .transpose()?
- .flatten(),
- display_name: visible_profile
- .map(|row| optional_string_field(row, "display_name"))
- .transpose()?
- .flatten(),
- about: visible_profile
- .map(|row| optional_string_field(row, "about"))
- .transpose()?
- .flatten(),
- picture: visible_profile
- .map(|row| optional_string_field(row, "picture"))
- .transpose()?
- .flatten(),
- website: visible_profile
- .map(|row| optional_string_field(row, "website"))
- .transpose()?
- .flatten(),
- nip05: visible_profile
- .map(|row| optional_string_field(row, "nip05"))
- .transpose()?
- .flatten(),
- lud16: visible_profile
- .map(|row| optional_string_field(row, "lud16"))
- .transpose()?
- .flatten(),
- regions: visible_profile
- .map(|row| string_array_field(row, "regions"))
- .transpose()?
- .unwrap_or_default(),
- categories: visible_profile
- .map(|row| string_array_field(row, "categories"))
- .transpose()?
- .unwrap_or_default(),
- trust_markers: visible_profile
- .map(|row| string_array_field(row, "trust_markers"))
- .transpose()?
- .unwrap_or_default(),
- approved: seller
- .as_ref()
- .and_then(|row| row.get("seller_approved"))
- .and_then(serde_json::Value::as_bool)
- .or_else(|| {
- visible_profile
- .and_then(|row| row.get("seller_approved"))
- .and_then(serde_json::Value::as_bool)
- })
- .unwrap_or(false),
- blocked: seller
- .as_ref()
- .and_then(|row| row.get("blocked"))
- .and_then(serde_json::Value::as_bool)
- .or_else(|| {
- visible_profile
- .and_then(|row| row.get("blocked"))
- .and_then(serde_json::Value::as_bool)
- })
- .unwrap_or(false),
- active_listing_count: listings.len() as u64,
- }))
-}
-
-fn accepts_nostr_json(value: Option<&HeaderValue>) -> bool {
- value
- .and_then(|value| value.to_str().ok())
- .is_some_and(|value| {
- value.split(',').any(|part| {
- part.split(';').next().is_some_and(|media_type| {
- media_type
- .trim()
- .eq_ignore_ascii_case("application/nostr+json")
- })
- })
- })
-}
-
-fn require_admin_pubkey(
- config: &TangleRuntimeConfig,
- headers: &HeaderMap,
-) -> Result<PublicKeyHex, ApiError> {
- if config.admin_pubkeys().is_empty() {
- return Err(ApiError::forbidden("admin policy api is disabled"));
- }
- let value = headers
- .get("x-tangle-admin-pubkey")
- .ok_or_else(|| ApiError::unauthorized("admin pubkey header is required"))?
- .to_str()
- .map_err(|_| ApiError::unauthorized("admin pubkey header is invalid"))?;
- let pubkey = PublicKeyHex::new(value)
- .map_err(|_| ApiError::unauthorized("admin pubkey header is invalid"))?;
- if !config.admin_pubkeys().contains(&pubkey) {
- return Err(ApiError::forbidden("admin pubkey is not authorized"));
- }
- Ok(pubkey)
-}
-
-fn listing_projection_query(parsed: &ListingHttpQuery) -> Result<ListingProjectionQuery, ApiError> {
- let query = parsed.marketplace();
- if !query.categories.is_empty() {
- return Err(invalid_parameter(
- "category",
- "is not supported by the listings endpoint",
- ));
- }
- if parsed.geohash().is_some() {
- return Err(invalid_parameter(
- "geohash",
- "is not supported by the listings endpoint",
- ));
- }
- if !query.fulfillment.is_empty() {
- return Err(invalid_parameter(
- "fulfillment",
- "is not supported by the listings endpoint",
- ));
- }
- if query.delivery_only.is_some() {
- return Err(invalid_parameter(
- "delivery_only",
- "is not supported by the listings endpoint",
- ));
- }
- if query.pickup.is_some() {
- return Err(invalid_parameter(
- "pickup",
- "is not supported by the listings endpoint",
- ));
- }
- if query.location.point.is_some()
- || query.location.radius_meters.is_some()
- || query.location.near.is_some()
- {
- return Err(invalid_parameter(
- "location",
- "is not supported by the listings endpoint",
- ));
- }
- if !matches!(
- query.sort,
- MarketplaceSort::Relevance | MarketplaceSort::Freshness
- ) {
- return Err(invalid_parameter(
- "sort",
- "is not supported by the listings endpoint",
- ));
- }
- if query.statuses.len() != 1 {
- return Err(invalid_parameter(
- "status",
- "must contain exactly one value for the listings endpoint",
- ));
- }
- if query.currencies.len() > 1 {
- return Err(invalid_parameter(
- "currency",
- "must contain at most one value for the listings endpoint",
- ));
- }
- if query.units.len() > 1 {
- return Err(invalid_parameter(
- "unit",
- "must contain at most one value for the listings endpoint",
- ));
- }
- let mut store_query =
- ListingProjectionQuery::new().with_effective_status(query.statuses[0].as_str());
- if let Some(seller) = &query.seller {
- store_query = store_query.with_seller_pubkey(seller.as_str());
- }
- if let Some(unit) = query.units.first() {
- store_query = store_query.with_unit(unit.canonical());
- }
- if let Some(currency) = query.currencies.first() {
- store_query = store_query.with_currency_norm(currency);
- }
- if let Some(price) = &query.min_price {
- store_query = store_query.with_min_price_minor(price_minor_units(&price.raw)?);
- }
- if let Some(price) = &query.max_price {
- store_query = store_query.with_max_price_minor(price_minor_units(&price.raw)?);
- }
- Ok(store_query.with_limit(query.limit))
-}
-
-fn search_document_query(parsed: &MarketplaceSearchHttpQuery) -> SearchDocumentQuery {
- let mut query = SearchDocumentQuery::new()
- .with_doc_type("listing")
- .with_kind(30_402)
- .with_visible(true)
- .with_status("active")
- .with_limit(parsed.limit());
- if let Some(text) = parsed.text() {
- query = query.with_text(text);
- }
- if let Some(seller) = parsed.seller() {
- query = query.with_pubkey(seller.as_str());
- }
- query
-}
-
-fn forum_thread_query(raw: &str) -> Result<ForumThreadProjectionQuery, ApiError> {
- let mut pubkey = None;
- let mut topic = None;
- let mut limit = None;
- for (key, value) in form_urlencoded::parse(raw.as_bytes()) {
- let value = value.into_owned();
- match key.as_ref() {
- "pubkey" => set_once("pubkey", &mut pubkey, parse_pubkey("pubkey", &value)?)?,
- "topic" => set_once("topic", &mut topic, required_value("topic", &value)?)?,
- "limit" => set_once("limit", &mut limit, parse_limit(&value)?)?,
- "cursor" => {
- return Err(invalid_parameter(
- "cursor",
- "signed cursor decoding is not implemented",
- ));
- }
- "" => {}
- unsupported => {
- return Err(ApiError::invalid_request(format!(
- "query parameter `{unsupported}` is unsupported"
- )));
- }
- }
- }
- let mut query = ForumThreadProjectionQuery::new().with_limit(limit.unwrap_or(50));
- if let Some(pubkey) = pubkey {
- query = query.with_pubkey(pubkey.as_str());
- }
- if let Some(topic) = topic {
- query = query.with_topic(&topic);
- }
- Ok(query)
-}
-
-fn label_projection_query(raw: &str) -> Result<LabelProjectionQuery, ApiError> {
- let mut target_type = None;
- let mut target_ref = None;
- let mut namespace = None;
- let mut label = None;
- let mut pubkey = None;
- let mut limit = None;
- for (key, value) in form_urlencoded::parse(raw.as_bytes()) {
- let value = value.into_owned();
- match key.as_ref() {
- "target_type" => {
- let value = required_value("target_type", &value)?;
- set_once("target_type", &mut target_type, value)?;
- }
- "target_ref" => {
- let value = required_value("target_ref", &value)?;
- set_once("target_ref", &mut target_ref, value)?;
- }
- "namespace" => {
- let value = required_value("namespace", &value)?;
- set_once("namespace", &mut namespace, value)?;
- }
- "label" => set_once("label", &mut label, required_value("label", &value)?)?,
- "pubkey" => set_once("pubkey", &mut pubkey, parse_pubkey("pubkey", &value)?)?,
- "limit" => set_once("limit", &mut limit, parse_limit(&value)?)?,
- "cursor" => {
- return Err(invalid_parameter(
- "cursor",
- "signed cursor decoding is not implemented",
- ));
- }
- "" => {}
- unsupported => {
- return Err(ApiError::invalid_request(format!(
- "query parameter `{unsupported}` is unsupported"
- )));
- }
- }
- }
- if target_type.is_some() != target_ref.is_some() {
- return Err(invalid_parameter(
- "target",
- "target_type and target_ref must be provided together",
- ));
- }
- let mut query = LabelProjectionQuery::new().with_limit(limit.unwrap_or(50));
- if let (Some(target_type), Some(target_ref)) = (target_type, target_ref) {
- query = query.with_target(&target_type, &target_ref);
- }
- if let Some(namespace) = namespace {
- query = query.with_namespace(&namespace);
- }
- if let Some(label) = label {
- query = query.with_label(&label);
- }
- if let Some(pubkey) = pubkey {
- query = query.with_pubkey(pubkey.as_str());
- }
- Ok(query)
-}
-
-fn report_projection_query(raw: &str) -> Result<ReportProjectionQuery, ApiError> {
- let mut target_type = None;
- let mut target_ref = None;
- let mut report_type = None;
- let mut pubkey = None;
- let mut limit = None;
- for (key, value) in form_urlencoded::parse(raw.as_bytes()) {
- let value = value.into_owned();
- match key.as_ref() {
- "target_type" => {
- let value = required_value("target_type", &value)?;
- set_once("target_type", &mut target_type, value)?;
- }
- "target_ref" => {
- let value = required_value("target_ref", &value)?;
- set_once("target_ref", &mut target_ref, value)?;
- }
- "report_type" => {
- let value = required_value("report_type", &value)?;
- set_once("report_type", &mut report_type, value)?;
- }
- "pubkey" => set_once("pubkey", &mut pubkey, parse_pubkey("pubkey", &value)?)?,
- "limit" => set_once("limit", &mut limit, parse_limit(&value)?)?,
- "cursor" => {
- return Err(invalid_parameter(
- "cursor",
- "signed cursor decoding is not implemented",
- ));
- }
- "" => {}
- unsupported => {
- return Err(ApiError::invalid_request(format!(
- "query parameter `{unsupported}` is unsupported"
- )));
- }
- }
- }
- if target_type.is_some() != target_ref.is_some() {
- return Err(invalid_parameter(
- "target",
- "target_type and target_ref must be provided together",
- ));
- }
- let mut query = ReportProjectionQuery::new().with_limit(limit.unwrap_or(50));
- if let (Some(target_type), Some(target_ref)) = (target_type, target_ref) {
- query = query.with_target(&target_type, &target_ref);
- }
- if let Some(report_type) = report_type {
- query = query.with_report_type(&report_type);
- }
- if let Some(pubkey) = pubkey {
- query = query.with_pubkey(pubkey.as_str());
- }
- Ok(query)
-}
-
-fn parse_comment_query(raw: &str) -> Result<u64, ApiError> {
- let mut limit = None;
- for (key, value) in form_urlencoded::parse(raw.as_bytes()) {
- match key.as_ref() {
- "limit" => set_once("limit", &mut limit, parse_limit(value.as_ref())?)?,
- "" => {}
- _ => {
- return Err(ApiError::invalid_request(format!(
- "{} is not supported by the listing comments endpoint",
- key
- )));
- }
- }
- }
- Ok(limit.unwrap_or(50))
-}
-
-fn listing_item_document(row: &serde_json::Value) -> Result<ListingItemDocument, ApiError> {
- Ok(ListingItemDocument {
- listing_key: string_field(row, "listing_key")?,
- event_id: string_field(row, "event_id")?,
- seller_pubkey: string_field(row, "seller_pubkey")?,
- d: string_field(row, "d")?,
- title: string_field(row, "title")?,
- summary: optional_string_field(row, "summary")?,
- price: ListingPriceDocument {
- amount: string_field(row, "price_decimal")?,
- currency: string_field(row, "currency_norm")?,
- unit: string_field(row, "unit")?,
- },
- location: ListingLocationDocument {
- text: optional_string_field(row, "location_text")?,
- geohash: optional_string_field(row, "geohash")?,
- },
- fulfillment: fulfillment_document(row)?,
- status: string_field(row, "effective_status")?,
- updated_at: u64_field(row, "updated_at")?,
- })
-}
-
-fn forum_thread_item_document(
- row: &serde_json::Value,
-) -> Result<ForumThreadItemDocument, ApiError> {
- Ok(ForumThreadItemDocument {
- event_id: string_field(row, "event_id")?,
- pubkey: string_field(row, "pubkey")?,
- created_at: u64_field(row, "created_at")?,
- updated_at: u64_field(row, "updated_at")?,
- title: optional_string_field(row, "title")?,
- content: string_field(row, "content")?,
- tags: string_array_field(row, "tags")?,
- })
-}
-
-fn comment_item_document(row: &serde_json::Value) -> Result<CommentItemDocument, ApiError> {
- Ok(CommentItemDocument {
- event_id: string_field(row, "event_id")?,
- pubkey: string_field(row, "pubkey")?,
- created_at: u64_field(row, "created_at")?,
- content: string_field(row, "content")?,
- root: CommentReferenceDocument {
- target_type: string_field(row, "root_target_type")?,
- target_ref: string_field(row, "root_ref")?,
- kind: string_field(row, "root_kind")?,
- author: optional_string_field(row, "root_author")?,
- },
- parent: CommentReferenceDocument {
- target_type: string_field(row, "parent_target_type")?,
- target_ref: string_field(row, "parent_ref")?,
- kind: string_field(row, "parent_kind")?,
- author: optional_string_field(row, "parent_author")?,
- },
- })
-}
-
-fn moderation_label_document(row: &serde_json::Value) -> Result<ModerationLabelDocument, ApiError> {
- Ok(ModerationLabelDocument {
- label_id: string_field(row, "label_id")?,
- event_id: string_field(row, "event_id")?,
- pubkey: string_field(row, "pubkey")?,
- created_at: u64_field(row, "created_at")?,
- content: string_field(row, "content")?,
- namespace: string_field(row, "namespace")?,
- label: string_field(row, "label")?,
- target_type: string_field(row, "target_type")?,
- target_ref: string_field(row, "target_ref")?,
- projected_at: u64_field(row, "projected_at")?,
- })
-}
-
-fn moderation_report_document(
- row: &serde_json::Value,
-) -> Result<ModerationReportDocument, ApiError> {
- Ok(ModerationReportDocument {
- report_id: string_field(row, "report_id")?,
- event_id: string_field(row, "event_id")?,
- pubkey: string_field(row, "pubkey")?,
- created_at: u64_field(row, "created_at")?,
- content: string_field(row, "content")?,
- target_type: string_field(row, "target_type")?,
- target_ref: string_field(row, "target_ref")?,
- report_type: string_field(row, "report_type")?,
- reported_pubkeys: string_array_field(row, "reported_pubkeys")?,
- server_urls: string_array_field(row, "server_urls")?,
- projected_at: u64_field(row, "projected_at")?,
- })
-}
-
-fn reaction_counts_document(
- row: Option<&serde_json::Value>,
- target_event_id: &str,
- target_kind: Option<&str>,
-) -> Result<ReactionCountsDocument, ApiError> {
- match row {
- Some(row) => Ok(ReactionCountsDocument {
- target_event_id: string_field(row, "target_event_id")?,
- target_kind: optional_string_field(row, "target_kind")?,
- like_count: u64_field(row, "like_count")?,
- dislike_count: u64_field(row, "dislike_count")?,
- emoji_count: u64_field(row, "emoji_count")?,
- text_count: u64_field(row, "text_count")?,
- total_count: u64_field(row, "total_count")?,
- updated_at: u64_field(row, "updated_at")?,
- }),
- None => Ok(ReactionCountsDocument {
- target_event_id: target_event_id.to_owned(),
- target_kind: target_kind.map(str::to_owned),
- like_count: 0,
- dislike_count: 0,
- emoji_count: 0,
- text_count: 0,
- total_count: 0,
- updated_at: 0,
- }),
- }
-}
-
-fn fulfillment_document(row: &serde_json::Value) -> Result<Vec<String>, ApiError> {
- let mut fulfillment = Vec::new();
- if bool_field(row, "pickup_available")? {
- fulfillment.push("pickup".to_owned());
- }
- if bool_field(row, "delivery_available")? {
- fulfillment.push("delivery".to_owned());
- }
- if bool_field(row, "shipping_available")? {
- fulfillment.push("shipping".to_owned());
- }
- Ok(fulfillment)
-}
-
-fn price_minor_units(raw: &str) -> Result<i64, ApiError> {
- let mut parts = raw.split('.');
- let whole = parts.next().unwrap_or_default();
- let fraction = parts.next();
- if parts.next().is_some() || whole.is_empty() {
- return Err(invalid_parameter(
- "price",
- "must fit two decimal minor units",
- ));
- }
- let whole = whole
- .parse::<i64>()
- .map_err(|_| invalid_parameter("price", "must fit two decimal minor units"))?;
- let fraction = match fraction {
- Some(value) if value.len() <= 2 => format!("{value:0<2}")
- .parse::<i64>()
- .map_err(|_| invalid_parameter("price", "must fit two decimal minor units"))?,
- Some(_) => {
- return Err(invalid_parameter(
- "price",
- "must fit two decimal minor units",
- ));
- }
- None => 0,
- };
- whole
- .checked_mul(100)
- .and_then(|whole| whole.checked_add(fraction))
- .ok_or_else(|| invalid_parameter("price", "must fit two decimal minor units"))
-}
-
-fn string_field(row: &serde_json::Value, field: &'static str) -> Result<String, ApiError> {
- row.get(field)
- .and_then(serde_json::Value::as_str)
- .map(str::to_owned)
- .ok_or_else(ApiError::internal)
-}
-
-fn optional_string_field(
- row: &serde_json::Value,
- field: &'static str,
-) -> Result<Option<String>, ApiError> {
- match row.get(field) {
- Some(value) if value.is_null() => Ok(None),
- Some(value) => value
- .as_str()
- .map(|value| Some(value.to_owned()))
- .ok_or_else(ApiError::internal),
- None => Ok(None),
- }
-}
-
-fn string_array_field(
- row: &serde_json::Value,
- field: &'static str,
-) -> Result<Vec<String>, ApiError> {
- row.get(field)
- .and_then(serde_json::Value::as_array)
- .ok_or_else(ApiError::internal)?
- .iter()
- .map(|value| {
- value
- .as_str()
- .map(str::to_owned)
- .ok_or_else(ApiError::internal)
- })
- .collect()
-}
-
-fn u64_field(row: &serde_json::Value, field: &'static str) -> Result<u64, ApiError> {
- row.get(field)
- .and_then(serde_json::Value::as_u64)
- .ok_or_else(ApiError::internal)
-}
-
-fn bool_field(row: &serde_json::Value, field: &'static str) -> Result<bool, ApiError> {
- row.get(field)
- .and_then(serde_json::Value::as_bool)
- .ok_or_else(ApiError::internal)
-}
-
-impl From<MarketplaceQueryError> for ApiError {
- fn from(error: MarketplaceQueryError) -> Self {
- Self::invalid_request(error.message())
- }
-}
-
-fn set_once<T>(field: &'static str, target: &mut Option<T>, value: T) -> Result<(), ApiError> {
- if target.replace(value).is_some() {
- return Err(invalid_parameter(field, "must not be repeated"));
- }
- Ok(())
-}
-
-fn push_text_values(
- field: &'static str,
- value: &str,
- target: &mut Vec<String>,
-) -> Result<(), ApiError> {
- for value in split_query_list(field, value)? {
- target.push(value);
- }
- Ok(())
-}
-
-fn push_status_values(
- value: &str,
- target: &mut Vec<MarketplaceListingStatus>,
-) -> Result<(), ApiError> {
- for value in split_query_list("status", value)? {
- target.push(parse_status(&value)?);
- }
- Ok(())
-}
-
-fn push_unit_values(value: &str, target: &mut Vec<ListingUnit>) -> Result<(), ApiError> {
- for value in split_query_list("unit", value)? {
- target.push(parse_unit(&value)?);
- }
- Ok(())
-}
-
-fn push_fulfillment_values(
- value: &str,
- target: &mut Vec<FulfillmentMethod>,
-) -> Result<(), ApiError> {
- for value in split_query_list("fulfillment", value)? {
- target.push(parse_fulfillment(&value)?);
- }
- Ok(())
-}
-
-fn split_query_list(field: &'static str, value: &str) -> Result<Vec<String>, ApiError> {
- value
- .split(',')
- .map(|value| required_value(field, value))
- .collect()
-}
-
-fn required_value(field: &'static str, value: &str) -> Result<String, ApiError> {
- let value = value.trim();
- if value.is_empty() {
- return Err(invalid_parameter(field, "must not be empty"));
- }
- Ok(value.to_owned())
-}
-
-fn parse_pubkey(field: &'static str, value: &str) -> Result<PublicKeyHex, ApiError> {
- let value = required_value(field, value)?;
- PublicKeyHex::new(&value)
- .map_err(|_| invalid_parameter(field, "must be a 64-character hex public key"))
-}
-
-fn parse_status(value: &str) -> Result<MarketplaceListingStatus, ApiError> {
- match value.trim().to_ascii_lowercase().as_str() {
- "active" => Ok(MarketplaceListingStatus::Active),
- "sold" => Ok(MarketplaceListingStatus::Sold),
- "draft" => Ok(MarketplaceListingStatus::Draft),
- "inactive" => Ok(MarketplaceListingStatus::Inactive),
- "expired" => Ok(MarketplaceListingStatus::Expired),
- "deleted" => Ok(MarketplaceListingStatus::Deleted),
- "hidden" => Ok(MarketplaceListingStatus::Hidden),
- "rejected" => Ok(MarketplaceListingStatus::Rejected),
- _ => Err(invalid_parameter("status", "is unsupported")),
- }
-}
-
-fn parse_sort(value: &str) -> Result<MarketplaceSort, ApiError> {
- match required_value("sort", value)?.to_ascii_lowercase().as_str() {
- "relevance" => Ok(MarketplaceSort::Relevance),
- "freshness" => Ok(MarketplaceSort::Freshness),
- "price_asc" => Ok(MarketplaceSort::PriceAsc),
- "price_desc" => Ok(MarketplaceSort::PriceDesc),
- "distance" => Ok(MarketplaceSort::Distance),
- "seller_trust" => Ok(MarketplaceSort::SellerTrust),
- _ => Err(invalid_parameter("sort", "is unsupported")),
- }
-}
-
-fn parse_unit(value: &str) -> Result<ListingUnit, ApiError> {
- match value.trim().to_ascii_lowercase().as_str() {
- "lb" | "lbs" | "pound" | "pounds" => Ok(ListingUnit::Lb),
- "oz" | "ounce" | "ounces" => Ok(ListingUnit::Oz),
- "each" | "ea" => Ok(ListingUnit::Each),
- "bunch" | "bunches" => Ok(ListingUnit::Bunch),
- "dozen" => Ok(ListingUnit::Dozen),
- "kg" | "kilogram" | "kilograms" => Ok(ListingUnit::Kg),
- "g" | "gram" | "grams" => Ok(ListingUnit::G),
- "share" | "shares" => Ok(ListingUnit::Share),
- "pint" | "pints" => Ok(ListingUnit::Pint),
- "quart" | "quarts" => Ok(ListingUnit::Quart),
- "box" | "boxes" => Ok(ListingUnit::Box),
- "crate" | "crates" => Ok(ListingUnit::Crate),
- "flat" | "flats" => Ok(ListingUnit::Flat),
- _ => Err(invalid_parameter("unit", "is unsupported")),
- }
-}
-
-fn parse_fulfillment(value: &str) -> Result<FulfillmentMethod, ApiError> {
- match value.trim().to_ascii_lowercase().as_str() {
- "pickup" => Ok(FulfillmentMethod::Pickup),
- "delivery" => Ok(FulfillmentMethod::Delivery),
- "shipping" => Ok(FulfillmentMethod::Shipping),
- _ => Err(invalid_parameter("fulfillment", "is unsupported")),
- }
-}
-
-fn parse_bool(field: &'static str, value: &str) -> Result<bool, ApiError> {
- match required_value(field, value)?.to_ascii_lowercase().as_str() {
- "true" => Ok(true),
- "false" => Ok(false),
- _ => Err(invalid_parameter(field, "must be true or false")),
- }
-}
-
-fn parse_geohash_query_value(value: &str) -> Result<String, ApiError> {
- let value = required_value("geohash", value)?.to_ascii_lowercase();
- if value
- .bytes()
- .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
- {
- Ok(value)
- } else {
- Err(invalid_parameter(
- "geohash",
- "must be lowercase alphanumeric",
- ))
- }
-}
-
-fn parse_microdegrees(
- field: &'static str,
- value: &str,
- min: i64,
- max: i64,
-) -> Result<i32, ApiError> {
- let value = required_value(field, value)?;
- let (negative, value) = match value.as_bytes().first() {
- Some(b'-') => (true, &value[1..]),
- Some(b'+') => (false, &value[1..]),
- _ => (false, value.as_str()),
- };
- let unsigned = parse_unsigned_decimal_scaled(field, value, 6)? as i64;
- let signed = if negative { -unsigned } else { unsigned };
- if !(min..=max).contains(&signed) {
- return Err(invalid_parameter(field, "is out of range"));
- }
- Ok(signed as i32)
-}
-
-fn parse_radius_meters(value: &str) -> Result<u64, ApiError> {
- let kilometers = required_value("radius_km", value)?;
- let meters = parse_unsigned_decimal_scaled("radius_km", &kilometers, 3)?;
- if meters == 0 {
- return Err(invalid_parameter("radius_km", "must be greater than zero"));
- }
- Ok(meters)
-}
-
-fn parse_limit(value: &str) -> Result<u64, ApiError> {
- required_value("limit", value)?
- .parse::<u64>()
- .map_err(|_| invalid_parameter("limit", "must be an unsigned integer"))
-}
-
-fn parse_unsigned_decimal_scaled(
- field: &'static str,
- value: &str,
- scale_digits: usize,
-) -> Result<u64, ApiError> {
- let mut parts = value.split('.');
- let whole = parts.next().unwrap_or_default();
- let fraction = parts.next().unwrap_or_default();
- if parts.next().is_some()
- || whole.is_empty()
- || !whole.bytes().all(|byte| byte.is_ascii_digit())
- || !fraction.bytes().all(|byte| byte.is_ascii_digit())
- || fraction.len() > scale_digits
- {
- return Err(invalid_parameter(
- field,
- "must be an exact unsigned decimal",
- ));
- }
- let whole = whole
- .parse::<u64>()
- .map_err(|_| invalid_parameter(field, "must be an exact unsigned decimal"))?;
- let mut fraction = fraction.to_owned();
- while fraction.len() < scale_digits {
- fraction.push('0');
- }
- let fraction = fraction
- .parse::<u64>()
- .map_err(|_| invalid_parameter(field, "must be an exact unsigned decimal"))?;
- whole
- .checked_mul(10_u64.pow(scale_digits as u32))
- .and_then(|whole| whole.checked_add(fraction))
- .ok_or_else(|| invalid_parameter(field, "must fit the supported range"))
-}
-
-fn invalid_parameter(field: &'static str, requirement: &str) -> ApiError {
- ApiError::invalid_request(format!("{field} {requirement}"))
-}
-
-#[cfg(test)]
-mod tests {
- use super::{
- ApiError, ApiErrorBody, ApiErrorCode, ApiErrorEnvelope, AuthMessageHandler, ClientFrame,
- ClientFrameOutcome, ClientMessageLoop, CloseMessageHandler, CloseMessageOutcome,
- EventMessageHandler, GracefulShutdownSignal, ListingsHttpState, LiveEventFanout,
- MetricsHttpState, ReadinessCheckStatus, ReadinessState, RelayConnection,
- RelayConnectionConfig, RelayConnectionId, RelayInfoDocument, ReqMessageHandler,
- RuntimeCommandError, RuntimeCommandErrorKind, RuntimeConfigErrorKind,
- RuntimeEventImportOutcome, RuntimeProjectionRebuildOutcome, RuntimeServerReport,
- RuntimeTracingFormat, TANGLE_RELAY_SOFTWARE, TANGLE_RELAY_VERSION, TANGLE_SUPPORTED_NIPS,
- WebSocketHttpState, backup_runtime_store, health_router, listing_item_document,
- listing_projection_query, listings_router, load_runtime_config, metrics_router,
- migrate_runtime_database, parse_listing_query, parse_marketplace_search_query,
- parse_runtime_config_json, relay_info_router, restore_runtime_store,
- runtime_readiness_state, search_document_query,
- };
- use axum::{body::Body, response::IntoResponse};
- use futures_util::{SinkExt, StreamExt};
- use http::{HeaderValue, Request, StatusCode, header};
- use tangle_core::{
- AdmissionContext, AdmissionEffect, AdmissionPolicy, EventValidator,
- MarketplaceListingStatus, MarketplaceSort, NostrFilterCompiler, RateLimitConfig,
- RuntimeLimits,
- };
- use tangle_nips::{
- FulfillmentMethod, ListingUnit, NIP01_METADATA_KIND, parse_relay_auth_event,
- };
- use tangle_protocol::{
- ClientMessage, EventId, PublicKeyHex, RelayMessage, SubscriptionId, UnixTimestamp,
- event_to_value, filter_from_value,
- };
- use tangle_store::{StoreEventOutcome, StoredEvent};
- use tangle_store_surreal::{
- SurrealConnectionConfig, SurrealConnectionMode, SurrealStore, base_migration_plan,
- };
- use tangle_test_support::{
- FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts,
- valid_public_listing_spec,
- };
- use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
- use tower::ServiceExt;
-
- #[test]
- fn api_error_codes_have_stable_labels_and_statuses() {
- let cases = [
- (ApiErrorCode::InvalidRequest, "invalid_request", 400),
- (ApiErrorCode::Unauthorized, "unauthorized", 401),
- (ApiErrorCode::Forbidden, "forbidden", 403),
- (ApiErrorCode::NotFound, "not_found", 404),
- (ApiErrorCode::Conflict, "conflict", 409),
- (ApiErrorCode::Internal, "internal_error", 500),
- ];
- for (code, label, status) in cases {
- assert_eq!(code.as_str(), label);
- assert_eq!(code.to_string(), label);
- assert_eq!(code.http_status(), status);
- }
- }
-
- #[test]
- fn api_error_constructors_preserve_public_envelope_shape() {
- let errors = [
- ApiError::invalid_request("bad query"),
- ApiError::unauthorized("authentication required"),
- ApiError::forbidden("admin role required"),
- ApiError::not_found("listing not found"),
- ApiError::conflict("event already exists"),
- ApiError::internal(),
- ];
- assert_eq!(errors[0].http_status(), 400);
- assert_eq!(errors[1].http_status(), 401);
- assert_eq!(errors[2].http_status(), 403);
- assert_eq!(errors[3].http_status(), 404);
- assert_eq!(errors[4].http_status(), 409);
- assert_eq!(errors[5].http_status(), 500);
- assert_eq!(errors[0].code(), ApiErrorCode::InvalidRequest);
- assert_eq!(errors[0].message(), "bad query");
- assert_eq!(errors[0].to_string(), "invalid_request: bad query");
- assert_eq!(
- errors[5].envelope(),
- ApiErrorEnvelope {
- error: ApiErrorBody {
- code: "internal_error".to_owned(),
- message: "internal server error".to_owned()
- }
- }
- );
- assert_eq!(
- serde_json::to_value(errors[0].envelope()).expect("json"),
- serde_json::json!({
- "error": {
- "code": "invalid_request",
- "message": "bad query"
- }
- })
- );
- assert_eq!(
- serde_json::from_value::<ApiErrorEnvelope>(serde_json::json!({
- "error": {
- "code": "invalid_request",
- "message": "bad query"
- }
- }))
- .expect("envelope"),
- errors[0].envelope()
- );
- }
-
- #[test]
- fn relay_connection_id_validates_and_displays_stable_value() {
- let id = RelayConnectionId::new(" conn-001 ").expect("id");
-
- assert_eq!(id.as_str(), "conn-001");
- assert_eq!(id.to_string(), "conn-001");
- assert_eq!(
- RelayConnectionId::new("").expect_err("empty"),
- "connection id must not be empty"
- );
- assert_eq!(
- RelayConnectionId::new(&"x".repeat(RelayConnectionId::MAX_LENGTH + 1))
- .expect_err("too long"),
- "connection id must be at most 128 bytes, got 129"
- );
- }
-
- #[test]
- fn relay_connection_config_normalizes_core_runtime_state() {
- let rate_limit = RateLimitConfig::new(10, 60).expect("rate limit");
- let config = RelayConnectionConfig::new(
- " wss://relay.radroots.test ",
- 42,
- rate_limit,
- RuntimeLimits::default(),
- )
- .expect("config");
-
- assert_eq!(config.relay_url(), "wss://relay.radroots.test");
- assert_eq!(config.auth_ttl_seconds(), 42);
- assert_eq!(config.message_rate_limit(), rate_limit);
- assert_eq!(config.runtime_limits(), RuntimeLimits::default());
- assert_eq!(
- RelayConnectionConfig::new("", 42, rate_limit, RuntimeLimits::default())
- .expect_err("relay"),
- "relay url must not be empty"
- );
- assert_eq!(
- RelayConnectionConfig::new(
- "wss://relay.radroots.test",
- 0,
- rate_limit,
- RuntimeLimits::default()
- )
- .expect_err("ttl"),
- "auth challenge ttl must be greater than zero"
- );
- }
-
- #[test]
- fn relay_connection_composes_subscription_auth_and_rate_state() {
- let config = RelayConnectionConfig::new(
- "wss://relay.radroots.test",
- 30,
- RateLimitConfig::new(2, 60).expect("rate limit"),
- RuntimeLimits::default(),
- )
- .expect("config");
- let mut connection =
- RelayConnection::new(RelayConnectionId::new("conn-a").expect("id"), config);
-
- assert_eq!(connection.id().as_str(), "conn-a");
- assert_eq!(connection.remote_addr(), None);
- connection.set_remote_addr("127.0.0.1:7777");
- assert_eq!(connection.remote_addr(), Some("127.0.0.1:7777"));
- assert_eq!(connection.subscriptions().active_count(), 0);
- assert_eq!(connection.auth().relay_url(), "wss://relay.radroots.test");
- assert_eq!(connection.auth().ttl_seconds(), 30);
- assert_eq!(connection.rate_limiter().tracked_key_count(), 0);
-
- let challenge = connection
- .auth_mut()
- .issue_challenge("challenge-a", UnixTimestamp::new(100))
- .expect("challenge");
- let decision = connection
- .rate_limiter_mut()
- .check("conn-a", UnixTimestamp::new(100), 1)
- .expect("rate limit");
-
- assert_eq!(challenge.value, "challenge-a");
- assert_eq!(
- connection
- .auth()
- .active_challenge()
- .expect("active")
- .expires_at,
- UnixTimestamp::new(130)
- );
- assert!(decision.allowed());
- assert_eq!(decision.remaining(), 1);
- assert_eq!(connection.rate_limiter().tracked_key_count(), 1);
- assert_eq!(connection.subscriptions_mut().active_count(), 0);
- }
-
- #[test]
- fn websocket_state_uses_relay_connection_config() {
- let config = RelayConnectionConfig::new(
- "wss://relay.radroots.test",
- 60,
- RateLimitConfig::new(5, 10).expect("rate limit"),
- RuntimeLimits::default(),
- )
- .expect("config");
- let state = WebSocketHttpState::new(config.clone());
- let default_state = WebSocketHttpState::default();
-
- assert_eq!(state.connection_config(), &config);
- assert_eq!(
- default_state.connection_config().relay_url(),
- "wss://relay.radroots.test"
- );
- assert!(!state.shutdown_signal().is_shutdown_requested());
- assert!(!default_state.shutdown_signal().is_shutdown_requested());
- }
-
- #[tokio::test]
- async fn graceful_shutdown_signal_notifies_subscribers() {
- let (shutdown, mut first) = GracefulShutdownSignal::new();
- let mut second = shutdown.subscribe();
-
- assert!(!shutdown.is_shutdown_requested());
- assert!(!first.is_shutdown_requested());
- assert!(!second.is_shutdown_requested());
-
- assert!(shutdown.request_shutdown());
- first.wait_for_shutdown().await;
- second.wait_for_shutdown().await;
-
- assert!(shutdown.is_shutdown_requested());
- assert!(first.is_shutdown_requested());
- assert!(second.is_shutdown_requested());
- }
-
- #[tokio::test]
- async fn graceful_shutdown_listener_returns_when_already_requested() {
- let (shutdown, mut listener) = GracefulShutdownSignal::new();
-
- assert!(shutdown.request_shutdown());
- listener.wait_for_shutdown().await;
-
- assert!(listener.is_shutdown_requested());
- }
-
- #[tokio::test]
- async fn graceful_shutdown_listener_wakes_after_request() {
- let (shutdown, mut listener) = GracefulShutdownSignal::new();
- let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
- let task = tokio::spawn(async move {
- ready_tx.send(()).expect("ready signal");
- listener.wait_for_shutdown().await;
- listener.is_shutdown_requested()
- });
-
- ready_rx.await.expect("listener ready");
- tokio::task::yield_now().await;
- assert!(shutdown.request_shutdown());
- assert!(task.await.expect("listener task"));
- }
-
- #[tokio::test]
- async fn runtime_config_accessors_and_error_types_are_stable() {
- let config = parse_runtime_config_json(
- r#"{
- "server": {
- "listen_addr": "127.0.0.1:7002",
- "relay_url": "ws://127.0.0.1:7002"
- },
- "database": {
- "mode": "memory",
- "namespace": "tangle_accessors",
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 120
- },
- "limits": {
- "message_rate_limit": {
- "limit": 3,
- "window_seconds": 5
- }
- }
- }"#,
- )
- .expect("runtime config");
- let store = runtime_memory_store().await;
- let listings_state = config.listings_state(store);
- let config_error = super::RuntimeConfigError::read("missing config");
- let command_errors = [
- RuntimeCommandError::unsupported("not supported"),
- RuntimeCommandError::input("bad input"),
- RuntimeCommandError::store("store failed"),
- ];
- let server_report = RuntimeServerReport::new("127.0.0.1:7002".parse().expect("addr"));
-
- assert_eq!(config.tracing_config().format().as_str(), "compact");
- assert_eq!(RuntimeTracingFormat::Json.as_str(), "json");
- assert_eq!(listings_state.limits, RuntimeLimits::default());
- assert_eq!(config_error.kind(), RuntimeConfigErrorKind::Read);
- assert_eq!(config_error.message(), "missing config");
- assert_eq!(config_error.to_string(), "Read: missing config");
- assert_eq!(
- command_errors[0].kind(),
- RuntimeCommandErrorKind::Unsupported
- );
- assert_eq!(command_errors[1].kind(), RuntimeCommandErrorKind::Input);
- assert_eq!(command_errors[2].kind(), RuntimeCommandErrorKind::Store);
- assert_eq!(command_errors[0].message(), "not supported");
- assert_eq!(command_errors[1].to_string(), "Input: bad input");
- assert_eq!(server_report.listen_addr().to_string(), "127.0.0.1:7002");
- }
-
- #[test]
- fn runtime_config_loader_parses_memory_config() {
- let config = parse_runtime_config_json(
- r#"{
- "server": {
- "listen_addr": "127.0.0.1:7000",
- "relay_url": "ws://127.0.0.1:7000"
- },
- "database": {
- "mode": "memory",
- "namespace": "tangle_test",
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 120
- },
- "limits": {
- "message_rate_limit": {
- "limit": 3,
- "window_seconds": 5
- },
- "runtime": {
- "max_event_bytes": 2048,
- "max_content_bytes": 1024,
- "max_tags_per_event": 32,
- "max_tag_values_per_tag": 8,
- "max_tag_value_bytes": 256,
- "max_filters_per_subscription": 4,
- "max_subscriptions_per_connection": 8,
- "max_search_query_bytes": 128,
- "max_search_tokens": 6,
- "max_filter_complexity": 64,
- "max_future_seconds": 60,
- "live_event_buffer": 128,
- "pending_store_events": 256
- }
- },
- "policy": {
- "admin_pubkeys": [
- "1111111111111111111111111111111111111111111111111111111111111111"
- ],
- "write_rate_limit": {
- "limit": 2,
- "window_seconds": 60
- }
- }
- }"#,
- )
- .expect("runtime config");
- let (shutdown, _) = GracefulShutdownSignal::new();
- let websocket_state = config.websocket_state(shutdown);
-
- assert_eq!(config.listen_addr().to_string(), "127.0.0.1:7000");
- assert_eq!(
- config.relay_connection_config().relay_url(),
- "ws://127.0.0.1:7000"
- );
- assert_eq!(config.relay_connection_config().auth_ttl_seconds(), 120);
- assert_eq!(
- config.relay_connection_config().message_rate_limit(),
- RateLimitConfig::new(3, 5).expect("rate")
- );
- assert_eq!(config.limits().max_event_bytes(), 2048);
- assert_eq!(config.limits().max_content_bytes(), 1024);
- assert_eq!(config.limits().max_tags_per_event(), 32);
- assert_eq!(config.limits().max_tag_values_per_tag(), 8);
- assert_eq!(config.limits().max_tag_value_bytes(), 256);
- assert_eq!(config.limits().max_filters_per_subscription(), 4);
- assert_eq!(config.limits().max_subscriptions_per_connection(), 8);
- assert_eq!(config.limits().max_search_query_bytes(), 128);
- assert_eq!(config.limits().max_search_tokens(), 6);
- assert_eq!(config.limits().max_filter_complexity(), 64);
- assert_eq!(config.limits().max_future_seconds(), 60);
- assert_eq!(config.limits().live_event_buffer(), 128);
- assert_eq!(config.limits().pending_store_events(), 256);
- assert_eq!(
- config.durable_write_rate_limit(),
- Some(RateLimitConfig::new(2, 60).expect("write limit"))
- );
- assert!(
- config.admin_pubkeys().contains(
- &PublicKeyHex::new(
- "1111111111111111111111111111111111111111111111111111111111111111"
- )
- .expect("admin pubkey")
- )
- );
- assert_eq!(config.database_config().namespace(), "tangle_test");
- assert_eq!(config.database_config().database(), "relay");
- assert_eq!(
- config.database_config().mode(),
- &SurrealConnectionMode::Memory
- );
- assert_eq!(
- websocket_state.connection_config(),
- config.relay_connection_config()
- );
- assert!(!config.tracing_config().enabled());
- assert_eq!(
- config.tracing_config().filter(),
- "info,tangle=info,tangle_runtime=info"
- );
- assert_eq!(
- config.tracing_config().format(),
- RuntimeTracingFormat::Compact
- );
- }
-
- #[test]
- fn runtime_config_loader_parses_websocket_database_config() {
- let config = parse_runtime_config_json(
- r#"{
- "server": {
- "listen_addr": "127.0.0.1:7100",
- "relay_url": "wss://relay.radroots.test"
- },
- "database": {
- "mode": "web_socket",
- "endpoint": "ws://127.0.0.1:8000",
- "username": "root",
- "password": "root",
- "namespace": "tangle",
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- }
- }"#,
- )
- .expect("runtime config");
-
- assert_eq!(
- config.database_config().mode(),
- &SurrealConnectionMode::WebSocket {
- endpoint: "ws://127.0.0.1:8000".to_owned()
- }
- );
- let credentials = config
- .database_config()
- .root_credentials()
- .expect("root credentials");
- assert_eq!(credentials.username(), "root");
- assert_eq!(credentials.password(), "root");
- assert_eq!(config.limits(), RuntimeLimits::default());
- }
-
- #[test]
- fn runtime_config_loader_parses_local_surrealdb_stack_config() {
- let config = parse_runtime_config_json(include_str!(
- "../../../ops/local/surrealdb/tangle-runtime.json"
- ))
- .expect("local stack config");
-
- assert_eq!(config.listen_addr().to_string(), "127.0.0.1:7000");
- assert_eq!(
- config.database_config().mode(),
- &SurrealConnectionMode::Http {
- endpoint: "http://127.0.0.1:8000".to_owned()
- }
- );
- let credentials = config
- .database_config()
- .root_credentials()
- .expect("root credentials");
- assert_eq!(credentials.username(), "root");
- assert_eq!(credentials.password(), "root");
- assert_eq!(config.database_config().namespace(), "tangle_local");
- assert_eq!(config.database_config().database(), "relay");
- assert!(config.tracing_config().enabled());
- assert_eq!(config.tracing_config().format(), RuntimeTracingFormat::Json);
- assert_eq!(
- config.durable_write_rate_limit(),
- Some(RateLimitConfig::new(60, 60).expect("write limit"))
- );
- assert!(config.admission_policy().require_write_auth());
- }
-
- #[test]
- fn runtime_config_loader_parses_production_config_example() {
- let config = parse_runtime_config_json(include_str!(
- "../../../ops/production/tangle-runtime.example.json"
- ))
- .expect("production example config");
-
- assert_eq!(config.listen_addr().to_string(), "0.0.0.0:7000");
- assert_eq!(
- config.database_config().mode(),
- &SurrealConnectionMode::Http {
- endpoint: "http://surrealdb:8000".to_owned()
- }
- );
- let credentials = config
- .database_config()
- .root_credentials()
- .expect("root credentials");
- assert_eq!(credentials.username(), "replace_with_surreal_root_user");
- assert_eq!(credentials.password(), "replace_with_surreal_root_password");
- assert_eq!(config.database_config().namespace(), "tangle");
- assert_eq!(config.database_config().database(), "relay");
- assert_eq!(config.tracing_config().format(), RuntimeTracingFormat::Json);
- assert_eq!(
- config.durable_write_rate_limit(),
- Some(RateLimitConfig::new(300, 60).expect("write limit"))
- );
- assert!(config.admission_policy().require_write_auth());
- assert!(config.admin_pubkeys().contains(
- &PublicKeyHex::new(&"a".repeat(PublicKeyHex::HEX_LENGTH)).expect("admin pubkey")
- ));
- }
-
- #[test]
- fn runtime_config_loader_parses_observability_tracing_config() {
- let config = parse_runtime_config_json(
- r#"{
- "server": {
- "listen_addr": "127.0.0.1:7101",
- "relay_url": "wss://relay.radroots.test"
- },
- "database": {
- "mode": "memory",
- "namespace": "tangle",
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- },
- "observability": {
- "tracing": {
- "enabled": true,
- "filter": "info,tangle=debug,tangle_runtime=debug",
- "format": "json"
- }
- }
- }"#,
- )
- .expect("runtime config");
-
- assert!(config.tracing_config().enabled());
- assert_eq!(
- config.tracing_config().filter(),
- "info,tangle=debug,tangle_runtime=debug"
- );
- assert_eq!(config.tracing_config().format(), RuntimeTracingFormat::Json);
- }
-
- #[test]
- fn runtime_config_loader_rejects_invalid_documents() {
- let parse_error = parse_runtime_config_json("{").expect_err("parse");
- let invalid_listen = parse_runtime_config_json(
- r#"{
- "server": {
- "listen_addr": "not a socket",
- "relay_url": "ws://127.0.0.1:7000"
- },
- "database": {
- "mode": "memory",
- "namespace": "tangle",
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- }
- }"#,
- )
- .expect_err("listen");
- let missing_endpoint = parse_runtime_config_json(
- r#"{
- "server": {
- "listen_addr": "127.0.0.1:7000",
- "relay_url": "ws://127.0.0.1:7000"
- },
- "database": {
- "mode": "http",
- "namespace": "tangle",
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- }
- }"#,
- )
- .expect_err("endpoint");
- let missing_credentials = parse_runtime_config_json(
- r#"{
- "server": {
- "listen_addr": "127.0.0.1:7000",
- "relay_url": "ws://127.0.0.1:7000"
- },
- "database": {
- "mode": "http",
- "endpoint": "http://127.0.0.1:8000",
- "namespace": "tangle",
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- }
- }"#,
- )
- .expect_err("credentials");
- let empty_tracing_filter = parse_runtime_config_json(
- r#"{
- "server": {
- "listen_addr": "127.0.0.1:7000",
- "relay_url": "ws://127.0.0.1:7000"
- },
- "database": {
- "mode": "memory",
- "namespace": "tangle",
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- },
- "observability": {
- "tracing": {
- "enabled": true,
- "filter": " "
- }
- }
- }"#,
- )
- .expect_err("tracing filter");
-
- assert_eq!(parse_error.kind(), RuntimeConfigErrorKind::Parse);
- assert!(
- parse_error
- .message()
- .starts_with("runtime config JSON is invalid:")
- );
- assert_eq!(invalid_listen.kind(), RuntimeConfigErrorKind::Invalid);
- assert!(
- invalid_listen
- .message()
- .starts_with("server.listen_addr is invalid:")
- );
- assert_eq!(missing_endpoint.kind(), RuntimeConfigErrorKind::Invalid);
- assert_eq!(
- missing_endpoint.message(),
- "database.endpoint is required for http mode"
- );
- assert_eq!(missing_credentials.kind(), RuntimeConfigErrorKind::Invalid);
- assert_eq!(
- missing_credentials.message(),
- "database.username is required for http mode"
- );
- assert_eq!(empty_tracing_filter.kind(), RuntimeConfigErrorKind::Invalid);
- assert_eq!(
- empty_tracing_filter.message(),
- "observability.tracing.filter must not be empty"
- );
- }
-
- #[test]
- fn runtime_config_loader_rejects_mode_specific_database_and_policy_edges() {
- let cases = [
- (
- r#"{
- "server": {"listen_addr": "127.0.0.1:7102", "relay_url": "ws://127.0.0.1:7102"},
- "database": {"mode": "memory", "endpoint": "mem://ignored", "namespace": "tangle", "database": "relay"},
- "auth": {"challenge_ttl_seconds": 300},
- "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}}
- }"#,
- "database.endpoint must be omitted for memory mode",
- ),
- (
- r#"{
- "server": {"listen_addr": "127.0.0.1:7109", "relay_url": "ws://127.0.0.1:7109"},
- "database": {"mode": "memory", "path": "db", "namespace": "tangle", "database": "relay"},
- "auth": {"challenge_ttl_seconds": 300},
- "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}}
- }"#,
- "database.path must be omitted for memory mode",
- ),
- (
- r#"{
- "server": {"listen_addr": "127.0.0.1:7110", "relay_url": "ws://127.0.0.1:7110"},
- "database": {"mode": "memory", "username": "root", "namespace": "tangle", "database": "relay"},
- "auth": {"challenge_ttl_seconds": 300},
- "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}}
- }"#,
- "database credentials must be omitted for memory mode",
- ),
- (
- r#"{
- "server": {"listen_addr": "127.0.0.1:7103", "relay_url": "ws://127.0.0.1:7103"},
- "database": {"mode": "rocks_db", "endpoint": "http://127.0.0.1:8000", "path": "db", "namespace": "tangle", "database": "relay"},
- "auth": {"challenge_ttl_seconds": 300},
- "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}}
- }"#,
- "database.endpoint must be omitted for rocksdb mode",
- ),
- (
- r#"{
- "server": {"listen_addr": "127.0.0.1:7104", "relay_url": "ws://127.0.0.1:7104"},
- "database": {"mode": "rocks_db", "username": "root", "path": "db", "namespace": "tangle", "database": "relay"},
- "auth": {"challenge_ttl_seconds": 300},
- "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}}
- }"#,
- "database credentials must be omitted for rocksdb mode",
- ),
- (
- r#"{
- "server": {"listen_addr": "127.0.0.1:7105", "relay_url": "ws://127.0.0.1:7105"},
- "database": {"mode": "rocks_db", "namespace": "tangle", "database": "relay"},
- "auth": {"challenge_ttl_seconds": 300},
- "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}}
- }"#,
- "database.path is required for rocksdb mode",
- ),
- (
- r#"{
- "server": {"listen_addr": "127.0.0.1:7106", "relay_url": "ws://127.0.0.1:7106"},
- "database": {"mode": "http", "endpoint": "http://127.0.0.1:8000", "username": "root", "namespace": "tangle", "database": "relay"},
- "auth": {"challenge_ttl_seconds": 300},
- "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}}
- }"#,
- "database.password is required for http mode",
- ),
- (
- r#"{
- "server": {"listen_addr": "127.0.0.1:7107", "relay_url": "ws://127.0.0.1:7107"},
- "database": {"mode": "web_socket", "username": "root", "password": "root", "namespace": "tangle", "database": "relay"},
- "auth": {"challenge_ttl_seconds": 300},
- "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}}
- }"#,
- "database.endpoint is required for websocket mode",
- ),
- (
- r#"{
- "server": {"listen_addr": "127.0.0.1:7108", "relay_url": "ws://127.0.0.1:7108"},
- "database": {"mode": "memory", "namespace": "tangle", "database": "relay"},
- "auth": {"challenge_ttl_seconds": 300},
- "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}},
- "policy": {"admin_pubkeys": ["bad"]}
- }"#,
- "policy.admin_pubkeys contains invalid pubkey: public key must be 64 characters, got 3",
- ),
- (
- r#"{
- "server": {"listen_addr": "127.0.0.1:7111", "relay_url": "ws://127.0.0.1:7111"},
- "database": {"mode": "memory", "namespace": "tangle", "database": "relay"},
- "auth": {"challenge_ttl_seconds": 300},
- "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}},
- "policy": {"approved_sellers": ["bad"]}
- }"#,
- "policy.approved_sellers contains invalid pubkey: public key must be 64 characters, got 3",
- ),
- (
- r#"{
- "server": {"listen_addr": "127.0.0.1:7112", "relay_url": "ws://127.0.0.1:7112"},
- "database": {"mode": "memory", "namespace": "tangle", "database": "relay"},
- "auth": {"challenge_ttl_seconds": 300},
- "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}},
- "policy": {"blocked_pubkeys": ["bad"]}
- }"#,
- "policy.blocked_pubkeys contains invalid pubkey: public key must be 64 characters, got 3",
- ),
- ];
-
- for (raw, expected) in cases {
- let error = parse_runtime_config_json(raw).expect_err(expected);
- assert_eq!(error.kind(), RuntimeConfigErrorKind::Invalid);
- assert_eq!(error.message(), expected);
- }
- }
-
- #[test]
- fn runtime_config_loader_parses_compact_tracing_format() {
- let config = parse_runtime_config_json(
- r#"{
- "server": {"listen_addr": "127.0.0.1:7113", "relay_url": "ws://127.0.0.1:7113"},
- "database": {"mode": "memory", "namespace": "tangle", "database": "relay"},
- "auth": {"challenge_ttl_seconds": 300},
- "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}},
- "observability": {
- "tracing": {
- "enabled": true,
- "filter": "info",
- "format": "compact"
- }
- }
- }"#,
- )
- .expect("runtime config");
-
- assert!(config.tracing_config().enabled());
- assert_eq!(
- config.tracing_config().format(),
- RuntimeTracingFormat::Compact
- );
- }
-
- #[test]
- fn event_import_document_parser_accepts_json_and_jsonl_edges() {
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let raw = event_to_value(&listing).to_string();
- let mut report = super::RuntimeEventImportReport::default();
-
- assert!(
- super::parse_event_import_document(" \n ")
- .expect("empty")
- .is_empty()
- );
- assert_eq!(
- super::parse_event_import_document(&raw)
- .expect("object")
- .first()
- .expect("event")
- .id(),
- listing.id()
- );
- assert_eq!(
- super::parse_event_import_document(&format!("[{raw}]"))
- .expect("array")
- .len(),
- 1
- );
- assert_eq!(
- super::parse_event_import_document(&format!("{raw}\n\n{raw}"))
- .expect("jsonl")
- .len(),
- 2
- );
- assert_eq!(
- super::parse_event_import_document("42")
- .expect_err("scalar")
- .message(),
- "event import file must contain event objects"
- );
- assert!(
- super::parse_event_import_document(r#"[{"id":"bad"}]"#)
- .expect_err("bad item")
- .message()
- .starts_with("event import item 1 is invalid:")
- );
- assert!(
- super::parse_event_import_document("{bad")
- .expect_err("bad line")
- .message()
- .starts_with("event import line 1 is invalid:")
- );
-
- report.record(RuntimeEventImportOutcome::Inserted { projected: true });
- report.record(RuntimeEventImportOutcome::Inserted { projected: false });
- report.record(RuntimeEventImportOutcome::Duplicate);
- report.record(RuntimeEventImportOutcome::Skipped);
- assert_eq!(report.total(), 4);
- assert_eq!(report.inserted(), 2);
- assert_eq!(report.duplicate(), 1);
- assert_eq!(report.projected(), 1);
- assert_eq!(report.skipped(), 1);
- }
-
- #[tokio::test]
- async fn import_and_rebuild_helpers_record_skipped_event_outcomes() {
- let store = runtime_memory_store().await;
- let validator = EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(FixtureKey::Seller.public_key()),
- );
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_124_435,
- 22_242,
- vec![
- vec!["relay".to_owned(), "ws://127.0.0.1:0".to_owned()],
- vec!["challenge".to_owned(), "challenge-001".to_owned()],
- ],
- "",
- )
- .expect("auth");
- let ephemeral = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_124_440,
- 20_000,
- Vec::new(),
- "ephemeral",
- )
- .expect("ephemeral");
- let mut rebuild_report = super::RuntimeProjectionRebuildReport::default();
- assert_eq!(
- validator
- .validate(
- &auth,
- &AdmissionContext::unauthenticated(),
- UnixTimestamp::new(1_714_124_500)
- )
- .expect("auth validates")
- .admission()
- .effect(),
- AdmissionEffect::AuthenticateOnly
- );
-
- assert_eq!(
- super::import_single_event(&store, &validator, listing.clone(), UnixTimestamp::new(1))
- .await
- .expect("invalid skip"),
- RuntimeEventImportOutcome::Skipped
- );
- assert_eq!(
- super::import_single_event(
- &store,
- &validator,
- auth.clone(),
- UnixTimestamp::new(1_714_124_500)
- )
- .await
- .expect("auth skip"),
- RuntimeEventImportOutcome::Skipped
- );
- assert_eq!(
- super::import_single_event(
- &store,
- &validator,
- ephemeral.clone(),
- UnixTimestamp::new(1_714_124_500)
- )
- .await
- .expect("ephemeral skip"),
- RuntimeEventImportOutcome::Skipped
- );
- assert_eq!(
- super::rebuild_single_event_projection(
- &store,
- &validator,
- listing,
- UnixTimestamp::new(1)
- )
- .await
- .expect("invalid rebuild skip"),
- RuntimeProjectionRebuildOutcome::Skipped
- );
- assert_eq!(
- super::rebuild_single_event_projection(
- &store,
- &validator,
- auth,
- UnixTimestamp::new(1_714_124_500)
- )
- .await
- .expect("auth rebuild skip"),
- RuntimeProjectionRebuildOutcome::Skipped
- );
- assert_eq!(
- super::rebuild_single_event_projection(
- &store,
- &validator,
- ephemeral,
- UnixTimestamp::new(1_714_124_500)
- )
- .await
- .expect("ephemeral rebuild skip"),
- RuntimeProjectionRebuildOutcome::Skipped
- );
-
- rebuild_report.record(RuntimeProjectionRebuildOutcome::Rebuilt { projected: true });
- rebuild_report.record(RuntimeProjectionRebuildOutcome::Rebuilt { projected: false });
- rebuild_report.record(RuntimeProjectionRebuildOutcome::Skipped);
- assert_eq!(rebuild_report.scanned(), 3);
- assert_eq!(rebuild_report.rebuilt(), 2);
- assert_eq!(rebuild_report.projected(), 1);
- assert_eq!(rebuild_report.skipped(), 1);
- }
-
- #[test]
- fn runtime_config_loader_reads_config_file() {
- let path = std::env::temp_dir().join(format!(
- "tangle-runtime-config-loader-{}.json",
- std::process::id()
- ));
- std::fs::write(
- &path,
- r#"{
- "server": {
- "listen_addr": "127.0.0.1:7200",
- "relay_url": "ws://127.0.0.1:7200"
- },
- "database": {
- "mode": "memory",
- "namespace": "tangle_file",
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- }
- }"#,
- )
- .expect("write config");
-
- let config = load_runtime_config(&path).expect("loaded config");
- std::fs::remove_file(&path).expect("remove config");
-
- assert_eq!(config.listen_addr().to_string(), "127.0.0.1:7200");
- assert_eq!(config.database_config().namespace(), "tangle_file");
- assert_eq!(
- load_runtime_config(&path).expect_err("missing").kind(),
- RuntimeConfigErrorKind::Read
- );
- }
-
- #[tokio::test]
- async fn runtime_migration_command_applies_memory_database_plan() {
- let config = parse_runtime_config_json(
- r#"{
- "server": {
- "listen_addr": "127.0.0.1:7300",
- "relay_url": "ws://127.0.0.1:7300"
- },
- "database": {
- "mode": "memory",
- "namespace": "tangle_migrate",
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- }
- }"#,
- )
- .expect("runtime config");
-
- let report = migrate_runtime_database(&config).await.expect("migrate");
-
- assert_eq!(
- report.applied(),
- base_migration_plan().migrations().len() as u64
- );
- assert_eq!(report.already_applied(), 0);
- assert_eq!(
- report.total(),
- base_migration_plan().migrations().len() as u64
- );
- }
-
- #[tokio::test]
- async fn runtime_backup_command_writes_manifest_and_raw_event_jsonl() {
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let root = std::env::temp_dir().join(format!(
- "tangle-runtime-backup-{}-{}",
- std::process::id(),
- &listing.id().as_str()[..8]
- ));
- let _ = std::fs::remove_dir_all(&root);
- let db_path = root.join("db");
- let backup_path = root.join("backup");
- std::fs::create_dir_all(&root).expect("runtime root");
- let config_json = serde_json::json!({
- "server": {
- "listen_addr": "127.0.0.1:7301",
- "relay_url": "ws://127.0.0.1:7301"
- },
- "database": {
- "mode": "rocks_db",
- "path": db_path.to_str().expect("db path"),
- "namespace": "tangle_backup",
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- },
- "policy": {
- "approved_sellers": [FixtureKey::Seller.public_key().as_str()]
- }
- });
- let config = parse_runtime_config_json(
- &serde_json::to_string(&config_json).expect("runtime config JSON"),
- )
- .expect("runtime config");
- let store = SurrealStore::connect(config.database_config())
- .await
- .expect("store");
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- assert_eq!(
- store
- .store_raw_event(&StoredEvent::new(
- listing.clone(),
- UnixTimestamp::new(1_714_124_500)
- ))
- .await
- .expect("store raw"),
- StoreEventOutcome::Inserted
- );
- let report = backup_runtime_store(&config, &store, &backup_path)
- .await
- .expect("backup");
-
- assert_eq!(report.output_dir(), backup_path.as_path());
- assert_eq!(
- report.raw_events_path(),
- backup_path.join("raw-events.jsonl")
- );
- assert_eq!(report.raw_event_count(), 1);
- assert_eq!(report.raw_events_sha256().len(), 64);
- assert_eq!(report.manifest_path(), backup_path.join("manifest.json"));
- assert_eq!(report.manifest_sha256().len(), 64);
- assert!(!report.surrealdb_export_available());
- assert_eq!(
- std::fs::read_to_string(report.raw_events_path()).expect("raw events"),
- format!("{}\n", event_to_value(&listing))
- );
- let manifest: serde_json::Value = serde_json::from_str(
- &std::fs::read_to_string(report.manifest_path()).expect("manifest"),
- )
- .expect("manifest JSON");
- assert_eq!(manifest["format"], "tangle-backup-v1");
- assert_eq!(manifest["database"]["namespace"], "tangle_backup");
- assert_eq!(manifest["database"]["database"], "relay");
- assert_eq!(manifest["raw_events"]["path"], "raw-events.jsonl");
- assert_eq!(manifest["raw_events"]["count"], 1);
- assert_eq!(manifest["raw_events"]["sha256"], report.raw_events_sha256());
- assert_eq!(manifest["surrealdb_export"]["available"], false);
- assert!(manifest["surrealdb_export"]["path"].is_null());
- assert!(manifest["surrealdb_export"]["sha256"].is_null());
-
- assert!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("raw row")
- .is_some()
- );
- drop(store);
- std::fs::remove_dir_all(&root).expect("remove runtime root");
- }
-
- #[tokio::test]
- async fn runtime_restore_command_imports_backup_and_rebuilds_projection_state() {
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let root = std::env::temp_dir().join(format!(
- "tangle-runtime-restore-{}-{}",
- std::process::id(),
- &listing.id().as_str()[..8]
- ));
- let _ = std::fs::remove_dir_all(&root);
- let source_db_path = root.join("source-db");
- let restore_db_path = root.join("restore-db");
- let backup_path = root.join("backup");
- std::fs::create_dir_all(&root).expect("runtime root");
- let source_config_json = serde_json::json!({
- "server": {
- "listen_addr": "127.0.0.1:7302",
- "relay_url": "ws://127.0.0.1:7302"
- },
- "database": {
- "mode": "rocks_db",
- "path": source_db_path.to_str().expect("source db path"),
- "namespace": "tangle_restore_source",
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- },
- "policy": {
- "approved_sellers": [FixtureKey::Seller.public_key().as_str()]
- }
- });
- let restore_config_json = serde_json::json!({
- "server": {
- "listen_addr": "127.0.0.1:7303",
- "relay_url": "ws://127.0.0.1:7303"
- },
- "database": {
- "mode": "rocks_db",
- "path": restore_db_path.to_str().expect("restore db path"),
- "namespace": "tangle_restore_destination",
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- },
- "policy": {
- "approved_sellers": [FixtureKey::Seller.public_key().as_str()]
- }
- });
- let source_config = parse_runtime_config_json(
- &serde_json::to_string(&source_config_json).expect("source config JSON"),
- )
- .expect("source config");
- let restore_config = parse_runtime_config_json(
- &serde_json::to_string(&restore_config_json).expect("restore config JSON"),
- )
- .expect("restore config");
- let source_store = SurrealStore::connect(source_config.database_config())
- .await
- .expect("source store");
- source_store
- .apply_plan(&base_migration_plan())
- .await
- .expect("source migrations");
- assert_eq!(
- source_store
- .store_raw_event(&StoredEvent::new(
- listing.clone(),
- UnixTimestamp::new(1_714_124_500)
- ))
- .await
- .expect("source raw event"),
- StoreEventOutcome::Inserted
- );
- let backup_report = backup_runtime_store(&source_config, &source_store, &backup_path)
- .await
- .expect("backup");
- assert_eq!(backup_report.raw_event_count(), 1);
- drop(source_store);
-
- let restore_store = SurrealStore::connect(restore_config.database_config())
- .await
- .expect("restore store");
- let restore_report = restore_runtime_store(&restore_config, &restore_store, &backup_path)
- .await
- .expect("restore");
- assert_eq!(restore_report.raw_event_count(), 1);
- assert_eq!(restore_report.import_report().inserted(), 1);
- assert_eq!(restore_report.import_report().duplicate(), 0);
- assert_eq!(restore_report.rebuild_report().rebuilt(), 1);
- assert_eq!(restore_report.rebuild_report().projected(), 1);
- assert_eq!(
- restore_report.raw_events_sha256(),
- backup_report.raw_events_sha256()
- );
- let seller = FixtureKey::Seller.public_key();
- let listing_key = format!("30402:{}:listing-a", seller.as_str());
- assert!(
- restore_store
- .raw_event_row(listing.id())
- .await
- .expect("raw row")
- .is_some()
- );
- assert!(
- restore_store
- .listing_current_row(&listing_key)
- .await
- .expect("listing row")
- .is_some()
- );
- assert!(
- restore_store
- .search_document_row(&listing_key)
- .await
- .expect("search row")
- .is_some()
- );
- drop(restore_store);
- std::fs::remove_dir_all(&root).expect("remove runtime root");
- }
-
- #[test]
- fn backup_manifest_validation_rejects_invalid_artifact_metadata() {
- let manifest = |format: &str, path: &str| super::RuntimeBackupManifestDocument {
- format: format.to_owned(),
- database: super::RuntimeBackupDatabaseDocument {
- namespace: "tangle".to_owned(),
- database: "relay".to_owned(),
- },
- raw_events: super::RuntimeBackupArtifactDocument {
- path: path.to_owned(),
- count: 0,
- sha256: "0".repeat(64),
- },
- surrealdb_export: super::RuntimeBackupOptionalArtifactDocument {
- available: false,
- path: None,
- sha256: None,
- },
- };
-
- assert_eq!(
- super::validate_backup_manifest(&manifest("old", "raw-events.jsonl"))
- .expect_err("format")
- .message(),
- "backup manifest format is unsupported: old"
- );
- assert_eq!(
- super::validate_backup_manifest(&manifest("tangle-backup-v1", " "))
- .expect_err("path")
- .message(),
- "backup manifest raw_events.path must not be empty"
- );
- assert_eq!(
- super::backup_artifact_path(std::path::Path::new("backup"), "../raw-events.jsonl")
- .expect_err("parent")
- .message(),
- "backup manifest artifact paths must be relative to the backup directory"
- );
- assert!(
- super::backup_artifact_path(std::path::Path::new("backup"), "/raw-events.jsonl")
- .expect_err("absolute")
- .message()
- .contains("relative")
- );
- assert_eq!(
- super::backup_artifact_path(std::path::Path::new("backup"), "raw-events.jsonl")
- .expect("path"),
- std::path::Path::new("backup").join("raw-events.jsonl")
- );
- assert_eq!(
- super::runtime_row_string(&serde_json::json!({"raw_json": null}), "raw_json")
- .expect_err("row")
- .message(),
- "stored row field `raw_json` is invalid"
- );
- }
-
- #[tokio::test]
- async fn runtime_file_commands_report_io_and_manifest_validation_failures() {
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let root = std::env::temp_dir().join(format!(
- "tangle-runtime-file-errors-{}-{}",
- std::process::id(),
- &listing.id().as_str()[..8]
- ));
- let _ = std::fs::remove_dir_all(&root);
- std::fs::create_dir_all(&root).expect("runtime root");
- let config = runtime_memory_config("tangle_file_errors");
- let store = SurrealStore::connect(config.database_config())
- .await
- .expect("store");
-
- assert!(
- super::import_events_from_path(&config, root.join("missing.jsonl"))
- .await
- .expect_err("missing import")
- .message()
- .starts_with("failed to read event import file `")
- );
- assert!(
- super::export_events_to_path(&config, &root)
- .await
- .expect_err("export dir")
- .message()
- .starts_with("failed to write event export file `")
- );
-
- let file_output = root.join("file-output");
- std::fs::write(&file_output, "not a directory").expect("file output");
- assert!(
- super::backup_runtime_store(&config, &store, &file_output)
- .await
- .expect_err("backup create dir")
- .message()
- .starts_with("failed to create backup directory `")
- );
-
- let raw_dir_output = root.join("raw-dir-output");
- std::fs::create_dir_all(raw_dir_output.join("raw-events.jsonl")).expect("raw dir");
- assert!(
- super::backup_runtime_store(&config, &store, &raw_dir_output)
- .await
- .expect_err("backup raw write")
- .message()
- .starts_with("failed to write backup raw events file `")
- );
-
- let manifest_dir_output = root.join("manifest-dir-output");
- std::fs::create_dir_all(manifest_dir_output.join("manifest.json")).expect("manifest dir");
- assert!(
- super::backup_runtime_store(&config, &store, &manifest_dir_output)
- .await
- .expect_err("backup manifest write")
- .message()
- .starts_with("failed to write backup manifest file `")
- );
-
- let missing_manifest = root.join("missing-manifest");
- std::fs::create_dir_all(&missing_manifest).expect("missing manifest dir");
- assert!(
- super::restore_runtime_store(&config, &store, &missing_manifest)
- .await
- .expect_err("missing manifest")
- .message()
- .starts_with("failed to read backup manifest file `")
- );
-
- let invalid_manifest = root.join("invalid-manifest");
- std::fs::create_dir_all(&invalid_manifest).expect("invalid manifest dir");
- std::fs::write(invalid_manifest.join("manifest.json"), "{").expect("invalid manifest");
- assert!(
- super::restore_runtime_store(&config, &store, &invalid_manifest)
- .await
- .expect_err("invalid manifest")
- .message()
- .starts_with("backup manifest JSON is invalid:")
- );
-
- let restore_case = |name: &str, raw_events: &str, manifest: serde_json::Value| {
- let path = root.join(name);
- std::fs::create_dir_all(&path).expect("restore case dir");
- std::fs::write(path.join("raw-events.jsonl"), raw_events).expect("raw events");
- std::fs::write(
- path.join("manifest.json"),
- serde_json::to_string_pretty(&manifest).expect("manifest JSON"),
- )
- .expect("manifest");
- path
- };
- let missing_raw = restore_case(
- "missing-raw",
- "",
- serde_json::json!({
- "format": "tangle-backup-v1",
- "database": {"namespace": "tangle", "database": "relay"},
- "raw_events": {"path": "absent.jsonl", "count": 0, "sha256": "0".repeat(64)},
- "surrealdb_export": {"available": false, "path": null, "sha256": null}
- }),
- );
- assert!(
- super::restore_runtime_store(&config, &store, &missing_raw)
- .await
- .expect_err("missing raw")
- .message()
- .starts_with("failed to read backup raw events file `")
- );
- let checksum = restore_case(
- "checksum",
- "",
- serde_json::json!({
- "format": "tangle-backup-v1",
- "database": {"namespace": "tangle", "database": "relay"},
- "raw_events": {"path": "raw-events.jsonl", "count": 0, "sha256": "1".repeat(64)},
- "surrealdb_export": {"available": false, "path": null, "sha256": null}
- }),
- );
- assert!(
- super::restore_runtime_store(&config, &store, &checksum)
- .await
- .expect_err("checksum")
- .message()
- .starts_with("backup raw events checksum mismatch:")
- );
- let raw = format!("{}\n", event_to_value(&listing));
- let count = restore_case(
- "count",
- &raw,
- serde_json::json!({
- "format": "tangle-backup-v1",
- "database": {"namespace": "tangle", "database": "relay"},
- "raw_events": {"path": "raw-events.jsonl", "count": 2, "sha256": super::sha256_hex(raw.as_bytes())},
- "surrealdb_export": {"available": false, "path": null, "sha256": null}
- }),
- );
- assert!(
- super::restore_runtime_store(&config, &store, &count)
- .await
- .expect_err("count")
- .message()
- .starts_with("backup raw events count mismatch:")
- );
-
- std::fs::remove_dir_all(&root).expect("remove runtime root");
- }
-
- #[tokio::test]
- async fn runtime_websocket_route_requires_upgrade_headers() {
- let store = runtime_memory_store().await;
- let (shutdown, _) = GracefulShutdownSignal::new();
- let response =
- super::runtime_router(runtime_memory_config("ws_missing_upgrade"), store, shutdown)
- .oneshot(
- Request::builder()
- .uri("/ws")
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
-
- assert_eq!(response.status(), StatusCode::BAD_REQUEST);
- }
-
- #[tokio::test]
- async fn runtime_server_reports_listener_bind_failures() {
- let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
- .await
- .expect("bind reserved listener");
- let address = listener.local_addr().expect("reserved listener address");
- let mut config = runtime_memory_config("runtime_listen_failure");
- config.listen_addr = address;
- let (shutdown, _) = GracefulShutdownSignal::new();
- let error = super::RuntimeServer::new(config, shutdown)
- .run()
- .await
- .expect_err("listen failure");
-
- assert!(error.message().contains("listen failed:"));
- drop(listener);
- }
-
- #[tokio::test]
- async fn runtime_websocket_route_handles_client_frame_edges() {
- let store = runtime_memory_store().await;
- let (shutdown, _) = GracefulShutdownSignal::new();
- let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
- .await
- .expect("bind listener");
- let address = listener.local_addr().expect("listener address");
- let app = super::runtime_router(runtime_memory_config("ws_frame_edges"), store, shutdown);
- let server =
- tokio::spawn(async move { axum::serve(listener, app).await.expect("serve runtime") });
-
- let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://{address}/ws"))
- .await
- .expect("websocket connect");
- assert_eq!(next_ws_json(&mut client, "initial auth").await[0], "AUTH");
- let listing = listing_event_at(1_714_124_436);
- client
- .send(TungsteniteMessage::Text(
- serde_json::json!([
- "REQ",
- "sub-live",
- {
- "kinds": [30402],
- "authors": [listing.unsigned().pubkey().as_str()]
- }
- ])
- .to_string()
- .into(),
- ))
- .await
- .expect("subscription send");
- assert_eq!(
- next_ws_json(&mut client, "subscription eose").await[0],
- "EOSE"
- );
-
- let auth = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_124_435,
- 22_242,
- vec![
- vec!["relay".to_owned(), "ws://127.0.0.1:0".to_owned()],
- vec!["challenge".to_owned(), "challenge-001".to_owned()],
- ],
- "",
- )
- .expect("auth");
- let (mut publisher, _) = tokio_tungstenite::connect_async(format!("ws://{address}/ws"))
- .await
- .expect("publisher connect");
- assert_eq!(
- next_ws_json(&mut publisher, "publisher auth").await[0],
- "AUTH"
- );
- publisher
- .send(TungsteniteMessage::Text(
- serde_json::json!(["AUTH", event_to_value(&auth)])
- .to_string()
- .into(),
- ))
- .await
- .expect("auth send");
- let auth_ok = next_ws_json(&mut publisher, "auth ok").await;
- assert_eq!(auth_ok[0], "OK");
- assert_eq!(auth_ok[2], true, "{auth_ok:?}");
- publisher
- .send(TungsteniteMessage::Text(
- serde_json::json!(["EVENT", event_to_value(&listing)])
- .to_string()
- .into(),
- ))
- .await
- .expect("listing send");
- let listing_ok = next_ws_json(&mut publisher, "listing ok").await;
- assert_eq!(listing_ok[0], "OK");
- assert_eq!(listing_ok[2], true);
- let live = next_ws_json(&mut client, "live event").await;
- assert_eq!(live[0], "EVENT");
- assert_eq!(live[1], "sub-live");
- assert_eq!(live[2]["id"], listing.id().as_str());
- client
- .send(TungsteniteMessage::Ping(vec![1].into()))
- .await
- .expect("ping send");
- client
- .send(TungsteniteMessage::Binary(vec![1].into()))
- .await
- .expect("binary send");
- let notice = next_ws_json(&mut client, "binary notice").await;
- assert_eq!(notice[0], "NOTICE");
- assert!(
- notice[1]
- .as_str()
- .expect("notice message")
- .contains("binary websocket messages are not supported")
- );
- client
- .send(TungsteniteMessage::Close(None))
- .await
- .expect("close send");
- tokio::time::sleep(std::time::Duration::from_millis(50)).await;
- server.abort();
- }
-
- #[test]
- fn client_message_loop_dispatches_supported_text_messages() {
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let mut loop_state = runtime_client_message_loop();
-
- let outcome = loop_state.handle_frame(ClientFrame::Text(
- serde_json::json!(["EVENT", event_to_value(&listing)]).to_string(),
- ));
- assert!(matches!(
- outcome,
- ClientFrameOutcome::Message(ClientMessage::Event(_))
- ));
- assert!(format!("{outcome:?}").contains(listing.id().as_str()));
- let outcome = loop_state.handle_frame(ClientFrame::Text(
- serde_json::json!(["AUTH", event_to_value(&auth)]).to_string(),
- ));
- assert!(matches!(
- outcome,
- ClientFrameOutcome::Message(ClientMessage::Auth(_))
- ));
- assert!(format!("{outcome:?}").contains(auth.id().as_str()));
- let outcome = loop_state.handle_frame(ClientFrame::Text(
- r#"["REQ","sub-a",{"kinds":[30402],"limit":1}]"#.to_owned(),
- ));
- assert!(matches!(
- outcome,
- ClientFrameOutcome::Message(ClientMessage::Req { .. })
- ));
- assert!(format!("{outcome:?}").contains("sub-a"));
- assert!(format!("{outcome:?}").contains("limit: Some(1)"));
- let outcome = loop_state.handle_frame(ClientFrame::Text(r#"["CLOSE","sub-a"]"#.to_owned()));
- assert!(matches!(
- outcome,
- ClientFrameOutcome::Message(ClientMessage::Close(_))
- ));
- assert!(format!("{outcome:?}").contains("sub-a"));
- assert_eq!(loop_state.connection().id().as_str(), "client-loop");
- assert_eq!(
- loop_state.connection_mut().remote_addr(),
- Some("127.0.0.1:7777")
- );
- }
-
- #[test]
- fn client_message_loop_rejects_or_ignores_non_message_frames() {
- let mut loop_state = runtime_client_message_loop();
-
- let outcome = loop_state.handle_frame(ClientFrame::Text("not json".to_owned()));
- assert!(matches!(
- outcome,
- ClientFrameOutcome::Reject(RelayMessage::Notice(_))
- ));
- assert!(format!("{outcome:?}").contains("client message JSON is invalid"));
- assert_eq!(
- loop_state.handle_frame(ClientFrame::Binary(vec![1, 2, 3])),
- ClientFrameOutcome::Reject(RelayMessage::Notice(
- "unsupported: binary websocket messages are not supported".to_owned()
- ))
- );
- assert_eq!(
- loop_state.handle_frame(ClientFrame::Ping(vec![1])),
- ClientFrameOutcome::Ignore
- );
- assert_eq!(
- loop_state.handle_frame(ClientFrame::Pong(vec![2])),
- ClientFrameOutcome::Ignore
- );
- assert_eq!(
- loop_state.handle_frame(ClientFrame::Close),
- ClientFrameOutcome::Close
- );
- }
-
- #[test]
- fn websocket_messages_convert_to_client_frames() {
- assert_eq!(
- super::client_frame_from_message(axum::extract::ws::Message::Text("hi".into())),
- ClientFrame::Text("hi".to_owned())
- );
- assert_eq!(
- super::client_frame_from_message(axum::extract::ws::Message::Binary(vec![1].into())),
- ClientFrame::Binary(vec![1])
- );
- assert_eq!(
- super::client_frame_from_message(axum::extract::ws::Message::Ping(vec![2].into())),
- ClientFrame::Ping(vec![2])
- );
- assert_eq!(
- super::client_frame_from_message(axum::extract::ws::Message::Pong(vec![3].into())),
- ClientFrame::Pong(vec![3])
- );
- assert_eq!(
- super::client_frame_from_message(axum::extract::ws::Message::Close(None)),
- ClientFrame::Close
- );
- }
-
- #[test]
- fn client_message_loop_enforces_backpressure_limits() {
- let config = RelayConnectionConfig::new(
- "wss://relay.radroots.test",
- 300,
- RateLimitConfig::new(2, 60).expect("rate limit"),
- RuntimeLimits::default(),
- )
- .expect("config");
- let connection =
- RelayConnection::new(RelayConnectionId::new("backpressure").expect("id"), config);
- let mut loop_state = ClientMessageLoop::new(connection);
- let frame = || ClientFrame::Text(r#"["REQ","sub-a",{"kinds":[30402]}]"#.to_owned());
-
- assert!(matches!(
- loop_state.handle_frame_at(frame(), UnixTimestamp::new(100)),
- ClientFrameOutcome::Message(ClientMessage::Req { .. })
- ));
- assert!(matches!(
- loop_state.handle_frame_at(frame(), UnixTimestamp::new(100)),
- ClientFrameOutcome::Message(ClientMessage::Req { .. })
- ));
- assert_eq!(
- loop_state.handle_frame_at(frame(), UnixTimestamp::new(100)),
- ClientFrameOutcome::Reject(RelayMessage::Notice(
- "rate-limited: retry after 60 seconds".to_owned()
- ))
- );
- assert_eq!(
- loop_state.handle_frame_at(ClientFrame::Ping(vec![1]), UnixTimestamp::new(100)),
- ClientFrameOutcome::Ignore
- );
- assert!(matches!(
- loop_state.handle_frame_at(frame(), UnixTimestamp::new(160)),
- ClientFrameOutcome::Message(ClientMessage::Req { .. })
- ));
- }
-
- #[tokio::test]
- async fn event_message_handler_stores_and_projects_authenticated_listing() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let mut connection = authenticated_connection();
- let handler = EventMessageHandler::new(
- store.clone(),
- EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(listing.unsigned().pubkey().clone()),
- ),
- );
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
-
- let outcome = handler
- .handle_event(
- &connection,
- listing.clone(),
- UnixTimestamp::new(1_714_125_300),
- UnixTimestamp::new(1_714_125_400),
- )
- .await;
-
- assert_eq!(
- outcome,
- RelayMessage::Ok {
- event_id: listing.id().clone(),
- accepted: true,
- message: String::new()
- }
- );
- assert_eq!(
- handler
- .store()
- .raw_event_row(listing.id())
- .await
- .expect("raw")
- .expect("raw exists")["event_id"],
- listing.id().as_str()
- );
- assert_eq!(
- store
- .listing_current_row(&listing_key)
- .await
- .expect("current")
- .expect("current exists")["event_id"],
- listing.id().as_str()
- );
- assert_eq!(
- store
- .search_document_row(&listing_key)
- .await
- .expect("search")
- .expect("search exists")["event_id"],
- listing.id().as_str()
- );
- assert_eq!(
- handler
- .handle_event(
- &connection,
- listing.clone(),
- UnixTimestamp::new(1_714_125_301),
- UnixTimestamp::new(1_714_125_401),
- )
- .await,
- RelayMessage::Ok {
- event_id: listing.id().clone(),
- accepted: true,
- message: String::new()
- }
- );
- assert_eq!(handler.validator().limits(), RuntimeLimits::default());
- connection.auth_mut().clear_authentication();
- let rejected = handler
- .handle_event(
- &connection,
- listing.clone(),
- UnixTimestamp::new(1_714_125_302),
- UnixTimestamp::new(1_714_125_402),
- )
- .await;
- assert!(matches!(
- rejected,
- RelayMessage::Ok {
- accepted: false,
- ..
- }
- ));
- assert!(format!("{rejected:?}").contains("write authentication required"));
- }
-
- #[tokio::test]
- async fn event_message_handler_persists_durable_write_rate_limits() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let connection = authenticated_connection();
- let handler = EventMessageHandler::new(
- store.clone(),
- EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(listing.unsigned().pubkey().clone()),
- ),
- )
- .with_durable_write_rate_limit(Some(RateLimitConfig::new(1, 60).expect("write rate")));
-
- let accepted = handler
- .handle_event(
- &connection,
- listing.clone(),
- UnixTimestamp::new(1_714_125_500),
- UnixTimestamp::new(1_714_125_500),
- )
- .await;
- let rejected = handler
- .handle_event(
- &connection,
- listing.clone(),
- UnixTimestamp::new(1_714_125_501),
- UnixTimestamp::new(1_714_125_501),
- )
- .await;
-
- assert_eq!(
- handler.durable_write_rate_limit(),
- Some(RateLimitConfig::new(1, 60).expect("write rate"))
- );
- assert_eq!(
- accepted,
- RelayMessage::Ok {
- event_id: listing.id().clone(),
- accepted: true,
- message: String::new()
- }
- );
- assert!(matches!(
- rejected,
- RelayMessage::Ok {
- accepted: false,
- ..
- }
- ));
- assert!(format!("{rejected:?}").contains("rate-limited: retry after 59 seconds"));
- let key = format!("event_write:{}", listing.unsigned().pubkey().as_str());
- let row = store
- .rate_limit_state_row(&key)
- .await
- .expect("rate row")
- .expect("rate row exists");
- assert_eq!(row["key"], key);
- assert_eq!(row["expires_at"], 1_714_125_560_u64);
- assert_eq!(
- serde_json::from_str::<serde_json::Value>(row["state"].as_str().expect("state"))
- .expect("state json"),
- serde_json::json!({
- "started_at": 1714125500_u64,
- "used": 1
- })
- );
- }
-
- #[tokio::test]
- async fn event_message_handler_reports_store_policy_failures() {
- let config = SurrealConnectionConfig::memory("tangle_runtime", "event_policy_failure")
- .expect("memory config");
- let store = SurrealStore::connect_memory(&config)
- .await
- .expect("memory store");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let connection = authenticated_connection();
- let handler = EventMessageHandler::new(
- store,
- EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(listing.unsigned().pubkey().clone()),
- ),
- );
-
- assert!(
- format!(
- "{:?}",
- handler
- .handle_event(
- &connection,
- listing,
- UnixTimestamp::new(1_714_125_600),
- UnixTimestamp::new(1_714_125_601),
- )
- .await
- )
- .contains("policy unavailable")
- );
- }
-
- #[tokio::test]
- async fn event_message_handler_reports_rate_limit_store_and_projection_failures() {
- let connection = authenticated_connection();
- let rate_limit_store = SurrealStore::connect_memory(
- &SurrealConnectionConfig::memory("tangle_runtime", "rate_limit_failure")
- .expect("rate limit config"),
- )
- .await
- .expect("rate limit store");
- rate_limit_store
- .database()
- .query(
- "DEFINE TABLE rate_limit_state SCHEMAFULL; DEFINE FIELD key ON TABLE rate_limit_state TYPE int;",
- )
- .await
- .expect("rate limit schema")
- .check()
- .expect("rate limit schema check");
- let rate_limited = EventMessageHandler::new(
- rate_limit_store,
- EventValidator::new(RuntimeLimits::default(), AdmissionPolicy::new()),
- )
- .with_durable_write_rate_limit(Some(RateLimitConfig::new(1, 60).expect("rate limit")));
- let outcome = rate_limited
- .handle_event(
- &connection,
- note_event(1_714_125_610, "rate limit unavailable"),
- UnixTimestamp::new(1_714_125_611),
- UnixTimestamp::new(1_714_125_611),
- )
- .await;
- assert!(format!("{outcome:?}").contains("rate limit unavailable"));
-
- let store_failure = SurrealStore::connect_memory(
- &SurrealConnectionConfig::memory("tangle_runtime", "store_failure")
- .expect("store config"),
- )
- .await
- .expect("store failure store");
- store_failure
- .database()
- .query(
- "DEFINE TABLE nostr_event SCHEMAFULL; DEFINE FIELD event_id ON TABLE nostr_event TYPE int;",
- )
- .await
- .expect("store schema")
- .check()
- .expect("store schema check");
- let store_handler = EventMessageHandler::new(
- store_failure,
- EventValidator::new(RuntimeLimits::default(), AdmissionPolicy::new()),
- );
- let outcome = store_handler
- .handle_event(
- &connection,
- note_event(1_714_125_620, "store unavailable"),
- UnixTimestamp::new(1_714_125_621),
- UnixTimestamp::new(1_714_125_621),
- )
- .await;
- assert!(format!("{outcome:?}").contains("store unavailable"));
-
- let projection_store = runtime_memory_store().await;
- projection_store
- .database()
- .query(
- "REMOVE TABLE event_tag_index; DEFINE TABLE event_tag_index SCHEMAFULL; DEFINE FIELD event_id ON TABLE event_tag_index TYPE int;",
- )
- .await
- .expect("projection schema")
- .check()
- .expect("projection schema check");
- let listing = listing_event_at(1_714_125_630);
- let projection_handler = EventMessageHandler::new(
- projection_store,
- EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(listing.unsigned().pubkey().clone()),
- ),
- );
- let outcome = projection_handler
- .handle_event(
- &connection,
- listing,
- UnixTimestamp::new(1_714_125_631),
- UnixTimestamp::new(1_714_125_631),
- )
- .await;
- assert!(format!("{outcome:?}").contains("projection failed"));
- }
-
- #[tokio::test]
- async fn event_message_handler_accepts_ephemeral_events_without_persistence() {
- let store = runtime_memory_store().await;
- let event = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_125_640,
- 20_000,
- Vec::new(),
- "ephemeral",
- )
- .expect("ephemeral event");
- let handler = EventMessageHandler::new(
- store.clone(),
- EventValidator::new(RuntimeLimits::default(), AdmissionPolicy::new()),
- );
-
- let outcome = handler
- .handle_event(
- &authenticated_connection(),
- event.clone(),
- UnixTimestamp::new(1_714_125_641),
- UnixTimestamp::new(1_714_125_641),
- )
- .await;
-
- assert_eq!(
- outcome,
- RelayMessage::Ok {
- event_id: event.id().clone(),
- accepted: true,
- message: String::new()
- }
- );
- assert!(
- store
- .raw_event_row(event.id())
- .await
- .expect("raw row")
- .is_none()
- );
- }
-
- #[tokio::test]
- async fn event_message_handler_applies_dynamic_seller_policy_rows() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- let connection = authenticated_connection();
- let handler = EventMessageHandler::new(
- store.clone(),
- EventValidator::new(RuntimeLimits::default(), AdmissionPolicy::new()),
- );
-
- store
- .set_seller_approved(
- listing.unsigned().pubkey().as_str(),
- true,
- UnixTimestamp::new(1_714_126_200),
- )
- .await
- .expect("approve seller");
- let accepted = handler
- .handle_event(
- &connection,
- listing.clone(),
- UnixTimestamp::new(1_714_126_201),
- UnixTimestamp::new(1_714_126_201),
- )
- .await;
-
- assert_eq!(
- accepted,
- RelayMessage::Ok {
- event_id: listing.id().clone(),
- accepted: true,
- message: String::new()
- }
- );
- assert!(
- store
- .listing_current_row(&listing_key)
- .await
- .expect("listing row")
- .is_some()
- );
- assert!(
- store
- .search_document_row(&listing_key)
- .await
- .expect("search row")
- .is_some()
- );
-
- store
- .set_pubkey_blocked(
- listing.unsigned().pubkey().as_str(),
- true,
- UnixTimestamp::new(1_714_126_202),
- )
- .await
- .expect("block seller");
- let blocked_listing = listing_event_at(1_714_126_203);
- let blocked = handler
- .handle_event(
- &connection,
- blocked_listing.clone(),
- UnixTimestamp::new(1_714_126_204),
- UnixTimestamp::new(1_714_126_204),
- )
- .await;
- assert_eq!(
- blocked,
- RelayMessage::Ok {
- event_id: blocked_listing.id().clone(),
- accepted: true,
- message: String::new()
- }
- );
- assert_ne!(
- store
- .listing_current_row(&listing_key)
- .await
- .expect("blocked current")
- .expect("current row")["event_id"],
- blocked_listing.id().as_str()
- );
-
- let fallback_store = runtime_memory_store().await;
- fallback_store
- .set_seller_approved(
- listing.unsigned().pubkey().as_str(),
- false,
- UnixTimestamp::new(1_714_126_205),
- )
- .await
- .expect("fallback row");
- let fallback_handler = EventMessageHandler::new(
- fallback_store.clone(),
- EventValidator::new(
- RuntimeLimits::default(),
- AdmissionPolicy::new().approve_seller(listing.unsigned().pubkey().clone()),
- ),
- );
- let fallback_listing = listing_event_at(1_714_126_206);
- let fallback = fallback_handler
- .handle_event(
- &connection,
- fallback_listing.clone(),
- UnixTimestamp::new(1_714_126_207),
- UnixTimestamp::new(1_714_126_207),
- )
- .await;
- assert_eq!(
- fallback,
- RelayMessage::Ok {
- event_id: fallback_listing.id().clone(),
- accepted: true,
- message: String::new()
- }
- );
- assert_eq!(
- fallback_store
- .listing_current_row(&listing_key)
- .await
- .expect("fallback current")
- .expect("fallback current row")["event_id"],
- fallback_listing.id().as_str()
- );
- }
-
- #[test]
- fn auth_message_handler_issues_and_accepts_auth_events() {
- let handler = AuthMessageHandler;
- let mut connection = RelayConnection::new(
- RelayConnectionId::new("auth").expect("connection id"),
- RelayConnectionConfig::default(),
- );
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
-
- assert_eq!(
- handler.issue_challenge(
- &mut connection,
- " challenge-001 ",
- UnixTimestamp::new(1_714_124_430)
- ),
- RelayMessage::Auth("challenge-001".to_owned())
- );
- assert_eq!(
- handler.handle_auth(
- &mut connection,
- auth.clone(),
- UnixTimestamp::new(1_714_124_435)
- ),
- RelayMessage::Ok {
- event_id: auth.id().clone(),
- accepted: true,
- message: String::new()
- }
- );
- assert_eq!(
- connection.auth().authenticated_pubkey(),
- Some(auth.unsigned().pubkey())
- );
- assert_eq!(
- handler.issue_challenge(&mut connection, " ", UnixTimestamp::new(1_714_124_436)),
- RelayMessage::Notice("error: auth challenge must not be empty".to_owned())
- );
- }
-
- #[test]
- fn auth_message_handler_rejects_invalid_auth_messages() {
- let handler = AuthMessageHandler;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let mut missing_challenge = RelayConnection::new(
- RelayConnectionId::new("missing-challenge").expect("connection id"),
- RelayConnectionConfig::default(),
- );
- let mut wrong_kind = RelayConnection::new(
- RelayConnectionId::new("wrong-kind").expect("connection id"),
- RelayConnectionConfig::default(),
- );
-
- let outcome = handler.handle_auth(
- &mut missing_challenge,
- auth.clone(),
- UnixTimestamp::new(1_714_124_435),
- );
- assert!(matches!(
- outcome,
- RelayMessage::Ok {
- accepted: false,
- ..
- }
- ));
- assert!(format!("{outcome:?}").contains("auth challenge is missing"));
- let outcome = handler.handle_auth(
- &mut wrong_kind,
- listing.clone(),
- UnixTimestamp::new(1_714_124_435),
- );
- assert!(matches!(
- outcome,
- RelayMessage::Ok {
- accepted: false,
- ..
- }
- ));
- assert!(format!("{outcome:?}").contains("AUTH message must contain kind 22242"));
- let malformed_auth = build_fixture_event_from_parts(
- FixtureKey::Relay,
- 1_714_124_436,
- 22_242,
- Vec::new(),
- "",
- )
- .expect("malformed auth");
- let outcome = handler.handle_auth(
- &mut wrong_kind,
- malformed_auth,
- UnixTimestamp::new(1_714_124_436),
- );
- assert!(format!("{outcome:?}").contains("invalid:"));
- }
-
- #[tokio::test]
- async fn req_message_handler_returns_raw_events_and_eose() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- store
- .store_raw_event(&StoredEvent::new(
- listing.clone(),
- UnixTimestamp::new(1_714_125_300),
- ))
- .await
- .expect("raw event");
- let handler = ReqMessageHandler::new(store, NostrFilterCompiler::default());
- let mut connection = runtime_connection("req-raw");
- let subscription_id = SubscriptionId::new("sub-raw").expect("subscription");
- let filter = filter_from_value(&serde_json::json!({
- "ids": [listing.id().as_str()],
- "limit": 10
- }))
- .expect("filter");
-
- let messages = handler
- .handle_req(&mut connection, subscription_id.clone(), vec![filter])
- .await;
-
- assert_eq!(messages.len(), 2);
- assert!(matches!(&messages[0], RelayMessage::Event { .. }));
- assert!(format!("{:?}", messages[0]).contains(subscription_id.as_str()));
- assert!(format!("{:?}", messages[0]).contains(listing.id().as_str()));
- assert_eq!(messages[1], RelayMessage::Eose(subscription_id.clone()));
- assert!(connection.subscriptions().plan(&subscription_id).is_some());
- assert_eq!(handler.compiler(), NostrFilterCompiler::default());
- }
-
- #[tokio::test]
- async fn req_message_handler_hydrates_search_results_and_closes_bad_requests() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- store
- .store_raw_event(&StoredEvent::new(
- listing.clone(),
- UnixTimestamp::new(1_714_125_300),
- ))
- .await
- .expect("raw event");
- store
- .index_listing_search_document(&listing)
- .await
- .expect("search");
- let handler = ReqMessageHandler::new(store.clone(), NostrFilterCompiler::default());
- let mut connection = runtime_connection("req-search");
- let search_id = SubscriptionId::new("sub-search").expect("subscription");
- let search_filter = filter_from_value(&serde_json::json!({
- "search": "carrot",
- "kinds": [30402],
- "authors": [listing.unsigned().pubkey().as_str()],
- "limit": 5
- }))
- .expect("filter");
-
- let messages = handler
- .handle_req(&mut connection, search_id.clone(), vec![search_filter])
- .await;
-
- assert_eq!(messages.len(), 2);
- assert!(matches!(&messages[0], RelayMessage::Event { .. }));
- assert!(format!("{:?}", messages[0]).contains(listing.id().as_str()));
- assert_eq!(messages[1], RelayMessage::Eose(search_id));
- let bad_id = SubscriptionId::new("sub-bad").expect("subscription");
- let bad = handler
- .handle_req(&mut connection, bad_id.clone(), Vec::new())
- .await;
- assert_eq!(
- bad,
- vec![RelayMessage::Closed {
- subscription_id: bad_id,
- message: "unsupported: query plan: query plan must include at least one branch"
- .to_owned()
- }]
- );
- assert!(
- handler
- .store()
- .raw_event_row(listing.id())
- .await
- .expect("raw")
- .is_some()
- );
- }
-
- #[tokio::test]
- async fn req_message_handler_closes_when_store_query_fails() {
- let config = SurrealConnectionConfig::memory("tangle_runtime", "req_store_failure")
- .expect("memory config");
- let store = SurrealStore::connect_memory(&config)
- .await
- .expect("memory store");
- let handler = ReqMessageHandler::new(store, NostrFilterCompiler::default());
- let mut connection = runtime_connection("req-error");
- let subscription_id = SubscriptionId::new("sub-error").expect("subscription");
- let filter =
- filter_from_value(&serde_json::json!({"kinds": [30402], "limit": 1})).expect("filter");
-
- assert_eq!(
- handler
- .handle_req(&mut connection, subscription_id.clone(), vec![filter])
- .await,
- vec![RelayMessage::Closed {
- subscription_id,
- message: "internal server error".to_owned()
- }]
- );
- }
-
- #[tokio::test]
- async fn close_message_handler_removes_subscriptions() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- store
- .store_raw_event(&StoredEvent::new(
- listing.clone(),
- UnixTimestamp::new(1_714_125_300),
- ))
- .await
- .expect("raw event");
- let req_handler = ReqMessageHandler::new(store, NostrFilterCompiler::default());
- let close_handler = CloseMessageHandler;
- let mut connection = runtime_connection("close");
- let subscription_id = SubscriptionId::new("sub-close").expect("subscription");
- let filter = filter_from_value(&serde_json::json!({
- "ids": [listing.id().as_str()]
- }))
- .expect("filter");
-
- req_handler
- .handle_req(&mut connection, subscription_id.clone(), vec![filter])
- .await;
-
- assert_eq!(connection.subscriptions().active_count(), 1);
- assert_eq!(
- close_handler.handle_close(&mut connection, &subscription_id),
- CloseMessageOutcome::Closed
- );
- assert_eq!(connection.subscriptions().active_count(), 0);
- assert_eq!(
- close_handler.handle_close(&mut connection, &subscription_id),
- CloseMessageOutcome::NotFound
- );
- }
-
- #[tokio::test]
- async fn live_event_fanout_delivers_matching_subscription_events() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let handler = ReqMessageHandler::new(store, NostrFilterCompiler::default());
- let fanout = LiveEventFanout;
- let mut connection = runtime_connection("fanout");
- let matching_id = SubscriptionId::new("sub-matching").expect("matching subscription");
- let miss_id = SubscriptionId::new("sub-miss").expect("miss subscription");
- let matching_filter = filter_from_value(&serde_json::json!({
- "kinds": [30402],
- "authors": [listing.unsigned().pubkey().as_str()]
- }))
- .expect("matching filter");
- let miss_filter = filter_from_value(&serde_json::json!({
- "ids": ["cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]
- }))
- .expect("miss filter");
-
- handler
- .handle_req(&mut connection, matching_id.clone(), vec![matching_filter])
- .await;
- handler
- .handle_req(&mut connection, miss_id, vec![miss_filter])
- .await;
-
- let messages = fanout.fanout(&connection, &listing);
-
- assert_eq!(messages.len(), 1);
- assert_eq!(
- messages,
- vec![RelayMessage::Event {
- subscription_id: matching_id,
- event: listing
- }]
- );
- }
-
- #[test]
- fn live_event_fanout_ignores_connections_without_matches() {
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let fanout = LiveEventFanout;
- let connection = runtime_connection("fanout-empty");
-
- assert_eq!(fanout.fanout(&connection, &listing), Vec::new());
- }
-
- #[tokio::test]
- async fn api_error_into_response_keeps_public_envelope_shape() {
- let response = ApiError::not_found("listing not found").into_response();
- assert_eq!(response.status(), StatusCode::NOT_FOUND);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "error": {
- "code": "not_found",
- "message": "listing not found"
- }
- })
- );
- }
-
- #[tokio::test]
- async fn health_endpoint_reports_liveness() {
- let response = health_router(ReadinessState::ready())
- .oneshot(
- Request::builder()
- .uri("/healthz")
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({ "status": "ok" })
- );
- }
-
- #[tokio::test]
- async fn readiness_endpoint_reports_ready_checks() {
- let response = health_router(ReadinessState::ready())
- .oneshot(
- Request::builder()
- .uri("/readyz")
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "status": "ready",
- "checks": {
- "database": "ready",
- "migrations": "ready",
- "repository": "ready"
- }
- })
- );
- }
-
- #[tokio::test]
- async fn readiness_endpoint_reports_unavailable_checks() {
- let response = health_router(ReadinessState::new(
- ReadinessCheckStatus::NotReady,
- ReadinessCheckStatus::Ready,
- ReadinessCheckStatus::NotReady,
- ))
- .oneshot(
- Request::builder()
- .uri("/readyz")
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "status": "not_ready",
- "checks": {
- "database": "not_ready",
- "migrations": "ready",
- "repository": "not_ready"
- }
- })
- );
- }
-
- #[tokio::test]
- async fn runtime_readiness_checks_database_migrations_and_repository() {
- let config = SurrealConnectionConfig::memory("tangle_runtime", "readiness_gates")
- .expect("memory config");
- let store = SurrealStore::connect_memory(&config)
- .await
- .expect("memory store");
-
- let missing = runtime_readiness_state(&store).await;
- assert_eq!(
- missing,
- ReadinessState::new(
- ReadinessCheckStatus::Ready,
- ReadinessCheckStatus::NotReady,
- ReadinessCheckStatus::NotReady
- )
- );
-
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- assert_eq!(
- runtime_readiness_state(&store).await,
- ReadinessState::ready()
- );
- }
-
- #[tokio::test]
- async fn runtime_readiness_rejects_migration_checksum_mismatch() {
- let store = runtime_memory_store().await;
-
- store
- .database()
- .query("UPDATE migration SET checksum = 'bad' WHERE name = '0001_migration_tracking';")
- .await
- .expect("checksum update")
- .check()
- .expect("checksum update check");
-
- assert_eq!(
- super::runtime_migrations_ready(&store)
- .await
- .expect_err("checksum mismatch")
- .message(),
- "runtime migrations do not match"
- );
- assert_eq!(
- runtime_readiness_state(&store).await,
- ReadinessState::new(
- ReadinessCheckStatus::Ready,
- ReadinessCheckStatus::NotReady,
- ReadinessCheckStatus::NotReady
- )
- );
- }
-
- #[tokio::test]
- async fn readiness_status_after_respects_dependency_gates() {
- assert_eq!(
- super::readiness_status_after(true, std::future::ready(Ok::<(), ()>(()))).await,
- ReadinessCheckStatus::Ready
- );
- assert_eq!(
- super::readiness_status_after(true, std::future::ready(Err::<(), ()>(()))).await,
- ReadinessCheckStatus::NotReady
- );
- assert_eq!(
- super::readiness_status_after(false, std::future::ready(Ok::<(), ()>(()))).await,
- ReadinessCheckStatus::NotReady
- );
- }
-
- #[tokio::test]
- async fn metrics_endpoint_reports_store_snapshot() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let profile = seller_profile(1_714_125_300, "radroots-market", Some("Radroots Market"));
- let seller = listing.unsigned().pubkey().as_str().to_owned();
- store
- .store_raw_event(&StoredEvent::new(
- listing.clone(),
- UnixTimestamp::new(1_714_125_301),
- ))
- .await
- .expect("store listing");
- store
- .store_raw_event(&StoredEvent::new(
- profile.clone(),
- UnixTimestamp::new(1_714_125_302),
- ))
- .await
- .expect("store profile");
- store
- .project_current_listing(&listing, UnixTimestamp::new(1_714_125_303))
- .await
- .expect("project listing");
- store
- .project_seller_profile(&profile, UnixTimestamp::new(1_714_125_304))
- .await
- .expect("project profile");
- store
- .set_seller_approved(seller.as_str(), true, UnixTimestamp::new(1_714_125_305))
- .await
- .expect("approve seller");
-
- let response = metrics_router(MetricsHttpState::new(store))
- .oneshot(
- Request::builder()
- .uri("/metrics")
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- assert_eq!(
- response.headers().get(header::CONTENT_TYPE),
- Some(&HeaderValue::from_static("text/plain; version=0.0.4"))
- );
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- let body = String::from_utf8(body.to_vec()).expect("utf8");
- assert!(body.contains(&format!(
- "tangle_info{{software=\"{}\",version=\"{}\"}} 1",
- TANGLE_RELAY_SOFTWARE, TANGLE_RELAY_VERSION
- )));
- assert!(body.contains("tangle_relay_ready 1"));
- assert!(body.contains("tangle_store_events{state=\"stored\"} 2"));
- assert!(body.contains("tangle_store_events{state=\"visible\"} 2"));
- assert!(body.contains("tangle_store_listings{state=\"active\"} 1"));
- assert!(body.contains("tangle_store_seller_profiles{state=\"visible\"} 1"));
- assert!(body.contains("tangle_store_sellers{state=\"approved\"} 1"));
- }
-
- #[test]
- fn admin_pubkey_requirement_rejects_disabled_and_unauthorized_access() {
- let admin = FixtureKey::Relay.public_key();
- let seller = FixtureKey::Seller.public_key();
- let disabled = runtime_memory_config("admin_disabled");
- let enabled = runtime_admin_config("admin_enabled");
- let mut headers = http::HeaderMap::new();
-
- assert_eq!(
- super::require_admin_pubkey(&disabled, &headers)
- .expect_err("disabled admin")
- .message(),
- "admin policy api is disabled"
- );
- headers.insert(
- "x-tangle-admin-pubkey",
- HeaderValue::from_str(seller.as_str()).expect("seller header"),
- );
- assert_eq!(
- super::require_admin_pubkey(&enabled, &headers)
- .expect_err("wrong admin")
- .message(),
- "admin pubkey is not authorized"
- );
- headers.insert(
- "x-tangle-admin-pubkey",
- HeaderValue::from_str(admin.as_str()).expect("admin header"),
- );
- assert_eq!(
- super::require_admin_pubkey(&enabled, &headers).expect("admin"),
- admin
- );
- headers.insert(
- "x-tangle-admin-pubkey",
- HeaderValue::from_static("not-a-pubkey"),
- );
- assert_eq!(
- super::require_admin_pubkey(&enabled, &headers)
- .expect_err("invalid admin")
- .message(),
- "admin pubkey header is invalid"
- );
- }
-
- #[tokio::test]
- async fn admin_event_policy_routes_report_missing_events() {
- let store = runtime_memory_store().await;
- let (shutdown, _) = GracefulShutdownSignal::new();
- let state =
- super::RuntimeRelayState::new(runtime_admin_config("admin_missing"), store, shutdown);
- let missing = "1".repeat(EventId::HEX_LENGTH);
- let mut headers = http::HeaderMap::new();
- headers.insert(
- "x-tangle-admin-pubkey",
- HeaderValue::from_str(FixtureKey::Relay.public_key().as_str()).expect("admin header"),
- );
-
- assert_eq!(
- super::runtime_admin_hide_event(
- axum::extract::State(state.clone()),
- headers.clone(),
- axum::extract::Path(missing.clone()),
- axum::Json(super::AdminEventPolicyRequest::default()),
- )
- .await
- .expect_err("hide missing")
- .message(),
- "event not found"
- );
- assert_eq!(
- super::runtime_admin_unhide_event(
- axum::extract::State(state),
- headers,
- axum::extract::Path(missing),
- axum::Json(super::AdminEventPolicyRequest::default()),
- )
- .await
- .expect_err("unhide missing")
- .message(),
- "event not found"
- );
- }
-
- #[tokio::test]
- async fn admin_policy_routes_reject_invalid_pubkey_paths() {
- let store = runtime_memory_store().await;
- let (shutdown, _) = GracefulShutdownSignal::new();
- let state = super::RuntimeRelayState::new(
- runtime_admin_config("admin_invalid_path"),
- store,
- shutdown,
- );
- let mut headers = http::HeaderMap::new();
- headers.insert(
- "x-tangle-admin-pubkey",
- HeaderValue::from_str(FixtureKey::Relay.public_key().as_str()).expect("admin header"),
- );
-
- assert_eq!(
- super::runtime_admin_approve_seller(
- axum::extract::State(state.clone()),
- headers.clone(),
- axum::extract::Path("not-a-pubkey".to_owned()),
- )
- .await
- .expect_err("approve invalid")
- .message(),
- "pubkey must be a 64-character hex public key"
- );
- assert_eq!(
- super::runtime_admin_block_pubkey(
- axum::extract::State(state),
- headers,
- axum::extract::Path("not-a-pubkey".to_owned()),
- )
- .await
- .expect_err("block invalid")
- .message(),
- "pubkey must be a 64-character hex public key"
- );
- }
-
- #[test]
- fn relay_info_default_matches_production_v1_protocol_claims() {
- let relay_info = RelayInfoDocument::tangle_default();
- assert_eq!(relay_info.name, "tangle");
- assert_eq!(relay_info.supported_nips, TANGLE_SUPPORTED_NIPS);
- assert_eq!(relay_info.software, TANGLE_RELAY_SOFTWARE);
- assert_eq!(relay_info.version, "0.1.0");
- assert!(!relay_info.limitation.payment_required);
- assert!(relay_info.limitation.restricted_writes);
- assert_eq!(
- serde_json::to_value(relay_info).expect("json"),
- serde_json::json!({
- "name": "tangle",
- "description": "SurrealDB-backed Nostr relay for NIP-99 marketplaces",
- "supported_nips": [1, 9, 11, 16, 22, 23, 25, 32, 33, 42, 50, 56, 99],
- "software": "https://github.com/radrootslabs/tangle",
- "version": "0.1.0",
- "limitation": {
- "payment_required": false,
- "restricted_writes": true
- }
- })
- );
- }
-
- #[tokio::test]
- async fn relay_info_endpoint_requires_nostr_accept_header() {
- let response = relay_info_router(RelayInfoDocument::tangle_default())
- .oneshot(
- Request::builder()
- .uri("/")
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::NOT_FOUND);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "error": {
- "code": "not_found",
- "message": "relay information requires application/nostr+json"
- }
- })
- );
- }
-
- #[tokio::test]
- async fn relay_info_endpoint_serves_nip11_document_for_nostr_accept() {
- let response = relay_info_router(RelayInfoDocument::tangle_default())
- .oneshot(
- Request::builder()
- .uri("/")
- .header(
- header::ACCEPT,
- "text/plain, APPLICATION/NOSTR+JSON; charset=utf-8",
- )
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- assert_eq!(
- response
- .headers()
- .get(header::CONTENT_TYPE)
- .expect("content-type"),
- HeaderValue::from_static("application/nostr+json")
- );
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "name": "tangle",
- "description": "SurrealDB-backed Nostr relay for NIP-99 marketplaces",
- "supported_nips": [1, 9, 11, 16, 22, 23, 25, 32, 33, 42, 50, 56, 99],
- "software": "https://github.com/radrootslabs/tangle",
- "version": "0.1.0",
- "limitation": {
- "payment_required": false,
- "restricted_writes": true
- }
- })
- );
- }
-
- #[tokio::test]
- async fn relay_info_endpoint_rejects_invalid_accept_header() {
- let response = relay_info_router(RelayInfoDocument::tangle_default())
- .oneshot(
- Request::builder()
- .uri("/")
- .header(
- header::ACCEPT,
- HeaderValue::from_bytes(b"\xff").expect("header"),
- )
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::NOT_FOUND);
- }
-
- #[test]
- fn listing_query_parser_defaults_to_active_marketplace_query() {
- let parsed = parse_listing_query("", RuntimeLimits::default()).expect("query");
- let query = parsed.marketplace();
-
- assert_eq!(parsed.geohash(), None);
- assert_eq!(query.statuses, [MarketplaceListingStatus::Active]);
- assert_eq!(query.limit, 50);
- assert_eq!(query.sort, MarketplaceSort::Relevance);
- assert_eq!(query.categories, Vec::<String>::new());
- assert_eq!(query.currencies, Vec::<String>::new());
- assert_eq!(query.units, Vec::<ListingUnit>::new());
- assert_eq!(query.fulfillment, Vec::<FulfillmentMethod>::new());
- }
-
- #[test]
- fn listing_query_parser_reads_supported_parameters() {
- let seller = "1".repeat(64);
- let query_string = format!(
- "category=vegetables,csa&category=roots&seller={seller}&status=active,sold,draft,inactive,expired,deleted,hidden,rejected¤cy=usd,cad&unit=lb,oz,each,bunch,dozen,kg,g,share,pint,quart,box,crate,flat&min_price=1.50&max_price=10&fulfillment=pickup,delivery,shipping&delivery_only=false&pickup=true&geohash=C23NB62&lat=%2B47.6062&lon=-122.332100&radius_km=25.5&near=Ballard&sort=distance&limit=25"
- );
- let parsed = parse_listing_query(&query_string, RuntimeLimits::default()).expect("query");
- let query = parsed.marketplace();
- let point = query.location.point.expect("point");
-
- assert_eq!(parsed.geohash(), Some("c23nb62"));
- assert_eq!(
- query.categories,
- [
- "csa".to_owned(),
- "roots".to_owned(),
- "vegetables".to_owned()
- ]
- );
- assert_eq!(query.seller.as_ref().expect("seller").as_str(), seller);
- assert_eq!(
- query.statuses,
- [
- MarketplaceListingStatus::Active,
- MarketplaceListingStatus::Sold,
- MarketplaceListingStatus::Draft,
- MarketplaceListingStatus::Inactive,
- MarketplaceListingStatus::Expired,
- MarketplaceListingStatus::Deleted,
- MarketplaceListingStatus::Hidden,
- MarketplaceListingStatus::Rejected,
- ]
- );
- assert_eq!(query.currencies, ["CAD".to_owned(), "USD".to_owned()]);
- assert_eq!(
- query
- .units
- .iter()
- .map(|unit| unit.canonical())
- .collect::<Vec<_>>(),
- [
- "box", "bunch", "crate", "dozen", "each", "flat", "g", "kg", "lb", "oz", "pint",
- "quart", "share",
- ]
- );
- assert_eq!(query.min_price.as_ref().expect("min").raw, "1.50");
- assert_eq!(query.max_price.as_ref().expect("max").raw, "10");
- assert_eq!(
- query.fulfillment,
- [
- FulfillmentMethod::Pickup,
- FulfillmentMethod::Delivery,
- FulfillmentMethod::Shipping,
- ]
- );
- assert_eq!(query.delivery_only, Some(false));
- assert_eq!(query.pickup, Some(true));
- assert_eq!(point.latitude_microdegrees, 47_606_200);
- assert_eq!(point.longitude_microdegrees, -122_332_100);
- assert_eq!(query.location.radius_meters, Some(25_500));
- assert_eq!(query.location.near.as_deref(), Some("ballard"));
- assert_eq!(query.sort, MarketplaceSort::Distance);
- assert_eq!(query.limit, 25);
- }
-
- #[test]
- fn listing_query_parser_accepts_all_sort_labels() {
- let cases = [
- ("relevance", MarketplaceSort::Relevance, ""),
- ("freshness", MarketplaceSort::Freshness, ""),
- ("price_asc", MarketplaceSort::PriceAsc, ""),
- ("price_desc", MarketplaceSort::PriceDesc, ""),
- ("distance", MarketplaceSort::Distance, "&lat=+0&lon=0"),
- ("seller_trust", MarketplaceSort::SellerTrust, ""),
- ];
- for (label, expected, suffix) in cases {
- let parsed =
- parse_listing_query(&format!("sort={label}{suffix}"), RuntimeLimits::default())
- .expect("query");
- assert_eq!(parsed.marketplace().sort, expected);
- }
- }
-
- #[test]
- fn listing_query_parser_rejects_invalid_parameters() {
- let seller = "1".repeat(64);
- let cases = [
- (
- "banana=1".to_owned(),
- "query parameter `banana` is unsupported",
- ),
- ("category=,roots".to_owned(), "category must not be empty"),
- (
- "seller=bad".to_owned(),
- "seller must be a 64-character hex public key",
- ),
- (
- format!("seller={seller}&seller={seller}"),
- "seller must not be repeated",
- ),
- ("status=bogus".to_owned(), "status is unsupported"),
- ("currency=%20".to_owned(), "currency must not be empty"),
- ("unit=bushel".to_owned(), "unit is unsupported"),
- ("min_price=".to_owned(), "min_price must not be empty"),
- (
- "min_price=2&max_price=1.99".to_owned(),
- "min_price must not exceed max_price",
- ),
- ("fulfillment=drone".to_owned(), "fulfillment is unsupported"),
- (
- "delivery_only=yes".to_owned(),
- "delivery_only must be true or false",
- ),
- ("pickup=".to_owned(), "pickup must not be empty"),
- (
- "geohash=c23-".to_owned(),
- "geohash must be lowercase alphanumeric",
- ),
- (
- "geohash=c23&geohash=c24".to_owned(),
- "geohash must not be repeated",
- ),
- ("lat=91".to_owned(), "lat is out of range"),
- ("lon=181".to_owned(), "lon is out of range"),
- (
- "lat=999999999999999999999999&lon=0".to_owned(),
- "lat must be an exact unsigned decimal",
- ),
- (
- "lat=0&radius_km=1".to_owned(),
- "lat and lon must be provided together",
- ),
- (
- "radius_km=0".to_owned(),
- "radius_km must be greater than zero",
- ),
- (
- "radius_km=1.0000".to_owned(),
- "radius_km must be an exact unsigned decimal",
- ),
- (
- "radius_km=18446744073709551615".to_owned(),
- "radius_km must fit the supported range",
- ),
- ("near=%20".to_owned(), "near must not be empty"),
- (
- "sort=relevance&sort=freshness".to_owned(),
- "sort must not be repeated",
- ),
- ("sort=popular".to_owned(), "sort is unsupported"),
- (
- "sort=distance".to_owned(),
- "distance sort requires a point or near filter",
- ),
- ("limit=abc".to_owned(), "limit must be an unsigned integer"),
- ("limit=0".to_owned(), "limit must be between 1 and 100"),
- (
- "cursor=opaque".to_owned(),
- "cursor signed cursor decoding is not implemented",
- ),
- ];
- for (query, expected) in cases {
- let error = parse_listing_query(&query, RuntimeLimits::default()).expect_err(&query);
- assert_eq!(error.code(), ApiErrorCode::InvalidRequest);
- assert_eq!(error.message(), expected);
- }
- }
-
- #[test]
- fn listing_projection_query_rejects_filters_store_cannot_apply() {
- let cases = [
- (
- "category=vegetables",
- "category is not supported by the listings endpoint",
- ),
- (
- "geohash=c22yzug",
- "geohash is not supported by the listings endpoint",
- ),
- (
- "fulfillment=pickup",
- "fulfillment is not supported by the listings endpoint",
- ),
- (
- "delivery_only=true",
- "delivery_only is not supported by the listings endpoint",
- ),
- (
- "pickup=true",
- "pickup is not supported by the listings endpoint",
- ),
- (
- "lat=0&lon=0",
- "location is not supported by the listings endpoint",
- ),
- (
- "sort=price_asc",
- "sort is not supported by the listings endpoint",
- ),
- (
- "status=active,sold",
- "status must contain exactly one value for the listings endpoint",
- ),
- (
- "currency=usd,cad",
- "currency must contain at most one value for the listings endpoint",
- ),
- (
- "unit=lb,kg",
- "unit must contain at most one value for the listings endpoint",
- ),
- ("min_price=1.234", "price must fit two decimal minor units"),
- (
- "min_price=999999999999999999999999999999",
- "price must fit two decimal minor units",
- ),
- (
- "min_price=9223372036854775807",
- "price must fit two decimal minor units",
- ),
- ];
- for (raw, expected) in cases {
- let parsed = parse_listing_query(raw, RuntimeLimits::default()).expect("query");
- let error = listing_projection_query(&parsed).expect_err(raw);
- assert_eq!(error.message(), expected);
- }
- }
-
- #[test]
- fn marketplace_search_query_parser_accepts_supported_modes() {
- let seller = "1".repeat(64);
- let text = parse_marketplace_search_query(
- &format!("q=carrot&seller={seller}&sort=relevance&limit=25"),
- RuntimeLimits::default(),
- )
- .expect("text search");
- assert_eq!(text.text(), Some("carrot"));
- assert_eq!(text.seller().expect("seller").as_str(), seller);
- assert_eq!(text.limit(), 25);
-
- let browse = parse_marketplace_search_query("sort=freshness", RuntimeLimits::default())
- .expect("browse");
- assert_eq!(browse.text(), None);
- assert_eq!(browse.seller(), None);
- assert_eq!(browse.limit(), 50);
-
- let query = search_document_query(&text);
- assert!(format!("{query:?}").contains("SearchDocumentQuery"));
- }
-
- #[test]
- fn marketplace_search_query_parser_rejects_invalid_parameters() {
- let seller = "1".repeat(64);
- let long_query = format!("q={}", "a".repeat(300));
- let cases = [
- ("q=".to_owned(), "q must not be empty"),
- ("q=carrot&q=roots".to_owned(), "q must not be repeated"),
- (
- format!("seller={seller}&seller={seller}"),
- "seller must not be repeated",
- ),
- (
- "status=active&status=active".to_owned(),
- "status must not be repeated",
- ),
- (
- "sort=freshness&sort=freshness".to_owned(),
- "sort must not be repeated",
- ),
- ("limit=1&limit=2".to_owned(), "limit must not be repeated"),
- (
- long_query,
- "runtime limit: search query bytes exceeded: 300 > 256",
- ),
- (
- "category=vegetables".to_owned(),
- "category is not supported by marketplace search",
- ),
- (
- "cursor=opaque".to_owned(),
- "cursor is not supported by marketplace search",
- ),
- (
- "status=sold".to_owned(),
- "status must be active for marketplace search",
- ),
- (
- "q=carrot&sort=freshness".to_owned(),
- "sort does not match marketplace search mode",
- ),
- (
- "sort=relevance".to_owned(),
- "sort does not match marketplace search mode",
- ),
- (
- "sort=price_asc".to_owned(),
- "sort does not match marketplace search mode",
- ),
- ("limit=0".to_owned(), "limit must be between 1 and 100"),
- (
- "banana=1".to_owned(),
- "query parameter `banana` is unsupported",
- ),
- ];
- for (raw, expected) in cases {
- let error =
- parse_marketplace_search_query(&raw, RuntimeLimits::default()).expect_err(&raw);
- assert_eq!(error.code(), ApiErrorCode::InvalidRequest);
- assert_eq!(error.message(), expected);
- }
- }
-
- #[test]
- fn projection_query_parsers_reject_cursor_and_unsupported_comment_parameters() {
- let seller = "1".repeat(PublicKeyHex::HEX_LENGTH);
- let cases = [
- (
- super::forum_thread_query("cursor=opaque").expect_err("forum cursor"),
- "cursor signed cursor decoding is not implemented",
- ),
- (
- super::forum_thread_query("banana=1").expect_err("forum unsupported"),
- "query parameter `banana` is unsupported",
- ),
- (
- super::label_projection_query("cursor=opaque").expect_err("label cursor"),
- "cursor signed cursor decoding is not implemented",
- ),
- (
- super::label_projection_query("target_type=event").expect_err("label target"),
- "target target_type and target_ref must be provided together",
- ),
- (
- super::label_projection_query("banana=1").expect_err("label unsupported"),
- "query parameter `banana` is unsupported",
- ),
- (
- super::report_projection_query("cursor=opaque").expect_err("report cursor"),
- "cursor signed cursor decoding is not implemented",
- ),
- (
- super::report_projection_query("target_ref=abc").expect_err("report target"),
- "target target_type and target_ref must be provided together",
- ),
- (
- super::report_projection_query("banana=1").expect_err("report unsupported"),
- "query parameter `banana` is unsupported",
- ),
- (
- super::parse_comment_query("cursor=opaque").expect_err("comment cursor"),
- "cursor is not supported by the listing comments endpoint",
- ),
- ];
-
- assert!(
- super::forum_thread_query(&format!("pubkey={seller}&topic=market&limit=2")).is_ok()
- );
- assert!(
- super::label_projection_query(&format!(
- "target_type=event&target_ref={}&namespace=ugc&label=approve&pubkey={seller}&limit=2",
- "2".repeat(EventId::HEX_LENGTH)
- ))
- .is_ok()
- );
- assert!(
- super::report_projection_query(&format!(
- "target_type=event&target_ref={}&report_type=spam&pubkey={seller}&limit=2",
- "2".repeat(EventId::HEX_LENGTH)
- ))
- .is_ok()
- );
- assert!(super::label_projection_query("=1").is_ok());
- assert!(super::report_projection_query("=1").is_ok());
- assert_eq!(super::parse_comment_query("=1").expect("empty key"), 50);
-
- for (error, expected) in cases {
- assert_eq!(error.code(), ApiErrorCode::InvalidRequest);
- assert_eq!(error.message(), expected);
- }
- }
-
- #[test]
- fn listing_item_document_maps_projection_rows_and_rejects_malformed_rows() {
- let row = serde_json::json!({
- "listing_key": "30402:pubkey:listing-a",
- "event_id": "event",
- "seller_pubkey": "pubkey",
- "d": "listing-a",
- "title": "Carrot bunches",
- "location_text": "Seattle",
- "price_decimal": "12.50",
- "currency_norm": "USD",
- "unit": "lb",
- "effective_status": "active",
- "updated_at": 1714124433_u64,
- "pickup_available": false,
- "delivery_available": true,
- "shipping_available": true
- });
- let item = listing_item_document(&row).expect("item");
-
- assert_eq!(item.summary, None);
- assert_eq!(item.location.text.as_deref(), Some("Seattle"));
- assert_eq!(item.location.geohash, None);
- assert_eq!(
- item.fulfillment,
- ["delivery".to_owned(), "shipping".to_owned()]
- );
- assert_eq!(item.price.amount, "12.50");
-
- for row in [
- serde_json::json!({
- "event_id": "event",
- "seller_pubkey": "pubkey",
- "d": "listing-a",
- "title": "Carrot bunches",
- "price_decimal": "12.50",
- "currency_norm": "USD",
- "unit": "lb",
- "effective_status": "active",
- "updated_at": 1714124433_u64,
- "pickup_available": false,
- "delivery_available": true,
- "shipping_available": true
- }),
- serde_json::json!({
- "listing_key": "30402:pubkey:listing-a",
- "event_id": "event",
- "seller_pubkey": "pubkey",
- "d": "listing-a",
- "title": "Carrot bunches",
- "summary": 1,
- "price_decimal": "12.50",
- "currency_norm": "USD",
- "unit": "lb",
- "effective_status": "active",
- "updated_at": 1714124433_u64,
- "pickup_available": false,
- "delivery_available": true,
- "shipping_available": true
- }),
- serde_json::json!({
- "listing_key": "30402:pubkey:listing-a",
- "event_id": "event",
- "seller_pubkey": "pubkey",
- "d": "listing-a",
- "title": "Carrot bunches",
- "price_decimal": "12.50",
- "currency_norm": "USD",
- "unit": "lb",
- "effective_status": "active",
- "updated_at": "bad",
- "pickup_available": false,
- "delivery_available": true,
- "shipping_available": true
- }),
- serde_json::json!({
- "listing_key": "30402:pubkey:listing-a",
- "event_id": "event",
- "seller_pubkey": "pubkey",
- "d": "listing-a",
- "title": "Carrot bunches",
- "price_decimal": "12.50",
- "currency_norm": "USD",
- "unit": "lb",
- "effective_status": "active",
- "updated_at": 1714124433_u64,
- "pickup_available": "bad",
- "delivery_available": true,
- "shipping_available": true
- }),
- ] {
- assert_eq!(
- listing_item_document(&row).expect_err("malformed").code(),
- ApiErrorCode::Internal
- );
- }
- assert_eq!(
- super::price_minor_units("1.2.3")
- .expect_err("invalid price")
- .message(),
- "price must fit two decimal minor units"
- );
- assert_eq!(
- super::fulfillment_document(&serde_json::json!({
- "pickup_available": true,
- "delivery_available": false,
- "shipping_available": false
- }))
- .expect("fulfillment"),
- ["pickup".to_owned()]
- );
- assert_eq!(
- listing_item_document(&serde_json::json!({
- "listing_key": "30402:pubkey:listing-a",
- "event_id": "event",
- "seller_pubkey": "pubkey",
- "d": "listing-a",
- "title": "Carrot bunches",
- "summary": null,
- "geohash": null,
- "price_decimal": "12.50",
- "currency_norm": "USD",
- "unit": "lb",
- "effective_status": "active",
- "updated_at": 1714124433_u64,
- "pickup_available": false,
- "delivery_available": false,
- "shipping_available": false
- }))
- .expect("nullable listing")
- .fulfillment,
- Vec::<String>::new()
- );
- }
-
- #[test]
- fn read_model_document_helpers_reject_malformed_rows() {
- let malformed = serde_json::json!({"event_id": "event"});
-
- assert_eq!(
- super::forum_thread_item_document(&malformed)
- .expect_err("forum thread")
- .code(),
- ApiErrorCode::Internal
- );
- assert_eq!(
- super::comment_item_document(&malformed)
- .expect_err("comment")
- .code(),
- ApiErrorCode::Internal
- );
- assert_eq!(
- super::moderation_label_document(&malformed)
- .expect_err("label")
- .code(),
- ApiErrorCode::Internal
- );
- assert_eq!(
- super::moderation_report_document(&malformed)
- .expect_err("report")
- .code(),
- ApiErrorCode::Internal
- );
- assert_eq!(
- super::reaction_counts_document(
- Some(&serde_json::json!({
- "target_event_id": "event",
- "like_count": "bad"
- })),
- "event",
- Some("30402"),
- )
- .expect_err("reaction count")
- .code(),
- ApiErrorCode::Internal
- );
- }
-
- #[tokio::test]
- async fn listings_endpoint_queries_projection_rows_and_excludes_hidden_rows() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- store
- .project_current_listing(&listing, UnixTimestamp::new(1_714_125_400))
- .await
- .expect("project listing");
-
- let uri = format!(
- "/api/listings?status=active&seller={}&unit=lb¤cy=usd&min_price=1.5&max_price=20.25&limit=5",
- listing.unsigned().pubkey().as_str()
- );
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "items": [{
- "listing_key": listing_key,
- "event_id": listing.id().as_str(),
- "seller_pubkey": listing.unsigned().pubkey().as_str(),
- "d": "listing-a",
- "title": "Carrot bunches",
- "summary": null,
- "price": {
- "amount": "12.50",
- "currency": "USD",
- "unit": "lb"
- },
- "location": {
- "text": null,
- "geohash": "c22yzug"
- },
- "fulfillment": ["pickup"],
- "status": "active",
- "updated_at": 1714124433
- }],
- "next_cursor": null
- })
- );
-
- store
- .database()
- .query("UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;")
- .bind(("listing_key", listing_key.as_str()))
- .await
- .expect("hide listing")
- .check()
- .expect("hide check");
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri("/api/listings")
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "items": [],
- "next_cursor": null
- })
- );
- }
-
- #[tokio::test]
- async fn listing_detail_endpoint_returns_projection_and_raw_event() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- store
- .store_raw_event(&StoredEvent::new(
- listing.clone(),
- UnixTimestamp::new(1_714_125_300),
- ))
- .await
- .expect("raw event");
- store
- .project_current_listing(&listing, UnixTimestamp::new(1_714_125_400))
- .await
- .expect("project listing");
-
- let uri = format!(
- "/api/listings/{}/listing-a",
- listing.unsigned().pubkey().as_str()
- );
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- let json = serde_json::from_slice::<serde_json::Value>(&body).expect("json");
- assert_eq!(json["listing"]["listing_key"], listing_key);
- assert_eq!(json["listing"]["event_id"], listing.id().as_str());
- assert_eq!(json["raw_event"], event_to_value(&listing));
-
- store
- .database()
- .query("UPDATE nostr_event SET hidden = true WHERE event_id = $event_id;")
- .bind(("event_id", listing.id().as_str()))
- .await
- .expect("hide raw")
- .check()
- .expect("hide raw check");
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::NOT_FOUND);
-
- store
- .database()
- .query(
- "UPDATE nostr_event SET hidden = false WHERE event_id = $event_id;
- UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;",
- )
- .bind(("event_id", listing.id().as_str()))
- .bind(("listing_key", listing_key.as_str()))
- .await
- .expect("hide listing")
- .check()
- .expect("hide listing check");
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(uri)
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::NOT_FOUND);
- }
-
- #[tokio::test]
- async fn listing_comments_endpoint_returns_visible_projected_comments() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- let comment = listing_comment(&listing, 1_714_125_410, "Can I pickup Saturday?");
- store
- .project_current_listing(&listing, UnixTimestamp::new(1_714_125_409))
- .await
- .expect("project listing");
- store
- .project_comment(&comment, UnixTimestamp::new(1_714_125_411))
- .await
- .expect("project comment");
-
- let uri = format!(
- "/api/listings/{}/listing-a/comments?limit=5",
- listing.unsigned().pubkey().as_str()
- );
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "items": [{
- "event_id": comment.id().as_str(),
- "pubkey": FixtureKey::Buyer.public_key().as_str(),
- "created_at": 1714125410_u64,
- "content": "Can I pickup Saturday?",
- "root": {
- "target_type": "address",
- "target_ref": listing_key,
- "kind": "30402",
- "author": listing.unsigned().pubkey().as_str()
- },
- "parent": {
- "target_type": "address",
- "target_ref": listing_key,
- "kind": "30402",
- "author": listing.unsigned().pubkey().as_str()
- }
- }],
- "next_cursor": null
- })
- );
-
- store
- .database()
- .query("UPDATE comment_projection SET hidden = true WHERE event_id = $event_id;")
- .bind(("event_id", comment.id().as_str()))
- .await
- .expect("hide comment")
- .check()
- .expect("hide comment check");
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "items": [],
- "next_cursor": null
- })
- );
- store
- .database()
- .query("UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;")
- .bind(("listing_key", listing_key.as_str()))
- .await
- .expect("hide listing")
- .check()
- .expect("hide listing check");
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::NOT_FOUND);
- }
-
- #[tokio::test]
- async fn listing_reactions_endpoint_returns_aggregate_counts() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let reaction = listing_reaction(&listing, 1_714_125_420, "+");
- store
- .project_current_listing(&listing, UnixTimestamp::new(1_714_125_419))
- .await
- .expect("project listing");
-
- let uri = format!(
- "/api/listings/{}/listing-a/reactions",
- listing.unsigned().pubkey().as_str()
- );
- let empty = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(empty.status(), StatusCode::OK);
- let body = axum::body::to_bytes(empty.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "target_event_id": listing.id().as_str(),
- "target_kind": "30402",
- "like_count": 0,
- "dislike_count": 0,
- "emoji_count": 0,
- "text_count": 0,
- "total_count": 0,
- "updated_at": 0
- })
- );
-
- store
- .project_reaction(&reaction, UnixTimestamp::new(1_714_125_421))
- .await
- .expect("project reaction");
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "target_event_id": listing.id().as_str(),
- "target_kind": "30402",
- "like_count": 1,
- "dislike_count": 0,
- "emoji_count": 0,
- "text_count": 0,
- "total_count": 1,
- "updated_at": 1714125421
- })
- );
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- store
- .database()
- .query("UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;")
- .bind(("listing_key", listing_key.as_str()))
- .await
- .expect("hide listing")
- .check()
- .expect("hide listing check");
- let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default()))
- .oneshot(
- Request::builder()
- .uri(uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::NOT_FOUND);
- }
-
- #[tokio::test]
- async fn forum_threads_endpoint_returns_visible_projected_threads() {
- let store = runtime_memory_store().await;
- let thread = forum_thread(1_714_125_430, Some("Market day thread"), &["Market", "CSA"]);
- store
- .project_forum_thread(&thread, UnixTimestamp::new(1_714_125_431))
- .await
- .expect("project thread");
-
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri("/api/forum/threads?topic=market&limit=5")
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "items": [{
- "event_id": thread.id().as_str(),
- "pubkey": FixtureKey::Buyer.public_key().as_str(),
- "created_at": 1714125430_u64,
- "updated_at": 1714125430_u64,
- "title": "Market day thread",
- "content": "What is everyone bringing this weekend?",
- "tags": ["csa", "market"]
- }],
- "next_cursor": null
- })
- );
-
- store
- .database()
- .query("UPDATE forum_thread_projection SET hidden = true WHERE event_id = $event_id;")
- .bind(("event_id", thread.id().as_str()))
- .await
- .expect("hide thread")
- .check()
- .expect("hide thread check");
- let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default()))
- .oneshot(
- Request::builder()
- .uri("/api/forum/threads?topic=market&limit=5")
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "items": [],
- "next_cursor": null
- })
- );
- }
-
- #[tokio::test]
- async fn forum_thread_detail_and_comments_endpoints_return_visible_rows() {
- let store = runtime_memory_store().await;
- let thread = forum_thread(1_714_125_440, Some("Market day thread"), &["market"]);
- let comment = forum_thread_comment(&thread, 1_714_125_441, "I can bring greens.");
- store
- .store_raw_event(&StoredEvent::new(
- thread.clone(),
- UnixTimestamp::new(1_714_125_442),
- ))
- .await
- .expect("raw thread");
- store
- .project_forum_thread(&thread, UnixTimestamp::new(1_714_125_443))
- .await
- .expect("project thread");
- store
- .project_comment(&comment, UnixTimestamp::new(1_714_125_444))
- .await
- .expect("project comment");
-
- let detail_uri = format!("/api/forum/threads/{}", thread.id().as_str());
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(detail_uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- let detail = serde_json::from_slice::<serde_json::Value>(&body).expect("json");
- assert_eq!(detail["thread"]["event_id"], thread.id().as_str());
- assert_eq!(detail["thread"]["title"], "Market day thread");
- assert_eq!(detail["raw_event"]["id"], thread.id().as_str());
-
- let comments_uri = format!(
- "/api/forum/threads/{}/comments?limit=5",
- thread.id().as_str()
- );
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(comments_uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "items": [{
- "event_id": comment.id().as_str(),
- "pubkey": FixtureKey::Seller.public_key().as_str(),
- "created_at": 1714125441_u64,
- "content": "I can bring greens.",
- "root": {
- "target_type": "event",
- "target_ref": thread.id().as_str(),
- "kind": "11",
- "author": thread.unsigned().pubkey().as_str()
- },
- "parent": {
- "target_type": "event",
- "target_ref": thread.id().as_str(),
- "kind": "11",
- "author": thread.unsigned().pubkey().as_str()
- }
- }],
- "next_cursor": null
- })
- );
- store
- .database()
- .query("UPDATE nostr_event SET hidden = true WHERE event_id = $event_id;")
- .bind(("event_id", thread.id().as_str()))
- .await
- .expect("hide raw")
- .check()
- .expect("hide raw check");
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(detail_uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::NOT_FOUND);
- store
- .database()
- .query(
- "UPDATE nostr_event SET hidden = false WHERE event_id = $event_id;
- UPDATE forum_thread_projection SET hidden = true WHERE event_id = $event_id;",
- )
- .bind(("event_id", thread.id().as_str()))
- .await
- .expect("hide thread")
- .check()
- .expect("hide thread check");
- let detail = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(detail_uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- let comments = listings_router(ListingsHttpState::new(store, RuntimeLimits::default()))
- .oneshot(
- Request::builder()
- .uri(comments_uri.as_str())
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(detail.status(), StatusCode::NOT_FOUND);
- assert_eq!(comments.status(), StatusCode::NOT_FOUND);
- }
-
- #[tokio::test]
- async fn listing_detail_endpoint_rejects_invalid_or_missing_listing() {
- let store = runtime_memory_store().await;
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri("/api/listings/not-a-pubkey/listing-a")
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::BAD_REQUEST);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "error": {
- "code": "invalid_request",
- "message": "pubkey must be a 64-character hex public key"
- }
- })
- );
-
- let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default()))
- .oneshot(
- Request::builder()
- .uri(format!("/api/listings/{}/missing", "1".repeat(64)))
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::NOT_FOUND);
- }
-
- #[tokio::test]
- async fn marketplace_search_endpoint_queries_search_docs_and_hydrates_listings() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- store
- .project_current_listing(&listing, UnixTimestamp::new(1_714_125_400))
- .await
- .expect("project listing");
- store
- .index_listing_search_document(&listing)
- .await
- .expect("index listing");
- store
- .database()
- .query(
- "CREATE search_doc CONTENT {
- doc_key: 'no-address',
- event_id: 'no-address-event',
- current_event_id: 'no-address-event',
- doc_type: 'listing',
- kind: 30402,
- pubkey: $pubkey,
- address_key: NONE,
- title: 'carrot no address',
- summary: NONE,
- body: 'carrot',
- category_text: 'carrot',
- location_text: NONE,
- tags: [],
- categories: [],
- created_at: 1,
- updated_at: 1,
- visible: true,
- status: 'active',
- seller_trust_score: NONE
- };
- CREATE search_doc CONTENT {
- doc_key: 'orphan-address',
- event_id: 'orphan-address-event',
- current_event_id: 'orphan-address-event',
- doc_type: 'listing',
- kind: 30402,
- pubkey: $pubkey,
- address_key: '30402:orphan:missing',
- title: 'carrot orphan',
- summary: NONE,
- body: 'carrot',
- category_text: 'carrot',
- location_text: NONE,
- tags: [],
- categories: [],
- created_at: 2,
- updated_at: 2,
- visible: true,
- status: 'active',
- seller_trust_score: NONE
- };",
- )
- .bind(("pubkey", listing.unsigned().pubkey().as_str()))
- .await
- .expect("extra search docs")
- .check()
- .expect("extra search docs check");
-
- let uri = format!(
- "/api/search?q=carrot&seller={}&sort=relevance&limit=5",
- listing.unsigned().pubkey().as_str()
- );
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(uri)
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- let json = serde_json::from_slice::<serde_json::Value>(&body).expect("json");
- assert_eq!(json["items"][0]["listing_key"], listing_key);
- assert_eq!(json["items"][0]["title"], "Carrot bunches");
- assert_eq!(json["next_cursor"], serde_json::Value::Null);
-
- store
- .database()
- .query("UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;")
- .bind(("listing_key", listing_key.as_str()))
- .await
- .expect("hide listing")
- .check()
- .expect("hide check");
- let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default()))
- .oneshot(
- Request::builder()
- .uri("/api/search?q=carrot")
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "items": [],
- "next_cursor": null
- })
- );
- }
-
- #[tokio::test]
- async fn seller_endpoint_returns_policy_state_and_active_listing_count() {
- let store = runtime_memory_store().await;
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let profile = seller_profile(1_714_125_300, "radroots-market", Some("Radroots Market"));
- let seller = listing.unsigned().pubkey().as_str().to_owned();
- let listing_key = format!("30402:{seller}:listing-a");
- store
- .store_raw_event(&StoredEvent::new(
- profile.clone(),
- UnixTimestamp::new(1_714_125_301),
- ))
- .await
- .expect("store profile");
- store
- .project_seller_profile(&profile, UnixTimestamp::new(1_714_125_302))
- .await
- .expect("project profile");
- store
- .project_current_listing(&listing, UnixTimestamp::new(1_714_125_400))
- .await
- .expect("project listing");
- store
- .set_seller_approved(seller.as_str(), true, UnixTimestamp::new(2))
- .await
- .expect("seller row");
-
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(format!("/api/sellers/{seller}"))
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "pubkey": seller,
- "event_id": profile.id().as_str(),
- "name": "radroots-market",
- "display_name": "Radroots Market",
- "about": "Local food seller profile",
- "picture": "https://fixtures.radroots.test/seller.png",
- "website": "https://seller.radroots.test",
- "nip05": "seller@radroots.test",
- "lud16": "seller@pay.radroots.test",
- "regions": ["cascadia", "pnw"],
- "categories": ["produce"],
- "trust_markers": ["csa", "regenerative"],
- "approved": true,
- "blocked": false,
- "active_listing_count": 1
- })
- );
-
- store
- .database()
- .query("UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;")
- .bind(("listing_key", listing_key.as_str()))
- .await
- .expect("hide listing")
- .check()
- .expect("hide check");
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(format!("/api/sellers/{seller}"))
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json")["active_listing_count"],
- 0
- );
-
- let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
- store
- .hide_event(
- profile.id(),
- "profile moderation",
- "admin_api",
- admin_pubkey.as_str(),
- UnixTimestamp::new(1_714_125_600),
- )
- .await
- .expect("hide profile");
- let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default()))
- .oneshot(
- Request::builder()
- .uri(format!("/api/sellers/{seller}"))
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- let document = serde_json::from_slice::<serde_json::Value>(&body).expect("json");
- assert!(document["event_id"].is_null());
- assert!(document["name"].is_null());
- assert_eq!(document["regions"], serde_json::json!([]));
- assert_eq!(document["approved"], true);
- }
-
- #[tokio::test]
- async fn seller_endpoint_defaults_missing_seller_and_rejects_invalid_pubkey() {
- let store = runtime_memory_store().await;
- let missing = "1".repeat(64);
- let response = listings_router(ListingsHttpState::new(
- store.clone(),
- RuntimeLimits::default(),
- ))
- .oneshot(
- Request::builder()
- .uri(format!("/api/sellers/{missing}"))
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::OK);
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .expect("body");
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
- serde_json::json!({
- "pubkey": missing,
- "event_id": null,
- "name": null,
- "display_name": null,
- "about": null,
- "picture": null,
- "website": null,
- "nip05": null,
- "lud16": null,
- "regions": [],
- "categories": [],
- "trust_markers": [],
- "approved": false,
- "blocked": false,
- "active_listing_count": 0
- })
- );
-
- let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default()))
- .oneshot(
- Request::builder()
- .uri("/api/sellers/not-a-pubkey")
- .body(Body::empty())
- .expect("request"),
- )
- .await
- .expect("response");
- assert_eq!(response.status(), StatusCode::BAD_REQUEST);
- }
-
- async fn runtime_memory_store() -> SurrealStore {
- let config = SurrealConnectionConfig::memory("tangle_runtime", "listings_endpoint")
- .expect("memory config");
- let store = SurrealStore::connect_memory(&config)
- .await
- .expect("memory store");
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- store
- }
-
- fn runtime_memory_config(namespace: &str) -> super::TangleRuntimeConfig {
- parse_runtime_config_json(
- &serde_json::json!({
- "server": {
- "listen_addr": "127.0.0.1:0",
- "relay_url": "ws://127.0.0.1:0"
- },
- "database": {
- "mode": "memory",
- "namespace": namespace,
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- },
- "policy": {
- "approved_sellers": [FixtureKey::Seller.public_key().as_str()]
- }
- })
- .to_string(),
- )
- .expect("runtime memory config")
- }
-
- fn runtime_admin_config(namespace: &str) -> super::TangleRuntimeConfig {
- parse_runtime_config_json(
- &serde_json::json!({
- "server": {
- "listen_addr": "127.0.0.1:0",
- "relay_url": "ws://127.0.0.1:0"
- },
- "database": {
- "mode": "memory",
- "namespace": namespace,
- "database": "relay"
- },
- "auth": {
- "challenge_ttl_seconds": 300
- },
- "limits": {
- "message_rate_limit": {
- "limit": 120,
- "window_seconds": 60
- }
- },
- "policy": {
- "admin_pubkeys": [FixtureKey::Relay.public_key().as_str()],
- "approved_sellers": [FixtureKey::Seller.public_key().as_str()]
- }
- })
- .to_string(),
- )
- .expect("runtime admin config")
- }
-
- fn seller_profile(
- created_at: u64,
- name: &str,
- display_name: Option<&str>,
- ) -> tangle_protocol::Event {
- let mut content = serde_json::json!({
- "name": name,
- "about": "Local food seller profile",
- "picture": "https://fixtures.radroots.test/seller.png",
- "website": "https://seller.radroots.test",
- "nip05": "seller@radroots.test",
- "lud16": "seller@pay.radroots.test"
- });
- if let Some(display_name) = display_name {
- content["display_name"] = serde_json::Value::String(display_name.to_owned());
- }
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- u64::from(NIP01_METADATA_KIND),
- vec![
- vec!["region".to_owned(), "PNW".to_owned()],
- vec!["region".to_owned(), "Cascadia".to_owned()],
- vec!["category".to_owned(), "Produce".to_owned()],
- vec!["trust".to_owned(), "CSA".to_owned()],
- vec!["trust".to_owned(), "regenerative".to_owned()],
- ],
- &content.to_string(),
- )
- .expect("seller profile")
- }
-
- fn listing_event_at(created_at: u64) -> tangle_protocol::Event {
- let spec = valid_public_listing_spec();
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- spec.kind(),
- spec.tags().to_vec(),
- spec.content(),
- )
- .expect("listing event")
- }
-
- fn note_event(created_at: u64, content: &str) -> tangle_protocol::Event {
- build_fixture_event_from_parts(FixtureKey::Seller, created_at, 1, Vec::new(), content)
- .expect("note event")
- }
-
- fn listing_comment(
- listing: &tangle_protocol::Event,
- created_at: u64,
- content: &str,
- ) -> tangle_protocol::Event {
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- build_fixture_event_from_parts(
- FixtureKey::Buyer,
- created_at,
- 1_111,
- vec![
- vec!["A".to_owned(), listing_key.clone()],
- vec!["K".to_owned(), "30402".to_owned()],
- vec![
- "P".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- vec!["a".to_owned(), listing_key],
- vec!["k".to_owned(), "30402".to_owned()],
- vec![
- "p".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- ],
- content,
- )
- .expect("comment event")
- }
-
- fn listing_reaction(
- listing: &tangle_protocol::Event,
- created_at: u64,
- content: &str,
- ) -> tangle_protocol::Event {
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- build_fixture_event_from_parts(
- FixtureKey::Buyer,
- created_at,
- 7,
- vec![
- vec![
- "e".to_owned(),
- listing.id().as_str().to_owned(),
- "wss://relay.radroots.test".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- vec![
- "p".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- vec!["a".to_owned(), listing_key],
- vec!["k".to_owned(), "30402".to_owned()],
- ],
- content,
- )
- .expect("reaction event")
- }
-
- fn forum_thread(
- created_at: u64,
- title: Option<&str>,
- topics: &[&str],
- ) -> tangle_protocol::Event {
- let mut tags = vec![
- vec!["e".to_owned(), "5".repeat(EventId::HEX_LENGTH)],
- vec![
- "p".to_owned(),
- FixtureKey::Seller.public_key().as_str().to_owned(),
- ],
- ];
- if let Some(title) = title {
- tags.push(vec!["title".to_owned(), title.to_owned()]);
- }
- tags.extend(
- topics
- .iter()
- .map(|topic| vec!["t".to_owned(), (*topic).to_owned()]),
- );
- build_fixture_event_from_parts(
- FixtureKey::Buyer,
- created_at,
- 11,
- tags,
- "What is everyone bringing this weekend?",
- )
- .expect("forum thread")
- }
-
- fn forum_thread_comment(
- thread: &tangle_protocol::Event,
- created_at: u64,
- content: &str,
- ) -> tangle_protocol::Event {
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- 1_111,
- vec![
- vec![
- "E".to_owned(),
- thread.id().as_str().to_owned(),
- "wss://relay.radroots.test".to_owned(),
- thread.unsigned().pubkey().as_str().to_owned(),
- ],
- vec!["K".to_owned(), "11".to_owned()],
- vec![
- "P".to_owned(),
- thread.unsigned().pubkey().as_str().to_owned(),
- ],
- vec![
- "e".to_owned(),
- thread.id().as_str().to_owned(),
- "wss://relay.radroots.test".to_owned(),
- thread.unsigned().pubkey().as_str().to_owned(),
- ],
- vec!["k".to_owned(), "11".to_owned()],
- vec![
- "p".to_owned(),
- thread.unsigned().pubkey().as_str().to_owned(),
- ],
- ],
- content,
- )
- .expect("forum comment event")
- }
-
- async fn next_ws_json(
- client: &mut tokio_tungstenite::WebSocketStream<
- tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
- >,
- phase: &'static str,
- ) -> serde_json::Value {
- loop {
- let message = tokio::time::timeout(std::time::Duration::from_secs(2), client.next())
- .await
- .expect(phase)
- .expect("websocket message")
- .expect("websocket frame");
- if let TungsteniteMessage::Text(raw) = message {
- return serde_json::from_str(&raw).expect("websocket JSON");
- }
- }
- }
-
- fn runtime_client_message_loop() -> ClientMessageLoop {
- let mut connection = runtime_connection("client-loop");
- connection.set_remote_addr("127.0.0.1:7777");
- ClientMessageLoop::new(connection)
- }
-
- fn runtime_connection(id: &str) -> RelayConnection {
- RelayConnection::new(
- RelayConnectionId::new(id).expect("connection id"),
- RelayConnectionConfig::default(),
- )
- }
-
- fn authenticated_connection() -> RelayConnection {
- let mut connection = runtime_connection("authenticated");
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- connection
- .auth_mut()
- .issue_challenge("challenge-001", UnixTimestamp::new(1_714_124_430))
- .expect("challenge");
- let auth = parse_relay_auth_event(&auth)
- .expect("auth parses")
- .expect("auth event");
- connection
- .auth_mut()
- .authenticate(&auth, UnixTimestamp::new(1_714_124_435))
- .expect("authenticate");
- connection
- }
-}
diff --git a/crates/tangle_store/Cargo.toml b/crates/tangle_store/Cargo.toml
@@ -1,15 +0,0 @@
-[package]
-name = "tangle_store"
-version.workspace = true
-edition.workspace = true
-authors.workspace = true
-rust-version.workspace = true
-license.workspace = true
-description = "Repository traits for tangle storage backends"
-
-[dependencies]
-tangle_nips = { path = "../tangle_nips" }
-tangle_protocol = { path = "../tangle_protocol" }
-
-[lints]
-workspace = true
diff --git a/crates/tangle_store/src/lib.rs b/crates/tangle_store/src/lib.rs
@@ -1,237 +0,0 @@
-#![forbid(unsafe_code)]
-
-use core::fmt;
-use tangle_nips::{DeletionTarget, ListingProjection};
-use tangle_protocol::{AddressCoordinate, Event, EventId, PublicKeyHex, UnixTimestamp};
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct StoredEvent {
- event: Event,
- received_at: UnixTimestamp,
- content_len: usize,
- tag_count: usize,
-}
-
-impl StoredEvent {
- pub fn new(event: Event, received_at: UnixTimestamp) -> Self {
- let content_len = event.unsigned().content().len();
- let tag_count = event.unsigned().tags().len();
- Self {
- event,
- received_at,
- content_len,
- tag_count,
- }
- }
-
- pub fn event(&self) -> &Event {
- &self.event
- }
-
- pub fn received_at(&self) -> UnixTimestamp {
- self.received_at
- }
-
- pub fn content_len(&self) -> usize {
- self.content_len
- }
-
- pub fn tag_count(&self) -> usize {
- self.tag_count
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum StoreEventOutcome {
- Inserted,
- Duplicate,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum StoreProjectionOutcome {
- Inserted,
- Replaced,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct DeletionMarker {
- deletion_event_id: EventId,
- author_pubkey: PublicKeyHex,
- target: DeletionTarget,
- deleted_at: UnixTimestamp,
-}
-
-impl DeletionMarker {
- pub fn new(
- deletion_event_id: EventId,
- author_pubkey: PublicKeyHex,
- target: DeletionTarget,
- deleted_at: UnixTimestamp,
- ) -> Self {
- Self {
- deletion_event_id,
- author_pubkey,
- target,
- deleted_at,
- }
- }
-
- pub fn deletion_event_id(&self) -> &EventId {
- &self.deletion_event_id
- }
-
- pub fn author_pubkey(&self) -> &PublicKeyHex {
- &self.author_pubkey
- }
-
- pub fn target(&self) -> &DeletionTarget {
- &self.target
- }
-
- pub fn deleted_at(&self) -> UnixTimestamp {
- self.deleted_at
- }
-}
-
-#[derive(Clone, PartialEq, Eq)]
-pub struct RepositoryError {
- message: String,
-}
-
-impl RepositoryError {
- pub fn new(message: &str) -> Self {
- Self {
- message: message.to_owned(),
- }
- }
-
- pub fn message(&self) -> &str {
- &self.message
- }
-}
-
-impl fmt::Display for RepositoryError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(&self.message)
- }
-}
-
-impl fmt::Debug for RepositoryError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter
- .debug_struct("RepositoryError")
- .field("message", &self.message)
- .finish()
- }
-}
-
-impl std::error::Error for RepositoryError {}
-
-pub trait RawEventRepository {
- fn put_event(&mut self, record: StoredEvent) -> Result<StoreEventOutcome, RepositoryError>;
-
- fn event_by_id(&self, event_id: &EventId) -> Result<Option<StoredEvent>, RepositoryError>;
-
- fn events(&self) -> Result<Vec<StoredEvent>, RepositoryError>;
-}
-
-pub trait ListingProjectionRepository {
- fn put_listing_projection(
- &mut self,
- projection: ListingProjection,
- ) -> Result<StoreProjectionOutcome, RepositoryError>;
-
- fn listing_projection(
- &self,
- address: &AddressCoordinate,
- ) -> Result<Option<ListingProjection>, RepositoryError>;
-}
-
-pub trait DeletionMarkerRepository {
- fn put_deletion_marker(&mut self, marker: DeletionMarker) -> Result<(), RepositoryError>;
-
- fn deletion_markers(&self) -> Result<Vec<DeletionMarker>, RepositoryError>;
-}
-
-#[cfg(test)]
-mod tests {
- use super::{
- DeletionMarker, RepositoryError, StoreEventOutcome, StoreProjectionOutcome, StoredEvent,
- };
- use tangle_nips::DeletionTarget;
- use tangle_protocol::{
- Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent,
- };
-
- #[test]
- fn stored_event_preserves_event_and_derived_metadata() {
- let event = event_with_tags_and_content(
- vec![
- Tag::from_parts("d", &["listing-a"]).expect("d"),
- Tag::from_parts("t", &["carrots"]).expect("topic"),
- ],
- "fresh",
- );
- let stored = StoredEvent::new(event.clone(), UnixTimestamp::new(100));
-
- assert_eq!(stored.event(), &event);
- assert_eq!(stored.received_at().as_u64(), 100);
- assert_eq!(stored.content_len(), 5);
- assert_eq!(stored.tag_count(), 2);
- assert_eq!(StoreEventOutcome::Inserted, StoreEventOutcome::Inserted);
- assert_eq!(StoreEventOutcome::Duplicate, StoreEventOutcome::Duplicate);
- assert_eq!(
- StoreProjectionOutcome::Inserted,
- StoreProjectionOutcome::Inserted
- );
- assert_eq!(
- StoreProjectionOutcome::Replaced,
- StoreProjectionOutcome::Replaced
- );
- }
-
- #[test]
- fn deletion_marker_preserves_typed_deletion_target() {
- let deletion_event_id = EventId::new(&"2".repeat(EventId::HEX_LENGTH)).expect("id");
- let author_pubkey = PublicKeyHex::new(&"3".repeat(PublicKeyHex::HEX_LENGTH)).expect("pk");
- let target_id = EventId::new(&"4".repeat(EventId::HEX_LENGTH)).expect("target");
- let target = DeletionTarget::Event(target_id.clone());
- let marker = DeletionMarker::new(
- deletion_event_id.clone(),
- author_pubkey.clone(),
- target,
- UnixTimestamp::new(200),
- );
-
- assert_eq!(marker.deletion_event_id(), &deletion_event_id);
- assert_eq!(marker.author_pubkey(), &author_pubkey);
- assert_eq!(marker.target(), &DeletionTarget::Event(target_id));
- assert_eq!(marker.deleted_at().as_u64(), 200);
- }
-
- #[test]
- fn repository_error_has_stable_message_display_and_debug() {
- let error = RepositoryError::new("store unavailable");
-
- assert_eq!(error.message(), "store unavailable");
- assert_eq!(error.to_string(), "store unavailable");
- assert_eq!(
- format!("{error:?}"),
- "RepositoryError { message: \"store unavailable\" }"
- );
- }
-
- fn event_with_tags_and_content(tags: Vec<Tag>, content: &str) -> Event {
- Event::new(
- EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"),
- UnsignedEvent::new(
- PublicKeyHex::new(&"1".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"),
- UnixTimestamp::new(1_714_124_433),
- Kind::new(30_402).expect("kind"),
- tags,
- content,
- ),
- SignatureHex::new(&"b".repeat(SignatureHex::HEX_LENGTH)).expect("sig"),
- )
- }
-}
diff --git a/crates/tangle_store_surreal/Cargo.toml b/crates/tangle_store_surreal/Cargo.toml
@@ -1,23 +0,0 @@
-[package]
-name = "tangle_store_surreal"
-version.workspace = true
-edition.workspace = true
-authors.workspace = true
-rust-version.workspace = true
-license.workspace = true
-description = "SurrealDB storage backend for tangle"
-
-[dependencies]
-serde_json = "1"
-sha2 = "0.10"
-surrealdb = { version = "3.1.3", default-features = false, features = ["kv-mem", "kv-rocksdb", "protocol-http", "protocol-ws"] }
-tangle_nips = { path = "../tangle_nips" }
-tangle_protocol = { path = "../tangle_protocol" }
-tangle_store = { path = "../tangle_store" }
-
-[dev-dependencies]
-tangle_test_support = { path = "../tangle_test_support" }
-tokio = { version = "1", features = ["macros", "rt"] }
-
-[lints]
-workspace = true
diff --git a/crates/tangle_store_surreal/src/lib.rs b/crates/tangle_store_surreal/src/lib.rs
@@ -1,10526 +0,0 @@
-#![forbid(unsafe_code)]
-
-use core::fmt;
-use sha2::{Digest, Sha256};
-use std::collections::BTreeSet;
-use surrealdb::Surreal;
-use surrealdb::engine::any::{Any, connect as connect_any};
-use surrealdb::opt::{Config as SurrealClientConfig, auth::Root};
-use tangle_nips::{
- CommentEvent, DeletionTarget, ForumThreadEvent, LabelEvent, ListingProjection,
- ListingProjectionEvaluation, LongFormEvent, LongFormKind, NIP99_DRAFT_LISTING_KIND,
- NIP99_PUBLIC_LISTING_KIND, ReactionEvent, ReactionValue, ReportEvent, ReportTarget,
- SellerProfileEvent, evaluate_listing_projection, parse_comment_event, parse_deletion_request,
- parse_forum_thread_event, parse_label_event, parse_long_form_event, parse_reaction_event,
- parse_report_event, parse_seller_profile_event,
-};
-use tangle_protocol::{
- AddressCoordinate, Event, EventId, Filter, RawEventJson, UnixTimestamp, event_to_value,
- parse_event_json,
-};
-use tangle_store::{StoreEventOutcome, StoredEvent};
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum SurrealConnectionMode {
- Memory,
- RocksDb { path: String },
- Http { endpoint: String },
- WebSocket { endpoint: String },
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SurrealConnectionConfig {
- mode: SurrealConnectionMode,
- namespace: String,
- database: String,
- root_credentials: Option<SurrealRootCredentials>,
-}
-
-impl SurrealConnectionConfig {
- pub fn memory(namespace: &str, database: &str) -> Result<Self, SurrealConfigError> {
- Self::new(SurrealConnectionMode::Memory, namespace, database)
- }
-
- pub fn rocksdb(
- path: &str,
- namespace: &str,
- database: &str,
- ) -> Result<Self, SurrealConfigError> {
- let path = normalized_endpoint(path, "rocksdb path")?;
- Self::new(SurrealConnectionMode::RocksDb { path }, namespace, database)
- }
-
- pub fn http(
- endpoint: &str,
- namespace: &str,
- database: &str,
- ) -> Result<Self, SurrealConfigError> {
- let endpoint = normalized_endpoint(endpoint, "http endpoint")?;
- Self::new(
- SurrealConnectionMode::Http { endpoint },
- namespace,
- database,
- )
- }
-
- pub fn websocket(
- endpoint: &str,
- namespace: &str,
- database: &str,
- ) -> Result<Self, SurrealConfigError> {
- let endpoint = normalized_endpoint(endpoint, "websocket endpoint")?;
- Self::new(
- SurrealConnectionMode::WebSocket { endpoint },
- namespace,
- database,
- )
- }
-
- pub fn mode(&self) -> &SurrealConnectionMode {
- &self.mode
- }
-
- pub fn namespace(&self) -> &str {
- &self.namespace
- }
-
- pub fn database(&self) -> &str {
- &self.database
- }
-
- pub fn root_credentials(&self) -> Option<&SurrealRootCredentials> {
- self.root_credentials.as_ref()
- }
-
- pub fn with_root_credentials(
- mut self,
- username: &str,
- password: &str,
- ) -> Result<Self, SurrealConfigError> {
- self.root_credentials = Some(SurrealRootCredentials::new(username, password)?);
- Ok(self)
- }
-
- fn new(
- mode: SurrealConnectionMode,
- namespace: &str,
- database: &str,
- ) -> Result<Self, SurrealConfigError> {
- Ok(Self {
- mode,
- namespace: normalized_identifier(namespace, "namespace")?,
- database: normalized_identifier(database, "database")?,
- root_credentials: None,
- })
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SurrealRootCredentials {
- username: String,
- password: String,
-}
-
-impl SurrealRootCredentials {
- pub fn new(username: &str, password: &str) -> Result<Self, SurrealConfigError> {
- Ok(Self {
- username: normalized_secret(username, "username")?,
- password: normalized_secret(password, "password")?,
- })
- }
-
- pub fn username(&self) -> &str {
- &self.username
- }
-
- pub fn password(&self) -> &str {
- &self.password
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SurrealConfigError {
- message: String,
-}
-
-impl SurrealConfigError {
- fn new(message: &str) -> Self {
- Self {
- message: message.to_owned(),
- }
- }
-
- pub fn message(&self) -> &str {
- &self.message
- }
-}
-
-impl fmt::Display for SurrealConfigError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(&self.message)
- }
-}
-
-impl std::error::Error for SurrealConfigError {}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct SurrealMetricsSnapshot {
- stored_events: u64,
- visible_events: u64,
- hidden_events: u64,
- deleted_events: u64,
- current_listings: u64,
- active_listings: u64,
- seller_profiles: u64,
- visible_seller_profiles: u64,
- approved_sellers: u64,
- blocked_pubkeys: u64,
-}
-
-impl SurrealMetricsSnapshot {
- pub fn stored_events(self) -> u64 {
- self.stored_events
- }
-
- pub fn visible_events(self) -> u64 {
- self.visible_events
- }
-
- pub fn hidden_events(self) -> u64 {
- self.hidden_events
- }
-
- pub fn deleted_events(self) -> u64 {
- self.deleted_events
- }
-
- pub fn current_listings(self) -> u64 {
- self.current_listings
- }
-
- pub fn active_listings(self) -> u64 {
- self.active_listings
- }
-
- pub fn seller_profiles(self) -> u64 {
- self.seller_profiles
- }
-
- pub fn visible_seller_profiles(self) -> u64 {
- self.visible_seller_profiles
- }
-
- pub fn approved_sellers(self) -> u64 {
- self.approved_sellers
- }
-
- pub fn blocked_pubkeys(self) -> u64 {
- self.blocked_pubkeys
- }
-}
-
-fn normalized_identifier(value: &str, field: &str) -> Result<String, SurrealConfigError> {
- let trimmed = value.trim();
- if trimmed.is_empty() {
- return Err(SurrealConfigError::new(&format!(
- "surreal {field} must not be empty"
- )));
- }
- if !trimmed
- .bytes()
- .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
- {
- return Err(SurrealConfigError::new(&format!(
- "surreal {field} must use ASCII letters, digits, or underscore"
- )));
- }
- Ok(trimmed.to_owned())
-}
-
-fn normalized_endpoint(value: &str, field: &str) -> Result<String, SurrealConfigError> {
- let trimmed = value.trim();
- if trimmed.is_empty() {
- return Err(SurrealConfigError::new(&format!(
- "surreal {field} must not be empty"
- )));
- }
- Ok(trimmed.to_owned())
-}
-
-fn normalized_secret(value: &str, field: &str) -> Result<String, SurrealConfigError> {
- let trimmed = value.trim();
- if trimmed.is_empty() {
- return Err(SurrealConfigError::new(&format!(
- "surreal root {field} must not be empty"
- )));
- }
- Ok(trimmed.to_owned())
-}
-
-fn rocksdb_endpoint(path: &str) -> String {
- format!("rocksdb://{path}")
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SurrealMigration {
- name: String,
- surql: &'static str,
- checksum: String,
-}
-
-impl SurrealMigration {
- pub fn new(name: &str, surql: &'static str) -> Result<Self, SurrealMigrationError> {
- let name = normalized_migration_name(name)?;
- if surql.trim().is_empty() {
- return Err(SurrealMigrationError::new(
- "surreal migration body must not be empty",
- ));
- }
- Ok(Self {
- name,
- surql,
- checksum: checksum(surql),
- })
- }
-
- pub fn name(&self) -> &str {
- &self.name
- }
-
- pub fn surql(&self) -> &'static str {
- self.surql
- }
-
- pub fn checksum(&self) -> &str {
- &self.checksum
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SurrealMigrationPlan {
- migrations: Vec<SurrealMigration>,
-}
-
-impl SurrealMigrationPlan {
- pub fn new(migrations: Vec<SurrealMigration>) -> Result<Self, SurrealMigrationError> {
- for pair in migrations.windows(2) {
- if pair[0].name() >= pair[1].name() {
- return Err(SurrealMigrationError::new(
- "surreal migrations must be strictly ordered by name",
- ));
- }
- }
- Ok(Self { migrations })
- }
-
- pub fn migrations(&self) -> &[SurrealMigration] {
- &self.migrations
- }
-
- pub fn names(&self) -> Vec<&str> {
- self.migrations.iter().map(SurrealMigration::name).collect()
- }
-
- pub fn find(&self, name: &str) -> Option<&SurrealMigration> {
- self.migrations
- .iter()
- .find(|migration| migration.name() == name)
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SurrealMigrationError {
- message: String,
-}
-
-impl SurrealMigrationError {
- fn new(message: &str) -> Self {
- Self {
- message: message.to_owned(),
- }
- }
-
- pub fn message(&self) -> &str {
- &self.message
- }
-}
-
-impl fmt::Display for SurrealMigrationError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(&self.message)
- }
-}
-
-impl std::error::Error for SurrealMigrationError {}
-
-fn normalized_migration_name(name: &str) -> Result<String, SurrealMigrationError> {
- let trimmed = name.trim();
- if trimmed.is_empty() {
- return Err(SurrealMigrationError::new(
- "surreal migration name must not be empty",
- ));
- }
- let mut parts = trimmed.splitn(2, '_');
- let version = parts.next().unwrap_or_default();
- let label = parts.next().unwrap_or_default();
- if version.len() != 4 || !version.bytes().all(|byte| byte.is_ascii_digit()) {
- return Err(SurrealMigrationError::new(
- "surreal migration name must start with four digits",
- ));
- }
- if label.is_empty()
- || !label
- .bytes()
- .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
- {
- return Err(SurrealMigrationError::new(
- "surreal migration label must use lowercase ASCII, digits, or underscore",
- ));
- }
- Ok(trimmed.to_owned())
-}
-
-fn checksum(surql: &str) -> String {
- let digest = Sha256::digest(surql.as_bytes());
- lower_hex(&digest)
-}
-
-fn lower_hex(bytes: &[u8]) -> String {
- const HEX: &[u8; 16] = b"0123456789abcdef";
- let mut output = String::with_capacity(bytes.len() * 2);
- for byte in bytes {
- output.push(char::from(HEX[usize::from(byte >> 4)]));
- output.push(char::from(HEX[usize::from(byte & 0x0f)]));
- }
- output
-}
-
-pub fn migration_tracking_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0001_migration_tracking",
- r#"
-DEFINE TABLE IF NOT EXISTS migration SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS name ON TABLE migration TYPE string;
-DEFINE FIELD IF NOT EXISTS checksum ON TABLE migration TYPE string;
-DEFINE FIELD IF NOT EXISTS applied_at ON TABLE migration TYPE datetime;
-DEFINE INDEX IF NOT EXISTS migration_name_uid ON TABLE migration COLUMNS name UNIQUE;
-"#,
- )
- .expect("migration tracking schema is valid")
-}
-
-pub fn base_migration_plan() -> SurrealMigrationPlan {
- SurrealMigrationPlan::new(vec![
- migration_tracking_schema(),
- raw_event_schema(),
- event_tag_index_schema(),
- current_event_schema(),
- deletion_marker_schema(),
- listing_revision_schema(),
- listing_current_schema(),
- listing_helper_schemas(),
- search_document_schema(),
- policy_schemas(),
- comment_projection_schema(),
- reaction_projection_schema(),
- long_form_projection_schema(),
- forum_thread_schema(),
- label_projection_schema(),
- report_projection_schema(),
- seller_profile_schema(),
- ])
- .expect("base migration plan is strictly ordered")
-}
-
-pub fn raw_event_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0002_raw_event",
- r#"
-DEFINE TABLE IF NOT EXISTS nostr_event SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE nostr_event TYPE string;
-DEFINE FIELD IF NOT EXISTS pubkey ON TABLE nostr_event TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE nostr_event TYPE int;
-DEFINE FIELD IF NOT EXISTS kind ON TABLE nostr_event TYPE int;
-DEFINE FIELD IF NOT EXISTS tags ON TABLE nostr_event TYPE array;
-DEFINE FIELD IF NOT EXISTS content ON TABLE nostr_event TYPE string;
-DEFINE FIELD IF NOT EXISTS sig ON TABLE nostr_event TYPE string;
-DEFINE FIELD IF NOT EXISTS raw_json ON TABLE nostr_event TYPE string;
-DEFINE FIELD IF NOT EXISTS received_at ON TABLE nostr_event TYPE int;
-DEFINE FIELD IF NOT EXISTS content_len ON TABLE nostr_event TYPE int;
-DEFINE FIELD IF NOT EXISTS tag_count ON TABLE nostr_event TYPE int;
-DEFINE FIELD IF NOT EXISTS d_tag ON TABLE nostr_event TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS address_key ON TABLE nostr_event TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS deleted ON TABLE nostr_event TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS hidden ON TABLE nostr_event TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS rejection_reason ON TABLE nostr_event TYPE option<string>;
-DEFINE INDEX IF NOT EXISTS nostr_event_id_uid ON TABLE nostr_event COLUMNS event_id UNIQUE;
-DEFINE INDEX IF NOT EXISTS nostr_event_author_created ON TABLE nostr_event COLUMNS pubkey, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS nostr_event_kind_created ON TABLE nostr_event COLUMNS kind, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS nostr_event_kind_author_created ON TABLE nostr_event COLUMNS kind, pubkey, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS nostr_event_address_created ON TABLE nostr_event COLUMNS address_key, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS nostr_event_created ON TABLE nostr_event COLUMNS created_at, event_id;
-"#,
- )
- .expect("raw event schema is valid")
-}
-
-pub fn event_tag_index_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0003_event_tag_index",
- r#"
-DEFINE TABLE IF NOT EXISTS event_tag_index SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE event_tag_index TYPE string;
-DEFINE FIELD IF NOT EXISTS kind ON TABLE event_tag_index TYPE int;
-DEFINE FIELD IF NOT EXISTS pubkey ON TABLE event_tag_index TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE event_tag_index TYPE int;
-DEFINE FIELD IF NOT EXISTS tag ON TABLE event_tag_index TYPE string;
-DEFINE FIELD IF NOT EXISTS value ON TABLE event_tag_index TYPE string;
-DEFINE FIELD IF NOT EXISTS ordinal ON TABLE event_tag_index TYPE int;
-DEFINE INDEX IF NOT EXISTS event_tag_lookup ON TABLE event_tag_index COLUMNS tag, value, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS event_tag_kind_lookup ON TABLE event_tag_index COLUMNS tag, value, kind, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS event_tag_event ON TABLE event_tag_index COLUMNS event_id;
-"#,
- )
- .expect("event tag index schema is valid")
-}
-
-pub fn current_event_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0004_current_event",
- r#"
-DEFINE TABLE IF NOT EXISTS event_current SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS address_key ON TABLE event_current TYPE string;
-DEFINE FIELD IF NOT EXISTS kind ON TABLE event_current TYPE int;
-DEFINE FIELD IF NOT EXISTS pubkey ON TABLE event_current TYPE string;
-DEFINE FIELD IF NOT EXISTS d ON TABLE event_current TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE event_current TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE event_current TYPE int;
-DEFINE FIELD IF NOT EXISTS tie_break_id ON TABLE event_current TYPE string;
-DEFINE FIELD IF NOT EXISTS deleted ON TABLE event_current TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS hidden ON TABLE event_current TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE event_current TYPE int;
-DEFINE INDEX IF NOT EXISTS event_current_address_uid ON TABLE event_current COLUMNS address_key UNIQUE;
-DEFINE INDEX IF NOT EXISTS event_current_kind_pubkey ON TABLE event_current COLUMNS kind, pubkey;
-DEFINE INDEX IF NOT EXISTS event_current_event ON TABLE event_current COLUMNS event_id;
-"#,
- )
- .expect("current event schema is valid")
-}
-
-pub fn deletion_marker_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0005_deletion_marker",
- r#"
-DEFINE TABLE IF NOT EXISTS deletion_marker SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS deletion_event_id ON TABLE deletion_marker TYPE string;
-DEFINE FIELD IF NOT EXISTS target_type ON TABLE deletion_marker TYPE string;
-DEFINE FIELD IF NOT EXISTS target_ref ON TABLE deletion_marker TYPE string;
-DEFINE FIELD IF NOT EXISTS author_pubkey ON TABLE deletion_marker TYPE string;
-DEFINE FIELD IF NOT EXISTS deletion_created_at ON TABLE deletion_marker TYPE int;
-DEFINE INDEX IF NOT EXISTS deletion_target ON TABLE deletion_marker COLUMNS target_type, target_ref, deletion_created_at;
-DEFINE INDEX IF NOT EXISTS deletion_author_target ON TABLE deletion_marker COLUMNS author_pubkey, target_type, target_ref;
-"#,
- )
- .expect("deletion marker schema is valid")
-}
-
-pub fn listing_revision_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0006_listing_revision",
- r#"
-DEFINE TABLE IF NOT EXISTS listing_revision SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS revision_key ON TABLE listing_revision TYPE string;
-DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_revision TYPE string;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_revision TYPE string;
-DEFINE FIELD IF NOT EXISTS seller_pubkey ON TABLE listing_revision TYPE string;
-DEFINE FIELD IF NOT EXISTS d ON TABLE listing_revision TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE listing_revision TYPE int;
-DEFINE FIELD IF NOT EXISTS parsed_ok ON TABLE listing_revision TYPE bool;
-DEFINE FIELD IF NOT EXISTS parse_errors ON TABLE listing_revision TYPE array;
-DEFINE FIELD IF NOT EXISTS title ON TABLE listing_revision TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS summary ON TABLE listing_revision TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS price_decimal ON TABLE listing_revision TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS price_minor ON TABLE listing_revision TYPE option<int>;
-DEFINE FIELD IF NOT EXISTS currency_raw ON TABLE listing_revision TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS currency_norm ON TABLE listing_revision TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS unit ON TABLE listing_revision TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS status_tag ON TABLE listing_revision TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS projected_at ON TABLE listing_revision TYPE int;
-DEFINE INDEX IF NOT EXISTS listing_revision_event_uid ON TABLE listing_revision COLUMNS event_id UNIQUE;
-DEFINE INDEX IF NOT EXISTS listing_revision_listing_created ON TABLE listing_revision COLUMNS listing_key, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS listing_revision_seller_created ON TABLE listing_revision COLUMNS seller_pubkey, created_at, event_id;
-"#,
- )
- .expect("listing revision schema is valid")
-}
-
-pub fn listing_current_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0007_listing_current",
- r#"
-DEFINE TABLE IF NOT EXISTS listing_current SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_current TYPE string;
-DEFINE FIELD IF NOT EXISTS listing_key_hash ON TABLE listing_current TYPE string;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_current TYPE string;
-DEFINE FIELD IF NOT EXISTS seller_pubkey ON TABLE listing_current TYPE string;
-DEFINE FIELD IF NOT EXISTS d ON TABLE listing_current TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE listing_current TYPE int;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE listing_current TYPE int;
-DEFINE FIELD IF NOT EXISTS published_at ON TABLE listing_current TYPE option<int>;
-DEFINE FIELD IF NOT EXISTS title ON TABLE listing_current TYPE string;
-DEFINE FIELD IF NOT EXISTS summary ON TABLE listing_current TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS content ON TABLE listing_current TYPE string;
-DEFINE FIELD IF NOT EXISTS price_decimal ON TABLE listing_current TYPE string;
-DEFINE FIELD IF NOT EXISTS price_minor ON TABLE listing_current TYPE int;
-DEFINE FIELD IF NOT EXISTS currency_raw ON TABLE listing_current TYPE string;
-DEFINE FIELD IF NOT EXISTS currency_norm ON TABLE listing_current TYPE string;
-DEFINE FIELD IF NOT EXISTS price_frequency ON TABLE listing_current TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS unit ON TABLE listing_current TYPE string;
-DEFINE FIELD IF NOT EXISTS unit_family ON TABLE listing_current TYPE string;
-DEFINE FIELD IF NOT EXISTS location_text ON TABLE listing_current TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS geohash ON TABLE listing_current TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS geohash4 ON TABLE listing_current TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS geohash5 ON TABLE listing_current TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS geohash6 ON TABLE listing_current TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS geohash7 ON TABLE listing_current TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS point ON TABLE listing_current TYPE option<array>;
-DEFINE FIELD IF NOT EXISTS status_tag ON TABLE listing_current TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS effective_status ON TABLE listing_current TYPE string;
-DEFINE FIELD IF NOT EXISTS categories ON TABLE listing_current TYPE array;
-DEFINE FIELD IF NOT EXISTS tags ON TABLE listing_current TYPE array;
-DEFINE FIELD IF NOT EXISTS practices ON TABLE listing_current TYPE array;
-DEFINE FIELD IF NOT EXISTS certifications ON TABLE listing_current TYPE array;
-DEFINE FIELD IF NOT EXISTS image_urls ON TABLE listing_current TYPE array;
-DEFINE FIELD IF NOT EXISTS pickup_available ON TABLE listing_current TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS delivery_available ON TABLE listing_current TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS shipping_available ON TABLE listing_current TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS delivery_only ON TABLE listing_current TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS seller_trust_score ON TABLE listing_current TYPE option<int>;
-DEFINE FIELD IF NOT EXISTS hidden ON TABLE listing_current TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS deleted ON TABLE listing_current TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS projected_at ON TABLE listing_current TYPE int;
-DEFINE INDEX IF NOT EXISTS listing_key_uid ON TABLE listing_current COLUMNS listing_key UNIQUE;
-DEFINE INDEX IF NOT EXISTS listing_event_uid ON TABLE listing_current COLUMNS event_id UNIQUE;
-DEFINE INDEX IF NOT EXISTS listing_status_updated ON TABLE listing_current COLUMNS effective_status, updated_at, event_id;
-DEFINE INDEX IF NOT EXISTS listing_seller_status_updated ON TABLE listing_current COLUMNS seller_pubkey, effective_status, updated_at, event_id;
-DEFINE INDEX IF NOT EXISTS listing_price_lookup ON TABLE listing_current COLUMNS effective_status, currency_norm, unit, price_minor, event_id;
-DEFINE INDEX IF NOT EXISTS listing_geo4_status ON TABLE listing_current COLUMNS effective_status, geohash4, updated_at, event_id;
-DEFINE INDEX IF NOT EXISTS listing_geo5_status ON TABLE listing_current COLUMNS effective_status, geohash5, updated_at, event_id;
-DEFINE INDEX IF NOT EXISTS listing_geo6_status ON TABLE listing_current COLUMNS effective_status, geohash6, updated_at, event_id;
-"#,
- )
- .expect("listing current schema is valid")
-}
-
-pub fn listing_helper_schemas() -> SurrealMigration {
- SurrealMigration::new(
- "0008_listing_helpers",
- r#"
-DEFINE TABLE IF NOT EXISTS listing_category SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_category TYPE string;
-DEFINE FIELD IF NOT EXISTS category ON TABLE listing_category TYPE string;
-DEFINE FIELD IF NOT EXISTS effective_status ON TABLE listing_category TYPE string;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE listing_category TYPE int;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_category TYPE string;
-DEFINE INDEX IF NOT EXISTS listing_category_lookup ON TABLE listing_category COLUMNS category, effective_status, updated_at, listing_key;
-DEFINE INDEX IF NOT EXISTS listing_category_listing ON TABLE listing_category COLUMNS listing_key;
-
-DEFINE TABLE IF NOT EXISTS listing_fulfillment SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_fulfillment TYPE string;
-DEFINE FIELD IF NOT EXISTS mode ON TABLE listing_fulfillment TYPE string;
-DEFINE FIELD IF NOT EXISTS effective_status ON TABLE listing_fulfillment TYPE string;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE listing_fulfillment TYPE int;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_fulfillment TYPE string;
-DEFINE INDEX IF NOT EXISTS listing_fulfillment_lookup ON TABLE listing_fulfillment COLUMNS mode, effective_status, updated_at, listing_key;
-DEFINE INDEX IF NOT EXISTS listing_fulfillment_listing ON TABLE listing_fulfillment COLUMNS listing_key;
-
-DEFINE TABLE IF NOT EXISTS listing_tag SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_tag TYPE string;
-DEFINE FIELD IF NOT EXISTS tag_value ON TABLE listing_tag TYPE string;
-DEFINE FIELD IF NOT EXISTS effective_status ON TABLE listing_tag TYPE string;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE listing_tag TYPE int;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_tag TYPE string;
-DEFINE INDEX IF NOT EXISTS listing_tag_lookup ON TABLE listing_tag COLUMNS tag_value, effective_status, updated_at, listing_key;
-DEFINE INDEX IF NOT EXISTS listing_tag_listing ON TABLE listing_tag COLUMNS listing_key;
-
-DEFINE TABLE IF NOT EXISTS listing_practice SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_practice TYPE string;
-DEFINE FIELD IF NOT EXISTS practice ON TABLE listing_practice TYPE string;
-DEFINE FIELD IF NOT EXISTS effective_status ON TABLE listing_practice TYPE string;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE listing_practice TYPE int;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_practice TYPE string;
-DEFINE INDEX IF NOT EXISTS listing_practice_lookup ON TABLE listing_practice COLUMNS practice, effective_status, updated_at, listing_key;
-DEFINE INDEX IF NOT EXISTS listing_practice_listing ON TABLE listing_practice COLUMNS listing_key;
-
-DEFINE TABLE IF NOT EXISTS listing_certification SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_certification TYPE string;
-DEFINE FIELD IF NOT EXISTS certification ON TABLE listing_certification TYPE string;
-DEFINE FIELD IF NOT EXISTS effective_status ON TABLE listing_certification TYPE string;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE listing_certification TYPE int;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_certification TYPE string;
-DEFINE INDEX IF NOT EXISTS listing_certification_lookup ON TABLE listing_certification COLUMNS certification, effective_status, updated_at, listing_key;
-DEFINE INDEX IF NOT EXISTS listing_certification_listing ON TABLE listing_certification COLUMNS listing_key;
-"#,
- )
- .expect("listing helper schemas are valid")
-}
-
-pub fn search_document_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0009_search_document",
- r#"
-DEFINE ANALYZER IF NOT EXISTS tangle_listing_search TOKENIZERS blank,class FILTERS lowercase,snowball(english);
-DEFINE TABLE IF NOT EXISTS search_doc SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS doc_key ON TABLE search_doc TYPE string;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE search_doc TYPE string;
-DEFINE FIELD IF NOT EXISTS current_event_id ON TABLE search_doc TYPE string;
-DEFINE FIELD IF NOT EXISTS doc_type ON TABLE search_doc TYPE string;
-DEFINE FIELD IF NOT EXISTS kind ON TABLE search_doc TYPE int;
-DEFINE FIELD IF NOT EXISTS pubkey ON TABLE search_doc TYPE string;
-DEFINE FIELD IF NOT EXISTS address_key ON TABLE search_doc TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS title ON TABLE search_doc TYPE string;
-DEFINE FIELD IF NOT EXISTS summary ON TABLE search_doc TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS body ON TABLE search_doc TYPE string;
-DEFINE FIELD IF NOT EXISTS category_text ON TABLE search_doc TYPE string;
-DEFINE FIELD IF NOT EXISTS location_text ON TABLE search_doc TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS tags ON TABLE search_doc TYPE array;
-DEFINE FIELD IF NOT EXISTS categories ON TABLE search_doc TYPE array;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE search_doc TYPE int;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE search_doc TYPE int;
-DEFINE FIELD IF NOT EXISTS visible ON TABLE search_doc TYPE bool;
-DEFINE FIELD IF NOT EXISTS status ON TABLE search_doc TYPE string;
-DEFINE FIELD IF NOT EXISTS seller_trust_score ON TABLE search_doc TYPE option<int>;
-DEFINE INDEX IF NOT EXISTS search_doc_key_uid ON TABLE search_doc COLUMNS doc_key UNIQUE;
-DEFINE INDEX IF NOT EXISTS search_doc_type_visible_updated ON TABLE search_doc COLUMNS doc_type, visible, updated_at, event_id;
-DEFINE INDEX IF NOT EXISTS search_doc_kind_visible_updated ON TABLE search_doc COLUMNS visible, kind, updated_at, event_id;
-DEFINE INDEX IF NOT EXISTS search_doc_kind_pubkey_updated ON TABLE search_doc COLUMNS kind, pubkey, updated_at, event_id;
-DEFINE INDEX IF NOT EXISTS search_doc_title_ft ON TABLE search_doc FIELDS title FULLTEXT ANALYZER tangle_listing_search BM25 HIGHLIGHTS;
-DEFINE INDEX IF NOT EXISTS search_doc_summary_ft ON TABLE search_doc FIELDS summary FULLTEXT ANALYZER tangle_listing_search BM25 HIGHLIGHTS;
-DEFINE INDEX IF NOT EXISTS search_doc_body_ft ON TABLE search_doc FIELDS body FULLTEXT ANALYZER tangle_listing_search BM25 HIGHLIGHTS;
-"#,
- )
- .expect("search document schema is valid")
-}
-
-pub fn policy_schemas() -> SurrealMigration {
- SurrealMigration::new(
- "0010_policy",
- r#"
-DEFINE TABLE IF NOT EXISTS relay_user SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS pubkey ON TABLE relay_user TYPE string;
-DEFINE FIELD IF NOT EXISTS role ON TABLE relay_user TYPE string;
-DEFINE FIELD IF NOT EXISTS seller_approved ON TABLE relay_user TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS blocked ON TABLE relay_user TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE relay_user TYPE int;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE relay_user TYPE int;
-DEFINE INDEX IF NOT EXISTS relay_user_pubkey_uid ON TABLE relay_user COLUMNS pubkey UNIQUE;
-DEFINE INDEX IF NOT EXISTS relay_user_role ON TABLE relay_user COLUMNS role;
-DEFINE INDEX IF NOT EXISTS relay_user_seller_gate ON TABLE relay_user COLUMNS seller_approved, blocked;
-
-DEFINE TABLE IF NOT EXISTS hidden_event SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE hidden_event TYPE string;
-DEFINE FIELD IF NOT EXISTS reason ON TABLE hidden_event TYPE string;
-DEFINE FIELD IF NOT EXISTS source ON TABLE hidden_event TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE hidden_event TYPE int;
-DEFINE FIELD IF NOT EXISTS admin_pubkey ON TABLE hidden_event TYPE string;
-DEFINE INDEX IF NOT EXISTS hidden_event_uid ON TABLE hidden_event COLUMNS event_id UNIQUE;
-DEFINE INDEX IF NOT EXISTS hidden_event_created ON TABLE hidden_event COLUMNS created_at;
-
-DEFINE TABLE IF NOT EXISTS moderation_action SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS action_id ON TABLE moderation_action TYPE string;
-DEFINE FIELD IF NOT EXISTS admin_pubkey ON TABLE moderation_action TYPE string;
-DEFINE FIELD IF NOT EXISTS target_type ON TABLE moderation_action TYPE string;
-DEFINE FIELD IF NOT EXISTS target_ref ON TABLE moderation_action TYPE string;
-DEFINE FIELD IF NOT EXISTS action ON TABLE moderation_action TYPE string;
-DEFINE FIELD IF NOT EXISTS reason ON TABLE moderation_action TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE moderation_action TYPE int;
-DEFINE INDEX IF NOT EXISTS moderation_action_target ON TABLE moderation_action COLUMNS target_type, target_ref, created_at;
-DEFINE INDEX IF NOT EXISTS moderation_action_admin ON TABLE moderation_action COLUMNS admin_pubkey, created_at;
-
-DEFINE TABLE IF NOT EXISTS rate_limit_state SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS key ON TABLE rate_limit_state TYPE string;
-DEFINE FIELD IF NOT EXISTS state ON TABLE rate_limit_state TYPE string;
-DEFINE FIELD IF NOT EXISTS expires_at ON TABLE rate_limit_state TYPE option<int>;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE rate_limit_state TYPE int;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE rate_limit_state TYPE int;
-DEFINE INDEX IF NOT EXISTS rate_limit_state_key_uid ON TABLE rate_limit_state COLUMNS key UNIQUE;
-DEFINE INDEX IF NOT EXISTS rate_limit_state_expires ON TABLE rate_limit_state COLUMNS expires_at;
-
-DEFINE TABLE IF NOT EXISTS import_checkpoint SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS name ON TABLE import_checkpoint TYPE string;
-DEFINE FIELD IF NOT EXISTS offset ON TABLE import_checkpoint TYPE int;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE import_checkpoint TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE import_checkpoint TYPE int;
-DEFINE INDEX IF NOT EXISTS import_checkpoint_name_uid ON TABLE import_checkpoint COLUMNS name UNIQUE;
-
-DEFINE TABLE IF NOT EXISTS projection_error SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE projection_error TYPE string;
-DEFINE FIELD IF NOT EXISTS projector ON TABLE projection_error TYPE string;
-DEFINE FIELD IF NOT EXISTS error ON TABLE projection_error TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE projection_error TYPE int;
-DEFINE INDEX IF NOT EXISTS projection_error_event ON TABLE projection_error COLUMNS event_id;
-DEFINE INDEX IF NOT EXISTS projection_error_projector_created ON TABLE projection_error COLUMNS projector, created_at;
-"#,
- )
- .expect("policy schemas are valid")
-}
-
-pub fn comment_projection_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0011_comment_projection",
- r#"
-DEFINE TABLE IF NOT EXISTS comment_projection SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS comment_id ON TABLE comment_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE comment_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS pubkey ON TABLE comment_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE comment_projection TYPE int;
-DEFINE FIELD IF NOT EXISTS content ON TABLE comment_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS root_target_type ON TABLE comment_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS root_ref ON TABLE comment_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS root_kind ON TABLE comment_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS root_author ON TABLE comment_projection TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS parent_target_type ON TABLE comment_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS parent_ref ON TABLE comment_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS parent_kind ON TABLE comment_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS parent_author ON TABLE comment_projection TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS hidden ON TABLE comment_projection TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS deleted ON TABLE comment_projection TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS projected_at ON TABLE comment_projection TYPE int;
-DEFINE INDEX IF NOT EXISTS comment_projection_event_uid ON TABLE comment_projection COLUMNS event_id UNIQUE;
-DEFINE INDEX IF NOT EXISTS comment_projection_root_lookup ON TABLE comment_projection COLUMNS root_target_type, root_ref, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS comment_projection_parent_lookup ON TABLE comment_projection COLUMNS parent_target_type, parent_ref, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS comment_projection_author_created ON TABLE comment_projection COLUMNS pubkey, created_at, event_id;
-"#,
- )
- .expect("comment projection schema is valid")
-}
-
-pub fn reaction_projection_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0012_reaction_projection",
- r#"
-DEFINE TABLE IF NOT EXISTS reaction_projection SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS reaction_id ON TABLE reaction_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE reaction_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS pubkey ON TABLE reaction_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE reaction_projection TYPE int;
-DEFINE FIELD IF NOT EXISTS content ON TABLE reaction_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS value_type ON TABLE reaction_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS value ON TABLE reaction_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS target_event_id ON TABLE reaction_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS target_pubkey ON TABLE reaction_projection TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS target_address ON TABLE reaction_projection TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS target_kind ON TABLE reaction_projection TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS hidden ON TABLE reaction_projection TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS deleted ON TABLE reaction_projection TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS projected_at ON TABLE reaction_projection TYPE int;
-DEFINE INDEX IF NOT EXISTS reaction_projection_event_uid ON TABLE reaction_projection COLUMNS event_id UNIQUE;
-DEFINE INDEX IF NOT EXISTS reaction_projection_target_created ON TABLE reaction_projection COLUMNS target_event_id, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS reaction_projection_author_created ON TABLE reaction_projection COLUMNS pubkey, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS reaction_projection_target_kind ON TABLE reaction_projection COLUMNS target_kind, target_event_id;
-
-DEFINE TABLE IF NOT EXISTS reaction_count SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS target_event_id ON TABLE reaction_count TYPE string;
-DEFINE FIELD IF NOT EXISTS target_kind ON TABLE reaction_count TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS like_count ON TABLE reaction_count TYPE int DEFAULT 0;
-DEFINE FIELD IF NOT EXISTS dislike_count ON TABLE reaction_count TYPE int DEFAULT 0;
-DEFINE FIELD IF NOT EXISTS emoji_count ON TABLE reaction_count TYPE int DEFAULT 0;
-DEFINE FIELD IF NOT EXISTS text_count ON TABLE reaction_count TYPE int DEFAULT 0;
-DEFINE FIELD IF NOT EXISTS total_count ON TABLE reaction_count TYPE int DEFAULT 0;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE reaction_count TYPE int;
-DEFINE INDEX IF NOT EXISTS reaction_count_target_uid ON TABLE reaction_count COLUMNS target_event_id UNIQUE;
-DEFINE INDEX IF NOT EXISTS reaction_count_kind_target ON TABLE reaction_count COLUMNS target_kind, target_event_id;
-"#,
- )
- .expect("reaction projection schema is valid")
-}
-
-pub fn long_form_projection_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0013_long_form_projection",
- r#"
-DEFINE TABLE IF NOT EXISTS long_form_current SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS long_form_key ON TABLE long_form_current TYPE string;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE long_form_current TYPE string;
-DEFINE FIELD IF NOT EXISTS author_pubkey ON TABLE long_form_current TYPE string;
-DEFINE FIELD IF NOT EXISTS d ON TABLE long_form_current TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE long_form_current TYPE int;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE long_form_current TYPE int;
-DEFINE FIELD IF NOT EXISTS published_at ON TABLE long_form_current TYPE option<int>;
-DEFINE FIELD IF NOT EXISTS title ON TABLE long_form_current TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS image ON TABLE long_form_current TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS summary ON TABLE long_form_current TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS content ON TABLE long_form_current TYPE string;
-DEFINE FIELD IF NOT EXISTS tags ON TABLE long_form_current TYPE array;
-DEFINE FIELD IF NOT EXISTS referenced_events ON TABLE long_form_current TYPE array;
-DEFINE FIELD IF NOT EXISTS referenced_addresses ON TABLE long_form_current TYPE array;
-DEFINE FIELD IF NOT EXISTS referenced_pubkeys ON TABLE long_form_current TYPE array;
-DEFINE FIELD IF NOT EXISTS hidden ON TABLE long_form_current TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS deleted ON TABLE long_form_current TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS projected_at ON TABLE long_form_current TYPE int;
-DEFINE INDEX IF NOT EXISTS long_form_current_key_uid ON TABLE long_form_current COLUMNS long_form_key UNIQUE;
-DEFINE INDEX IF NOT EXISTS long_form_current_event_uid ON TABLE long_form_current COLUMNS event_id UNIQUE;
-DEFINE INDEX IF NOT EXISTS long_form_current_author_updated ON TABLE long_form_current COLUMNS author_pubkey, updated_at, event_id;
-DEFINE INDEX IF NOT EXISTS long_form_current_published_updated ON TABLE long_form_current COLUMNS published_at, updated_at, event_id;
-DEFINE INDEX IF NOT EXISTS long_form_current_visibility ON TABLE long_form_current COLUMNS hidden, deleted, updated_at, event_id;
-
-DEFINE TABLE IF NOT EXISTS long_form_topic SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS long_form_key ON TABLE long_form_topic TYPE string;
-DEFINE FIELD IF NOT EXISTS topic ON TABLE long_form_topic TYPE string;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE long_form_topic TYPE int;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE long_form_topic TYPE string;
-DEFINE FIELD IF NOT EXISTS hidden ON TABLE long_form_topic TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS deleted ON TABLE long_form_topic TYPE bool DEFAULT false;
-DEFINE INDEX IF NOT EXISTS long_form_topic_lookup ON TABLE long_form_topic COLUMNS topic, hidden, deleted, updated_at, long_form_key;
-DEFINE INDEX IF NOT EXISTS long_form_topic_long_form ON TABLE long_form_topic COLUMNS long_form_key;
-"#,
- )
- .expect("long-form projection schema is valid")
-}
-
-pub fn forum_thread_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0014_forum_thread_projection",
- r#"
-DEFINE TABLE IF NOT EXISTS forum_thread_projection SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS thread_id ON TABLE forum_thread_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE forum_thread_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS pubkey ON TABLE forum_thread_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE forum_thread_projection TYPE int;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE forum_thread_projection TYPE int;
-DEFINE FIELD IF NOT EXISTS title ON TABLE forum_thread_projection TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS content ON TABLE forum_thread_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS tags ON TABLE forum_thread_projection TYPE array;
-DEFINE FIELD IF NOT EXISTS referenced_events ON TABLE forum_thread_projection TYPE array;
-DEFINE FIELD IF NOT EXISTS referenced_pubkeys ON TABLE forum_thread_projection TYPE array;
-DEFINE FIELD IF NOT EXISTS hidden ON TABLE forum_thread_projection TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS deleted ON TABLE forum_thread_projection TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS projected_at ON TABLE forum_thread_projection TYPE int;
-DEFINE INDEX IF NOT EXISTS forum_thread_event_uid ON TABLE forum_thread_projection COLUMNS event_id UNIQUE;
-DEFINE INDEX IF NOT EXISTS forum_thread_pubkey_updated ON TABLE forum_thread_projection COLUMNS pubkey, updated_at, event_id;
-DEFINE INDEX IF NOT EXISTS forum_thread_visibility_updated ON TABLE forum_thread_projection COLUMNS hidden, deleted, updated_at, event_id;
-
-DEFINE TABLE IF NOT EXISTS forum_thread_topic SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS thread_id ON TABLE forum_thread_topic TYPE string;
-DEFINE FIELD IF NOT EXISTS topic ON TABLE forum_thread_topic TYPE string;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE forum_thread_topic TYPE int;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE forum_thread_topic TYPE string;
-DEFINE FIELD IF NOT EXISTS hidden ON TABLE forum_thread_topic TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS deleted ON TABLE forum_thread_topic TYPE bool DEFAULT false;
-DEFINE INDEX IF NOT EXISTS forum_thread_topic_lookup ON TABLE forum_thread_topic COLUMNS topic, hidden, deleted, updated_at, thread_id;
-DEFINE INDEX IF NOT EXISTS forum_thread_topic_thread ON TABLE forum_thread_topic COLUMNS thread_id;
-"#,
- )
- .expect("forum thread schema is valid")
-}
-
-pub fn label_projection_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0015_label_projection",
- r#"
-DEFINE TABLE IF NOT EXISTS label_projection SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS label_id ON TABLE label_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE label_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS pubkey ON TABLE label_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE label_projection TYPE int;
-DEFINE FIELD IF NOT EXISTS content ON TABLE label_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS namespace ON TABLE label_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS label ON TABLE label_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS target_type ON TABLE label_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS target_ref ON TABLE label_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS hidden ON TABLE label_projection TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS deleted ON TABLE label_projection TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS projected_at ON TABLE label_projection TYPE int;
-DEFINE INDEX IF NOT EXISTS label_projection_label_uid ON TABLE label_projection COLUMNS label_id UNIQUE;
-DEFINE INDEX IF NOT EXISTS label_projection_event ON TABLE label_projection COLUMNS event_id;
-DEFINE INDEX IF NOT EXISTS label_projection_target_lookup ON TABLE label_projection COLUMNS target_type, target_ref, namespace, label, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS label_projection_namespace_lookup ON TABLE label_projection COLUMNS namespace, label, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS label_projection_author_created ON TABLE label_projection COLUMNS pubkey, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS label_projection_visibility ON TABLE label_projection COLUMNS hidden, deleted, created_at, event_id;
-"#,
- )
- .expect("label projection schema is valid")
-}
-
-pub fn report_projection_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0016_report_projection",
- r#"
-DEFINE TABLE IF NOT EXISTS report_projection SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS report_id ON TABLE report_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE report_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS pubkey ON TABLE report_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE report_projection TYPE int;
-DEFINE FIELD IF NOT EXISTS content ON TABLE report_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS target_type ON TABLE report_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS target_ref ON TABLE report_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS report_type ON TABLE report_projection TYPE string;
-DEFINE FIELD IF NOT EXISTS reported_pubkeys ON TABLE report_projection TYPE array;
-DEFINE FIELD IF NOT EXISTS server_urls ON TABLE report_projection TYPE array;
-DEFINE FIELD IF NOT EXISTS hidden ON TABLE report_projection TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS deleted ON TABLE report_projection TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS projected_at ON TABLE report_projection TYPE int;
-DEFINE INDEX IF NOT EXISTS report_projection_report_uid ON TABLE report_projection COLUMNS report_id UNIQUE;
-DEFINE INDEX IF NOT EXISTS report_projection_event ON TABLE report_projection COLUMNS event_id;
-DEFINE INDEX IF NOT EXISTS report_projection_target_lookup ON TABLE report_projection COLUMNS target_type, target_ref, report_type, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS report_projection_type_created ON TABLE report_projection COLUMNS report_type, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS report_projection_author_created ON TABLE report_projection COLUMNS pubkey, created_at, event_id;
-DEFINE INDEX IF NOT EXISTS report_projection_visibility ON TABLE report_projection COLUMNS hidden, deleted, created_at, event_id;
-"#,
- )
- .expect("report projection schema is valid")
-}
-
-pub fn seller_profile_schema() -> SurrealMigration {
- SurrealMigration::new(
- "0017_seller_profile",
- r#"
-DEFINE TABLE IF NOT EXISTS seller_profile SCHEMAFULL;
-DEFINE FIELD IF NOT EXISTS pubkey ON TABLE seller_profile TYPE string;
-DEFINE FIELD IF NOT EXISTS event_id ON TABLE seller_profile TYPE string;
-DEFINE FIELD IF NOT EXISTS created_at ON TABLE seller_profile TYPE int;
-DEFINE FIELD IF NOT EXISTS updated_at ON TABLE seller_profile TYPE int;
-DEFINE FIELD IF NOT EXISTS name ON TABLE seller_profile TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS display_name ON TABLE seller_profile TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS about ON TABLE seller_profile TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS picture ON TABLE seller_profile TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS website ON TABLE seller_profile TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS nip05 ON TABLE seller_profile TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS lud16 ON TABLE seller_profile TYPE option<string>;
-DEFINE FIELD IF NOT EXISTS regions ON TABLE seller_profile TYPE array;
-DEFINE FIELD IF NOT EXISTS categories ON TABLE seller_profile TYPE array;
-DEFINE FIELD IF NOT EXISTS trust_markers ON TABLE seller_profile TYPE array;
-DEFINE FIELD IF NOT EXISTS seller_approved ON TABLE seller_profile TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS blocked ON TABLE seller_profile TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS hidden ON TABLE seller_profile TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS deleted ON TABLE seller_profile TYPE bool DEFAULT false;
-DEFINE FIELD IF NOT EXISTS projected_at ON TABLE seller_profile TYPE int;
-DEFINE INDEX IF NOT EXISTS seller_profile_pubkey_uid ON TABLE seller_profile COLUMNS pubkey UNIQUE;
-DEFINE INDEX IF NOT EXISTS seller_profile_event_uid ON TABLE seller_profile COLUMNS event_id UNIQUE;
-DEFINE INDEX IF NOT EXISTS seller_profile_updated ON TABLE seller_profile COLUMNS updated_at, pubkey;
-DEFINE INDEX IF NOT EXISTS seller_profile_approved_blocked ON TABLE seller_profile COLUMNS seller_approved, blocked, updated_at, pubkey;
-DEFINE INDEX IF NOT EXISTS seller_profile_visibility ON TABLE seller_profile COLUMNS hidden, deleted, updated_at, pubkey;
-"#,
- )
- .expect("seller profile schema is valid")
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct AppliedMigration {
- name: String,
- checksum: String,
-}
-
-impl AppliedMigration {
- pub fn name(&self) -> &str {
- &self.name
- }
-
- pub fn checksum(&self) -> &str {
- &self.checksum
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum MigrationApplyOutcome {
- Applied,
- AlreadyApplied,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum CurrentEventOutcome {
- NotCurrent,
- Inserted,
- Replaced,
- Unchanged,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum DeletionMarkerOutcome {
- NotDeletion,
- Applied { targets: usize },
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum ListingRevisionOutcome {
- NotListing,
- Stored { parsed_ok: bool },
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum ListingCurrentOutcome {
- NotListing,
- Ineligible,
- Projected,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum ListingHelperOutcome {
- NotListing,
- Ineligible,
- Projected,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum SearchDocumentOutcome {
- NotListing,
- Ineligible,
- Indexed,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum CommentProjectionOutcome {
- NotComment,
- Ineligible,
- Projected,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum ReactionProjectionOutcome {
- NotReaction,
- Ineligible,
- Projected,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum LongFormProjectionOutcome {
- NotLongForm,
- Ineligible,
- Projected,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum ForumThreadProjectionOutcome {
- NotForumThread,
- Ineligible,
- Projected,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum LabelProjectionOutcome {
- NotLabel,
- Ineligible,
- Projected,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum ReportProjectionOutcome {
- NotReport,
- Ineligible,
- Projected,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum SellerProfileProjectionOutcome {
- NotProfile,
- Ineligible,
- Projected,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum HiddenEventOutcome {
- NotFound,
- Hidden,
- Unhidden,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum DurableRateLimitDecision {
- Accepted {
- remaining: u64,
- reset_at: UnixTimestamp,
- },
- Rejected {
- retry_after_seconds: u64,
- reset_at: UnixTimestamp,
- },
-}
-
-impl DurableRateLimitDecision {
- pub fn allowed(self) -> bool {
- matches!(self, Self::Accepted { .. })
- }
-
- pub fn remaining(self) -> u64 {
- match self {
- Self::Accepted { remaining, .. } => remaining,
- Self::Rejected { .. } => 0,
- }
- }
-
- pub fn reset_at(self) -> UnixTimestamp {
- match self {
- Self::Accepted { reset_at, .. } | Self::Rejected { reset_at, .. } => reset_at,
- }
- }
-
- pub fn retry_after_seconds(self) -> Option<u64> {
- match self {
- Self::Accepted { .. } => None,
- Self::Rejected {
- retry_after_seconds,
- ..
- } => Some(retry_after_seconds),
- }
- }
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct ListingProjectionQuery {
- effective_status: Option<String>,
- seller_pubkey: Option<String>,
- unit: Option<String>,
- currency_norm: Option<String>,
- min_price_minor: Option<i64>,
- max_price_minor: Option<i64>,
- limit: Option<u64>,
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct SearchDocumentQuery {
- text: Option<String>,
- doc_type: Option<String>,
- kind: Option<u32>,
- pubkey: Option<String>,
- visible: Option<bool>,
- status: Option<String>,
- limit: Option<u64>,
-}
-
-impl ListingProjectionQuery {
- pub fn new() -> Self {
- Self::default()
- }
-
- pub fn with_effective_status(mut self, value: &str) -> Self {
- self.effective_status = Some(value.to_owned());
- self
- }
-
- pub fn with_seller_pubkey(mut self, value: &str) -> Self {
- self.seller_pubkey = Some(value.to_owned());
- self
- }
-
- pub fn with_unit(mut self, value: &str) -> Self {
- self.unit = Some(value.to_owned());
- self
- }
-
- pub fn with_currency_norm(mut self, value: &str) -> Self {
- self.currency_norm = Some(value.to_owned());
- self
- }
-
- pub fn with_min_price_minor(mut self, value: i64) -> Self {
- self.min_price_minor = Some(value);
- self
- }
-
- pub fn with_max_price_minor(mut self, value: i64) -> Self {
- self.max_price_minor = Some(value);
- self
- }
-
- pub fn with_limit(mut self, value: u64) -> Self {
- self.limit = Some(value);
- self
- }
-}
-
-impl SearchDocumentQuery {
- pub fn new() -> Self {
- Self::default()
- }
-
- pub fn with_text(mut self, value: &str) -> Self {
- self.text = Some(value.to_owned());
- self
- }
-
- pub fn with_doc_type(mut self, value: &str) -> Self {
- self.doc_type = Some(value.to_owned());
- self
- }
-
- pub fn with_kind(mut self, value: u32) -> Self {
- self.kind = Some(value);
- self
- }
-
- pub fn with_pubkey(mut self, value: &str) -> Self {
- self.pubkey = Some(value.to_owned());
- self
- }
-
- pub fn with_visible(mut self, value: bool) -> Self {
- self.visible = Some(value);
- self
- }
-
- pub fn with_status(mut self, value: &str) -> Self {
- self.status = Some(value.to_owned());
- self
- }
-
- pub fn with_limit(mut self, value: u64) -> Self {
- self.limit = Some(value);
- self
- }
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct CommentProjectionQuery {
- root_target_type: Option<String>,
- root_ref: Option<String>,
- parent_target_type: Option<String>,
- parent_ref: Option<String>,
- pubkey: Option<String>,
- limit: Option<u64>,
-}
-
-impl CommentProjectionQuery {
- pub fn new() -> Self {
- Self::default()
- }
-
- pub fn with_root(mut self, target_type: &str, target_ref: &str) -> Self {
- self.root_target_type = Some(target_type.to_owned());
- self.root_ref = Some(target_ref.to_owned());
- self
- }
-
- pub fn with_parent(mut self, target_type: &str, target_ref: &str) -> Self {
- self.parent_target_type = Some(target_type.to_owned());
- self.parent_ref = Some(target_ref.to_owned());
- self
- }
-
- pub fn with_pubkey(mut self, pubkey: &str) -> Self {
- self.pubkey = Some(pubkey.to_owned());
- self
- }
-
- pub fn with_limit(mut self, limit: u64) -> Self {
- self.limit = Some(limit);
- self
- }
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct LongFormProjectionQuery {
- author_pubkey: Option<String>,
- topic: Option<String>,
- limit: Option<u64>,
-}
-
-impl LongFormProjectionQuery {
- pub fn new() -> Self {
- Self::default()
- }
-
- pub fn with_author_pubkey(mut self, pubkey: &str) -> Self {
- self.author_pubkey = Some(pubkey.to_owned());
- self
- }
-
- pub fn with_topic(mut self, topic: &str) -> Self {
- self.topic = Some(topic.to_owned());
- self
- }
-
- pub fn with_limit(mut self, limit: u64) -> Self {
- self.limit = Some(limit);
- self
- }
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct ForumThreadProjectionQuery {
- pubkey: Option<String>,
- topic: Option<String>,
- limit: Option<u64>,
-}
-
-impl ForumThreadProjectionQuery {
- pub fn new() -> Self {
- Self::default()
- }
-
- pub fn with_pubkey(mut self, pubkey: &str) -> Self {
- self.pubkey = Some(pubkey.to_owned());
- self
- }
-
- pub fn with_topic(mut self, topic: &str) -> Self {
- self.topic = Some(topic.to_owned());
- self
- }
-
- pub fn with_limit(mut self, limit: u64) -> Self {
- self.limit = Some(limit);
- self
- }
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct LabelProjectionQuery {
- target_type: Option<String>,
- target_ref: Option<String>,
- namespace: Option<String>,
- label: Option<String>,
- pubkey: Option<String>,
- limit: Option<u64>,
-}
-
-impl LabelProjectionQuery {
- pub fn new() -> Self {
- Self::default()
- }
-
- pub fn with_target(mut self, target_type: &str, target_ref: &str) -> Self {
- self.target_type = Some(target_type.to_owned());
- self.target_ref = Some(target_ref.to_owned());
- self
- }
-
- pub fn with_namespace(mut self, namespace: &str) -> Self {
- self.namespace = Some(namespace.to_owned());
- self
- }
-
- pub fn with_label(mut self, label: &str) -> Self {
- self.label = Some(label.to_owned());
- self
- }
-
- pub fn with_pubkey(mut self, pubkey: &str) -> Self {
- self.pubkey = Some(pubkey.to_owned());
- self
- }
-
- pub fn with_limit(mut self, limit: u64) -> Self {
- self.limit = Some(limit);
- self
- }
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct ReportProjectionQuery {
- target_type: Option<String>,
- target_ref: Option<String>,
- report_type: Option<String>,
- pubkey: Option<String>,
- limit: Option<u64>,
-}
-
-impl ReportProjectionQuery {
- pub fn new() -> Self {
- Self::default()
- }
-
- pub fn with_target(mut self, target_type: &str, target_ref: &str) -> Self {
- self.target_type = Some(target_type.to_owned());
- self.target_ref = Some(target_ref.to_owned());
- self
- }
-
- pub fn with_report_type(mut self, report_type: &str) -> Self {
- self.report_type = Some(report_type.to_owned());
- self
- }
-
- pub fn with_pubkey(mut self, pubkey: &str) -> Self {
- self.pubkey = Some(pubkey.to_owned());
- self
- }
-
- pub fn with_limit(mut self, limit: u64) -> Self {
- self.limit = Some(limit);
- self
- }
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct SellerProfileQuery {
- pubkey: Option<String>,
- approved: Option<bool>,
- blocked: Option<bool>,
- limit: Option<u64>,
-}
-
-impl SellerProfileQuery {
- pub fn new() -> Self {
- Self::default()
- }
-
- pub fn with_pubkey(mut self, pubkey: &str) -> Self {
- self.pubkey = Some(pubkey.to_owned());
- self
- }
-
- pub fn with_approved(mut self, approved: bool) -> Self {
- self.approved = Some(approved);
- self
- }
-
- pub fn with_blocked(mut self, blocked: bool) -> Self {
- self.blocked = Some(blocked);
- self
- }
-
- pub fn with_limit(mut self, limit: u64) -> Self {
- self.limit = Some(limit);
- self
- }
-}
-
-#[derive(Clone)]
-pub struct SurrealStore {
- db: Surreal<Any>,
-}
-
-impl fmt::Debug for SurrealStore {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter
- .debug_struct("SurrealStore")
- .finish_non_exhaustive()
- }
-}
-
-impl SurrealStore {
- pub async fn connect(config: &SurrealConnectionConfig) -> Result<Self, SurrealStoreError> {
- match config.mode() {
- SurrealConnectionMode::Memory | SurrealConnectionMode::RocksDb { .. } => {
- Self::connect_local(config).await
- }
- SurrealConnectionMode::Http { endpoint }
- | SurrealConnectionMode::WebSocket { endpoint } => {
- Self::connect_remote(config, endpoint).await
- }
- }
- }
-
- pub async fn connect_local(
- config: &SurrealConnectionConfig,
- ) -> Result<Self, SurrealStoreError> {
- match config.mode() {
- SurrealConnectionMode::Memory => Self::connect_memory(config).await,
- SurrealConnectionMode::RocksDb { path } => {
- let db = connect_any(rocksdb_endpoint(path))
- .await
- .map_err(SurrealStoreError::from)?;
- db.use_ns(config.namespace())
- .use_db(config.database())
- .await
- .map_err(SurrealStoreError::from)?;
- Ok(Self { db })
- }
- SurrealConnectionMode::Http { .. } | SurrealConnectionMode::WebSocket { .. } => {
- Err(SurrealStoreError::new(
- "surreal local connection requires memory or rocksdb mode config",
- ))
- }
- }
- }
-
- pub async fn connect_memory(
- config: &SurrealConnectionConfig,
- ) -> Result<Self, SurrealStoreError> {
- if config.mode() != &SurrealConnectionMode::Memory {
- return Err(SurrealStoreError::new(
- "surreal memory connection requires memory mode config",
- ));
- }
- let db = connect_any("memory")
- .await
- .map_err(SurrealStoreError::from)?;
- db.use_ns(config.namespace())
- .use_db(config.database())
- .await
- .map_err(SurrealStoreError::from)?;
- Ok(Self { db })
- }
-
- async fn connect_remote(
- config: &SurrealConnectionConfig,
- endpoint: &str,
- ) -> Result<Self, SurrealStoreError> {
- let credentials = config.root_credentials().ok_or_else(|| {
- SurrealStoreError::new("surreal remote connection requires root credentials")
- })?;
- let root = Root {
- username: credentials.username().to_owned(),
- password: credentials.password().to_owned(),
- };
- let db = connect_any((
- endpoint.to_owned(),
- SurrealClientConfig::new().user(root.clone()),
- ))
- .await
- .map_err(SurrealStoreError::from)?;
- db.signin(root).await.map_err(SurrealStoreError::from)?;
- db.use_ns(config.namespace())
- .use_db(config.database())
- .await
- .map_err(SurrealStoreError::from)?;
- Ok(Self { db })
- }
-
- pub fn database(&self) -> &Surreal<Any> {
- &self.db
- }
-
- pub async fn ping(&self) -> Result<(), SurrealStoreError> {
- self.db
- .query("RETURN true;")
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- Ok(())
- }
-
- pub async fn apply_plan(
- &self,
- plan: &SurrealMigrationPlan,
- ) -> Result<Vec<MigrationApplyOutcome>, SurrealStoreError> {
- let mut outcomes = Vec::with_capacity(plan.migrations().len());
- for migration in plan.migrations() {
- outcomes.push(self.apply_migration(migration).await?);
- }
- Ok(outcomes)
- }
-
- pub async fn apply_migration(
- &self,
- migration: &SurrealMigration,
- ) -> Result<MigrationApplyOutcome, SurrealStoreError> {
- if self.has_migration_table().await?
- && let Some(applied) = self.applied_migration(migration.name()).await?
- {
- if applied.checksum() == migration.checksum() {
- return Ok(MigrationApplyOutcome::AlreadyApplied);
- }
- return Err(SurrealStoreError::new(&format!(
- "surreal migration `{}` checksum changed",
- migration.name()
- )));
- }
- self.db
- .query(migration.surql())
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- self.record_migration(migration).await?;
- Ok(MigrationApplyOutcome::Applied)
- }
-
- pub async fn applied_migrations(&self) -> Result<Vec<AppliedMigration>, SurrealStoreError> {
- if !self.has_migration_table().await? {
- return Ok(Vec::new());
- }
- let mut response = self
- .db
- .query("SELECT VALUE name FROM migration ORDER BY name ASC;")
- .query("SELECT VALUE checksum FROM migration ORDER BY name ASC;")
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- let names: Vec<String> = response.take(0).map_err(SurrealStoreError::from)?;
- let checksums: Vec<String> = response.take(1).map_err(SurrealStoreError::from)?;
- Ok(names
- .into_iter()
- .zip(checksums)
- .map(|(name, checksum)| AppliedMigration { name, checksum })
- .collect())
- }
-
- pub async fn table_info(&self, table: &str) -> Result<String, SurrealStoreError> {
- let table = normalized_identifier(table, "table").map_err(|error| {
- SurrealStoreError::new(&format!("surreal table info target is invalid: {error}"))
- })?;
- let mut response = self
- .db
- .query(format!("INFO FOR TABLE {table};"))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- let info: surrealdb::types::Value = response.take(0).map_err(SurrealStoreError::from)?;
- Ok(format!("{info:?}"))
- }
-
- pub async fn metrics_snapshot(&self) -> Result<SurrealMetricsSnapshot, SurrealStoreError> {
- Ok(SurrealMetricsSnapshot {
- stored_events: self
- .count_query("SELECT VALUE count() FROM nostr_event GROUP ALL;")
- .await?,
- visible_events: self
- .count_query(
- "SELECT VALUE count() FROM nostr_event WHERE deleted = false AND hidden = false GROUP ALL;",
- )
- .await?,
- hidden_events: self
- .count_query("SELECT VALUE count() FROM nostr_event WHERE hidden = true GROUP ALL;")
- .await?,
- deleted_events: self
- .count_query(
- "SELECT VALUE count() FROM nostr_event WHERE deleted = true GROUP ALL;",
- )
- .await?,
- current_listings: self
- .count_query("SELECT VALUE count() FROM listing_current GROUP ALL;")
- .await?,
- active_listings: self
- .count_query(
- "SELECT VALUE count() FROM listing_current WHERE effective_status = 'active' AND hidden = false AND deleted = false GROUP ALL;",
- )
- .await?,
- seller_profiles: self
- .count_query("SELECT VALUE count() FROM seller_profile GROUP ALL;")
- .await?,
- visible_seller_profiles: self
- .count_query(
- "SELECT VALUE count() FROM seller_profile WHERE hidden = false AND deleted = false GROUP ALL;",
- )
- .await?,
- approved_sellers: self
- .count_query(
- "SELECT VALUE count() FROM relay_user WHERE seller_approved = true AND blocked = false GROUP ALL;",
- )
- .await?,
- blocked_pubkeys: self
- .count_query("SELECT VALUE count() FROM relay_user WHERE blocked = true GROUP ALL;")
- .await?,
- })
- }
-
- pub async fn store_raw_event(
- &self,
- stored: &StoredEvent,
- ) -> Result<StoreEventOutcome, SurrealStoreError> {
- if self.raw_event_row(stored.event().id()).await?.is_some() {
- return Ok(StoreEventOutcome::Duplicate);
- }
- let event = stored.event();
- self.db
- .query(
- r#"
-CREATE type::record('nostr_event', $event_id) CONTENT {
- event_id: $event_id,
- pubkey: $pubkey,
- created_at: $created_at,
- kind: $kind,
- tags: $tags,
- content: $content,
- sig: $sig,
- raw_json: $raw_json,
- received_at: $received_at,
- content_len: $content_len,
- tag_count: $tag_count,
- d_tag: $d_tag,
- address_key: $address_key,
- deleted: false,
- hidden: false,
- rejection_reason: NONE
-};
-"#,
- )
- .bind(("event_id", event.id().as_str()))
- .bind(("pubkey", event.unsigned().pubkey().as_str()))
- .bind(("created_at", event.unsigned().created_at().as_u64()))
- .bind(("kind", event.unsigned().kind().as_u32()))
- .bind(("tags", event_tags_json(event)))
- .bind(("content", event.unsigned().content()))
- .bind(("sig", event.sig().as_str()))
- .bind(("raw_json", event_to_value(event).to_string()))
- .bind(("received_at", stored.received_at().as_u64()))
- .bind(("content_len", stored.content_len() as u64))
- .bind(("tag_count", stored.tag_count() as u64))
- .bind(("d_tag", d_tag_value(event)))
- .bind(("address_key", address_key_value(event)?))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- Ok(StoreEventOutcome::Inserted)
- }
-
- pub async fn raw_event_row(
- &self,
- event_id: &EventId,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('nostr_event', $event_id);")
- .bind(("event_id", event_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- async fn count_query(&self, statement: &str) -> Result<u64, SurrealStoreError> {
- let mut response = self
- .db
- .query(statement)
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- let rows: Vec<serde_json::Value> = response.take(0).map_err(SurrealStoreError::from)?;
- rows.into_iter().next().map(count_value).unwrap_or(Ok(0))
- }
-
- pub async fn query_raw_events(
- &self,
- filter: &Filter,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut statement =
- "SELECT * FROM nostr_event WHERE deleted = false AND hidden = false".to_owned();
- if !filter.ids().is_empty() {
- statement.push_str(" AND event_id IN $ids");
- }
- if !filter.authors().is_empty() {
- statement.push_str(" AND pubkey IN $authors");
- }
- if !filter.kinds().is_empty() {
- statement.push_str(" AND kind IN $kinds");
- }
- if filter.since().is_some() {
- statement.push_str(" AND created_at >= $since");
- }
- if filter.until().is_some() {
- statement.push_str(" AND created_at <= $until");
- }
- statement.push_str(" ORDER BY created_at DESC, event_id ASC");
- if filter.limit().is_some() {
- statement.push_str(" LIMIT $limit");
- }
- statement.push(';');
- let mut query = self.db.query(statement);
- if !filter.ids().is_empty() {
- query = query.bind((
- "ids",
- filter
- .ids()
- .iter()
- .map(|id| id.as_str().to_owned())
- .collect::<Vec<_>>(),
- ));
- }
- if !filter.authors().is_empty() {
- query = query.bind((
- "authors",
- filter
- .authors()
- .iter()
- .map(|pubkey| pubkey.as_str().to_owned())
- .collect::<Vec<_>>(),
- ));
- }
- if !filter.kinds().is_empty() {
- query = query.bind((
- "kinds",
- filter
- .kinds()
- .iter()
- .map(|kind| kind.as_u32())
- .collect::<Vec<_>>(),
- ));
- }
- if let Some(since) = filter.since() {
- query = query.bind(("since", since.as_u64()));
- }
- if let Some(until) = filter.until() {
- query = query.bind(("until", until.as_u64()));
- }
- if let Some(limit) = filter.limit() {
- query = query.bind(("limit", limit));
- }
- let mut response = query
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn backup_raw_events(&self) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM nostr_event ORDER BY created_at ASC, event_id ASC;")
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn index_event_tags(&self, event: &Event) -> Result<(), SurrealStoreError> {
- self.db
- .query("DELETE event_tag_index WHERE event_id = $event_id;")
- .bind(("event_id", event.id().as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- for (ordinal, tag) in event.unsigned().tags().iter().enumerate() {
- let Some((name, value)) = tag.indexed_pair() else {
- continue;
- };
- self.db
- .query(
- r#"
-CREATE event_tag_index CONTENT {
- event_id: $event_id,
- kind: $kind,
- pubkey: $pubkey,
- created_at: $created_at,
- tag: $tag,
- value: $value,
- ordinal: $ordinal
-};
-"#,
- )
- .bind(("event_id", event.id().as_str()))
- .bind(("kind", event.unsigned().kind().as_u32()))
- .bind(("pubkey", event.unsigned().pubkey().as_str()))
- .bind(("created_at", event.unsigned().created_at().as_u64()))
- .bind(("tag", name))
- .bind(("value", value))
- .bind(("ordinal", ordinal as u64))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- }
- Ok(())
- }
-
- pub async fn tag_index_rows(
- &self,
- event_id: &EventId,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM event_tag_index WHERE event_id = $event_id ORDER BY ordinal ASC;")
- .bind(("event_id", event_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn query_indexed_tag_event_ids(
- &self,
- filter: &Filter,
- ) -> Result<Vec<String>, SurrealStoreError> {
- if filter.tag_filters().is_empty() {
- return Ok(Vec::new());
- }
- let mut first_order = Vec::new();
- let mut intersection = None::<BTreeSet<String>>;
- for (name, values) in filter.tag_filters() {
- let ids = self
- .query_single_indexed_tag_event_ids(
- name.as_str(),
- &values
- .iter()
- .map(|value| value.as_str().to_owned())
- .collect::<Vec<_>>(),
- filter,
- )
- .await?;
- let ids = unique_in_order(ids);
- if first_order.is_empty() {
- first_order = ids.clone();
- }
- let current = ids.into_iter().collect::<BTreeSet<_>>();
- intersection = Some(match intersection {
- Some(previous) => previous.intersection(¤t).cloned().collect(),
- None => current,
- });
- }
- let intersection = intersection.unwrap_or_default();
- let mut result = first_order
- .into_iter()
- .filter(|event_id| intersection.contains(event_id))
- .collect::<Vec<_>>();
- if let Some(limit) = filter.limit() {
- result.truncate(limit as usize);
- }
- Ok(result)
- }
-
- pub async fn maintain_current_event(
- &self,
- event: &Event,
- ) -> Result<CurrentEventOutcome, SurrealStoreError> {
- let Some(current_key) = current_event_key(event)? else {
- return Ok(CurrentEventOutcome::NotCurrent);
- };
- let existing = self.current_event_row(¤t_key.address_key).await?;
- let outcome = existing
- .as_ref()
- .map(|row| current_event_replacement_outcome(event, row))
- .unwrap_or(CurrentEventOutcome::Inserted);
- if outcome == CurrentEventOutcome::Unchanged {
- return Ok(outcome);
- }
- self.db
- .query(
- r#"
-UPSERT type::record('event_current', $address_key) CONTENT {
- address_key: $address_key,
- kind: $kind,
- pubkey: $pubkey,
- d: $d,
- event_id: $event_id,
- created_at: $created_at,
- tie_break_id: $tie_break_id,
- deleted: false,
- hidden: false,
- updated_at: $updated_at
-};
-"#,
- )
- .bind(("address_key", current_key.address_key))
- .bind(("kind", event.unsigned().kind().as_u32()))
- .bind(("pubkey", event.unsigned().pubkey().as_str()))
- .bind(("d", current_key.d))
- .bind(("event_id", event.id().as_str()))
- .bind(("created_at", event.unsigned().created_at().as_u64()))
- .bind(("tie_break_id", event.id().as_str()))
- .bind(("updated_at", event.unsigned().created_at().as_u64()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- Ok(outcome)
- }
-
- pub async fn current_event_row(
- &self,
- address_key: &str,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('event_current', $address_key);")
- .bind(("address_key", address_key))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn query_current_events(
- &self,
- filter: &Filter,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut statement =
- "SELECT * FROM event_current WHERE deleted = false AND hidden = false".to_owned();
- if !filter.ids().is_empty() {
- statement.push_str(" AND event_id IN $ids");
- }
- if !filter.authors().is_empty() {
- statement.push_str(" AND pubkey IN $authors");
- }
- if !filter.kinds().is_empty() {
- statement.push_str(" AND kind IN $kinds");
- }
- if filter.since().is_some() {
- statement.push_str(" AND created_at >= $since");
- }
- if filter.until().is_some() {
- statement.push_str(" AND created_at <= $until");
- }
- statement.push_str(" ORDER BY created_at DESC, event_id ASC");
- statement.push(';');
- let mut query = self.db.query(statement);
- if !filter.ids().is_empty() {
- query = query.bind((
- "ids",
- filter
- .ids()
- .iter()
- .map(|id| id.as_str().to_owned())
- .collect::<Vec<_>>(),
- ));
- }
- if !filter.authors().is_empty() {
- query = query.bind((
- "authors",
- filter
- .authors()
- .iter()
- .map(|pubkey| pubkey.as_str().to_owned())
- .collect::<Vec<_>>(),
- ));
- }
- if !filter.kinds().is_empty() {
- query = query.bind((
- "kinds",
- filter
- .kinds()
- .iter()
- .map(|kind| kind.as_u32())
- .collect::<Vec<_>>(),
- ));
- }
- if let Some(since) = filter.since() {
- query = query.bind(("since", since.as_u64()));
- }
- if let Some(until) = filter.until() {
- query = query.bind(("until", until.as_u64()));
- }
- let mut response = query
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- let rows: Vec<serde_json::Value> = response.take(0).map_err(SurrealStoreError::from)?;
- let mut events = Vec::new();
- for row in rows {
- let event_id = EventId::new(&string_row_field(&row, "event_id")?)
- .map_err(|source| SurrealStoreError::new(&source))?;
- let Some(raw_row) = self.raw_event_row(&event_id).await? else {
- continue;
- };
- let raw_json = string_row_field(&raw_row, "raw_json")?;
- let raw = RawEventJson::new(&raw_json)
- .map_err(|source| SurrealStoreError::new(&source.to_string()))?;
- let event = parse_event_json(&raw)
- .map_err(|source| SurrealStoreError::new(&source.to_string()))?;
- if filter.matches(&event) {
- events.push(raw_row);
- }
- if filter
- .limit()
- .is_some_and(|limit| events.len() >= limit as usize)
- {
- break;
- }
- }
- Ok(events)
- }
-
- pub async fn apply_deletion_markers(
- &self,
- event: &Event,
- ) -> Result<DeletionMarkerOutcome, SurrealStoreError> {
- let Some(request) =
- parse_deletion_request(event).map_err(|message| SurrealStoreError::new(&message))?
- else {
- return Ok(DeletionMarkerOutcome::NotDeletion);
- };
- for target in request.targets() {
- let (target_type, target_ref) = deletion_target_parts(target);
- let marker_id = format!("{}:{}:{}", event.id().as_str(), target_type, target_ref);
- self.db
- .query(
- r#"
-UPSERT type::record('deletion_marker', $marker_id) CONTENT {
- deletion_event_id: $deletion_event_id,
- target_type: $target_type,
- target_ref: $target_ref,
- author_pubkey: $author_pubkey,
- deletion_created_at: $deletion_created_at
-};
-"#,
- )
- .bind(("marker_id", marker_id))
- .bind(("deletion_event_id", event.id().as_str()))
- .bind(("target_type", target_type))
- .bind(("target_ref", target_ref.as_str()))
- .bind(("author_pubkey", event.unsigned().pubkey().as_str()))
- .bind((
- "deletion_created_at",
- event.unsigned().created_at().as_u64(),
- ))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- if target_type == "event" {
- self.mark_raw_event_deleted(
- &target_ref,
- event.unsigned().pubkey().as_str(),
- event.unsigned().created_at().as_u64(),
- )
- .await?;
- } else {
- self.mark_address_deleted(&target_ref, event.unsigned().pubkey().as_str())
- .await?;
- }
- }
- Ok(DeletionMarkerOutcome::Applied {
- targets: request.targets().len(),
- })
- }
-
- pub async fn deletion_marker_rows(
- &self,
- deletion_event_id: &EventId,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query(
- "SELECT * FROM deletion_marker WHERE deletion_event_id = $deletion_event_id ORDER BY target_type ASC, target_ref ASC;",
- )
- .bind(("deletion_event_id", deletion_event_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn store_listing_revision(
- &self,
- event: &Event,
- projected_at: UnixTimestamp,
- ) -> Result<ListingRevisionOutcome, SurrealStoreError> {
- if !is_listing_event(event) {
- return Ok(ListingRevisionOutcome::NotListing);
- }
- let evaluation = evaluate_listing_projection(event);
- let fields = listing_revision_fields(event, &evaluation)?;
- self.db
- .query(
- r#"
-UPSERT type::record('listing_revision', $event_id) CONTENT {
- revision_key: $revision_key,
- listing_key: $listing_key,
- event_id: $event_id,
- seller_pubkey: $seller_pubkey,
- d: $d,
- created_at: $created_at,
- parsed_ok: $parsed_ok,
- parse_errors: $parse_errors,
- title: $title,
- summary: $summary,
- price_decimal: $price_decimal,
- price_minor: $price_minor,
- currency_raw: $currency_raw,
- currency_norm: $currency_norm,
- unit: $unit,
- status_tag: $status_tag,
- projected_at: $projected_at
-};
-"#,
- )
- .bind(("event_id", event.id().as_str()))
- .bind(("revision_key", fields.revision_key))
- .bind(("listing_key", fields.listing_key))
- .bind(("seller_pubkey", fields.seller_pubkey))
- .bind(("d", fields.d))
- .bind(("created_at", event.unsigned().created_at().as_u64()))
- .bind(("parsed_ok", fields.parsed_ok))
- .bind(("parse_errors", fields.parse_errors))
- .bind(("title", fields.title))
- .bind(("summary", fields.summary))
- .bind(("price_decimal", fields.price_decimal))
- .bind(("price_minor", fields.price_minor))
- .bind(("currency_raw", fields.currency_raw))
- .bind(("currency_norm", fields.currency_norm))
- .bind(("unit", fields.unit))
- .bind(("status_tag", fields.status_tag))
- .bind(("projected_at", projected_at.as_u64()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- Ok(ListingRevisionOutcome::Stored {
- parsed_ok: fields.parsed_ok,
- })
- }
-
- pub async fn listing_revision_row(
- &self,
- event_id: &EventId,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('listing_revision', $event_id);")
- .bind(("event_id", event_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn project_current_listing(
- &self,
- event: &Event,
- projected_at: UnixTimestamp,
- ) -> Result<ListingCurrentOutcome, SurrealStoreError> {
- let evaluation = evaluate_listing_projection(event);
- let ListingProjectionEvaluation::Eligible(projection) = evaluation else {
- return Ok(
- if matches!(evaluation, ListingProjectionEvaluation::NotListing) {
- ListingCurrentOutcome::NotListing
- } else {
- ListingCurrentOutcome::Ineligible
- },
- );
- };
- let fields = listing_current_fields(&projection, event, projected_at)?;
- self.db
- .query(
- r#"
-UPSERT type::record('listing_current', $listing_key) CONTENT {
- listing_key: $listing_key,
- listing_key_hash: $listing_key_hash,
- event_id: $event_id,
- seller_pubkey: $seller_pubkey,
- d: $d,
- created_at: $created_at,
- updated_at: $updated_at,
- published_at: $published_at,
- title: $title,
- summary: $summary,
- content: $content,
- price_decimal: $price_decimal,
- price_minor: $price_minor,
- currency_raw: $currency_raw,
- currency_norm: $currency_norm,
- price_frequency: $price_frequency,
- unit: $unit,
- unit_family: $unit_family,
- location_text: $location_text,
- geohash: $geohash,
- geohash4: $geohash4,
- geohash5: $geohash5,
- geohash6: $geohash6,
- geohash7: $geohash7,
- point: $point,
- status_tag: $status_tag,
- effective_status: $effective_status,
- categories: $categories,
- tags: $tags,
- practices: $practices,
- certifications: $certifications,
- image_urls: $image_urls,
- pickup_available: $pickup_available,
- delivery_available: $delivery_available,
- shipping_available: $shipping_available,
- delivery_only: $delivery_only,
- seller_trust_score: $seller_trust_score,
- hidden: false,
- deleted: false,
- projected_at: $projected_at
-};
-"#,
- )
- .bind(("listing_key", fields.listing_key))
- .bind(("listing_key_hash", fields.listing_key_hash))
- .bind(("event_id", event.id().as_str()))
- .bind(("seller_pubkey", fields.seller_pubkey))
- .bind(("d", fields.d))
- .bind(("created_at", event.unsigned().created_at().as_u64()))
- .bind(("updated_at", event.unsigned().created_at().as_u64()))
- .bind(("published_at", fields.published_at))
- .bind(("title", fields.title))
- .bind(("summary", fields.summary))
- .bind(("content", fields.content))
- .bind(("price_decimal", fields.price_decimal))
- .bind(("price_minor", fields.price_minor))
- .bind(("currency_raw", fields.currency_raw))
- .bind(("currency_norm", fields.currency_norm))
- .bind(("price_frequency", fields.price_frequency))
- .bind(("unit", fields.unit.clone()))
- .bind(("unit_family", fields.unit))
- .bind(("location_text", fields.location_text))
- .bind(("geohash", fields.geohash))
- .bind(("geohash4", fields.geohash4))
- .bind(("geohash5", fields.geohash5))
- .bind(("geohash6", fields.geohash6))
- .bind(("geohash7", fields.geohash7))
- .bind(("point", Option::<Vec<serde_json::Value>>::None))
- .bind(("status_tag", fields.status_tag))
- .bind(("effective_status", fields.effective_status))
- .bind(("categories", fields.categories))
- .bind(("tags", fields.tags))
- .bind(("practices", fields.practices))
- .bind(("certifications", fields.certifications))
- .bind(("image_urls", fields.image_urls))
- .bind(("pickup_available", fields.pickup_available))
- .bind(("delivery_available", fields.delivery_available))
- .bind(("shipping_available", fields.shipping_available))
- .bind(("delivery_only", fields.delivery_only))
- .bind(("seller_trust_score", Option::<i64>::None))
- .bind(("projected_at", projected_at.as_u64()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- Ok(ListingCurrentOutcome::Projected)
- }
-
- pub async fn listing_current_row(
- &self,
- listing_key: &str,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('listing_current', $listing_key);")
- .bind(("listing_key", listing_key))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn query_current_listings(
- &self,
- query: &ListingProjectionQuery,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut statement =
- "SELECT * FROM listing_current WHERE hidden = false AND deleted = false".to_owned();
- if query.effective_status.is_some() {
- statement.push_str(" AND effective_status = $effective_status");
- }
- if query.seller_pubkey.is_some() {
- statement.push_str(" AND seller_pubkey = $seller_pubkey");
- }
- if query.unit.is_some() {
- statement.push_str(" AND unit = $unit");
- }
- if query.currency_norm.is_some() {
- statement.push_str(" AND currency_norm = $currency_norm");
- }
- if query.min_price_minor.is_some() {
- statement.push_str(" AND price_minor >= $min_price_minor");
- }
- if query.max_price_minor.is_some() {
- statement.push_str(" AND price_minor <= $max_price_minor");
- }
- statement.push_str(" ORDER BY updated_at DESC, event_id ASC");
- if query.limit.is_some() {
- statement.push_str(" LIMIT $limit");
- }
- statement.push(';');
- let mut surreal_query = self.db.query(statement);
- if let Some(value) = &query.effective_status {
- surreal_query = surreal_query.bind(("effective_status", value.as_str()));
- }
- if let Some(value) = &query.seller_pubkey {
- surreal_query = surreal_query.bind(("seller_pubkey", value.as_str()));
- }
- if let Some(value) = &query.unit {
- surreal_query = surreal_query.bind(("unit", value.as_str()));
- }
- if let Some(value) = &query.currency_norm {
- surreal_query = surreal_query.bind(("currency_norm", value.as_str()));
- }
- if let Some(value) = query.min_price_minor {
- surreal_query = surreal_query.bind(("min_price_minor", value));
- }
- if let Some(value) = query.max_price_minor {
- surreal_query = surreal_query.bind(("max_price_minor", value));
- }
- if let Some(value) = query.limit {
- surreal_query = surreal_query.bind(("limit", value));
- }
- let mut response = surreal_query
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn project_comment(
- &self,
- event: &Event,
- projected_at: UnixTimestamp,
- ) -> Result<CommentProjectionOutcome, SurrealStoreError> {
- let comment = match parse_comment_event(event) {
- Ok(Some(comment)) => comment,
- Ok(None) => return Ok(CommentProjectionOutcome::NotComment),
- Err(_) => return Ok(CommentProjectionOutcome::Ineligible),
- };
- let fields = comment_projection_fields(&comment, projected_at);
- self.db
- .query(
- r#"
-UPSERT type::record('comment_projection', $event_id) CONTENT {
- comment_id: $comment_id,
- event_id: $event_id,
- pubkey: $pubkey,
- created_at: $created_at,
- content: $content,
- root_target_type: $root_target_type,
- root_ref: $root_ref,
- root_kind: $root_kind,
- root_author: $root_author,
- parent_target_type: $parent_target_type,
- parent_ref: $parent_ref,
- parent_kind: $parent_kind,
- parent_author: $parent_author,
- hidden: false,
- deleted: false,
- projected_at: $projected_at
-};
-"#,
- )
- .bind(("event_id", event.id().as_str()))
- .bind(("comment_id", fields.comment_id))
- .bind(("pubkey", fields.pubkey))
- .bind(("created_at", fields.created_at))
- .bind(("content", fields.content))
- .bind(("root_target_type", fields.root_target_type))
- .bind(("root_ref", fields.root_ref))
- .bind(("root_kind", fields.root_kind))
- .bind(("root_author", fields.root_author))
- .bind(("parent_target_type", fields.parent_target_type))
- .bind(("parent_ref", fields.parent_ref))
- .bind(("parent_kind", fields.parent_kind))
- .bind(("parent_author", fields.parent_author))
- .bind(("projected_at", fields.projected_at))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- Ok(CommentProjectionOutcome::Projected)
- }
-
- pub async fn comment_projection_row(
- &self,
- event_id: &EventId,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('comment_projection', $event_id);")
- .bind(("event_id", event_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn query_comment_projections(
- &self,
- query: &CommentProjectionQuery,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut statement =
- "SELECT * FROM comment_projection WHERE hidden = false AND deleted = false".to_owned();
- if query.root_target_type.is_some() {
- statement.push_str(" AND root_target_type = $root_target_type");
- }
- if query.root_ref.is_some() {
- statement.push_str(" AND root_ref = $root_ref");
- }
- if query.parent_target_type.is_some() {
- statement.push_str(" AND parent_target_type = $parent_target_type");
- }
- if query.parent_ref.is_some() {
- statement.push_str(" AND parent_ref = $parent_ref");
- }
- if query.pubkey.is_some() {
- statement.push_str(" AND pubkey = $pubkey");
- }
- statement.push_str(" ORDER BY created_at ASC, event_id ASC");
- if query.limit.is_some() {
- statement.push_str(" LIMIT $limit");
- }
- statement.push(';');
- let mut surreal_query = self.db.query(statement);
- if let Some(value) = &query.root_target_type {
- surreal_query = surreal_query.bind(("root_target_type", value.as_str()));
- }
- if let Some(value) = &query.root_ref {
- surreal_query = surreal_query.bind(("root_ref", value.as_str()));
- }
- if let Some(value) = &query.parent_target_type {
- surreal_query = surreal_query.bind(("parent_target_type", value.as_str()));
- }
- if let Some(value) = &query.parent_ref {
- surreal_query = surreal_query.bind(("parent_ref", value.as_str()));
- }
- if let Some(value) = &query.pubkey {
- surreal_query = surreal_query.bind(("pubkey", value.as_str()));
- }
- if let Some(value) = query.limit {
- surreal_query = surreal_query.bind(("limit", value));
- }
- let mut response = surreal_query
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn project_reaction(
- &self,
- event: &Event,
- projected_at: UnixTimestamp,
- ) -> Result<ReactionProjectionOutcome, SurrealStoreError> {
- let reaction = match parse_reaction_event(event) {
- Ok(Some(reaction)) => reaction,
- Ok(None) => return Ok(ReactionProjectionOutcome::NotReaction),
- Err(_) => return Ok(ReactionProjectionOutcome::Ineligible),
- };
- let fields = reaction_projection_fields(&reaction, projected_at);
- self.db
- .query(
- r#"
-UPSERT type::record('reaction_projection', $event_id) CONTENT {
- reaction_id: $reaction_id,
- event_id: $event_id,
- pubkey: $pubkey,
- created_at: $created_at,
- content: $content,
- value_type: $value_type,
- value: $value,
- target_event_id: $target_event_id,
- target_pubkey: $target_pubkey,
- target_address: $target_address,
- target_kind: $target_kind,
- hidden: false,
- deleted: false,
- projected_at: $projected_at
-};
-"#,
- )
- .bind(("event_id", event.id().as_str()))
- .bind(("reaction_id", fields.reaction_id))
- .bind(("pubkey", fields.pubkey))
- .bind(("created_at", fields.created_at))
- .bind(("content", fields.content))
- .bind(("value_type", fields.value_type))
- .bind(("value", fields.value))
- .bind(("target_event_id", fields.target_event_id.as_str()))
- .bind(("target_pubkey", fields.target_pubkey))
- .bind(("target_address", fields.target_address))
- .bind(("target_kind", fields.target_kind.as_deref()))
- .bind(("projected_at", fields.projected_at))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- self.recompute_reaction_count(
- &fields.target_event_id,
- fields.target_kind,
- projected_at.as_u64(),
- )
- .await?;
- Ok(ReactionProjectionOutcome::Projected)
- }
-
- pub async fn reaction_projection_row(
- &self,
- event_id: &EventId,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('reaction_projection', $event_id);")
- .bind(("event_id", event_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn reaction_count_row(
- &self,
- target_event_id: &EventId,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('reaction_count', $target_event_id);")
- .bind(("target_event_id", target_event_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn project_long_form(
- &self,
- event: &Event,
- projected_at: UnixTimestamp,
- ) -> Result<LongFormProjectionOutcome, SurrealStoreError> {
- let article = match parse_long_form_event(event) {
- Ok(Some(article)) => article,
- Ok(None) => return Ok(LongFormProjectionOutcome::NotLongForm),
- Err(_) => return Ok(LongFormProjectionOutcome::Ineligible),
- };
- if article.long_form_kind() != LongFormKind::Published {
- return Ok(LongFormProjectionOutcome::Ineligible);
- }
- let fields = long_form_projection_fields(&article, projected_at);
- if self
- .long_form_current_row(&fields.long_form_key)
- .await?
- .as_ref()
- .is_some_and(|row| !long_form_current_should_replace(&article, row))
- {
- return Ok(LongFormProjectionOutcome::Ineligible);
- }
- self.db
- .query(
- r#"
-UPSERT type::record('long_form_current', $long_form_key) CONTENT {
- long_form_key: $long_form_key,
- event_id: $event_id,
- author_pubkey: $author_pubkey,
- d: $d,
- created_at: $created_at,
- updated_at: $updated_at,
- published_at: $published_at,
- title: $title,
- image: $image,
- summary: $summary,
- content: $content,
- tags: $tags,
- referenced_events: $referenced_events,
- referenced_addresses: $referenced_addresses,
- referenced_pubkeys: $referenced_pubkeys,
- hidden: false,
- deleted: false,
- projected_at: $projected_at
-};
-UPSERT type::record('search_doc', $long_form_key) CONTENT {
- doc_key: $long_form_key,
- event_id: $event_id,
- current_event_id: $event_id,
- doc_type: "long_form",
- kind: $kind,
- pubkey: $author_pubkey,
- address_key: $long_form_key,
- title: $search_title,
- summary: $summary,
- body: $content,
- category_text: $category_text,
- location_text: NONE,
- tags: $tags,
- categories: [],
- created_at: $created_at,
- updated_at: $updated_at,
- visible: true,
- status: "published",
- seller_trust_score: NONE
-};
-"#,
- )
- .bind(("long_form_key", fields.long_form_key.as_str()))
- .bind(("event_id", fields.event_id.as_str()))
- .bind(("author_pubkey", fields.author_pubkey.as_str()))
- .bind(("d", fields.d.as_str()))
- .bind(("created_at", fields.created_at))
- .bind(("updated_at", fields.updated_at))
- .bind(("published_at", fields.published_at))
- .bind(("title", fields.title.as_deref()))
- .bind(("image", fields.image.as_deref()))
- .bind(("summary", fields.summary.as_deref()))
- .bind(("content", fields.content.as_str()))
- .bind(("tags", fields.tags.clone()))
- .bind(("referenced_events", fields.referenced_events.clone()))
- .bind(("referenced_addresses", fields.referenced_addresses.clone()))
- .bind(("referenced_pubkeys", fields.referenced_pubkeys.clone()))
- .bind(("projected_at", fields.projected_at))
- .bind(("kind", fields.kind))
- .bind(("search_title", fields.search_title.as_str()))
- .bind(("category_text", fields.tags.join(" ")))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- self.replace_long_form_topic_rows(&fields).await?;
- Ok(LongFormProjectionOutcome::Projected)
- }
-
- pub async fn long_form_current_row(
- &self,
- long_form_key: &str,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('long_form_current', $long_form_key);")
- .bind(("long_form_key", long_form_key))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn long_form_topic_rows(
- &self,
- long_form_key: &str,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query(
- "SELECT * FROM long_form_topic WHERE long_form_key = $long_form_key ORDER BY topic ASC;",
- )
- .bind(("long_form_key", long_form_key))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn query_long_form_projections(
- &self,
- query: &LongFormProjectionQuery,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let topic_keys = match query.topic.as_deref() {
- Some(topic) => {
- let normalized = topic.trim().to_ascii_lowercase();
- let mut response = self
- .db
- .query(
- "SELECT VALUE long_form_key FROM long_form_topic WHERE topic = $topic AND hidden = false AND deleted = false ORDER BY updated_at DESC, event_id ASC;",
- )
- .bind(("topic", normalized.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- let keys: Vec<String> = response.take(0).map_err(SurrealStoreError::from)?;
- if keys.is_empty() {
- return Ok(Vec::new());
- }
- Some(keys)
- }
- None => None,
- };
- let mut statement =
- "SELECT * FROM long_form_current WHERE hidden = false AND deleted = false".to_owned();
- if query.author_pubkey.is_some() {
- statement.push_str(" AND author_pubkey = $author_pubkey");
- }
- if topic_keys.is_some() {
- statement.push_str(" AND long_form_key IN $topic_keys");
- }
- statement.push_str(" ORDER BY updated_at DESC, event_id ASC");
- if query.limit.is_some() {
- statement.push_str(" LIMIT $limit");
- }
- statement.push(';');
- let mut surreal_query = self.db.query(statement);
- if let Some(value) = &query.author_pubkey {
- surreal_query = surreal_query.bind(("author_pubkey", value.as_str()));
- }
- if let Some(keys) = topic_keys {
- surreal_query = surreal_query.bind(("topic_keys", keys));
- }
- if let Some(value) = query.limit {
- surreal_query = surreal_query.bind(("limit", value));
- }
- let mut response = surreal_query
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn project_forum_thread(
- &self,
- event: &Event,
- projected_at: UnixTimestamp,
- ) -> Result<ForumThreadProjectionOutcome, SurrealStoreError> {
- let thread = match parse_forum_thread_event(event) {
- Ok(Some(thread)) => thread,
- Ok(None) => return Ok(ForumThreadProjectionOutcome::NotForumThread),
- Err(_) => return Ok(ForumThreadProjectionOutcome::Ineligible),
- };
- let fields = forum_thread_projection_fields(&thread, projected_at);
- self.db
- .query(
- r#"
-UPSERT type::record('forum_thread_projection', $thread_id) CONTENT {
- thread_id: $thread_id,
- event_id: $event_id,
- pubkey: $pubkey,
- created_at: $created_at,
- updated_at: $updated_at,
- title: $title,
- content: $content,
- tags: $tags,
- referenced_events: $referenced_events,
- referenced_pubkeys: $referenced_pubkeys,
- hidden: false,
- deleted: false,
- projected_at: $projected_at
-};
-UPSERT type::record('search_doc', $thread_id) CONTENT {
- doc_key: $thread_id,
- event_id: $event_id,
- current_event_id: $event_id,
- doc_type: "forum_thread",
- kind: $kind,
- pubkey: $pubkey,
- address_key: NONE,
- title: $search_title,
- summary: $title,
- body: $content,
- category_text: $category_text,
- location_text: NONE,
- tags: $tags,
- categories: [],
- created_at: $created_at,
- updated_at: $updated_at,
- visible: true,
- status: "open",
- seller_trust_score: NONE
-};
-"#,
- )
- .bind(("thread_id", fields.thread_id.as_str()))
- .bind(("event_id", fields.event_id.as_str()))
- .bind(("pubkey", fields.pubkey.as_str()))
- .bind(("created_at", fields.created_at))
- .bind(("updated_at", fields.updated_at))
- .bind(("title", fields.title.as_deref()))
- .bind(("content", fields.content.as_str()))
- .bind(("tags", fields.tags.clone()))
- .bind(("referenced_events", fields.referenced_events.clone()))
- .bind(("referenced_pubkeys", fields.referenced_pubkeys.clone()))
- .bind(("projected_at", fields.projected_at))
- .bind(("kind", fields.kind))
- .bind(("search_title", fields.search_title.as_str()))
- .bind(("category_text", fields.tags.join(" ")))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- self.replace_forum_thread_topic_rows(&fields).await?;
- Ok(ForumThreadProjectionOutcome::Projected)
- }
-
- pub async fn forum_thread_row(
- &self,
- thread_id: &EventId,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('forum_thread_projection', $thread_id);")
- .bind(("thread_id", thread_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn forum_thread_topic_rows(
- &self,
- thread_id: &EventId,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query(
- "SELECT * FROM forum_thread_topic WHERE thread_id = $thread_id ORDER BY topic ASC;",
- )
- .bind(("thread_id", thread_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn query_forum_threads(
- &self,
- query: &ForumThreadProjectionQuery,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let topic_thread_ids = match query.topic.as_deref() {
- Some(topic) => {
- let normalized = topic.trim().to_ascii_lowercase();
- let mut response = self
- .db
- .query(
- "SELECT VALUE thread_id FROM forum_thread_topic WHERE topic = $topic AND hidden = false AND deleted = false ORDER BY updated_at DESC, event_id ASC;",
- )
- .bind(("topic", normalized.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- let ids: Vec<String> = response.take(0).map_err(SurrealStoreError::from)?;
- if ids.is_empty() {
- return Ok(Vec::new());
- }
- Some(ids)
- }
- None => None,
- };
- let mut statement =
- "SELECT * FROM forum_thread_projection WHERE hidden = false AND deleted = false"
- .to_owned();
- if query.pubkey.is_some() {
- statement.push_str(" AND pubkey = $pubkey");
- }
- if topic_thread_ids.is_some() {
- statement.push_str(" AND thread_id IN $topic_thread_ids");
- }
- statement.push_str(" ORDER BY updated_at DESC, event_id ASC");
- if query.limit.is_some() {
- statement.push_str(" LIMIT $limit");
- }
- statement.push(';');
- let mut surreal_query = self.db.query(statement);
- if let Some(value) = &query.pubkey {
- surreal_query = surreal_query.bind(("pubkey", value.as_str()));
- }
- if let Some(ids) = topic_thread_ids {
- surreal_query = surreal_query.bind(("topic_thread_ids", ids));
- }
- if let Some(value) = query.limit {
- surreal_query = surreal_query.bind(("limit", value));
- }
- let mut response = surreal_query
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn project_label(
- &self,
- event: &Event,
- projected_at: UnixTimestamp,
- ) -> Result<LabelProjectionOutcome, SurrealStoreError> {
- let label = match parse_label_event(event) {
- Ok(Some(label)) => label,
- Ok(None) => return Ok(LabelProjectionOutcome::NotLabel),
- Err(_) => return Ok(LabelProjectionOutcome::Ineligible),
- };
- let fields = label_projection_fields(&label, projected_at);
- self.db
- .query("DELETE label_projection WHERE event_id = $event_id;")
- .bind(("event_id", label.event_id().as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- for field in fields {
- self.db
- .query(
- r#"
-UPSERT type::record('label_projection', $label_id) CONTENT {
- label_id: $label_id,
- event_id: $event_id,
- pubkey: $pubkey,
- created_at: $created_at,
- content: $content,
- namespace: $namespace,
- label: $label,
- target_type: $target_type,
- target_ref: $target_ref,
- hidden: false,
- deleted: false,
- projected_at: $projected_at
-};
-"#,
- )
- .bind(("label_id", field.label_id.as_str()))
- .bind(("event_id", field.event_id.as_str()))
- .bind(("pubkey", field.pubkey.as_str()))
- .bind(("created_at", field.created_at))
- .bind(("content", field.content.as_str()))
- .bind(("namespace", field.namespace.as_str()))
- .bind(("label", field.label.as_str()))
- .bind(("target_type", field.target_type.as_str()))
- .bind(("target_ref", field.target_ref.as_str()))
- .bind(("projected_at", field.projected_at))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- }
- Ok(LabelProjectionOutcome::Projected)
- }
-
- pub async fn label_projection_rows(
- &self,
- event_id: &EventId,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query(
- "SELECT * FROM label_projection WHERE event_id = $event_id ORDER BY target_type ASC, target_ref ASC, namespace ASC, label ASC, label_id ASC;",
- )
- .bind(("event_id", event_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn query_label_projections(
- &self,
- query: &LabelProjectionQuery,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut statement =
- "SELECT * FROM label_projection WHERE hidden = false AND deleted = false".to_owned();
- if query.target_type.is_some() {
- statement.push_str(" AND target_type = $target_type");
- }
- if query.target_ref.is_some() {
- statement.push_str(" AND target_ref = $target_ref");
- }
- if query.namespace.is_some() {
- statement.push_str(" AND namespace = $namespace");
- }
- if query.label.is_some() {
- statement.push_str(" AND label = $label");
- }
- if query.pubkey.is_some() {
- statement.push_str(" AND pubkey = $pubkey");
- }
- statement.push_str(" ORDER BY created_at DESC, event_id ASC, label_id ASC");
- if query.limit.is_some() {
- statement.push_str(" LIMIT $limit");
- }
- statement.push(';');
- let mut surreal_query = self.db.query(statement);
- if let Some(value) = &query.target_type {
- surreal_query = surreal_query.bind(("target_type", value.as_str()));
- }
- if let Some(value) = &query.target_ref {
- surreal_query = surreal_query.bind(("target_ref", value.as_str()));
- }
- if let Some(value) = &query.namespace {
- surreal_query = surreal_query.bind(("namespace", value.as_str()));
- }
- if let Some(value) = &query.label {
- surreal_query = surreal_query.bind(("label", value.as_str()));
- }
- if let Some(value) = &query.pubkey {
- surreal_query = surreal_query.bind(("pubkey", value.as_str()));
- }
- if let Some(value) = query.limit {
- surreal_query = surreal_query.bind(("limit", value));
- }
- let mut response = surreal_query
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn project_report(
- &self,
- event: &Event,
- projected_at: UnixTimestamp,
- ) -> Result<ReportProjectionOutcome, SurrealStoreError> {
- let report = match parse_report_event(event) {
- Ok(Some(report)) => report,
- Ok(None) => return Ok(ReportProjectionOutcome::NotReport),
- Err(_) => return Ok(ReportProjectionOutcome::Ineligible),
- };
- let fields = report_projection_fields(&report, projected_at);
- self.db
- .query("DELETE report_projection WHERE event_id = $event_id;")
- .bind(("event_id", report.event_id().as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- for field in fields {
- self.db
- .query(
- r#"
-UPSERT type::record('report_projection', $report_id) CONTENT {
- report_id: $report_id,
- event_id: $event_id,
- pubkey: $pubkey,
- created_at: $created_at,
- content: $content,
- target_type: $target_type,
- target_ref: $target_ref,
- report_type: $report_type,
- reported_pubkeys: $reported_pubkeys,
- server_urls: $server_urls,
- hidden: false,
- deleted: false,
- projected_at: $projected_at
-};
-"#,
- )
- .bind(("report_id", field.report_id.as_str()))
- .bind(("event_id", field.event_id.as_str()))
- .bind(("pubkey", field.pubkey.as_str()))
- .bind(("created_at", field.created_at))
- .bind(("content", field.content.as_str()))
- .bind(("target_type", field.target_type.as_str()))
- .bind(("target_ref", field.target_ref.as_str()))
- .bind(("report_type", field.report_type.as_str()))
- .bind(("reported_pubkeys", field.reported_pubkeys))
- .bind(("server_urls", field.server_urls))
- .bind(("projected_at", field.projected_at))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- }
- Ok(ReportProjectionOutcome::Projected)
- }
-
- pub async fn report_projection_rows(
- &self,
- event_id: &EventId,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query(
- "SELECT * FROM report_projection WHERE event_id = $event_id ORDER BY target_type ASC, target_ref ASC, report_type ASC, report_id ASC;",
- )
- .bind(("event_id", event_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn query_report_projections(
- &self,
- query: &ReportProjectionQuery,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut statement =
- "SELECT * FROM report_projection WHERE hidden = false AND deleted = false".to_owned();
- if query.target_type.is_some() {
- statement.push_str(" AND target_type = $target_type");
- }
- if query.target_ref.is_some() {
- statement.push_str(" AND target_ref = $target_ref");
- }
- if query.report_type.is_some() {
- statement.push_str(" AND report_type = $report_type");
- }
- if query.pubkey.is_some() {
- statement.push_str(" AND pubkey = $pubkey");
- }
- statement.push_str(" ORDER BY created_at DESC, event_id ASC, report_id ASC");
- if query.limit.is_some() {
- statement.push_str(" LIMIT $limit");
- }
- statement.push(';');
- let mut surreal_query = self.db.query(statement);
- if let Some(value) = &query.target_type {
- surreal_query = surreal_query.bind(("target_type", value.as_str()));
- }
- if let Some(value) = &query.target_ref {
- surreal_query = surreal_query.bind(("target_ref", value.as_str()));
- }
- if let Some(value) = &query.report_type {
- surreal_query = surreal_query.bind(("report_type", value.as_str()));
- }
- if let Some(value) = &query.pubkey {
- surreal_query = surreal_query.bind(("pubkey", value.as_str()));
- }
- if let Some(value) = query.limit {
- surreal_query = surreal_query.bind(("limit", value));
- }
- let mut response = surreal_query
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn project_seller_profile(
- &self,
- event: &Event,
- projected_at: UnixTimestamp,
- ) -> Result<SellerProfileProjectionOutcome, SurrealStoreError> {
- let profile = match parse_seller_profile_event(event) {
- Ok(Some(profile)) => profile,
- Ok(None) => return Ok(SellerProfileProjectionOutcome::NotProfile),
- Err(_) => return Ok(SellerProfileProjectionOutcome::Ineligible),
- };
- let pubkey = profile.pubkey().as_str().to_owned();
- let existing = self.seller_profile_row(&pubkey).await?;
- if existing
- .as_ref()
- .is_some_and(|row| !seller_profile_should_replace(&profile, row))
- {
- return Ok(SellerProfileProjectionOutcome::Ineligible);
- }
- let policy = self
- .seller_profile_policy_state(&pubkey, existing.as_ref())
- .await?;
- let fields = seller_profile_fields(
- &profile,
- policy.seller_approved,
- policy.blocked,
- projected_at,
- );
- self.db
- .query(
- r#"
-UPSERT type::record('seller_profile', $pubkey) CONTENT {
- pubkey: $pubkey,
- event_id: $event_id,
- created_at: $created_at,
- updated_at: $updated_at,
- name: $name,
- display_name: $display_name,
- about: $about,
- picture: $picture,
- website: $website,
- nip05: $nip05,
- lud16: $lud16,
- regions: $regions,
- categories: $categories,
- trust_markers: $trust_markers,
- seller_approved: $seller_approved,
- blocked: $blocked,
- hidden: false,
- deleted: false,
- projected_at: $projected_at
-};
-"#,
- )
- .bind(("pubkey", fields.pubkey.as_str()))
- .bind(("event_id", fields.event_id.as_str()))
- .bind(("created_at", fields.created_at))
- .bind(("updated_at", fields.updated_at))
- .bind(("name", fields.name.as_deref()))
- .bind(("display_name", fields.display_name.as_deref()))
- .bind(("about", fields.about.as_deref()))
- .bind(("picture", fields.picture.as_deref()))
- .bind(("website", fields.website.as_deref()))
- .bind(("nip05", fields.nip05.as_deref()))
- .bind(("lud16", fields.lud16.as_deref()))
- .bind(("regions", fields.regions))
- .bind(("categories", fields.categories))
- .bind(("trust_markers", fields.trust_markers))
- .bind(("seller_approved", fields.seller_approved))
- .bind(("blocked", fields.blocked))
- .bind(("projected_at", fields.projected_at))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- Ok(SellerProfileProjectionOutcome::Projected)
- }
-
- pub async fn seller_profile_row(
- &self,
- pubkey: &str,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let pubkey = required_policy_text(pubkey, "seller profile pubkey")?;
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('seller_profile', $pubkey);")
- .bind(("pubkey", pubkey.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn query_seller_profiles(
- &self,
- query: &SellerProfileQuery,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut statement =
- "SELECT * FROM seller_profile WHERE hidden = false AND deleted = false".to_owned();
- if query.pubkey.is_some() {
- statement.push_str(" AND pubkey = $pubkey");
- }
- if query.approved.is_some() {
- statement.push_str(" AND seller_approved = $approved");
- }
- if query.blocked.is_some() {
- statement.push_str(" AND blocked = $blocked");
- }
- statement.push_str(" ORDER BY updated_at DESC, pubkey ASC");
- if query.limit.is_some() {
- statement.push_str(" LIMIT $limit");
- }
- statement.push(';');
- let mut surreal_query = self.db.query(statement);
- if let Some(value) = &query.pubkey {
- surreal_query = surreal_query.bind(("pubkey", value.as_str()));
- }
- if let Some(value) = query.approved {
- surreal_query = surreal_query.bind(("approved", value));
- }
- if let Some(value) = query.blocked {
- surreal_query = surreal_query.bind(("blocked", value));
- }
- if let Some(value) = query.limit {
- surreal_query = surreal_query.bind(("limit", value));
- }
- let mut response = surreal_query
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn project_listing_helpers(
- &self,
- event: &Event,
- ) -> Result<ListingHelperOutcome, SurrealStoreError> {
- let evaluation = evaluate_listing_projection(event);
- let ListingProjectionEvaluation::Eligible(projection) = evaluation else {
- return Ok(
- if matches!(evaluation, ListingProjectionEvaluation::NotListing) {
- ListingHelperOutcome::NotListing
- } else {
- ListingHelperOutcome::Ineligible
- },
- );
- };
- let listing_key = projection.identity().address().key().to_string();
- let effective_status = projection
- .status()
- .effective_status()
- .canonical()
- .to_owned();
- let updated_at = event.unsigned().created_at().as_u64();
- let event_id = event.id().as_str();
- let helper_context = ListingHelperProjectionContext {
- listing_key: &listing_key,
- effective_status: &effective_status,
- updated_at,
- event_id,
- };
- self.replace_listing_helper_rows(
- "listing_category",
- "category",
- projection.taxonomy().categories(),
- &helper_context,
- )
- .await?;
- let fulfillment = projection
- .fulfillment()
- .methods()
- .iter()
- .map(|method| method.canonical().to_owned())
- .collect::<Vec<_>>();
- self.replace_listing_helper_rows(
- "listing_fulfillment",
- "mode",
- &fulfillment,
- &helper_context,
- )
- .await?;
- self.replace_listing_helper_rows(
- "listing_tag",
- "tag_value",
- projection.taxonomy().topics(),
- &helper_context,
- )
- .await?;
- self.replace_listing_helper_rows(
- "listing_practice",
- "practice",
- projection.taxonomy().practices(),
- &helper_context,
- )
- .await?;
- self.replace_listing_helper_rows(
- "listing_certification",
- "certification",
- projection.taxonomy().certifications(),
- &helper_context,
- )
- .await?;
- Ok(ListingHelperOutcome::Projected)
- }
-
- pub async fn listing_category_rows(
- &self,
- listing_key: &str,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- self.listing_helper_rows("listing_category", "category", listing_key)
- .await
- }
-
- pub async fn listing_fulfillment_rows(
- &self,
- listing_key: &str,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- self.listing_helper_rows("listing_fulfillment", "mode", listing_key)
- .await
- }
-
- pub async fn listing_topic_rows(
- &self,
- listing_key: &str,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- self.listing_helper_rows("listing_tag", "tag_value", listing_key)
- .await
- }
-
- pub async fn listing_practice_rows(
- &self,
- listing_key: &str,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- self.listing_helper_rows("listing_practice", "practice", listing_key)
- .await
- }
-
- pub async fn listing_certification_rows(
- &self,
- listing_key: &str,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- self.listing_helper_rows("listing_certification", "certification", listing_key)
- .await
- }
-
- pub async fn index_listing_search_document(
- &self,
- event: &Event,
- ) -> Result<SearchDocumentOutcome, SurrealStoreError> {
- let evaluation = evaluate_listing_projection(event);
- let ListingProjectionEvaluation::Eligible(projection) = evaluation else {
- return Ok(
- if matches!(evaluation, ListingProjectionEvaluation::NotListing) {
- SearchDocumentOutcome::NotListing
- } else {
- SearchDocumentOutcome::Ineligible
- },
- );
- };
- let fields = search_document_fields(&projection, event);
- self.db
- .query(
- r#"
-UPSERT type::record('search_doc', $doc_key) CONTENT {
- doc_key: $doc_key,
- event_id: $event_id,
- current_event_id: $current_event_id,
- doc_type: "listing",
- kind: $kind,
- pubkey: $pubkey,
- address_key: $address_key,
- title: $title,
- summary: $summary,
- body: $body,
- category_text: $category_text,
- location_text: $location_text,
- tags: $tags,
- categories: $categories,
- created_at: $created_at,
- updated_at: $updated_at,
- visible: $visible,
- status: $status,
- seller_trust_score: $seller_trust_score
-};
-"#,
- )
- .bind(("doc_key", fields.doc_key))
- .bind(("event_id", event.id().as_str()))
- .bind(("current_event_id", event.id().as_str()))
- .bind(("kind", event.unsigned().kind().as_u32()))
- .bind(("pubkey", event.unsigned().pubkey().as_str()))
- .bind(("address_key", fields.address_key))
- .bind(("title", fields.title))
- .bind(("summary", fields.summary))
- .bind(("body", fields.body))
- .bind(("category_text", fields.category_text))
- .bind(("location_text", fields.location_text))
- .bind(("tags", fields.tags))
- .bind(("categories", fields.categories))
- .bind(("created_at", event.unsigned().created_at().as_u64()))
- .bind(("updated_at", event.unsigned().created_at().as_u64()))
- .bind(("visible", fields.visible))
- .bind(("status", fields.status))
- .bind(("seller_trust_score", Option::<i64>::None))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- Ok(SearchDocumentOutcome::Indexed)
- }
-
- pub async fn search_document_row(
- &self,
- doc_key: &str,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('search_doc', $doc_key);")
- .bind(("doc_key", doc_key))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn query_search_documents(
- &self,
- query: &SearchDocumentQuery,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut statement = if query.text.is_some() {
- "SELECT *, search::score(0) AS score FROM search_doc WHERE true".to_owned()
- } else {
- "SELECT * FROM search_doc WHERE true".to_owned()
- };
- if query.text.is_some() {
- statement.push_str(" AND (title @0@ $text OR summary @1@ $text OR body @2@ $text)");
- }
- if query.doc_type.is_some() {
- statement.push_str(" AND doc_type = $doc_type");
- }
- if query.kind.is_some() {
- statement.push_str(" AND kind = $kind");
- }
- if query.pubkey.is_some() {
- statement.push_str(" AND pubkey = $pubkey");
- }
- if query.visible.is_some() {
- statement.push_str(" AND visible = $visible");
- }
- if query.status.is_some() {
- statement.push_str(" AND status = $status");
- }
- if query.text.is_some() {
- statement.push_str(" ORDER BY score DESC, updated_at DESC, event_id ASC");
- } else {
- statement.push_str(" ORDER BY updated_at DESC, event_id ASC");
- }
- if query.limit.is_some() {
- statement.push_str(" LIMIT $limit");
- }
- statement.push(';');
- let mut surreal_query = self.db.query(statement);
- if let Some(value) = &query.text {
- surreal_query = surreal_query.bind(("text", value.as_str()));
- }
- if let Some(value) = &query.doc_type {
- surreal_query = surreal_query.bind(("doc_type", value.as_str()));
- }
- if let Some(value) = query.kind {
- surreal_query = surreal_query.bind(("kind", value));
- }
- if let Some(value) = &query.pubkey {
- surreal_query = surreal_query.bind(("pubkey", value.as_str()));
- }
- if let Some(value) = query.visible {
- surreal_query = surreal_query.bind(("visible", value));
- }
- if let Some(value) = &query.status {
- surreal_query = surreal_query.bind(("status", value.as_str()));
- }
- if let Some(value) = query.limit {
- surreal_query = surreal_query.bind(("limit", value));
- }
- let mut response = surreal_query
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn hide_event(
- &self,
- event_id: &EventId,
- reason: &str,
- source: &str,
- admin_pubkey: &str,
- created_at: UnixTimestamp,
- ) -> Result<HiddenEventOutcome, SurrealStoreError> {
- if self.raw_event_row(event_id).await?.is_none() {
- return Ok(HiddenEventOutcome::NotFound);
- }
- let reason = required_policy_text(reason, "hidden event reason")?;
- let source = required_policy_text(source, "hidden event source")?;
- let admin_pubkey = required_policy_text(admin_pubkey, "admin pubkey")?;
- self.db
- .query(
- r#"
-UPSERT type::record('hidden_event', $event_id) CONTENT {
- event_id: $event_id,
- reason: $reason,
- source: $source,
- created_at: $created_at,
- admin_pubkey: $admin_pubkey
-};
-CREATE moderation_action CONTENT {
- action_id: $action_id,
- admin_pubkey: $admin_pubkey,
- target_type: "event",
- target_ref: $event_id,
- action: "hide",
- reason: $reason,
- created_at: $created_at
-};
-UPDATE nostr_event SET hidden = true WHERE event_id = $event_id;
-UPDATE event_current SET hidden = true WHERE event_id = $event_id;
-UPDATE listing_current SET hidden = true WHERE event_id = $event_id;
-UPDATE comment_projection SET hidden = true WHERE event_id = $event_id;
-UPDATE reaction_projection SET hidden = true WHERE event_id = $event_id;
-UPDATE long_form_current SET hidden = true WHERE event_id = $event_id;
-UPDATE long_form_topic SET hidden = true WHERE event_id = $event_id;
-UPDATE forum_thread_projection SET hidden = true WHERE event_id = $event_id;
-UPDATE forum_thread_topic SET hidden = true WHERE event_id = $event_id;
-UPDATE label_projection SET hidden = true WHERE event_id = $event_id;
-UPDATE report_projection SET hidden = true WHERE event_id = $event_id;
-UPDATE seller_profile SET hidden = true WHERE event_id = $event_id;
-UPDATE search_doc SET visible = false WHERE event_id = $event_id OR current_event_id = $event_id;
-"#,
- )
- .bind(("event_id", event_id.as_str()))
- .bind(("reason", reason.as_str()))
- .bind(("source", source.as_str()))
- .bind(("created_at", created_at.as_u64()))
- .bind(("admin_pubkey", admin_pubkey.as_str()))
- .bind((
- "action_id",
- moderation_action_id("hide", event_id.as_str(), admin_pubkey.as_str(), created_at),
- ))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- self.refresh_reaction_count_for_event(event_id.as_str(), created_at.as_u64())
- .await?;
- Ok(HiddenEventOutcome::Hidden)
- }
-
- pub async fn unhide_event(
- &self,
- event_id: &EventId,
- reason: &str,
- admin_pubkey: &str,
- created_at: UnixTimestamp,
- ) -> Result<HiddenEventOutcome, SurrealStoreError> {
- if self.raw_event_row(event_id).await?.is_none() {
- return Ok(HiddenEventOutcome::NotFound);
- }
- let reason = required_policy_text(reason, "hidden event reason")?;
- let admin_pubkey = required_policy_text(admin_pubkey, "admin pubkey")?;
- self.db
- .query(
- r#"
-DELETE type::record('hidden_event', $event_id);
-CREATE moderation_action CONTENT {
- action_id: $action_id,
- admin_pubkey: $admin_pubkey,
- target_type: "event",
- target_ref: $event_id,
- action: "unhide",
- reason: $reason,
- created_at: $created_at
-};
-UPDATE nostr_event SET hidden = false WHERE event_id = $event_id;
-UPDATE event_current SET hidden = false WHERE event_id = $event_id;
-UPDATE listing_current SET hidden = false WHERE event_id = $event_id;
-UPDATE comment_projection SET hidden = false WHERE event_id = $event_id;
-UPDATE reaction_projection SET hidden = false WHERE event_id = $event_id;
-UPDATE long_form_current SET hidden = false WHERE event_id = $event_id;
-UPDATE long_form_topic SET hidden = false WHERE event_id = $event_id;
-UPDATE forum_thread_projection SET hidden = false WHERE event_id = $event_id;
-UPDATE forum_thread_topic SET hidden = false WHERE event_id = $event_id;
-UPDATE label_projection SET hidden = false WHERE event_id = $event_id;
-UPDATE report_projection SET hidden = false WHERE event_id = $event_id;
-UPDATE seller_profile SET hidden = false WHERE event_id = $event_id;
-UPDATE search_doc SET visible = true WHERE (event_id = $event_id OR current_event_id = $event_id) AND (status = "active" OR status = "published" OR status = "open");
-UPDATE search_doc SET visible = false WHERE (event_id = $event_id OR current_event_id = $event_id) AND status != "active" AND status != "published" AND status != "open";
-"#,
- )
- .bind(("event_id", event_id.as_str()))
- .bind(("reason", reason.as_str()))
- .bind(("created_at", created_at.as_u64()))
- .bind(("admin_pubkey", admin_pubkey.as_str()))
- .bind((
- "action_id",
- moderation_action_id(
- "unhide",
- event_id.as_str(),
- admin_pubkey.as_str(),
- created_at,
- ),
- ))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- self.refresh_reaction_count_for_event(event_id.as_str(), created_at.as_u64())
- .await?;
- Ok(HiddenEventOutcome::Unhidden)
- }
-
- pub async fn hidden_event_row(
- &self,
- event_id: &EventId,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('hidden_event', $event_id);")
- .bind(("event_id", event_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn moderation_action_rows(
- &self,
- target_type: &str,
- target_ref: &str,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let mut response = self
- .db
- .query(
- "SELECT * FROM moderation_action WHERE target_type = $target_type AND target_ref = $target_ref ORDER BY created_at ASC, action_id ASC;",
- )
- .bind(("target_type", target_type))
- .bind(("target_ref", target_ref))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn relay_user_row(
- &self,
- pubkey: &str,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let pubkey = required_policy_text(pubkey, "relay user pubkey")?;
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('relay_user', $pubkey);")
- .bind(("pubkey", pubkey.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn set_seller_approved(
- &self,
- pubkey: &str,
- approved: bool,
- updated_at: UnixTimestamp,
- ) -> Result<(), SurrealStoreError> {
- let pubkey = required_policy_text(pubkey, "relay user pubkey")?;
- let existing = self.relay_user_row(&pubkey).await?;
- let blocked = existing
- .as_ref()
- .and_then(|row| row.get("blocked"))
- .and_then(serde_json::Value::as_bool)
- .unwrap_or(false);
- let created_at = existing
- .as_ref()
- .and_then(|row| row.get("created_at"))
- .and_then(serde_json::Value::as_u64)
- .unwrap_or_else(|| updated_at.as_u64());
- self.upsert_relay_user(&pubkey, "seller", approved, blocked, created_at, updated_at)
- .await
- }
-
- pub async fn set_pubkey_blocked(
- &self,
- pubkey: &str,
- blocked: bool,
- updated_at: UnixTimestamp,
- ) -> Result<(), SurrealStoreError> {
- let pubkey = required_policy_text(pubkey, "relay user pubkey")?;
- let existing = self.relay_user_row(&pubkey).await?;
- let approved = existing
- .as_ref()
- .and_then(|row| row.get("seller_approved"))
- .and_then(serde_json::Value::as_bool)
- .unwrap_or(false);
- let role = existing
- .as_ref()
- .and_then(|row| row.get("role"))
- .and_then(serde_json::Value::as_str)
- .unwrap_or("seller")
- .to_owned();
- let created_at = existing
- .as_ref()
- .and_then(|row| row.get("created_at"))
- .and_then(serde_json::Value::as_u64)
- .unwrap_or_else(|| updated_at.as_u64());
- self.upsert_relay_user(&pubkey, &role, approved, blocked, created_at, updated_at)
- .await
- }
-
- pub async fn check_durable_rate_limit(
- &self,
- key: &str,
- limit: u64,
- window_seconds: u64,
- cost: u64,
- now: UnixTimestamp,
- ) -> Result<DurableRateLimitDecision, SurrealStoreError> {
- let key = required_policy_text(key, "rate limit key")?;
- if limit == 0 {
- return Err(SurrealStoreError::new(
- "rate limit must be greater than zero",
- ));
- }
- if window_seconds == 0 {
- return Err(SurrealStoreError::new(
- "rate limit window must be greater than zero seconds",
- ));
- }
- if cost == 0 {
- return Err(SurrealStoreError::new(
- "rate limit cost must be greater than zero",
- ));
- }
- if cost > limit {
- return Err(SurrealStoreError::new(&format!(
- "rate limit cost {cost} exceeds limit {limit}"
- )));
- }
- let row = self.rate_limit_state_row(&key).await?;
- let created_at = row
- .as_ref()
- .and_then(|row| row.get("created_at"))
- .and_then(serde_json::Value::as_u64)
- .unwrap_or_else(|| now.as_u64());
- let mut state = row
- .as_ref()
- .map(rate_limit_window_state_from_row)
- .transpose()?
- .unwrap_or_else(|| DurableRateLimitWindowState::new(now));
- state.reset_if_elapsed(now, window_seconds);
- let reset_at = state.reset_at(window_seconds);
- if state.used.saturating_add(cost) > limit {
- return Ok(DurableRateLimitDecision::Rejected {
- retry_after_seconds: reset_at.as_u64().saturating_sub(now.as_u64()),
- reset_at,
- });
- }
- state.used += cost;
- self.upsert_rate_limit_state(&key, state, reset_at, created_at, now)
- .await?;
- Ok(DurableRateLimitDecision::Accepted {
- remaining: limit - state.used,
- reset_at,
- })
- }
-
- pub async fn rate_limit_state_row(
- &self,
- key: &str,
- ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
- let key = required_policy_text(key, "rate limit key")?;
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('rate_limit_state', $key);")
- .bind(("key", key.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- pub async fn prune_expired_rate_limit_state(
- &self,
- now: UnixTimestamp,
- ) -> Result<u64, SurrealStoreError> {
- let mut response = self
- .db
- .query(
- "DELETE rate_limit_state WHERE expires_at != NONE AND expires_at <= $now RETURN BEFORE;",
- )
- .bind(("now", now.as_u64()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- let rows = response
- .take::<Vec<serde_json::Value>>(0)
- .map_err(SurrealStoreError::from)?;
- Ok(rows.len() as u64)
- }
-
- async fn replace_listing_helper_rows(
- &self,
- table: &str,
- field: &str,
- values: &[String],
- context: &ListingHelperProjectionContext<'_>,
- ) -> Result<(), SurrealStoreError> {
- let delete_query = format!("DELETE {table} WHERE listing_key = $listing_key;");
- self.db
- .query(delete_query)
- .bind(("listing_key", context.listing_key))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- let create_query = format!(
- "CREATE {table} CONTENT {{ listing_key: $listing_key, {field}: $value, effective_status: $effective_status, updated_at: $updated_at, event_id: $event_id }};"
- );
- for value in values {
- self.db
- .query(create_query.as_str())
- .bind(("listing_key", context.listing_key))
- .bind(("value", value.as_str()))
- .bind(("effective_status", context.effective_status))
- .bind(("updated_at", context.updated_at))
- .bind(("event_id", context.event_id))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- }
- Ok(())
- }
-
- async fn listing_helper_rows(
- &self,
- table: &str,
- field: &str,
- listing_key: &str,
- ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
- let query =
- format!("SELECT * FROM {table} WHERE listing_key = $listing_key ORDER BY {field} ASC;");
- let mut response = self
- .db
- .query(query)
- .bind(("listing_key", listing_key))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- async fn replace_long_form_topic_rows(
- &self,
- fields: &LongFormProjectionFields,
- ) -> Result<(), SurrealStoreError> {
- self.db
- .query("DELETE long_form_topic WHERE long_form_key = $long_form_key;")
- .bind(("long_form_key", fields.long_form_key.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- for topic in &fields.tags {
- self.db
- .query(
- r#"
-CREATE long_form_topic CONTENT {
- long_form_key: $long_form_key,
- topic: $topic,
- updated_at: $updated_at,
- event_id: $event_id,
- hidden: false,
- deleted: false
-};
-"#,
- )
- .bind(("long_form_key", fields.long_form_key.as_str()))
- .bind(("topic", topic.as_str()))
- .bind(("updated_at", fields.updated_at))
- .bind(("event_id", fields.event_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- }
- Ok(())
- }
-
- async fn replace_forum_thread_topic_rows(
- &self,
- fields: &ForumThreadProjectionFields,
- ) -> Result<(), SurrealStoreError> {
- self.db
- .query("DELETE forum_thread_topic WHERE thread_id = $thread_id;")
- .bind(("thread_id", fields.thread_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- for topic in &fields.tags {
- self.db
- .query(
- r#"
-CREATE forum_thread_topic CONTENT {
- thread_id: $thread_id,
- topic: $topic,
- updated_at: $updated_at,
- event_id: $event_id,
- hidden: false,
- deleted: false
-};
-"#,
- )
- .bind(("thread_id", fields.thread_id.as_str()))
- .bind(("topic", topic.as_str()))
- .bind(("updated_at", fields.updated_at))
- .bind(("event_id", fields.event_id.as_str()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- }
- Ok(())
- }
-
- async fn upsert_rate_limit_state(
- &self,
- key: &str,
- state: DurableRateLimitWindowState,
- expires_at: UnixTimestamp,
- created_at: u64,
- updated_at: UnixTimestamp,
- ) -> Result<(), SurrealStoreError> {
- self.db
- .query(
- r#"
-UPSERT type::record('rate_limit_state', $key) CONTENT {
- key: $key,
- state: $state,
- expires_at: $expires_at,
- created_at: $created_at,
- updated_at: $updated_at
-};
-"#,
- )
- .bind(("key", key))
- .bind(("state", state.to_json_string()))
- .bind(("expires_at", expires_at.as_u64()))
- .bind(("created_at", created_at))
- .bind(("updated_at", updated_at.as_u64()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- Ok(())
- }
-
- async fn upsert_relay_user(
- &self,
- pubkey: &str,
- role: &str,
- seller_approved: bool,
- blocked: bool,
- created_at: u64,
- updated_at: UnixTimestamp,
- ) -> Result<(), SurrealStoreError> {
- self.db
- .query(
- r#"
-UPSERT type::record('relay_user', $pubkey) CONTENT {
- pubkey: $pubkey,
- role: $role,
- seller_approved: $seller_approved,
- blocked: $blocked,
- created_at: $created_at,
- updated_at: $updated_at
-};
-UPDATE seller_profile SET seller_approved = $seller_approved, blocked = $blocked WHERE pubkey = $pubkey;
-"#,
- )
- .bind(("pubkey", pubkey))
- .bind(("role", role))
- .bind(("seller_approved", seller_approved))
- .bind(("blocked", blocked))
- .bind(("created_at", created_at))
- .bind(("updated_at", updated_at.as_u64()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- Ok(())
- }
-
- async fn seller_profile_policy_state(
- &self,
- pubkey: &str,
- existing_profile: Option<&serde_json::Value>,
- ) -> Result<SellerProfilePolicyState, SurrealStoreError> {
- let relay_user = self.relay_user_row(pubkey).await?;
- Ok(SellerProfilePolicyState {
- seller_approved: relay_user
- .as_ref()
- .and_then(|row| row.get("seller_approved"))
- .and_then(serde_json::Value::as_bool)
- .or_else(|| {
- existing_profile
- .and_then(|row| row.get("seller_approved"))
- .and_then(serde_json::Value::as_bool)
- })
- .unwrap_or(false),
- blocked: relay_user
- .as_ref()
- .and_then(|row| row.get("blocked"))
- .and_then(serde_json::Value::as_bool)
- .or_else(|| {
- existing_profile
- .and_then(|row| row.get("blocked"))
- .and_then(serde_json::Value::as_bool)
- })
- .unwrap_or(false),
- })
- }
-
- async fn refresh_reaction_count_for_event(
- &self,
- event_id: &str,
- updated_at: u64,
- ) -> Result<(), SurrealStoreError> {
- let mut response = self
- .db
- .query("SELECT * FROM ONLY type::record('reaction_projection', $event_id);")
- .bind(("event_id", event_id))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- let row: Option<serde_json::Value> = response.take(0).map_err(SurrealStoreError::from)?;
- let Some(row) = row else {
- return Ok(());
- };
- let target_event_id = string_row_field(&row, "target_event_id")?;
- let target_kind = optional_string_row_field(&row, "target_kind")?;
- self.recompute_reaction_count(&target_event_id, target_kind, updated_at)
- .await
- }
-
- async fn recompute_reaction_count(
- &self,
- target_event_id: &str,
- target_kind: Option<String>,
- updated_at: u64,
- ) -> Result<(), SurrealStoreError> {
- let mut response = self
- .db
- .query(
- "SELECT value_type FROM reaction_projection WHERE target_event_id = $target_event_id AND hidden = false AND deleted = false;",
- )
- .bind(("target_event_id", target_event_id))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- let rows: Vec<serde_json::Value> = response.take(0).map_err(SurrealStoreError::from)?;
- let mut like_count = 0_i64;
- let mut dislike_count = 0_i64;
- let mut emoji_count = 0_i64;
- let mut text_count = 0_i64;
- for row in rows {
- match string_row_field(&row, "value_type")?.as_str() {
- "like" => like_count += 1,
- "dislike" => dislike_count += 1,
- "emoji" => emoji_count += 1,
- "text" => text_count += 1,
- _ => {}
- }
- }
- let total_count = like_count + dislike_count + emoji_count + text_count;
- self.db
- .query(
- r#"
-UPSERT type::record('reaction_count', $target_event_id) CONTENT {
- target_event_id: $target_event_id,
- target_kind: $target_kind,
- like_count: $like_count,
- dislike_count: $dislike_count,
- emoji_count: $emoji_count,
- text_count: $text_count,
- total_count: $total_count,
- updated_at: $updated_at
-};
-"#,
- )
- .bind(("target_event_id", target_event_id))
- .bind(("target_kind", target_kind.as_deref()))
- .bind(("like_count", like_count))
- .bind(("dislike_count", dislike_count))
- .bind(("emoji_count", emoji_count))
- .bind(("text_count", text_count))
- .bind(("total_count", total_count))
- .bind(("updated_at", updated_at))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- Ok(())
- }
-
- async fn query_single_indexed_tag_event_ids(
- &self,
- tag: &str,
- values: &[String],
- filter: &Filter,
- ) -> Result<Vec<String>, SurrealStoreError> {
- let mut statement =
- "SELECT VALUE event_id FROM event_tag_index WHERE tag = $tag AND value IN $values"
- .to_owned();
- if !filter.authors().is_empty() {
- statement.push_str(" AND pubkey IN $authors");
- }
- if !filter.kinds().is_empty() {
- statement.push_str(" AND kind IN $kinds");
- }
- if filter.since().is_some() {
- statement.push_str(" AND created_at >= $since");
- }
- if filter.until().is_some() {
- statement.push_str(" AND created_at <= $until");
- }
- statement.push_str(" ORDER BY created_at DESC, event_id ASC;");
- let mut query = self
- .db
- .query(statement)
- .bind(("tag", tag))
- .bind(("values", values.to_vec()));
- if !filter.authors().is_empty() {
- query = query.bind((
- "authors",
- filter
- .authors()
- .iter()
- .map(|pubkey| pubkey.as_str().to_owned())
- .collect::<Vec<_>>(),
- ));
- }
- if !filter.kinds().is_empty() {
- query = query.bind((
- "kinds",
- filter
- .kinds()
- .iter()
- .map(|kind| kind.as_u32())
- .collect::<Vec<_>>(),
- ));
- }
- if let Some(since) = filter.since() {
- query = query.bind(("since", since.as_u64()));
- }
- if let Some(until) = filter.until() {
- query = query.bind(("until", until.as_u64()));
- }
- let mut response = query
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- response.take(0).map_err(SurrealStoreError::from)
- }
-
- async fn applied_migration(
- &self,
- name: &str,
- ) -> Result<Option<AppliedMigration>, SurrealStoreError> {
- Ok(self
- .applied_migrations()
- .await?
- .into_iter()
- .find(|migration| migration.name() == name))
- }
-
- async fn has_migration_table(&self) -> Result<bool, SurrealStoreError> {
- let mut response = self
- .db
- .query("INFO FOR DB;")
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- let info: Option<surrealdb::types::Value> =
- response.take(0).map_err(SurrealStoreError::from)?;
- Ok(info
- .map(|value| format!("{value:?}").contains("migration"))
- .unwrap_or(false))
- }
-
- async fn mark_raw_event_deleted(
- &self,
- event_id: &str,
- author_pubkey: &str,
- deleted_at: u64,
- ) -> Result<(), SurrealStoreError> {
- self.db
- .query(
- r#"
-UPDATE nostr_event SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey;
-UPDATE comment_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey;
-UPDATE reaction_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey;
-UPDATE long_form_current SET deleted = true WHERE event_id = $event_id AND author_pubkey = $author_pubkey;
-UPDATE long_form_topic SET deleted = true WHERE event_id = $event_id;
-UPDATE forum_thread_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey;
-UPDATE forum_thread_topic SET deleted = true WHERE event_id = $event_id;
-UPDATE label_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey;
-UPDATE report_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey;
-UPDATE seller_profile SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey;
-UPDATE search_doc SET visible = false WHERE event_id = $event_id OR current_event_id = $event_id;
-"#,
- )
- .bind(("event_id", event_id))
- .bind(("author_pubkey", author_pubkey))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- self.refresh_reaction_count_for_event(event_id, deleted_at)
- .await?;
- Ok(())
- }
-
- async fn mark_address_deleted(
- &self,
- address_key: &str,
- author_pubkey: &str,
- ) -> Result<(), SurrealStoreError> {
- self.db
- .query(
- "UPDATE nostr_event SET deleted = true WHERE address_key = $address_key AND pubkey = $author_pubkey;",
- )
- .query(
- "UPDATE event_current SET deleted = true WHERE address_key = $address_key AND pubkey = $author_pubkey;",
- )
- .query(
- "UPDATE listing_current SET deleted = true WHERE listing_key = $address_key AND seller_pubkey = $author_pubkey;",
- )
- .query(
- "UPDATE long_form_current SET deleted = true WHERE long_form_key = $address_key AND author_pubkey = $author_pubkey;",
- )
- .query("UPDATE long_form_topic SET deleted = true WHERE long_form_key = $address_key;")
- .query("UPDATE search_doc SET visible = false WHERE address_key = $address_key;")
- .bind(("address_key", address_key))
- .bind(("author_pubkey", author_pubkey))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- Ok(())
- }
-
- async fn record_migration(
- &self,
- migration: &SurrealMigration,
- ) -> Result<(), SurrealStoreError> {
- self.db
- .query(
- "CREATE migration CONTENT { name: $name, checksum: $checksum, applied_at: time::now() };",
- )
- .bind(("name", migration.name()))
- .bind(("checksum", migration.checksum()))
- .await
- .map_err(SurrealStoreError::from)?
- .check()
- .map_err(SurrealStoreError::from)?;
- Ok(())
- }
-}
-
-fn event_tags_json(event: &Event) -> Vec<serde_json::Value> {
- event
- .unsigned()
- .tags()
- .iter()
- .map(|tag| {
- serde_json::Value::Array(
- tag.values()
- .iter()
- .map(|value| serde_json::Value::String(value.clone()))
- .collect(),
- )
- })
- .collect()
-}
-
-fn count_value(value: serde_json::Value) -> Result<u64, SurrealStoreError> {
- if let Some(count) = value.as_u64() {
- return Ok(count);
- }
- if let Some(count) = value.get("count").and_then(serde_json::Value::as_u64) {
- return Ok(count);
- }
- Err(SurrealStoreError::new(
- "surreal count query returned a non-numeric count",
- ))
-}
-
-fn required_policy_text(value: &str, field: &str) -> Result<String, SurrealStoreError> {
- let value = value.trim();
- if value.is_empty() {
- return Err(SurrealStoreError::new(&format!(
- "{field} must not be empty"
- )));
- }
- Ok(value.to_owned())
-}
-
-fn string_row_field(row: &serde_json::Value, field: &str) -> Result<String, SurrealStoreError> {
- row.get(field)
- .and_then(serde_json::Value::as_str)
- .map(str::to_owned)
- .ok_or_else(|| SurrealStoreError::new(&format!("{field} row field must be a string")))
-}
-
-fn optional_string_row_field(
- row: &serde_json::Value,
- field: &str,
-) -> Result<Option<String>, SurrealStoreError> {
- match row.get(field) {
- Some(value) if value.is_null() => Ok(None),
- Some(value) => value
- .as_str()
- .map(|value| Some(value.to_owned()))
- .ok_or_else(|| SurrealStoreError::new(&format!("{field} row field must be a string"))),
- None => Ok(None),
- }
-}
-
-fn moderation_action_id(
- action: &str,
- target_ref: &str,
- admin_pubkey: &str,
- created_at: UnixTimestamp,
-) -> String {
- checksum(&format!(
- "{action}:{target_ref}:{admin_pubkey}:{}",
- created_at.as_u64()
- ))
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-struct DurableRateLimitWindowState {
- started_at: UnixTimestamp,
- used: u64,
-}
-
-impl DurableRateLimitWindowState {
- fn new(started_at: UnixTimestamp) -> Self {
- Self {
- started_at,
- used: 0,
- }
- }
-
- fn reset_at(self, window_seconds: u64) -> UnixTimestamp {
- UnixTimestamp::new(self.started_at.as_u64().saturating_add(window_seconds))
- }
-
- fn reset_if_elapsed(&mut self, now: UnixTimestamp, window_seconds: u64) {
- if now >= self.reset_at(window_seconds) || now < self.started_at {
- self.started_at = now;
- self.used = 0;
- }
- }
-
- fn to_json_string(self) -> String {
- serde_json::json!({
- "started_at": self.started_at.as_u64(),
- "used": self.used
- })
- .to_string()
- }
-}
-
-fn rate_limit_window_state_from_row(
- row: &serde_json::Value,
-) -> Result<DurableRateLimitWindowState, SurrealStoreError> {
- let raw = row
- .get("state")
- .and_then(serde_json::Value::as_str)
- .ok_or_else(|| SurrealStoreError::new("rate limit state is invalid"))?;
- let value = serde_json::from_str::<serde_json::Value>(raw)
- .map_err(|_| SurrealStoreError::new("rate limit state is invalid"))?;
- let started_at = value
- .get("started_at")
- .and_then(serde_json::Value::as_u64)
- .ok_or_else(|| SurrealStoreError::new("rate limit state is invalid"))?;
- let used = value
- .get("used")
- .and_then(serde_json::Value::as_u64)
- .ok_or_else(|| SurrealStoreError::new("rate limit state is invalid"))?;
- Ok(DurableRateLimitWindowState {
- started_at: UnixTimestamp::new(started_at),
- used,
- })
-}
-
-fn d_tag_value(event: &Event) -> Option<String> {
- event
- .unsigned()
- .tags()
- .iter()
- .find_map(|tag| tag.indexed_pair())
- .and_then(|(name, value)| (name == "d").then(|| value.to_owned()))
-}
-
-fn address_key_value(event: &Event) -> Result<Option<String>, SurrealStoreError> {
- AddressCoordinate::from_event(event)
- .map(|address| address.map(|address| address.key().to_string()))
- .map_err(|message| SurrealStoreError::new(&message))
-}
-
-struct CurrentEventKey {
- address_key: String,
- d: Option<String>,
-}
-
-fn current_event_key(event: &Event) -> Result<Option<CurrentEventKey>, SurrealStoreError> {
- let kind = event.unsigned().kind();
- if kind.is_addressable() {
- let coordinate = AddressCoordinate::from_event(event)
- .map_err(|message| SurrealStoreError::new(&message))?;
- return Ok(coordinate.map(|coordinate| CurrentEventKey {
- address_key: coordinate.key().to_string(),
- d: Some(coordinate.d().as_str().to_owned()),
- }));
- }
- if kind.is_replaceable() {
- return Ok(Some(CurrentEventKey {
- address_key: format!("{}:{}", kind.as_u32(), event.unsigned().pubkey().as_str()),
- d: None,
- }));
- }
- Ok(None)
-}
-
-fn current_event_replacement_outcome(
- event: &Event,
- row: &serde_json::Value,
-) -> CurrentEventOutcome {
- let incoming_created_at = event.unsigned().created_at().as_u64();
- let existing_created_at = row["created_at"].as_u64().unwrap_or_default();
- let existing_event_id = row["event_id"].as_str().unwrap_or_default();
- if incoming_created_at > existing_created_at
- || (incoming_created_at == existing_created_at && event.id().as_str() > existing_event_id)
- {
- CurrentEventOutcome::Replaced
- } else {
- CurrentEventOutcome::Unchanged
- }
-}
-
-fn deletion_target_parts(target: &DeletionTarget) -> (&'static str, String) {
- match target {
- DeletionTarget::Event(event_id) => ("event", event_id.as_str().to_owned()),
- DeletionTarget::Address(address) => ("address", address.key().to_string()),
- }
-}
-
-struct ListingRevisionFields {
- revision_key: String,
- listing_key: String,
- seller_pubkey: String,
- d: String,
- parsed_ok: bool,
- parse_errors: Vec<String>,
- title: Option<String>,
- summary: Option<String>,
- price_decimal: Option<String>,
- price_minor: Option<i64>,
- currency_raw: Option<String>,
- currency_norm: Option<String>,
- unit: Option<String>,
- status_tag: Option<String>,
-}
-
-struct CommentProjectionFields {
- comment_id: String,
- pubkey: String,
- created_at: u64,
- content: String,
- root_target_type: String,
- root_ref: String,
- root_kind: String,
- root_author: Option<String>,
- parent_target_type: String,
- parent_ref: String,
- parent_kind: String,
- parent_author: Option<String>,
- projected_at: u64,
-}
-
-struct ReactionProjectionFields {
- reaction_id: String,
- pubkey: String,
- created_at: u64,
- content: String,
- value_type: String,
- value: String,
- target_event_id: String,
- target_pubkey: Option<String>,
- target_address: Option<String>,
- target_kind: Option<String>,
- projected_at: u64,
-}
-
-struct LongFormProjectionFields {
- long_form_key: String,
- event_id: String,
- author_pubkey: String,
- d: String,
- created_at: u64,
- updated_at: u64,
- published_at: Option<u64>,
- title: Option<String>,
- image: Option<String>,
- summary: Option<String>,
- content: String,
- tags: Vec<String>,
- referenced_events: Vec<String>,
- referenced_addresses: Vec<String>,
- referenced_pubkeys: Vec<String>,
- projected_at: u64,
- kind: u32,
- search_title: String,
-}
-
-struct ForumThreadProjectionFields {
- thread_id: String,
- event_id: String,
- pubkey: String,
- created_at: u64,
- updated_at: u64,
- title: Option<String>,
- content: String,
- tags: Vec<String>,
- referenced_events: Vec<String>,
- referenced_pubkeys: Vec<String>,
- projected_at: u64,
- kind: u32,
- search_title: String,
-}
-
-struct LabelProjectionFields {
- label_id: String,
- event_id: String,
- pubkey: String,
- created_at: u64,
- content: String,
- namespace: String,
- label: String,
- target_type: String,
- target_ref: String,
- projected_at: u64,
-}
-
-struct ReportProjectionFields {
- report_id: String,
- event_id: String,
- pubkey: String,
- created_at: u64,
- content: String,
- target_type: String,
- target_ref: String,
- report_type: String,
- reported_pubkeys: Vec<String>,
- server_urls: Vec<String>,
- projected_at: u64,
-}
-
-struct SellerProfilePolicyState {
- seller_approved: bool,
- blocked: bool,
-}
-
-struct SellerProfileFields {
- pubkey: String,
- event_id: String,
- created_at: u64,
- updated_at: u64,
- name: Option<String>,
- display_name: Option<String>,
- about: Option<String>,
- picture: Option<String>,
- website: Option<String>,
- nip05: Option<String>,
- lud16: Option<String>,
- regions: Vec<String>,
- categories: Vec<String>,
- trust_markers: Vec<String>,
- seller_approved: bool,
- blocked: bool,
- projected_at: u64,
-}
-
-struct ListingCurrentFields {
- listing_key: String,
- listing_key_hash: String,
- seller_pubkey: String,
- d: String,
- published_at: Option<u64>,
- title: String,
- summary: Option<String>,
- content: String,
- price_decimal: String,
- price_minor: i64,
- currency_raw: String,
- currency_norm: String,
- price_frequency: Option<String>,
- unit: String,
- location_text: Option<String>,
- geohash: Option<String>,
- geohash4: Option<String>,
- geohash5: Option<String>,
- geohash6: Option<String>,
- geohash7: Option<String>,
- status_tag: Option<String>,
- effective_status: String,
- categories: Vec<String>,
- tags: Vec<String>,
- practices: Vec<String>,
- certifications: Vec<String>,
- image_urls: Vec<String>,
- pickup_available: bool,
- delivery_available: bool,
- shipping_available: bool,
- delivery_only: bool,
-}
-
-struct ListingHelperProjectionContext<'a> {
- listing_key: &'a str,
- effective_status: &'a str,
- updated_at: u64,
- event_id: &'a str,
-}
-
-fn comment_projection_fields(
- comment: &CommentEvent,
- projected_at: UnixTimestamp,
-) -> CommentProjectionFields {
- CommentProjectionFields {
- comment_id: comment.event_id().as_str().to_owned(),
- pubkey: comment.pubkey().as_str().to_owned(),
- created_at: comment.created_at().as_u64(),
- content: comment.content().to_owned(),
- root_target_type: comment.root().target().target_type().to_owned(),
- root_ref: comment.root().target().target_ref(),
- root_kind: comment.root().kind().to_owned(),
- root_author: comment
- .root()
- .author()
- .map(|pubkey| pubkey.as_str().to_owned()),
- parent_target_type: comment.parent().target().target_type().to_owned(),
- parent_ref: comment.parent().target().target_ref(),
- parent_kind: comment.parent().kind().to_owned(),
- parent_author: comment
- .parent()
- .author()
- .map(|pubkey| pubkey.as_str().to_owned()),
- projected_at: projected_at.as_u64(),
- }
-}
-
-fn reaction_projection_fields(
- reaction: &ReactionEvent,
- projected_at: UnixTimestamp,
-) -> ReactionProjectionFields {
- ReactionProjectionFields {
- reaction_id: reaction.event_id().as_str().to_owned(),
- pubkey: reaction.pubkey().as_str().to_owned(),
- created_at: reaction.created_at().as_u64(),
- content: reaction.content().to_owned(),
- value_type: reaction.value().canonical().to_owned(),
- value: reaction_value_string(reaction.value()),
- target_event_id: reaction.target_event_id().as_str().to_owned(),
- target_pubkey: reaction
- .target_pubkey()
- .map(|pubkey| pubkey.as_str().to_owned()),
- target_address: reaction
- .target_address()
- .map(|address| address.key().to_string()),
- target_kind: reaction.target_kind().map(str::to_owned),
- projected_at: projected_at.as_u64(),
- }
-}
-
-fn reaction_value_string(value: &ReactionValue) -> String {
- match value {
- ReactionValue::Like => "like".to_owned(),
- ReactionValue::Dislike => "dislike".to_owned(),
- ReactionValue::Emoji(value) | ReactionValue::Text(value) => value.clone(),
- }
-}
-
-fn long_form_projection_fields(
- article: &LongFormEvent,
- projected_at: UnixTimestamp,
-) -> LongFormProjectionFields {
- let d = article.d().as_str().to_owned();
- LongFormProjectionFields {
- long_form_key: article.address().key().to_string(),
- event_id: article.event_id().as_str().to_owned(),
- author_pubkey: article.pubkey().as_str().to_owned(),
- d: d.clone(),
- created_at: article.created_at().as_u64(),
- updated_at: article.created_at().as_u64(),
- published_at: article.published_at(),
- title: article.title().map(str::to_owned),
- image: article.image().map(str::to_owned),
- summary: article.summary().map(str::to_owned),
- content: article.content().to_owned(),
- tags: article.topics().to_vec(),
- referenced_events: article
- .referenced_events()
- .iter()
- .map(|event_id| event_id.as_str().to_owned())
- .collect(),
- referenced_addresses: article
- .referenced_addresses()
- .iter()
- .map(|address| address.key().to_string())
- .collect(),
- referenced_pubkeys: article
- .referenced_pubkeys()
- .iter()
- .map(|pubkey| pubkey.as_str().to_owned())
- .collect(),
- projected_at: projected_at.as_u64(),
- kind: article.address().kind().as_u32(),
- search_title: article.title().unwrap_or(&d).to_owned(),
- }
-}
-
-fn long_form_current_should_replace(article: &LongFormEvent, row: &serde_json::Value) -> bool {
- let incoming_created_at = article.created_at().as_u64();
- let existing_created_at = row["updated_at"].as_u64().unwrap_or_default();
- let existing_event_id = row["event_id"].as_str().unwrap_or_default();
- incoming_created_at > existing_created_at
- || (incoming_created_at == existing_created_at
- && article.event_id().as_str() > existing_event_id)
-}
-
-fn forum_thread_projection_fields(
- thread: &ForumThreadEvent,
- projected_at: UnixTimestamp,
-) -> ForumThreadProjectionFields {
- ForumThreadProjectionFields {
- thread_id: thread.event_id().as_str().to_owned(),
- event_id: thread.event_id().as_str().to_owned(),
- pubkey: thread.pubkey().as_str().to_owned(),
- created_at: thread.created_at().as_u64(),
- updated_at: thread.created_at().as_u64(),
- title: thread.title().map(str::to_owned),
- content: thread.content().to_owned(),
- tags: thread.topics().to_vec(),
- referenced_events: thread
- .referenced_events()
- .iter()
- .map(|event_id| event_id.as_str().to_owned())
- .collect(),
- referenced_pubkeys: thread
- .referenced_pubkeys()
- .iter()
- .map(|pubkey| pubkey.as_str().to_owned())
- .collect(),
- projected_at: projected_at.as_u64(),
- kind: 11,
- search_title: thread
- .title()
- .map(str::to_owned)
- .unwrap_or_else(|| fallback_thread_title(thread)),
- }
-}
-
-fn fallback_thread_title(thread: &ForumThreadEvent) -> String {
- let fallback = thread.content().chars().take(80).collect::<String>();
- if fallback.is_empty() {
- return thread.event_id().as_str().to_owned();
- }
- fallback
-}
-
-fn label_projection_fields(
- label: &LabelEvent,
- projected_at: UnixTimestamp,
-) -> Vec<LabelProjectionFields> {
- let mut pairs = BTreeSet::new();
- for target in label.targets() {
- let target_type = target.target_type().to_owned();
- let target_ref = target.target_ref();
- for value in label.labels() {
- pairs.insert((
- target_type.clone(),
- target_ref.clone(),
- value.namespace().to_owned(),
- value.value().to_owned(),
- ));
- }
- }
- pairs
- .into_iter()
- .map(|(target_type, target_ref, namespace, value)| {
- let event_id = label.event_id().as_str().to_owned();
- let pubkey = label.pubkey().as_str().to_owned();
- LabelProjectionFields {
- label_id: label_projection_id(
- &event_id,
- &target_type,
- &target_ref,
- &namespace,
- &value,
- ),
- event_id,
- pubkey,
- created_at: label.created_at().as_u64(),
- content: label.content().to_owned(),
- namespace,
- label: value,
- target_type,
- target_ref,
- projected_at: projected_at.as_u64(),
- }
- })
- .collect()
-}
-
-fn label_projection_id(
- event_id: &str,
- target_type: &str,
- target_ref: &str,
- namespace: &str,
- label: &str,
-) -> String {
- checksum(
- &serde_json::to_string(&[event_id, target_type, target_ref, namespace, label])
- .expect("label projection identity serializes"),
- )
-}
-
-fn report_projection_fields(
- report: &ReportEvent,
- projected_at: UnixTimestamp,
-) -> Vec<ReportProjectionFields> {
- let reported_pubkeys = report
- .reported_pubkeys()
- .iter()
- .map(|pubkey| pubkey.as_str().to_owned())
- .collect::<Vec<_>>();
- let server_urls = report.server_urls().to_vec();
- let mut pairs = BTreeSet::new();
- for target in report.targets() {
- pairs.insert(report_target_parts(target));
- }
- pairs
- .into_iter()
- .map(|(target_type, target_ref, report_type)| {
- let event_id = report.event_id().as_str().to_owned();
- let pubkey = report.pubkey().as_str().to_owned();
- ReportProjectionFields {
- report_id: report_projection_id(&event_id, &target_type, &target_ref, &report_type),
- event_id,
- pubkey,
- created_at: report.created_at().as_u64(),
- content: report.content().to_owned(),
- target_type,
- target_ref,
- report_type,
- reported_pubkeys: reported_pubkeys.clone(),
- server_urls: server_urls.clone(),
- projected_at: projected_at.as_u64(),
- }
- })
- .collect()
-}
-
-fn report_target_parts(target: &ReportTarget) -> (String, String, String) {
- (
- target.target_type().to_owned(),
- target.target_ref().to_owned(),
- target.report_type().canonical().to_owned(),
- )
-}
-
-fn report_projection_id(
- event_id: &str,
- target_type: &str,
- target_ref: &str,
- report_type: &str,
-) -> String {
- checksum(
- &serde_json::to_string(&[event_id, target_type, target_ref, report_type])
- .expect("report projection identity serializes"),
- )
-}
-
-fn seller_profile_fields(
- profile: &SellerProfileEvent,
- seller_approved: bool,
- blocked: bool,
- projected_at: UnixTimestamp,
-) -> SellerProfileFields {
- let metadata = profile.metadata();
- SellerProfileFields {
- pubkey: profile.pubkey().as_str().to_owned(),
- event_id: profile.event_id().as_str().to_owned(),
- created_at: profile.created_at().as_u64(),
- updated_at: profile.created_at().as_u64(),
- name: metadata.name().map(str::to_owned),
- display_name: metadata.display_name().map(str::to_owned),
- about: metadata.about().map(str::to_owned),
- picture: metadata.picture().map(str::to_owned),
- website: metadata.website().map(str::to_owned),
- nip05: metadata.nip05().map(str::to_owned),
- lud16: metadata.lud16().map(str::to_owned),
- regions: profile.regions().to_vec(),
- categories: profile.categories().to_vec(),
- trust_markers: profile.trust_markers().to_vec(),
- seller_approved,
- blocked,
- projected_at: projected_at.as_u64(),
- }
-}
-
-fn seller_profile_should_replace(profile: &SellerProfileEvent, row: &serde_json::Value) -> bool {
- let incoming_created_at = profile.created_at().as_u64();
- let existing_created_at = row["updated_at"].as_u64().unwrap_or_default();
- let existing_event_id = row["event_id"].as_str().unwrap_or_default();
- incoming_created_at > existing_created_at
- || (incoming_created_at == existing_created_at
- && profile.event_id().as_str() > existing_event_id)
-}
-
-struct SearchDocumentFields {
- doc_key: String,
- address_key: Option<String>,
- title: String,
- summary: Option<String>,
- body: String,
- category_text: String,
- location_text: Option<String>,
- tags: Vec<String>,
- categories: Vec<String>,
- visible: bool,
- status: String,
-}
-
-fn search_document_fields(projection: &ListingProjection, _event: &Event) -> SearchDocumentFields {
- let doc_key = projection.identity().address().key().to_string();
- let status = projection
- .status()
- .effective_status()
- .canonical()
- .to_owned();
- let categories = projection.taxonomy().categories().to_vec();
- SearchDocumentFields {
- address_key: Some(doc_key.clone()),
- doc_key,
- title: projection.text().title().to_owned(),
- summary: projection.text().summary().map(str::to_owned),
- body: projection.text().body().to_owned(),
- category_text: categories.join(" "),
- location_text: projection.location().location_text().map(str::to_owned),
- tags: projection.taxonomy().topics().to_vec(),
- categories,
- visible: status == "active",
- status,
- }
-}
-
-fn listing_current_fields(
- projection: &ListingProjection,
- event: &Event,
- _projected_at: UnixTimestamp,
-) -> Result<ListingCurrentFields, SurrealStoreError> {
- let listing_key = projection.identity().address().key().to_string();
- let price_decimal = projection.price().amount().raw().to_owned();
- let price_minor = price_minor(&price_decimal).ok_or_else(|| {
- SurrealStoreError::new("listing price amount must fit two decimal minor units")
- })?;
- Ok(ListingCurrentFields {
- listing_key_hash: checksum(&listing_key),
- listing_key,
- seller_pubkey: projection.identity().seller_pubkey().as_str().to_owned(),
- d: projection.identity().d().as_str().to_owned(),
- published_at: first_tag_value(event, "published_at").and_then(|value| value.parse().ok()),
- title: projection.text().title().to_owned(),
- summary: projection.text().summary().map(str::to_owned),
- content: projection.text().body().to_owned(),
- price_decimal,
- price_minor,
- currency_raw: projection.price().currency().to_owned(),
- currency_norm: projection.price().display_currency().to_owned(),
- price_frequency: projection.price().frequency().map(str::to_owned),
- unit: projection.unit().canonical().to_owned(),
- location_text: projection.location().location_text().map(str::to_owned),
- geohash: projection.location().geohash().map(str::to_owned),
- geohash4: projection.location().geohash4().map(str::to_owned),
- geohash5: projection.location().geohash5().map(str::to_owned),
- geohash6: projection.location().geohash6().map(str::to_owned),
- geohash7: projection.location().geohash7().map(str::to_owned),
- status_tag: projection.status().raw_status().map(str::to_owned),
- effective_status: projection
- .status()
- .effective_status()
- .canonical()
- .to_owned(),
- categories: projection.taxonomy().categories().to_vec(),
- tags: projection.taxonomy().topics().to_vec(),
- practices: projection.taxonomy().practices().to_vec(),
- certifications: projection.taxonomy().certifications().to_vec(),
- image_urls: tag_values(event, "image"),
- pickup_available: projection.fulfillment().pickup_available(),
- delivery_available: projection.fulfillment().delivery_available(),
- shipping_available: projection.fulfillment().shipping_available(),
- delivery_only: projection.fulfillment().delivery_only(),
- })
-}
-
-fn listing_revision_fields(
- event: &Event,
- evaluation: &ListingProjectionEvaluation,
-) -> Result<ListingRevisionFields, SurrealStoreError> {
- let d = d_tag_value(event).unwrap_or_default();
- let fallback_listing_key = format!(
- "{}:{}:{}",
- event.unsigned().kind().as_u32(),
- event.unsigned().pubkey().as_str(),
- d
- );
- let listing_key = address_key_value(event)?.unwrap_or(fallback_listing_key);
- let base = ListingRevisionFields {
- revision_key: event.id().as_str().to_owned(),
- listing_key,
- seller_pubkey: event.unsigned().pubkey().as_str().to_owned(),
- d,
- parsed_ok: false,
- parse_errors: Vec::new(),
- title: first_tag_value(event, "title"),
- summary: first_tag_value(event, "summary"),
- price_decimal: None,
- price_minor: None,
- currency_raw: None,
- currency_norm: None,
- unit: None,
- status_tag: first_tag_value(event, "status"),
- };
- match evaluation {
- ListingProjectionEvaluation::Eligible(projection) => Ok(ListingRevisionFields {
- parsed_ok: true,
- title: Some(projection.text().title().to_owned()),
- summary: projection.text().summary().map(str::to_owned),
- price_decimal: Some(projection.price().amount().raw().to_owned()),
- price_minor: price_minor(projection.price().amount().raw()),
- currency_raw: Some(projection.price().currency().to_owned()),
- currency_norm: Some(projection.price().display_currency().to_owned()),
- unit: Some(projection.unit().canonical().to_owned()),
- status_tag: projection.status().raw_status().map(str::to_owned),
- ..base
- }),
- ListingProjectionEvaluation::Ineligible(rejection) => Ok(ListingRevisionFields {
- parse_errors: rejection.reasons().to_vec(),
- ..base
- }),
- ListingProjectionEvaluation::NotListing => Ok(base),
- }
-}
-
-fn is_listing_event(event: &Event) -> bool {
- matches!(
- event.unsigned().kind().as_u32(),
- NIP99_PUBLIC_LISTING_KIND | NIP99_DRAFT_LISTING_KIND
- )
-}
-
-fn first_tag_value(event: &Event, name: &str) -> Option<String> {
- event
- .unsigned()
- .tags()
- .iter()
- .find(|tag| tag.name().as_str() == name)
- .and_then(|tag| tag.value())
- .map(|value| value.as_str().to_owned())
-}
-
-fn tag_values(event: &Event, name: &str) -> Vec<String> {
- event
- .unsigned()
- .tags()
- .iter()
- .filter(|tag| tag.name().as_str() == name)
- .filter_map(|tag| tag.value())
- .map(|value| value.as_str().to_owned())
- .collect()
-}
-
-fn unique_in_order(values: Vec<String>) -> Vec<String> {
- let mut seen = BTreeSet::new();
- let mut unique = Vec::new();
- for value in values {
- if seen.insert(value.clone()) {
- unique.push(value);
- }
- }
- unique
-}
-
-fn price_minor(raw: &str) -> Option<i64> {
- let mut parts = raw.split('.');
- let whole = parts.next()?.parse::<i64>().ok()?;
- let fraction = parts.next();
- if parts.next().is_some() {
- return None;
- }
- match fraction {
- Some(value) if value.len() <= 2 => {
- let padded = format!("{value:0<2}");
- Some(whole * 100 + padded.parse::<i64>().ok()?)
- }
- Some(_) => None,
- None => Some(whole * 100),
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SurrealStoreError {
- message: String,
-}
-
-impl SurrealStoreError {
- fn new(message: &str) -> Self {
- Self {
- message: message.to_owned(),
- }
- }
-
- pub fn message(&self) -> &str {
- &self.message
- }
-}
-
-impl fmt::Display for SurrealStoreError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(&self.message)
- }
-}
-
-impl std::error::Error for SurrealStoreError {}
-
-impl From<surrealdb::Error> for SurrealStoreError {
- fn from(source: surrealdb::Error) -> Self {
- Self::new(&source.to_string())
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{
- CommentProjectionOutcome, CommentProjectionQuery, CurrentEventOutcome,
- DeletionMarkerOutcome, DurableRateLimitDecision, ForumThreadProjectionOutcome,
- ForumThreadProjectionQuery, HiddenEventOutcome, LabelProjectionOutcome,
- LabelProjectionQuery, ListingCurrentOutcome, ListingHelperOutcome, ListingProjectionQuery,
- ListingRevisionOutcome, LongFormProjectionOutcome, LongFormProjectionQuery,
- MigrationApplyOutcome, ReactionProjectionOutcome, ReportProjectionOutcome,
- ReportProjectionQuery, SearchDocumentOutcome, SearchDocumentQuery,
- SellerProfileProjectionOutcome, SellerProfileQuery, SurrealConfigError,
- SurrealConnectionConfig, SurrealConnectionMode, SurrealMigration, SurrealMigrationError,
- SurrealMigrationPlan, SurrealStore, SurrealStoreError, base_migration_plan, count_value,
- fallback_thread_title, long_form_current_should_replace, migration_tracking_schema,
- optional_string_row_field, required_policy_text, seller_profile_should_replace,
- };
- use tangle_nips::{
- ListingProjectionEvaluation, NIP01_METADATA_KIND, NIP7D_THREAD_KIND,
- NIP23_LONG_FORM_DRAFT_KIND, NIP23_LONG_FORM_KIND, NIP32_LABEL_KIND, NIP56_REPORT_KIND,
- parse_forum_thread_event, parse_long_form_event, parse_seller_profile_event,
- };
- use tangle_protocol::{
- Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent,
- filter_from_value,
- };
- use tangle_store::{StoreEventOutcome, StoredEvent};
- use tangle_test_support::{
- FixtureKey, build_fixture_event, build_fixture_event_from_parts, valid_public_listing_spec,
- };
-
- #[test]
- fn memory_config_normalizes_namespace_and_database() {
- let config =
- SurrealConnectionConfig::memory(" tangle_test ", " relay_one ").expect("memory config");
-
- assert_eq!(config.mode(), &SurrealConnectionMode::Memory);
- assert_eq!(config.namespace(), "tangle_test");
- assert_eq!(config.database(), "relay_one");
- }
-
- #[test]
- fn remote_config_preserves_trimmed_endpoints() {
- let rocksdb = SurrealConnectionConfig::rocksdb(" /tmp/tangle-rocksdb ", "ns", "db")
- .expect("rocksdb config");
- let http = SurrealConnectionConfig::http(" http://127.0.0.1:8000 ", "ns", "db")
- .expect("http config")
- .with_root_credentials(" root ", " root ")
- .expect("http credentials");
- let websocket = SurrealConnectionConfig::websocket(" ws://127.0.0.1:8000 ", "ns", "db")
- .expect("websocket config");
-
- assert_eq!(
- rocksdb.mode(),
- &SurrealConnectionMode::RocksDb {
- path: "/tmp/tangle-rocksdb".to_owned()
- }
- );
- assert_eq!(
- http.mode(),
- &SurrealConnectionMode::Http {
- endpoint: "http://127.0.0.1:8000".to_owned()
- }
- );
- let credentials = http.root_credentials().expect("http credentials");
- assert_eq!(credentials.username(), "root");
- assert_eq!(credentials.password(), "root");
- assert_eq!(
- websocket.mode(),
- &SurrealConnectionMode::WebSocket {
- endpoint: "ws://127.0.0.1:8000".to_owned()
- }
- );
- }
-
- #[test]
- fn config_rejects_empty_namespace_database_and_endpoint() {
- assert_eq!(
- SurrealConnectionConfig::memory(" ", "db")
- .expect_err("namespace error")
- .to_string(),
- "surreal namespace must not be empty"
- );
- assert_eq!(
- SurrealConnectionConfig::memory("ns", "")
- .expect_err("database error")
- .message(),
- "surreal database must not be empty"
- );
- assert_eq!(
- SurrealConnectionConfig::http("", "ns", "db").expect_err("endpoint error"),
- SurrealConfigError {
- message: "surreal http endpoint must not be empty".to_owned()
- }
- );
- assert_eq!(
- SurrealConnectionConfig::rocksdb("", "ns", "db").expect_err("path error"),
- SurrealConfigError {
- message: "surreal rocksdb path must not be empty".to_owned()
- }
- );
- assert_eq!(
- SurrealConnectionConfig::websocket(" ", "ns", "db")
- .expect_err("websocket endpoint error")
- .to_string(),
- "surreal websocket endpoint must not be empty"
- );
- assert_eq!(
- SurrealConnectionConfig::http("http://127.0.0.1:8000", "ns", "db")
- .expect("http config")
- .with_root_credentials("", "root")
- .expect_err("username error")
- .to_string(),
- "surreal root username must not be empty"
- );
- assert_eq!(
- SurrealConnectionConfig::http("http://127.0.0.1:8000", "ns", "db")
- .expect("http config")
- .with_root_credentials("root", " ")
- .expect_err("password error")
- .to_string(),
- "surreal root password must not be empty"
- );
- }
-
- #[test]
- fn config_rejects_non_portable_identifiers() {
- assert_eq!(
- SurrealConnectionConfig::memory("tangle-test", "db")
- .expect_err("namespace syntax")
- .to_string(),
- "surreal namespace must use ASCII letters, digits, or underscore"
- );
- assert_eq!(
- SurrealConnectionConfig::memory("ns", "relay.db")
- .expect_err("database syntax")
- .to_string(),
- "surreal database must use ASCII letters, digits, or underscore"
- );
- }
-
- #[test]
- fn migration_model_normalizes_names_and_hashes_surql() {
- let migration =
- SurrealMigration::new(" 0001_tracking ", "DEFINE TABLE migration SCHEMAFULL;")
- .expect("migration");
-
- assert_eq!(migration.name(), "0001_tracking");
- assert_eq!(migration.surql(), "DEFINE TABLE migration SCHEMAFULL;");
- assert_eq!(
- migration.checksum(),
- "ffedba540d84072a42d0e3f97bfdc054e688667e073b879e7409dd5253c8c896"
- );
- }
-
- #[test]
- fn migration_model_rejects_invalid_name_and_body() {
- assert_eq!(
- SurrealMigration::new("", "RETURN true;")
- .expect_err("missing name")
- .to_string(),
- "surreal migration name must not be empty"
- );
- assert_eq!(
- SurrealMigration::new("001_tracking", "RETURN true;")
- .expect_err("short version")
- .message(),
- "surreal migration name must start with four digits"
- );
- assert_eq!(
- SurrealMigration::new("0001_Tracking", "RETURN true;")
- .expect_err("bad label")
- .to_string(),
- "surreal migration label must use lowercase ASCII, digits, or underscore"
- );
- assert_eq!(
- SurrealMigration::new("0001_tracking", " ")
- .expect_err("empty body")
- .to_string(),
- "surreal migration body must not be empty"
- );
- }
-
- #[test]
- fn migration_plan_preserves_order_and_lookup() {
- let first = SurrealMigration::new("0001_tracking", "RETURN true;").expect("first");
- let second = SurrealMigration::new("0002_events", "RETURN false;").expect("second");
- let plan =
- SurrealMigrationPlan::new(vec![first.clone(), second.clone()]).expect("ordered plan");
-
- assert_eq!(plan.migrations(), &[first, second]);
- assert_eq!(plan.names(), vec!["0001_tracking", "0002_events"]);
- assert_eq!(
- plan.find("0002_events").expect("second migration").surql(),
- "RETURN false;"
- );
- assert_eq!(plan.find("9999_missing"), None);
- }
-
- #[test]
- fn migration_plan_rejects_duplicate_or_descending_names() {
- let first = SurrealMigration::new("0002_events", "RETURN true;").expect("first");
- let duplicate = SurrealMigration::new("0002_events", "RETURN false;").expect("duplicate");
- let descending = SurrealMigration::new("0001_tracking", "RETURN false;").expect("older");
-
- assert_eq!(
- SurrealMigrationPlan::new(vec![first.clone(), duplicate])
- .expect_err("duplicate")
- .to_string(),
- "surreal migrations must be strictly ordered by name"
- );
- assert_eq!(
- SurrealMigrationPlan::new(vec![first, descending]).expect_err("descending"),
- SurrealMigrationError {
- message: "surreal migrations must be strictly ordered by name".to_owned()
- }
- );
- }
-
- #[tokio::test]
- async fn memory_store_rejects_remote_config() {
- let config =
- SurrealConnectionConfig::websocket("ws://127.0.0.1:8000", "ns", "db").expect("config");
- let error = SurrealStore::connect_memory(&config)
- .await
- .expect_err("memory mismatch");
-
- assert_eq!(
- error.message(),
- "surreal memory connection requires memory mode config"
- );
- let local_error = SurrealStore::connect_local(&config)
- .await
- .expect_err("remote local mismatch");
- assert_eq!(
- local_error.message(),
- "surreal local connection requires memory or rocksdb mode config"
- );
- }
-
- #[tokio::test]
- async fn remote_connection_requires_root_credentials_before_network_use() {
- let config =
- SurrealConnectionConfig::http("http://127.0.0.1:8000", "ns", "db").expect("config");
- let error = SurrealStore::connect(&config)
- .await
- .expect_err("missing credentials");
-
- assert_eq!(
- error.message(),
- "surreal remote connection requires root credentials"
- );
- }
-
- #[tokio::test]
- async fn remote_connection_uses_any_engine_endpoint_with_root_credentials() {
- for config in [
- SurrealConnectionConfig::http("memory", "ns", "db")
- .expect("http config")
- .with_root_credentials("root", "root")
- .expect("http credentials"),
- SurrealConnectionConfig::websocket("memory", "ns", "db")
- .expect("websocket config")
- .with_root_credentials("root", "root")
- .expect("websocket credentials"),
- ] {
- SurrealStore::connect(&config)
- .await
- .expect("remote any connection")
- .ping()
- .await
- .expect("remote any ping");
- }
- }
-
- #[tokio::test]
- async fn remote_connection_converts_invalid_endpoint_errors() {
- let config = SurrealConnectionConfig::http("definitely-not-a-surreal-engine", "ns", "db")
- .expect("http config")
- .with_root_credentials("root", "root")
- .expect("credentials");
-
- let error = SurrealStore::connect(&config)
- .await
- .expect_err("invalid endpoint");
-
- assert!(!error.message().is_empty());
- }
-
- #[test]
- fn row_helper_functions_accept_supported_shapes_and_reject_malformed_values() {
- assert_eq!(count_value(serde_json::json!(3)).expect("numeric count"), 3);
- assert_eq!(
- count_value(serde_json::json!({"count": 4})).expect("object count"),
- 4
- );
- assert_eq!(
- count_value(serde_json::json!({"count": "4"}))
- .expect_err("bad count")
- .message(),
- "surreal count query returned a non-numeric count"
- );
- assert_eq!(
- required_policy_text(" seller ", "seller profile pubkey").expect("policy text"),
- "seller"
- );
- assert_eq!(
- required_policy_text(" ", "seller profile pubkey")
- .expect_err("empty policy text")
- .message(),
- "seller profile pubkey must not be empty"
- );
- assert_eq!(
- optional_string_row_field(&serde_json::json!({"target_kind": null}), "target_kind")
- .expect("null field"),
- None
- );
- assert_eq!(
- optional_string_row_field(&serde_json::json!({"target_kind": "30402"}), "target_kind")
- .expect("string field"),
- Some("30402".to_owned())
- );
- assert_eq!(
- optional_string_row_field(&serde_json::json!({}), "target_kind")
- .expect("missing field"),
- None
- );
- assert_eq!(
- optional_string_row_field(&serde_json::json!({"target_kind": 30402}), "target_kind")
- .expect_err("numeric field")
- .message(),
- "target_kind row field must be a string"
- );
- }
-
- #[test]
- fn projection_helper_edges_cover_replacement_ties_and_empty_forum_titles() {
- let article_event = long_form_article(1_714_125_010, "tie-break", "Tie break", "Body", &[]);
- let article = parse_long_form_event(&article_event)
- .expect("long form parse")
- .expect("long form");
- assert!(long_form_current_should_replace(
- &article,
- &serde_json::json!({
- "updated_at": 1714125010_u64,
- "event_id": "0".repeat(EventId::HEX_LENGTH)
- })
- ));
-
- let empty_thread = build_fixture_event_from_parts(
- FixtureKey::Buyer,
- 1_714_125_011,
- u64::from(NIP7D_THREAD_KIND),
- Vec::new(),
- "",
- )
- .expect("empty thread");
- let thread = parse_forum_thread_event(&empty_thread)
- .expect("thread parse")
- .expect("thread");
- assert_eq!(fallback_thread_title(&thread), empty_thread.id().as_str());
-
- let profile_event = seller_profile(1_714_125_012, "tie-market", None, &[], &[], &[]);
- let profile = parse_seller_profile_event(&profile_event)
- .expect("profile parse")
- .expect("profile");
- assert!(seller_profile_should_replace(
- &profile,
- &serde_json::json!({
- "updated_at": 1714125012_u64,
- "event_id": "0".repeat(EventId::HEX_LENGTH)
- })
- ));
- }
-
- #[tokio::test]
- async fn durable_rate_limit_rejects_invalid_window_and_cost_inputs() {
- let store = memory_store().await;
- let now = UnixTimestamp::new(1_714_124_500);
- let cases = [
- (0, 60, 1, "rate limit must be greater than zero"),
- (
- 1,
- 0,
- 1,
- "rate limit window must be greater than zero seconds",
- ),
- (1, 60, 0, "rate limit cost must be greater than zero"),
- (1, 60, 2, "rate limit cost 2 exceeds limit 1"),
- ];
-
- for (limit, window, cost, expected) in cases {
- assert_eq!(
- store
- .check_durable_rate_limit("key", limit, window, cost, now)
- .await
- .expect_err(expected)
- .message(),
- expected
- );
- }
- }
-
- #[tokio::test]
- async fn durable_rate_limit_rejects_malformed_persisted_state_rows() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let key = "event_write:".to_owned() + &"4".repeat(PublicKeyHex::HEX_LENGTH);
- let cases = [
- "not json".to_owned(),
- serde_json::json!({"started_at": "100", "used": 1}).to_string(),
- serde_json::json!({"started_at": 100, "used": "1"}).to_string(),
- ];
-
- for state in cases {
- store
- .database()
- .query(
- r#"
-UPSERT type::record('rate_limit_state', $key) CONTENT {
- key: $key,
- state: $state,
- expires_at: 160,
- created_at: 100,
- updated_at: 100
-};
-"#,
- )
- .bind(("key", key.as_str()))
- .bind(("state", state))
- .await
- .expect("upsert malformed state")
- .check()
- .expect("upsert malformed state check");
-
- assert_eq!(
- store
- .check_durable_rate_limit(&key, 3, 60, 1, UnixTimestamp::new(110))
- .await
- .expect_err("malformed state")
- .message(),
- "rate limit state is invalid"
- );
- }
- }
-
- #[tokio::test]
- async fn topic_projection_queries_short_circuit_when_topic_indexes_are_empty() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
-
- assert_eq!(
- store
- .query_long_form_projections(&LongFormProjectionQuery::new().with_topic("missing"))
- .await
- .expect("long form query"),
- Vec::<serde_json::Value>::new()
- );
- assert_eq!(
- store
- .query_forum_threads(&ForumThreadProjectionQuery::new().with_topic("missing"))
- .await
- .expect("forum query"),
- Vec::<serde_json::Value>::new()
- );
- }
-
- #[tokio::test]
- async fn unhide_event_reports_not_found_before_policy_validation() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let missing = EventId::new(&"1".repeat(EventId::HEX_LENGTH)).expect("event id");
-
- assert_eq!(
- store
- .unhide_event(&missing, "", "", UnixTimestamp::new(1_714_124_500))
- .await
- .expect("unhide missing"),
- HiddenEventOutcome::NotFound
- );
- }
-
- #[tokio::test]
- async fn hidden_event_policy_validation_rejects_blank_fields_after_event_lookup() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
- store
- .store_raw_event(&StoredEvent::new(
- listing.clone(),
- UnixTimestamp::new(1_714_124_600),
- ))
- .await
- .expect("raw listing");
-
- assert_eq!(
- store
- .hide_event(
- listing.id(),
- " ",
- "admin_api",
- &admin_pubkey,
- UnixTimestamp::new(1_714_124_601),
- )
- .await
- .expect_err("blank hide reason")
- .message(),
- "hidden event reason must not be empty"
- );
- assert_eq!(
- store
- .hide_event(
- listing.id(),
- "reason",
- "",
- &admin_pubkey,
- UnixTimestamp::new(1_714_124_602),
- )
- .await
- .expect_err("blank hide source")
- .message(),
- "hidden event source must not be empty"
- );
- assert_eq!(
- store
- .hide_event(
- listing.id(),
- "reason",
- "admin_api",
- " ",
- UnixTimestamp::new(1_714_124_603),
- )
- .await
- .expect_err("blank hide admin")
- .message(),
- "admin pubkey must not be empty"
- );
- assert_eq!(
- store
- .unhide_event(
- listing.id(),
- "",
- &admin_pubkey,
- UnixTimestamp::new(1_714_124_604),
- )
- .await
- .expect_err("blank unhide reason")
- .message(),
- "hidden event reason must not be empty"
- );
- assert_eq!(
- store
- .unhide_event(
- listing.id(),
- "reason",
- "",
- UnixTimestamp::new(1_714_124_605),
- )
- .await
- .expect_err("blank unhide admin")
- .message(),
- "admin pubkey must not be empty"
- );
- assert!(
- store
- .moderation_action_rows("event", listing.id().as_str())
- .await
- .expect("actions")
- .is_empty()
- );
- }
-
- #[tokio::test]
- async fn migration_tracking_schema_applies_idempotently() {
- let store = memory_store().await;
- let plan = base_migration_plan();
-
- assert_eq!(
- store.applied_migrations().await.expect("no table yet"),
- Vec::new()
- );
- assert_eq!(
- store.apply_plan(&plan).await.expect("apply"),
- vec![MigrationApplyOutcome::Applied; plan.migrations().len()]
- );
- assert_eq!(
- store.apply_plan(&plan).await.expect("reapply"),
- vec![MigrationApplyOutcome::AlreadyApplied; plan.migrations().len()]
- );
-
- let migrations = store.applied_migrations().await.expect("applied rows");
- assert_eq!(migrations.len(), plan.migrations().len());
- for (applied, expected) in migrations.iter().zip(plan.migrations()) {
- assert_eq!(applied.name(), expected.name());
- assert_eq!(applied.checksum(), expected.checksum());
- }
- assert!(format!("{:?}", store.database()).contains("Surreal"));
- }
-
- #[tokio::test]
- async fn store_ping_confirms_database_connectivity() {
- let store = memory_store().await;
-
- store.ping().await.expect("ping");
- }
-
- #[tokio::test]
- async fn migration_tracking_detects_checksum_changes() {
- let store = memory_store().await;
- let original = migration_tracking_schema();
- let changed = SurrealMigration::new(original.name(), "DEFINE TABLE migration SCHEMALESS;")
- .expect("changed");
-
- assert_eq!(
- store.apply_migration(&original).await.expect("apply"),
- MigrationApplyOutcome::Applied
- );
- assert_eq!(
- store
- .apply_migration(&changed)
- .await
- .expect_err("checksum changed")
- .to_string(),
- "surreal migration `0001_migration_tracking` checksum changed"
- );
- }
-
- async fn memory_store() -> SurrealStore {
- let config = SurrealConnectionConfig::memory("tangle_test", "relay").expect("config");
- SurrealStore::connect_memory(&config).await.expect("store")
- }
-
- #[tokio::test]
- async fn raw_event_schema_defines_canonical_event_table() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let info = store.table_info("nostr_event").await.expect("table info");
-
- for expected in [
- "event_id",
- "pubkey",
- "created_at",
- "kind",
- "tags",
- "content",
- "sig",
- "raw_json",
- "received_at",
- "content_len",
- "tag_count",
- "d_tag",
- "address_key",
- "deleted",
- "hidden",
- "rejection_reason",
- "nostr_event_id_uid",
- "nostr_event_kind_author_created",
- ] {
- assert!(info.contains(expected), "missing {expected} in {info}");
- }
- assert_eq!(
- store
- .table_info("nostr-event")
- .await
- .expect_err("invalid table")
- .to_string(),
- "surreal table info target is invalid: surreal table must use ASCII letters, digits, or underscore"
- );
- }
-
- #[tokio::test]
- async fn event_tag_index_schema_defines_single_letter_lookup_table() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let info = store
- .table_info("event_tag_index")
- .await
- .expect("table info");
-
- for expected in [
- "event_id",
- "kind",
- "pubkey",
- "created_at",
- "tag",
- "value",
- "ordinal",
- "event_tag_lookup",
- "event_tag_kind_lookup",
- "event_tag_event",
- ] {
- assert!(info.contains(expected), "missing {expected} in {info}");
- }
- }
-
- #[tokio::test]
- async fn current_event_schema_defines_replaceable_pointer_table() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let info = store.table_info("event_current").await.expect("table info");
-
- for expected in [
- "address_key",
- "kind",
- "pubkey",
- "d",
- "event_id",
- "created_at",
- "tie_break_id",
- "deleted",
- "hidden",
- "updated_at",
- "event_current_address_uid",
- "event_current_kind_pubkey",
- "event_current_event",
- ] {
- assert!(info.contains(expected), "missing {expected} in {info}");
- }
- }
-
- #[tokio::test]
- async fn deletion_marker_schema_defines_author_scoped_target_table() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let info = store
- .table_info("deletion_marker")
- .await
- .expect("table info");
-
- for expected in [
- "deletion_event_id",
- "target_type",
- "target_ref",
- "author_pubkey",
- "deletion_created_at",
- "deletion_target",
- "deletion_author_target",
- ] {
- assert!(info.contains(expected), "missing {expected} in {info}");
- }
- }
-
- #[tokio::test]
- async fn listing_revision_schema_defines_parse_audit_table() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let info = store
- .table_info("listing_revision")
- .await
- .expect("table info");
-
- for expected in [
- "revision_key",
- "listing_key",
- "event_id",
- "seller_pubkey",
- "d",
- "created_at",
- "parsed_ok",
- "parse_errors",
- "title",
- "summary",
- "price_decimal",
- "price_minor",
- "currency_raw",
- "currency_norm",
- "unit",
- "status_tag",
- "projected_at",
- "listing_revision_event_uid",
- "listing_revision_listing_created",
- "listing_revision_seller_created",
- ] {
- assert!(info.contains(expected), "missing {expected} in {info}");
- }
- }
-
- #[tokio::test]
- async fn comment_projection_schema_defines_threaded_comment_table() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let info = store
- .table_info("comment_projection")
- .await
- .expect("table info");
-
- for expected in [
- "comment_id",
- "event_id",
- "pubkey",
- "created_at",
- "content",
- "root_target_type",
- "root_ref",
- "root_kind",
- "root_author",
- "parent_target_type",
- "parent_ref",
- "parent_kind",
- "parent_author",
- "hidden",
- "deleted",
- "projected_at",
- "comment_projection_event_uid",
- "comment_projection_root_lookup",
- "comment_projection_parent_lookup",
- "comment_projection_author_created",
- ] {
- assert!(info.contains(expected), "missing {expected} in {info}");
- }
- }
-
- #[tokio::test]
- async fn reaction_projection_schema_defines_reaction_and_count_tables() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let projection = store
- .table_info("reaction_projection")
- .await
- .expect("projection info");
- let count = store
- .table_info("reaction_count")
- .await
- .expect("count info");
-
- for expected in [
- "reaction_id",
- "event_id",
- "pubkey",
- "created_at",
- "content",
- "value_type",
- "value",
- "target_event_id",
- "target_pubkey",
- "target_address",
- "target_kind",
- "hidden",
- "deleted",
- "projected_at",
- "reaction_projection_event_uid",
- "reaction_projection_target_created",
- "reaction_projection_author_created",
- "reaction_projection_target_kind",
- ] {
- assert!(
- projection.contains(expected),
- "missing {expected} in {projection}"
- );
- }
- for expected in [
- "target_event_id",
- "target_kind",
- "like_count",
- "dislike_count",
- "emoji_count",
- "text_count",
- "total_count",
- "updated_at",
- "reaction_count_target_uid",
- "reaction_count_kind_target",
- ] {
- assert!(count.contains(expected), "missing {expected} in {count}");
- }
- }
-
- #[tokio::test]
- async fn long_form_projection_schema_defines_current_and_topic_tables() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let current = store
- .table_info("long_form_current")
- .await
- .expect("current info");
- let topic = store
- .table_info("long_form_topic")
- .await
- .expect("topic info");
-
- for expected in [
- "long_form_key",
- "event_id",
- "author_pubkey",
- "d",
- "created_at",
- "updated_at",
- "published_at",
- "title",
- "image",
- "summary",
- "content",
- "tags",
- "referenced_events",
- "referenced_addresses",
- "referenced_pubkeys",
- "hidden",
- "deleted",
- "projected_at",
- "long_form_current_key_uid",
- "long_form_current_event_uid",
- "long_form_current_author_updated",
- "long_form_current_published_updated",
- "long_form_current_visibility",
- ] {
- assert!(
- current.contains(expected),
- "missing {expected} in {current}"
- );
- }
- for expected in [
- "long_form_key",
- "topic",
- "updated_at",
- "event_id",
- "hidden",
- "deleted",
- "long_form_topic_lookup",
- "long_form_topic_long_form",
- ] {
- assert!(topic.contains(expected), "missing {expected} in {topic}");
- }
- }
-
- #[tokio::test]
- async fn forum_thread_schema_defines_projection_and_topic_tables() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let projection = store
- .table_info("forum_thread_projection")
- .await
- .expect("projection info");
- let topic = store
- .table_info("forum_thread_topic")
- .await
- .expect("topic info");
-
- for expected in [
- "thread_id",
- "event_id",
- "pubkey",
- "created_at",
- "updated_at",
- "title",
- "content",
- "tags",
- "referenced_events",
- "referenced_pubkeys",
- "hidden",
- "deleted",
- "projected_at",
- "forum_thread_event_uid",
- "forum_thread_pubkey_updated",
- "forum_thread_visibility_updated",
- ] {
- assert!(
- projection.contains(expected),
- "missing {expected} in {projection}"
- );
- }
- for expected in [
- "thread_id",
- "topic",
- "updated_at",
- "event_id",
- "hidden",
- "deleted",
- "forum_thread_topic_lookup",
- "forum_thread_topic_thread",
- ] {
- assert!(topic.contains(expected), "missing {expected} in {topic}");
- }
- }
-
- #[tokio::test]
- async fn label_projection_schema_defines_reviewable_label_table() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let info = store
- .table_info("label_projection")
- .await
- .expect("label info");
-
- for expected in [
- "label_id",
- "event_id",
- "pubkey",
- "created_at",
- "content",
- "namespace",
- "label",
- "target_type",
- "target_ref",
- "hidden",
- "deleted",
- "projected_at",
- "label_projection_label_uid",
- "label_projection_event",
- "label_projection_target_lookup",
- "label_projection_namespace_lookup",
- "label_projection_author_created",
- "label_projection_visibility",
- ] {
- assert!(info.contains(expected), "missing {expected} in {info}");
- }
- }
-
- #[tokio::test]
- async fn report_projection_schema_defines_reviewable_report_table() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let info = store
- .table_info("report_projection")
- .await
- .expect("report info");
-
- for expected in [
- "report_id",
- "event_id",
- "pubkey",
- "created_at",
- "content",
- "target_type",
- "target_ref",
- "report_type",
- "reported_pubkeys",
- "server_urls",
- "hidden",
- "deleted",
- "projected_at",
- "report_projection_report_uid",
- "report_projection_event",
- "report_projection_target_lookup",
- "report_projection_type_created",
- "report_projection_author_created",
- "report_projection_visibility",
- ] {
- assert!(info.contains(expected), "missing {expected} in {info}");
- }
- }
-
- #[tokio::test]
- async fn seller_profile_schema_defines_current_seller_profile_table() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let info = store
- .table_info("seller_profile")
- .await
- .expect("seller profile info");
-
- for expected in [
- "pubkey",
- "event_id",
- "created_at",
- "updated_at",
- "name",
- "display_name",
- "about",
- "picture",
- "website",
- "nip05",
- "lud16",
- "regions",
- "categories",
- "trust_markers",
- "seller_approved",
- "blocked",
- "hidden",
- "deleted",
- "projected_at",
- "seller_profile_pubkey_uid",
- "seller_profile_event_uid",
- "seller_profile_updated",
- "seller_profile_approved_blocked",
- "seller_profile_visibility",
- ] {
- assert!(info.contains(expected), "missing {expected} in {info}");
- }
- }
-
- #[tokio::test]
- async fn listing_current_schema_defines_marketplace_projection_table() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let info = store
- .table_info("listing_current")
- .await
- .expect("table info");
-
- for expected in [
- "listing_key",
- "listing_key_hash",
- "event_id",
- "seller_pubkey",
- "d",
- "created_at",
- "updated_at",
- "published_at",
- "title",
- "summary",
- "content",
- "price_decimal",
- "price_minor",
- "currency_raw",
- "currency_norm",
- "price_frequency",
- "unit",
- "unit_family",
- "location_text",
- "geohash",
- "geohash4",
- "geohash5",
- "geohash6",
- "geohash7",
- "point",
- "status_tag",
- "effective_status",
- "categories",
- "tags",
- "practices",
- "certifications",
- "image_urls",
- "pickup_available",
- "delivery_available",
- "shipping_available",
- "delivery_only",
- "seller_trust_score",
- "hidden",
- "deleted",
- "projected_at",
- "listing_key_uid",
- "listing_event_uid",
- "listing_price_lookup",
- "listing_geo6_status",
- ] {
- assert!(info.contains(expected), "missing {expected} in {info}");
- }
- }
-
- #[tokio::test]
- async fn listing_helper_schemas_define_discovery_tables() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
-
- for (table, value_field, lookup_index, listing_index) in [
- (
- "listing_category",
- "category",
- "listing_category_lookup",
- "listing_category_listing",
- ),
- (
- "listing_fulfillment",
- "mode",
- "listing_fulfillment_lookup",
- "listing_fulfillment_listing",
- ),
- (
- "listing_tag",
- "tag_value",
- "listing_tag_lookup",
- "listing_tag_listing",
- ),
- (
- "listing_practice",
- "practice",
- "listing_practice_lookup",
- "listing_practice_listing",
- ),
- (
- "listing_certification",
- "certification",
- "listing_certification_lookup",
- "listing_certification_listing",
- ),
- ] {
- let info = store.table_info(table).await.expect("table info");
- for expected in [
- "listing_key",
- value_field,
- "effective_status",
- "updated_at",
- "event_id",
- lookup_index,
- listing_index,
- ] {
- assert!(
- info.contains(expected),
- "missing {expected} in {table} info {info}"
- );
- }
- }
- }
-
- #[tokio::test]
- async fn search_document_schema_defines_listing_search_surface() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let info = store.table_info("search_doc").await.expect("table info");
-
- for expected in [
- "doc_key",
- "event_id",
- "current_event_id",
- "doc_type",
- "kind",
- "pubkey",
- "address_key",
- "title",
- "summary",
- "body",
- "category_text",
- "location_text",
- "tags",
- "categories",
- "created_at",
- "updated_at",
- "visible",
- "status",
- "seller_trust_score",
- "search_doc_key_uid",
- "search_doc_type_visible_updated",
- "search_doc_kind_visible_updated",
- "search_doc_title_ft",
- "tangle_listing_search",
- "FULLTEXT",
- "BM25",
- ] {
- assert!(info.contains(expected), "missing {expected} in {info}");
- }
- }
-
- #[tokio::test]
- async fn policy_schemas_define_user_moderation_and_rebuild_tables() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
-
- for (table, expected) in [
- (
- "relay_user",
- vec![
- "pubkey",
- "role",
- "seller_approved",
- "blocked",
- "created_at",
- "updated_at",
- "relay_user_pubkey_uid",
- "relay_user_seller_gate",
- ],
- ),
- (
- "hidden_event",
- vec![
- "event_id",
- "reason",
- "source",
- "created_at",
- "admin_pubkey",
- "hidden_event_uid",
- "hidden_event_created",
- ],
- ),
- (
- "moderation_action",
- vec![
- "action_id",
- "admin_pubkey",
- "target_type",
- "target_ref",
- "action",
- "reason",
- "created_at",
- "moderation_action_target",
- "moderation_action_admin",
- ],
- ),
- (
- "rate_limit_state",
- vec![
- "key",
- "state",
- "expires_at",
- "created_at",
- "updated_at",
- "rate_limit_state_key_uid",
- "rate_limit_state_expires",
- ],
- ),
- (
- "import_checkpoint",
- vec![
- "name",
- "offset",
- "event_id",
- "updated_at",
- "import_checkpoint_name_uid",
- ],
- ),
- (
- "projection_error",
- vec![
- "event_id",
- "projector",
- "error",
- "created_at",
- "projection_error_event",
- "projection_error_projector_created",
- ],
- ),
- ] {
- let info = store.table_info(table).await.expect("table info");
- for field in expected {
- assert!(
- info.contains(field),
- "missing {field} in {table} info {info}"
- );
- }
- }
- }
-
- #[tokio::test]
- async fn store_raw_event_persists_canonical_nostr_event_row() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let event = build_fixture_event(&valid_public_listing_spec()).expect("event");
- let stored = StoredEvent::new(event.clone(), UnixTimestamp::new(1_714_124_500));
-
- assert_eq!(
- store.store_raw_event(&stored).await.expect("insert"),
- StoreEventOutcome::Inserted
- );
- assert_eq!(
- store.store_raw_event(&stored).await.expect("duplicate"),
- StoreEventOutcome::Duplicate
- );
-
- let row = store
- .raw_event_row(event.id())
- .await
- .expect("row query")
- .expect("row exists");
- assert_eq!(row["event_id"], event.id().as_str());
- assert_eq!(row["pubkey"], event.unsigned().pubkey().as_str());
- assert_eq!(row["created_at"], event.unsigned().created_at().as_u64());
- assert_eq!(row["kind"], event.unsigned().kind().as_u32());
- assert_eq!(row["content"], "Sweet storage carrots.");
- assert_eq!(row["sig"], event.sig().as_str());
- assert_eq!(row["received_at"], 1_714_124_500_u64);
- assert_eq!(row["content_len"], 22_u64);
- assert_eq!(row["tag_count"], 10_u64);
- assert_eq!(row["d_tag"], "listing-a");
- assert_eq!(
- row["address_key"],
- format!(
- "{}:{}:{}",
- event.unsigned().kind().as_u32(),
- event.unsigned().pubkey().as_str(),
- event.unsigned().tags()[0].values()[1]
- )
- );
- assert_eq!(row["deleted"], false);
- assert_eq!(row["hidden"], false);
- assert_eq!(
- row["raw_json"]
- .as_str()
- .expect("raw json string")
- .parse::<serde_json::Value>()
- .expect("raw json parses")["id"],
- event.id().as_str()
- );
- assert_eq!(row["tags"].as_array().expect("tags").len(), 10);
- }
-
- #[tokio::test]
- async fn query_raw_events_applies_core_filter_constraints() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let pubkey_a = "1".repeat(PublicKeyHex::HEX_LENGTH);
- let pubkey_b = "2".repeat(PublicKeyHex::HEX_LENGTH);
- let first = synthetic_event("1", "b", &pubkey_a, 100, 1, Vec::new(), "first");
- let second = synthetic_event("2", "c", &pubkey_a, 101, 1, Vec::new(), "second");
- let third = synthetic_event("3", "d", &pubkey_a, 102, 2, Vec::new(), "third");
- let fourth = synthetic_event("4", "e", &pubkey_b, 103, 1, Vec::new(), "fourth");
- for event in [&first, &second, &third, &fourth] {
- assert_eq!(
- store
- .store_raw_event(&StoredEvent::new(event.clone(), UnixTimestamp::new(200)))
- .await
- .expect("insert"),
- StoreEventOutcome::Inserted
- );
- }
-
- let filtered = filter_from_value(&serde_json::json!({
- "authors": [pubkey_a],
- "kinds": [1],
- "since": 100,
- "until": 101,
- "limit": 1
- }))
- .expect("filter");
- let rows = store
- .query_raw_events(&filtered)
- .await
- .expect("filtered rows");
- assert_eq!(rows.len(), 1);
- assert_eq!(rows[0]["event_id"], second.id().as_str());
-
- let id_filter = filter_from_value(&serde_json::json!({
- "ids": [first.id().as_str()]
- }))
- .expect("id filter");
- assert_eq!(
- store.query_raw_events(&id_filter).await.expect("id rows")[0]["event_id"],
- first.id().as_str()
- );
-
- store
- .database()
- .query("UPDATE nostr_event SET deleted = true WHERE event_id = $event_id;")
- .bind(("event_id", first.id().as_str()))
- .await
- .expect("delete marker")
- .check()
- .expect("delete check");
- assert!(
- store
- .query_raw_events(&id_filter)
- .await
- .expect("deleted rows")
- .is_empty()
- );
- let backup_rows = store.backup_raw_events().await.expect("backup rows");
- assert!(
- backup_rows
- .iter()
- .any(|row| row["event_id"] == first.id().as_str())
- );
- }
-
- #[tokio::test]
- async fn index_event_tags_persists_single_letter_tag_rows() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let event = Event::new(
- EventId::new(&"c".repeat(EventId::HEX_LENGTH)).expect("id"),
- UnsignedEvent::new(
- PublicKeyHex::new(&"d".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"),
- UnixTimestamp::new(1_714_124_600),
- Kind::new(1).expect("kind"),
- vec![
- Tag::from_parts("e", &["target-event"]).expect("e"),
- Tag::from_parts("A", &["upper-tag"]).expect("A"),
- Tag::from_parts("topic", &["ignored"]).expect("topic"),
- Tag::from_parts("p", &["target-pubkey"]).expect("p"),
- ],
- "tagged event",
- ),
- SignatureHex::new(&"e".repeat(SignatureHex::HEX_LENGTH)).expect("sig"),
- );
-
- store.index_event_tags(&event).await.expect("index");
- store.index_event_tags(&event).await.expect("reindex");
-
- let rows = store.tag_index_rows(event.id()).await.expect("rows");
- assert_eq!(rows.len(), 3);
- assert_eq!(rows[0]["tag"], "e");
- assert_eq!(rows[0]["value"], "target-event");
- assert_eq!(rows[0]["ordinal"], 0_u64);
- assert_eq!(rows[1]["tag"], "A");
- assert_eq!(rows[1]["value"], "upper-tag");
- assert_eq!(rows[1]["ordinal"], 1_u64);
- assert_eq!(rows[2]["tag"], "p");
- assert_eq!(rows[2]["value"], "target-pubkey");
- assert_eq!(rows[2]["kind"], 1_u64);
- assert_eq!(rows[2]["pubkey"], event.unsigned().pubkey().as_str());
- assert_eq!(
- rows[2]["created_at"],
- event.unsigned().created_at().as_u64()
- );
- }
-
- #[tokio::test]
- async fn query_indexed_tags_intersects_filter_tag_constraints() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let target_event = "a".repeat(EventId::HEX_LENGTH);
- let other_event = "b".repeat(EventId::HEX_LENGTH);
- let pubkey_a = "1".repeat(PublicKeyHex::HEX_LENGTH);
- let pubkey_b = "2".repeat(PublicKeyHex::HEX_LENGTH);
- let first = synthetic_event(
- "1",
- "b",
- &pubkey_a,
- 100,
- 1,
- vec![
- Tag::from_parts("e", &[&target_event]).expect("e tag"),
- Tag::from_parts("p", &[&pubkey_a]).expect("p tag"),
- ],
- "first",
- );
- let second = synthetic_event(
- "2",
- "c",
- &pubkey_b,
- 101,
- 1,
- vec![
- Tag::from_parts("e", &[&target_event]).expect("e tag"),
- Tag::from_parts("p", &[&pubkey_b]).expect("p tag"),
- ],
- "second",
- );
- let third = synthetic_event(
- "3",
- "d",
- &pubkey_a,
- 102,
- 1,
- vec![
- Tag::from_parts("e", &[&other_event]).expect("e tag"),
- Tag::from_parts("p", &[&pubkey_a]).expect("p tag"),
- ],
- "third",
- );
- for event in [&first, &second, &third] {
- store.index_event_tags(event).await.expect("index");
- }
-
- let intersection = filter_from_value(&serde_json::json!({
- "#e": [target_event],
- "#p": [pubkey_a],
- "authors": [pubkey_a],
- "kinds": [1],
- "since": 100,
- "until": 102
- }))
- .expect("intersection filter");
- assert_eq!(
- store
- .query_indexed_tag_event_ids(&intersection)
- .await
- .expect("intersection"),
- vec![first.id().as_str().to_owned()]
- );
-
- let ordered = filter_from_value(&serde_json::json!({
- "#e": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
- "limit": 1
- }))
- .expect("ordered filter");
- assert_eq!(
- store
- .query_indexed_tag_event_ids(&ordered)
- .await
- .expect("ordered"),
- vec![second.id().as_str().to_owned()]
- );
- assert!(
- store
- .query_indexed_tag_event_ids(
- &filter_from_value(&serde_json::json!({"kinds": [1]})).expect("no tag filter")
- )
- .await
- .expect("no tags")
- .is_empty()
- );
- }
-
- #[tokio::test]
- async fn maintain_current_events_tracks_replaceable_and_addressable_winners() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
- let replaceable_key = format!("3:{pubkey}");
- let first = synthetic_event(
- "1",
- "b",
- &pubkey,
- 1_714_124_700,
- 3,
- Vec::new(),
- "profile one",
- );
- let older = synthetic_event(
- "2",
- "c",
- &pubkey,
- 1_714_124_699,
- 3,
- Vec::new(),
- "profile older",
- );
- let newer = synthetic_event(
- "3",
- "d",
- &pubkey,
- 1_714_124_701,
- 3,
- Vec::new(),
- "profile newer",
- );
- let tied_lower = synthetic_event(
- "2",
- "e",
- &pubkey,
- 1_714_124_701,
- 3,
- Vec::new(),
- "profile tied lower",
- );
- let tied_higher = synthetic_event(
- "4",
- "f",
- &pubkey,
- 1_714_124_701,
- 3,
- Vec::new(),
- "profile tied higher",
- );
- let regular = synthetic_event(
- "5",
- "1",
- &pubkey,
- 1_714_124_702,
- 1,
- Vec::new(),
- "regular note",
- );
-
- assert_eq!(
- store.maintain_current_event(&first).await.expect("first"),
- CurrentEventOutcome::Inserted
- );
- assert_eq!(
- store.maintain_current_event(&older).await.expect("older"),
- CurrentEventOutcome::Unchanged
- );
- assert_eq!(
- store.maintain_current_event(&newer).await.expect("newer"),
- CurrentEventOutcome::Replaced
- );
- assert_eq!(
- store
- .maintain_current_event(&tied_lower)
- .await
- .expect("tied lower"),
- CurrentEventOutcome::Unchanged
- );
- assert_eq!(
- store
- .maintain_current_event(&tied_higher)
- .await
- .expect("tied higher"),
- CurrentEventOutcome::Replaced
- );
- assert_eq!(
- store
- .maintain_current_event(®ular)
- .await
- .expect("regular"),
- CurrentEventOutcome::NotCurrent
- );
-
- let row = store
- .current_event_row(&replaceable_key)
- .await
- .expect("replaceable row")
- .expect("replaceable row exists");
- assert_eq!(row["address_key"], replaceable_key);
- assert_eq!(row["kind"], 3_u64);
- assert_eq!(row["pubkey"], pubkey);
- assert_eq!(row["event_id"], tied_higher.id().as_str());
- assert_eq!(row["tie_break_id"], tied_higher.id().as_str());
- assert_eq!(row["created_at"], 1_714_124_701_u64);
- assert!(row["d"].is_null());
- assert_eq!(row["deleted"], false);
- assert_eq!(row["hidden"], false);
-
- let addressable = synthetic_event(
- "6",
- "2",
- &pubkey,
- 1_714_124_703,
- 30_402,
- vec![Tag::from_parts("d", &["listing-a"]).expect("d tag")],
- "listing projection",
- );
- let addressable_key = format!("30402:{pubkey}:listing-a");
-
- assert_eq!(
- store
- .maintain_current_event(&addressable)
- .await
- .expect("addressable"),
- CurrentEventOutcome::Inserted
- );
-
- let addressable_row = store
- .current_event_row(&addressable_key)
- .await
- .expect("addressable row")
- .expect("addressable row exists");
- assert_eq!(addressable_row["address_key"], addressable_key);
- assert_eq!(addressable_row["kind"], 30_402_u64);
- assert_eq!(addressable_row["d"], "listing-a");
- assert_eq!(addressable_row["event_id"], addressable.id().as_str());
- }
-
- #[tokio::test]
- async fn query_current_events_returns_replaceable_winners() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let pubkey_a = "1".repeat(PublicKeyHex::HEX_LENGTH);
- let pubkey_b = "2".repeat(PublicKeyHex::HEX_LENGTH);
- let older = synthetic_event("1", "b", &pubkey_a, 100, 3, Vec::new(), "older");
- let newer = synthetic_event("2", "c", &pubkey_a, 101, 3, Vec::new(), "newer");
- let other = synthetic_event("3", "d", &pubkey_b, 102, 3, Vec::new(), "other");
- let addressable = synthetic_event(
- "4",
- "e",
- &pubkey_a,
- 103,
- 30_402,
- vec![Tag::from_parts("d", &["listing-current"]).expect("d tag")],
- "listing",
- );
- for event in [&older, &newer, &other, &addressable] {
- store
- .store_raw_event(&StoredEvent::new(event.clone(), UnixTimestamp::new(200)))
- .await
- .expect("raw event");
- store.maintain_current_event(event).await.expect("current");
- }
-
- let replaceable_filter = filter_from_value(&serde_json::json!({
- "authors": [pubkey_a],
- "kinds": [3],
- "since": 100,
- "until": 101,
- "limit": 1
- }))
- .expect("replaceable filter");
- let rows = store
- .query_current_events(&replaceable_filter)
- .await
- .expect("current rows");
- assert_eq!(rows.len(), 1);
- assert_eq!(rows[0]["event_id"], newer.id().as_str());
-
- let kind_filter = filter_from_value(&serde_json::json!({
- "kinds": [3]
- }))
- .expect("kind filter");
- let rows = store
- .query_current_events(&kind_filter)
- .await
- .expect("kind rows");
- assert_eq!(rows.len(), 2);
- assert_eq!(rows[0]["event_id"], other.id().as_str());
- assert_eq!(rows[1]["event_id"], newer.id().as_str());
- assert!(
- rows[0]["raw_json"]
- .as_str()
- .expect("raw json")
- .contains("other")
- );
-
- let address_filter = filter_from_value(&serde_json::json!({
- "kinds": [30402],
- "authors": [pubkey_a],
- "#d": ["listing-current"],
- "limit": 1
- }))
- .expect("address filter");
- let rows = store
- .query_current_events(&address_filter)
- .await
- .expect("address rows");
- assert_eq!(rows.len(), 1);
- assert_eq!(rows[0]["event_id"], addressable.id().as_str());
- store
- .database()
- .query("DELETE nostr_event WHERE event_id = $event_id;")
- .bind(("event_id", addressable.id().as_str()))
- .await
- .expect("delete raw")
- .check()
- .expect("delete raw check");
- let orphan_filter = filter_from_value(&serde_json::json!({
- "ids": [addressable.id().as_str()]
- }))
- .expect("orphan filter");
- assert!(
- store
- .query_current_events(&orphan_filter)
- .await
- .expect("orphan current")
- .is_empty()
- );
-
- store
- .database()
- .query("UPDATE event_current SET deleted = true WHERE event_id = $event_id;")
- .bind(("event_id", newer.id().as_str()))
- .await
- .expect("delete current")
- .check()
- .expect("delete check");
- let id_filter = filter_from_value(&serde_json::json!({
- "ids": [newer.id().as_str()]
- }))
- .expect("id filter");
- assert!(
- store
- .query_current_events(&id_filter)
- .await
- .expect("deleted current")
- .is_empty()
- );
- }
-
- #[tokio::test]
- async fn apply_deletion_markers_persists_markers_and_author_scoped_deletes() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
- let other_pubkey = "b".repeat(PublicKeyHex::HEX_LENGTH);
- let raw = synthetic_event("7", "3", &pubkey, 1_714_124_800, 1, Vec::new(), "delete me");
- let foreign_target =
- synthetic_event("8", "4", &pubkey, 1_714_124_801, 1, Vec::new(), "keep me");
- let addressable_key = format!("30402:{pubkey}:listing-delete");
- let addressable = synthetic_event(
- "9",
- "5",
- &pubkey,
- 1_714_124_802,
- 30_402,
- vec![Tag::from_parts("d", &["listing-delete"]).expect("d tag")],
- "delete listing",
- );
- for event in [&raw, &foreign_target, &addressable] {
- assert_eq!(
- store
- .store_raw_event(&StoredEvent::new(
- event.clone(),
- UnixTimestamp::new(1_714_124_900)
- ))
- .await
- .expect("raw insert"),
- StoreEventOutcome::Inserted
- );
- }
- assert_eq!(
- store
- .maintain_current_event(&addressable)
- .await
- .expect("current"),
- CurrentEventOutcome::Inserted
- );
-
- let deletion = synthetic_event(
- "b",
- "6",
- &pubkey,
- 1_714_124_903,
- 5,
- vec![
- Tag::from_parts("e", &[raw.id().as_str()]).expect("e tag"),
- Tag::from_parts("a", &[&addressable_key]).expect("a tag"),
- ],
- "remove stale events",
- );
- let not_deletion = synthetic_event(
- "c",
- "7",
- &pubkey,
- 1_714_124_904,
- 1,
- Vec::new(),
- "plain note",
- );
- let unauthorized = synthetic_event(
- "d",
- "8",
- &other_pubkey,
- 1_714_124_905,
- 5,
- vec![Tag::from_parts("e", &[foreign_target.id().as_str()]).expect("foreign e")],
- "foreign delete",
- );
-
- assert_eq!(
- store
- .apply_deletion_markers(¬_deletion)
- .await
- .expect("not deletion"),
- DeletionMarkerOutcome::NotDeletion
- );
- assert_eq!(
- store
- .apply_deletion_markers(&deletion)
- .await
- .expect("delete"),
- DeletionMarkerOutcome::Applied { targets: 2 }
- );
- assert_eq!(
- store
- .apply_deletion_markers(&unauthorized)
- .await
- .expect("unauthorized"),
- DeletionMarkerOutcome::Applied { targets: 1 }
- );
-
- let markers = store
- .deletion_marker_rows(deletion.id())
- .await
- .expect("markers");
- assert_eq!(markers.len(), 2);
- assert_eq!(markers[0]["target_type"], "address");
- assert_eq!(markers[0]["target_ref"], addressable_key);
- assert_eq!(markers[0]["author_pubkey"], pubkey);
- assert_eq!(markers[1]["target_type"], "event");
- assert_eq!(markers[1]["target_ref"], raw.id().as_str());
- assert_eq!(markers[1]["deletion_created_at"], 1_714_124_903_u64);
-
- let unauthorized_markers = store
- .deletion_marker_rows(unauthorized.id())
- .await
- .expect("unauthorized markers");
- assert_eq!(unauthorized_markers.len(), 1);
-
- assert_eq!(
- store
- .raw_event_row(raw.id())
- .await
- .expect("raw row")
- .expect("raw exists")["deleted"],
- true
- );
- assert_eq!(
- store
- .raw_event_row(addressable.id())
- .await
- .expect("address row")
- .expect("address exists")["deleted"],
- true
- );
- assert_eq!(
- store
- .current_event_row(&addressable_key)
- .await
- .expect("current row")
- .expect("current exists")["deleted"],
- true
- );
- assert_eq!(
- store
- .raw_event_row(foreign_target.id())
- .await
- .expect("foreign row")
- .expect("foreign exists")["deleted"],
- false
- );
- }
-
- #[tokio::test]
- async fn store_listing_revisions_persists_projection_audit_rows() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let projected_at = UnixTimestamp::new(1_714_125_000);
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
-
- assert_eq!(
- store
- .store_listing_revision(&listing, projected_at)
- .await
- .expect("valid revision"),
- ListingRevisionOutcome::Stored { parsed_ok: true }
- );
-
- let row = store
- .listing_revision_row(listing.id())
- .await
- .expect("valid row")
- .expect("valid row exists");
- assert_eq!(row["revision_key"], listing.id().as_str());
- assert_eq!(
- row["listing_key"],
- format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str())
- );
- assert_eq!(row["event_id"], listing.id().as_str());
- assert_eq!(row["seller_pubkey"], listing.unsigned().pubkey().as_str());
- assert_eq!(row["d"], "listing-a");
- assert_eq!(row["created_at"], 1_714_124_433_u64);
- assert_eq!(row["parsed_ok"], true);
- assert_eq!(row["parse_errors"].as_array().expect("errors").len(), 0);
- assert_eq!(row["title"], "Carrot bunches");
- assert!(row["summary"].is_null());
- assert_eq!(row["price_decimal"], "12.50");
- assert_eq!(row["price_minor"], 1_250_u64);
- assert_eq!(row["currency_raw"], "USD");
- assert_eq!(row["currency_norm"], "USD");
- assert_eq!(row["unit"], "lb");
- assert!(row["status_tag"].is_null());
- assert_eq!(row["projected_at"], projected_at.as_u64());
-
- let pubkey = "c".repeat(PublicKeyHex::HEX_LENGTH);
- let invalid = synthetic_event(
- "e",
- "9",
- &pubkey,
- 1_714_125_010,
- 30_402,
- vec![Tag::from_parts("d", &["listing-invalid"]).expect("d tag")],
- "",
- );
- let note = synthetic_event(
- "f",
- "a",
- &pubkey,
- 1_714_125_011,
- 1,
- Vec::new(),
- "not a listing",
- );
-
- assert_eq!(
- store
- .store_listing_revision(&invalid, projected_at)
- .await
- .expect("invalid revision"),
- ListingRevisionOutcome::Stored { parsed_ok: false }
- );
- assert_eq!(
- store
- .store_listing_revision(¬e, projected_at)
- .await
- .expect("note revision"),
- ListingRevisionOutcome::NotListing
- );
-
- let invalid_row = store
- .listing_revision_row(invalid.id())
- .await
- .expect("invalid row")
- .expect("invalid row exists");
- let errors = invalid_row["parse_errors"].as_array().expect("errors");
- assert_eq!(
- invalid_row["listing_key"],
- format!("30402:{pubkey}:listing-invalid")
- );
- assert_eq!(invalid_row["parsed_ok"], false);
- assert!(errors.contains(&serde_json::Value::String(
- "tag `title` is required".to_owned()
- )));
- assert!(errors.contains(&serde_json::Value::String(
- "tag `price` is required".to_owned()
- )));
- assert!(invalid_row["price_decimal"].is_null());
- assert!(invalid_row["unit"].is_null());
- assert!(
- store
- .listing_revision_row(note.id())
- .await
- .expect("note row")
- .is_none()
- );
- }
-
- #[tokio::test]
- async fn project_current_listings_persists_normalized_marketplace_rows() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let projected_at = UnixTimestamp::new(1_714_125_100);
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
-
- assert_eq!(
- store
- .project_current_listing(&listing, projected_at)
- .await
- .expect("current listing"),
- ListingCurrentOutcome::Projected
- );
-
- let row = store
- .listing_current_row(&listing_key)
- .await
- .expect("listing row")
- .expect("listing row exists");
- assert_eq!(row["listing_key"], listing_key);
- assert_eq!(row["listing_key_hash"].as_str().expect("hash").len(), 64);
- assert_eq!(row["event_id"], listing.id().as_str());
- assert_eq!(row["seller_pubkey"], listing.unsigned().pubkey().as_str());
- assert_eq!(row["d"], "listing-a");
- assert_eq!(row["created_at"], 1_714_124_433_u64);
- assert_eq!(row["updated_at"], 1_714_124_433_u64);
- assert!(row["published_at"].is_null());
- assert_eq!(row["title"], "Carrot bunches");
- assert!(row["summary"].is_null());
- assert_eq!(row["content"], "Sweet storage carrots.");
- assert_eq!(row["price_decimal"], "12.50");
- assert_eq!(row["price_minor"], 1_250_u64);
- assert_eq!(row["currency_raw"], "USD");
- assert_eq!(row["currency_norm"], "USD");
- assert!(row["price_frequency"].is_null());
- assert_eq!(row["unit"], "lb");
- assert_eq!(row["unit_family"], "lb");
- assert!(row["location_text"].is_null());
- assert_eq!(row["geohash"], "c22yzug");
- assert_eq!(row["geohash4"], "c22y");
- assert_eq!(row["geohash5"], "c22yz");
- assert_eq!(row["geohash6"], "c22yzu");
- assert_eq!(row["geohash7"], "c22yzug");
- assert!(row["point"].is_null());
- assert!(row["status_tag"].is_null());
- assert_eq!(row["effective_status"], "active");
- assert_eq!(row["categories"].as_array().expect("categories").len(), 1);
- assert_eq!(row["categories"][0], "vegetables");
- assert_eq!(row["tags"].as_array().expect("tags").len(), 1);
- assert_eq!(row["tags"][0], "carrots");
- assert_eq!(row["practices"].as_array().expect("practices").len(), 1);
- assert_eq!(row["practices"][0], "no spray");
- assert_eq!(
- row["certifications"]
- .as_array()
- .expect("certifications")
- .len(),
- 1
- );
- assert_eq!(row["certifications"][0], "organic");
- assert_eq!(row["image_urls"].as_array().expect("images").len(), 0);
- assert_eq!(row["pickup_available"], true);
- assert_eq!(row["delivery_available"], false);
- assert_eq!(row["shipping_available"], false);
- assert_eq!(row["delivery_only"], false);
- assert!(row["seller_trust_score"].is_null());
- assert_eq!(row["hidden"], false);
- assert_eq!(row["deleted"], false);
- assert_eq!(row["projected_at"], projected_at.as_u64());
-
- let media_listing = synthetic_event(
- "6",
- "8",
- listing.unsigned().pubkey().as_str(),
- 1_714_125_101,
- 30_402,
- vec![
- Tag::from_parts("d", &["listing-media"]).expect("d tag"),
- Tag::from_parts("title", &["Media carrots"]).expect("title"),
- Tag::from_parts("price", &["7.25", "USD"]).expect("price"),
- Tag::from_parts("unit", &["lb"]).expect("unit"),
- Tag::from_parts("fulfillment", &["pickup"]).expect("fulfillment"),
- Tag::from_parts("published_at", &["1714125100"]).expect("published"),
- Tag::from_parts(
- "image",
- &["https://fixtures.radroots.test/listing-media.png"],
- )
- .expect("image"),
- ],
- "media listing",
- );
- assert_eq!(
- store
- .project_current_listing(&media_listing, projected_at)
- .await
- .expect("media current"),
- ListingCurrentOutcome::Projected
- );
- let media_row = store
- .listing_current_row(&format!(
- "30402:{}:listing-media",
- media_listing.unsigned().pubkey().as_str()
- ))
- .await
- .expect("media row")
- .expect("media row exists");
- assert_eq!(media_row["published_at"], 1_714_125_100_u64);
- assert_eq!(
- media_row["image_urls"][0],
- "https://fixtures.radroots.test/listing-media.png"
- );
-
- let pubkey = "d".repeat(PublicKeyHex::HEX_LENGTH);
- let invalid = synthetic_event(
- "e",
- "9",
- &pubkey,
- 1_714_125_110,
- 30_402,
- vec![Tag::from_parts("d", &["listing-invalid"]).expect("d tag")],
- "",
- );
- let note = synthetic_event(
- "f",
- "a",
- &pubkey,
- 1_714_125_111,
- 1,
- Vec::new(),
- "not a listing",
- );
-
- assert_eq!(
- store
- .project_current_listing(&invalid, projected_at)
- .await
- .expect("invalid current"),
- ListingCurrentOutcome::Ineligible
- );
- assert_eq!(
- store
- .project_current_listing(¬e, projected_at)
- .await
- .expect("note current"),
- ListingCurrentOutcome::NotListing
- );
- assert!(
- store
- .listing_current_row(&format!("30402:{pubkey}:listing-invalid"))
- .await
- .expect("invalid row")
- .is_none()
- );
- }
-
- #[tokio::test]
- async fn query_current_listings_applies_projection_filters() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- store
- .project_current_listing(&listing, UnixTimestamp::new(1_714_125_400))
- .await
- .expect("project listing");
-
- let query = ListingProjectionQuery::new()
- .with_effective_status("active")
- .with_seller_pubkey(listing.unsigned().pubkey().as_str())
- .with_unit("lb")
- .with_currency_norm("USD")
- .with_min_price_minor(1_000)
- .with_max_price_minor(2_000)
- .with_limit(5);
- let rows = store
- .query_current_listings(&query)
- .await
- .expect("listing query");
- assert_eq!(rows.len(), 1);
- assert_eq!(rows[0]["listing_key"], listing_key);
-
- let no_match = ListingProjectionQuery::new()
- .with_effective_status("active")
- .with_min_price_minor(2_000);
- assert!(
- store
- .query_current_listings(&no_match)
- .await
- .expect("no match")
- .is_empty()
- );
-
- store
- .database()
- .query("UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;")
- .bind(("listing_key", listing_key.as_str()))
- .await
- .expect("hide listing")
- .check()
- .expect("hide check");
- assert!(
- store
- .query_current_listings(
- &ListingProjectionQuery::new().with_effective_status("active")
- )
- .await
- .expect("hidden query")
- .is_empty()
- );
- }
-
- #[tokio::test]
- async fn project_listing_helpers_persists_discovery_tables() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
-
- assert_eq!(
- store
- .project_listing_helpers(&listing)
- .await
- .expect("helpers"),
- ListingHelperOutcome::Projected
- );
- assert_eq!(
- store
- .project_listing_helpers(&listing)
- .await
- .expect("helpers again"),
- ListingHelperOutcome::Projected
- );
-
- let categories = store
- .listing_category_rows(&listing_key)
- .await
- .expect("categories");
- let fulfillment = store
- .listing_fulfillment_rows(&listing_key)
- .await
- .expect("fulfillment");
- let topics = store
- .listing_topic_rows(&listing_key)
- .await
- .expect("topics");
- let practices = store
- .listing_practice_rows(&listing_key)
- .await
- .expect("practices");
- let certifications = store
- .listing_certification_rows(&listing_key)
- .await
- .expect("certifications");
-
- assert_eq!(categories.len(), 1);
- assert_eq!(categories[0]["category"], "vegetables");
- assert_eq!(categories[0]["effective_status"], "active");
- assert_eq!(categories[0]["updated_at"], 1_714_124_433_u64);
- assert_eq!(categories[0]["event_id"], listing.id().as_str());
- assert_eq!(fulfillment.len(), 1);
- assert_eq!(fulfillment[0]["mode"], "pickup");
- assert_eq!(topics.len(), 1);
- assert_eq!(topics[0]["tag_value"], "carrots");
- assert_eq!(practices.len(), 1);
- assert_eq!(practices[0]["practice"], "no spray");
- assert_eq!(certifications.len(), 1);
- assert_eq!(certifications[0]["certification"], "organic");
-
- let pubkey = "e".repeat(PublicKeyHex::HEX_LENGTH);
- let invalid = synthetic_event(
- "e",
- "9",
- &pubkey,
- 1_714_125_210,
- 30_402,
- vec![Tag::from_parts("d", &["listing-invalid"]).expect("d tag")],
- "",
- );
- let note = synthetic_event(
- "f",
- "a",
- &pubkey,
- 1_714_125_211,
- 1,
- Vec::new(),
- "not a listing",
- );
-
- assert_eq!(
- store
- .project_listing_helpers(&invalid)
- .await
- .expect("invalid helpers"),
- ListingHelperOutcome::Ineligible
- );
- assert_eq!(
- store
- .project_listing_helpers(¬e)
- .await
- .expect("note helpers"),
- ListingHelperOutcome::NotListing
- );
- assert!(
- store
- .listing_category_rows(&format!("30402:{pubkey}:listing-invalid"))
- .await
- .expect("invalid categories")
- .is_empty()
- );
- }
-
- #[tokio::test]
- async fn index_listing_search_documents_persists_listing_search_rows() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let doc_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
-
- assert_eq!(
- store
- .index_listing_search_document(&listing)
- .await
- .expect("search document"),
- SearchDocumentOutcome::Indexed
- );
- assert_eq!(
- store
- .index_listing_search_document(&listing)
- .await
- .expect("search document again"),
- SearchDocumentOutcome::Indexed
- );
-
- let row = store
- .search_document_row(&doc_key)
- .await
- .expect("search row")
- .expect("search row exists");
- assert_eq!(row["doc_key"], doc_key);
- assert_eq!(row["event_id"], listing.id().as_str());
- assert_eq!(row["current_event_id"], listing.id().as_str());
- assert_eq!(row["doc_type"], "listing");
- assert_eq!(row["kind"], 30_402_u64);
- assert_eq!(row["pubkey"], listing.unsigned().pubkey().as_str());
- assert_eq!(row["address_key"], doc_key);
- assert_eq!(row["title"], "Carrot bunches");
- assert!(row["summary"].is_null());
- assert_eq!(row["body"], "Sweet storage carrots.");
- assert_eq!(row["category_text"], "vegetables");
- assert!(row["location_text"].is_null());
- assert_eq!(row["tags"].as_array().expect("tags").len(), 1);
- assert_eq!(row["tags"][0], "carrots");
- assert_eq!(row["categories"].as_array().expect("categories").len(), 1);
- assert_eq!(row["categories"][0], "vegetables");
- assert_eq!(row["created_at"], 1_714_124_433_u64);
- assert_eq!(row["updated_at"], 1_714_124_433_u64);
- assert_eq!(row["visible"], true);
- assert_eq!(row["status"], "active");
- assert!(row["seller_trust_score"].is_null());
-
- let pubkey = "f".repeat(PublicKeyHex::HEX_LENGTH);
- let invalid = synthetic_event(
- "e",
- "9",
- &pubkey,
- 1_714_125_310,
- 30_402,
- vec![Tag::from_parts("d", &["listing-invalid"]).expect("d tag")],
- "",
- );
- let note = synthetic_event(
- "f",
- "a",
- &pubkey,
- 1_714_125_311,
- 1,
- Vec::new(),
- "not a listing",
- );
-
- assert_eq!(
- store
- .index_listing_search_document(&invalid)
- .await
- .expect("invalid search"),
- SearchDocumentOutcome::Ineligible
- );
- assert_eq!(
- store
- .index_listing_search_document(¬e)
- .await
- .expect("note search"),
- SearchDocumentOutcome::NotListing
- );
- assert!(
- store
- .search_document_row(&format!("30402:{pubkey}:listing-invalid"))
- .await
- .expect("invalid row")
- .is_none()
- );
- }
-
- #[tokio::test]
- async fn query_search_documents_applies_full_text_and_structured_filters() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let doc_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- store
- .index_listing_search_document(&listing)
- .await
- .expect("index search");
-
- let query = SearchDocumentQuery::new()
- .with_text("carrot")
- .with_doc_type("listing")
- .with_kind(30_402)
- .with_pubkey(listing.unsigned().pubkey().as_str())
- .with_visible(true)
- .with_status("active")
- .with_limit(5);
- let rows = store
- .query_search_documents(&query)
- .await
- .expect("search rows");
- assert_eq!(rows.len(), 1);
- assert_eq!(rows[0]["doc_key"], doc_key);
- assert!(rows[0]["score"].is_number());
-
- let miss = SearchDocumentQuery::new()
- .with_text("turnip")
- .with_visible(true);
- assert!(
- store
- .query_search_documents(&miss)
- .await
- .expect("miss rows")
- .is_empty()
- );
-
- let structured = SearchDocumentQuery::new()
- .with_doc_type("listing")
- .with_visible(true)
- .with_status("active")
- .with_limit(1);
- assert_eq!(
- store
- .query_search_documents(&structured)
- .await
- .expect("structured rows")[0]["doc_key"],
- doc_key
- );
-
- store
- .database()
- .query("UPDATE search_doc SET visible = false WHERE doc_key = $doc_key;")
- .bind(("doc_key", doc_key.as_str()))
- .await
- .expect("hide search doc")
- .check()
- .expect("hide check");
- assert!(
- store
- .query_search_documents(
- &SearchDocumentQuery::new()
- .with_doc_type("listing")
- .with_visible(true)
- )
- .await
- .expect("hidden rows")
- .is_empty()
- );
- }
-
- #[tokio::test]
- async fn project_comments_persists_threaded_comment_rows() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- let comment = listing_comment(&listing, 1_714_125_010, "Is pickup open Friday?");
- let invalid_comment = build_fixture_event_from_parts(
- FixtureKey::Buyer,
- 1_714_125_009,
- 1_111,
- vec![vec!["K".to_owned(), "30402".to_owned()]],
- "missing scoped targets",
- )
- .expect("invalid comment");
-
- assert_eq!(
- store
- .project_comment(&listing, UnixTimestamp::new(1_714_125_011))
- .await
- .expect("not comment"),
- CommentProjectionOutcome::NotComment
- );
- assert_eq!(
- store
- .project_comment(&invalid_comment, UnixTimestamp::new(1_714_125_011))
- .await
- .expect("invalid comment"),
- CommentProjectionOutcome::Ineligible
- );
- assert_eq!(
- store
- .project_comment(&comment, UnixTimestamp::new(1_714_125_011))
- .await
- .expect("project comment"),
- CommentProjectionOutcome::Projected
- );
-
- let row = store
- .comment_projection_row(comment.id())
- .await
- .expect("comment row")
- .expect("comment row exists");
- assert_eq!(row["comment_id"], comment.id().as_str());
- assert_eq!(row["event_id"], comment.id().as_str());
- assert_eq!(row["pubkey"], FixtureKey::Buyer.public_key().as_str());
- assert_eq!(row["content"], "Is pickup open Friday?");
- assert_eq!(row["root_target_type"], "address");
- assert_eq!(row["root_ref"], listing_key);
- assert_eq!(row["root_kind"], "30402");
- assert_eq!(row["root_author"], listing.unsigned().pubkey().as_str());
- assert_eq!(row["parent_target_type"], "address");
- assert_eq!(row["parent_ref"], listing_key);
- assert_eq!(row["parent_kind"], "30402");
- assert_eq!(row["parent_author"], listing.unsigned().pubkey().as_str());
- assert_eq!(row["hidden"], false);
- assert_eq!(row["deleted"], false);
- assert_eq!(row["projected_at"], 1_714_125_011_u64);
-
- let rows = store
- .query_comment_projections(
- &CommentProjectionQuery::new()
- .with_root("address", &listing_key)
- .with_parent("address", &listing_key)
- .with_pubkey(FixtureKey::Buyer.public_key().as_str())
- .with_limit(5),
- )
- .await
- .expect("comment query");
- assert_eq!(rows.len(), 1);
- assert_eq!(rows[0]["event_id"], comment.id().as_str());
- }
-
- #[tokio::test]
- async fn comment_projection_visibility_tracks_hidden_and_deleted_events() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- let comment = listing_comment(&listing, 1_714_125_020, "Do you offer bunch pricing?");
- let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
- store
- .store_raw_event(&StoredEvent::new(
- comment.clone(),
- UnixTimestamp::new(1_714_125_021),
- ))
- .await
- .expect("raw comment");
- store
- .project_comment(&comment, UnixTimestamp::new(1_714_125_022))
- .await
- .expect("project comment");
-
- assert_eq!(
- store
- .query_comment_projections(
- &CommentProjectionQuery::new().with_root("address", &listing_key)
- )
- .await
- .expect("visible comments")
- .len(),
- 1
- );
- assert_eq!(
- store
- .hide_event(
- comment.id(),
- "discussion moderation",
- "admin_api",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_023),
- )
- .await
- .expect("hide comment"),
- HiddenEventOutcome::Hidden
- );
- assert!(
- store
- .query_comment_projections(
- &CommentProjectionQuery::new().with_root("address", &listing_key)
- )
- .await
- .expect("hidden comments")
- .is_empty()
- );
- assert_eq!(
- store
- .comment_projection_row(comment.id())
- .await
- .expect("comment row")
- .expect("comment row exists")["hidden"],
- true
- );
- assert_eq!(
- store
- .unhide_event(
- comment.id(),
- "discussion restored",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_024),
- )
- .await
- .expect("unhide comment"),
- HiddenEventOutcome::Unhidden
- );
- assert_eq!(
- store
- .query_comment_projections(
- &CommentProjectionQuery::new().with_root("address", &listing_key)
- )
- .await
- .expect("restored comments")
- .len(),
- 1
- );
-
- let deletion = build_fixture_event_from_parts(
- FixtureKey::Buyer,
- 1_714_125_025,
- 5,
- vec![vec!["e".to_owned(), comment.id().as_str().to_owned()]],
- "",
- )
- .expect("deletion event");
- assert_eq!(
- store
- .apply_deletion_markers(&deletion)
- .await
- .expect("delete comment"),
- DeletionMarkerOutcome::Applied { targets: 1 }
- );
- assert!(
- store
- .query_comment_projections(
- &CommentProjectionQuery::new().with_root("address", &listing_key)
- )
- .await
- .expect("deleted comments")
- .is_empty()
- );
- assert_eq!(
- store
- .comment_projection_row(comment.id())
- .await
- .expect("comment row")
- .expect("comment row exists")["deleted"],
- true
- );
- }
-
- #[tokio::test]
- async fn project_reactions_persists_rows_and_aggregate_counts() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let like = listing_reaction(&listing, 1_714_125_030, "+");
- let dislike = listing_reaction(&listing, 1_714_125_031, "-");
- let emoji = listing_reaction(&listing, 1_714_125_032, "⭐");
- let text = listing_reaction(&listing, 1_714_125_033, "fresh");
- let invalid =
- build_fixture_event_from_parts(FixtureKey::Buyer, 1_714_125_029, 7, Vec::new(), "+")
- .expect("invalid reaction");
-
- assert_eq!(
- store
- .project_reaction(&listing, UnixTimestamp::new(1_714_125_033))
- .await
- .expect("not reaction"),
- ReactionProjectionOutcome::NotReaction
- );
- assert_eq!(
- store
- .project_reaction(&invalid, UnixTimestamp::new(1_714_125_033))
- .await
- .expect("invalid reaction"),
- ReactionProjectionOutcome::Ineligible
- );
- for reaction in [&like, &dislike, &emoji, &text] {
- assert_eq!(
- store
- .project_reaction(reaction, UnixTimestamp::new(1_714_125_034))
- .await
- .expect("project reaction"),
- ReactionProjectionOutcome::Projected
- );
- }
-
- let row = store
- .reaction_projection_row(like.id())
- .await
- .expect("reaction row")
- .expect("reaction row exists");
- assert_eq!(row["reaction_id"], like.id().as_str());
- assert_eq!(row["event_id"], like.id().as_str());
- assert_eq!(row["pubkey"], FixtureKey::Buyer.public_key().as_str());
- assert_eq!(row["content"], "+");
- assert_eq!(row["value_type"], "like");
- assert_eq!(row["value"], "like");
- assert_eq!(row["target_event_id"], listing.id().as_str());
- assert_eq!(row["target_pubkey"], listing.unsigned().pubkey().as_str());
- assert_eq!(
- row["target_address"],
- format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str())
- );
- assert_eq!(row["target_kind"], "30402");
- assert_eq!(row["hidden"], false);
- assert_eq!(row["deleted"], false);
-
- let count = store
- .reaction_count_row(listing.id())
- .await
- .expect("count row")
- .expect("count row exists");
- assert_eq!(count["target_event_id"], listing.id().as_str());
- assert_eq!(count["target_kind"], "30402");
- assert_eq!(count["like_count"], 1_i64);
- assert_eq!(count["dislike_count"], 1_i64);
- assert_eq!(count["emoji_count"], 1_i64);
- assert_eq!(count["text_count"], 1_i64);
- assert_eq!(count["total_count"], 4_i64);
- store
- .database()
- .query(
- "UPDATE reaction_projection SET value_type = 'unknown' WHERE event_id = $event_id;",
- )
- .bind(("event_id", text.id().as_str()))
- .await
- .expect("unknown reaction update")
- .check()
- .expect("unknown reaction update check");
- store
- .refresh_reaction_count_for_event(text.id().as_str(), 1_714_125_035)
- .await
- .expect("refresh unknown reaction");
- let count = store
- .reaction_count_row(listing.id())
- .await
- .expect("unknown count row")
- .expect("unknown count row exists");
- assert_eq!(count["like_count"], 1_i64);
- assert_eq!(count["dislike_count"], 1_i64);
- assert_eq!(count["emoji_count"], 1_i64);
- assert_eq!(count["text_count"], 0_i64);
- assert_eq!(count["total_count"], 3_i64);
- }
-
- #[tokio::test]
- async fn reaction_counts_track_hidden_restored_and_deleted_reactions() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let reaction = listing_reaction(&listing, 1_714_125_040, "+");
- let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
- store
- .store_raw_event(&StoredEvent::new(
- reaction.clone(),
- UnixTimestamp::new(1_714_125_041),
- ))
- .await
- .expect("raw reaction");
- store
- .project_reaction(&reaction, UnixTimestamp::new(1_714_125_042))
- .await
- .expect("project reaction");
- store
- .refresh_reaction_count_for_event(listing.id().as_str(), 1_714_125_042)
- .await
- .expect("refresh missing reaction row");
-
- assert_eq!(
- store
- .reaction_count_row(listing.id())
- .await
- .expect("count")
- .expect("count row")["like_count"],
- 1_i64
- );
- store
- .hide_event(
- reaction.id(),
- "reaction moderation",
- "admin_api",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_043),
- )
- .await
- .expect("hide reaction");
- assert_eq!(
- store
- .reaction_count_row(listing.id())
- .await
- .expect("count")
- .expect("count row")["total_count"],
- 0_i64
- );
- store
- .unhide_event(
- reaction.id(),
- "reaction restored",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_044),
- )
- .await
- .expect("unhide reaction");
- assert_eq!(
- store
- .reaction_count_row(listing.id())
- .await
- .expect("count")
- .expect("count row")["total_count"],
- 1_i64
- );
- let deletion = build_fixture_event_from_parts(
- FixtureKey::Buyer,
- 1_714_125_045,
- 5,
- vec![vec!["e".to_owned(), reaction.id().as_str().to_owned()]],
- "",
- )
- .expect("deletion event");
- store
- .apply_deletion_markers(&deletion)
- .await
- .expect("delete reaction");
- let row = store
- .reaction_projection_row(reaction.id())
- .await
- .expect("reaction row")
- .expect("reaction row exists");
- assert_eq!(row["deleted"], true);
- assert_eq!(
- store
- .reaction_count_row(listing.id())
- .await
- .expect("count")
- .expect("count row")["total_count"],
- 0_i64
- );
- }
-
- #[tokio::test]
- async fn project_long_form_posts_persists_current_topic_and_search_rows() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let article = long_form_article(
- 1_714_125_060,
- "harvest-notes",
- "Harvest notes",
- "The storage carrots held well.",
- &["Carrots", "CSA"],
- );
- let draft = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_125_061,
- u64::from(NIP23_LONG_FORM_DRAFT_KIND),
- vec![vec!["d".to_owned(), "draft-a".to_owned()]],
- "Draft body.",
- )
- .expect("draft");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let long_form_key = format!(
- "30023:{}:harvest-notes",
- article.unsigned().pubkey().as_str()
- );
-
- assert_eq!(
- store
- .project_long_form(&listing, UnixTimestamp::new(1_714_125_062))
- .await
- .expect("not long form"),
- LongFormProjectionOutcome::NotLongForm
- );
- assert_eq!(
- store
- .project_long_form(&draft, UnixTimestamp::new(1_714_125_062))
- .await
- .expect("draft long form"),
- LongFormProjectionOutcome::Ineligible
- );
- assert_eq!(
- store
- .project_long_form(&article, UnixTimestamp::new(1_714_125_063))
- .await
- .expect("project article"),
- LongFormProjectionOutcome::Projected
- );
-
- let row = store
- .long_form_current_row(&long_form_key)
- .await
- .expect("long-form row")
- .expect("long-form row exists");
- assert_eq!(row["long_form_key"], long_form_key);
- assert_eq!(row["event_id"], article.id().as_str());
- assert_eq!(
- row["author_pubkey"],
- FixtureKey::Seller.public_key().as_str()
- );
- assert_eq!(row["d"], "harvest-notes");
- assert_eq!(row["created_at"], 1_714_125_060_u64);
- assert_eq!(row["updated_at"], 1_714_125_060_u64);
- assert_eq!(row["published_at"], 1_714_125_000_u64);
- assert_eq!(row["title"], "Harvest notes");
- assert_eq!(row["image"], "https://radroots.test/harvest.jpg");
- assert_eq!(row["summary"], "Long-form harvest field notes.");
- assert_eq!(row["content"], "The storage carrots held well.");
- assert_eq!(row["tags"][0], "carrots");
- assert_eq!(row["tags"][1], "csa");
- assert_eq!(row["referenced_events"][0], "4".repeat(EventId::HEX_LENGTH));
- assert_eq!(
- row["referenced_pubkeys"][0],
- FixtureKey::Buyer.public_key().as_str()
- );
- assert_eq!(row["hidden"], false);
- assert_eq!(row["deleted"], false);
-
- let topics = store
- .long_form_topic_rows(&long_form_key)
- .await
- .expect("topic rows");
- assert_eq!(topics.len(), 2);
- assert_eq!(topics[0]["topic"], "carrots");
- assert_eq!(topics[1]["topic"], "csa");
- assert_eq!(
- store
- .query_long_form_projections(
- &LongFormProjectionQuery::new()
- .with_author_pubkey(FixtureKey::Seller.public_key().as_str())
- .with_topic("CSA")
- .with_limit(5),
- )
- .await
- .expect("long-form query")
- .len(),
- 1
- );
-
- let search = store
- .search_document_row(&long_form_key)
- .await
- .expect("search row")
- .expect("search row exists");
- assert_eq!(search["doc_type"], "long_form");
- assert_eq!(search["kind"], u64::from(NIP23_LONG_FORM_KIND));
- assert_eq!(search["title"], "Harvest notes");
- assert_eq!(search["body"], "The storage carrots held well.");
- assert_eq!(search["status"], "published");
- assert_eq!(search["visible"], true);
- assert_eq!(
- store
- .query_search_documents(
- &SearchDocumentQuery::new()
- .with_text("carrots")
- .with_doc_type("long_form")
- .with_visible(true),
- )
- .await
- .expect("search query")
- .len(),
- 1
- );
- }
-
- #[tokio::test]
- async fn long_form_projection_tracks_replacement_moderation_and_deletion() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let first = long_form_article(
- 1_714_125_070,
- "harvest-notes",
- "First harvest notes",
- "First body.",
- &["carrots"],
- );
- let second = long_form_article(
- 1_714_125_071,
- "harvest-notes",
- "Updated harvest notes",
- "Updated body.",
- &["storage"],
- );
- let malformed = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_125_072,
- u64::from(NIP23_LONG_FORM_KIND),
- Vec::new(),
- "Missing d tag",
- )
- .expect("malformed long form");
- let long_form_key = format!(
- "30023:{}:harvest-notes",
- second.unsigned().pubkey().as_str()
- );
- let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
- store
- .store_raw_event(&StoredEvent::new(
- second.clone(),
- UnixTimestamp::new(1_714_125_072),
- ))
- .await
- .expect("raw article");
-
- assert_eq!(
- store
- .project_long_form(&first, UnixTimestamp::new(1_714_125_073))
- .await
- .expect("project first"),
- LongFormProjectionOutcome::Projected
- );
- assert_eq!(
- store
- .project_long_form(&malformed, UnixTimestamp::new(1_714_125_073))
- .await
- .expect("malformed long form"),
- LongFormProjectionOutcome::Ineligible
- );
- assert_eq!(
- store
- .project_long_form(&second, UnixTimestamp::new(1_714_125_074))
- .await
- .expect("project second"),
- LongFormProjectionOutcome::Projected
- );
- assert_eq!(
- store
- .project_long_form(&first, UnixTimestamp::new(1_714_125_075))
- .await
- .expect("stale first"),
- LongFormProjectionOutcome::Ineligible
- );
- assert_eq!(
- store
- .long_form_current_row(&long_form_key)
- .await
- .expect("row")
- .expect("row exists")["event_id"],
- second.id().as_str()
- );
- assert_eq!(
- store
- .long_form_topic_rows(&long_form_key)
- .await
- .expect("topics")[0]["topic"],
- "storage"
- );
-
- assert_eq!(
- store
- .hide_event(
- second.id(),
- "long-form moderation",
- "admin_api",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_076),
- )
- .await
- .expect("hide article"),
- HiddenEventOutcome::Hidden
- );
- assert!(
- store
- .query_long_form_projections(&LongFormProjectionQuery::new())
- .await
- .expect("hidden query")
- .is_empty()
- );
- assert_eq!(
- store
- .long_form_current_row(&long_form_key)
- .await
- .expect("row")
- .expect("row exists")["hidden"],
- true
- );
- assert_eq!(
- store
- .long_form_topic_rows(&long_form_key)
- .await
- .expect("topics")[0]["hidden"],
- true
- );
- assert_eq!(
- store
- .search_document_row(&long_form_key)
- .await
- .expect("search row")
- .expect("search exists")["visible"],
- false
- );
-
- assert_eq!(
- store
- .unhide_event(
- second.id(),
- "long-form restored",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_077),
- )
- .await
- .expect("unhide article"),
- HiddenEventOutcome::Unhidden
- );
- assert_eq!(
- store
- .query_long_form_projections(&LongFormProjectionQuery::new())
- .await
- .expect("visible query")
- .len(),
- 1
- );
- assert_eq!(
- store
- .search_document_row(&long_form_key)
- .await
- .expect("search row")
- .expect("search exists")["visible"],
- true
- );
-
- let deletion = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_125_078,
- 5,
- vec![vec!["e".to_owned(), second.id().as_str().to_owned()]],
- "",
- )
- .expect("deletion event");
- assert_eq!(
- store
- .apply_deletion_markers(&deletion)
- .await
- .expect("delete article"),
- DeletionMarkerOutcome::Applied { targets: 1 }
- );
- assert!(
- store
- .query_long_form_projections(&LongFormProjectionQuery::new())
- .await
- .expect("deleted query")
- .is_empty()
- );
- assert_eq!(
- store
- .long_form_current_row(&long_form_key)
- .await
- .expect("row")
- .expect("row exists")["deleted"],
- true
- );
- assert_eq!(
- store
- .long_form_topic_rows(&long_form_key)
- .await
- .expect("topics")[0]["deleted"],
- true
- );
- assert_eq!(
- store
- .search_document_row(&long_form_key)
- .await
- .expect("search row")
- .expect("search exists")["visible"],
- false
- );
- }
-
- #[tokio::test]
- async fn project_forum_threads_persists_projection_topic_and_search_rows() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let thread = forum_thread(1_714_125_080, Some("Market day thread"), &["market", "CSA"]);
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let invalid = build_fixture_event_from_parts(
- FixtureKey::Buyer,
- 1_714_125_079,
- u64::from(NIP7D_THREAD_KIND),
- vec![vec!["p".to_owned(), "bad".to_owned()]],
- "Invalid thread.",
- )
- .expect("invalid thread");
-
- assert_eq!(
- store
- .project_forum_thread(&listing, UnixTimestamp::new(1_714_125_081))
- .await
- .expect("not forum"),
- ForumThreadProjectionOutcome::NotForumThread
- );
- assert_eq!(
- store
- .project_forum_thread(&invalid, UnixTimestamp::new(1_714_125_081))
- .await
- .expect("invalid forum"),
- ForumThreadProjectionOutcome::Ineligible
- );
- assert_eq!(
- store
- .project_forum_thread(&thread, UnixTimestamp::new(1_714_125_082))
- .await
- .expect("project thread"),
- ForumThreadProjectionOutcome::Projected
- );
-
- let row = store
- .forum_thread_row(thread.id())
- .await
- .expect("thread row")
- .expect("thread row exists");
- assert_eq!(row["thread_id"], thread.id().as_str());
- assert_eq!(row["event_id"], thread.id().as_str());
- assert_eq!(row["pubkey"], FixtureKey::Buyer.public_key().as_str());
- assert_eq!(row["created_at"], 1_714_125_080_u64);
- assert_eq!(row["updated_at"], 1_714_125_080_u64);
- assert_eq!(row["title"], "Market day thread");
- assert_eq!(row["content"], "What is everyone bringing this weekend?");
- assert_eq!(row["tags"][0], "csa");
- assert_eq!(row["tags"][1], "market");
- assert_eq!(row["referenced_events"][0], "5".repeat(EventId::HEX_LENGTH));
- assert_eq!(
- row["referenced_pubkeys"][0],
- FixtureKey::Seller.public_key().as_str()
- );
- assert_eq!(row["hidden"], false);
- assert_eq!(row["deleted"], false);
-
- let topics = store
- .forum_thread_topic_rows(thread.id())
- .await
- .expect("topic rows");
- assert_eq!(topics.len(), 2);
- assert_eq!(topics[0]["topic"], "csa");
- assert_eq!(topics[1]["topic"], "market");
- assert_eq!(
- store
- .query_forum_threads(
- &ForumThreadProjectionQuery::new()
- .with_pubkey(FixtureKey::Buyer.public_key().as_str())
- .with_topic("Market")
- .with_limit(5),
- )
- .await
- .expect("forum query")
- .len(),
- 1
- );
-
- let search = store
- .search_document_row(thread.id().as_str())
- .await
- .expect("search row")
- .expect("search row exists");
- assert_eq!(search["doc_type"], "forum_thread");
- assert_eq!(search["kind"], u64::from(NIP7D_THREAD_KIND));
- assert_eq!(search["title"], "Market day thread");
- assert_eq!(search["body"], "What is everyone bringing this weekend?");
- assert_eq!(search["status"], "open");
- assert_eq!(search["visible"], true);
- assert_eq!(
- store
- .query_search_documents(
- &SearchDocumentQuery::new()
- .with_text("bringing")
- .with_doc_type("forum_thread")
- .with_visible(true),
- )
- .await
- .expect("search query")
- .len(),
- 1
- );
- }
-
- #[tokio::test]
- async fn forum_thread_projection_tracks_moderation_and_deletion() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let thread = forum_thread(1_714_125_090, None, &["market"]);
- let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
- store
- .store_raw_event(&StoredEvent::new(
- thread.clone(),
- UnixTimestamp::new(1_714_125_091),
- ))
- .await
- .expect("raw thread");
- store
- .project_forum_thread(&thread, UnixTimestamp::new(1_714_125_092))
- .await
- .expect("project thread");
-
- assert_eq!(
- store
- .search_document_row(thread.id().as_str())
- .await
- .expect("search")
- .expect("search row")["title"],
- "What is everyone bringing this weekend?"
- );
- assert_eq!(
- store
- .hide_event(
- thread.id(),
- "forum moderation",
- "admin_api",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_093),
- )
- .await
- .expect("hide thread"),
- HiddenEventOutcome::Hidden
- );
- assert!(
- store
- .query_forum_threads(&ForumThreadProjectionQuery::new())
- .await
- .expect("hidden query")
- .is_empty()
- );
- assert_eq!(
- store
- .forum_thread_row(thread.id())
- .await
- .expect("row")
- .expect("row exists")["hidden"],
- true
- );
- assert_eq!(
- store
- .forum_thread_topic_rows(thread.id())
- .await
- .expect("topics")[0]["hidden"],
- true
- );
- assert_eq!(
- store
- .search_document_row(thread.id().as_str())
- .await
- .expect("search")
- .expect("search row")["visible"],
- false
- );
-
- assert_eq!(
- store
- .unhide_event(
- thread.id(),
- "forum restored",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_094),
- )
- .await
- .expect("unhide thread"),
- HiddenEventOutcome::Unhidden
- );
- assert_eq!(
- store
- .query_forum_threads(&ForumThreadProjectionQuery::new())
- .await
- .expect("visible query")
- .len(),
- 1
- );
- assert_eq!(
- store
- .search_document_row(thread.id().as_str())
- .await
- .expect("search")
- .expect("search row")["visible"],
- true
- );
-
- let deletion = build_fixture_event_from_parts(
- FixtureKey::Buyer,
- 1_714_125_095,
- 5,
- vec![vec!["e".to_owned(), thread.id().as_str().to_owned()]],
- "",
- )
- .expect("deletion event");
- assert_eq!(
- store
- .apply_deletion_markers(&deletion)
- .await
- .expect("delete thread"),
- DeletionMarkerOutcome::Applied { targets: 1 }
- );
- assert!(
- store
- .query_forum_threads(&ForumThreadProjectionQuery::new())
- .await
- .expect("deleted query")
- .is_empty()
- );
- assert_eq!(
- store
- .forum_thread_row(thread.id())
- .await
- .expect("row")
- .expect("row exists")["deleted"],
- true
- );
- assert_eq!(
- store
- .forum_thread_topic_rows(thread.id())
- .await
- .expect("topics")[0]["deleted"],
- true
- );
- assert_eq!(
- store
- .search_document_row(thread.id().as_str())
- .await
- .expect("search")
- .expect("search row")["visible"],
- false
- );
- }
-
- #[tokio::test]
- async fn project_labels_persists_deterministic_target_label_rows() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- let label = listing_label(
- &listing,
- 1_714_125_100,
- &["reviewed", "market"],
- "moderator labels listing",
- );
- let invalid_label = build_fixture_event_from_parts(
- FixtureKey::Buyer,
- 1_714_125_099,
- u64::from(NIP32_LABEL_KIND),
- vec![vec!["l".to_owned(), "reviewed".to_owned()]],
- "missing target",
- )
- .expect("invalid label");
-
- assert_eq!(
- store
- .project_label(&listing, UnixTimestamp::new(1_714_125_101))
- .await
- .expect("not label"),
- LabelProjectionOutcome::NotLabel
- );
- assert_eq!(
- store
- .project_label(&invalid_label, UnixTimestamp::new(1_714_125_101))
- .await
- .expect("invalid label"),
- LabelProjectionOutcome::Ineligible
- );
- assert_eq!(
- store
- .project_label(&label, UnixTimestamp::new(1_714_125_102))
- .await
- .expect("project label"),
- LabelProjectionOutcome::Projected
- );
- assert_eq!(
- store
- .project_label(&label, UnixTimestamp::new(1_714_125_103))
- .await
- .expect("reproject label"),
- LabelProjectionOutcome::Projected
- );
-
- let rows = store
- .label_projection_rows(label.id())
- .await
- .expect("label rows");
- assert_eq!(rows.len(), 4);
- assert!(
- rows.iter()
- .all(|row| row["label_id"].as_str().expect("label id").len() == 64)
- );
- assert!(
- rows.iter()
- .all(|row| row["event_id"] == label.id().as_str())
- );
- assert!(
- rows.iter()
- .all(|row| row["pubkey"] == FixtureKey::Buyer.public_key().as_str())
- );
- assert!(
- rows.iter()
- .all(|row| row["created_at"] == 1_714_125_100_u64)
- );
- assert!(
- rows.iter()
- .all(|row| row["content"] == "moderator labels listing")
- );
- assert!(
- rows.iter()
- .all(|row| row["namespace"] == "com.radroots.moderation")
- );
- assert!(
- rows.iter()
- .all(|row| row["projected_at"] == 1_714_125_103_u64)
- );
-
- let address_rows = store
- .query_label_projections(
- &LabelProjectionQuery::new()
- .with_target("address", &listing_key)
- .with_namespace("com.radroots.moderation")
- .with_label("reviewed")
- .with_pubkey(FixtureKey::Buyer.public_key().as_str())
- .with_limit(5),
- )
- .await
- .expect("label query");
- assert_eq!(address_rows.len(), 1);
- assert_eq!(address_rows[0]["target_type"], "address");
- assert_eq!(address_rows[0]["target_ref"], listing_key);
- assert_eq!(address_rows[0]["label"], "reviewed");
- assert_eq!(address_rows[0]["hidden"], false);
- assert_eq!(address_rows[0]["deleted"], false);
-
- let event_rows = store
- .query_label_projections(
- &LabelProjectionQuery::new()
- .with_target("event", listing.id().as_str())
- .with_namespace("com.radroots.moderation")
- .with_limit(5),
- )
- .await
- .expect("event label query");
- assert_eq!(event_rows.len(), 2);
- }
-
- #[tokio::test]
- async fn label_projection_visibility_tracks_hidden_and_deleted_events() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- let label = listing_label(&listing, 1_714_125_110, &["reviewed"], "label under review");
- let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
- store
- .store_raw_event(&StoredEvent::new(
- label.clone(),
- UnixTimestamp::new(1_714_125_111),
- ))
- .await
- .expect("raw label");
- store
- .project_label(&label, UnixTimestamp::new(1_714_125_112))
- .await
- .expect("project label");
-
- let query = LabelProjectionQuery::new()
- .with_target("address", &listing_key)
- .with_namespace("com.radroots.moderation")
- .with_label("reviewed");
- assert_eq!(
- store
- .query_label_projections(&query)
- .await
- .expect("visible labels")
- .len(),
- 1
- );
- assert_eq!(
- store
- .hide_event(
- label.id(),
- "label moderation",
- "admin_api",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_113),
- )
- .await
- .expect("hide label"),
- HiddenEventOutcome::Hidden
- );
- assert!(
- store
- .query_label_projections(&query)
- .await
- .expect("hidden labels")
- .is_empty()
- );
- assert!(
- store
- .label_projection_rows(label.id())
- .await
- .expect("label rows")
- .iter()
- .all(|row| row["hidden"] == true)
- );
- assert_eq!(
- store
- .unhide_event(
- label.id(),
- "label restored",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_114),
- )
- .await
- .expect("unhide label"),
- HiddenEventOutcome::Unhidden
- );
- assert_eq!(
- store
- .query_label_projections(&query)
- .await
- .expect("restored labels")
- .len(),
- 1
- );
-
- let deletion = build_fixture_event_from_parts(
- FixtureKey::Buyer,
- 1_714_125_115,
- 5,
- vec![vec!["e".to_owned(), label.id().as_str().to_owned()]],
- "",
- )
- .expect("deletion event");
- assert_eq!(
- store
- .apply_deletion_markers(&deletion)
- .await
- .expect("delete label"),
- DeletionMarkerOutcome::Applied { targets: 1 }
- );
- assert!(
- store
- .query_label_projections(&query)
- .await
- .expect("deleted labels")
- .is_empty()
- );
- assert!(
- store
- .label_projection_rows(label.id())
- .await
- .expect("label rows")
- .iter()
- .all(|row| row["deleted"] == true)
- );
- }
-
- #[tokio::test]
- async fn project_reports_persists_deterministic_target_report_rows() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let report = listing_report(
- &listing,
- 1_714_125_120,
- Some("impersonation"),
- "spam",
- "moderator report",
- );
- let invalid_report = build_fixture_event_from_parts(
- FixtureKey::Buyer,
- 1_714_125_119,
- u64::from(NIP56_REPORT_KIND),
- vec![vec![
- "p".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ]],
- "missing report type",
- )
- .expect("invalid report");
-
- assert_eq!(
- store
- .project_report(&listing, UnixTimestamp::new(1_714_125_121))
- .await
- .expect("not report"),
- ReportProjectionOutcome::NotReport
- );
- assert_eq!(
- store
- .project_report(&invalid_report, UnixTimestamp::new(1_714_125_121))
- .await
- .expect("invalid report"),
- ReportProjectionOutcome::Ineligible
- );
- assert_eq!(
- store
- .project_report(&report, UnixTimestamp::new(1_714_125_122))
- .await
- .expect("project report"),
- ReportProjectionOutcome::Projected
- );
- assert_eq!(
- store
- .project_report(&report, UnixTimestamp::new(1_714_125_123))
- .await
- .expect("reproject report"),
- ReportProjectionOutcome::Projected
- );
-
- let rows = store
- .report_projection_rows(report.id())
- .await
- .expect("report rows");
- assert_eq!(rows.len(), 2);
- assert!(
- rows.iter()
- .all(|row| row["report_id"].as_str().expect("report id").len() == 64)
- );
- assert!(
- rows.iter()
- .all(|row| row["event_id"] == report.id().as_str())
- );
- assert!(
- rows.iter()
- .all(|row| row["pubkey"] == FixtureKey::Buyer.public_key().as_str())
- );
- assert!(
- rows.iter()
- .all(|row| row["created_at"] == 1_714_125_120_u64)
- );
- assert!(rows.iter().all(|row| row["content"] == "moderator report"));
- assert!(
- rows.iter()
- .all(|row| row["reported_pubkeys"][0] == listing.unsigned().pubkey().as_str())
- );
- assert!(
- rows.iter()
- .all(|row| row["server_urls"][0] == "https://media.radroots.test/report.jpg")
- );
- assert!(
- rows.iter()
- .all(|row| row["projected_at"] == 1_714_125_123_u64)
- );
-
- let event_rows = store
- .query_report_projections(
- &ReportProjectionQuery::new()
- .with_target("event", listing.id().as_str())
- .with_report_type("spam")
- .with_pubkey(FixtureKey::Buyer.public_key().as_str())
- .with_limit(5),
- )
- .await
- .expect("report query");
- assert_eq!(event_rows.len(), 1);
- assert_eq!(event_rows[0]["target_type"], "event");
- assert_eq!(event_rows[0]["target_ref"], listing.id().as_str());
- assert_eq!(event_rows[0]["report_type"], "spam");
- assert_eq!(event_rows[0]["hidden"], false);
- assert_eq!(event_rows[0]["deleted"], false);
-
- let pubkey_rows = store
- .query_report_projections(
- &ReportProjectionQuery::new()
- .with_target("pubkey", listing.unsigned().pubkey().as_str())
- .with_report_type("impersonation")
- .with_limit(5),
- )
- .await
- .expect("profile report query");
- assert_eq!(pubkey_rows.len(), 1);
- }
-
- #[tokio::test]
- async fn report_projection_visibility_tracks_hidden_and_deleted_events() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let report = listing_report(
- &listing,
- 1_714_125_130,
- None,
- "spam",
- "listing should be reviewed",
- );
- let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
- store
- .store_raw_event(&StoredEvent::new(
- report.clone(),
- UnixTimestamp::new(1_714_125_131),
- ))
- .await
- .expect("raw report");
- store
- .project_report(&report, UnixTimestamp::new(1_714_125_132))
- .await
- .expect("project report");
-
- let query = ReportProjectionQuery::new()
- .with_target("event", listing.id().as_str())
- .with_report_type("spam");
- assert_eq!(
- store
- .query_report_projections(&query)
- .await
- .expect("visible reports")
- .len(),
- 1
- );
- assert_eq!(
- store
- .hide_event(
- report.id(),
- "report moderation",
- "admin_api",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_133),
- )
- .await
- .expect("hide report"),
- HiddenEventOutcome::Hidden
- );
- assert!(
- store
- .query_report_projections(&query)
- .await
- .expect("hidden reports")
- .is_empty()
- );
- assert!(
- store
- .report_projection_rows(report.id())
- .await
- .expect("report rows")
- .iter()
- .all(|row| row["hidden"] == true)
- );
- assert_eq!(
- store
- .unhide_event(
- report.id(),
- "report restored",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_134),
- )
- .await
- .expect("unhide report"),
- HiddenEventOutcome::Unhidden
- );
- assert_eq!(
- store
- .query_report_projections(&query)
- .await
- .expect("restored reports")
- .len(),
- 1
- );
-
- let deletion = build_fixture_event_from_parts(
- FixtureKey::Buyer,
- 1_714_125_135,
- 5,
- vec![vec!["e".to_owned(), report.id().as_str().to_owned()]],
- "",
- )
- .expect("deletion event");
- assert_eq!(
- store
- .apply_deletion_markers(&deletion)
- .await
- .expect("delete report"),
- DeletionMarkerOutcome::Applied { targets: 1 }
- );
- assert!(
- store
- .query_report_projections(&query)
- .await
- .expect("deleted reports")
- .is_empty()
- );
- assert!(
- store
- .report_projection_rows(report.id())
- .await
- .expect("report rows")
- .iter()
- .all(|row| row["deleted"] == true)
- );
- }
-
- #[tokio::test]
- async fn project_seller_profiles_persists_current_metadata_and_trust_state() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let profile = seller_profile(
- 1_714_125_140,
- "radroots-market",
- Some("Radroots Market"),
- &["PNW", "pnw", " Cascadia "],
- &["Produce", "produce"],
- &["CSA", "regenerative"],
- );
- let invalid_profile = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_125_139,
- u64::from(NIP01_METADATA_KIND),
- Vec::new(),
- "{\"name\":7}",
- )
- .expect("invalid profile");
-
- store
- .set_seller_approved(
- FixtureKey::Seller.public_key().as_str(),
- true,
- UnixTimestamp::new(1_714_125_138),
- )
- .await
- .expect("approve seller");
- assert_eq!(
- store
- .project_seller_profile(&listing, UnixTimestamp::new(1_714_125_141))
- .await
- .expect("not profile"),
- SellerProfileProjectionOutcome::NotProfile
- );
- assert_eq!(
- store
- .project_seller_profile(&invalid_profile, UnixTimestamp::new(1_714_125_141))
- .await
- .expect("invalid profile"),
- SellerProfileProjectionOutcome::Ineligible
- );
- assert_eq!(
- store
- .project_seller_profile(&profile, UnixTimestamp::new(1_714_125_142))
- .await
- .expect("project profile"),
- SellerProfileProjectionOutcome::Projected
- );
-
- let row = store
- .seller_profile_row(FixtureKey::Seller.public_key().as_str())
- .await
- .expect("profile row")
- .expect("profile exists");
- assert_eq!(row["pubkey"], FixtureKey::Seller.public_key().as_str());
- assert_eq!(row["event_id"], profile.id().as_str());
- assert_eq!(row["created_at"], 1_714_125_140_u64);
- assert_eq!(row["updated_at"], 1_714_125_140_u64);
- assert_eq!(row["name"], "radroots-market");
- assert_eq!(row["display_name"], "Radroots Market");
- assert_eq!(row["about"], "Local food seller profile");
- assert_eq!(row["picture"], "https://fixtures.radroots.test/seller.png");
- assert_eq!(row["website"], "https://seller.radroots.test");
- assert_eq!(row["nip05"], "seller@radroots.test");
- assert_eq!(row["lud16"], "seller@pay.radroots.test");
- assert_eq!(row["regions"], serde_json::json!(["cascadia", "pnw"]));
- assert_eq!(row["categories"], serde_json::json!(["produce"]));
- assert_eq!(
- row["trust_markers"],
- serde_json::json!(["csa", "regenerative"])
- );
- assert_eq!(row["seller_approved"], true);
- assert_eq!(row["blocked"], false);
- assert_eq!(row["hidden"], false);
- assert_eq!(row["deleted"], false);
- assert_eq!(row["projected_at"], 1_714_125_142_u64);
-
- let rows = store
- .query_seller_profiles(
- &SellerProfileQuery::new()
- .with_pubkey(FixtureKey::Seller.public_key().as_str())
- .with_approved(true)
- .with_blocked(false)
- .with_limit(5),
- )
- .await
- .expect("seller query");
- assert_eq!(rows.len(), 1);
- assert_eq!(rows[0]["event_id"], profile.id().as_str());
- }
-
- #[tokio::test]
- async fn metrics_snapshot_counts_projected_store_state() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let profile = seller_profile(
- 1_714_125_145,
- "radroots-market",
- Some("Radroots Market"),
- &["PNW"],
- &["Produce"],
- &["CSA"],
- );
- let blocked_pubkey = "b".repeat(PublicKeyHex::HEX_LENGTH);
-
- store
- .store_raw_event(&StoredEvent::new(
- listing.clone(),
- UnixTimestamp::new(1_714_125_146),
- ))
- .await
- .expect("store listing");
- store
- .store_raw_event(&StoredEvent::new(
- profile.clone(),
- UnixTimestamp::new(1_714_125_147),
- ))
- .await
- .expect("store profile");
- store
- .project_current_listing(&listing, UnixTimestamp::new(1_714_125_148))
- .await
- .expect("project listing");
- store
- .project_seller_profile(&profile, UnixTimestamp::new(1_714_125_149))
- .await
- .expect("project profile");
- store
- .set_seller_approved(
- FixtureKey::Seller.public_key().as_str(),
- true,
- UnixTimestamp::new(1_714_125_150),
- )
- .await
- .expect("approve seller");
- store
- .set_pubkey_blocked(&blocked_pubkey, true, UnixTimestamp::new(1_714_125_151))
- .await
- .expect("block pubkey");
-
- let snapshot = store.metrics_snapshot().await.expect("snapshot");
- assert_eq!(snapshot.stored_events(), 2);
- assert_eq!(snapshot.visible_events(), 2);
- assert_eq!(snapshot.hidden_events(), 0);
- assert_eq!(snapshot.deleted_events(), 0);
- assert_eq!(snapshot.current_listings(), 1);
- assert_eq!(snapshot.active_listings(), 1);
- assert_eq!(snapshot.seller_profiles(), 1);
- assert_eq!(snapshot.visible_seller_profiles(), 1);
- assert_eq!(snapshot.approved_sellers(), 1);
- assert_eq!(snapshot.blocked_pubkeys(), 1);
- }
-
- #[tokio::test]
- async fn seller_profile_projection_tracks_replacement_moderation_and_deletion() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let older = seller_profile(
- 1_714_125_150,
- "older-market",
- None,
- &["pnw"],
- &["produce"],
- &["csa"],
- );
- let newer = seller_profile(
- 1_714_125_151,
- "newer-market",
- Some("Newer Market"),
- &["cascadia"],
- &["fruit"],
- &["inspected"],
- );
- let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
-
- assert_eq!(
- store
- .project_seller_profile(&older, UnixTimestamp::new(1_714_125_152))
- .await
- .expect("older profile"),
- SellerProfileProjectionOutcome::Projected
- );
- assert_eq!(
- store
- .project_seller_profile(&newer, UnixTimestamp::new(1_714_125_153))
- .await
- .expect("newer profile"),
- SellerProfileProjectionOutcome::Projected
- );
- assert_eq!(
- store
- .project_seller_profile(&older, UnixTimestamp::new(1_714_125_154))
- .await
- .expect("stale profile"),
- SellerProfileProjectionOutcome::Ineligible
- );
- let current = store
- .seller_profile_row(FixtureKey::Seller.public_key().as_str())
- .await
- .expect("profile row")
- .expect("profile exists");
- assert_eq!(current["event_id"], newer.id().as_str());
- assert_eq!(current["name"], "newer-market");
- assert_eq!(current["categories"], serde_json::json!(["fruit"]));
-
- store
- .store_raw_event(&StoredEvent::new(
- newer.clone(),
- UnixTimestamp::new(1_714_125_155),
- ))
- .await
- .expect("raw profile");
- assert_eq!(
- store
- .hide_event(
- newer.id(),
- "profile moderation",
- "admin_api",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_156),
- )
- .await
- .expect("hide profile"),
- HiddenEventOutcome::Hidden
- );
- assert!(
- store
- .query_seller_profiles(&SellerProfileQuery::new())
- .await
- .expect("hidden profiles")
- .is_empty()
- );
- assert_eq!(
- store
- .seller_profile_row(FixtureKey::Seller.public_key().as_str())
- .await
- .expect("profile row")
- .expect("profile exists")["hidden"],
- true
- );
- assert_eq!(
- store
- .unhide_event(
- newer.id(),
- "profile restored",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_157),
- )
- .await
- .expect("unhide profile"),
- HiddenEventOutcome::Unhidden
- );
- assert_eq!(
- store
- .query_seller_profiles(&SellerProfileQuery::new())
- .await
- .expect("visible profiles")
- .len(),
- 1
- );
-
- store
- .set_pubkey_blocked(
- FixtureKey::Seller.public_key().as_str(),
- true,
- UnixTimestamp::new(1_714_125_158),
- )
- .await
- .expect("block seller");
- assert_eq!(
- store
- .seller_profile_row(FixtureKey::Seller.public_key().as_str())
- .await
- .expect("profile row")
- .expect("profile exists")["blocked"],
- true
- );
-
- let deletion = build_fixture_event_from_parts(
- FixtureKey::Seller,
- 1_714_125_159,
- 5,
- vec![vec!["e".to_owned(), newer.id().as_str().to_owned()]],
- "",
- )
- .expect("deletion event");
- assert_eq!(
- store
- .apply_deletion_markers(&deletion)
- .await
- .expect("delete profile"),
- DeletionMarkerOutcome::Applied { targets: 1 }
- );
- assert!(
- store
- .query_seller_profiles(&SellerProfileQuery::new())
- .await
- .expect("deleted profiles")
- .is_empty()
- );
- assert_eq!(
- store
- .seller_profile_row(FixtureKey::Seller.public_key().as_str())
- .await
- .expect("profile row")
- .expect("profile exists")["deleted"],
- true
- );
- }
-
- #[tokio::test]
- async fn hidden_event_overlay_excludes_events_from_public_read_models() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
- let id_filter = filter_from_value(&serde_json::json!({
- "ids": [listing.id().as_str()]
- }))
- .expect("id filter");
-
- store
- .store_raw_event(&StoredEvent::new(
- listing.clone(),
- UnixTimestamp::new(1_714_125_500),
- ))
- .await
- .expect("raw event");
- store
- .maintain_current_event(&listing)
- .await
- .expect("current event");
- store
- .project_current_listing(&listing, UnixTimestamp::new(1_714_125_501))
- .await
- .expect("listing projection");
- store
- .index_listing_search_document(&listing)
- .await
- .expect("search document");
-
- assert_eq!(
- store
- .query_raw_events(&id_filter)
- .await
- .expect("raw query")
- .len(),
- 1
- );
- assert_eq!(
- store
- .query_current_events(&id_filter)
- .await
- .expect("current query")
- .len(),
- 1
- );
- assert_eq!(
- store
- .query_current_listings(
- &ListingProjectionQuery::new().with_effective_status("active")
- )
- .await
- .expect("listing query")
- .len(),
- 1
- );
- assert_eq!(
- store
- .query_search_documents(
- &SearchDocumentQuery::new()
- .with_text("carrot")
- .with_doc_type("listing")
- .with_visible(true)
- )
- .await
- .expect("search query")
- .len(),
- 1
- );
-
- assert_eq!(
- store
- .hide_event(
- listing.id(),
- "policy proof",
- "admin_api",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_600),
- )
- .await
- .expect("hide"),
- HiddenEventOutcome::Hidden
- );
-
- let hidden = store
- .hidden_event_row(listing.id())
- .await
- .expect("hidden row")
- .expect("hidden row exists");
- assert_eq!(hidden["event_id"], listing.id().as_str());
- assert_eq!(hidden["reason"], "policy proof");
- assert_eq!(hidden["source"], "admin_api");
- assert_eq!(hidden["created_at"], 1_714_125_600_u64);
- assert_eq!(hidden["admin_pubkey"], admin_pubkey);
- assert_eq!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("raw row")
- .expect("raw row exists")["hidden"],
- true
- );
- assert_eq!(
- store
- .current_event_row(&listing_key)
- .await
- .expect("current row")
- .expect("current row exists")["hidden"],
- true
- );
- assert_eq!(
- store
- .listing_current_row(&listing_key)
- .await
- .expect("listing row")
- .expect("listing row exists")["hidden"],
- true
- );
- assert_eq!(
- store
- .search_document_row(&listing_key)
- .await
- .expect("search row")
- .expect("search row exists")["visible"],
- false
- );
- assert!(
- store
- .query_raw_events(&id_filter)
- .await
- .expect("hidden raw query")
- .is_empty()
- );
- assert!(
- store
- .query_current_events(&id_filter)
- .await
- .expect("hidden current query")
- .is_empty()
- );
- assert!(
- store
- .query_current_listings(
- &ListingProjectionQuery::new().with_effective_status("active")
- )
- .await
- .expect("hidden listing query")
- .is_empty()
- );
- assert!(
- store
- .query_search_documents(
- &SearchDocumentQuery::new()
- .with_text("carrot")
- .with_doc_type("listing")
- .with_visible(true)
- )
- .await
- .expect("hidden search query")
- .is_empty()
- );
- let actions = store
- .moderation_action_rows("event", listing.id().as_str())
- .await
- .expect("actions");
- assert_eq!(actions.len(), 1);
- assert_eq!(actions[0]["action"], "hide");
- assert_eq!(actions[0]["target_ref"], listing.id().as_str());
-
- assert_eq!(
- store
- .unhide_event(
- listing.id(),
- "policy proof complete",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_700),
- )
- .await
- .expect("unhide"),
- HiddenEventOutcome::Unhidden
- );
- assert!(
- store
- .hidden_event_row(listing.id())
- .await
- .expect("hidden row removed")
- .is_none()
- );
- assert_eq!(
- store
- .raw_event_row(listing.id())
- .await
- .expect("raw row")
- .expect("raw row exists")["hidden"],
- false
- );
- assert_eq!(
- store
- .search_document_row(&listing_key)
- .await
- .expect("search row")
- .expect("search row exists")["visible"],
- true
- );
- assert_eq!(
- store
- .query_search_documents(
- &SearchDocumentQuery::new()
- .with_text("carrot")
- .with_doc_type("listing")
- .with_visible(true)
- )
- .await
- .expect("visible search query")
- .len(),
- 1
- );
- let actions = store
- .moderation_action_rows("event", listing.id().as_str())
- .await
- .expect("actions");
- assert_eq!(actions.len(), 2);
- assert_eq!(actions[1]["action"], "unhide");
- assert_eq!(
- store
- .hide_event(
- &EventId::new(&"b".repeat(EventId::HEX_LENGTH)).expect("missing id"),
- "missing",
- "admin_api",
- &admin_pubkey,
- UnixTimestamp::new(1_714_125_800),
- )
- .await
- .expect("missing hide"),
- HiddenEventOutcome::NotFound
- );
- }
-
- #[tokio::test]
- async fn durable_rate_limit_state_persists_fixed_windows() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let key = "event_write:".to_owned() + &"1".repeat(PublicKeyHex::HEX_LENGTH);
-
- let first = store
- .check_durable_rate_limit(&key, 3, 60, 1, UnixTimestamp::new(100))
- .await
- .expect("first");
- let second = store
- .check_durable_rate_limit(&key, 3, 60, 2, UnixTimestamp::new(110))
- .await
- .expect("second");
- let rejected = store
- .check_durable_rate_limit(&key, 3, 60, 1, UnixTimestamp::new(120))
- .await
- .expect("rejected");
-
- assert_eq!(
- first,
- DurableRateLimitDecision::Accepted {
- remaining: 2,
- reset_at: UnixTimestamp::new(160)
- }
- );
- assert!(first.allowed());
- assert_eq!(first.remaining(), 2);
- assert_eq!(first.reset_at(), UnixTimestamp::new(160));
- assert_eq!(first.retry_after_seconds(), None);
- assert_eq!(
- second,
- DurableRateLimitDecision::Accepted {
- remaining: 0,
- reset_at: UnixTimestamp::new(160)
- }
- );
- assert_eq!(
- rejected,
- DurableRateLimitDecision::Rejected {
- retry_after_seconds: 40,
- reset_at: UnixTimestamp::new(160)
- }
- );
- assert!(!rejected.allowed());
- assert_eq!(rejected.remaining(), 0);
- assert_eq!(rejected.reset_at(), UnixTimestamp::new(160));
- assert_eq!(rejected.retry_after_seconds(), Some(40));
- let row = store
- .rate_limit_state_row(&key)
- .await
- .expect("rate row")
- .expect("rate row exists");
- assert_eq!(row["key"], key);
- assert_eq!(row["expires_at"], 160_u64);
- assert_eq!(row["created_at"], 100_u64);
- assert_eq!(row["updated_at"], 110_u64);
- assert_eq!(
- serde_json::from_str::<serde_json::Value>(row["state"].as_str().expect("state"))
- .expect("state json"),
- serde_json::json!({
- "started_at": 100,
- "used": 3
- })
- );
-
- let reset = store
- .check_durable_rate_limit(&key, 3, 60, 1, UnixTimestamp::new(160))
- .await
- .expect("reset");
- assert_eq!(
- reset,
- DurableRateLimitDecision::Accepted {
- remaining: 2,
- reset_at: UnixTimestamp::new(220)
- }
- );
- let row = store
- .rate_limit_state_row(&key)
- .await
- .expect("rate row")
- .expect("rate row exists");
- assert_eq!(row["expires_at"], 220_u64);
- assert_eq!(row["created_at"], 100_u64);
- assert_eq!(row["updated_at"], 160_u64);
- assert_eq!(
- store
- .prune_expired_rate_limit_state(UnixTimestamp::new(221))
- .await
- .expect("prune"),
- 1
- );
- assert!(
- store
- .rate_limit_state_row(&key)
- .await
- .expect("pruned row")
- .is_none()
- );
- }
-
- #[tokio::test]
- async fn relay_user_policy_rows_persist_approval_and_block_state() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let seller = "2".repeat(PublicKeyHex::HEX_LENGTH);
-
- store
- .set_seller_approved(&seller, true, UnixTimestamp::new(1_714_125_900))
- .await
- .expect("approve seller");
- let approved = store
- .relay_user_row(&seller)
- .await
- .expect("relay user")
- .expect("relay user exists");
- assert_eq!(approved["pubkey"], seller);
- assert_eq!(approved["role"], "seller");
- assert_eq!(approved["seller_approved"], true);
- assert_eq!(approved["blocked"], false);
- assert_eq!(approved["created_at"], 1_714_125_900_u64);
- assert_eq!(approved["updated_at"], 1_714_125_900_u64);
-
- store
- .set_pubkey_blocked(&seller, true, UnixTimestamp::new(1_714_126_000))
- .await
- .expect("block seller");
- let blocked = store
- .relay_user_row(&seller)
- .await
- .expect("relay user")
- .expect("relay user exists");
- assert_eq!(blocked["seller_approved"], true);
- assert_eq!(blocked["blocked"], true);
- assert_eq!(blocked["created_at"], 1_714_125_900_u64);
- assert_eq!(blocked["updated_at"], 1_714_126_000_u64);
-
- store
- .set_seller_approved(&seller, false, UnixTimestamp::new(1_714_126_100))
- .await
- .expect("unapprove seller");
- let unapproved = store
- .relay_user_row(&seller)
- .await
- .expect("relay user")
- .expect("relay user exists");
- assert_eq!(unapproved["seller_approved"], false);
- assert_eq!(unapproved["blocked"], true);
- assert_eq!(unapproved["created_at"], 1_714_125_900_u64);
- assert_eq!(unapproved["updated_at"], 1_714_126_100_u64);
- }
-
- #[tokio::test]
- async fn private_helpers_cover_debug_errors_and_decimal_edges() {
- let store = memory_store().await;
- assert!(format!("{store:?}").contains("SurrealStore"));
- let source = store
- .database()
- .query("THIS IS NOT VALID SURQL")
- .await
- .expect_err("surreal error");
- assert!(!SurrealStoreError::from(source).message().is_empty());
- let note = synthetic_event(
- "1",
- "b",
- &"1".repeat(PublicKeyHex::HEX_LENGTH),
- 1,
- 1,
- Vec::new(),
- "note",
- );
- let fields =
- super::listing_revision_fields(¬e, &ListingProjectionEvaluation::NotListing)
- .expect("not listing fields");
- assert_eq!(fields.revision_key, note.id().as_str());
- assert!(!fields.parsed_ok);
- let pubkey = "2".repeat(PublicKeyHex::HEX_LENGTH);
- let addressable_without_d =
- synthetic_event("2", "c", &pubkey, 2, 30_402, Vec::new(), "addressless");
- assert_eq!(
- super::address_key_value(&addressable_without_d)
- .expect_err("address key error")
- .message(),
- "addressable event must include a d tag"
- );
- assert_eq!(
- store
- .maintain_current_event(&addressable_without_d)
- .await
- .expect_err("current key error")
- .message(),
- "addressable event must include a d tag"
- );
- let malformed_deletion = synthetic_event(
- "3",
- "d",
- &pubkey,
- 3,
- 5,
- vec![Tag::from_parts("e", &["not-hex"]).expect("e tag")],
- "bad deletion",
- );
- assert_eq!(
- store
- .apply_deletion_markers(&malformed_deletion)
- .await
- .expect_err("malformed deletion")
- .message(),
- "event id must be 64 characters, got 7"
- );
- assert_eq!(
- super::tag_values(
- &synthetic_event(
- "4",
- "e",
- &pubkey,
- 4,
- 1,
- vec![
- Tag::from_parts("image", &["https://fixtures.radroots.test/helper.png"])
- .expect("image tag")
- ],
- "image helper",
- ),
- "image"
- ),
- vec!["https://fixtures.radroots.test/helper.png".to_owned()]
- );
- assert_eq!(
- super::unique_in_order(vec![
- "first".to_owned(),
- "first".to_owned(),
- "second".to_owned()
- ]),
- vec!["first".to_owned(), "second".to_owned()]
- );
- assert_eq!(super::price_minor("12"), Some(1_200));
- assert_eq!(super::price_minor("1.2.3"), None);
- assert_eq!(super::price_minor("1.234"), None);
- }
-
- #[tokio::test]
- async fn project_current_listing_rejects_prices_without_minor_unit_representation() {
- let store = memory_store().await;
- store
- .apply_plan(&base_migration_plan())
- .await
- .expect("apply plan");
- let pubkey = "1".repeat(PublicKeyHex::HEX_LENGTH);
- let listing = synthetic_event(
- "2",
- "c",
- &pubkey,
- 1_714_125_500,
- 30_402,
- vec![
- Tag::from_parts("d", &["listing-fractional"]).expect("d tag"),
- Tag::from_parts("title", &["Fractional carrots"]).expect("title"),
- Tag::from_parts("price", &["1.234", "USD"]).expect("price"),
- Tag::from_parts("unit", &["lb"]).expect("unit"),
- Tag::from_parts("fulfillment", &["pickup"]).expect("fulfillment"),
- ],
- "fractional listing",
- );
-
- assert_eq!(
- store
- .store_listing_revision(&listing, UnixTimestamp::new(1_714_125_501))
- .await
- .expect("revision"),
- ListingRevisionOutcome::Stored { parsed_ok: true }
- );
- assert_eq!(
- store
- .listing_revision_row(listing.id())
- .await
- .expect("revision row")
- .expect("revision exists")["price_minor"],
- serde_json::Value::Null
- );
- let error = store
- .project_current_listing(&listing, UnixTimestamp::new(1_714_125_501))
- .await
- .expect_err("minor unit error");
- assert_eq!(
- error.message(),
- "listing price amount must fit two decimal minor units"
- );
- }
-
- fn seller_profile(
- created_at: u64,
- name: &str,
- display_name: Option<&str>,
- regions: &[&str],
- categories: &[&str],
- trust_markers: &[&str],
- ) -> Event {
- let mut tags = Vec::new();
- tags.extend(
- regions
- .iter()
- .map(|region| vec!["region".to_owned(), (*region).to_owned()]),
- );
- tags.extend(
- categories
- .iter()
- .map(|category| vec!["category".to_owned(), (*category).to_owned()]),
- );
- tags.extend(
- trust_markers
- .iter()
- .map(|trust| vec!["trust".to_owned(), (*trust).to_owned()]),
- );
- let mut content = serde_json::json!({
- "name": name,
- "about": "Local food seller profile",
- "picture": "https://fixtures.radroots.test/seller.png",
- "website": "https://seller.radroots.test",
- "nip05": "seller@radroots.test",
- "lud16": "seller@pay.radroots.test"
- });
- if let Some(display_name) = display_name {
- content["display_name"] = serde_json::Value::String(display_name.to_owned());
- }
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- u64::from(NIP01_METADATA_KIND),
- tags,
- &content.to_string(),
- )
- .expect("seller profile")
- }
-
- fn listing_comment(listing: &Event, created_at: u64, content: &str) -> Event {
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- build_fixture_event_from_parts(
- FixtureKey::Buyer,
- created_at,
- 1_111,
- vec![
- vec!["A".to_owned(), listing_key.clone()],
- vec!["K".to_owned(), "30402".to_owned()],
- vec![
- "P".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- vec!["a".to_owned(), listing_key],
- vec!["k".to_owned(), "30402".to_owned()],
- vec![
- "p".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- ],
- content,
- )
- .expect("comment event")
- }
-
- fn listing_reaction(listing: &Event, created_at: u64, content: &str) -> Event {
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- build_fixture_event_from_parts(
- FixtureKey::Buyer,
- created_at,
- 7,
- vec![
- vec![
- "e".to_owned(),
- listing.id().as_str().to_owned(),
- "wss://relay.radroots.test".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- vec![
- "p".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ],
- vec!["a".to_owned(), listing_key],
- vec!["k".to_owned(), "30402".to_owned()],
- ],
- content,
- )
- .expect("reaction event")
- }
-
- fn listing_label(listing: &Event, created_at: u64, labels: &[&str], content: &str) -> Event {
- let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
- let namespace = "com.radroots.moderation";
- let mut tags = vec![
- vec!["L".to_owned(), namespace.to_owned()],
- vec!["e".to_owned(), listing.id().as_str().to_owned()],
- vec!["a".to_owned(), listing_key],
- ];
- tags.extend(
- labels
- .iter()
- .map(|label| vec!["l".to_owned(), (*label).to_owned(), namespace.to_owned()]),
- );
- build_fixture_event_from_parts(
- FixtureKey::Buyer,
- created_at,
- u64::from(NIP32_LABEL_KIND),
- tags,
- content,
- )
- .expect("label event")
- }
-
- fn listing_report(
- listing: &Event,
- created_at: u64,
- profile_report_type: Option<&str>,
- event_report_type: &str,
- content: &str,
- ) -> Event {
- let mut pubkey_tag = vec![
- "p".to_owned(),
- listing.unsigned().pubkey().as_str().to_owned(),
- ];
- if let Some(report_type) = profile_report_type {
- pubkey_tag.push(report_type.to_owned());
- }
- build_fixture_event_from_parts(
- FixtureKey::Buyer,
- created_at,
- u64::from(NIP56_REPORT_KIND),
- vec![
- pubkey_tag,
- vec![
- "e".to_owned(),
- listing.id().as_str().to_owned(),
- event_report_type.to_owned(),
- ],
- vec![
- "server".to_owned(),
- "https://media.radroots.test/report.jpg".to_owned(),
- ],
- ],
- content,
- )
- .expect("report event")
- }
-
- fn long_form_article(
- created_at: u64,
- d: &str,
- title: &str,
- content: &str,
- topics: &[&str],
- ) -> Event {
- let buyer_pubkey = FixtureKey::Buyer.public_key().as_str().to_owned();
- let referenced_address = format!("30023:{buyer_pubkey}:soil-notes");
- let mut tags = vec![
- vec!["d".to_owned(), d.to_owned()],
- vec!["title".to_owned(), title.to_owned()],
- vec![
- "summary".to_owned(),
- "Long-form harvest field notes.".to_owned(),
- ],
- vec![
- "image".to_owned(),
- "https://radroots.test/harvest.jpg".to_owned(),
- ],
- vec!["published_at".to_owned(), "1714125000".to_owned()],
- vec!["e".to_owned(), "4".repeat(EventId::HEX_LENGTH)],
- vec!["a".to_owned(), referenced_address],
- vec!["p".to_owned(), buyer_pubkey],
- ];
- tags.extend(
- topics
- .iter()
- .map(|topic| vec!["t".to_owned(), (*topic).to_owned()]),
- );
- build_fixture_event_from_parts(
- FixtureKey::Seller,
- created_at,
- u64::from(NIP23_LONG_FORM_KIND),
- tags,
- content,
- )
- .expect("long-form article")
- }
-
- fn forum_thread(created_at: u64, title: Option<&str>, topics: &[&str]) -> Event {
- let mut tags = vec![
- vec!["e".to_owned(), "5".repeat(EventId::HEX_LENGTH)],
- vec![
- "p".to_owned(),
- FixtureKey::Seller.public_key().as_str().to_owned(),
- ],
- ];
- if let Some(title) = title {
- tags.push(vec!["title".to_owned(), title.to_owned()]);
- }
- tags.extend(
- topics
- .iter()
- .map(|topic| vec!["t".to_owned(), (*topic).to_owned()]),
- );
- build_fixture_event_from_parts(
- FixtureKey::Buyer,
- created_at,
- u64::from(NIP7D_THREAD_KIND),
- tags,
- "What is everyone bringing this weekend?",
- )
- .expect("forum thread")
- }
-
- fn synthetic_event(
- id_digit: &str,
- sig_digit: &str,
- pubkey: &str,
- created_at: u64,
- kind: u64,
- tags: Vec<Tag>,
- content: &str,
- ) -> Event {
- Event::new(
- EventId::new(&id_digit.repeat(EventId::HEX_LENGTH)).expect("id"),
- UnsignedEvent::new(
- PublicKeyHex::new(pubkey).expect("pubkey"),
- UnixTimestamp::new(created_at),
- Kind::new(kind).expect("kind"),
- tags,
- content,
- ),
- SignatureHex::new(&sig_digit.repeat(SignatureHex::HEX_LENGTH)).expect("sig"),
- )
- }
-}
diff --git a/crates/tangle_test_support/Cargo.toml b/crates/tangle_test_support/Cargo.toml
@@ -9,13 +9,10 @@ description = "Deterministic fixtures and event builders for tangle tests"
[dependencies]
k256 = { version = "0.13", features = ["schnorr"] }
-serde = { version = "1", features = ["derive"] }
serde_json = "1"
tangle_crypto = { path = "../tangle_crypto" }
tangle_groups = { path = "../tangle_groups" }
-tangle_nips = { path = "../tangle_nips" }
tangle_protocol = { path = "../tangle_protocol" }
-tangle_store = { path = "../tangle_store" }
[lints]
workspace = true
diff --git a/crates/tangle_test_support/src/lib.rs b/crates/tangle_test_support/src/lib.rs
@@ -3,8 +3,6 @@
use core::fmt;
use k256::schnorr::signature::Signer;
use k256::schnorr::{Signature, SigningKey};
-use serde::Deserialize;
-use std::collections::BTreeMap;
use tangle_crypto::{RelaySigner, compute_event_id};
use tangle_groups::{
CanonicalRelayUrl, GroupGeneratedEventBuilder, GroupLimitsConfig, GroupOutboxPayload,
@@ -12,29 +10,13 @@ use tangle_groups::{
KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, KIND_GROUP_JOIN_REQUEST,
KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, RelaySecret,
};
-use tangle_nips::ListingProjection;
use tangle_protocol::{
- AddressCoordinate, Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp,
- UnsignedEvent, event_to_value,
+ Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent,
+ event_to_value,
};
-use tangle_store::{
- DeletionMarker, DeletionMarkerRepository, ListingProjectionRepository, RawEventRepository,
- RepositoryError, StoreEventOutcome, StoreProjectionOutcome, StoredEvent,
-};
-
-const VALID_PUBLIC_LISTING_JSON: &str =
- include_str!("../../../testing/fixtures/canonical/nostr/valid_public_listing.json");
-const PROJECTION_INELIGIBLE_LISTING_JSON: &str =
- include_str!("../../../testing/fixtures/canonical/nostr/projection_ineligible_listing.json");
-const AUTH_EVENT_JSON: &str =
- include_str!("../../../testing/fixtures/canonical/nostr/auth_event.json");
-const DELETION_EVENT_JSON: &str =
- include_str!("../../../testing/fixtures/canonical/nostr/deletion_event.json");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FixtureKey {
- Seller,
- Buyer,
Relay,
Owner,
Admin,
@@ -51,8 +33,6 @@ impl FixtureKey {
fn signing_key(self) -> SigningKey {
match self {
- Self::Seller => SigningKey::from_bytes(&[7_u8; 32]).expect("seller fixture key"),
- Self::Buyer => SigningKey::from_bytes(&[8_u8; 32]).expect("buyer fixture key"),
Self::Relay => SigningKey::from_bytes(&[9_u8; 32]).expect("relay fixture key"),
Self::Owner => SigningKey::from_bytes(&[10_u8; 32]).expect("owner fixture key"),
Self::Admin => SigningKey::from_bytes(&[11_u8; 32]).expect("admin fixture key"),
@@ -65,8 +45,6 @@ impl FixtureKey {
impl fmt::Display for FixtureKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
- Self::Seller => "seller",
- Self::Buyer => "buyer",
Self::Relay => "relay",
Self::Owner => "owner",
Self::Admin => "admin",
@@ -76,87 +54,6 @@ impl fmt::Display for FixtureKey {
}
}
-#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
-pub struct FixtureEventSpec {
- name: String,
- key: String,
- created_at: u64,
- kind: u64,
- tags: Vec<Vec<String>>,
- content: String,
-}
-
-impl FixtureEventSpec {
- pub fn name(&self) -> &str {
- &self.name
- }
-
- pub fn key(&self) -> &str {
- &self.key
- }
-
- pub fn created_at(&self) -> u64 {
- self.created_at
- }
-
- pub fn kind(&self) -> u64 {
- self.kind
- }
-
- pub fn tags(&self) -> &[Vec<String>] {
- &self.tags
- }
-
- pub fn content(&self) -> &str {
- &self.content
- }
-
- pub fn fixture_key(&self) -> Result<FixtureKey, String> {
- match self.key.as_str() {
- "seller" => Ok(FixtureKey::Seller),
- "buyer" => Ok(FixtureKey::Buyer),
- "relay" => Ok(FixtureKey::Relay),
- "owner" => Ok(FixtureKey::Owner),
- "admin" => Ok(FixtureKey::Admin),
- "member" => Ok(FixtureKey::Member),
- "outsider" => Ok(FixtureKey::Outsider),
- value => Err(format!("fixture key `{value}` is unsupported")),
- }
- }
-}
-
-pub fn valid_public_listing_spec() -> FixtureEventSpec {
- fixture_spec_from_json(VALID_PUBLIC_LISTING_JSON).expect("valid listing fixture parses")
-}
-
-pub fn projection_ineligible_listing_spec() -> FixtureEventSpec {
- fixture_spec_from_json(PROJECTION_INELIGIBLE_LISTING_JSON)
- .expect("projection-ineligible listing fixture parses")
-}
-
-pub fn auth_event_spec() -> FixtureEventSpec {
- fixture_spec_from_json(AUTH_EVENT_JSON).expect("auth event fixture parses")
-}
-
-pub fn deletion_event_spec() -> FixtureEventSpec {
- fixture_spec_from_json(DELETION_EVENT_JSON).expect("deletion event fixture parses")
-}
-
-pub fn fixture_spec_from_json(raw: &str) -> Result<FixtureEventSpec, String> {
- serde_json::from_str(raw).map_err(|source| format!("fixture JSON is invalid: {source}"))
-}
-
-pub fn build_fixture_event(spec: &FixtureEventSpec) -> Result<Event, String> {
- let fixture_key = spec.fixture_key()?;
- build_fixture_event_from_parts(
- fixture_key,
- spec.created_at,
- spec.kind,
- spec.tags.clone(),
- &spec.content,
- )
-}
-
pub fn build_fixture_event_from_parts(
fixture_key: FixtureKey,
created_at: u64,
@@ -425,69 +322,6 @@ pub fn fixture_event_json(event: &Event) -> serde_json::Value {
event_to_value(event)
}
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct InMemoryRepository {
- events: BTreeMap<EventId, StoredEvent>,
- listing_projections: BTreeMap<AddressCoordinate, ListingProjection>,
- deletion_markers: Vec<DeletionMarker>,
-}
-
-impl InMemoryRepository {
- pub fn new() -> Self {
- Self::default()
- }
-}
-
-impl RawEventRepository for InMemoryRepository {
- fn put_event(&mut self, record: StoredEvent) -> Result<StoreEventOutcome, RepositoryError> {
- let event_id = record.event().id().clone();
- if self.events.contains_key(&event_id) {
- return Ok(StoreEventOutcome::Duplicate);
- }
- self.events.insert(event_id, record);
- Ok(StoreEventOutcome::Inserted)
- }
-
- fn event_by_id(&self, event_id: &EventId) -> Result<Option<StoredEvent>, RepositoryError> {
- Ok(self.events.get(event_id).cloned())
- }
-
- fn events(&self) -> Result<Vec<StoredEvent>, RepositoryError> {
- Ok(self.events.values().cloned().collect())
- }
-}
-
-impl ListingProjectionRepository for InMemoryRepository {
- fn put_listing_projection(
- &mut self,
- projection: ListingProjection,
- ) -> Result<StoreProjectionOutcome, RepositoryError> {
- let address = projection.identity().address().clone();
- match self.listing_projections.insert(address, projection) {
- Some(_) => Ok(StoreProjectionOutcome::Replaced),
- None => Ok(StoreProjectionOutcome::Inserted),
- }
- }
-
- fn listing_projection(
- &self,
- address: &AddressCoordinate,
- ) -> Result<Option<ListingProjection>, RepositoryError> {
- Ok(self.listing_projections.get(address).cloned())
- }
-}
-
-impl DeletionMarkerRepository for InMemoryRepository {
- fn put_deletion_marker(&mut self, marker: DeletionMarker) -> Result<(), RepositoryError> {
- self.deletion_markers.push(marker);
- Ok(())
- }
-
- fn deletion_markers(&self) -> Result<Vec<DeletionMarker>, RepositoryError> {
- Ok(self.deletion_markers.clone())
- }
-}
-
fn sign_unsigned_event(fixture_key: FixtureKey, unsigned: UnsignedEvent) -> Result<Event, String> {
let signing_key = fixture_key.signing_key();
let event_id = compute_event_id(&unsigned);
@@ -534,60 +368,29 @@ fn lower_hex(bytes: &[u8]) -> String {
#[cfg(test)]
mod tests {
use super::{
- FixtureKey, InMemoryRepository, auth_event_spec, build_fixture_event, deletion_event_spec,
- fixed_hex_bytes, fixture_event_json, fixture_spec_from_json,
- projection_ineligible_listing_spec, tangle_v2_auth_event, tangle_v2_generated_event,
- tangle_v2_group_config, tangle_v2_group_create_event, tangle_v2_group_event,
- tangle_v2_group_metadata_event, tangle_v2_join_event, tangle_v2_put_user_event,
- valid_public_listing_spec,
+ FixtureKey, build_fixture_event_from_parts, fixed_hex_bytes, fixture_event_json,
+ tangle_v2_auth_event, tangle_v2_generated_event, tangle_v2_group_config,
+ tangle_v2_group_create_event, tangle_v2_group_event, tangle_v2_group_metadata_event,
+ tangle_v2_join_event, tangle_v2_put_user_event,
};
use tangle_crypto::{event_id_matches, verify_event_signature};
use tangle_groups::{GroupOutboxPayload, KIND_GROUP_CREATE_GROUP, KIND_GROUP_METADATA};
- use tangle_nips::{
- DeletionTarget, ListingProjectionEvaluation, evaluate_listing_projection,
- parse_deletion_request, parse_relay_auth_event,
- };
use tangle_protocol::UnixTimestamp;
- use tangle_protocol::{EventId, PublicKeyHex};
- use tangle_store::{
- DeletionMarker, DeletionMarkerRepository, ListingProjectionRepository, RawEventRepository,
- StoreEventOutcome, StoreProjectionOutcome, StoredEvent,
- };
-
- #[test]
- fn fixture_specs_load_from_canonical_json() {
- let listing = valid_public_listing_spec();
- let ineligible = projection_ineligible_listing_spec();
- let auth = auth_event_spec();
- let deletion = deletion_event_spec();
-
- assert_eq!(listing.name(), "valid_public_listing");
- assert_eq!(listing.key(), "seller");
- assert_eq!(listing.created_at(), 1_714_124_433);
- assert_eq!(listing.kind(), 30_402);
- assert_eq!(listing.content(), "Sweet storage carrots.");
- assert_eq!(listing.tags().len(), 10);
- assert_eq!(ineligible.name(), "projection_ineligible_listing");
- assert_eq!(auth.name(), "auth_event");
- assert_eq!(deletion.name(), "deletion_event");
- }
#[test]
fn fixture_keys_have_stable_synthetic_public_keys() {
- assert_eq!(FixtureKey::Seller.to_string(), "seller");
- assert_eq!(FixtureKey::Buyer.to_string(), "buyer");
assert_eq!(FixtureKey::Relay.to_string(), "relay");
assert_eq!(FixtureKey::Owner.to_string(), "owner");
assert_eq!(FixtureKey::Admin.to_string(), "admin");
assert_eq!(FixtureKey::Member.to_string(), "member");
assert_eq!(FixtureKey::Outsider.to_string(), "outsider");
assert_eq!(
- FixtureKey::Seller.public_key().as_str(),
- "989c0b76cb563971fdc9bef31ec06c3560f3249d6ee9e5d83c57625596e05f6f"
+ FixtureKey::Owner.public_key().as_str(),
+ "f76a39d05686e34a4420897e359371836145dd3973e3982568b60f8433adde6e"
);
assert_ne!(
- FixtureKey::Seller.public_key(),
- FixtureKey::Buyer.public_key()
+ FixtureKey::Owner.public_key(),
+ FixtureKey::Admin.public_key()
);
}
@@ -605,14 +408,9 @@ mod tests {
assert_eq!(first.id(), second.id());
assert_eq!(verify_event_signature(&first), Ok(()));
assert_eq!(verify_event_signature(&auth), Ok(()));
+ assert!(event_id_matches(&first));
assert_eq!(first.unsigned().kind().as_u32(), KIND_GROUP_CREATE_GROUP);
- assert_eq!(
- parse_relay_auth_event(&auth)
- .expect("auth parse")
- .expect("auth")
- .challenge(),
- "challenge-001"
- );
+ assert_eq!(auth.unsigned().kind().as_u32(), 22_242);
}
#[test]
@@ -653,214 +451,39 @@ mod tests {
}
#[test]
- fn fixture_builder_signs_verifiable_public_listing_events() {
- let event = build_fixture_event(&valid_public_listing_spec()).expect("event");
+ fn fixture_json_uses_signed_event_shape() {
+ let event =
+ build_fixture_event_from_parts(FixtureKey::Member, 1_714_124_440, 1, Vec::new(), "hi")
+ .expect("event");
let json = fixture_event_json(&event);
- assert!(event_id_matches(&event));
- assert_eq!(verify_event_signature(&event), Ok(()));
- assert_eq!(json["kind"], 30_402);
- assert_eq!(json["content"], "Sweet storage carrots.");
- assert_eq!(
- evaluate_listing_projection(&event),
- ListingProjectionEvaluation::Eligible(Box::new(
- evaluate_listing_projection(&event)
- .projection()
- .expect("projection")
- .clone()
- ))
- );
- }
-
- #[test]
- fn projection_ineligible_fixture_is_signed_but_not_projectable() {
- let event = build_fixture_event(&projection_ineligible_listing_spec()).expect("event");
- let evaluation = evaluate_listing_projection(&event);
-
assert_eq!(verify_event_signature(&event), Ok(()));
- assert_eq!(
- evaluation.rejection().expect("rejection").reasons(),
- &["tag `title` is required".to_owned()]
- );
- }
-
- #[test]
- fn auth_and_deletion_fixtures_build_protocol_specific_events() {
- let auth = build_fixture_event(&auth_event_spec()).expect("auth");
- let deletion = build_fixture_event(&deletion_event_spec()).expect("deletion");
- let auth = parse_relay_auth_event(&auth)
- .expect("auth parse")
- .expect("auth event");
- let deletion = parse_deletion_request(&deletion)
- .expect("deletion parse")
- .expect("deletion event");
- let target = EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("target");
-
- assert_eq!(auth.relay(), "wss://relay.radroots.test");
- assert_eq!(auth.challenge(), "challenge-001");
- assert_eq!(deletion.targets(), &[DeletionTarget::Event(target)]);
- }
-
- #[test]
- fn fixture_spec_parser_rejects_invalid_json_and_keys() {
- let invalid = fixture_spec_from_json("{").expect_err("json");
- let unsupported = fixture_spec_from_json(
- r#"{"name":"bad","key":"unknown","created_at":1,"kind":1,"tags":[],"content":""}"#,
- )
- .expect("fixture");
-
- assert!(invalid.starts_with("fixture JSON is invalid"));
- assert_eq!(
- unsupported.fixture_key().expect_err("key"),
- "fixture key `unknown` is unsupported"
- );
- assert_eq!(
- FixtureKey::Buyer,
- fixture_spec_from_json(
- r#"{"name":"buyer","key":"buyer","created_at":1,"kind":1,"tags":[],"content":""}"#,
- )
- .expect("buyer")
- .fixture_key()
- .expect("buyer")
- );
- assert_eq!(
- FixtureKey::Relay,
- fixture_spec_from_json(
- r#"{"name":"relay","key":"relay","created_at":1,"kind":1,"tags":[],"content":""}"#,
- )
- .expect("relay")
- .fixture_key()
- .expect("relay")
- );
- }
-
- #[test]
- fn fixture_builder_rejects_malformed_fixture_shapes() {
- let bad_key = fixture_spec_from_json(
- r#"{"name":"bad","key":"unknown","created_at":1,"kind":1,"tags":[],"content":""}"#,
- )
- .expect("bad key");
- let bad_tag = fixture_spec_from_json(
- r#"{"name":"bad","key":"seller","created_at":1,"kind":1,"tags":[[]],"content":""}"#,
- )
- .expect("bad tag");
- let bad_kind = fixture_spec_from_json(
- r#"{"name":"bad","key":"seller","created_at":1,"kind":4294967296,"tags":[],"content":""}"#,
- )
- .expect("bad kind");
-
- assert_eq!(
- build_fixture_event(&bad_tag).expect_err("tag"),
- "tag must not be empty"
- );
- assert_eq!(
- build_fixture_event(&bad_kind).expect_err("kind"),
- "kind must fit in u32, got 4294967296"
- );
- assert_eq!(
- PublicKeyHex::HEX_LENGTH,
- FixtureKey::Relay.public_key().as_str().len()
- );
- assert_eq!(
- build_fixture_event(&bad_key).expect_err("key"),
- "fixture key `unknown` is unsupported"
- );
+ assert_eq!(json["kind"], 1);
+ assert_eq!(json["content"], "hi");
}
#[test]
- fn fixed_hex_decoder_rejects_bad_lengths_and_characters() {
- assert_eq!(
- fixed_hex_bytes("abc", 2, "fixture").expect_err("length"),
- "fixture must decode to 2 bytes, got 3 hex characters"
- );
- assert_eq!(
- fixed_hex_bytes("0G", 1, "fixture").expect_err("low"),
- "fixture must be lowercase hex"
- );
- assert_eq!(
- fixed_hex_bytes("G0", 1, "fixture").expect_err("high"),
- "fixture must be lowercase hex"
- );
- }
-
- #[test]
- fn in_memory_repository_stores_raw_events_by_id_and_preserves_first_insert() {
- let mut repository = InMemoryRepository::new();
- let event = build_fixture_event(&valid_public_listing_spec()).expect("event");
- let stored = StoredEvent::new(event.clone(), UnixTimestamp::new(100));
- let duplicate = StoredEvent::new(event.clone(), UnixTimestamp::new(200));
- let missing = EventId::new(&"f".repeat(EventId::HEX_LENGTH)).expect("missing");
+ fn fixture_builder_rejects_invalid_parts() {
+ let bad_tag = build_fixture_event_from_parts(FixtureKey::Owner, 1, 1, vec![Vec::new()], "")
+ .expect_err("tag");
+ let bad_kind =
+ build_fixture_event_from_parts(FixtureKey::Owner, 1, 4_294_967_296, Vec::new(), "")
+ .expect_err("kind");
- assert_eq!(
- repository.put_event(stored.clone()).expect("insert"),
- StoreEventOutcome::Inserted
- );
- assert_eq!(
- repository.put_event(duplicate).expect("duplicate"),
- StoreEventOutcome::Duplicate
- );
- assert_eq!(
- repository.event_by_id(event.id()).expect("lookup"),
- Some(stored.clone())
- );
- assert_eq!(repository.event_by_id(&missing).expect("missing"), None);
- assert_eq!(repository.events().expect("events"), vec![stored]);
+ assert_eq!(bad_tag, "tag must not be empty");
+ assert_eq!(bad_kind, "kind must fit in u32, got 4294967296");
}
#[test]
- fn in_memory_repository_stores_listing_projections_by_address() {
- let mut repository = InMemoryRepository::new();
- let event = build_fixture_event(&valid_public_listing_spec()).expect("event");
- let projection = evaluate_listing_projection(&event)
- .projection()
- .expect("projection")
- .clone();
- let address = projection.identity().address().clone();
-
- assert_eq!(
- repository
- .put_listing_projection(projection.clone())
- .expect("insert"),
- StoreProjectionOutcome::Inserted
- );
+ fn fixed_hex_bytes_validates_expected_width_and_lowercase() {
+ assert_eq!(fixed_hex_bytes("0a", 1, "value"), Ok(vec![10]));
assert_eq!(
- repository
- .listing_projection(&address)
- .expect("projection lookup"),
- Some(projection.clone())
+ fixed_hex_bytes("0a", 2, "value").expect_err("width"),
+ "value must decode to 2 bytes, got 2 hex characters"
);
assert_eq!(
- repository
- .put_listing_projection(projection)
- .expect("replace"),
- StoreProjectionOutcome::Replaced
- );
- }
-
- #[test]
- fn in_memory_repository_appends_deletion_markers() {
- let mut repository = InMemoryRepository::new();
- let deletion = build_fixture_event(&deletion_event_spec()).expect("deletion");
- let request = parse_deletion_request(&deletion)
- .expect("deletion parse")
- .expect("deletion request");
- let marker = DeletionMarker::new(
- deletion.id().clone(),
- deletion.unsigned().pubkey().clone(),
- request.targets()[0].clone(),
- UnixTimestamp::new(300),
- );
-
- repository
- .put_deletion_marker(marker.clone())
- .expect("marker");
- repository
- .put_deletion_marker(marker.clone())
- .expect("marker again");
-
- assert_eq!(
- repository.deletion_markers().expect("markers"),
- vec![marker.clone(), marker]
+ fixed_hex_bytes("0G", 1, "value").expect_err("case"),
+ "value must be lowercase hex"
);
}
}
diff --git a/scripts/local-surrealdb-down.sh b/scripts/local-surrealdb-down.sh
@@ -1,4 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-docker compose -f ops/local/surrealdb/compose.yml down
diff --git a/scripts/local-surrealdb-up.sh b/scripts/local-surrealdb-up.sh
@@ -1,4 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-docker compose -f ops/local/surrealdb/compose.yml up -d --wait
diff --git a/scripts/release_acceptance.sh b/scripts/release_acceptance.sh
@@ -4,17 +4,9 @@ set -euo pipefail
scripts/check.sh
scripts/test.sh
cargo nextest run --workspace
-cargo test -p tangle --test nip01_conformance
-cargo test -p tangle --test nip09_conformance
-cargo test -p tangle --test nip42_conformance
-cargo test -p tangle --test nip50_conformance
-cargo test -p tangle --test nip99_conformance
-cargo test -p tangle --test discussion_conformance
-cargo test -p tangle --test moderation_conformance
-cargo test -p tangle --test commerce_privacy_conformance
-cargo test -p tangle --test abuse_conformance
-cargo test -p tangle --test run_integration
-cargo test -p tangle_runtime runtime_restore_command_imports_backup_and_rebuilds_projection_state
+cargo test -p tangle_runtime --test base_relay_v2
+cargo test -p tangle_groups
+cargo test -p tangle_store_pocket
cargo test -p tangle_bench
scripts/benchmark_report.sh
cargo test -p tangle --test source_comments
diff --git a/testing/fixtures/canonical/nostr/auth_event.json b/testing/fixtures/canonical/nostr/auth_event.json
@@ -1,11 +0,0 @@
-{
- "name": "auth_event",
- "key": "seller",
- "created_at": 1714124435,
- "kind": 22242,
- "tags": [
- ["relay", "wss://relay.radroots.test"],
- ["challenge", "challenge-001"]
- ],
- "content": ""
-}
diff --git a/testing/fixtures/canonical/nostr/deletion_event.json b/testing/fixtures/canonical/nostr/deletion_event.json
@@ -1,10 +0,0 @@
-{
- "name": "deletion_event",
- "key": "seller",
- "created_at": 1714124436,
- "kind": 5,
- "tags": [
- ["e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
- ],
- "content": ""
-}
diff --git a/testing/fixtures/canonical/nostr/projection_ineligible_listing.json b/testing/fixtures/canonical/nostr/projection_ineligible_listing.json
@@ -1,13 +0,0 @@
-{
- "name": "projection_ineligible_listing",
- "key": "seller",
- "created_at": 1714124434,
- "kind": 30402,
- "tags": [
- ["d", "listing-missing-title"],
- ["price", "8.00", "USD"],
- ["unit", "each"],
- ["fulfillment", "pickup"]
- ],
- "content": "This event is valid Nostr data but not projection eligible."
-}
diff --git a/testing/fixtures/canonical/nostr/valid_public_listing.json b/testing/fixtures/canonical/nostr/valid_public_listing.json
@@ -1,19 +0,0 @@
-{
- "name": "valid_public_listing",
- "key": "seller",
- "created_at": 1714124433,
- "kind": 30402,
- "tags": [
- ["d", "listing-a"],
- ["title", "Carrot bunches"],
- ["price", "12.50", "USD"],
- ["unit", "lb"],
- ["fulfillment", "pickup"],
- ["g", "c22yzug"],
- ["category", "vegetables"],
- ["t", "carrots"],
- ["practice", "no spray"],
- ["certification", "organic"]
- ],
- "content": "Sweet storage carrots."
-}