wip: you can use your pds as a yrs-relay if you want to
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial yrs-pds

notplants 47010eeb

+4446
+2
.gitignore
··· 1 + /target 2 + testuser.toml
+2094
Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "android_system_properties" 7 + version = "0.1.5" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 10 + dependencies = [ 11 + "libc", 12 + ] 13 + 14 + [[package]] 15 + name = "anstream" 16 + version = "1.0.0" 17 + source = "registry+https://github.com/rust-lang/crates.io-index" 18 + checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" 19 + dependencies = [ 20 + "anstyle", 21 + "anstyle-parse", 22 + "anstyle-query", 23 + "anstyle-wincon", 24 + "colorchoice", 25 + "is_terminal_polyfill", 26 + "utf8parse", 27 + ] 28 + 29 + [[package]] 30 + name = "anstyle" 31 + version = "1.0.13" 32 + source = "registry+https://github.com/rust-lang/crates.io-index" 33 + checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 34 + 35 + [[package]] 36 + name = "anstyle-parse" 37 + version = "1.0.0" 38 + source = "registry+https://github.com/rust-lang/crates.io-index" 39 + checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" 40 + dependencies = [ 41 + "utf8parse", 42 + ] 43 + 44 + [[package]] 45 + name = "anstyle-query" 46 + version = "1.1.5" 47 + source = "registry+https://github.com/rust-lang/crates.io-index" 48 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 49 + dependencies = [ 50 + "windows-sys 0.61.2", 51 + ] 52 + 53 + [[package]] 54 + name = "anstyle-wincon" 55 + version = "3.0.11" 56 + source = "registry+https://github.com/rust-lang/crates.io-index" 57 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 58 + dependencies = [ 59 + "anstyle", 60 + "once_cell_polyfill", 61 + "windows-sys 0.61.2", 62 + ] 63 + 64 + [[package]] 65 + name = "anyhow" 66 + version = "1.0.102" 67 + source = "registry+https://github.com/rust-lang/crates.io-index" 68 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 69 + 70 + [[package]] 71 + name = "arc-swap" 72 + version = "1.8.2" 73 + source = "registry+https://github.com/rust-lang/crates.io-index" 74 + checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" 75 + dependencies = [ 76 + "rustversion", 77 + ] 78 + 79 + [[package]] 80 + name = "async-lock" 81 + version = "3.4.2" 82 + source = "registry+https://github.com/rust-lang/crates.io-index" 83 + checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" 84 + dependencies = [ 85 + "event-listener", 86 + "event-listener-strategy", 87 + "pin-project-lite", 88 + ] 89 + 90 + [[package]] 91 + name = "async-trait" 92 + version = "0.1.89" 93 + source = "registry+https://github.com/rust-lang/crates.io-index" 94 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 95 + dependencies = [ 96 + "proc-macro2", 97 + "quote", 98 + "syn", 99 + ] 100 + 101 + [[package]] 102 + name = "atomic-waker" 103 + version = "1.1.2" 104 + source = "registry+https://github.com/rust-lang/crates.io-index" 105 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 106 + 107 + [[package]] 108 + name = "autocfg" 109 + version = "1.5.0" 110 + source = "registry+https://github.com/rust-lang/crates.io-index" 111 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 112 + 113 + [[package]] 114 + name = "base64" 115 + version = "0.22.1" 116 + source = "registry+https://github.com/rust-lang/crates.io-index" 117 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 118 + 119 + [[package]] 120 + name = "bitflags" 121 + version = "2.11.0" 122 + source = "registry+https://github.com/rust-lang/crates.io-index" 123 + checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 124 + 125 + [[package]] 126 + name = "bumpalo" 127 + version = "3.20.2" 128 + source = "registry+https://github.com/rust-lang/crates.io-index" 129 + checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" 130 + 131 + [[package]] 132 + name = "bytes" 133 + version = "1.11.1" 134 + source = "registry+https://github.com/rust-lang/crates.io-index" 135 + checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" 136 + 137 + [[package]] 138 + name = "cc" 139 + version = "1.2.56" 140 + source = "registry+https://github.com/rust-lang/crates.io-index" 141 + checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" 142 + dependencies = [ 143 + "find-msvc-tools", 144 + "shlex", 145 + ] 146 + 147 + [[package]] 148 + name = "cfg-if" 149 + version = "1.0.4" 150 + source = "registry+https://github.com/rust-lang/crates.io-index" 151 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 152 + 153 + [[package]] 154 + name = "chrono" 155 + version = "0.4.44" 156 + source = "registry+https://github.com/rust-lang/crates.io-index" 157 + checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" 158 + dependencies = [ 159 + "iana-time-zone", 160 + "js-sys", 161 + "num-traits", 162 + "serde", 163 + "wasm-bindgen", 164 + "windows-link", 165 + ] 166 + 167 + [[package]] 168 + name = "clap" 169 + version = "4.6.0" 170 + source = "registry+https://github.com/rust-lang/crates.io-index" 171 + checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" 172 + dependencies = [ 173 + "clap_builder", 174 + "clap_derive", 175 + ] 176 + 177 + [[package]] 178 + name = "clap_builder" 179 + version = "4.6.0" 180 + source = "registry+https://github.com/rust-lang/crates.io-index" 181 + checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" 182 + dependencies = [ 183 + "anstream", 184 + "anstyle", 185 + "clap_lex", 186 + "strsim", 187 + ] 188 + 189 + [[package]] 190 + name = "clap_derive" 191 + version = "4.6.0" 192 + source = "registry+https://github.com/rust-lang/crates.io-index" 193 + checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" 194 + dependencies = [ 195 + "heck", 196 + "proc-macro2", 197 + "quote", 198 + "syn", 199 + ] 200 + 201 + [[package]] 202 + name = "clap_lex" 203 + version = "1.1.0" 204 + source = "registry+https://github.com/rust-lang/crates.io-index" 205 + checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" 206 + 207 + [[package]] 208 + name = "colorchoice" 209 + version = "1.0.4" 210 + source = "registry+https://github.com/rust-lang/crates.io-index" 211 + checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 212 + 213 + [[package]] 214 + name = "concurrent-queue" 215 + version = "2.5.0" 216 + source = "registry+https://github.com/rust-lang/crates.io-index" 217 + checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" 218 + dependencies = [ 219 + "crossbeam-utils", 220 + ] 221 + 222 + [[package]] 223 + name = "core-foundation" 224 + version = "0.9.4" 225 + source = "registry+https://github.com/rust-lang/crates.io-index" 226 + checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 227 + dependencies = [ 228 + "core-foundation-sys", 229 + "libc", 230 + ] 231 + 232 + [[package]] 233 + name = "core-foundation" 234 + version = "0.10.1" 235 + source = "registry+https://github.com/rust-lang/crates.io-index" 236 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" 237 + dependencies = [ 238 + "core-foundation-sys", 239 + "libc", 240 + ] 241 + 242 + [[package]] 243 + name = "core-foundation-sys" 244 + version = "0.8.7" 245 + source = "registry+https://github.com/rust-lang/crates.io-index" 246 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 247 + 248 + [[package]] 249 + name = "crossbeam-utils" 250 + version = "0.8.21" 251 + source = "registry+https://github.com/rust-lang/crates.io-index" 252 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 253 + 254 + [[package]] 255 + name = "dashmap" 256 + version = "6.1.0" 257 + source = "registry+https://github.com/rust-lang/crates.io-index" 258 + checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" 259 + dependencies = [ 260 + "cfg-if", 261 + "crossbeam-utils", 262 + "hashbrown 0.14.5", 263 + "lock_api", 264 + "once_cell", 265 + "parking_lot_core", 266 + ] 267 + 268 + [[package]] 269 + name = "displaydoc" 270 + version = "0.2.5" 271 + source = "registry+https://github.com/rust-lang/crates.io-index" 272 + checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 273 + dependencies = [ 274 + "proc-macro2", 275 + "quote", 276 + "syn", 277 + ] 278 + 279 + [[package]] 280 + name = "encoding_rs" 281 + version = "0.8.35" 282 + source = "registry+https://github.com/rust-lang/crates.io-index" 283 + checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 284 + dependencies = [ 285 + "cfg-if", 286 + ] 287 + 288 + [[package]] 289 + name = "equivalent" 290 + version = "1.0.2" 291 + source = "registry+https://github.com/rust-lang/crates.io-index" 292 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 293 + 294 + [[package]] 295 + name = "errno" 296 + version = "0.3.14" 297 + source = "registry+https://github.com/rust-lang/crates.io-index" 298 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 299 + dependencies = [ 300 + "libc", 301 + "windows-sys 0.61.2", 302 + ] 303 + 304 + [[package]] 305 + name = "event-listener" 306 + version = "5.4.1" 307 + source = "registry+https://github.com/rust-lang/crates.io-index" 308 + checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" 309 + dependencies = [ 310 + "concurrent-queue", 311 + "parking", 312 + "pin-project-lite", 313 + ] 314 + 315 + [[package]] 316 + name = "event-listener-strategy" 317 + version = "0.5.4" 318 + source = "registry+https://github.com/rust-lang/crates.io-index" 319 + checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" 320 + dependencies = [ 321 + "event-listener", 322 + "pin-project-lite", 323 + ] 324 + 325 + [[package]] 326 + name = "fastrand" 327 + version = "2.3.0" 328 + source = "registry+https://github.com/rust-lang/crates.io-index" 329 + checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 330 + dependencies = [ 331 + "getrandom 0.2.17", 332 + ] 333 + 334 + [[package]] 335 + name = "find-msvc-tools" 336 + version = "0.1.9" 337 + source = "registry+https://github.com/rust-lang/crates.io-index" 338 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 339 + 340 + [[package]] 341 + name = "fnv" 342 + version = "1.0.7" 343 + source = "registry+https://github.com/rust-lang/crates.io-index" 344 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 345 + 346 + [[package]] 347 + name = "foldhash" 348 + version = "0.1.5" 349 + source = "registry+https://github.com/rust-lang/crates.io-index" 350 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 351 + 352 + [[package]] 353 + name = "foreign-types" 354 + version = "0.3.2" 355 + source = "registry+https://github.com/rust-lang/crates.io-index" 356 + checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 357 + dependencies = [ 358 + "foreign-types-shared", 359 + ] 360 + 361 + [[package]] 362 + name = "foreign-types-shared" 363 + version = "0.1.1" 364 + source = "registry+https://github.com/rust-lang/crates.io-index" 365 + checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 366 + 367 + [[package]] 368 + name = "form_urlencoded" 369 + version = "1.2.2" 370 + source = "registry+https://github.com/rust-lang/crates.io-index" 371 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 372 + dependencies = [ 373 + "percent-encoding", 374 + ] 375 + 376 + [[package]] 377 + name = "futures-channel" 378 + version = "0.3.32" 379 + source = "registry+https://github.com/rust-lang/crates.io-index" 380 + checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" 381 + dependencies = [ 382 + "futures-core", 383 + ] 384 + 385 + [[package]] 386 + name = "futures-core" 387 + version = "0.3.32" 388 + source = "registry+https://github.com/rust-lang/crates.io-index" 389 + checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 390 + 391 + [[package]] 392 + name = "futures-sink" 393 + version = "0.3.32" 394 + source = "registry+https://github.com/rust-lang/crates.io-index" 395 + checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" 396 + 397 + [[package]] 398 + name = "futures-task" 399 + version = "0.3.32" 400 + source = "registry+https://github.com/rust-lang/crates.io-index" 401 + checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" 402 + 403 + [[package]] 404 + name = "futures-util" 405 + version = "0.3.32" 406 + source = "registry+https://github.com/rust-lang/crates.io-index" 407 + checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 408 + dependencies = [ 409 + "futures-core", 410 + "futures-task", 411 + "pin-project-lite", 412 + "slab", 413 + ] 414 + 415 + [[package]] 416 + name = "getrandom" 417 + version = "0.2.17" 418 + source = "registry+https://github.com/rust-lang/crates.io-index" 419 + checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" 420 + dependencies = [ 421 + "cfg-if", 422 + "js-sys", 423 + "libc", 424 + "wasi", 425 + "wasm-bindgen", 426 + ] 427 + 428 + [[package]] 429 + name = "getrandom" 430 + version = "0.4.2" 431 + source = "registry+https://github.com/rust-lang/crates.io-index" 432 + checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" 433 + dependencies = [ 434 + "cfg-if", 435 + "libc", 436 + "r-efi", 437 + "wasip2", 438 + "wasip3", 439 + ] 440 + 441 + [[package]] 442 + name = "h2" 443 + version = "0.4.13" 444 + source = "registry+https://github.com/rust-lang/crates.io-index" 445 + checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" 446 + dependencies = [ 447 + "atomic-waker", 448 + "bytes", 449 + "fnv", 450 + "futures-core", 451 + "futures-sink", 452 + "http", 453 + "indexmap", 454 + "slab", 455 + "tokio", 456 + "tokio-util", 457 + "tracing", 458 + ] 459 + 460 + [[package]] 461 + name = "hashbrown" 462 + version = "0.14.5" 463 + source = "registry+https://github.com/rust-lang/crates.io-index" 464 + checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 465 + 466 + [[package]] 467 + name = "hashbrown" 468 + version = "0.15.5" 469 + source = "registry+https://github.com/rust-lang/crates.io-index" 470 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 471 + dependencies = [ 472 + "foldhash", 473 + ] 474 + 475 + [[package]] 476 + name = "hashbrown" 477 + version = "0.16.1" 478 + source = "registry+https://github.com/rust-lang/crates.io-index" 479 + checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 480 + 481 + [[package]] 482 + name = "heck" 483 + version = "0.5.0" 484 + source = "registry+https://github.com/rust-lang/crates.io-index" 485 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 486 + 487 + [[package]] 488 + name = "http" 489 + version = "1.4.0" 490 + source = "registry+https://github.com/rust-lang/crates.io-index" 491 + checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" 492 + dependencies = [ 493 + "bytes", 494 + "itoa", 495 + ] 496 + 497 + [[package]] 498 + name = "http-body" 499 + version = "1.0.1" 500 + source = "registry+https://github.com/rust-lang/crates.io-index" 501 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 502 + dependencies = [ 503 + "bytes", 504 + "http", 505 + ] 506 + 507 + [[package]] 508 + name = "http-body-util" 509 + version = "0.1.3" 510 + source = "registry+https://github.com/rust-lang/crates.io-index" 511 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 512 + dependencies = [ 513 + "bytes", 514 + "futures-core", 515 + "http", 516 + "http-body", 517 + "pin-project-lite", 518 + ] 519 + 520 + [[package]] 521 + name = "httparse" 522 + version = "1.10.1" 523 + source = "registry+https://github.com/rust-lang/crates.io-index" 524 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 525 + 526 + [[package]] 527 + name = "hyper" 528 + version = "1.8.1" 529 + source = "registry+https://github.com/rust-lang/crates.io-index" 530 + checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" 531 + dependencies = [ 532 + "atomic-waker", 533 + "bytes", 534 + "futures-channel", 535 + "futures-core", 536 + "h2", 537 + "http", 538 + "http-body", 539 + "httparse", 540 + "itoa", 541 + "pin-project-lite", 542 + "pin-utils", 543 + "smallvec", 544 + "tokio", 545 + "want", 546 + ] 547 + 548 + [[package]] 549 + name = "hyper-rustls" 550 + version = "0.27.7" 551 + source = "registry+https://github.com/rust-lang/crates.io-index" 552 + checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" 553 + dependencies = [ 554 + "http", 555 + "hyper", 556 + "hyper-util", 557 + "rustls", 558 + "rustls-pki-types", 559 + "tokio", 560 + "tokio-rustls", 561 + "tower-service", 562 + ] 563 + 564 + [[package]] 565 + name = "hyper-tls" 566 + version = "0.6.0" 567 + source = "registry+https://github.com/rust-lang/crates.io-index" 568 + checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" 569 + dependencies = [ 570 + "bytes", 571 + "http-body-util", 572 + "hyper", 573 + "hyper-util", 574 + "native-tls", 575 + "tokio", 576 + "tokio-native-tls", 577 + "tower-service", 578 + ] 579 + 580 + [[package]] 581 + name = "hyper-util" 582 + version = "0.1.20" 583 + source = "registry+https://github.com/rust-lang/crates.io-index" 584 + checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" 585 + dependencies = [ 586 + "base64", 587 + "bytes", 588 + "futures-channel", 589 + "futures-util", 590 + "http", 591 + "http-body", 592 + "hyper", 593 + "ipnet", 594 + "libc", 595 + "percent-encoding", 596 + "pin-project-lite", 597 + "socket2", 598 + "system-configuration", 599 + "tokio", 600 + "tower-service", 601 + "tracing", 602 + "windows-registry", 603 + ] 604 + 605 + [[package]] 606 + name = "iana-time-zone" 607 + version = "0.1.65" 608 + source = "registry+https://github.com/rust-lang/crates.io-index" 609 + checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" 610 + dependencies = [ 611 + "android_system_properties", 612 + "core-foundation-sys", 613 + "iana-time-zone-haiku", 614 + "js-sys", 615 + "log", 616 + "wasm-bindgen", 617 + "windows-core", 618 + ] 619 + 620 + [[package]] 621 + name = "iana-time-zone-haiku" 622 + version = "0.1.2" 623 + source = "registry+https://github.com/rust-lang/crates.io-index" 624 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 625 + dependencies = [ 626 + "cc", 627 + ] 628 + 629 + [[package]] 630 + name = "icu_collections" 631 + version = "2.1.1" 632 + source = "registry+https://github.com/rust-lang/crates.io-index" 633 + checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" 634 + dependencies = [ 635 + "displaydoc", 636 + "potential_utf", 637 + "yoke", 638 + "zerofrom", 639 + "zerovec", 640 + ] 641 + 642 + [[package]] 643 + name = "icu_locale_core" 644 + version = "2.1.1" 645 + source = "registry+https://github.com/rust-lang/crates.io-index" 646 + checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" 647 + dependencies = [ 648 + "displaydoc", 649 + "litemap", 650 + "tinystr", 651 + "writeable", 652 + "zerovec", 653 + ] 654 + 655 + [[package]] 656 + name = "icu_normalizer" 657 + version = "2.1.1" 658 + source = "registry+https://github.com/rust-lang/crates.io-index" 659 + checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" 660 + dependencies = [ 661 + "icu_collections", 662 + "icu_normalizer_data", 663 + "icu_properties", 664 + "icu_provider", 665 + "smallvec", 666 + "zerovec", 667 + ] 668 + 669 + [[package]] 670 + name = "icu_normalizer_data" 671 + version = "2.1.1" 672 + source = "registry+https://github.com/rust-lang/crates.io-index" 673 + checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" 674 + 675 + [[package]] 676 + name = "icu_properties" 677 + version = "2.1.2" 678 + source = "registry+https://github.com/rust-lang/crates.io-index" 679 + checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" 680 + dependencies = [ 681 + "icu_collections", 682 + "icu_locale_core", 683 + "icu_properties_data", 684 + "icu_provider", 685 + "zerotrie", 686 + "zerovec", 687 + ] 688 + 689 + [[package]] 690 + name = "icu_properties_data" 691 + version = "2.1.2" 692 + source = "registry+https://github.com/rust-lang/crates.io-index" 693 + checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" 694 + 695 + [[package]] 696 + name = "icu_provider" 697 + version = "2.1.1" 698 + source = "registry+https://github.com/rust-lang/crates.io-index" 699 + checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" 700 + dependencies = [ 701 + "displaydoc", 702 + "icu_locale_core", 703 + "writeable", 704 + "yoke", 705 + "zerofrom", 706 + "zerotrie", 707 + "zerovec", 708 + ] 709 + 710 + [[package]] 711 + name = "id-arena" 712 + version = "2.3.0" 713 + source = "registry+https://github.com/rust-lang/crates.io-index" 714 + checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" 715 + 716 + [[package]] 717 + name = "idna" 718 + version = "1.1.0" 719 + source = "registry+https://github.com/rust-lang/crates.io-index" 720 + checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 721 + dependencies = [ 722 + "idna_adapter", 723 + "smallvec", 724 + "utf8_iter", 725 + ] 726 + 727 + [[package]] 728 + name = "idna_adapter" 729 + version = "1.2.1" 730 + source = "registry+https://github.com/rust-lang/crates.io-index" 731 + checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" 732 + dependencies = [ 733 + "icu_normalizer", 734 + "icu_properties", 735 + ] 736 + 737 + [[package]] 738 + name = "indexmap" 739 + version = "2.13.0" 740 + source = "registry+https://github.com/rust-lang/crates.io-index" 741 + checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" 742 + dependencies = [ 743 + "equivalent", 744 + "hashbrown 0.16.1", 745 + "serde", 746 + "serde_core", 747 + ] 748 + 749 + [[package]] 750 + name = "ipnet" 751 + version = "2.12.0" 752 + source = "registry+https://github.com/rust-lang/crates.io-index" 753 + checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" 754 + 755 + [[package]] 756 + name = "iri-string" 757 + version = "0.7.10" 758 + source = "registry+https://github.com/rust-lang/crates.io-index" 759 + checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" 760 + dependencies = [ 761 + "memchr", 762 + "serde", 763 + ] 764 + 765 + [[package]] 766 + name = "is_terminal_polyfill" 767 + version = "1.70.2" 768 + source = "registry+https://github.com/rust-lang/crates.io-index" 769 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 770 + 771 + [[package]] 772 + name = "itoa" 773 + version = "1.0.17" 774 + source = "registry+https://github.com/rust-lang/crates.io-index" 775 + checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" 776 + 777 + [[package]] 778 + name = "js-sys" 779 + version = "0.3.91" 780 + source = "registry+https://github.com/rust-lang/crates.io-index" 781 + checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" 782 + dependencies = [ 783 + "once_cell", 784 + "wasm-bindgen", 785 + ] 786 + 787 + [[package]] 788 + name = "leb128fmt" 789 + version = "0.1.0" 790 + source = "registry+https://github.com/rust-lang/crates.io-index" 791 + checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 792 + 793 + [[package]] 794 + name = "libc" 795 + version = "0.2.183" 796 + source = "registry+https://github.com/rust-lang/crates.io-index" 797 + checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" 798 + 799 + [[package]] 800 + name = "linux-raw-sys" 801 + version = "0.12.1" 802 + source = "registry+https://github.com/rust-lang/crates.io-index" 803 + checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" 804 + 805 + [[package]] 806 + name = "litemap" 807 + version = "0.8.1" 808 + source = "registry+https://github.com/rust-lang/crates.io-index" 809 + checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" 810 + 811 + [[package]] 812 + name = "lock_api" 813 + version = "0.4.14" 814 + source = "registry+https://github.com/rust-lang/crates.io-index" 815 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 816 + dependencies = [ 817 + "scopeguard", 818 + ] 819 + 820 + [[package]] 821 + name = "log" 822 + version = "0.4.29" 823 + source = "registry+https://github.com/rust-lang/crates.io-index" 824 + checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 825 + 826 + [[package]] 827 + name = "memchr" 828 + version = "2.8.0" 829 + source = "registry+https://github.com/rust-lang/crates.io-index" 830 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 831 + 832 + [[package]] 833 + name = "mime" 834 + version = "0.3.17" 835 + source = "registry+https://github.com/rust-lang/crates.io-index" 836 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 837 + 838 + [[package]] 839 + name = "mio" 840 + version = "1.1.1" 841 + source = "registry+https://github.com/rust-lang/crates.io-index" 842 + checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" 843 + dependencies = [ 844 + "libc", 845 + "wasi", 846 + "windows-sys 0.61.2", 847 + ] 848 + 849 + [[package]] 850 + name = "native-tls" 851 + version = "0.2.18" 852 + source = "registry+https://github.com/rust-lang/crates.io-index" 853 + checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" 854 + dependencies = [ 855 + "libc", 856 + "log", 857 + "openssl", 858 + "openssl-probe", 859 + "openssl-sys", 860 + "schannel", 861 + "security-framework", 862 + "security-framework-sys", 863 + "tempfile", 864 + ] 865 + 866 + [[package]] 867 + name = "num-traits" 868 + version = "0.2.19" 869 + source = "registry+https://github.com/rust-lang/crates.io-index" 870 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 871 + dependencies = [ 872 + "autocfg", 873 + ] 874 + 875 + [[package]] 876 + name = "once_cell" 877 + version = "1.21.4" 878 + source = "registry+https://github.com/rust-lang/crates.io-index" 879 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" 880 + 881 + [[package]] 882 + name = "once_cell_polyfill" 883 + version = "1.70.2" 884 + source = "registry+https://github.com/rust-lang/crates.io-index" 885 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 886 + 887 + [[package]] 888 + name = "openssl" 889 + version = "0.10.76" 890 + source = "registry+https://github.com/rust-lang/crates.io-index" 891 + checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" 892 + dependencies = [ 893 + "bitflags", 894 + "cfg-if", 895 + "foreign-types", 896 + "libc", 897 + "once_cell", 898 + "openssl-macros", 899 + "openssl-sys", 900 + ] 901 + 902 + [[package]] 903 + name = "openssl-macros" 904 + version = "0.1.1" 905 + source = "registry+https://github.com/rust-lang/crates.io-index" 906 + checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 907 + dependencies = [ 908 + "proc-macro2", 909 + "quote", 910 + "syn", 911 + ] 912 + 913 + [[package]] 914 + name = "openssl-probe" 915 + version = "0.2.1" 916 + source = "registry+https://github.com/rust-lang/crates.io-index" 917 + checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" 918 + 919 + [[package]] 920 + name = "openssl-sys" 921 + version = "0.9.112" 922 + source = "registry+https://github.com/rust-lang/crates.io-index" 923 + checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" 924 + dependencies = [ 925 + "cc", 926 + "libc", 927 + "pkg-config", 928 + "vcpkg", 929 + ] 930 + 931 + [[package]] 932 + name = "parking" 933 + version = "2.2.1" 934 + source = "registry+https://github.com/rust-lang/crates.io-index" 935 + checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" 936 + 937 + [[package]] 938 + name = "parking_lot" 939 + version = "0.12.5" 940 + source = "registry+https://github.com/rust-lang/crates.io-index" 941 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 942 + dependencies = [ 943 + "lock_api", 944 + "parking_lot_core", 945 + ] 946 + 947 + [[package]] 948 + name = "parking_lot_core" 949 + version = "0.9.12" 950 + source = "registry+https://github.com/rust-lang/crates.io-index" 951 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 952 + dependencies = [ 953 + "cfg-if", 954 + "libc", 955 + "redox_syscall", 956 + "smallvec", 957 + "windows-link", 958 + ] 959 + 960 + [[package]] 961 + name = "pds-yrs" 962 + version = "0.1.0" 963 + dependencies = [ 964 + "chrono", 965 + "clap", 966 + "reqwest", 967 + "serde", 968 + "serde_json", 969 + "similar", 970 + "tempfile", 971 + "tokio", 972 + "yrs", 973 + ] 974 + 975 + [[package]] 976 + name = "percent-encoding" 977 + version = "2.3.2" 978 + source = "registry+https://github.com/rust-lang/crates.io-index" 979 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 980 + 981 + [[package]] 982 + name = "pin-project-lite" 983 + version = "0.2.17" 984 + source = "registry+https://github.com/rust-lang/crates.io-index" 985 + checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" 986 + 987 + [[package]] 988 + name = "pin-utils" 989 + version = "0.1.0" 990 + source = "registry+https://github.com/rust-lang/crates.io-index" 991 + checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 992 + 993 + [[package]] 994 + name = "pkg-config" 995 + version = "0.3.32" 996 + source = "registry+https://github.com/rust-lang/crates.io-index" 997 + checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 998 + 999 + [[package]] 1000 + name = "potential_utf" 1001 + version = "0.1.4" 1002 + source = "registry+https://github.com/rust-lang/crates.io-index" 1003 + checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" 1004 + dependencies = [ 1005 + "zerovec", 1006 + ] 1007 + 1008 + [[package]] 1009 + name = "prettyplease" 1010 + version = "0.2.37" 1011 + source = "registry+https://github.com/rust-lang/crates.io-index" 1012 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 1013 + dependencies = [ 1014 + "proc-macro2", 1015 + "syn", 1016 + ] 1017 + 1018 + [[package]] 1019 + name = "proc-macro2" 1020 + version = "1.0.106" 1021 + source = "registry+https://github.com/rust-lang/crates.io-index" 1022 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 1023 + dependencies = [ 1024 + "unicode-ident", 1025 + ] 1026 + 1027 + [[package]] 1028 + name = "quote" 1029 + version = "1.0.45" 1030 + source = "registry+https://github.com/rust-lang/crates.io-index" 1031 + checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" 1032 + dependencies = [ 1033 + "proc-macro2", 1034 + ] 1035 + 1036 + [[package]] 1037 + name = "r-efi" 1038 + version = "6.0.0" 1039 + source = "registry+https://github.com/rust-lang/crates.io-index" 1040 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 1041 + 1042 + [[package]] 1043 + name = "redox_syscall" 1044 + version = "0.5.18" 1045 + source = "registry+https://github.com/rust-lang/crates.io-index" 1046 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 1047 + dependencies = [ 1048 + "bitflags", 1049 + ] 1050 + 1051 + [[package]] 1052 + name = "reqwest" 1053 + version = "0.12.28" 1054 + source = "registry+https://github.com/rust-lang/crates.io-index" 1055 + checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" 1056 + dependencies = [ 1057 + "base64", 1058 + "bytes", 1059 + "encoding_rs", 1060 + "futures-core", 1061 + "h2", 1062 + "http", 1063 + "http-body", 1064 + "http-body-util", 1065 + "hyper", 1066 + "hyper-rustls", 1067 + "hyper-tls", 1068 + "hyper-util", 1069 + "js-sys", 1070 + "log", 1071 + "mime", 1072 + "native-tls", 1073 + "percent-encoding", 1074 + "pin-project-lite", 1075 + "rustls-pki-types", 1076 + "serde", 1077 + "serde_json", 1078 + "serde_urlencoded", 1079 + "sync_wrapper", 1080 + "tokio", 1081 + "tokio-native-tls", 1082 + "tower", 1083 + "tower-http", 1084 + "tower-service", 1085 + "url", 1086 + "wasm-bindgen", 1087 + "wasm-bindgen-futures", 1088 + "web-sys", 1089 + ] 1090 + 1091 + [[package]] 1092 + name = "ring" 1093 + version = "0.17.14" 1094 + source = "registry+https://github.com/rust-lang/crates.io-index" 1095 + checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 1096 + dependencies = [ 1097 + "cc", 1098 + "cfg-if", 1099 + "getrandom 0.2.17", 1100 + "libc", 1101 + "untrusted", 1102 + "windows-sys 0.52.0", 1103 + ] 1104 + 1105 + [[package]] 1106 + name = "rustix" 1107 + version = "1.1.4" 1108 + source = "registry+https://github.com/rust-lang/crates.io-index" 1109 + checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" 1110 + dependencies = [ 1111 + "bitflags", 1112 + "errno", 1113 + "libc", 1114 + "linux-raw-sys", 1115 + "windows-sys 0.61.2", 1116 + ] 1117 + 1118 + [[package]] 1119 + name = "rustls" 1120 + version = "0.23.37" 1121 + source = "registry+https://github.com/rust-lang/crates.io-index" 1122 + checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" 1123 + dependencies = [ 1124 + "once_cell", 1125 + "rustls-pki-types", 1126 + "rustls-webpki", 1127 + "subtle", 1128 + "zeroize", 1129 + ] 1130 + 1131 + [[package]] 1132 + name = "rustls-pki-types" 1133 + version = "1.14.0" 1134 + source = "registry+https://github.com/rust-lang/crates.io-index" 1135 + checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" 1136 + dependencies = [ 1137 + "zeroize", 1138 + ] 1139 + 1140 + [[package]] 1141 + name = "rustls-webpki" 1142 + version = "0.103.9" 1143 + source = "registry+https://github.com/rust-lang/crates.io-index" 1144 + checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" 1145 + dependencies = [ 1146 + "ring", 1147 + "rustls-pki-types", 1148 + "untrusted", 1149 + ] 1150 + 1151 + [[package]] 1152 + name = "rustversion" 1153 + version = "1.0.22" 1154 + source = "registry+https://github.com/rust-lang/crates.io-index" 1155 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 1156 + 1157 + [[package]] 1158 + name = "ryu" 1159 + version = "1.0.23" 1160 + source = "registry+https://github.com/rust-lang/crates.io-index" 1161 + checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 1162 + 1163 + [[package]] 1164 + name = "schannel" 1165 + version = "0.1.29" 1166 + source = "registry+https://github.com/rust-lang/crates.io-index" 1167 + checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" 1168 + dependencies = [ 1169 + "windows-sys 0.61.2", 1170 + ] 1171 + 1172 + [[package]] 1173 + name = "scopeguard" 1174 + version = "1.2.0" 1175 + source = "registry+https://github.com/rust-lang/crates.io-index" 1176 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1177 + 1178 + [[package]] 1179 + name = "security-framework" 1180 + version = "3.7.0" 1181 + source = "registry+https://github.com/rust-lang/crates.io-index" 1182 + checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" 1183 + dependencies = [ 1184 + "bitflags", 1185 + "core-foundation 0.10.1", 1186 + "core-foundation-sys", 1187 + "libc", 1188 + "security-framework-sys", 1189 + ] 1190 + 1191 + [[package]] 1192 + name = "security-framework-sys" 1193 + version = "2.17.0" 1194 + source = "registry+https://github.com/rust-lang/crates.io-index" 1195 + checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" 1196 + dependencies = [ 1197 + "core-foundation-sys", 1198 + "libc", 1199 + ] 1200 + 1201 + [[package]] 1202 + name = "semver" 1203 + version = "1.0.27" 1204 + source = "registry+https://github.com/rust-lang/crates.io-index" 1205 + checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" 1206 + 1207 + [[package]] 1208 + name = "serde" 1209 + version = "1.0.228" 1210 + source = "registry+https://github.com/rust-lang/crates.io-index" 1211 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 1212 + dependencies = [ 1213 + "serde_core", 1214 + "serde_derive", 1215 + ] 1216 + 1217 + [[package]] 1218 + name = "serde_core" 1219 + version = "1.0.228" 1220 + source = "registry+https://github.com/rust-lang/crates.io-index" 1221 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 1222 + dependencies = [ 1223 + "serde_derive", 1224 + ] 1225 + 1226 + [[package]] 1227 + name = "serde_derive" 1228 + version = "1.0.228" 1229 + source = "registry+https://github.com/rust-lang/crates.io-index" 1230 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 1231 + dependencies = [ 1232 + "proc-macro2", 1233 + "quote", 1234 + "syn", 1235 + ] 1236 + 1237 + [[package]] 1238 + name = "serde_json" 1239 + version = "1.0.149" 1240 + source = "registry+https://github.com/rust-lang/crates.io-index" 1241 + checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" 1242 + dependencies = [ 1243 + "itoa", 1244 + "memchr", 1245 + "serde", 1246 + "serde_core", 1247 + "zmij", 1248 + ] 1249 + 1250 + [[package]] 1251 + name = "serde_urlencoded" 1252 + version = "0.7.1" 1253 + source = "registry+https://github.com/rust-lang/crates.io-index" 1254 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1255 + dependencies = [ 1256 + "form_urlencoded", 1257 + "itoa", 1258 + "ryu", 1259 + "serde", 1260 + ] 1261 + 1262 + [[package]] 1263 + name = "shlex" 1264 + version = "1.3.0" 1265 + source = "registry+https://github.com/rust-lang/crates.io-index" 1266 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1267 + 1268 + [[package]] 1269 + name = "signal-hook-registry" 1270 + version = "1.4.8" 1271 + source = "registry+https://github.com/rust-lang/crates.io-index" 1272 + checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" 1273 + dependencies = [ 1274 + "errno", 1275 + "libc", 1276 + ] 1277 + 1278 + [[package]] 1279 + name = "similar" 1280 + version = "2.7.0" 1281 + source = "registry+https://github.com/rust-lang/crates.io-index" 1282 + checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" 1283 + 1284 + [[package]] 1285 + name = "slab" 1286 + version = "0.4.12" 1287 + source = "registry+https://github.com/rust-lang/crates.io-index" 1288 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" 1289 + 1290 + [[package]] 1291 + name = "smallstr" 1292 + version = "0.3.1" 1293 + source = "registry+https://github.com/rust-lang/crates.io-index" 1294 + checksum = "862077b1e764f04c251fe82a2ef562fd78d7cadaeb072ca7c2bcaf7217b1ff3b" 1295 + dependencies = [ 1296 + "smallvec", 1297 + ] 1298 + 1299 + [[package]] 1300 + name = "smallvec" 1301 + version = "1.15.1" 1302 + source = "registry+https://github.com/rust-lang/crates.io-index" 1303 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 1304 + 1305 + [[package]] 1306 + name = "socket2" 1307 + version = "0.6.3" 1308 + source = "registry+https://github.com/rust-lang/crates.io-index" 1309 + checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" 1310 + dependencies = [ 1311 + "libc", 1312 + "windows-sys 0.61.2", 1313 + ] 1314 + 1315 + [[package]] 1316 + name = "stable_deref_trait" 1317 + version = "1.2.1" 1318 + source = "registry+https://github.com/rust-lang/crates.io-index" 1319 + checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 1320 + 1321 + [[package]] 1322 + name = "strsim" 1323 + version = "0.11.1" 1324 + source = "registry+https://github.com/rust-lang/crates.io-index" 1325 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1326 + 1327 + [[package]] 1328 + name = "subtle" 1329 + version = "2.6.1" 1330 + source = "registry+https://github.com/rust-lang/crates.io-index" 1331 + checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 1332 + 1333 + [[package]] 1334 + name = "syn" 1335 + version = "2.0.117" 1336 + source = "registry+https://github.com/rust-lang/crates.io-index" 1337 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 1338 + dependencies = [ 1339 + "proc-macro2", 1340 + "quote", 1341 + "unicode-ident", 1342 + ] 1343 + 1344 + [[package]] 1345 + name = "sync_wrapper" 1346 + version = "1.0.2" 1347 + source = "registry+https://github.com/rust-lang/crates.io-index" 1348 + checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 1349 + dependencies = [ 1350 + "futures-core", 1351 + ] 1352 + 1353 + [[package]] 1354 + name = "synstructure" 1355 + version = "0.13.2" 1356 + source = "registry+https://github.com/rust-lang/crates.io-index" 1357 + checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" 1358 + dependencies = [ 1359 + "proc-macro2", 1360 + "quote", 1361 + "syn", 1362 + ] 1363 + 1364 + [[package]] 1365 + name = "system-configuration" 1366 + version = "0.7.0" 1367 + source = "registry+https://github.com/rust-lang/crates.io-index" 1368 + checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" 1369 + dependencies = [ 1370 + "bitflags", 1371 + "core-foundation 0.9.4", 1372 + "system-configuration-sys", 1373 + ] 1374 + 1375 + [[package]] 1376 + name = "system-configuration-sys" 1377 + version = "0.6.0" 1378 + source = "registry+https://github.com/rust-lang/crates.io-index" 1379 + checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" 1380 + dependencies = [ 1381 + "core-foundation-sys", 1382 + "libc", 1383 + ] 1384 + 1385 + [[package]] 1386 + name = "tempfile" 1387 + version = "3.27.0" 1388 + source = "registry+https://github.com/rust-lang/crates.io-index" 1389 + checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" 1390 + dependencies = [ 1391 + "fastrand", 1392 + "getrandom 0.4.2", 1393 + "once_cell", 1394 + "rustix", 1395 + "windows-sys 0.61.2", 1396 + ] 1397 + 1398 + [[package]] 1399 + name = "thiserror" 1400 + version = "2.0.18" 1401 + source = "registry+https://github.com/rust-lang/crates.io-index" 1402 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 1403 + dependencies = [ 1404 + "thiserror-impl", 1405 + ] 1406 + 1407 + [[package]] 1408 + name = "thiserror-impl" 1409 + version = "2.0.18" 1410 + source = "registry+https://github.com/rust-lang/crates.io-index" 1411 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 1412 + dependencies = [ 1413 + "proc-macro2", 1414 + "quote", 1415 + "syn", 1416 + ] 1417 + 1418 + [[package]] 1419 + name = "tinystr" 1420 + version = "0.8.2" 1421 + source = "registry+https://github.com/rust-lang/crates.io-index" 1422 + checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" 1423 + dependencies = [ 1424 + "displaydoc", 1425 + "zerovec", 1426 + ] 1427 + 1428 + [[package]] 1429 + name = "tokio" 1430 + version = "1.50.0" 1431 + source = "registry+https://github.com/rust-lang/crates.io-index" 1432 + checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" 1433 + dependencies = [ 1434 + "bytes", 1435 + "libc", 1436 + "mio", 1437 + "parking_lot", 1438 + "pin-project-lite", 1439 + "signal-hook-registry", 1440 + "socket2", 1441 + "tokio-macros", 1442 + "windows-sys 0.61.2", 1443 + ] 1444 + 1445 + [[package]] 1446 + name = "tokio-macros" 1447 + version = "2.6.1" 1448 + source = "registry+https://github.com/rust-lang/crates.io-index" 1449 + checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" 1450 + dependencies = [ 1451 + "proc-macro2", 1452 + "quote", 1453 + "syn", 1454 + ] 1455 + 1456 + [[package]] 1457 + name = "tokio-native-tls" 1458 + version = "0.3.1" 1459 + source = "registry+https://github.com/rust-lang/crates.io-index" 1460 + checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" 1461 + dependencies = [ 1462 + "native-tls", 1463 + "tokio", 1464 + ] 1465 + 1466 + [[package]] 1467 + name = "tokio-rustls" 1468 + version = "0.26.4" 1469 + source = "registry+https://github.com/rust-lang/crates.io-index" 1470 + checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" 1471 + dependencies = [ 1472 + "rustls", 1473 + "tokio", 1474 + ] 1475 + 1476 + [[package]] 1477 + name = "tokio-util" 1478 + version = "0.7.18" 1479 + source = "registry+https://github.com/rust-lang/crates.io-index" 1480 + checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" 1481 + dependencies = [ 1482 + "bytes", 1483 + "futures-core", 1484 + "futures-sink", 1485 + "pin-project-lite", 1486 + "tokio", 1487 + ] 1488 + 1489 + [[package]] 1490 + name = "tower" 1491 + version = "0.5.3" 1492 + source = "registry+https://github.com/rust-lang/crates.io-index" 1493 + checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" 1494 + dependencies = [ 1495 + "futures-core", 1496 + "futures-util", 1497 + "pin-project-lite", 1498 + "sync_wrapper", 1499 + "tokio", 1500 + "tower-layer", 1501 + "tower-service", 1502 + ] 1503 + 1504 + [[package]] 1505 + name = "tower-http" 1506 + version = "0.6.8" 1507 + source = "registry+https://github.com/rust-lang/crates.io-index" 1508 + checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" 1509 + dependencies = [ 1510 + "bitflags", 1511 + "bytes", 1512 + "futures-util", 1513 + "http", 1514 + "http-body", 1515 + "iri-string", 1516 + "pin-project-lite", 1517 + "tower", 1518 + "tower-layer", 1519 + "tower-service", 1520 + ] 1521 + 1522 + [[package]] 1523 + name = "tower-layer" 1524 + version = "0.3.3" 1525 + source = "registry+https://github.com/rust-lang/crates.io-index" 1526 + checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 1527 + 1528 + [[package]] 1529 + name = "tower-service" 1530 + version = "0.3.3" 1531 + source = "registry+https://github.com/rust-lang/crates.io-index" 1532 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 1533 + 1534 + [[package]] 1535 + name = "tracing" 1536 + version = "0.1.44" 1537 + source = "registry+https://github.com/rust-lang/crates.io-index" 1538 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 1539 + dependencies = [ 1540 + "pin-project-lite", 1541 + "tracing-core", 1542 + ] 1543 + 1544 + [[package]] 1545 + name = "tracing-core" 1546 + version = "0.1.36" 1547 + source = "registry+https://github.com/rust-lang/crates.io-index" 1548 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 1549 + dependencies = [ 1550 + "once_cell", 1551 + ] 1552 + 1553 + [[package]] 1554 + name = "try-lock" 1555 + version = "0.2.5" 1556 + source = "registry+https://github.com/rust-lang/crates.io-index" 1557 + checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 1558 + 1559 + [[package]] 1560 + name = "unicode-ident" 1561 + version = "1.0.24" 1562 + source = "registry+https://github.com/rust-lang/crates.io-index" 1563 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 1564 + 1565 + [[package]] 1566 + name = "unicode-xid" 1567 + version = "0.2.6" 1568 + source = "registry+https://github.com/rust-lang/crates.io-index" 1569 + checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 1570 + 1571 + [[package]] 1572 + name = "untrusted" 1573 + version = "0.9.0" 1574 + source = "registry+https://github.com/rust-lang/crates.io-index" 1575 + checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 1576 + 1577 + [[package]] 1578 + name = "url" 1579 + version = "2.5.8" 1580 + source = "registry+https://github.com/rust-lang/crates.io-index" 1581 + checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" 1582 + dependencies = [ 1583 + "form_urlencoded", 1584 + "idna", 1585 + "percent-encoding", 1586 + "serde", 1587 + ] 1588 + 1589 + [[package]] 1590 + name = "utf8_iter" 1591 + version = "1.0.4" 1592 + source = "registry+https://github.com/rust-lang/crates.io-index" 1593 + checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 1594 + 1595 + [[package]] 1596 + name = "utf8parse" 1597 + version = "0.2.2" 1598 + source = "registry+https://github.com/rust-lang/crates.io-index" 1599 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1600 + 1601 + [[package]] 1602 + name = "vcpkg" 1603 + version = "0.2.15" 1604 + source = "registry+https://github.com/rust-lang/crates.io-index" 1605 + checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 1606 + 1607 + [[package]] 1608 + name = "want" 1609 + version = "0.3.1" 1610 + source = "registry+https://github.com/rust-lang/crates.io-index" 1611 + checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 1612 + dependencies = [ 1613 + "try-lock", 1614 + ] 1615 + 1616 + [[package]] 1617 + name = "wasi" 1618 + version = "0.11.1+wasi-snapshot-preview1" 1619 + source = "registry+https://github.com/rust-lang/crates.io-index" 1620 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 1621 + 1622 + [[package]] 1623 + name = "wasip2" 1624 + version = "1.0.2+wasi-0.2.9" 1625 + source = "registry+https://github.com/rust-lang/crates.io-index" 1626 + checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" 1627 + dependencies = [ 1628 + "wit-bindgen", 1629 + ] 1630 + 1631 + [[package]] 1632 + name = "wasip3" 1633 + version = "0.4.0+wasi-0.3.0-rc-2026-01-06" 1634 + source = "registry+https://github.com/rust-lang/crates.io-index" 1635 + checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" 1636 + dependencies = [ 1637 + "wit-bindgen", 1638 + ] 1639 + 1640 + [[package]] 1641 + name = "wasm-bindgen" 1642 + version = "0.2.114" 1643 + source = "registry+https://github.com/rust-lang/crates.io-index" 1644 + checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" 1645 + dependencies = [ 1646 + "cfg-if", 1647 + "once_cell", 1648 + "rustversion", 1649 + "wasm-bindgen-macro", 1650 + "wasm-bindgen-shared", 1651 + ] 1652 + 1653 + [[package]] 1654 + name = "wasm-bindgen-futures" 1655 + version = "0.4.64" 1656 + source = "registry+https://github.com/rust-lang/crates.io-index" 1657 + checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" 1658 + dependencies = [ 1659 + "cfg-if", 1660 + "futures-util", 1661 + "js-sys", 1662 + "once_cell", 1663 + "wasm-bindgen", 1664 + "web-sys", 1665 + ] 1666 + 1667 + [[package]] 1668 + name = "wasm-bindgen-macro" 1669 + version = "0.2.114" 1670 + source = "registry+https://github.com/rust-lang/crates.io-index" 1671 + checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" 1672 + dependencies = [ 1673 + "quote", 1674 + "wasm-bindgen-macro-support", 1675 + ] 1676 + 1677 + [[package]] 1678 + name = "wasm-bindgen-macro-support" 1679 + version = "0.2.114" 1680 + source = "registry+https://github.com/rust-lang/crates.io-index" 1681 + checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" 1682 + dependencies = [ 1683 + "bumpalo", 1684 + "proc-macro2", 1685 + "quote", 1686 + "syn", 1687 + "wasm-bindgen-shared", 1688 + ] 1689 + 1690 + [[package]] 1691 + name = "wasm-bindgen-shared" 1692 + version = "0.2.114" 1693 + source = "registry+https://github.com/rust-lang/crates.io-index" 1694 + checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" 1695 + dependencies = [ 1696 + "unicode-ident", 1697 + ] 1698 + 1699 + [[package]] 1700 + name = "wasm-encoder" 1701 + version = "0.244.0" 1702 + source = "registry+https://github.com/rust-lang/crates.io-index" 1703 + checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" 1704 + dependencies = [ 1705 + "leb128fmt", 1706 + "wasmparser", 1707 + ] 1708 + 1709 + [[package]] 1710 + name = "wasm-metadata" 1711 + version = "0.244.0" 1712 + source = "registry+https://github.com/rust-lang/crates.io-index" 1713 + checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" 1714 + dependencies = [ 1715 + "anyhow", 1716 + "indexmap", 1717 + "wasm-encoder", 1718 + "wasmparser", 1719 + ] 1720 + 1721 + [[package]] 1722 + name = "wasmparser" 1723 + version = "0.244.0" 1724 + source = "registry+https://github.com/rust-lang/crates.io-index" 1725 + checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" 1726 + dependencies = [ 1727 + "bitflags", 1728 + "hashbrown 0.15.5", 1729 + "indexmap", 1730 + "semver", 1731 + ] 1732 + 1733 + [[package]] 1734 + name = "web-sys" 1735 + version = "0.3.91" 1736 + source = "registry+https://github.com/rust-lang/crates.io-index" 1737 + checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" 1738 + dependencies = [ 1739 + "js-sys", 1740 + "wasm-bindgen", 1741 + ] 1742 + 1743 + [[package]] 1744 + name = "windows-core" 1745 + version = "0.62.2" 1746 + source = "registry+https://github.com/rust-lang/crates.io-index" 1747 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 1748 + dependencies = [ 1749 + "windows-implement", 1750 + "windows-interface", 1751 + "windows-link", 1752 + "windows-result", 1753 + "windows-strings", 1754 + ] 1755 + 1756 + [[package]] 1757 + name = "windows-implement" 1758 + version = "0.60.2" 1759 + source = "registry+https://github.com/rust-lang/crates.io-index" 1760 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 1761 + dependencies = [ 1762 + "proc-macro2", 1763 + "quote", 1764 + "syn", 1765 + ] 1766 + 1767 + [[package]] 1768 + name = "windows-interface" 1769 + version = "0.59.3" 1770 + source = "registry+https://github.com/rust-lang/crates.io-index" 1771 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 1772 + dependencies = [ 1773 + "proc-macro2", 1774 + "quote", 1775 + "syn", 1776 + ] 1777 + 1778 + [[package]] 1779 + name = "windows-link" 1780 + version = "0.2.1" 1781 + source = "registry+https://github.com/rust-lang/crates.io-index" 1782 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 1783 + 1784 + [[package]] 1785 + name = "windows-registry" 1786 + version = "0.6.1" 1787 + source = "registry+https://github.com/rust-lang/crates.io-index" 1788 + checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" 1789 + dependencies = [ 1790 + "windows-link", 1791 + "windows-result", 1792 + "windows-strings", 1793 + ] 1794 + 1795 + [[package]] 1796 + name = "windows-result" 1797 + version = "0.4.1" 1798 + source = "registry+https://github.com/rust-lang/crates.io-index" 1799 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 1800 + dependencies = [ 1801 + "windows-link", 1802 + ] 1803 + 1804 + [[package]] 1805 + name = "windows-strings" 1806 + version = "0.5.1" 1807 + source = "registry+https://github.com/rust-lang/crates.io-index" 1808 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 1809 + dependencies = [ 1810 + "windows-link", 1811 + ] 1812 + 1813 + [[package]] 1814 + name = "windows-sys" 1815 + version = "0.52.0" 1816 + source = "registry+https://github.com/rust-lang/crates.io-index" 1817 + checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1818 + dependencies = [ 1819 + "windows-targets", 1820 + ] 1821 + 1822 + [[package]] 1823 + name = "windows-sys" 1824 + version = "0.61.2" 1825 + source = "registry+https://github.com/rust-lang/crates.io-index" 1826 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 1827 + dependencies = [ 1828 + "windows-link", 1829 + ] 1830 + 1831 + [[package]] 1832 + name = "windows-targets" 1833 + version = "0.52.6" 1834 + source = "registry+https://github.com/rust-lang/crates.io-index" 1835 + checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1836 + dependencies = [ 1837 + "windows_aarch64_gnullvm", 1838 + "windows_aarch64_msvc", 1839 + "windows_i686_gnu", 1840 + "windows_i686_gnullvm", 1841 + "windows_i686_msvc", 1842 + "windows_x86_64_gnu", 1843 + "windows_x86_64_gnullvm", 1844 + "windows_x86_64_msvc", 1845 + ] 1846 + 1847 + [[package]] 1848 + name = "windows_aarch64_gnullvm" 1849 + version = "0.52.6" 1850 + source = "registry+https://github.com/rust-lang/crates.io-index" 1851 + checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1852 + 1853 + [[package]] 1854 + name = "windows_aarch64_msvc" 1855 + version = "0.52.6" 1856 + source = "registry+https://github.com/rust-lang/crates.io-index" 1857 + checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1858 + 1859 + [[package]] 1860 + name = "windows_i686_gnu" 1861 + version = "0.52.6" 1862 + source = "registry+https://github.com/rust-lang/crates.io-index" 1863 + checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1864 + 1865 + [[package]] 1866 + name = "windows_i686_gnullvm" 1867 + version = "0.52.6" 1868 + source = "registry+https://github.com/rust-lang/crates.io-index" 1869 + checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1870 + 1871 + [[package]] 1872 + name = "windows_i686_msvc" 1873 + version = "0.52.6" 1874 + source = "registry+https://github.com/rust-lang/crates.io-index" 1875 + checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1876 + 1877 + [[package]] 1878 + name = "windows_x86_64_gnu" 1879 + version = "0.52.6" 1880 + source = "registry+https://github.com/rust-lang/crates.io-index" 1881 + checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1882 + 1883 + [[package]] 1884 + name = "windows_x86_64_gnullvm" 1885 + version = "0.52.6" 1886 + source = "registry+https://github.com/rust-lang/crates.io-index" 1887 + checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1888 + 1889 + [[package]] 1890 + name = "windows_x86_64_msvc" 1891 + version = "0.52.6" 1892 + source = "registry+https://github.com/rust-lang/crates.io-index" 1893 + checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1894 + 1895 + [[package]] 1896 + name = "wit-bindgen" 1897 + version = "0.51.0" 1898 + source = "registry+https://github.com/rust-lang/crates.io-index" 1899 + checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 1900 + dependencies = [ 1901 + "wit-bindgen-rust-macro", 1902 + ] 1903 + 1904 + [[package]] 1905 + name = "wit-bindgen-core" 1906 + version = "0.51.0" 1907 + source = "registry+https://github.com/rust-lang/crates.io-index" 1908 + checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" 1909 + dependencies = [ 1910 + "anyhow", 1911 + "heck", 1912 + "wit-parser", 1913 + ] 1914 + 1915 + [[package]] 1916 + name = "wit-bindgen-rust" 1917 + version = "0.51.0" 1918 + source = "registry+https://github.com/rust-lang/crates.io-index" 1919 + checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" 1920 + dependencies = [ 1921 + "anyhow", 1922 + "heck", 1923 + "indexmap", 1924 + "prettyplease", 1925 + "syn", 1926 + "wasm-metadata", 1927 + "wit-bindgen-core", 1928 + "wit-component", 1929 + ] 1930 + 1931 + [[package]] 1932 + name = "wit-bindgen-rust-macro" 1933 + version = "0.51.0" 1934 + source = "registry+https://github.com/rust-lang/crates.io-index" 1935 + checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" 1936 + dependencies = [ 1937 + "anyhow", 1938 + "prettyplease", 1939 + "proc-macro2", 1940 + "quote", 1941 + "syn", 1942 + "wit-bindgen-core", 1943 + "wit-bindgen-rust", 1944 + ] 1945 + 1946 + [[package]] 1947 + name = "wit-component" 1948 + version = "0.244.0" 1949 + source = "registry+https://github.com/rust-lang/crates.io-index" 1950 + checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" 1951 + dependencies = [ 1952 + "anyhow", 1953 + "bitflags", 1954 + "indexmap", 1955 + "log", 1956 + "serde", 1957 + "serde_derive", 1958 + "serde_json", 1959 + "wasm-encoder", 1960 + "wasm-metadata", 1961 + "wasmparser", 1962 + "wit-parser", 1963 + ] 1964 + 1965 + [[package]] 1966 + name = "wit-parser" 1967 + version = "0.244.0" 1968 + source = "registry+https://github.com/rust-lang/crates.io-index" 1969 + checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" 1970 + dependencies = [ 1971 + "anyhow", 1972 + "id-arena", 1973 + "indexmap", 1974 + "log", 1975 + "semver", 1976 + "serde", 1977 + "serde_derive", 1978 + "serde_json", 1979 + "unicode-xid", 1980 + "wasmparser", 1981 + ] 1982 + 1983 + [[package]] 1984 + name = "writeable" 1985 + version = "0.6.2" 1986 + source = "registry+https://github.com/rust-lang/crates.io-index" 1987 + checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 1988 + 1989 + [[package]] 1990 + name = "yoke" 1991 + version = "0.8.1" 1992 + source = "registry+https://github.com/rust-lang/crates.io-index" 1993 + checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" 1994 + dependencies = [ 1995 + "stable_deref_trait", 1996 + "yoke-derive", 1997 + "zerofrom", 1998 + ] 1999 + 2000 + [[package]] 2001 + name = "yoke-derive" 2002 + version = "0.8.1" 2003 + source = "registry+https://github.com/rust-lang/crates.io-index" 2004 + checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" 2005 + dependencies = [ 2006 + "proc-macro2", 2007 + "quote", 2008 + "syn", 2009 + "synstructure", 2010 + ] 2011 + 2012 + [[package]] 2013 + name = "yrs" 2014 + version = "0.25.0" 2015 + source = "registry+https://github.com/rust-lang/crates.io-index" 2016 + checksum = "f6893d39bc55d014e4a1d0e71d06c0c41590d5cdeac35c126be44998bc320cff" 2017 + dependencies = [ 2018 + "arc-swap", 2019 + "async-lock", 2020 + "async-trait", 2021 + "dashmap", 2022 + "fastrand", 2023 + "serde", 2024 + "serde_json", 2025 + "smallstr", 2026 + "smallvec", 2027 + "thiserror", 2028 + ] 2029 + 2030 + [[package]] 2031 + name = "zerofrom" 2032 + version = "0.1.6" 2033 + source = "registry+https://github.com/rust-lang/crates.io-index" 2034 + checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 2035 + dependencies = [ 2036 + "zerofrom-derive", 2037 + ] 2038 + 2039 + [[package]] 2040 + name = "zerofrom-derive" 2041 + version = "0.1.6" 2042 + source = "registry+https://github.com/rust-lang/crates.io-index" 2043 + checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 2044 + dependencies = [ 2045 + "proc-macro2", 2046 + "quote", 2047 + "syn", 2048 + "synstructure", 2049 + ] 2050 + 2051 + [[package]] 2052 + name = "zeroize" 2053 + version = "1.8.2" 2054 + source = "registry+https://github.com/rust-lang/crates.io-index" 2055 + checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 2056 + 2057 + [[package]] 2058 + name = "zerotrie" 2059 + version = "0.2.3" 2060 + source = "registry+https://github.com/rust-lang/crates.io-index" 2061 + checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" 2062 + dependencies = [ 2063 + "displaydoc", 2064 + "yoke", 2065 + "zerofrom", 2066 + ] 2067 + 2068 + [[package]] 2069 + name = "zerovec" 2070 + version = "0.11.5" 2071 + source = "registry+https://github.com/rust-lang/crates.io-index" 2072 + checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" 2073 + dependencies = [ 2074 + "yoke", 2075 + "zerofrom", 2076 + "zerovec-derive", 2077 + ] 2078 + 2079 + [[package]] 2080 + name = "zerovec-derive" 2081 + version = "0.11.2" 2082 + source = "registry+https://github.com/rust-lang/crates.io-index" 2083 + checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" 2084 + dependencies = [ 2085 + "proc-macro2", 2086 + "quote", 2087 + "syn", 2088 + ] 2089 + 2090 + [[package]] 2091 + name = "zmij" 2092 + version = "1.0.21" 2093 + source = "registry+https://github.com/rust-lang/crates.io-index" 2094 + checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+28
Cargo.toml
··· 1 + [workspace] 2 + 3 + [package] 4 + name = "pds-yrs" 5 + version = "0.1.0" 6 + edition = "2021" 7 + description = "Sync Yrs CRDT documents via AT Protocol PDS" 8 + license = "MIT" 9 + 10 + [[bin]] 11 + name = "pds-yrs" 12 + path = "src/main.rs" 13 + 14 + [dependencies] 15 + yrs = "0.25.0" 16 + reqwest = { version = "0.12", features = ["json"] } 17 + serde = { version = "1.0", features = ["derive"] } 18 + serde_json = "1.0" 19 + clap = { version = "4.5", features = ["derive"] } 20 + tokio = { version = "1", features = ["full"] } 21 + chrono = { version = "0.4", features = ["serde"] } 22 + similar = "2.6" 23 + 24 + [dev-dependencies] 25 + tempfile = "3" 26 + 27 + [features] 28 + e2e = []
+128
e2e-tests/run.sh
··· 1 + #!/usr/bin/env bash 2 + # End-to-end tests for pds-yrs against a real remote PDS. 3 + # 4 + # Uses testuser.toml for credentials. No local PDS needed. 5 + # 6 + # Usage: ./e2e-tests/run.sh 7 + set -euo pipefail 8 + 9 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 10 + CRATE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" 11 + PASS=0 12 + FAIL=0 13 + 14 + # ── load credentials from testuser.toml ────────────────────────── 15 + TOML="${CRATE_DIR}/testuser.toml" 16 + if [[ ! -f "$TOML" ]]; then 17 + echo "ERROR: testuser.toml not found at $TOML" 18 + exit 1 19 + fi 20 + PDS_URL=$(grep '^pds' "$TOML" | cut -d'"' -f2) 21 + PDS_HANDLE=$(grep '^handle' "$TOML" | cut -d'"' -f2) 22 + PDS_PASSWORD=$(grep '^password' "$TOML" | cut -d'"' -f2) 23 + 24 + # ── build ──────────────────────────────────────────────────────── 25 + echo "Building pds-yrs..." 26 + (cd "$CRATE_DIR" && cargo build --quiet) 27 + BINARY="${CRATE_DIR}/target/debug/pds-yrs" 28 + 29 + # ── check PDS availability ────────────────────────────────────── 30 + if ! curl -sf "${PDS_URL}/xrpc/_health" > /dev/null 2>&1; then 31 + echo "SKIP: PDS not available at ${PDS_URL}" 32 + exit 0 33 + fi 34 + 35 + # ── unique rkey ────────────────────────────────────────────────── 36 + RKEY="test-pds-yrs-$(date +%s%N)" 37 + 38 + # ── temp dirs ──────────────────────────────────────────────────── 39 + TMPDIR_BASE="$(mktemp -d)" 40 + trap "rm -rf ${TMPDIR_BASE}" EXIT 41 + 42 + pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } 43 + fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } 44 + 45 + SITE_DIR="${TMPDIR_BASE}/site" 46 + LOAD_DIR="${TMPDIR_BASE}/loaded" 47 + EXPORT_DIR="${TMPDIR_BASE}/exported" 48 + 49 + # ── Test 1: save + load round-trip ─────────────────────────────── 50 + echo "" 51 + echo "=== Test 1: save + load round-trip ===" 52 + mkdir -p "${SITE_DIR}/blog" 53 + echo "# Home" > "${SITE_DIR}/index.md" 54 + echo "# About" > "${SITE_DIR}/about.md" 55 + echo "# Post" > "${SITE_DIR}/blog/first.md" 56 + 57 + if $BINARY save --dir "${SITE_DIR}" --handle "${PDS_HANDLE}" --site "${RKEY}" --password "${PDS_PASSWORD}" --pds "${PDS_URL}" 2>&1; then 58 + pass "save succeeded" 59 + else 60 + fail "save failed" 61 + fi 62 + 63 + if $BINARY load --handle "${PDS_HANDLE}" --site "${RKEY}" --output "${LOAD_DIR}" --password "${PDS_PASSWORD}" --pds "${PDS_URL}" 2>&1; then 64 + pass "load succeeded" 65 + else 66 + fail "load failed" 67 + fi 68 + 69 + if diff "${SITE_DIR}/index.md" "${LOAD_DIR}/index.md" > /dev/null 2>&1; then 70 + pass "index.md matches" 71 + else 72 + fail "index.md mismatch" 73 + fi 74 + 75 + if diff "${SITE_DIR}/blog/first.md" "${LOAD_DIR}/blog/first.md" > /dev/null 2>&1; then 76 + pass "blog/first.md matches" 77 + else 78 + fail "blog/first.md mismatch" 79 + fi 80 + 81 + # ── Test 2: export (plain text, no Yrs) ───────────────────────── 82 + echo "" 83 + echo "=== Test 2: export ===" 84 + if $BINARY export --handle "${PDS_HANDLE}" --site "${RKEY}" --output "${EXPORT_DIR}" --password "${PDS_PASSWORD}" --pds "${PDS_URL}" 2>&1; then 85 + pass "export succeeded" 86 + else 87 + fail "export failed" 88 + fi 89 + 90 + if diff "${LOAD_DIR}/index.md" "${EXPORT_DIR}/index.md" > /dev/null 2>&1; then 91 + pass "export matches load" 92 + else 93 + fail "export does not match load" 94 + fi 95 + 96 + # ── Test 3: incremental save ──────────────────────────────────── 97 + echo "" 98 + echo "=== Test 3: incremental save ===" 99 + echo "# Home Updated" > "${SITE_DIR}/index.md" 100 + if $BINARY save --dir "${SITE_DIR}" --handle "${PDS_HANDLE}" --site "${RKEY}" --password "${PDS_PASSWORD}" --pds "${PDS_URL}" 2>&1; then 101 + pass "incremental save succeeded" 102 + else 103 + fail "incremental save failed" 104 + fi 105 + 106 + LOAD_DIR2="${TMPDIR_BASE}/loaded2" 107 + $BINARY load --handle "${PDS_HANDLE}" --site "${RKEY}" --output "${LOAD_DIR2}" --password "${PDS_PASSWORD}" --pds "${PDS_URL}" 2>&1 108 + 109 + LOADED_INDEX=$(cat "${LOAD_DIR2}/index.md") 110 + if [[ "$LOADED_INDEX" == "# Home Updated" ]]; then 111 + pass "incremental content correct" 112 + else 113 + fail "incremental content wrong: $LOADED_INDEX" 114 + fi 115 + 116 + # Verify unchanged file still correct 117 + if diff "${SITE_DIR}/about.md" "${LOAD_DIR2}/about.md" > /dev/null 2>&1; then 118 + pass "unchanged file preserved" 119 + else 120 + fail "unchanged file corrupted" 121 + fi 122 + 123 + # ── Summary ────────────────────────────────────────────────────── 124 + echo "" 125 + echo "==============================" 126 + echo "Results: ${PASS} passed, ${FAIL} failed" 127 + echo "==============================" 128 + [[ $FAIL -eq 0 ]] || exit 1
+197
plans/improvements-v1.md
··· 1 + # Plan: pds-yrs Improvements (v1) 2 + 3 + ## Context 4 + 5 + pds-yrs is a Rust crate that syncs Yrs CRDT documents to/from AT Protocol PDS. The benchmark work proved pure CRDT merge is both the fastest and most correct approach (2ms for 10 files, 81ms for 200 files with guaranteed conflicts, zero conflicts ever). Now we need to harden pds-yrs for real-world use. 6 + 7 + Current limitations: text-only files, no binary support, no rate limit handling, no blob batching, no compression, no token refresh, no chunking for large files, no file deletion support, and batch-only (no real-time sync). 8 + 9 + ## Improvements 10 + 11 + ### 1. File Manifest + Deletion Support 12 + 13 + **Problem**: `SiteRecord.files` is a HashMap that only grows — `save.rs` adds/updates entries but never removes them. Deleted local files persist in the record forever and get restored on load. There's no way to distinguish "file was deleted" from "file was never synced." 14 + 15 + **Approach**: 16 + - During `save`: collect the set of local files, compare against `SiteRecord.files`, and remove entries for files that no longer exist locally 17 + - Add a `deleted_at: Option<String>` tombstone field to `FileEntry` instead of hard-deleting — this preserves deletion intent for merge 18 + - Tombstoned entries: `content` cleared, `snapshot_blob` kept (for undo within a window), `deleted_at` set to ISO timestamp 19 + - During `load`: skip files where `deleted_at` is set (don't restore deleted files) 20 + - During `save`: if a local file exists that matches a tombstoned entry, clear the tombstone (file was re-created) 21 + - Tombstone garbage collection: on save, remove tombstones older than 30 days (configurable) to keep record size bounded 22 + 23 + **Merge behavior — edit wins**: 24 + - If Site A has `deleted_at` set and Site B has an updated version (no tombstone), **keep Site B's version** — never lose edits 25 + - If both sites tombstoned the same file, keep the tombstone 26 + - If Site A tombstoned and Site B has no entry at all, keep the tombstone (propagate deletion) 27 + 28 + **Files to modify**: 29 + - `src/types.rs` — add `deleted_at: Option<String>` to FileEntry 30 + - `src/save.rs` — detect deleted files, create tombstones, GC old tombstones, un-tombstone re-created files 31 + - `src/load.rs` — skip tombstoned entries 32 + - `src/merge.rs` — edit-wins resolution for delete-vs-edit conflicts 33 + 34 + ### 2. Binary / Non-CRDT File Support 35 + 36 + **Problem**: Binary files (images, PDFs, fonts, etc.) are silently skipped. They can't use Yrs CRDT merging. 37 + 38 + **Approach**: 39 + - Add a `FileKind` enum to `types.rs`: `Text` (Yrs CRDT) vs `Binary` (raw blob) 40 + - `FileEntry` gets a `kind: FileKind` field 41 + - Binary files: store raw content as a single blob, `content` field stores a hash or empty string (not the binary data) 42 + - On save: detect binary vs text by extension (extend `is_text_extension` with a `is_binary_extension`, and treat unknown as binary) 43 + - On load: binary files download the blob and write raw bytes to disk 44 + 45 + **Conflict handling for binary files**: 46 + - Each FileEntry stores ONE blob ref (the file's content) 47 + - During `merge_sites()`, if two sites have different blobs for the same binary file (different CIDs), create TWO FileEntries: 48 + - `images/photo.png` → `images/photo.creator1.png` + `images/photo.creator2.png` 49 + - Both entries stored in the SiteRecord's files HashMap 50 + - A `conflict_source: Option<String>` field on FileEntry tracks the original path 51 + - User resolves by keeping one and deleting the other (via next save) 52 + - Text files continue to use CRDT merge (no conflicts ever) 53 + 54 + **Files to modify**: 55 + - `src/types.rs` — add `FileKind`, `conflict_source` to FileEntry 56 + - `src/save.rs` — handle binary files in `collect_text_files()` (rename to `collect_files()`), upload raw blobs for binary 57 + - `src/load.rs` — handle binary FileEntries (write raw bytes) 58 + - `src/merge.rs` — binary conflict detection + dual-file creation 59 + 60 + ### 3. Adaptive Bundling + Rate Limit Retry 61 + 62 + **Problem**: Each file gets a separate `upload_blob()` call. 200 files = 200 HTTP requests. No retry on rate limits. But bundling adds complexity — it shouldn't be forced when not needed. 63 + 64 + **Approach — Configurable bundling**: 65 + - `BundleStrategy` enum: `None` (one blob per request, default), `Auto` (bundle when rate-limited), `Always(usize)` (bundle N blobs per request) 66 + - `--bundle` CLI flag: `none`, `auto`, `always[:N]` (default N=50) 67 + - In `Auto` mode: start with individual uploads. On first 429, switch to bundling for remaining blobs (concatenate small blobs into a single upload with index header, like git-remote-pds's bundle format, store bundle CID + offset/length in FileEntry) 68 + - In `None` mode: always individual uploads, still retries on transient errors 69 + - In `Always` mode: always bundle, good for large initial syncs 70 + 71 + **Approach — Retry with backoff** (always active regardless of bundle mode): 72 + - Add `RetryConfig` struct: `max_retries: u32`, `base_delay_ms: u64`, `max_delay_ms: u64` 73 + - Add `request_with_retry()` wrapper in `pds_client.rs` 74 + - On 429: read `Retry-After` header if present, else exponential backoff (100ms, 200ms, 400ms, ...) 75 + - On 5xx: same retry logic 76 + - On 4xx (non-429): fail immediately 77 + - Default: 3 retries, 100ms base, 5s max 78 + 79 + **Files to modify**: 80 + - `src/pds_client.rs` — add retry wrapper, `BundleStrategy`, apply to `upload_blob()`, `put_record()`, `get_blob()` 81 + - `src/main.rs` — add `--bundle` CLI arg to save/sync commands 82 + 83 + ### 4. Large File Chunking (>40MB) 84 + 85 + **Problem**: PDS has ~50MB blob limit. Large files (videos, database dumps) would fail. 86 + 87 + **Approach**: Port the chunking strategy from `git-remote-pds/src/chunk.rs`: 88 + - `DEFAULT_CHUNK_SIZE = 40MB` (safe headroom under 50MB limit) 89 + - Files > 40MB split into chunks, each uploaded as separate blob 90 + - FileEntry gets `parts: Option<Vec<BlobRef>>` for multi-part files 91 + - On download: reassemble chunks in order 92 + - Reuse the existing `chunk.rs` logic (or copy the simple split/join functions) 93 + 94 + **Files to modify**: 95 + - `src/types.rs` — add `parts: Option<Vec<BlobRef>>` to FileEntry 96 + - `src/yrs_pds.rs` — chunked upload/download helpers 97 + - `src/save.rs` — chunk large blobs before upload 98 + - `src/load.rs` — reassemble chunks on download 99 + 100 + ### 5. Blob Compression 101 + 102 + **Problem**: Yrs snapshots and binary blobs are uploaded as raw bytes. Compression would reduce bandwidth and storage. 103 + 104 + **Approach**: 105 + - Use `flate2` crate for gzip compression/decompression 106 + - Compress all blobs before upload, decompress on download 107 + - Add `compressed: bool` field to BlobRef (or use mime type `application/gzip`) 108 + - Transparent to the rest of the code — compress/decompress at the pds_client layer 109 + - Skip compression for already-compressed formats (jpg, png, zip, gz) 110 + 111 + **Files to modify**: 112 + - `Cargo.toml` — add `flate2` dependency 113 + - `src/pds_client.rs` or `src/yrs_pds.rs` — compress before `upload_blob()`, decompress after `get_blob()` 114 + - `src/types.rs` — track compression state in BlobRef 115 + 116 + ### 6. Token Refresh 117 + 118 + **Problem**: Long-running syncs (1000 files) may exceed Bearer token TTL. No refresh logic. 119 + 120 + **Approach**: 121 + - Track token expiry (conservative 90min TTL like git-remote-pds) 122 + - Before each PDS call, check if token is about to expire 123 + - If expiring: call `com.atproto.server.refreshSession` with refresh_jwt 124 + - Store both access_jwt and refresh_jwt from login response (currently only stores access_jwt) 125 + 126 + **Files to modify**: 127 + - `src/pds_client.rs` — store refresh_jwt, add `maybe_refresh_token()`, call before requests 128 + 129 + ### 7. Real-Time Sync Relay (PDS as WebRTC Fallback) 130 + 131 + **Problem**: When NAT traversal prevents direct yrs-webrtc connections between clients, there's no fallback for real-time collaborative editing. 132 + 133 + **Approach — PDS as a relay**: 134 + - Add a `sync` subcommand that runs continuously (poll loop) 135 + - Each client periodically: 136 + 1. Encodes its local Yrs state vector 137 + 2. Fetches the remote SiteRecord from PDS 138 + 3. Computes diff: `encode_diff_v1(remote_sv)` for what remote is missing 139 + 4. Uploads the diff as an `updates_blob` on the record 140 + 5. Downloads remote's updates and applies locally 141 + - **Polling interval**: configurable, default 2-5 seconds (higher latency than WebRTC but functional) 142 + - **Conflict-free by design**: Yrs CRDT ensures all updates converge regardless of order 143 + 144 + **Data model changes**: 145 + - SiteRecord gets a `sync_cursor: Option<String>` — state vector of last sync 146 + - FileEntry's `updates_blob` becomes actively used (not just for compaction) 147 + - Add `last_synced_at: Option<String>` timestamp per file 148 + 149 + **Optimization — AT Protocol Event Stream**: 150 + - Instead of polling, subscribe to the PDS firehose for record changes 151 + - `com.atproto.sync.subscribeRepos` WebSocket — get notified when the SiteRecord changes 152 + - Reduces latency from poll-interval to near-real-time (~100-500ms) 153 + - Fall back to polling if firehose unavailable 154 + 155 + **CLI**: 156 + ``` 157 + pds-yrs sync --dir DIR --handle HANDLE --site RKEY --password PASS [--interval 3s] [--verbose] 158 + ``` 159 + 160 + **Files to modify/create**: 161 + - `src/sync.rs` (new) — poll loop, diff computation, update application 162 + - `src/main.rs` — add `sync` subcommand 163 + - `src/types.rs` — add sync_cursor, last_synced_at 164 + - `src/yrs_pds.rs` — incremental diff helpers (mostly exist already) 165 + 166 + ### 8. Configurable File Filters 167 + 168 + **Problem**: Hardcoded list of text extensions. Users may want to include/exclude specific patterns. 169 + 170 + **Approach**: 171 + - Add optional `--include` and `--exclude` glob patterns to save/sync commands 172 + - Default: current behavior (known text extensions as CRDT, skip hidden/node_modules/target) 173 + - With `--include "*.md,*.txt"`: only sync matching files 174 + - Binary detection still automatic by extension, but user can override 175 + 176 + **Files to modify**: 177 + - `src/save.rs` — accept filter config in `collect_files()` 178 + - `src/main.rs` — add CLI args 179 + 180 + ## Implementation Order 181 + 182 + 1. **File manifest + deletion** — foundational, everything else depends on knowing what files exist 183 + 2. **Binary file support** — most impactful, enables syncing real sites (builds on manifest for tombstones) 184 + 3. **Retry logic + adaptive bundling** — required for reliability (bundling configurable: none/auto/always) 185 + 4. **Token refresh** — required for long syncs 186 + 5. **Compression** — easy win for bandwidth 187 + 6. **Large file chunking** — needed for completeness 188 + 7. **Real-time sync relay** — new capability, most complex 189 + 8. **Configurable filters** — nice to have 190 + 191 + ## Verification 192 + 193 + For each improvement: 194 + - Unit tests for new logic (tombstone GC, binary detection, compression round-trip, chunking) 195 + - Update existing E2E tests to cover deletion, binary files, and compressed blobs 196 + - New E2E test for real-time sync (two clients syncing via PDS relay) 197 + - Benchmark: re-run merge-bench Strategy 4 with compression enabled to measure impact
+151
plans/improvements-v2-crdt-manifest.md
··· 1 + # Plan: pds-yrs Improvements (v2 — CRDT Manifest) 2 + 3 + **Status**: Superseded by active plan file. Key refinements made: 4 + - Manifest changed from Yrs Text (line-per-path) to **Yrs Map** (key=path, value=kind) to avoid character interleaving 5 + - Bundling changed from adaptive (none/auto/always) to **pack blob with index** (always, fewest HTTP calls) 6 + 7 + ## Context 8 + 9 + Same as v1. The key difference: instead of tombstone-based deletion tracking, the file manifest is itself a Yrs CRDT document. Deletions are CRDT operations that propagate automatically through normal Yrs merging. 10 + 11 + ## Core Design: CRDT Manifest 12 + 13 + ### What it is 14 + 15 + A special Yrs text document where each line is a file path: 16 + 17 + ``` 18 + docs/index.md 19 + docs/about.md 20 + images/logo.png 21 + style.css 22 + ``` 23 + 24 + The manifest is stored as a dedicated FileEntry in the SiteRecord (e.g., key `_manifest`). It uses the same Yrs CRDT infrastructure as all other text files — snapshot_blob, state_vector, updates_blob, compaction. 25 + 26 + ### Operations 27 + 28 + - **New file**: insert line into manifest Yrs doc + create FileEntry for the file's content 29 + - **Delete file**: remove line from manifest Yrs doc (on next save, when local file is gone) 30 + - **Edit file**: update the file's FileEntry only — manifest is untouched 31 + - **Rename file**: remove old line + add new line (Yrs handles as delete + insert) 32 + 33 + ### Save flow 34 + 35 + 1. Collect local files on disk 36 + 2. Fetch existing SiteRecord (includes manifest Yrs doc + all FileEntries) 37 + 3. Reconstruct manifest Yrs doc from snapshot + updates 38 + 4. Compute diff between local file list and manifest text: 39 + - Files on disk but NOT in manifest → insert line into manifest Yrs doc, create FileEntry 40 + - Files in manifest but NOT on disk → remove line from manifest Yrs doc (deletion) 41 + - Files in both → check if content changed, update FileEntry if so 42 + 5. Upload updated manifest + changed FileEntries 43 + 44 + ### Load flow 45 + 46 + 1. Fetch SiteRecord 47 + 2. Materialize manifest Yrs doc → get list of file paths 48 + 3. For each path in manifest: download and reconstruct FileEntry, write to disk 49 + 4. Ignore FileEntries not listed in manifest (they're deleted or orphaned) 50 + 51 + ### Merge flow 52 + 53 + 1. Fetch all SiteRecords 54 + 2. CRDT-merge the manifest Yrs docs (standard Yrs merge — handles concurrent inserts/deletes) 55 + 3. Materialize the merged manifest → file list 56 + 4. For each file in merged manifest: CRDT-merge the file's Yrs docs across sites 57 + 5. **Edit-wins reconciliation**: for each FileEntry that exists in ANY site but is NOT in the merged manifest: 58 + - Check if the FileEntry content differs from the merge base (the common ancestor) 59 + - If content was modified → someone edited while someone else deleted → re-add line to manifest (edit wins) 60 + - If content is unchanged → file was just deleted, no concurrent edit → leave it out 61 + 6. For binary files not in manifest: same check — if blob CID differs from base, re-add 62 + 63 + ### Edge cases 64 + 65 + - **Both sites delete same file**: both remove the line, CRDT merge still removes it → clean deletion 66 + - **Both sites add same new file**: both insert a line — Yrs may produce duplicate lines. Deduplicate by normalizing manifest text (sort + dedup) after merge 67 + - **Site A deletes, Site B edits**: edit-wins check catches this — re-adds to manifest 68 + - **Site A deletes, Site B doesn't touch it**: line removed in merged manifest, FileEntry unchanged from base → true deletion 69 + - **Orphaned FileEntries**: FileEntries with no manifest line and no edits since base → safe to garbage collect during save 70 + 71 + ### Why this is better than tombstones (v1) 72 + 73 + - No tombstone GC — deleted files just disappear from the manifest 74 + - No `deleted_at` field cluttering the data model 75 + - Deletion is a first-class CRDT operation, not a convention 76 + - The manifest doubles as a human-readable file listing 77 + - Merge behavior emerges from CRDT semantics + one application-level check 78 + 79 + ## Other Improvements (same as v1, renumbered) 80 + 81 + ### 2. Binary / Non-CRDT File Support 82 + 83 + Same as v1. Binary files get a FileEntry with `kind: Binary` and a raw blob. The manifest lists them alongside text files — the manifest doesn't distinguish file types (that's in the FileEntry). 84 + 85 + Conflict handling for binary files during merge: 86 + - If two sites have different blob CIDs for the same binary file path → create `file.creator1.ext` + `file.creator2.ext` entries in manifest + FileEntries 87 + - User resolves by keeping one, deleting the other (which removes the line from manifest on next save) 88 + 89 + ### 3. Adaptive Bundling + Rate Limit Retry 90 + 91 + Same as v1. Configurable bundling: 92 + - `BundleStrategy::None` (default) — individual uploads with retry 93 + - `BundleStrategy::Auto` — start individual, switch to bundling on first 429 94 + - `BundleStrategy::Always(N)` — always bundle N blobs per request 95 + 96 + Retry always active (3 retries, exponential backoff, respect Retry-After header). 97 + 98 + ### 4. Large File Chunking (>40MB) 99 + 100 + Same as v1. Port from `git-remote-pds/src/chunk.rs`. 101 + 102 + ### 5. Blob Compression 103 + 104 + Same as v1. `flate2` gzip, skip already-compressed formats. 105 + 106 + ### 6. Token Refresh 107 + 108 + Same as v1. Store refresh_jwt, auto-refresh before expiry. 109 + 110 + ### 7. Real-Time Sync Relay (PDS as WebRTC Fallback) 111 + 112 + Same as v1, but the manifest CRDT makes this cleaner: 113 + - Sync loop pushes/pulls manifest updates alongside file updates 114 + - New files from remote appear in manifest → trigger download 115 + - Deleted files disappear from manifest → trigger local cleanup 116 + - The manifest state vector tracks what the remote has seen 117 + 118 + ### 8. Configurable File Filters 119 + 120 + Same as v1. `--include` and `--exclude` glob patterns. 121 + 122 + ## Implementation Order 123 + 124 + 1. **CRDT manifest + deletion** — foundational, replaces SiteRecord.files HashMap as source of truth for file existence 125 + 2. **Binary file support** — enables syncing real sites 126 + 3. **Retry logic + adaptive bundling** — reliability 127 + 4. **Token refresh** — long sync support 128 + 5. **Compression** — bandwidth optimization 129 + 6. **Large file chunking** — completeness 130 + 7. **Real-time sync relay** — new capability 131 + 8. **Configurable filters** — nice to have 132 + 133 + ## Key Files to Modify 134 + 135 + For the manifest specifically: 136 + - `src/types.rs` — add `FileKind` enum, `kind` + `conflict_source` to FileEntry, manifest constant (e.g., `MANIFEST_KEY = "_manifest"`) 137 + - `src/save.rs` — reconstruct manifest doc, diff local files vs manifest, insert/remove lines, update FileEntries 138 + - `src/load.rs` — materialize manifest to get file list, only load listed files 139 + - `src/merge.rs` — CRDT-merge manifest docs, edit-wins reconciliation, binary conflict detection 140 + - `src/yrs_pds.rs` — helpers for manifest text manipulation (insert line, remove line, deduplicate) 141 + 142 + ## Verification 143 + 144 + - Unit test: manifest round-trip (add files, delete files, materialize, verify) 145 + - Unit test: concurrent add + delete merges correctly via CRDT 146 + - Unit test: edit-wins check (Site A deletes, Site B edits → file survives) 147 + - Unit test: both-delete → file gone 148 + - Unit test: duplicate line dedup after concurrent adds 149 + - E2E test: save with deletions, load respects manifest 150 + - E2E test: merge two sites with conflicting deletes/edits 151 + - Benchmark: re-run merge-bench with manifest overhead
+55
src/export.rs
··· 1 + //! Export site content from PDS as plain text files. 2 + //! 3 + //! This is the data portability escape hatch — reads the `content` field 4 + //! from each FileEntry without requiring Yrs decoding. 5 + 6 + use std::path::Path; 7 + 8 + use crate::pds_client::PdsClient; 9 + use crate::types::{SiteRecord, COLLECTION}; 10 + 11 + /// Export a site from PDS to plain text files. 12 + /// 13 + /// Reads only the `content` field from each FileEntry — no Yrs 14 + /// decoding or blob downloads needed. This works even if the Yrs 15 + /// library is unavailable. 16 + pub async fn export( 17 + client: &PdsClient, 18 + did: &str, 19 + rkey: &str, 20 + output_dir: &Path, 21 + verbose: bool, 22 + ) -> Result<usize, String> { 23 + // Fetch site record 24 + let record = client 25 + .get_record(did, COLLECTION, rkey) 26 + .await? 27 + .ok_or_else(|| format!("site record not found: {}", rkey))?; 28 + 29 + let site: SiteRecord = serde_json::from_value(record.value) 30 + .map_err(|e| format!("parse SiteRecord: {}", e))?; 31 + 32 + let mut files_exported = 0; 33 + 34 + for (rel_path, entry) in &site.files { 35 + if verbose { 36 + eprintln!("pds-yrs: export {}", rel_path); 37 + } 38 + 39 + let output_path = output_dir.join(rel_path); 40 + if let Some(parent) = output_path.parent() { 41 + std::fs::create_dir_all(parent) 42 + .map_err(|e| format!("create dir {:?}: {}", parent, e))?; 43 + } 44 + std::fs::write(&output_path, &entry.content) 45 + .map_err(|e| format!("write {:?}: {}", output_path, e))?; 46 + 47 + files_exported += 1; 48 + } 49 + 50 + if verbose { 51 + eprintln!("pds-yrs: exported {} file(s)", files_exported); 52 + } 53 + 54 + Ok(files_exported) 55 + }
+19
src/lib.rs
··· 1 + //! pds-yrs: Sync Yrs CRDT documents via AT Protocol PDS. 2 + //! 3 + //! No git involved — files are stored as Yrs Doc state on the PDS, 4 + //! with plain text content alongside for portability. 5 + 6 + pub mod export; 7 + pub mod load; 8 + pub mod merge; 9 + pub mod pds_client; 10 + pub mod save; 11 + pub mod types; 12 + pub mod yrs_pds; 13 + 14 + pub use export::export; 15 + pub use load::load; 16 + pub use merge::merge_sites; 17 + pub use pds_client::PdsClient; 18 + pub use save::save; 19 + pub use types::COLLECTION;
+67
src/load.rs
··· 1 + //! Load a site from PDS into a local directory. 2 + 3 + use std::path::Path; 4 + 5 + use crate::pds_client::PdsClient; 6 + use crate::types::{LoadResult, SiteRecord, COLLECTION}; 7 + use crate::yrs_pds; 8 + 9 + /// Load a site from PDS into a directory. 10 + /// 11 + /// Fetches the SiteRecord, downloads blob data, reconstructs Yrs Docs, 12 + /// and materializes text files into the output directory. 13 + pub async fn load( 14 + client: &PdsClient, 15 + did: &str, 16 + rkey: &str, 17 + output_dir: &Path, 18 + verbose: bool, 19 + ) -> Result<LoadResult, String> { 20 + // Fetch site record 21 + let record = client 22 + .get_record(did, COLLECTION, rkey) 23 + .await? 24 + .ok_or_else(|| format!("site record not found: {}", rkey))?; 25 + 26 + let site: SiteRecord = serde_json::from_value(record.value) 27 + .map_err(|e| format!("parse SiteRecord: {}", e))?; 28 + 29 + let mut files_loaded = 0; 30 + let mut blobs_downloaded = 0; 31 + 32 + for (rel_path, entry) in &site.files { 33 + if verbose { 34 + eprintln!("pds-yrs: loading {}", rel_path); 35 + } 36 + 37 + // Reconstruct Doc from PDS blobs 38 + let doc = yrs_pds::file_entry_to_doc(entry, client, did).await?; 39 + blobs_downloaded += 1; 40 + if entry.updates_blob.is_some() { 41 + blobs_downloaded += 1; 42 + } 43 + 44 + // Materialize text 45 + let content = yrs_pds::materialize(&doc); 46 + 47 + // Write to output directory 48 + let output_path = output_dir.join(rel_path); 49 + if let Some(parent) = output_path.parent() { 50 + std::fs::create_dir_all(parent) 51 + .map_err(|e| format!("create dir {:?}: {}", parent, e))?; 52 + } 53 + std::fs::write(&output_path, &content) 54 + .map_err(|e| format!("write {:?}: {}", output_path, e))?; 55 + 56 + files_loaded += 1; 57 + } 58 + 59 + if verbose { 60 + eprintln!("pds-yrs: loaded {} file(s)", files_loaded); 61 + } 62 + 63 + Ok(LoadResult { 64 + files_loaded, 65 + blobs_downloaded, 66 + }) 67 + }
+244
src/main.rs
··· 1 + use clap::{Parser, Subcommand}; 2 + use std::process; 3 + 4 + #[derive(Parser)] 5 + #[command(name = "pds-yrs", about = "Sync Yrs CRDT documents via AT Protocol PDS")] 6 + struct Cli { 7 + #[command(subcommand)] 8 + command: Command, 9 + } 10 + 11 + #[derive(Subcommand)] 12 + enum Command { 13 + /// Save a directory to PDS as Yrs CRDT state 14 + Save { 15 + /// Directory to save 16 + #[arg(long)] 17 + dir: String, 18 + /// AT Protocol handle 19 + #[arg(long)] 20 + handle: String, 21 + /// Site name (used as rkey) 22 + #[arg(long)] 23 + site: String, 24 + /// Password for authentication 25 + #[arg(long)] 26 + password: String, 27 + /// PDS URL (default: resolved from handle) 28 + #[arg(long)] 29 + pds: Option<String>, 30 + /// Show progress 31 + #[arg(long)] 32 + verbose: bool, 33 + }, 34 + /// Load a site from PDS into a local directory 35 + Load { 36 + /// AT Protocol handle 37 + #[arg(long)] 38 + handle: String, 39 + /// Site name (used as rkey) 40 + #[arg(long)] 41 + site: String, 42 + /// Output directory 43 + #[arg(long)] 44 + output: String, 45 + /// Password for authentication 46 + #[arg(long)] 47 + password: String, 48 + /// PDS URL 49 + #[arg(long)] 50 + pds: Option<String>, 51 + /// Show progress 52 + #[arg(long)] 53 + verbose: bool, 54 + }, 55 + /// Merge sites from multiple collaborators 56 + Merge { 57 + /// Comma-separated site rkeys to merge 58 + #[arg(long)] 59 + sites: String, 60 + /// AT Protocol handle 61 + #[arg(long)] 62 + handle: String, 63 + /// Output directory 64 + #[arg(long)] 65 + output: String, 66 + /// Password for authentication 67 + #[arg(long)] 68 + password: String, 69 + /// PDS URL 70 + #[arg(long)] 71 + pds: Option<String>, 72 + /// Show progress 73 + #[arg(long)] 74 + verbose: bool, 75 + }, 76 + /// Export site content as plain text (no Yrs decoding needed) 77 + Export { 78 + /// AT Protocol handle 79 + #[arg(long)] 80 + handle: String, 81 + /// Site name (used as rkey) 82 + #[arg(long)] 83 + site: String, 84 + /// Output directory 85 + #[arg(long)] 86 + output: String, 87 + /// Password for authentication 88 + #[arg(long)] 89 + password: String, 90 + /// PDS URL 91 + #[arg(long)] 92 + pds: Option<String>, 93 + /// Show progress 94 + #[arg(long)] 95 + verbose: bool, 96 + }, 97 + } 98 + 99 + #[tokio::main] 100 + async fn main() { 101 + let cli = Cli::parse(); 102 + 103 + let result = match cli.command { 104 + Command::Save { 105 + dir, 106 + handle, 107 + site, 108 + password, 109 + pds, 110 + verbose, 111 + } => run_save(&dir, &handle, &site, &password, pds.as_deref(), verbose).await, 112 + Command::Load { 113 + handle, 114 + site, 115 + output, 116 + password, 117 + pds, 118 + verbose, 119 + } => run_load(&handle, &site, &output, &password, pds.as_deref(), verbose).await, 120 + Command::Merge { 121 + sites, 122 + handle, 123 + output, 124 + password, 125 + pds, 126 + verbose, 127 + } => run_merge(&sites, &handle, &output, &password, pds.as_deref(), verbose).await, 128 + Command::Export { 129 + handle, 130 + site, 131 + output, 132 + password, 133 + pds, 134 + verbose, 135 + } => run_export(&handle, &site, &output, &password, pds.as_deref(), verbose).await, 136 + }; 137 + 138 + if let Err(e) = result { 139 + eprintln!("pds-yrs: {}", e); 140 + process::exit(1); 141 + } 142 + } 143 + 144 + async fn login( 145 + handle: &str, 146 + password: &str, 147 + pds_url: Option<&str>, 148 + ) -> Result<(pds_yrs::PdsClient, String), String> { 149 + let url = pds_url.unwrap_or("https://bluesky-pds.t1cc.commoninternet.net"); 150 + let mut client = pds_yrs::PdsClient::new(url); 151 + let session = client.login(handle, password).await?; 152 + Ok((client, session.did)) 153 + } 154 + 155 + async fn run_save( 156 + dir: &str, 157 + handle: &str, 158 + site: &str, 159 + password: &str, 160 + pds_url: Option<&str>, 161 + verbose: bool, 162 + ) -> Result<(), String> { 163 + let (client, did) = login(handle, password, pds_url).await?; 164 + let result = pds_yrs::save( 165 + std::path::Path::new(dir), 166 + &client, 167 + &did, 168 + site, 169 + verbose, 170 + ) 171 + .await?; 172 + eprintln!( 173 + "pds-yrs: saved {} file(s), skipped {} unchanged, {} bytes total", 174 + result.files_uploaded, result.files_skipped, result.total_bytes 175 + ); 176 + Ok(()) 177 + } 178 + 179 + async fn run_load( 180 + handle: &str, 181 + site: &str, 182 + output: &str, 183 + password: &str, 184 + pds_url: Option<&str>, 185 + verbose: bool, 186 + ) -> Result<(), String> { 187 + let (client, did) = login(handle, password, pds_url).await?; 188 + let result = pds_yrs::load( 189 + &client, 190 + &did, 191 + site, 192 + std::path::Path::new(output), 193 + verbose, 194 + ) 195 + .await?; 196 + eprintln!( 197 + "pds-yrs: loaded {} file(s), {} blob(s) downloaded", 198 + result.files_loaded, result.blobs_downloaded 199 + ); 200 + Ok(()) 201 + } 202 + 203 + async fn run_merge( 204 + sites: &str, 205 + handle: &str, 206 + output: &str, 207 + password: &str, 208 + pds_url: Option<&str>, 209 + verbose: bool, 210 + ) -> Result<(), String> { 211 + let (client, did) = login(handle, password, pds_url).await?; 212 + let rkeys: Vec<&str> = sites.split(',').collect(); 213 + pds_yrs::merge_sites( 214 + &client, 215 + &did, 216 + &rkeys, 217 + std::path::Path::new(output), 218 + verbose, 219 + ) 220 + .await?; 221 + eprintln!("pds-yrs: merge complete"); 222 + Ok(()) 223 + } 224 + 225 + async fn run_export( 226 + handle: &str, 227 + site: &str, 228 + output: &str, 229 + password: &str, 230 + pds_url: Option<&str>, 231 + verbose: bool, 232 + ) -> Result<(), String> { 233 + let (client, did) = login(handle, password, pds_url).await?; 234 + let count = pds_yrs::export( 235 + &client, 236 + &did, 237 + site, 238 + std::path::Path::new(output), 239 + verbose, 240 + ) 241 + .await?; 242 + eprintln!("pds-yrs: exported {} file(s)", count); 243 + Ok(()) 244 + }
+99
src/merge.rs
··· 1 + //! Merge multiple collaborators' sites via CRDT. 2 + 3 + use std::collections::HashMap; 4 + use std::path::Path; 5 + 6 + use yrs::updates::decoder::Decode; 7 + use yrs::{Doc, ReadTxn, Transact}; 8 + 9 + use crate::pds_client::PdsClient; 10 + use crate::types::{SiteRecord, COLLECTION}; 11 + use crate::yrs_pds; 12 + 13 + /// Merge sites from multiple rkeys into an output directory. 14 + /// 15 + /// For each file that exists in any site: 16 + /// - If only one site has it: use that version 17 + /// - If multiple sites have it: CRDT merge all Yrs Docs 18 + pub async fn merge_sites( 19 + client: &PdsClient, 20 + did: &str, 21 + rkeys: &[&str], 22 + output_dir: &Path, 23 + verbose: bool, 24 + ) -> Result<(), String> { 25 + // Fetch all site records 26 + let mut sites: Vec<SiteRecord> = Vec::new(); 27 + for rkey in rkeys { 28 + let record = client 29 + .get_record(did, COLLECTION, rkey) 30 + .await? 31 + .ok_or_else(|| format!("site record not found: {}", rkey))?; 32 + let site: SiteRecord = serde_json::from_value(record.value) 33 + .map_err(|e| format!("parse SiteRecord for {}: {}", rkey, e))?; 34 + sites.push(site); 35 + } 36 + 37 + // Collect all file paths across all sites 38 + let mut all_files: HashMap<String, Vec<usize>> = HashMap::new(); 39 + for (i, site) in sites.iter().enumerate() { 40 + for path in site.files.keys() { 41 + all_files 42 + .entry(path.clone()) 43 + .or_default() 44 + .push(i); 45 + } 46 + } 47 + 48 + for (rel_path, site_indices) in &all_files { 49 + if verbose { 50 + eprintln!( 51 + "pds-yrs: merging {} (from {} site(s))", 52 + rel_path, 53 + site_indices.len() 54 + ); 55 + } 56 + 57 + let content = if site_indices.len() == 1 { 58 + // Only one site has this file — use it directly 59 + let entry = &sites[site_indices[0]].files[rel_path]; 60 + let doc = yrs_pds::file_entry_to_doc(entry, client, did).await?; 61 + yrs_pds::materialize(&doc) 62 + } else { 63 + // Multiple sites — CRDT merge 64 + let mut docs: Vec<Doc> = Vec::new(); 65 + for &idx in site_indices { 66 + let entry = &sites[idx].files[rel_path]; 67 + let doc = yrs_pds::file_entry_to_doc(entry, client, did).await?; 68 + docs.push(doc); 69 + } 70 + 71 + // Merge all docs into the first one 72 + let merged_doc = &docs[0]; 73 + for other_doc in docs.iter().skip(1) { 74 + let sv = merged_doc.transact().state_vector(); 75 + let diff = other_doc.transact().encode_diff_v1(&sv); 76 + if let Ok(update) = yrs::Update::decode_v1(&diff) { 77 + let _ = merged_doc.transact_mut().apply_update(update); 78 + } 79 + } 80 + 81 + yrs_pds::materialize(merged_doc) 82 + }; 83 + 84 + // Write to output directory 85 + let output_path = output_dir.join(rel_path); 86 + if let Some(parent) = output_path.parent() { 87 + std::fs::create_dir_all(parent) 88 + .map_err(|e| format!("create dir {:?}: {}", parent, e))?; 89 + } 90 + std::fs::write(&output_path, &content) 91 + .map_err(|e| format!("write {:?}: {}", output_path, e))?; 92 + } 93 + 94 + if verbose { 95 + eprintln!("pds-yrs: merged {} file(s)", all_files.len()); 96 + } 97 + 98 + Ok(()) 99 + }
+325
src/pds_client.rs
··· 1 + //! PDS client for AT Protocol XRPC calls. 2 + //! 3 + //! Simplified port from git-remote-pds — Bearer auth only. 4 + 5 + use serde::{Deserialize, Serialize}; 6 + 7 + use crate::types::BlobRef; 8 + 9 + pub struct PdsClient { 10 + base_url: String, 11 + auth_token: Option<String>, 12 + http: reqwest::Client, 13 + } 14 + 15 + #[derive(Debug, Deserialize)] 16 + pub struct CreateSessionResponse { 17 + pub did: String, 18 + #[serde(rename = "accessJwt")] 19 + pub access_jwt: String, 20 + #[serde(rename = "refreshJwt")] 21 + pub refresh_jwt: String, 22 + pub handle: String, 23 + } 24 + 25 + #[derive(Debug, Deserialize)] 26 + pub struct GetRecordResponse { 27 + pub uri: String, 28 + pub cid: Option<String>, 29 + pub value: serde_json::Value, 30 + } 31 + 32 + #[derive(Debug, Serialize)] 33 + struct PutRecordRequest { 34 + repo: String, 35 + collection: String, 36 + rkey: String, 37 + record: serde_json::Value, 38 + #[serde(rename = "swapRecord", skip_serializing_if = "Option::is_none")] 39 + swap_record: Option<String>, 40 + } 41 + 42 + #[derive(Debug, Deserialize)] 43 + pub struct PutRecordResponse { 44 + pub uri: String, 45 + pub cid: String, 46 + } 47 + 48 + #[derive(Debug, Deserialize)] 49 + struct UploadBlobResponse { 50 + blob: UploadedBlob, 51 + } 52 + 53 + #[derive(Debug, Deserialize)] 54 + struct UploadedBlob { 55 + #[serde(rename = "$type")] 56 + blob_type: String, 57 + #[serde(rename = "ref")] 58 + link: crate::types::CidLink, 59 + #[serde(rename = "mimeType")] 60 + mime_type: String, 61 + size: u64, 62 + } 63 + 64 + impl PdsClient { 65 + pub fn new(base_url: impl Into<String>) -> Self { 66 + Self { 67 + base_url: base_url.into().trim_end_matches('/').to_string(), 68 + auth_token: None, 69 + http: reqwest::Client::new(), 70 + } 71 + } 72 + 73 + pub fn base_url(&self) -> &str { 74 + &self.base_url 75 + } 76 + 77 + pub async fn login( 78 + &mut self, 79 + identifier: &str, 80 + password: &str, 81 + ) -> Result<CreateSessionResponse, String> { 82 + let url = format!("{}/xrpc/com.atproto.server.createSession", self.base_url); 83 + let body = serde_json::json!({ 84 + "identifier": identifier, 85 + "password": password, 86 + }); 87 + 88 + let resp = self 89 + .http 90 + .post(&url) 91 + .json(&body) 92 + .send() 93 + .await 94 + .map_err(|e| format!("login request failed: {}", e))?; 95 + 96 + if !resp.status().is_success() { 97 + let status = resp.status(); 98 + let text = resp.text().await.unwrap_or_default(); 99 + return Err(format!("login failed ({}): {}", status, text)); 100 + } 101 + 102 + let session: CreateSessionResponse = resp 103 + .json() 104 + .await 105 + .map_err(|e| format!("parse login response: {}", e))?; 106 + 107 + self.auth_token = Some(session.access_jwt.clone()); 108 + Ok(session) 109 + } 110 + 111 + pub async fn get_record( 112 + &self, 113 + did: &str, 114 + collection: &str, 115 + rkey: &str, 116 + ) -> Result<Option<GetRecordResponse>, String> { 117 + let url = format!( 118 + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", 119 + self.base_url, did, collection, rkey 120 + ); 121 + 122 + let resp = self 123 + .http 124 + .get(&url) 125 + .send() 126 + .await 127 + .map_err(|e| format!("get_record request failed: {}", e))?; 128 + 129 + if resp.status().as_u16() == 400 { 130 + let text = resp.text().await.unwrap_or_default(); 131 + if text.contains("RecordNotFound") { 132 + return Ok(None); 133 + } 134 + return Err(format!("get_record failed: {}", text)); 135 + } 136 + 137 + if !resp.status().is_success() { 138 + let status = resp.status(); 139 + let text = resp.text().await.unwrap_or_default(); 140 + return Err(format!("get_record failed ({}): {}", status, text)); 141 + } 142 + 143 + let record: GetRecordResponse = resp 144 + .json() 145 + .await 146 + .map_err(|e| format!("parse get_record response: {}", e))?; 147 + Ok(Some(record)) 148 + } 149 + 150 + pub async fn put_record( 151 + &self, 152 + did: &str, 153 + collection: &str, 154 + rkey: &str, 155 + record: serde_json::Value, 156 + swap_record: Option<String>, 157 + ) -> Result<PutRecordResponse, String> { 158 + let url = format!("{}/xrpc/com.atproto.repo.putRecord", self.base_url); 159 + let token = self 160 + .auth_token 161 + .as_ref() 162 + .ok_or("not authenticated")?; 163 + 164 + let body = PutRecordRequest { 165 + repo: did.to_string(), 166 + collection: collection.to_string(), 167 + rkey: rkey.to_string(), 168 + record, 169 + swap_record, 170 + }; 171 + 172 + let resp = self 173 + .http 174 + .post(&url) 175 + .bearer_auth(token) 176 + .json(&body) 177 + .send() 178 + .await 179 + .map_err(|e| format!("put_record request failed: {}", e))?; 180 + 181 + if !resp.status().is_success() { 182 + let status = resp.status(); 183 + let text = resp.text().await.unwrap_or_default(); 184 + return Err(format!("put_record failed ({}): {}", status, text)); 185 + } 186 + 187 + resp.json() 188 + .await 189 + .map_err(|e| format!("parse put_record response: {}", e)) 190 + } 191 + 192 + pub async fn upload_blob(&self, data: Vec<u8>) -> Result<BlobRef, String> { 193 + let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", self.base_url); 194 + let token = self 195 + .auth_token 196 + .as_ref() 197 + .ok_or("not authenticated")?; 198 + 199 + let resp = self 200 + .http 201 + .post(&url) 202 + .bearer_auth(token) 203 + .header("content-type", "application/octet-stream") 204 + .body(data) 205 + .send() 206 + .await 207 + .map_err(|e| format!("upload_blob request failed: {}", e))?; 208 + 209 + if !resp.status().is_success() { 210 + let status = resp.status(); 211 + let text = resp.text().await.unwrap_or_default(); 212 + return Err(format!("upload_blob failed ({}): {}", status, text)); 213 + } 214 + 215 + let upload: UploadBlobResponse = resp 216 + .json() 217 + .await 218 + .map_err(|e| format!("parse upload_blob response: {}", e))?; 219 + 220 + Ok(BlobRef { 221 + blob_type: upload.blob.blob_type, 222 + link: upload.blob.link, 223 + mime_type: upload.blob.mime_type, 224 + size: upload.blob.size, 225 + }) 226 + } 227 + 228 + pub async fn get_blob(&self, did: &str, cid: &str) -> Result<Vec<u8>, String> { 229 + let url = format!( 230 + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}", 231 + self.base_url, did, cid 232 + ); 233 + 234 + let resp = self 235 + .http 236 + .get(&url) 237 + .send() 238 + .await 239 + .map_err(|e| format!("get_blob request failed: {}", e))?; 240 + 241 + if !resp.status().is_success() { 242 + let status = resp.status(); 243 + let text = resp.text().await.unwrap_or_default(); 244 + return Err(format!("get_blob failed ({}): {}", status, text)); 245 + } 246 + 247 + resp.bytes() 248 + .await 249 + .map(|b| b.to_vec()) 250 + .map_err(|e| format!("read blob bytes: {}", e)) 251 + } 252 + 253 + pub async fn delete_record( 254 + &self, 255 + collection: &str, 256 + rkey: &str, 257 + ) -> Result<(), String> { 258 + let url = format!("{}/xrpc/com.atproto.repo.deleteRecord", self.base_url); 259 + let token = self 260 + .auth_token 261 + .as_ref() 262 + .ok_or("not authenticated")?; 263 + 264 + let did = ""; // needs to be passed 265 + let body = serde_json::json!({ 266 + "repo": did, 267 + "collection": collection, 268 + "rkey": rkey, 269 + }); 270 + 271 + let resp = self 272 + .http 273 + .post(&url) 274 + .bearer_auth(token) 275 + .json(&body) 276 + .send() 277 + .await 278 + .map_err(|e| format!("delete_record request failed: {}", e))?; 279 + 280 + if !resp.status().is_success() { 281 + let status = resp.status(); 282 + let text = resp.text().await.unwrap_or_default(); 283 + return Err(format!("delete_record failed ({}): {}", status, text)); 284 + } 285 + 286 + Ok(()) 287 + } 288 + 289 + /// Delete a record with explicit DID. 290 + pub async fn delete_record_with_did( 291 + &self, 292 + did: &str, 293 + collection: &str, 294 + rkey: &str, 295 + ) -> Result<(), String> { 296 + let url = format!("{}/xrpc/com.atproto.repo.deleteRecord", self.base_url); 297 + let token = self 298 + .auth_token 299 + .as_ref() 300 + .ok_or("not authenticated")?; 301 + 302 + let body = serde_json::json!({ 303 + "repo": did, 304 + "collection": collection, 305 + "rkey": rkey, 306 + }); 307 + 308 + let resp = self 309 + .http 310 + .post(&url) 311 + .bearer_auth(token) 312 + .json(&body) 313 + .send() 314 + .await 315 + .map_err(|e| format!("delete_record request failed: {}", e))?; 316 + 317 + if !resp.status().is_success() { 318 + let status = resp.status(); 319 + let text = resp.text().await.unwrap_or_default(); 320 + return Err(format!("delete_record failed ({}): {}", status, text)); 321 + } 322 + 323 + Ok(()) 324 + } 325 + }
+262
src/save.rs
··· 1 + //! Save a directory of files to PDS as a SiteRecord with Yrs CRDT state. 2 + 3 + use std::collections::HashMap; 4 + use std::path::Path; 5 + 6 + use crate::pds_client::PdsClient; 7 + use crate::types::{FileEntry, SaveResult, SiteRecord, COLLECTION}; 8 + use crate::yrs_pds; 9 + 10 + /// Compaction threshold: create new snapshot when updates exceed this count. 11 + const COMPACTION_THRESHOLD: u32 = 50; 12 + 13 + /// Save a directory to PDS. 14 + /// 15 + /// On first save: creates a SiteRecord with FileEntry per text file. 16 + /// On incremental save: compares state vectors, only uploads changed files. 17 + pub async fn save( 18 + dir: &Path, 19 + client: &PdsClient, 20 + did: &str, 21 + rkey: &str, 22 + verbose: bool, 23 + ) -> Result<SaveResult, String> { 24 + // Collect text files from directory 25 + let files = collect_text_files(dir)?; 26 + if files.is_empty() { 27 + return Err("no text files found".to_string()); 28 + } 29 + 30 + // Fetch existing record if present 31 + let existing = client.get_record(did, COLLECTION, rkey).await?; 32 + let existing_site: Option<SiteRecord> = existing.as_ref().and_then(|r| { 33 + serde_json::from_value(r.value.clone()).ok() 34 + }); 35 + let swap_cid = existing.as_ref().and_then(|r| r.cid.clone()); 36 + 37 + let mut file_entries: HashMap<String, FileEntry> = HashMap::new(); 38 + let mut files_uploaded = 0; 39 + let mut files_skipped = 0; 40 + let mut total_bytes: u64 = 0; 41 + 42 + for (rel_path, content) in &files { 43 + // Check if file changed since last save 44 + if let Some(ref site) = existing_site { 45 + if let Some(existing_entry) = site.files.get(rel_path) { 46 + if existing_entry.content == *content { 47 + // File unchanged — reuse existing entry 48 + file_entries.insert(rel_path.clone(), existing_entry.clone()); 49 + files_skipped += 1; 50 + if verbose { 51 + eprintln!("pds-yrs: skip (unchanged) {}", rel_path); 52 + } 53 + continue; 54 + } 55 + 56 + // File changed — check if we should do incremental or full snapshot 57 + if existing_entry.updates_count < COMPACTION_THRESHOLD { 58 + // Try incremental update 59 + if let Ok(entry) = incremental_update( 60 + existing_entry, 61 + content, 62 + client, 63 + did, 64 + verbose, 65 + ) 66 + .await 67 + { 68 + total_bytes += entry.snapshot_blob.size; 69 + file_entries.insert(rel_path.clone(), entry); 70 + files_uploaded += 1; 71 + if verbose { 72 + eprintln!("pds-yrs: incremental update {}", rel_path); 73 + } 74 + continue; 75 + } 76 + // Fall through to full snapshot on failure 77 + } 78 + 79 + if verbose { 80 + eprintln!("pds-yrs: full snapshot (compaction) {}", rel_path); 81 + } 82 + } 83 + } 84 + 85 + // Full snapshot: create Doc from content, upload 86 + let doc = yrs_pds::doc_from_text(content); 87 + let entry = yrs_pds::doc_to_file_entry(&doc, client, did).await?; 88 + total_bytes += entry.snapshot_blob.size; 89 + file_entries.insert(rel_path.clone(), entry); 90 + files_uploaded += 1; 91 + 92 + if verbose { 93 + eprintln!("pds-yrs: upload {}", rel_path); 94 + } 95 + } 96 + 97 + // Build SiteRecord 98 + let now = chrono::Utc::now().to_rfc3339(); 99 + let record = SiteRecord { 100 + name: rkey.to_string(), 101 + files: file_entries, 102 + updated_at: now, 103 + }; 104 + 105 + let record_json = serde_json::to_value(&record) 106 + .map_err(|e| format!("serialize SiteRecord: {}", e))?; 107 + 108 + client 109 + .put_record(did, COLLECTION, rkey, record_json, swap_cid) 110 + .await?; 111 + 112 + Ok(SaveResult { 113 + files_uploaded, 114 + files_skipped, 115 + total_bytes, 116 + }) 117 + } 118 + 119 + /// Attempt an incremental update to an existing FileEntry. 120 + async fn incremental_update( 121 + existing: &FileEntry, 122 + new_content: &str, 123 + client: &PdsClient, 124 + did: &str, 125 + _verbose: bool, 126 + ) -> Result<FileEntry, String> { 127 + // Reconstruct the existing Doc 128 + let doc = yrs_pds::file_entry_to_doc(existing, client, did).await?; 129 + let old_content = yrs_pds::materialize(&doc); 130 + 131 + // Apply the diff as Yrs operations 132 + apply_text_diff(&doc, &old_content, new_content); 133 + 134 + // Encode new state 135 + let content = yrs_pds::materialize(&doc); 136 + let snapshot = yrs_pds::encode_snapshot(&doc); 137 + let sv = yrs_pds::encode_state_vector(&doc); 138 + 139 + // Upload new snapshot 140 + let snapshot_blob = client.upload_blob(snapshot).await?; 141 + 142 + let now = chrono::Utc::now().to_rfc3339(); 143 + Ok(FileEntry { 144 + content, 145 + snapshot_blob, 146 + state_vector: yrs_pds::base64_encode(&sv), 147 + updates_blob: None, 148 + updates_count: existing.updates_count + 1, 149 + snapshot_at: now, 150 + }) 151 + } 152 + 153 + /// Apply a text diff to a Yrs Doc as character-level operations. 154 + fn apply_text_diff(doc: &yrs::Doc, old: &str, new: &str) { 155 + use similar::{ChangeTag, TextDiff}; 156 + use yrs::{Text, Transact}; 157 + 158 + let text = doc.get_or_insert_text("content"); 159 + let diff = TextDiff::configure() 160 + .algorithm(similar::Algorithm::Patience) 161 + .diff_chars(old, new); 162 + 163 + let mut txn = doc.transact_mut(); 164 + let mut pos: u32 = 0; 165 + 166 + for change in diff.iter_all_changes() { 167 + match change.tag() { 168 + ChangeTag::Equal => { 169 + pos += change.value().len() as u32; 170 + } 171 + ChangeTag::Insert => { 172 + let s = change.value(); 173 + text.insert(&mut txn, pos, s); 174 + pos += s.len() as u32; 175 + } 176 + ChangeTag::Delete => { 177 + let len = change.value().len() as u32; 178 + text.remove_range(&mut txn, pos, len); 179 + } 180 + } 181 + } 182 + } 183 + 184 + /// Collect all text files from a directory, returning (relative_path, content). 185 + fn collect_text_files(dir: &Path) -> Result<Vec<(String, String)>, String> { 186 + let mut files = Vec::new(); 187 + collect_recursive(dir, dir, &mut files)?; 188 + files.sort_by(|a, b| a.0.cmp(&b.0)); 189 + Ok(files) 190 + } 191 + 192 + fn collect_recursive( 193 + base: &Path, 194 + current: &Path, 195 + files: &mut Vec<(String, String)>, 196 + ) -> Result<(), String> { 197 + let entries = std::fs::read_dir(current) 198 + .map_err(|e| format!("read dir {:?}: {}", current, e))?; 199 + 200 + for entry in entries { 201 + let entry = entry.map_err(|e| format!("read dir entry: {}", e))?; 202 + let path = entry.path(); 203 + 204 + if path.is_dir() { 205 + let name = path.file_name().unwrap().to_str().unwrap_or(""); 206 + // Skip hidden directories and common non-content dirs 207 + if name.starts_with('.') || name == "node_modules" || name == "target" { 208 + continue; 209 + } 210 + collect_recursive(base, &path, files)?; 211 + } else { 212 + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); 213 + if is_text_extension(ext) { 214 + let rel_path = path 215 + .strip_prefix(base) 216 + .map_err(|e| format!("strip prefix: {}", e))? 217 + .to_str() 218 + .ok_or("non-utf8 path")? 219 + .to_string(); 220 + let content = std::fs::read_to_string(&path) 221 + .map_err(|e| format!("read {:?}: {}", path, e))?; 222 + files.push((rel_path, content)); 223 + } 224 + } 225 + } 226 + Ok(()) 227 + } 228 + 229 + fn is_text_extension(ext: &str) -> bool { 230 + matches!( 231 + ext, 232 + "md" | "html" | "css" | "js" | "ts" | "json" | "toml" | "yaml" | "yml" | "txt" | "xml" 233 + | "svg" | "jsx" | "tsx" 234 + ) 235 + } 236 + 237 + #[cfg(test)] 238 + mod tests { 239 + use super::*; 240 + 241 + #[test] 242 + fn collect_text_files_finds_markdown() { 243 + let tmp = tempfile::tempdir().unwrap(); 244 + std::fs::write(tmp.path().join("index.md"), "# Home").unwrap(); 245 + std::fs::write(tmp.path().join("image.png"), "binary").unwrap(); 246 + std::fs::create_dir_all(tmp.path().join("blog")).unwrap(); 247 + std::fs::write(tmp.path().join("blog/post.md"), "# Post").unwrap(); 248 + 249 + let files = collect_text_files(tmp.path()).unwrap(); 250 + let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect(); 251 + assert!(paths.contains(&"index.md")); 252 + assert!(paths.contains(&"blog/post.md")); 253 + assert!(!paths.iter().any(|p| p.contains("image.png"))); 254 + } 255 + 256 + #[test] 257 + fn apply_text_diff_works() { 258 + let doc = yrs_pds::doc_from_text("Hello world"); 259 + apply_text_diff(&doc, "Hello world", "Hello beautiful world"); 260 + assert_eq!(yrs_pds::materialize(&doc), "Hello beautiful world"); 261 + } 262 + }
+145
src/types.rs
··· 1 + //! AT Protocol types for CRDT-on-PDS storage. 2 + 3 + use serde::{Deserialize, Serialize}; 4 + use std::collections::HashMap; 5 + 6 + /// Collection name for site records. 7 + pub const COLLECTION: &str = "net.commoninternet.lichen.site"; 8 + 9 + /// A site stored on PDS with Yrs CRDT state per file. 10 + #[derive(Debug, Clone, Serialize, Deserialize)] 11 + pub struct SiteRecord { 12 + pub name: String, 13 + pub files: HashMap<String, FileEntry>, 14 + #[serde(rename = "updatedAt")] 15 + pub updated_at: String, 16 + } 17 + 18 + /// A single file's state, stored as Yrs CRDT + plain text. 19 + #[derive(Debug, Clone, Serialize, Deserialize)] 20 + pub struct FileEntry { 21 + /// Plain text content (always current, for portability). 22 + pub content: String, 23 + /// Full Yrs state blob reference (encode_state_as_update_v1). 24 + #[serde(rename = "snapshotBlob")] 25 + pub snapshot_blob: BlobRef, 26 + /// State vector bytes, base64-encoded for inline storage. 27 + #[serde(rename = "stateVector")] 28 + pub state_vector: String, 29 + /// Incremental updates since snapshot. 30 + #[serde(rename = "updatesBlob", skip_serializing_if = "Option::is_none")] 31 + pub updates_blob: Option<BlobRef>, 32 + /// Number of incremental updates applied since last snapshot. 33 + #[serde(rename = "updatesCount", default)] 34 + pub updates_count: u32, 35 + /// When the snapshot was taken. 36 + #[serde(rename = "snapshotAt")] 37 + pub snapshot_at: String, 38 + } 39 + 40 + /// AT Protocol blob reference. 41 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 42 + pub struct BlobRef { 43 + #[serde(rename = "$type")] 44 + pub blob_type: String, 45 + #[serde(rename = "ref")] 46 + pub link: CidLink, 47 + #[serde(rename = "mimeType")] 48 + pub mime_type: String, 49 + pub size: u64, 50 + } 51 + 52 + /// CID link for blob references. 53 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 54 + pub struct CidLink { 55 + #[serde(rename = "$link")] 56 + pub link: String, 57 + } 58 + 59 + impl BlobRef { 60 + pub fn new(cid: String, mime_type: String, size: u64) -> Self { 61 + Self { 62 + blob_type: "blob".to_string(), 63 + link: CidLink { link: cid }, 64 + mime_type, 65 + size, 66 + } 67 + } 68 + 69 + pub fn cid(&self) -> &str { 70 + &self.link.link 71 + } 72 + } 73 + 74 + /// Result of a save operation. 75 + #[derive(Debug)] 76 + pub struct SaveResult { 77 + pub files_uploaded: usize, 78 + pub files_skipped: usize, 79 + pub total_bytes: u64, 80 + } 81 + 82 + /// Result of a load operation. 83 + #[derive(Debug)] 84 + pub struct LoadResult { 85 + pub files_loaded: usize, 86 + pub blobs_downloaded: usize, 87 + } 88 + 89 + #[cfg(test)] 90 + mod tests { 91 + use super::*; 92 + 93 + #[test] 94 + fn blob_ref_serialization_round_trip() { 95 + let blob = BlobRef::new("bafyabc123".to_string(), "application/octet-stream".to_string(), 1024); 96 + let json = serde_json::to_string(&blob).unwrap(); 97 + assert!(json.contains("\"$type\":\"blob\"")); 98 + assert!(json.contains("\"$link\":\"bafyabc123\"")); 99 + let deserialized: BlobRef = serde_json::from_str(&json).unwrap(); 100 + assert_eq!(deserialized, blob); 101 + } 102 + 103 + #[test] 104 + fn file_entry_serialization() { 105 + let entry = FileEntry { 106 + content: "Hello world".to_string(), 107 + snapshot_blob: BlobRef::new("bafysnap".to_string(), "application/octet-stream".to_string(), 100), 108 + state_vector: "AQID".to_string(), 109 + updates_blob: None, 110 + updates_count: 0, 111 + snapshot_at: "2026-03-13T00:00:00Z".to_string(), 112 + }; 113 + let json = serde_json::to_string(&entry).unwrap(); 114 + assert!(json.contains("\"snapshotBlob\"")); 115 + assert!(json.contains("\"stateVector\"")); 116 + assert!(!json.contains("updatesBlob")); // skipped when None 117 + let deserialized: FileEntry = serde_json::from_str(&json).unwrap(); 118 + assert_eq!(deserialized.content, "Hello world"); 119 + } 120 + 121 + #[test] 122 + fn site_record_serialization() { 123 + let mut files = HashMap::new(); 124 + files.insert( 125 + "index.md".to_string(), 126 + FileEntry { 127 + content: "# Home".to_string(), 128 + snapshot_blob: BlobRef::new("bafyindex".to_string(), "application/octet-stream".to_string(), 50), 129 + state_vector: "AQID".to_string(), 130 + updates_blob: None, 131 + updates_count: 0, 132 + snapshot_at: "2026-03-13T00:00:00Z".to_string(), 133 + }, 134 + ); 135 + let record = SiteRecord { 136 + name: "my-site".to_string(), 137 + files, 138 + updated_at: "2026-03-13T00:00:00Z".to_string(), 139 + }; 140 + let json = serde_json::to_string(&record).unwrap(); 141 + let deserialized: SiteRecord = serde_json::from_str(&json).unwrap(); 142 + assert_eq!(deserialized.name, "my-site"); 143 + assert!(deserialized.files.contains_key("index.md")); 144 + } 145 + }
+308
src/yrs_pds.rs
··· 1 + //! Yrs Doc ↔ PDS serialization. 2 + //! 3 + //! Convert between Yrs Docs and FileEntry records stored on PDS. 4 + 5 + use yrs::updates::decoder::Decode; 6 + use yrs::updates::encoder::Encode; 7 + use yrs::{Doc, GetString, ReadTxn, Text, Transact}; 8 + 9 + use crate::pds_client::PdsClient; 10 + use crate::types::FileEntry; 11 + 12 + /// Create a Yrs Doc from text content. 13 + pub fn doc_from_text(content: &str) -> Doc { 14 + let doc = Doc::new(); 15 + let text = doc.get_or_insert_text("content"); 16 + { 17 + let mut txn = doc.transact_mut(); 18 + text.insert(&mut txn, 0, content); 19 + } 20 + doc 21 + } 22 + 23 + /// Create a Yrs Doc from text content with a specific client ID. 24 + pub fn doc_from_text_with_client(content: &str, client_id: u64) -> Doc { 25 + let doc = Doc::with_client_id(client_id); 26 + let text = doc.get_or_insert_text("content"); 27 + { 28 + let mut txn = doc.transact_mut(); 29 + text.insert(&mut txn, 0, content); 30 + } 31 + doc 32 + } 33 + 34 + /// Materialize text from a Yrs Doc. 35 + pub fn materialize(doc: &Doc) -> String { 36 + let text = doc.get_or_insert_text("content"); 37 + let txn = doc.transact(); 38 + text.get_string(&txn) 39 + } 40 + 41 + /// Encode a Doc's full state as bytes. 42 + pub fn encode_snapshot(doc: &Doc) -> Vec<u8> { 43 + let txn = doc.transact(); 44 + txn.encode_state_as_update_v1(&yrs::StateVector::default()) 45 + } 46 + 47 + /// Encode a Doc's state vector as bytes. 48 + pub fn encode_state_vector(doc: &Doc) -> Vec<u8> { 49 + let txn = doc.transact(); 50 + txn.state_vector().encode_v1() 51 + } 52 + 53 + /// Encode an incremental update from a previous state vector. 54 + pub fn encode_diff(doc: &Doc, remote_sv: &[u8]) -> Result<Vec<u8>, String> { 55 + let sv = yrs::StateVector::decode_v1(remote_sv) 56 + .map_err(|e| format!("decode state vector: {}", e))?; 57 + let txn = doc.transact(); 58 + Ok(txn.encode_diff_v1(&sv)) 59 + } 60 + 61 + /// Load a Doc from a snapshot blob. 62 + pub fn doc_from_snapshot(data: &[u8]) -> Result<Doc, String> { 63 + let doc = Doc::new(); 64 + let _text = doc.get_or_insert_text("content"); 65 + let update = yrs::Update::decode_v1(data) 66 + .map_err(|e| format!("decode snapshot: {}", e))?; 67 + doc.transact_mut() 68 + .apply_update(update) 69 + .map_err(|e| format!("apply snapshot: {}", e))?; 70 + Ok(doc) 71 + } 72 + 73 + /// Apply an incremental update to a Doc. 74 + pub fn apply_update(doc: &Doc, data: &[u8]) -> Result<(), String> { 75 + let update = yrs::Update::decode_v1(data) 76 + .map_err(|e| format!("decode update: {}", e))?; 77 + doc.transact_mut() 78 + .apply_update(update) 79 + .map_err(|e| format!("apply update: {}", e))?; 80 + Ok(()) 81 + } 82 + 83 + /// Upload a Doc as a FileEntry to PDS. 84 + pub async fn doc_to_file_entry( 85 + doc: &Doc, 86 + client: &PdsClient, 87 + did: &str, 88 + ) -> Result<FileEntry, String> { 89 + let content = materialize(doc); 90 + let snapshot = encode_snapshot(doc); 91 + let sv = encode_state_vector(doc); 92 + 93 + // Upload snapshot blob 94 + let snapshot_blob = client.upload_blob(snapshot.clone()).await?; 95 + 96 + // We need to reference the blob in a record for it to persist, 97 + // so we return the FileEntry which will be embedded in a SiteRecord. 98 + 99 + let now = chrono::Utc::now().to_rfc3339(); 100 + let _ = did; // used by caller for the record 101 + 102 + Ok(FileEntry { 103 + content, 104 + snapshot_blob, 105 + state_vector: base64_encode(&sv), 106 + updates_blob: None, 107 + updates_count: 0, 108 + snapshot_at: now, 109 + }) 110 + } 111 + 112 + /// Reconstruct a Doc from a FileEntry by downloading blobs from PDS. 113 + pub async fn file_entry_to_doc( 114 + entry: &FileEntry, 115 + client: &PdsClient, 116 + did: &str, 117 + ) -> Result<Doc, String> { 118 + // Download snapshot blob 119 + let snapshot_data = client 120 + .get_blob(did, entry.snapshot_blob.cid()) 121 + .await?; 122 + 123 + let doc = doc_from_snapshot(&snapshot_data)?; 124 + 125 + // Apply incremental updates if present 126 + if let Some(ref updates_blob) = entry.updates_blob { 127 + let updates_data = client.get_blob(did, updates_blob.cid()).await?; 128 + apply_update(&doc, &updates_data)?; 129 + } 130 + 131 + Ok(doc) 132 + } 133 + 134 + /// Base64 encode bytes. 135 + pub fn base64_encode(data: &[u8]) -> String { 136 + use std::io::Write; 137 + let mut buf = Vec::new(); 138 + { 139 + let mut encoder = Base64Encoder::new(&mut buf); 140 + encoder.write_all(data).unwrap(); 141 + encoder.finish().unwrap(); 142 + } 143 + String::from_utf8(buf).unwrap() 144 + } 145 + 146 + /// Base64 decode a string. 147 + pub fn base64_decode(s: &str) -> Result<Vec<u8>, String> { 148 + let mut result = Vec::new(); 149 + let chars: Vec<u8> = s.bytes().collect(); 150 + let mut i = 0; 151 + while i < chars.len() { 152 + let a = decode_b64_char(chars[i])?; 153 + let b = if i + 1 < chars.len() { decode_b64_char(chars[i + 1])? } else { 0 }; 154 + let c = if i + 2 < chars.len() && chars[i + 2] != b'=' { decode_b64_char(chars[i + 2])? } else { 0 }; 155 + let d = if i + 3 < chars.len() && chars[i + 3] != b'=' { decode_b64_char(chars[i + 3])? } else { 0 }; 156 + 157 + result.push((a << 2) | (b >> 4)); 158 + if i + 2 < chars.len() && chars[i + 2] != b'=' { 159 + result.push(((b & 0xF) << 4) | (c >> 2)); 160 + } 161 + if i + 3 < chars.len() && chars[i + 3] != b'=' { 162 + result.push(((c & 0x3) << 6) | d); 163 + } 164 + i += 4; 165 + } 166 + Ok(result) 167 + } 168 + 169 + fn decode_b64_char(c: u8) -> Result<u8, String> { 170 + match c { 171 + b'A'..=b'Z' => Ok(c - b'A'), 172 + b'a'..=b'z' => Ok(c - b'a' + 26), 173 + b'0'..=b'9' => Ok(c - b'0' + 52), 174 + b'+' => Ok(62), 175 + b'/' => Ok(63), 176 + b'=' => Ok(0), 177 + _ => Err(format!("invalid base64 char: {}", c as char)), 178 + } 179 + } 180 + 181 + /// Simple base64 encoder. 182 + struct Base64Encoder<'a> { 183 + buf: &'a mut Vec<u8>, 184 + pending: [u8; 3], 185 + pending_len: usize, 186 + } 187 + 188 + const B64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; 189 + 190 + impl<'a> Base64Encoder<'a> { 191 + fn new(buf: &'a mut Vec<u8>) -> Self { 192 + Self { buf, pending: [0; 3], pending_len: 0 } 193 + } 194 + 195 + fn finish(mut self) -> Result<(), std::io::Error> { 196 + if self.pending_len > 0 { 197 + self.flush_pending(); 198 + } 199 + Ok(()) 200 + } 201 + 202 + fn flush_pending(&mut self) { 203 + let a = self.pending[0]; 204 + let b = if self.pending_len > 1 { self.pending[1] } else { 0 }; 205 + let c = if self.pending_len > 2 { self.pending[2] } else { 0 }; 206 + 207 + self.buf.push(B64_CHARS[(a >> 2) as usize]); 208 + self.buf.push(B64_CHARS[(((a & 0x3) << 4) | (b >> 4)) as usize]); 209 + 210 + if self.pending_len > 1 { 211 + self.buf.push(B64_CHARS[(((b & 0xF) << 2) | (c >> 6)) as usize]); 212 + } else { 213 + self.buf.push(b'='); 214 + } 215 + 216 + if self.pending_len > 2 { 217 + self.buf.push(B64_CHARS[(c & 0x3F) as usize]); 218 + } else { 219 + self.buf.push(b'='); 220 + } 221 + 222 + self.pending_len = 0; 223 + } 224 + } 225 + 226 + impl<'a> std::io::Write for Base64Encoder<'a> { 227 + fn write(&mut self, data: &[u8]) -> std::io::Result<usize> { 228 + for &byte in data { 229 + self.pending[self.pending_len] = byte; 230 + self.pending_len += 1; 231 + if self.pending_len == 3 { 232 + self.flush_pending(); 233 + } 234 + } 235 + Ok(data.len()) 236 + } 237 + 238 + fn flush(&mut self) -> std::io::Result<()> { 239 + Ok(()) 240 + } 241 + } 242 + 243 + #[cfg(test)] 244 + mod tests { 245 + use super::*; 246 + 247 + #[test] 248 + fn doc_round_trip() { 249 + let content = "Hello world"; 250 + let doc = doc_from_text(content); 251 + assert_eq!(materialize(&doc), content); 252 + 253 + let snapshot = encode_snapshot(&doc); 254 + let restored = doc_from_snapshot(&snapshot).unwrap(); 255 + assert_eq!(materialize(&restored), content); 256 + } 257 + 258 + #[test] 259 + fn incremental_update() { 260 + let doc = doc_from_text("Hello"); 261 + let snapshot = encode_snapshot(&doc); 262 + let sv = encode_state_vector(&doc); 263 + 264 + // Apply an edit 265 + let text = doc.get_or_insert_text("content"); 266 + { 267 + let mut txn = doc.transact_mut(); 268 + text.insert(&mut txn, 5, " world"); 269 + } 270 + assert_eq!(materialize(&doc), "Hello world"); 271 + 272 + // Encode diff from original state 273 + let diff = encode_diff(&doc, &sv).unwrap(); 274 + 275 + // Apply diff to a copy restored from the same snapshot (same client history) 276 + let doc2 = doc_from_snapshot(&snapshot).unwrap(); 277 + assert_eq!(materialize(&doc2), "Hello"); 278 + apply_update(&doc2, &diff).unwrap(); 279 + assert_eq!(materialize(&doc2), "Hello world"); 280 + } 281 + 282 + #[test] 283 + fn base64_round_trip() { 284 + let data = b"Hello, World!"; 285 + let encoded = base64_encode(data); 286 + let decoded = base64_decode(&encoded).unwrap(); 287 + assert_eq!(decoded, data); 288 + } 289 + 290 + #[test] 291 + fn base64_empty() { 292 + let encoded = base64_encode(b""); 293 + assert_eq!(encoded, ""); 294 + let decoded = base64_decode("").unwrap(); 295 + assert_eq!(decoded, b""); 296 + } 297 + 298 + #[test] 299 + fn state_vector_encode_decode() { 300 + let doc = doc_from_text("test content"); 301 + let sv_bytes = encode_state_vector(&doc); 302 + assert!(!sv_bytes.is_empty()); 303 + 304 + let encoded = base64_encode(&sv_bytes); 305 + let decoded = base64_decode(&encoded).unwrap(); 306 + assert_eq!(decoded, sv_bytes); 307 + } 308 + }
+322
tests/e2e_tests.rs
··· 1 + //! End-to-end tests for pds-yrs against a real PDS. 2 + //! 3 + //! Gated behind the `e2e` feature flag. 4 + //! Run with: cargo test -p pds-yrs --features e2e -- --test-threads=1 5 + #![cfg(feature = "e2e")] 6 + 7 + use std::path::Path; 8 + use tokio::fs; 9 + 10 + use pds_yrs::pds_client::PdsClient; 11 + use pds_yrs::types::COLLECTION; 12 + 13 + const PDS_URL: &str = "https://bluesky-pds.t1cc.commoninternet.net"; 14 + const HANDLE: &str = "testadmin.bluesky-pds.t1cc.commoninternet.net"; 15 + const PASSWORD: &str = "manual-test-9e449f9687bc8d35"; 16 + 17 + async fn pds_is_available() -> bool { 18 + let url = format!("{}/xrpc/_health", PDS_URL); 19 + reqwest::get(&url) 20 + .await 21 + .is_ok_and(|r| r.status().is_success()) 22 + } 23 + 24 + macro_rules! require_pds { 25 + () => { 26 + if !pds_is_available().await { 27 + eprintln!("SKIP: PDS not available at {}", PDS_URL); 28 + return; 29 + } 30 + }; 31 + } 32 + 33 + fn unique_rkey(prefix: &str) -> String { 34 + use std::time::{SystemTime, UNIX_EPOCH}; 35 + let nanos = SystemTime::now() 36 + .duration_since(UNIX_EPOCH) 37 + .unwrap() 38 + .as_nanos(); 39 + format!("{}-{}", prefix, nanos) 40 + } 41 + 42 + async fn login_client() -> (PdsClient, String) { 43 + let mut client = PdsClient::new(PDS_URL); 44 + let session = client.login(HANDLE, PASSWORD).await.expect("login failed"); 45 + (client, session.did) 46 + } 47 + 48 + async fn write_file(dir: &Path, name: &str, content: &str) { 49 + let path = dir.join(name); 50 + if let Some(parent) = path.parent() { 51 + fs::create_dir_all(parent).await.unwrap(); 52 + } 53 + fs::write(&path, content).await.unwrap(); 54 + } 55 + 56 + async fn read_file(dir: &Path, name: &str) -> String { 57 + fs::read_to_string(dir.join(name)).await.unwrap() 58 + } 59 + 60 + async fn create_sample_site(dir: &Path) { 61 + write_file(dir, "index.md", "# Home\n\nWelcome to my site.").await; 62 + write_file(dir, "about.md", "# About\n\nThis is about.").await; 63 + write_file(dir, "blog/first.md", "# First Post\n\nHello world.").await; 64 + } 65 + 66 + // ── Tests ─────────────────────────────────────────────────────── 67 + 68 + #[tokio::test] 69 + async fn e2e_login() { 70 + require_pds!(); 71 + let mut client = PdsClient::new(PDS_URL); 72 + let session = client.login(HANDLE, PASSWORD).await.unwrap(); 73 + assert!(session.did.starts_with("did:plc:")); 74 + assert!(!session.access_jwt.is_empty()); 75 + } 76 + 77 + #[tokio::test] 78 + async fn e2e_blob_round_trip() { 79 + require_pds!(); 80 + let (client, did) = login_client().await; 81 + 82 + let data = b"hello from pds-yrs e2e test!".to_vec(); 83 + let blob_ref = client.upload_blob(data.clone()).await.unwrap(); 84 + assert!(!blob_ref.cid().is_empty()); 85 + 86 + // Must reference blob in a record for PDS to persist it 87 + let rkey = unique_rkey("e2e-blob"); 88 + let record = serde_json::json!({ 89 + "blob": blob_ref, 90 + "createdAt": "2026-03-13T00:00:00Z" 91 + }); 92 + client 93 + .put_record(&did, COLLECTION, &rkey, record, None) 94 + .await 95 + .unwrap(); 96 + 97 + let downloaded = client.get_blob(&did, blob_ref.cid()).await.unwrap(); 98 + assert_eq!(downloaded, data); 99 + 100 + client 101 + .delete_record_with_did(&did, COLLECTION, &rkey) 102 + .await 103 + .ok(); 104 + } 105 + 106 + #[tokio::test] 107 + async fn e2e_save_load_round_trip() { 108 + require_pds!(); 109 + let (client, did) = login_client().await; 110 + let rkey = unique_rkey("e2e-save-load"); 111 + 112 + let source = tempfile::tempdir().unwrap(); 113 + create_sample_site(source.path()).await; 114 + 115 + // Save to PDS 116 + let result = pds_yrs::save(source.path(), &client, &did, &rkey, false) 117 + .await 118 + .unwrap(); 119 + assert_eq!(result.files_uploaded, 3); 120 + 121 + // Load into a new directory 122 + let dest = tempfile::tempdir().unwrap(); 123 + let load_result = pds_yrs::load(&client, &did, &rkey, dest.path(), false) 124 + .await 125 + .unwrap(); 126 + assert_eq!(load_result.files_loaded, 3); 127 + 128 + // Verify files match 129 + assert_eq!( 130 + read_file(dest.path(), "index.md").await, 131 + "# Home\n\nWelcome to my site." 132 + ); 133 + assert_eq!( 134 + read_file(dest.path(), "about.md").await, 135 + "# About\n\nThis is about." 136 + ); 137 + assert_eq!( 138 + read_file(dest.path(), "blog/first.md").await, 139 + "# First Post\n\nHello world." 140 + ); 141 + 142 + client 143 + .delete_record_with_did(&did, COLLECTION, &rkey) 144 + .await 145 + .ok(); 146 + } 147 + 148 + #[tokio::test] 149 + async fn e2e_incremental_save() { 150 + require_pds!(); 151 + let (client, did) = login_client().await; 152 + let rkey = unique_rkey("e2e-incremental"); 153 + 154 + let site = tempfile::tempdir().unwrap(); 155 + create_sample_site(site.path()).await; 156 + 157 + // First save 158 + let result = pds_yrs::save(site.path(), &client, &did, &rkey, false) 159 + .await 160 + .unwrap(); 161 + assert_eq!(result.files_uploaded, 3); 162 + 163 + // Edit one file 164 + write_file(site.path(), "index.md", "# Home\n\nUpdated content.").await; 165 + 166 + // Second save — should only upload 1 changed file 167 + let result = pds_yrs::save(site.path(), &client, &did, &rkey, false) 168 + .await 169 + .unwrap(); 170 + assert_eq!( 171 + result.files_uploaded, 1, 172 + "only changed file should be uploaded" 173 + ); 174 + assert_eq!( 175 + result.files_skipped, 2, 176 + "unchanged files should be skipped" 177 + ); 178 + 179 + // Verify content 180 + let dest = tempfile::tempdir().unwrap(); 181 + pds_yrs::load(&client, &did, &rkey, dest.path(), false) 182 + .await 183 + .unwrap(); 184 + assert_eq!( 185 + read_file(dest.path(), "index.md").await, 186 + "# Home\n\nUpdated content." 187 + ); 188 + assert_eq!( 189 + read_file(dest.path(), "about.md").await, 190 + "# About\n\nThis is about." 191 + ); 192 + 193 + client 194 + .delete_record_with_did(&did, COLLECTION, &rkey) 195 + .await 196 + .ok(); 197 + } 198 + 199 + #[tokio::test] 200 + async fn e2e_concurrent_edits_merge() { 201 + require_pds!(); 202 + let (client, did) = login_client().await; 203 + let rkey_a = unique_rkey("e2e-merge-a"); 204 + let rkey_b = unique_rkey("e2e-merge-b"); 205 + 206 + // Both collaborators start from the same content 207 + let site_a = tempfile::tempdir().unwrap(); 208 + let site_b = tempfile::tempdir().unwrap(); 209 + write_file( 210 + site_a.path(), 211 + "shared.md", 212 + "# Shared\n\nOriginal content.\n", 213 + ) 214 + .await; 215 + write_file( 216 + site_b.path(), 217 + "shared.md", 218 + "# Shared\n\nOriginal content.\n", 219 + ) 220 + .await; 221 + 222 + // Save initial state for both 223 + pds_yrs::save(site_a.path(), &client, &did, &rkey_a, false) 224 + .await 225 + .unwrap(); 226 + pds_yrs::save(site_b.path(), &client, &did, &rkey_b, false) 227 + .await 228 + .unwrap(); 229 + 230 + // Collaborator A appends 231 + write_file( 232 + site_a.path(), 233 + "shared.md", 234 + "# Shared\n\nOriginal content.\n\nAlice's addition.\n", 235 + ) 236 + .await; 237 + pds_yrs::save(site_a.path(), &client, &did, &rkey_a, false) 238 + .await 239 + .unwrap(); 240 + 241 + // Collaborator B edits first paragraph 242 + write_file( 243 + site_b.path(), 244 + "shared.md", 245 + "# Shared\n\nBob's edit to original content.\n", 246 + ) 247 + .await; 248 + pds_yrs::save(site_b.path(), &client, &did, &rkey_b, false) 249 + .await 250 + .unwrap(); 251 + 252 + // Merge both 253 + let merged = tempfile::tempdir().unwrap(); 254 + pds_yrs::merge_sites( 255 + &client, 256 + &did, 257 + &[&rkey_a, &rkey_b], 258 + merged.path(), 259 + false, 260 + ) 261 + .await 262 + .unwrap(); 263 + 264 + let content = read_file(merged.path(), "shared.md").await; 265 + // Note: since these are independent docs with different client IDs, 266 + // the CRDT merge may interleave content rather than cleanly combining. 267 + // The key assertion is no conflict markers and the merge completes. 268 + assert!( 269 + !content.contains("<<<<"), 270 + "no conflict markers in CRDT merge" 271 + ); 272 + 273 + client 274 + .delete_record_with_did(&did, COLLECTION, &rkey_a) 275 + .await 276 + .ok(); 277 + client 278 + .delete_record_with_did(&did, COLLECTION, &rkey_b) 279 + .await 280 + .ok(); 281 + } 282 + 283 + #[tokio::test] 284 + async fn e2e_export_no_yrs() { 285 + require_pds!(); 286 + let (client, did) = login_client().await; 287 + let rkey = unique_rkey("e2e-export"); 288 + 289 + let source = tempfile::tempdir().unwrap(); 290 + create_sample_site(source.path()).await; 291 + pds_yrs::save(source.path(), &client, &did, &rkey, false) 292 + .await 293 + .unwrap(); 294 + 295 + // Export (reads content field only, no Yrs decoding) 296 + let exported = tempfile::tempdir().unwrap(); 297 + let count = pds_yrs::export(&client, &did, &rkey, exported.path(), false) 298 + .await 299 + .unwrap(); 300 + assert_eq!(count, 3); 301 + 302 + // Load (full Yrs decode path) 303 + let loaded = tempfile::tempdir().unwrap(); 304 + pds_yrs::load(&client, &did, &rkey, loaded.path(), false) 305 + .await 306 + .unwrap(); 307 + 308 + // Both should produce identical files 309 + assert_eq!( 310 + read_file(exported.path(), "index.md").await, 311 + read_file(loaded.path(), "index.md").await, 312 + ); 313 + assert_eq!( 314 + read_file(exported.path(), "about.md").await, 315 + read_file(loaded.path(), "about.md").await, 316 + ); 317 + 318 + client 319 + .delete_record_with_did(&did, COLLECTION, &rkey) 320 + .await 321 + .ok(); 322 + }