From e21aa4dd70aafc6880c67cc80cbd1e98ad07eb2c Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Thu, 15 Jan 2026 11:07:02 +0000 Subject: [PATCH] First version working --- .gitignore | 7 +++++++ Cargo.lock | 1855 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 29 +++++++++++++++++++++++++++++ README.md | 254 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ examples/standard_site_sync.rs | 295 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/channel.rs | 209 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/client.rs | 362 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 444 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/error.rs | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/handle.rs | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/process.rs | 236 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/types.rs | 601 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/integration.rs | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 14 file(s) changed, 4789 insertion(s)(+), 0 deletion(s)(-) diff --git a/.gitignore b/.gitignore new file mode 100644 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +tap +cache.json +documents.txt +publications.txt +target +tap-example* +test.db* diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1855 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" + +[[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 = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +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 = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +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 = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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 = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[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", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +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 = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tapped" +version = "0.1.0" +dependencies = [ + "base64", + "env_logger", + "futures-util", + "libc", + "log", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "url", + "which", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +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.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[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 = [ + "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", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[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.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "tapped" +version = "0.1.0" +edition = "2021" +description = "Rust wrapper for the tap ATProto utility" +license = "MIT" +readme = "README.md" +authors = ["Thomas Karpiniec "] +keywords = ["atproto", "tap"] +repository = "https://github.com/thombles/tapped" + +[dependencies] +tokio = { version = "1", features = ["net", "process", "rt", "sync", "time"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +url = { version = "2", features = ["serde"] } +futures-util = "0.3" +tracing = "0.1" +base64 = "0.22" +libc = "0.2" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +which = "7" +env_logger = "0.11" +log = "0.4" diff --git a/README.md b/README.md new file mode 100644 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# tapped + +A Rust wrapper library for the [`tap`](https://github.com/bluesky-social/indigo/tree/main/cmd/tap) ATProto sync utility. + +`tapped` provides an idiomatic async Rust interface for spawning and communicating with a `tap` subprocess, making it easy to build applications that sync data from the ATProto network. + +## Features + +- Spawn and manage `tap` subprocesses with graceful shutdown +- Strongly-typed configuration for all tap envvars +- Strongly-typed async Rust functions covering all of tap's HTTP API endpoints +- WebSocket-based event channel with automatic acknowledgment + +## Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +tapped = "0.1" +``` + +You'll also need the `tap` binary. Build it from the [indigo repository](https://github.com/bluesky-social/indigo): + +```bash +cd cmd/tap && go build +``` + +`tapped` has been most recently tested against: + +``` +tap version v0.0.0-20260114211028-207c9d49d0de-rev-207c9d4 +``` + +## Quick Start + +```rust +use tapped::{TapHandle, TapConfig, Event}; + +#[tokio::main] +async fn main() -> tapped::Result<()> { + let config = TapConfig::builder() + .database_url("sqlite://tap.db") + .collection_filter("app.bsky.feed.post") + .build(); + + // Spawn tap and connect + let handle = TapHandle::spawn_default(config).await?; + + // Subscribe to events + let mut channel = handle.channel().await?; + + while let Ok(received) = channel.recv().await { + match &received.event { + Event::Record(record) => { + println!("[{}] {}/{}", + record.action, + record.collection, + record.rkey + ); + } + Event::Identity(identity) => { + println!("Identity: {} -> {}", identity.did, identity.handle); + } + } + // Event is auto-acknowledged when `received` is dropped + } + + Ok(()) +} +``` + +## Usage Patterns + +### Connect to Existing Instance + +If you have a tap instance already running: + +```rust +use tapped::TapClient; + +let client = TapClient::new("http://localhost:2480")?; +client.health().await?; +``` + +### Spawn with Custom Binary Path + +```rust +use tapped::{TapProcess, TapConfig}; + +let config = TapConfig::builder() + .database_url("sqlite://my-app.db") + .build(); + +let mut process = TapProcess::spawn("/path/to/tap", config).await?; +let client = process.client(); + +// Use the client... + +process.shutdown().await?; +``` + +### Using TapHandle (Recommended) + +`TapHandle` combines process management and client access: + +```rust +use tapped::{TapHandle, TapConfig}; + +let config = TapConfig::builder() + .database_url("sqlite://app.db") + .full_network(false) + .build(); + +let handle = TapHandle::spawn_default(config).await?; + +// TapHandle derefs to TapClient, so you can call client methods directly +handle.health().await?; +let count = handle.repo_count().await?; +println!("Tracking {} repos", count); +``` + +### Configuration Options + +```rust +use tapped::{TapConfig, LogLevel}; +use std::time::Duration; + +let config = TapConfig::builder() + // Database + .database_url("sqlite://tap.db") + .max_db_conns(10) + + // Network + .bind("127.0.0.1:2480") + .relay_url("wss://bsky.network") + .plc_url("https://plc.directory") + + // Filtering + .signal_collection("app.bsky.feed.post") + .collection_filter("app.bsky.feed.post") + .collection_filter("app.bsky.feed.like") + .full_network(false) + + // Performance + .firehose_parallelism(10) + .resync_parallelism(5) + .outbox_parallelism(10) + .outbox_capacity(10000) + + // Timeouts + .repo_fetch_timeout(Duration::from_secs(30)) + .startup_timeout(Duration::from_secs(60)) + .shutdown_timeout(Duration::from_secs(10)) + + // Logging + .log_level(LogLevel::Info) + + .build(); +``` + +### Working with Events + +Events are automatically acknowledged when dropped: + +```rust +use tapped::{Event, RecordAction}; + +let mut channel = client.channel().await?; + +while let Ok(received) = channel.recv().await { + match &received.event { + Event::Record(record) => { + match record.action { + RecordAction::Create => { + if let Some(ref rec) = record.record { + // Access the raw JSON + println!("Type: {:?}", rec.record_type()); + + // Or deserialize to a specific type + // let post: MyPostType = rec.deserialize_as()?; + } + } + RecordAction::Update => { /* ... */ } + RecordAction::Delete => { /* ... */ } + _ => {} + } + } + Event::Identity(identity) => { + println!("{} is now @{}", identity.did, identity.handle); + } + } + // Ack sent automatically here when `received` goes out of scope +} +``` + +### Managing Repositories + +```rust +// Add repos to track +client.add_repos(&["did:plc:abc123", "did:plc:def456"]).await?; + +// Remove repos +client.remove_repos(&["did:plc:abc123"]).await?; + +// Get info about a specific repo +let info = client.repo_info("did:plc:def456").await?; +println!("State: {:?}, Records: {}", info.state, info.records); + +// Resolve a DID to its document +let doc = client.resolve_did("did:plc:def456").await?; +println!("Handles: {:?}", doc.also_known_as); +``` + +### Checking Stats + +```rust +let repos = client.repo_count().await?; +let records = client.record_count().await?; +let outbox = client.outbox_buffer().await?; +let resync = client.resync_buffer().await?; +let cursors = client.cursors().await?; + +println!("Tracking {} repos with {} records", repos, records); +println!("Outbox buffer: {}, Resync buffer: {}", outbox, resync); +println!("Firehose cursor: {:?}", cursors.firehose); +``` + +## Error Handling + +All operations return `tapped::Result`, which uses the `tapped::Error` enum: + +```rust +use tapped::Error; + +match client.health().await { + Ok(()) => println!("Healthy!"), + Err(Error::Http(e)) => println!("HTTP error: {}", e), + Err(Error::Timeout) => println!("Request timed out"), + Err(e) => println!("Other error: {}", e), +} +``` + +## Example: Syncing Standard Site Records + +See [examples/standard_site_sync.rs](examples/standard_site_sync.rs) for a complete example that syncs `site.standard.publication` and `site.standard.document` records to local files. + +```bash +cargo run --example standard_site_sync +``` + +## License + +MIT diff --git a/examples/standard_site_sync.rs b/examples/standard_site_sync.rs new file mode 100644 --- /dev/null +++ b/examples/standard_site_sync.rs @@ -0,0 +1,295 @@ +//! Example: Sync site.standard.publication and site.standard.document records. +//! +//! This example demonstrates using tapped to track publication and document +//! records from the ATProto network. It maintains an on-disk cache and +//! writes URL lists to disk on each change for inspection. +//! +//! Run with: `RUST_LOG=info cargo run --example standard_site_sync` + +use std::collections::HashMap; +use std::fs; + +use log::info; +use serde::{Deserialize, Serialize}; +use tapped::{Event, RecordAction, RecordEvent, TapConfig, TapProcess}; + +/// A site.standard.publication record (subset of fields). +#[derive(Debug, Deserialize)] +struct Publication { + url: String, + name: String, +} + +/// A site.standard.document record (subset of fields). +#[derive(Debug, Deserialize)] +struct Document { + site: String, + title: String, + #[serde(default)] + path: Option, +} + +/// Key for storing records in cache. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct RecordKey { + did: String, + collection: String, + rkey: String, +} + +impl RecordKey { + fn new(event: &RecordEvent) -> Self { + Self { + did: event.did.clone(), + collection: event.collection.clone(), + rkey: event.rkey.clone(), + } + } +} + +/// Cached record data. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +enum CachedRecord { + Publication { url: String }, + Document { site: String, path: Option }, +} + +fn main() -> Result<(), Box> { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(async_main()) +} + +async fn async_main() -> Result<(), Box> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_secs() + .init(); + + info!("Starting standard.site sync example"); + + let config = TapConfig::builder() + .database_url("sqlite://./tap-example.db") + .bind(":2480") + .signal_collection("site.standard.publication") + .collection_filters(vec![ + "site.standard.publication".to_string(), + "site.standard.document".to_string(), + ]) + .disable_acks(false) + .inherit_stdio(true) + .build(); + + info!("Spawning tap process..."); + + // Spawn tap (looks for ./tap first, then tap on PATH) + let process = TapProcess::spawn_default(config).await?; + info!("Tap running at {}", process.url()); + + let client = process.client()?; + client.health().await?; + info!("Tap is healthy!"); + + let mut receiver = client.channel().await?; + info!("Connected! Waiting for events..."); + + // In-memory cache - load from disk if available + let mut cache: HashMap = load_cache_from_disk(); + let (pub_count, doc_count) = count_cached(&cache); + info!( + "Loaded cache from disk: {} publications, {} documents", + pub_count, doc_count + ); + + let mut live_count = 0u64; + let mut backfill_count = 0u64; + + loop { + match receiver.recv().await { + Ok(received) => { + if let Event::Record(ref record_event) = *received { + // Track live vs backfill + if record_event.live { + live_count += 1; + } else { + backfill_count += 1; + } + + process_record_event(record_event, &mut cache); + write_output_files(&cache)?; + + // Periodically show event source breakdown + if (live_count + backfill_count).is_multiple_of(100) { + info!( + "[Stats] Live events: {}, Backfill events: {}", + live_count, backfill_count + ); + } + } else if let Event::Identity(ref identity_event) = *received { + info!( + "[IDENTITY] {} -> {} (active: {})", + identity_event.did, identity_event.handle, identity_event.is_active + ); + } + // Event is automatically acked when `received` is dropped here + } + Err(e) => { + eprintln!("Error receiving event: {}", e); + break; + } + } + } + + Ok(()) +} + +fn process_record_event(event: &RecordEvent, cache: &mut HashMap) { + let key = RecordKey::new(event); + let action_str = match event.action { + RecordAction::Create => "CREATE", + RecordAction::Update => "UPDATE", + RecordAction::Delete => "DELETE", + _ => "UNKNOWN", + }; + + // Show whether this is a live or backfill event + let source = if event.live { "LIVE" } else { "BACKFILL" }; + + info!( + "[{} {}] {} {}/{}", + action_str, source, event.did, event.collection, event.rkey + ); + + match event.action { + RecordAction::Create | RecordAction::Update => { + if let Some(ref record) = event.record { + match event.collection.as_str() { + "site.standard.publication" => match record.deserialize_as::() { + Ok(pub_record) => { + info!("Publication: {} ({})", pub_record.name, pub_record.url); + cache.insert( + key, + CachedRecord::Publication { + url: pub_record.url, + }, + ); + } + Err(e) => { + log::error!("Failed to parse publication: {}", e); + } + }, + "site.standard.document" => match record.deserialize_as::() { + Ok(doc_record) => { + let full_url = match &doc_record.path { + Some(path) if !path.is_empty() => { + format!( + "{}/{}", + doc_record.site.trim_end_matches('/'), + path.trim_start_matches('/') + ) + } + _ => doc_record.site.clone(), + }; + info!("Document: {} ({})", doc_record.title, full_url); + cache.insert( + key, + CachedRecord::Document { + site: doc_record.site, + path: doc_record.path, + }, + ); + } + Err(e) => { + log::error!("Failed to parse document: {}", e); + } + }, + _ => {} + } + } + } + RecordAction::Delete => { + cache.remove(&key); + } + _ => {} + } + + // Log cache stats + let (pub_count, doc_count) = count_cached(cache); + info!( + "Cached: {} publications, {} documents", + pub_count, doc_count + ); +} + +fn count_cached(cache: &HashMap) -> (usize, usize) { + let mut publications = 0; + let mut documents = 0; + + for record in cache.values() { + match record { + CachedRecord::Publication { .. } => publications += 1, + CachedRecord::Document { .. } => documents += 1, + } + } + + (publications, documents) +} + +fn write_output_files(cache: &HashMap) -> Result<(), std::io::Error> { + let mut publication_urls: Vec<&str> = Vec::new(); + let mut document_urls: Vec = Vec::new(); + + for record in cache.values() { + match record { + CachedRecord::Publication { url } => { + publication_urls.push(url); + } + CachedRecord::Document { site, path } => { + let full_url = match path { + Some(p) if !p.is_empty() => { + format!( + "{}/{}", + site.trim_end_matches('/'), + p.trim_start_matches('/') + ) + } + _ => site.clone(), + }; + document_urls.push(full_url); + } + } + } + + publication_urls.sort(); + document_urls.sort(); + + fs::write("publications.txt", publication_urls.join("\n"))?; + + // Write documents.txt (publication + document URLs combined) + let mut all_urls: Vec = publication_urls.iter().map(|s| s.to_string()).collect(); + all_urls.extend(document_urls); + all_urls.sort(); + all_urls.dedup(); + fs::write("documents.txt", all_urls.join("\n"))?; + + // Write cache.json for persistence + let cache_entries: Vec<(&RecordKey, &CachedRecord)> = cache.iter().collect(); + let cache_json = serde_json::to_string_pretty(&cache_entries).map_err(std::io::Error::other)?; + fs::write("cache.json", cache_json)?; + + Ok(()) +} + +fn load_cache_from_disk() -> HashMap { + match fs::read_to_string("cache.json") { + Ok(content) => match serde_json::from_str::>(&content) { + Ok(entries) => entries.into_iter().collect(), + Err(e) => { + log::warn!("Failed to parse cache.json: {}", e); + HashMap::new() + } + }, + Err(_) => HashMap::new(), + } +} diff --git a/src/channel.rs b/src/channel.rs new file mode 100644 --- /dev/null +++ b/src/channel.rs @@ -0,0 +1,209 @@ +//! WebSocket event channel and receiver. + +use futures_util::{SinkExt, StreamExt}; +use serde::Serialize; +use tokio::sync::mpsc; +use tokio_tungstenite::{connect_async, tungstenite::Message}; +use url::Url; + +use crate::types::RawEvent; +use crate::{Error, Event, Result}; + +type WsStream = + tokio_tungstenite::WebSocketStream>; +type WsSink = futures_util::stream::SplitSink; +type WsSource = futures_util::stream::SplitStream; + +/// Receiver for events from a tap WebSocket channel. +/// +/// Events are received via the [`recv`](EventReceiver::recv) method. +/// Acknowledgments are sent automatically when events are dropped. +/// +/// This type does not implement auto-reconnection. If the connection +/// closes, `recv()` will return an error and you must create a new +/// `EventReceiver` via [`TapClient::channel()`](crate::TapClient::channel). +pub struct EventReceiver { + event_rx: mpsc::Receiver>, + _ack_tx: mpsc::Sender, +} + +struct EventWithAck { + event: Event, + ack_tx: mpsc::Sender, +} + +struct AckGuard { + id: u64, + ack_tx: Option>, +} + +impl Drop for AckGuard { + fn drop(&mut self) { + if let Some(tx) = self.ack_tx.take() { + // Fire and forget - if the channel is closed, we can't ack anyway + let id = self.id; + tokio::spawn(async move { + let _ = tx.send(id).await; + }); + } + } +} + +/// Wrapper around Event that includes the ack trigger. +pub struct ReceivedEvent { + pub event: Event, + _ack_guard: AckGuard, +} + +impl std::ops::Deref for ReceivedEvent { + type Target = Event; + + fn deref(&self) -> &Self::Target { + &self.event + } +} + +impl std::fmt::Debug for ReceivedEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.event.fmt(f) + } +} + +impl EventReceiver { + /// Connect to a tap WebSocket channel. + pub(crate) async fn connect(base_url: &Url, admin_password: Option<&str>) -> Result { + let mut ws_url = base_url.clone(); + match ws_url.scheme() { + "http" => ws_url.set_scheme("ws").unwrap(), + "https" => ws_url.set_scheme("wss").unwrap(), + _ => {} + } + ws_url.set_path("/channel"); + + if let Some(password) = admin_password { + ws_url + .set_username("admin") + .map_err(|_| Error::InvalidUrl("cannot set username".into()))?; + ws_url + .set_password(Some(password)) + .map_err(|_| Error::InvalidUrl("cannot set password".into()))?; + } + + let (ws_stream, response) = connect_async(ws_url.as_str()) + .await + .map_err(|e| Error::WebSocket(Box::new(e)))?; + + if response.status().as_u16() == 400 { + return Err(Error::WebhookModeActive); + } + + let (write, read) = ws_stream.split(); + + let (event_tx, event_rx) = mpsc::channel::>(100); + let (ack_tx, ack_rx) = mpsc::channel::(1000); + + let ack_tx_clone = ack_tx.clone(); + tokio::spawn(async move { + Self::writer_task(write, ack_rx).await; + }); + + tokio::spawn(async move { + Self::reader_task(read, event_tx, ack_tx_clone).await; + }); + + Ok(Self { + event_rx, + _ack_tx: ack_tx, + }) + } + + /// Receive the next event. + /// + /// Returns the event wrapped in a [`ReceivedEvent`] that automatically + /// sends an acknowledgment when dropped. + /// + /// # Errors + /// + /// Returns [`Error::ChannelClosed`] if the WebSocket connection closes. + pub async fn recv(&mut self) -> Result { + match self.event_rx.recv().await { + Some(Ok(event_with_ack)) => { + let id = event_with_ack.event.id(); + Ok(ReceivedEvent { + event: event_with_ack.event, + _ack_guard: AckGuard { + id, + ack_tx: Some(event_with_ack.ack_tx), + }, + }) + } + Some(Err(e)) => Err(e), + None => Err(Error::ChannelClosed), + } + } + + /// Writer task: sends ack messages to the WebSocket. + async fn writer_task(mut write: WsSink, mut ack_rx: mpsc::Receiver) { + #[derive(Serialize)] + struct AckMessage { + #[serde(rename = "type")] + type_: &'static str, + id: u64, + } + + while let Some(id) = ack_rx.recv().await { + let msg = AckMessage { type_: "ack", id }; + let json = match serde_json::to_string(&msg) { + Ok(j) => j, + Err(e) => { + tracing::warn!("Failed to serialize ack: {}", e); + continue; + } + }; + + if let Err(e) = write.send(Message::Text(json.into())).await { + tracing::warn!("Failed to send ack: {}", e); + break; + } + } + } + + /// Reader task: reads events from WebSocket and sends to channel. + async fn reader_task( + mut read: WsSource, + event_tx: mpsc::Sender>, + ack_tx: mpsc::Sender, + ) { + while let Some(msg_result) = read.next().await { + match msg_result { + Ok(Message::Text(text)) => match serde_json::from_str::(&text) { + Ok(raw) => { + if let Some(event) = raw.into_event() { + let event_with_ack = EventWithAck { + event, + ack_tx: ack_tx.clone(), + }; + if event_tx.send(Ok(event_with_ack)).await.is_err() { + break; + } + } + } + Err(e) => { + tracing::warn!("Failed to parse event: {}", e); + } + }, + Ok(Message::Close(_)) => { + let _ = event_tx.send(Err(Error::ChannelClosed)).await; + break; + } + Ok(_) => { + // Ignore ping/pong/binary + } + Err(e) => { + let _ = event_tx.send(Err(Error::WebSocket(Box::new(e)))).await; + break; + } + } + } + } +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,362 @@ +//! HTTP client for tap API. + +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; +use reqwest::Response; +use serde::de::DeserializeOwned; +use serde::Serialize; +use url::Url; + +use crate::channel::EventReceiver; +use crate::types::{ + ApiError, Cursors, DidDocument, OutboxBufferResponse, RecordCountResponse, RepoCountResponse, + RepoInfo, ResyncBufferResponse, +}; +use crate::{Error, Result, TapConfig}; + +/// HTTP client for interacting with a tap instance. +/// +/// Provides methods for all tap HTTP endpoints. The client is cheap to clone +/// and can be shared across tasks. +#[derive(Debug, Clone)] +pub struct TapClient { + client: reqwest::Client, + base_url: Url, + admin_password: Option, +} + +impl TapClient { + /// Create a new client connecting to the given URL. + /// + /// # Example + /// + /// ```no_run + /// use tapped::TapClient; + /// + /// let client = TapClient::new("http://localhost:2480")?; + /// # Ok::<(), tapped::Error>(()) + /// ``` + pub fn new(url: impl AsRef) -> Result { + Self::with_config(url, &TapConfig::default()) + } + + /// Create a new client with Basic auth. + /// + /// # Example + /// + /// ```no_run + /// use tapped::TapClient; + /// + /// let client = TapClient::with_auth("http://localhost:2480", "secret")?; + /// # Ok::<(), tapped::Error>(()) + /// ``` + pub fn with_auth(url: impl AsRef, password: impl Into) -> Result { + let config = TapConfig::builder().admin_password(password.into()).build(); + Self::with_config(url, &config) + } + + /// Create a new client with the given configuration. + pub fn with_config(url: impl AsRef, config: &TapConfig) -> Result { + let base_url: Url = url + .as_ref() + .parse() + .map_err(|_| Error::InvalidUrl(url.as_ref().to_string()))?; + + let timeout = config.request_timeout(); + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .map_err(Error::Http)?; + + Ok(Self { + client, + base_url, + admin_password: config.admin_password.clone(), + }) + } + + /// Get the base URL of the tap instance. + pub fn url(&self) -> &Url { + &self.base_url + } + + /// Build authorization headers if password is set. + fn auth_headers(&self) -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Some(ref password) = self.admin_password { + use base64::Engine; + let credentials = format!("admin:{}", password); + let encoded = base64::engine::general_purpose::STANDARD.encode(credentials); + if let Ok(value) = HeaderValue::from_str(&format!("Basic {}", encoded)) { + headers.insert(AUTHORIZATION, value); + } + } + headers + } + + /// Handle a response, returning an error for non-success status codes. + async fn handle_response(resp: Response) -> Result { + if resp.status().is_success() { + Ok(resp.json().await?) + } else { + Err(Self::error_from_response(resp).await) + } + } + + /// Handle a response that returns no body on success. + async fn handle_empty_response(resp: Response) -> Result<()> { + if resp.status().is_success() { + let _ = resp.bytes().await; + Ok(()) + } else { + Err(Self::error_from_response(resp).await) + } + } + + /// Extract an error from a failed response. + async fn error_from_response(resp: Response) -> Error { + let status = resp.status().as_u16(); + let message = resp + .json::() + .await + .map(|e| e.message) + .unwrap_or_else(|_| "Unknown error".into()); + Error::Api { status, message } + } + + /// Check if the tap instance is healthy. + /// + /// # Example + /// + /// ```no_run + /// # async fn example() -> tapped::Result<()> { + /// use tapped::TapClient; + /// + /// let client = TapClient::new("http://localhost:2480")?; + /// client.health().await?; + /// println!("Tap is healthy!"); + /// # Ok(()) + /// # } + /// ``` + pub async fn health(&self) -> Result<()> { + let url = self.base_url.join("/health")?; + let resp = self + .client + .get(url) + .headers(self.auth_headers()) + .send() + .await?; + Self::handle_empty_response(resp).await + } + + /// Add DIDs to track. + /// + /// Triggers backfill for newly added repos. + /// + /// # Example + /// + /// ```no_run + /// # async fn example() -> tapped::Result<()> { + /// use tapped::TapClient; + /// + /// let client = TapClient::new("http://localhost:2480")?; + /// client.add_repos(&["did:plc:example1234567890abc"]).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn add_repos(&self, dids: &[impl AsRef]) -> Result<()> { + #[derive(Serialize)] + struct Payload { + dids: Vec, + } + + let payload = Payload { + dids: dids.iter().map(|d| d.as_ref().to_string()).collect(), + }; + + let url = self.base_url.join("/repos/add")?; + let resp = self + .client + .post(url) + .headers(self.auth_headers()) + .json(&payload) + .send() + .await?; + Self::handle_empty_response(resp).await + } + + /// Remove DIDs from tracking. + /// + /// Stops sync and deletes tracked repo metadata. Does not delete buffered + /// events in the outbox. + /// + /// # Example + /// + /// ```no_run + /// # async fn example() -> tapped::Result<()> { + /// use tapped::TapClient; + /// + /// let client = TapClient::new("http://localhost:2480")?; + /// client.remove_repos(&["did:plc:example1234567890abc"]).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn remove_repos(&self, dids: &[impl AsRef]) -> Result<()> { + #[derive(Serialize)] + struct Payload { + dids: Vec, + } + + let payload = Payload { + dids: dids.iter().map(|d| d.as_ref().to_string()).collect(), + }; + + let url = self.base_url.join("/repos/remove")?; + let resp = self + .client + .post(url) + .headers(self.auth_headers()) + .json(&payload) + .send() + .await?; + Self::handle_empty_response(resp).await + } + + /// Resolve a DID to its DID document. + /// + /// # Example + /// + /// ```no_run + /// # async fn example() -> tapped::Result<()> { + /// use tapped::TapClient; + /// + /// let client = TapClient::new("http://localhost:2480")?; + /// let doc = client.resolve_did("did:plc:example1234567890abc").await?; + /// println!("Handle: {:?}", doc.also_known_as); + /// # Ok(()) + /// # } + /// ``` + pub async fn resolve_did(&self, did: &str) -> Result { + let url = self.base_url.join(&format!("/resolve/{}", did))?; + let resp = self + .client + .get(url) + .headers(self.auth_headers()) + .send() + .await?; + Self::handle_response(resp).await + } + + /// Get information about a tracked repository. + /// + /// # Example + /// + /// ```no_run + /// # async fn example() -> tapped::Result<()> { + /// use tapped::TapClient; + /// + /// let client = TapClient::new("http://localhost:2480")?; + /// let info = client.repo_info("did:plc:example1234567890abc").await?; + /// println!("State: {:?}, Records: {}", info.state, info.records); + /// # Ok(()) + /// # } + /// ``` + pub async fn repo_info(&self, did: &str) -> Result { + let url = self.base_url.join(&format!("/info/{}", did))?; + let resp = self + .client + .get(url) + .headers(self.auth_headers()) + .send() + .await?; + Self::handle_response(resp).await + } + + /// Get the total number of tracked repositories. + pub async fn repo_count(&self) -> Result { + let url = self.base_url.join("/stats/repo-count")?; + let resp = self + .client + .get(url) + .headers(self.auth_headers()) + .send() + .await?; + let data: RepoCountResponse = Self::handle_response(resp).await?; + Ok(data.repo_count) + } + + /// Get the total number of tracked records. + pub async fn record_count(&self) -> Result { + let url = self.base_url.join("/stats/record-count")?; + let resp = self + .client + .get(url) + .headers(self.auth_headers()) + .send() + .await?; + let data: RecordCountResponse = Self::handle_response(resp).await?; + Ok(data.record_count) + } + + /// Get the number of events in the outbox buffer. + pub async fn outbox_buffer(&self) -> Result { + let url = self.base_url.join("/stats/outbox-buffer")?; + let resp = self + .client + .get(url) + .headers(self.auth_headers()) + .send() + .await?; + let data: OutboxBufferResponse = Self::handle_response(resp).await?; + Ok(data.outbox_buffer) + } + + /// Get the number of events in the resync buffer. + pub async fn resync_buffer(&self) -> Result { + let url = self.base_url.join("/stats/resync-buffer")?; + let resp = self + .client + .get(url) + .headers(self.auth_headers()) + .send() + .await?; + let data: ResyncBufferResponse = Self::handle_response(resp).await?; + Ok(data.resync_buffer) + } + + /// Get the current cursor positions. + pub async fn cursors(&self) -> Result { + let url = self.base_url.join("/stats/cursors")?; + let resp = self + .client + .get(url) + .headers(self.auth_headers()) + .send() + .await?; + Self::handle_response(resp).await + } + + /// Connect to the WebSocket event channel. + /// + /// Returns an [`EventReceiver`] for receiving events. Events are + /// automatically acknowledged when dropped. + /// + /// # Example + /// + /// ```no_run + /// # async fn example() -> tapped::Result<()> { + /// use tapped::TapClient; + /// + /// let client = TapClient::new("http://localhost:2480")?; + /// let mut receiver = client.channel().await?; + /// + /// while let Ok(event) = receiver.recv().await { + /// println!("Event: {:?}", event); + /// // Event is automatically acknowledged when dropped + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn channel(&self) -> Result { + EventReceiver::connect(&self.base_url, self.admin_password.as_deref()).await + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,444 @@ +//! Configuration types for tap process and client. + +use std::time::Duration; +use url::Url; + +/// Log level for tap process. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[non_exhaustive] +pub enum LogLevel { + Debug, + #[default] + Info, + Warn, + Error, +} + +impl LogLevel { + /// Convert to the string value expected by tap. + pub fn as_str(&self) -> &'static str { + match self { + LogLevel::Debug => "debug", + LogLevel::Info => "info", + LogLevel::Warn => "warn", + LogLevel::Error => "error", + } + } +} + +/// Configuration for a tap instance. +/// +/// All fields are optional. When spawning a tap process, unset fields will use +/// tap's built-in defaults. When connecting to an existing instance, only +/// client-side options (timeouts, auth) are relevant. +/// +/// Use [`TapConfig::builder()`] for ergonomic construction. +#[derive(Debug, Clone, Default)] +pub struct TapConfig { + // Database + /// Database connection string (sqlite://path or postgres://...) + pub database_url: Option, + /// Maximum number of database connections + pub max_db_conns: Option, + + // Server + /// HTTP server bind address (e.g., ":2480", "127.0.0.1:2480", or "[::1]:2480") + pub bind: Option, + /// Basic auth admin password for all requests + pub admin_password: Option, + /// Address for metrics/pprof server + pub metrics_listen: Option, + /// Log verbosity level + pub log_level: Option, + + // AT Protocol + /// PLC directory URL + pub plc_url: Option, + /// AT Protocol relay URL + pub relay_url: Option, + + // Processing + /// Number of parallel firehose event processors + pub firehose_parallelism: Option, + /// Number of parallel resync workers + pub resync_parallelism: Option, + /// Number of parallel outbox workers + pub outbox_parallelism: Option, + /// How often to save firehose cursor + pub cursor_save_interval: Option, + /// Timeout for fetching repo CARs from PDS + pub repo_fetch_timeout: Option, + /// Size of in-process identity cache + pub ident_cache_size: Option, + /// Size of outbox before back pressure + pub outbox_capacity: Option, + /// Timeout before retrying unacked events + pub retry_timeout: Option, + + // Network boundary + /// Track all repos on the network + pub full_network: Option, + /// Track repos with records in this collection + pub signal_collection: Option, + + // Filtering + /// Filter output records by collection (supports wildcards) + pub collection_filters: Option>, + + // Delivery mode + /// Enable fire-and-forget mode (no client acks) + pub disable_acks: Option, + /// Webhook URL for event delivery + pub webhook_url: Option, + /// Run in outbox-only mode + pub outbox_only: Option, + + // Client-side options (not sent to tap process) + /// Forward tap's stdout/stderr to this process (default: false) + pub inherit_stdio: Option, + /// Graceful shutdown timeout (default: 5s) + pub shutdown_timeout: Option, + /// HTTP request timeout (default: 30s) + pub request_timeout: Option, + /// Max wait for tap to become healthy (default: 30s) + pub startup_timeout: Option, +} + +impl TapConfig { + /// Create a new empty configuration. + pub fn new() -> Self { + Self::default() + } + + /// Create a builder for ergonomic configuration. + pub fn builder() -> TapConfigBuilder { + TapConfigBuilder::default() + } + + /// Get the shutdown timeout, or the default (5 seconds). + pub fn shutdown_timeout(&self) -> Duration { + self.shutdown_timeout.unwrap_or(Duration::from_secs(5)) + } + + /// Get the request timeout, or the default (30 seconds). + pub fn request_timeout(&self) -> Duration { + self.request_timeout.unwrap_or(Duration::from_secs(30)) + } + + /// Get the startup timeout, or the default (30 seconds). + pub fn startup_timeout(&self) -> Duration { + self.startup_timeout.unwrap_or(Duration::from_secs(30)) + } + + /// Whether to inherit stdio from the parent process (default: false). + pub fn inherit_stdio(&self) -> bool { + self.inherit_stdio.unwrap_or(false) + } + + /// Convert configuration to environment variables for subprocess. + pub fn to_env_vars(&self) -> Vec<(String, String)> { + let mut vars = Vec::new(); + + /// Helper macro to push an env var if the field is Some. + macro_rules! push_env { + ($field:expr, $name:literal, clone) => { + if let Some(ref v) = $field { + vars.push(($name.into(), v.clone())); + } + }; + ($field:expr, $name:literal, string) => { + if let Some(v) = $field { + vars.push(($name.into(), v.to_string())); + } + }; + ($field:expr, $name:literal, ref_string) => { + if let Some(ref v) = $field { + vars.push(($name.into(), v.to_string())); + } + }; + ($field:expr, $name:literal, as_str) => { + if let Some(ref v) = $field { + vars.push(($name.into(), v.as_str().into())); + } + }; + ($field:expr, $name:literal, duration) => { + if let Some(v) = $field { + vars.push(($name.into(), format_duration(v))); + } + }; + } + + push_env!(self.database_url, "TAP_DATABASE_URL", clone); + push_env!(self.max_db_conns, "TAP_MAX_DB_CONNS", string); + push_env!(self.bind, "TAP_BIND", clone); + push_env!(self.admin_password, "TAP_ADMIN_PASSWORD", clone); + push_env!(self.metrics_listen, "TAP_METRICS_LISTEN", clone); + push_env!(self.log_level, "TAP_LOG_LEVEL", as_str); + push_env!(self.plc_url, "TAP_PLC_URL", ref_string); + push_env!(self.relay_url, "TAP_RELAY_URL", ref_string); + push_env!( + self.firehose_parallelism, + "TAP_FIREHOSE_PARALLELISM", + string + ); + push_env!(self.resync_parallelism, "TAP_RESYNC_PARALLELISM", string); + push_env!(self.outbox_parallelism, "TAP_OUTBOX_PARALLELISM", string); + push_env!( + self.cursor_save_interval, + "TAP_CURSOR_SAVE_INTERVAL", + duration + ); + push_env!(self.repo_fetch_timeout, "TAP_REPO_FETCH_TIMEOUT", duration); + push_env!(self.ident_cache_size, "RELAY_IDENT_CACHE_SIZE", string); + push_env!(self.outbox_capacity, "TAP_OUTBOX_CAPACITY", string); + push_env!(self.retry_timeout, "TAP_RETRY_TIMEOUT", duration); + push_env!(self.full_network, "TAP_FULL_NETWORK", string); + push_env!(self.signal_collection, "TAP_SIGNAL_COLLECTION", clone); + push_env!(self.disable_acks, "TAP_DISABLE_ACKS", string); + push_env!(self.webhook_url, "TAP_WEBHOOK_URL", ref_string); + push_env!(self.outbox_only, "TAP_OUTBOX_ONLY", string); + + if let Some(ref v) = self.collection_filters { + vars.push(("TAP_COLLECTION_FILTERS".into(), v.join(","))); + } + + vars + } +} + +/// Format a Duration as a Go-style duration string (e.g., "30s", "5m"). +fn format_duration(d: Duration) -> String { + let secs = d.as_secs(); + let millis = d.subsec_millis(); + + if millis == 0 { + if secs > 0 && secs.is_multiple_of(3600) { + format!("{}h", secs / 3600) + } else if secs > 0 && secs.is_multiple_of(60) { + format!("{}m", secs / 60) + } else { + format!("{}s", secs) + } + } else { + format!("{}ms", d.as_millis()) + } +} + +/// Builder for [`TapConfig`]. +#[derive(Debug, Clone, Default)] +pub struct TapConfigBuilder { + config: TapConfig, +} + +impl TapConfigBuilder { + /// Set the database URL. + pub fn database_url(mut self, url: impl Into) -> Self { + self.config.database_url = Some(url.into()); + self + } + + /// Set the maximum number of database connections. + pub fn max_db_conns(mut self, n: u32) -> Self { + self.config.max_db_conns = Some(n); + self + } + + /// Set the HTTP server bind address. + pub fn bind(mut self, addr: impl Into) -> Self { + self.config.bind = Some(addr.into()); + self + } + + /// Set the admin password for Basic auth. + pub fn admin_password(mut self, password: impl Into) -> Self { + self.config.admin_password = Some(password.into()); + self + } + + /// Set the metrics server listen address. + pub fn metrics_listen(mut self, addr: impl Into) -> Self { + self.config.metrics_listen = Some(addr.into()); + self + } + + /// Set the log level. + pub fn log_level(mut self, level: LogLevel) -> Self { + self.config.log_level = Some(level); + self + } + + /// Set the PLC directory URL. + pub fn plc_url(mut self, url: Url) -> Self { + self.config.plc_url = Some(url); + self + } + + /// Set the relay URL. + pub fn relay_url(mut self, url: Url) -> Self { + self.config.relay_url = Some(url); + self + } + + /// Set the firehose parallelism. + pub fn firehose_parallelism(mut self, n: u32) -> Self { + self.config.firehose_parallelism = Some(n); + self + } + + /// Set the resync parallelism. + pub fn resync_parallelism(mut self, n: u32) -> Self { + self.config.resync_parallelism = Some(n); + self + } + + /// Set the outbox parallelism. + pub fn outbox_parallelism(mut self, n: u32) -> Self { + self.config.outbox_parallelism = Some(n); + self + } + + /// Set how often to save the firehose cursor. + pub fn cursor_save_interval(mut self, d: Duration) -> Self { + self.config.cursor_save_interval = Some(d); + self + } + + /// Set the repo fetch timeout. + pub fn repo_fetch_timeout(mut self, d: Duration) -> Self { + self.config.repo_fetch_timeout = Some(d); + self + } + + /// Set the identity cache size. + pub fn ident_cache_size(mut self, n: u32) -> Self { + self.config.ident_cache_size = Some(n); + self + } + + /// Set the outbox capacity. + pub fn outbox_capacity(mut self, n: u32) -> Self { + self.config.outbox_capacity = Some(n); + self + } + + /// Set the retry timeout for unacked events. + pub fn retry_timeout(mut self, d: Duration) -> Self { + self.config.retry_timeout = Some(d); + self + } + + /// Enable full network mode. + pub fn full_network(mut self, enabled: bool) -> Self { + self.config.full_network = Some(enabled); + self + } + + /// Set the signal collection for repo discovery. + pub fn signal_collection(mut self, collection: impl Into) -> Self { + self.config.signal_collection = Some(collection.into()); + self + } + + /// Add a collection filter. + /// + /// This can be called multiple times to add multiple filters. + /// Supports wildcards (e.g., "app.bsky.feed.*"). + pub fn collection_filter(mut self, filter: impl Into) -> Self { + self.config + .collection_filters + .get_or_insert_with(Vec::new) + .push(filter.into()); + self + } + + /// Set collection filters. + pub fn collection_filters(mut self, filters: Vec) -> Self { + self.config.collection_filters = Some(filters); + self + } + + /// Disable acknowledgments (fire-and-forget mode). + pub fn disable_acks(mut self, disabled: bool) -> Self { + self.config.disable_acks = Some(disabled); + self + } + + /// Set the webhook URL for event delivery. + pub fn webhook_url(mut self, url: Url) -> Self { + self.config.webhook_url = Some(url); + self + } + + /// Enable outbox-only mode. + pub fn outbox_only(mut self, enabled: bool) -> Self { + self.config.outbox_only = Some(enabled); + self + } + + /// Set the graceful shutdown timeout. + pub fn shutdown_timeout(mut self, d: Duration) -> Self { + self.config.shutdown_timeout = Some(d); + self + } + + /// Set the HTTP request timeout. + pub fn request_timeout(mut self, d: Duration) -> Self { + self.config.request_timeout = Some(d); + self + } + + /// Set the startup health check timeout. + pub fn startup_timeout(mut self, d: Duration) -> Self { + self.config.startup_timeout = Some(d); + self + } + + /// Forward tap's stdout/stderr to this process. + /// + /// When enabled, tap's output will be visible in the terminal. + /// When disabled (default), tap's output is discarded. + pub fn inherit_stdio(mut self, inherit: bool) -> Self { + self.config.inherit_stdio = Some(inherit); + self + } + + /// Build the configuration. + pub fn build(self) -> TapConfig { + self.config + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_duration() { + assert_eq!(format_duration(Duration::from_secs(30)), "30s"); + assert_eq!(format_duration(Duration::from_secs(60)), "1m"); + assert_eq!(format_duration(Duration::from_secs(3600)), "1h"); + assert_eq!(format_duration(Duration::from_secs(90)), "90s"); + assert_eq!(format_duration(Duration::from_millis(500)), "500ms"); + } + + #[test] + fn test_config_to_env_vars() { + let config = TapConfig::builder() + .database_url("sqlite://./test.db") + .bind(":3000") + .signal_collection("app.bsky.feed.post") + .collection_filters(vec!["app.bsky.feed.post".into(), "app.bsky.graph.*".into()]) + .disable_acks(true) + .build(); + + let vars = config.to_env_vars(); + assert!(vars.contains(&("TAP_DATABASE_URL".into(), "sqlite://./test.db".into()))); + assert!(vars.contains(&("TAP_BIND".into(), ":3000".into()))); + assert!(vars.contains(&("TAP_SIGNAL_COLLECTION".into(), "app.bsky.feed.post".into()))); + assert!(vars.contains(&( + "TAP_COLLECTION_FILTERS".into(), + "app.bsky.feed.post,app.bsky.graph.*".into() + ))); + assert!(vars.contains(&("TAP_DISABLE_ACKS".into(), "true".into()))); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,67 @@ +//! Error types for the tapped crate. + +use std::io; + +/// The error type for tapped operations. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// I/O error (process/file operations) + #[error("I/O error: {0}")] + Io(#[from] io::Error), + + /// HTTP client error + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + /// WebSocket error + #[error("WebSocket error: {0}")] + WebSocket(#[from] Box), + + /// JSON serialisation/deserialisation error + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// API error returned by tap server + #[error("API error (status {status}): {message}")] + Api { + /// HTTP status code + status: u16, + /// Error message from server + message: String, + }, + + /// Failed to start the tap process + #[error("Failed to start tap process: {message}")] + ProcessStart { + /// Description of the failure + message: String, + }, + + /// The tap process exited unexpectedly + #[error("Tap process exited with code: {code:?}")] + ProcessExited { + /// Exit code, if available + code: Option, + }, + + /// The event channel was closed + #[error("Event channel closed")] + ChannelClosed, + + /// WebSocket is not available because tap is in webhook mode + #[error("WebSocket not available: tap is in webhook mode")] + WebhookModeActive, + + /// Invalid URL provided + #[error("Invalid URL: {0}")] + InvalidUrl(String), + + /// Operation timed out + #[error("Operation timed out")] + Timeout, + + /// URL parse error + #[error("URL parse error: {0}")] + UrlParse(#[from] url::ParseError), +} diff --git a/src/handle.rs b/src/handle.rs new file mode 100644 --- /dev/null +++ b/src/handle.rs @@ -0,0 +1,120 @@ +//! Convenience type combining process and client. + +use std::ops::Deref; +use std::path::Path; + +use crate::client::TapClient; +use crate::config::TapConfig; +use crate::process::TapProcess; +use crate::Result; + +/// A convenience type that owns both a tap process and its client. +/// +/// `TapHandle` manages the lifecycle of a tap subprocess and provides +/// access to a [`TapClient`] for interacting with it. When dropped, +/// the process is gracefully shut down. +/// +/// # Example +/// +/// ```no_run +/// use tapped::{TapHandle, TapConfig}; +/// +/// #[tokio::main] +/// async fn main() -> tapped::Result<()> { +/// let config = TapConfig::builder() +/// .database_url("sqlite://tap.db") +/// .build(); +/// +/// // Spawn tap and get a handle +/// let handle = TapHandle::spawn_default(config).await?; +/// +/// // Use the client methods directly on the handle +/// handle.health().await?; +/// +/// let mut channel = handle.channel().await?; +/// while let Ok(event) = channel.recv().await { +/// println!("Event: {:?}", event.event); +/// } +/// +/// Ok(()) +/// } +/// ``` +pub struct TapHandle { + process: TapProcess, + client: TapClient, +} + +impl TapHandle { + /// Spawn a tap process at the given path and create a client for it. + /// + /// This combines [`TapProcess::spawn`] and [`TapClient`] creation into + /// a single convenient call. + /// + /// # Arguments + /// + /// * `path` - Path to the tap binary + /// * `config` - Configuration for the tap instance + /// + /// # Errors + /// + /// Returns an error if the process fails to start or become healthy. + pub async fn spawn(path: impl AsRef, config: TapConfig) -> Result { + let process = TapProcess::spawn(path, config).await?; + let client = process.client()?; + Ok(Self { process, client }) + } + + /// Spawn a tap process using the default binary location. + /// + /// This looks for `tap` in the current directory first, then on the PATH. + /// Combines [`TapProcess::spawn_default`] and [`TapClient`] creation. + /// + /// # Arguments + /// + /// * `config` - Configuration for the tap instance + /// + /// # Errors + /// + /// Returns an error if no tap binary is found or it fails to start. + pub async fn spawn_default(config: TapConfig) -> Result { + let process = TapProcess::spawn_default(config).await?; + let client = process.client()?; + Ok(Self { process, client }) + } + + /// Get a reference to the underlying process. + pub fn process(&self) -> &TapProcess { + &self.process + } + + /// Get a mutable reference to the underlying process. + pub fn process_mut(&mut self) -> &mut TapProcess { + &mut self.process + } + + /// Get a reference to the client. + pub fn client(&self) -> &TapClient { + &self.client + } + + /// Check if the tap process is still running. + pub fn is_running(&mut self) -> bool { + self.process.is_running() + } + + /// Gracefully shut down the tap process. + /// + /// This sends SIGTERM and waits for the process to exit, then + /// sends SIGKILL if it doesn't exit within the shutdown timeout. + pub async fn shutdown(&mut self) -> Result<()> { + self.process.shutdown().await + } +} + +impl Deref for TapHandle { + type Target = TapClient; + + fn deref(&self) -> &Self::Target { + &self.client + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,63 @@ +//! # tapped +//! +//! A Rust wrapper for the `tap` ATProto sync utility. +//! +//! Tap simplifies ATProto sync by handling the firehose connection, verification, +//! backfill, and filtering. This crate provides an idiomatic async Rust interface +//! to tap's HTTP API and WebSocket event stream. +//! +//! ## Features +//! +//! - Connect to an existing tap instance or spawn one as a subprocess +//! - Strongly-typed configuration with builder pattern +//! - Async event streaming with automatic acknowledgment +//! - Full HTTP API coverage for repo management and statistics +//! +//! ## Example +//! +//! ```no_run +//! use tapped::{TapClient, TapConfig, Result}; +//! +//! #[tokio::main] +//! async fn main() -> Result<()> { +//! // Connect to an existing tap instance +//! let client = TapClient::new("http://localhost:2480")?; +//! +//! // Check health +//! client.health().await?; +//! +//! // Add repos to track +//! client.add_repos(&["did:plc:example1234567890abc"]).await?; +//! +//! // Stream events +//! let mut receiver = client.channel().await?; +//! while let Ok(event) = receiver.recv().await { +//! println!("Received event: {:?}", event); +//! // Event is automatically acknowledged when dropped +//! } +//! +//! Ok(()) +//! } +//! ``` + +mod channel; +mod client; +mod config; +mod error; +mod handle; +mod process; +mod types; + +pub use channel::{EventReceiver, ReceivedEvent}; +pub use client::TapClient; +pub use config::{LogLevel, TapConfig, TapConfigBuilder}; +pub use error::Error; +pub use handle::TapHandle; +pub use process::TapProcess; +pub use types::{ + AccountStatus, Cursors, DidDocument, Event, IdentityEvent, Record, RecordAction, RecordEvent, + RepoInfo, RepoState, Service, VerificationMethod, +}; + +/// A specialised Result type for tapped operations. +pub type Result = std::result::Result; diff --git a/src/process.rs b/src/process.rs new file mode 100644 --- /dev/null +++ b/src/process.rs @@ -0,0 +1,236 @@ +//! Subprocess management for spawning and managing a tap process. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use tokio::process::{Child, Command}; +use tokio::time::{sleep, timeout}; +use url::Url; + +use crate::{Error, Result, TapClient, TapConfig}; + +/// A running tap process. +/// +/// The process is gracefully shut down when this struct is dropped. +pub struct TapProcess { + child: Child, + url: Url, + config: TapConfig, +} + +impl TapProcess { + /// Spawn a tap process at the given path with the given configuration. + /// + /// The path should point to the `tap` binary. The process will be started + /// with the `run` subcommand and configuration passed as environment + /// variables. + /// + /// # Example + /// + /// ```no_run + /// # async fn example() -> tapped::Result<()> { + /// use tapped::{TapProcess, TapConfig}; + /// + /// let config = TapConfig::builder() + /// .database_url("sqlite://./my-tap.db") + /// .build(); + /// + /// let process = TapProcess::spawn("./tap", config).await?; + /// let client = process.client()?; + /// # Ok(()) + /// # } + /// ``` + pub async fn spawn(path: impl AsRef, config: TapConfig) -> Result { + let path = path.as_ref(); + + if !path.exists() { + return Err(Error::ProcessStart { + message: format!("tap binary not found at: {}", path.display()), + }); + } + + Self::spawn_inner(path.to_path_buf(), config).await + } + + /// Spawn a tap process using the default path discovery. + /// + /// Checks for `./tap` first, then falls back to `tap` on PATH. + /// + /// # Example + /// + /// ```no_run + /// # async fn example() -> tapped::Result<()> { + /// use tapped::{TapProcess, TapConfig}; + /// + /// let config = TapConfig::builder() + /// .database_url("sqlite://./my-tap.db") + /// .build(); + /// + /// let process = TapProcess::spawn_default(config).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn spawn_default(config: TapConfig) -> Result { + let local_tap = PathBuf::from("./tap"); + if local_tap.exists() { + return Self::spawn_inner(local_tap, config).await; + } + + Self::spawn_inner(PathBuf::from("tap"), config).await + } + + async fn spawn_inner(path: PathBuf, config: TapConfig) -> Result { + let bind = config.bind.clone().unwrap_or_else(|| ":2480".to_string()); + let port = parse_port(&bind).unwrap_or(2480); + + // Spawned processes are always local - connect to localhost regardless + // of what interface tap binds to. + let url: Url = format!("http://127.0.0.1:{}", port) + .parse() + .map_err(|_| Error::InvalidUrl(format!("http://127.0.0.1:{}", port)))?; + + let mut cmd = Command::new(&path); + cmd.arg("run").stdin(Stdio::null()).kill_on_drop(true); + + if config.inherit_stdio() { + cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + } else { + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + } + + for (key, value) in config.to_env_vars() { + cmd.env(key, value); + } + + let child = cmd.spawn().map_err(|e| Error::ProcessStart { + message: format!("Failed to spawn {}: {}", path.display(), e), + })?; + + let mut process = Self { + child, + url: url.clone(), + config, + }; + + if let Err(e) = process.wait_for_healthy().await { + // Kill the process if health check fails + let _ = process.child.kill().await; + return Err(e); + } + + Ok(process) + } + + /// Wait for the tap process to become healthy. + async fn wait_for_healthy(&self) -> Result<()> { + let startup_timeout = self.config.startup_timeout(); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .map_err(Error::Http)?; + + let health_url = self.url.join("/health")?; + + let result = timeout(startup_timeout, async { + loop { + match client.get(health_url.clone()).send().await { + Ok(resp) if resp.status().is_success() => return Ok(()), + _ => sleep(Duration::from_millis(100)).await, + } + } + }) + .await; + + match result { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(e), + Err(_) => Err(Error::Timeout), + } + } + + /// Get the URL of the running tap instance. + pub fn url(&self) -> &Url { + &self.url + } + + /// Create a client connected to this tap process. + pub fn client(&self) -> Result { + TapClient::with_config(self.url.as_str(), &self.config) + } + + /// Check if the process is still running. + pub fn is_running(&mut self) -> bool { + matches!(self.child.try_wait(), Ok(None)) + } + + /// Gracefully shut down the tap process. + /// + /// Sends SIGTERM and waits up to `shutdown_timeout` before sending SIGKILL. + pub async fn shutdown(&mut self) -> Result<()> { + #[cfg(unix)] + { + // Send SIGTERM + if let Some(pid) = self.child.id() { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + } + + // Wait for graceful shutdown + let shutdown_timeout = self.config.shutdown_timeout(); + match timeout(shutdown_timeout, self.child.wait()).await { + Ok(Ok(_)) => return Ok(()), + Ok(Err(e)) => return Err(Error::Io(e)), + Err(_) => { + // Timeout, send SIGKILL + let _ = self.child.kill().await; + } + } + } + + #[cfg(not(unix))] + { + let _ = self.child.kill().await; + } + + Ok(()) + } +} + +impl Drop for TapProcess { + fn drop(&mut self) { + #[cfg(unix)] + { + if let Some(pid) = self.child.id() { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + } + } + } +} + +/// Extract the port from a bind address. +/// +/// The bind format is passed directly to tap (Go's net.Listen format). +/// We just need the port to construct a localhost URL for health checks. +fn parse_port(bind: &str) -> Option { + // The port is always after the last colon, even for IPv6 like [::1]:2480 + bind.rsplit(':').next()?.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_port() { + assert_eq!(parse_port(":2480"), Some(2480)); + assert_eq!(parse_port("127.0.0.1:3000"), Some(3000)); + assert_eq!(parse_port("0.0.0.0:8080"), Some(8080)); + assert_eq!(parse_port("[::1]:2480"), Some(2480)); + assert_eq!(parse_port("[2001:db8::1]:8080"), Some(8080)); + assert_eq!(parse_port("invalid"), None); + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,601 @@ +//! Type definitions for tap events and API responses. + +use serde::{Deserialize, Serialize}; + +/// A record's JSON data with helper methods. +/// +/// Wraps the raw JSON value and provides convenient accessors. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Record(serde_json::Value); + +impl Record { + /// Create a new Record from a JSON value. + pub fn new(value: serde_json::Value) -> Self { + Self(value) + } + + /// Access the raw JSON value. + pub fn json(&self) -> &serde_json::Value { + &self.0 + } + + /// Consume and return the inner JSON value. + pub fn into_json(self) -> serde_json::Value { + self.0 + } + + /// Returns the `$type` field (e.g., "app.bsky.feed.post"). + pub fn record_type(&self) -> Option<&str> { + self.0.get("$type")?.as_str() + } + + /// Deserialize the record into a user-provided type. + pub fn deserialize_as(&self) -> crate::Result { + Ok(serde_json::from_value(self.0.clone())?) + } +} + +/// Action performed on a record. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum RecordAction { + Create, + Update, + Delete, +} + +/// Account status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum AccountStatus { + Active, + Takendown, + Suspended, + Deactivated, + Deleted, +} + +/// Repository sync state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum RepoState { + Pending, + Desynchronized, + Resyncing, + Active, + Takendown, + Suspended, + Deactivated, + Error, +} + +/// A record event from the tap stream. +#[derive(Debug, Clone)] +pub struct RecordEvent { + /// Unique event ID for acknowledgment. + pub id: u64, + /// True if from live firehose, false if from backfill/resync. + pub live: bool, + /// DID of the repository. + pub did: String, + /// Repository revision (TID format). + pub rev: String, + /// Collection NSID (e.g., "app.bsky.feed.post"). + pub collection: String, + /// Record key (usually TID format). + pub rkey: String, + /// Action performed on the record. + pub action: RecordAction, + /// CID of the record (None on delete). + pub cid: Option, + /// The record data (None on delete). + pub record: Option, +} + +/// An identity event from the tap stream. +#[derive(Debug, Clone)] +pub struct IdentityEvent { + /// Unique event ID for acknowledgment. + pub id: u64, + /// DID of the account. + pub did: String, + /// Current handle. + pub handle: String, + /// Whether the account is active. + pub is_active: bool, + /// Account status. + pub status: AccountStatus, +} + +/// An event from the tap stream. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum Event { + /// A record create/update/delete event. + Record(RecordEvent), + /// An identity (handle/status) change event. + Identity(IdentityEvent), +} + +impl Event { + /// Get the event ID. + pub fn id(&self) -> u64 { + match self { + Event::Record(e) => e.id, + Event::Identity(e) => e.id, + } + } + + /// Get the DID associated with this event. + pub fn did(&self) -> &str { + match self { + Event::Record(e) => &e.did, + Event::Identity(e) => &e.did, + } + } +} + +/// Information about a tracked repository. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoInfo { + /// DID of the repository. + pub did: String, + /// Current handle (may be empty). + pub handle: String, + /// Sync state. + pub state: RepoState, + /// Current revision (TID format, empty if not synced). + pub rev: String, + /// Error message if in error state. + pub error: String, + /// Number of failed retry attempts. + pub retries: u32, + /// Total number of tracked records. + pub records: u64, +} + +/// Cursor positions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Cursors { + /// Firehose sequence number (None if not consuming). + pub firehose: Option, + /// List repos enumeration cursor (None if not enumerating). + pub list_repos: Option, +} + +/// A DID document. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DidDocument { + /// The DID itself. + pub id: String, + /// Also known as (ATProto handles, etc.). + #[serde(default, rename = "alsoKnownAs")] + pub also_known_as: Vec, + /// Verification methods (signing keys). + #[serde(default, rename = "verificationMethod")] + pub verification_method: Vec, + /// Services (PDS endpoint, etc.). + #[serde(default)] + pub service: Vec, + /// Additional fields not explicitly modelled. + #[serde(flatten)] + pub extra: serde_json::Value, +} + +/// A verification method in a DID document. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerificationMethod { + /// Method ID. + pub id: String, + /// Method type. + #[serde(rename = "type")] + pub type_: String, + /// Controller DID. + pub controller: String, + /// Public key in multibase format. + #[serde(rename = "publicKeyMultibase")] + pub public_key_multibase: Option, +} + +/// A service in a DID document. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Service { + /// Service ID. + pub id: String, + /// Service type. + #[serde(rename = "type")] + pub type_: String, + /// Service endpoint URL. + #[serde(rename = "serviceEndpoint")] + pub service_endpoint: String, +} + +// Internal deserialisation structures for parsing tap's JSON format + +#[derive(Deserialize)] +pub(crate) struct RawEvent { + pub id: u64, + #[serde(rename = "type")] + pub type_: String, + pub record: Option, + pub identity: Option, +} + +#[derive(Deserialize)] +pub(crate) struct RawRecordEvent { + pub live: bool, + pub did: String, + pub rev: String, + pub collection: String, + pub rkey: String, + pub action: RecordAction, + pub cid: Option, + pub record: Option, +} + +#[derive(Deserialize)] +pub(crate) struct RawIdentityEvent { + pub did: String, + pub handle: String, + #[serde(rename = "is_active")] + pub is_active: bool, + pub status: AccountStatus, +} + +impl RawEvent { + /// Convert to the public Event type. + pub fn into_event(self) -> Option { + match self.type_.as_str() { + "record" => { + let r = self.record?; + Some(Event::Record(RecordEvent { + id: self.id, + live: r.live, + did: r.did, + rev: r.rev, + collection: r.collection, + rkey: r.rkey, + action: r.action, + cid: r.cid, + record: r.record.map(Record::new), + })) + } + "identity" => { + let i = self.identity?; + Some(Event::Identity(IdentityEvent { + id: self.id, + did: i.did, + handle: i.handle, + is_active: i.is_active, + status: i.status, + })) + } + _ => None, + } + } +} + +// Response types for stats endpoints + +#[derive(Deserialize)] +pub(crate) struct RepoCountResponse { + pub repo_count: u64, +} + +#[derive(Deserialize)] +pub(crate) struct RecordCountResponse { + pub record_count: u64, +} + +#[derive(Deserialize)] +pub(crate) struct OutboxBufferResponse { + pub outbox_buffer: u64, +} + +#[derive(Deserialize)] +pub(crate) struct ResyncBufferResponse { + pub resync_buffer: u64, +} + +#[derive(Deserialize)] +pub(crate) struct ApiError { + pub message: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn record_type_extraction() { + let record = Record::new(json!({ + "$type": "app.bsky.feed.post", + "text": "Hello, world!", + "createdAt": "2024-01-01T00:00:00Z" + })); + + assert_eq!(record.record_type(), Some("app.bsky.feed.post")); + } + + #[test] + fn record_type_missing() { + let record = Record::new(json!({ + "text": "No type field" + })); + + assert_eq!(record.record_type(), None); + } + + #[test] + fn record_deserialize_as() { + #[derive(Debug, Deserialize, PartialEq)] + struct SimpleRecord { + text: String, + } + + let record = Record::new(json!({ + "$type": "test.record", + "text": "Hello!" + })); + + let parsed: SimpleRecord = record.deserialize_as().unwrap(); + assert_eq!(parsed.text, "Hello!"); + } + + /// Helper macro for testing enum variant deserialisation. + macro_rules! assert_deserialize { + ($type:ty, $($json:literal => $variant:expr),+ $(,)?) => { + $( + assert_eq!( + serde_json::from_str::<$type>($json).unwrap(), + $variant + ); + )+ + }; + } + + #[test] + fn record_action_deserialize() { + assert_deserialize!(RecordAction, + r#""create""# => RecordAction::Create, + r#""update""# => RecordAction::Update, + r#""delete""# => RecordAction::Delete, + ); + } + + #[test] + fn account_status_deserialize() { + assert_deserialize!(AccountStatus, + r#""active""# => AccountStatus::Active, + r#""takendown""# => AccountStatus::Takendown, + r#""suspended""# => AccountStatus::Suspended, + r#""deactivated""# => AccountStatus::Deactivated, + r#""deleted""# => AccountStatus::Deleted, + ); + } + + #[test] + fn repo_state_deserialize() { + assert_deserialize!(RepoState, + r#""pending""# => RepoState::Pending, + r#""active""# => RepoState::Active, + r#""error""# => RepoState::Error, + ); + } + + #[test] + fn repo_info_deserialize() { + let json = json!({ + "did": "did:plc:abc123", + "handle": "test.bsky.social", + "state": "active", + "rev": "3abc123", + "error": "", + "retries": 0, + "records": 42 + }); + + let info: RepoInfo = serde_json::from_value(json).unwrap(); + assert_eq!(info.did, "did:plc:abc123"); + assert_eq!(info.handle, "test.bsky.social"); + assert_eq!(info.state, RepoState::Active); + assert_eq!(info.records, 42); + } + + #[test] + fn cursors_deserialize() { + let json = json!({ + "firehose": 12345678, + "list_repos": "some-cursor" + }); + + let cursors: Cursors = serde_json::from_value(json).unwrap(); + assert_eq!(cursors.firehose, Some(12345678)); + assert_eq!(cursors.list_repos, Some("some-cursor".to_string())); + } + + #[test] + fn cursors_deserialize_nulls() { + let json = json!({ + "firehose": null, + "list_repos": null + }); + + let cursors: Cursors = serde_json::from_value(json).unwrap(); + assert_eq!(cursors.firehose, None); + assert_eq!(cursors.list_repos, None); + } + + #[test] + fn did_document_deserialize() { + let json = json!({ + "id": "did:plc:example1234567890abc", + "alsoKnownAs": ["at://alice.test"], + "verificationMethod": [{ + "id": "did:plc:example1234567890abc#atproto", + "type": "Multikey", + "controller": "did:plc:example1234567890abc", + "publicKeyMultibase": "zDnaekeGCpVsdvDCrGNa9t3bXYUs45MHX1hLwqvaKLtPU9m7X" + }], + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": "https://pds.example.com" + }] + }); + + let doc: DidDocument = serde_json::from_value(json).unwrap(); + assert_eq!(doc.id, "did:plc:example1234567890abc"); + assert_eq!(doc.also_known_as, vec!["at://alice.test"]); + assert_eq!(doc.verification_method.len(), 1); + assert_eq!(doc.verification_method[0].type_, "Multikey"); + assert_eq!(doc.service.len(), 1); + assert_eq!(doc.service[0].type_, "AtprotoPersonalDataServer"); + } + + #[test] + fn did_document_with_extra_fields() { + let json = json!({ + "id": "did:plc:test", + "alsoKnownAs": [], + "@context": ["https://www.w3.org/ns/did/v1"], + "customField": "some value" + }); + + let doc: DidDocument = serde_json::from_value(json).unwrap(); + assert_eq!(doc.id, "did:plc:test"); + assert!(doc.extra.get("@context").is_some()); + assert!(doc.extra.get("customField").is_some()); + } + + #[test] + fn raw_record_event_deserialize() { + let json = json!({ + "id": 12345, + "type": "record", + "record": { + "live": true, + "did": "did:plc:abc123", + "rev": "3abc", + "collection": "app.bsky.feed.post", + "rkey": "3def", + "action": "create", + "cid": "bafyreid...", + "record": { + "$type": "app.bsky.feed.post", + "text": "Hello!" + } + } + }); + + let raw: RawEvent = serde_json::from_value(json).unwrap(); + assert_eq!(raw.id, 12345); + assert_eq!(raw.type_, "record"); + + let event = raw.into_event().unwrap(); + match event { + Event::Record(r) => { + assert_eq!(r.id, 12345); + assert!(r.live); + assert_eq!(r.did, "did:plc:abc123"); + assert_eq!(r.collection, "app.bsky.feed.post"); + assert_eq!(r.action, RecordAction::Create); + assert!(r.record.is_some()); + assert_eq!(r.record.unwrap().record_type(), Some("app.bsky.feed.post")); + } + _ => panic!("Expected Record event"), + } + } + + #[test] + fn raw_identity_event_deserialize() { + let json = json!({ + "id": 99999, + "type": "identity", + "identity": { + "did": "did:plc:xyz789", + "handle": "alice.bsky.social", + "is_active": true, + "status": "active" + } + }); + + let raw: RawEvent = serde_json::from_value(json).unwrap(); + let event = raw.into_event().unwrap(); + + match event { + Event::Identity(i) => { + assert_eq!(i.id, 99999); + assert_eq!(i.did, "did:plc:xyz789"); + assert_eq!(i.handle, "alice.bsky.social"); + assert!(i.is_active); + assert_eq!(i.status, AccountStatus::Active); + } + _ => panic!("Expected Identity event"), + } + } + + #[test] + fn raw_delete_event_no_record() { + let json = json!({ + "id": 55555, + "type": "record", + "record": { + "live": false, + "did": "did:plc:deleted", + "rev": "3xyz", + "collection": "app.bsky.feed.post", + "rkey": "3abc", + "action": "delete", + "cid": null, + "record": null + } + }); + + let raw: RawEvent = serde_json::from_value(json).unwrap(); + let event = raw.into_event().unwrap(); + + match event { + Event::Record(r) => { + assert_eq!(r.action, RecordAction::Delete); + assert!(r.cid.is_none()); + assert!(r.record.is_none()); + } + _ => panic!("Expected Record event"), + } + } + + #[test] + fn event_helper_methods() { + let record_event = Event::Record(RecordEvent { + id: 123, + live: true, + did: "did:plc:record".to_string(), + rev: "abc".to_string(), + collection: "test".to_string(), + rkey: "key".to_string(), + action: RecordAction::Create, + cid: None, + record: None, + }); + + assert_eq!(record_event.id(), 123); + assert_eq!(record_event.did(), "did:plc:record"); + + let identity_event = Event::Identity(IdentityEvent { + id: 456, + did: "did:plc:identity".to_string(), + handle: "test".to_string(), + is_active: true, + status: AccountStatus::Active, + }); + + assert_eq!(identity_event.id(), 456); + assert_eq!(identity_event.did(), "did:plc:identity"); + } +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,247 @@ +//! Integration tests for tapped. +//! +//! These tests require a tap binary to be available on the PATH or in the +//! current directory. They spawn real tap processes and test the full +//! client functionality. +//! +//! Run with: `cargo test --test integration -- --ignored` + +use std::path::Path; +use std::sync::atomic::{AtomicU16, Ordering}; +use std::time::Duration; +use tapped::{TapClient, TapConfig, TapHandle, TapProcess}; + +/// Atomic counter for unique test instance assignment to avoid test conflicts. +static TEST_COUNTER: AtomicU16 = AtomicU16::new(15000); + +/// Get a unique port and database URL for each test. +/// +/// Note: file-based SQLite databases are used because tap's internal +/// components cannot share an in-memory database. +fn unique_test_config() -> (u16, String) { + let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + let db_url = format!("sqlite:///tmp/tap-test-{}.db", id); + (id, db_url) +} + +/// Check if tap binary is available. +fn tap_available() -> bool { + Path::new("./tap").exists() || which::which("tap").is_ok() +} + +/// Skip test if tap is not available. +macro_rules! require_tap { + () => { + if !tap_available() { + eprintln!("Skipping test: tap binary not found"); + return; + } + }; +} + +#[tokio::test] +#[ignore = "requires tap binary"] +async fn test_process_spawn_default() { + require_tap!(); + + let (port, db_url) = unique_test_config(); + let config = TapConfig::builder() + .database_url(db_url) + .bind(format!("127.0.0.1:{}", port)) + .build(); + + let mut process = TapProcess::spawn_default(config) + .await + .expect("Failed to spawn tap"); + + assert!(process.is_running()); + + let client = process.client().expect("Failed to create client"); + client.health().await.expect("Health check failed"); + + process.shutdown().await.expect("Shutdown failed"); + assert!(!process.is_running()); +} + +#[tokio::test] +#[ignore = "requires tap binary"] +async fn test_stats_endpoints() { + require_tap!(); + + let (port, db_url) = unique_test_config(); + let config = TapConfig::builder() + .database_url(db_url) + .bind(format!("127.0.0.1:{}", port)) + .full_network(false) + .build(); + + let handle = TapHandle::spawn_default(config) + .await + .expect("Failed to spawn tap"); + + let repo_count = handle.repo_count().await.expect("repo_count failed"); + assert_eq!(repo_count, 0); + + let record_count = handle.record_count().await.expect("record_count failed"); + assert_eq!(record_count, 0); + + let outbox = handle.outbox_buffer().await.expect("outbox_buffer failed"); + let _ = outbox; + + let resync = handle.resync_buffer().await.expect("resync_buffer failed"); + let _ = resync; + + let cursors = handle.cursors().await.expect("cursors failed"); + let _ = cursors; +} + +#[tokio::test] +#[ignore = "requires tap binary"] +async fn test_add_and_query_repos() { + require_tap!(); + + let (port, db_url) = unique_test_config(); + let config = TapConfig::builder() + .database_url(db_url) + .bind(format!("127.0.0.1:{}", port)) + .full_network(false) + .build(); + + let handle = TapHandle::spawn_default(config) + .await + .expect("Failed to spawn tap"); + + handle + .add_repos(&["did:plc:ewvi7nxzyoun6zhxrhs64oiz"]) + .await + .expect("add_repos failed"); + + let count = handle.repo_count().await.expect("repo_count failed"); + assert_eq!(count, 1); + + let info = handle + .repo_info("did:plc:ewvi7nxzyoun6zhxrhs64oiz") + .await + .expect("repo_info failed"); + assert_eq!(info.did, "did:plc:ewvi7nxzyoun6zhxrhs64oiz"); + + handle + .remove_repos(&["did:plc:ewvi7nxzyoun6zhxrhs64oiz"]) + .await + .expect("remove_repos failed"); + + let count = handle.repo_count().await.expect("repo_count failed"); + assert_eq!(count, 0); +} + +#[tokio::test] +#[ignore = "requires tap binary"] +async fn test_resolve_did() { + require_tap!(); + + let (port, db_url) = unique_test_config(); + let config = TapConfig::builder() + .database_url(db_url) + .bind(format!("127.0.0.1:{}", port)) + .build(); + + let handle = TapHandle::spawn_default(config) + .await + .expect("Failed to spawn tap"); + + let doc = handle + .resolve_did("did:plc:ewvi7nxzyoun6zhxrhs64oiz") + .await + .expect("resolve_did failed"); + + assert_eq!(doc.id, "did:plc:ewvi7nxzyoun6zhxrhs64oiz"); + assert!(!doc.also_known_as.is_empty()); + assert!(!doc.service.is_empty()); +} + +#[tokio::test] +#[ignore = "requires tap binary"] +async fn test_channel_connection() { + require_tap!(); + + let (port, db_url) = unique_test_config(); + let config = TapConfig::builder() + .database_url(db_url) + .bind(format!("127.0.0.1:{}", port)) + .startup_timeout(Duration::from_secs(60)) + .build(); + + let handle = TapHandle::spawn_default(config) + .await + .expect("Failed to spawn tap"); + + let _channel = handle.channel().await.expect("channel connection failed"); +} + +#[tokio::test] +#[ignore = "requires tap binary"] +async fn test_graceful_shutdown() { + require_tap!(); + + let (port, db_url) = unique_test_config(); + let config = TapConfig::builder() + .database_url(db_url) + .bind(format!("127.0.0.1:{}", port)) + .startup_timeout(Duration::from_secs(60)) + .shutdown_timeout(Duration::from_secs(2)) + .build(); + + let mut process = TapProcess::spawn_default(config) + .await + .expect("Failed to spawn tap"); + + assert!(process.is_running()); + + process.shutdown().await.expect("Shutdown failed"); + + assert!(!process.is_running()); +} + +#[tokio::test] +#[ignore = "requires tap binary"] +async fn test_client_from_url() { + require_tap!(); + + let (port, db_url) = unique_test_config(); + let config = TapConfig::builder() + .database_url(db_url) + .bind(format!("127.0.0.1:{}", port)) + .build(); + + let _process = TapProcess::spawn_default(config) + .await + .expect("Failed to spawn tap"); + + let client = + TapClient::new(format!("http://127.0.0.1:{}", port)).expect("Failed to create client"); + + client.health().await.expect("Health check failed"); +} + +#[tokio::test] +#[ignore = "requires tap binary"] +async fn test_multiple_collection_filters() { + require_tap!(); + + let (port, db_url) = unique_test_config(); + let config = TapConfig::builder() + .database_url(db_url) + .bind(format!("127.0.0.1:{}", port)) + .startup_timeout(Duration::from_secs(60)) + .collection_filter("app.bsky.feed.post") + .collection_filter("app.bsky.feed.like") + .collection_filter("app.bsky.feed.repost") + .full_network(false) + .build(); + + let handle = TapHandle::spawn_default(config) + .await + .expect("Failed to spawn tap with multiple filters"); + + handle.health().await.expect("Health check failed"); +} -- tangled.sh