Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +1041 −5
| @@ -3,6 +3,12 @@ | |||
| 3 | 3 | version = 4 | |
| 4 | 4 | ||
| 5 | 5 | [[package]] | |
| 6 | + | name = "allocator-api2" | |
| 7 | + | version = "0.2.21" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 10 | + | ||
| 11 | + | [[package]] | |
| 6 | 12 | name = "anstream" | |
| 7 | 13 | version = "1.0.0" | |
| 8 | 14 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -63,6 +69,15 @@ name = "api" | |||
| 63 | 69 | version = "0.1.0" | |
| 64 | 70 | ||
| 65 | 71 | [[package]] | |
| 72 | + | name = "atoi" | |
| 73 | + | version = "2.0.0" | |
| 74 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 75 | + | checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" | |
| 76 | + | dependencies = [ | |
| 77 | + | "num-traits", | |
| 78 | + | ] | |
| 79 | + | ||
| 80 | + | [[package]] | |
| 66 | 81 | name = "atomic" | |
| 67 | 82 | version = "0.6.1" | |
| 68 | 83 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -76,10 +91,49 @@ name = "auth" | |||
| 76 | 91 | version = "0.1.0" | |
| 77 | 92 | ||
| 78 | 93 | [[package]] | |
| 94 | + | name = "autocfg" | |
| 95 | + | version = "1.5.1" | |
| 96 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 97 | + | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 98 | + | ||
| 99 | + | [[package]] | |
| 100 | + | name = "base64" | |
| 101 | + | version = "0.22.1" | |
| 102 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 103 | + | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 104 | + | ||
| 105 | + | [[package]] | |
| 79 | 106 | name = "bitflags" | |
| 80 | 107 | version = "2.13.1" | |
| 81 | 108 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 82 | 109 | checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" | |
| 110 | + | dependencies = [ | |
| 111 | + | "serde_core", | |
| 112 | + | ] | |
| 113 | + | ||
| 114 | + | [[package]] | |
| 115 | + | name = "block-buffer" | |
| 116 | + | version = "0.10.4" | |
| 117 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 118 | + | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | |
| 119 | + | dependencies = [ | |
| 120 | + | "generic-array", | |
| 121 | + | ] | |
| 122 | + | ||
| 123 | + | [[package]] | |
| 124 | + | name = "block-buffer" | |
| 125 | + | version = "0.12.1" | |
| 126 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 127 | + | checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" | |
| 128 | + | dependencies = [ | |
| 129 | + | "hybrid-array", | |
| 130 | + | ] | |
| 131 | + | ||
| 132 | + | [[package]] | |
| 133 | + | name = "bumpalo" | |
| 134 | + | version = "3.20.3" | |
| 135 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 136 | + | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 83 | 137 | ||
| 84 | 138 | [[package]] | |
| 85 | 139 | name = "bytemuck" | |
| @@ -88,12 +142,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 88 | 142 | checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" | |
| 89 | 143 | ||
| 90 | 144 | [[package]] | |
| 145 | + | name = "byteorder" | |
| 146 | + | version = "1.5.0" | |
| 147 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 148 | + | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | |
| 149 | + | ||
| 150 | + | [[package]] | |
| 151 | + | name = "bytes" | |
| 152 | + | version = "1.12.1" | |
| 153 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 154 | + | checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" | |
| 155 | + | ||
| 156 | + | [[package]] | |
| 157 | + | name = "cc" | |
| 158 | + | version = "1.4.0" | |
| 159 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 160 | + | checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" | |
| 161 | + | dependencies = [ | |
| 162 | + | "find-msvc-tools", | |
| 163 | + | "shlex", | |
| 164 | + | ] | |
| 165 | + | ||
| 166 | + | [[package]] | |
| 91 | 167 | name = "cfg-if" | |
| 92 | 168 | version = "1.0.4" | |
| 93 | 169 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 94 | 170 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 95 | 171 | ||
| 96 | 172 | [[package]] | |
| 173 | + | name = "chacha20" | |
| 174 | + | version = "0.10.1" | |
| 175 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 176 | + | checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" | |
| 177 | + | dependencies = [ | |
| 178 | + | "cfg-if", | |
| 179 | + | "cpufeatures 0.3.0", | |
| 180 | + | "rand_core 0.10.1", | |
| 181 | + | ] | |
| 182 | + | ||
| 183 | + | [[package]] | |
| 97 | 184 | name = "clap" | |
| 98 | 185 | version = "4.6.4" | |
| 99 | 186 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -144,12 +231,27 @@ dependencies = [ | |||
| 144 | 231 | ] | |
| 145 | 232 | ||
| 146 | 233 | [[package]] | |
| 234 | + | name = "cmov" | |
| 235 | + | version = "0.5.4" | |
| 236 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 237 | + | checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" | |
| 238 | + | ||
| 239 | + | [[package]] | |
| 147 | 240 | name = "colorchoice" | |
| 148 | 241 | version = "1.0.5" | |
| 149 | 242 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 150 | 243 | checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" | |
| 151 | 244 | ||
| 152 | 245 | [[package]] | |
| 246 | + | name = "concurrent-queue" | |
| 247 | + | version = "2.5.0" | |
| 248 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 249 | + | checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" | |
| 250 | + | dependencies = [ | |
| 251 | + | "crossbeam-utils", | |
| 252 | + | ] | |
| 253 | + | ||
| 254 | + | [[package]] | |
| 153 | 255 | name = "config" | |
| 154 | 256 | version = "0.1.0" | |
| 155 | 257 | dependencies = [ | |
| @@ -161,6 +263,103 @@ dependencies = [ | |||
| 161 | 263 | ] | |
| 162 | 264 | ||
| 163 | 265 | [[package]] | |
| 266 | + | name = "cpufeatures" | |
| 267 | + | version = "0.2.17" | |
| 268 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 269 | + | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | |
| 270 | + | dependencies = [ | |
| 271 | + | "libc", | |
| 272 | + | ] | |
| 273 | + | ||
| 274 | + | [[package]] | |
| 275 | + | name = "cpufeatures" | |
| 276 | + | version = "0.3.0" | |
| 277 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 278 | + | checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" | |
| 279 | + | dependencies = [ | |
| 280 | + | "libc", | |
| 281 | + | ] | |
| 282 | + | ||
| 283 | + | [[package]] | |
| 284 | + | name = "crc" | |
| 285 | + | version = "3.4.0" | |
| 286 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 287 | + | checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" | |
| 288 | + | dependencies = [ | |
| 289 | + | "crc-catalog", | |
| 290 | + | ] | |
| 291 | + | ||
| 292 | + | [[package]] | |
| 293 | + | name = "crc-catalog" | |
| 294 | + | version = "2.5.0" | |
| 295 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 296 | + | checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" | |
| 297 | + | ||
| 298 | + | [[package]] | |
| 299 | + | name = "crossbeam-queue" | |
| 300 | + | version = "0.3.13" | |
| 301 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 302 | + | checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" | |
| 303 | + | dependencies = [ | |
| 304 | + | "crossbeam-utils", | |
| 305 | + | ] | |
| 306 | + | ||
| 307 | + | [[package]] | |
| 308 | + | name = "crossbeam-utils" | |
| 309 | + | version = "0.8.22" | |
| 310 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 311 | + | checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" | |
| 312 | + | ||
| 313 | + | [[package]] | |
| 314 | + | name = "crypto-common" | |
| 315 | + | version = "0.1.6" | |
| 316 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 317 | + | checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" | |
| 318 | + | dependencies = [ | |
| 319 | + | "generic-array", | |
| 320 | + | "typenum", | |
| 321 | + | ] | |
| 322 | + | ||
| 323 | + | [[package]] | |
| 324 | + | name = "crypto-common" | |
| 325 | + | version = "0.2.2" | |
| 326 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 327 | + | checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" | |
| 328 | + | dependencies = [ | |
| 329 | + | "hybrid-array", | |
| 330 | + | ] | |
| 331 | + | ||
| 332 | + | [[package]] | |
| 333 | + | name = "ctutils" | |
| 334 | + | version = "0.4.2" | |
| 335 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 336 | + | checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" | |
| 337 | + | dependencies = [ | |
| 338 | + | "cmov", | |
| 339 | + | ] | |
| 340 | + | ||
| 341 | + | [[package]] | |
| 342 | + | name = "digest" | |
| 343 | + | version = "0.10.7" | |
| 344 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 345 | + | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |
| 346 | + | dependencies = [ | |
| 347 | + | "block-buffer 0.10.4", | |
| 348 | + | "crypto-common 0.1.6", | |
| 349 | + | ] | |
| 350 | + | ||
| 351 | + | [[package]] | |
| 352 | + | name = "digest" | |
| 353 | + | version = "0.11.3" | |
| 354 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 355 | + | checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" | |
| 356 | + | dependencies = [ | |
| 357 | + | "block-buffer 0.12.1", | |
| 358 | + | "crypto-common 0.2.2", | |
| 359 | + | "ctutils", | |
| 360 | + | ] | |
| 361 | + | ||
| 362 | + | [[package]] | |
| 164 | 363 | name = "displaydoc" | |
| 165 | 364 | version = "0.2.6" | |
| 166 | 365 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -172,6 +371,21 @@ dependencies = [ | |||
| 172 | 371 | ] | |
| 173 | 372 | ||
| 174 | 373 | [[package]] | |
| 374 | + | name = "dotenvy" | |
| 375 | + | version = "0.15.7" | |
| 376 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 377 | + | checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" | |
| 378 | + | ||
| 379 | + | [[package]] | |
| 380 | + | name = "either" | |
| 381 | + | version = "1.17.0" | |
| 382 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 383 | + | checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" | |
| 384 | + | dependencies = [ | |
| 385 | + | "serde", | |
| 386 | + | ] | |
| 387 | + | ||
| 388 | + | [[package]] | |
| 175 | 389 | name = "equivalent" | |
| 176 | 390 | version = "1.0.2" | |
| 177 | 391 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -188,6 +402,27 @@ dependencies = [ | |||
| 188 | 402 | ] | |
| 189 | 403 | ||
| 190 | 404 | [[package]] | |
| 405 | + | name = "etcetera" | |
| 406 | + | version = "0.11.0" | |
| 407 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 408 | + | checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" | |
| 409 | + | dependencies = [ | |
| 410 | + | "cfg-if", | |
| 411 | + | "windows-sys", | |
| 412 | + | ] | |
| 413 | + | ||
| 414 | + | [[package]] | |
| 415 | + | name = "event-listener" | |
| 416 | + | version = "5.4.1" | |
| 417 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 418 | + | checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" | |
| 419 | + | dependencies = [ | |
| 420 | + | "concurrent-queue", | |
| 421 | + | "parking", | |
| 422 | + | "pin-project-lite", | |
| 423 | + | ] | |
| 424 | + | ||
| 425 | + | [[package]] | |
| 191 | 426 | name = "fabrica" | |
| 192 | 427 | version = "0.1.0" | |
| 193 | 428 | dependencies = [ | |
| @@ -217,6 +452,29 @@ dependencies = [ | |||
| 217 | 452 | ] | |
| 218 | 453 | ||
| 219 | 454 | [[package]] | |
| 455 | + | name = "find-msvc-tools" | |
| 456 | + | version = "0.1.9" | |
| 457 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 458 | + | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | |
| 459 | + | ||
| 460 | + | [[package]] | |
| 461 | + | name = "flume" | |
| 462 | + | version = "0.12.0" | |
| 463 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 464 | + | checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" | |
| 465 | + | dependencies = [ | |
| 466 | + | "futures-core", | |
| 467 | + | "futures-sink", | |
| 468 | + | "spin", | |
| 469 | + | ] | |
| 470 | + | ||
| 471 | + | [[package]] | |
| 472 | + | name = "foldhash" | |
| 473 | + | version = "0.2.0" | |
| 474 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 475 | + | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| 476 | + | ||
| 477 | + | [[package]] | |
| 220 | 478 | name = "form_urlencoded" | |
| 221 | 479 | version = "1.2.2" | |
| 222 | 480 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -226,6 +484,99 @@ dependencies = [ | |||
| 226 | 484 | ] | |
| 227 | 485 | ||
| 228 | 486 | [[package]] | |
| 487 | + | name = "futures-channel" | |
| 488 | + | version = "0.3.33" | |
| 489 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 490 | + | checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" | |
| 491 | + | dependencies = [ | |
| 492 | + | "futures-core", | |
| 493 | + | "futures-sink", | |
| 494 | + | ] | |
| 495 | + | ||
| 496 | + | [[package]] | |
| 497 | + | name = "futures-core" | |
| 498 | + | version = "0.3.33" | |
| 499 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 500 | + | checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" | |
| 501 | + | ||
| 502 | + | [[package]] | |
| 503 | + | name = "futures-executor" | |
| 504 | + | version = "0.3.33" | |
| 505 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 506 | + | checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" | |
| 507 | + | dependencies = [ | |
| 508 | + | "futures-core", | |
| 509 | + | "futures-task", | |
| 510 | + | "futures-util", | |
| 511 | + | ] | |
| 512 | + | ||
| 513 | + | [[package]] | |
| 514 | + | name = "futures-intrusive" | |
| 515 | + | version = "0.5.0" | |
| 516 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 517 | + | checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" | |
| 518 | + | dependencies = [ | |
| 519 | + | "futures-core", | |
| 520 | + | "lock_api", | |
| 521 | + | "parking_lot", | |
| 522 | + | ] | |
| 523 | + | ||
| 524 | + | [[package]] | |
| 525 | + | name = "futures-io" | |
| 526 | + | version = "0.3.33" | |
| 527 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 528 | + | checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" | |
| 529 | + | ||
| 530 | + | [[package]] | |
| 531 | + | name = "futures-sink" | |
| 532 | + | version = "0.3.33" | |
| 533 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 534 | + | checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" | |
| 535 | + | ||
| 536 | + | [[package]] | |
| 537 | + | name = "futures-task" | |
| 538 | + | version = "0.3.33" | |
| 539 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 540 | + | checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" | |
| 541 | + | ||
| 542 | + | [[package]] | |
| 543 | + | name = "futures-util" | |
| 544 | + | version = "0.3.33" | |
| 545 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 546 | + | checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" | |
| 547 | + | dependencies = [ | |
| 548 | + | "futures-core", | |
| 549 | + | "futures-io", | |
| 550 | + | "futures-sink", | |
| 551 | + | "futures-task", | |
| 552 | + | "memchr", | |
| 553 | + | "pin-project-lite", | |
| 554 | + | "slab", | |
| 555 | + | ] | |
| 556 | + | ||
| 557 | + | [[package]] | |
| 558 | + | name = "generic-array" | |
| 559 | + | version = "0.14.9" | |
| 560 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 561 | + | checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" | |
| 562 | + | dependencies = [ | |
| 563 | + | "typenum", | |
| 564 | + | "version_check", | |
| 565 | + | ] | |
| 566 | + | ||
| 567 | + | [[package]] | |
| 568 | + | name = "getrandom" | |
| 569 | + | version = "0.3.4" | |
| 570 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 571 | + | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" | |
| 572 | + | dependencies = [ | |
| 573 | + | "cfg-if", | |
| 574 | + | "libc", | |
| 575 | + | "r-efi 5.3.0", | |
| 576 | + | "wasip2", | |
| 577 | + | ] | |
| 578 | + | ||
| 579 | + | [[package]] | |
| 229 | 580 | name = "getrandom" | |
| 230 | 581 | version = "0.4.3" | |
| 231 | 582 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -233,7 +584,8 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" | |||
| 233 | 584 | dependencies = [ | |
| 234 | 585 | "cfg-if", | |
| 235 | 586 | "libc", | |
| 236 | - | "r-efi", | |
| 587 | + | "r-efi 6.0.0", | |
| 588 | + | "rand_core 0.10.1", | |
| 237 | 589 | ] | |
| 238 | 590 | ||
| 239 | 591 | [[package]] | |
| @@ -242,21 +594,74 @@ version = "0.1.0" | |||
| 242 | 594 | ||
| 243 | 595 | [[package]] | |
| 244 | 596 | name = "hashbrown" | |
| 597 | + | version = "0.16.1" | |
| 598 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 599 | + | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | |
| 600 | + | dependencies = [ | |
| 601 | + | "allocator-api2", | |
| 602 | + | "equivalent", | |
| 603 | + | "foldhash", | |
| 604 | + | ] | |
| 605 | + | ||
| 606 | + | [[package]] | |
| 607 | + | name = "hashbrown" | |
| 245 | 608 | version = "0.17.1" | |
| 246 | 609 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 247 | 610 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | |
| 248 | 611 | ||
| 249 | 612 | [[package]] | |
| 613 | + | name = "hashlink" | |
| 614 | + | version = "0.11.1" | |
| 615 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 616 | + | checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" | |
| 617 | + | dependencies = [ | |
| 618 | + | "hashbrown 0.16.1", | |
| 619 | + | ] | |
| 620 | + | ||
| 621 | + | [[package]] | |
| 250 | 622 | name = "heck" | |
| 251 | 623 | version = "0.5.0" | |
| 252 | 624 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 253 | 625 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | |
| 254 | 626 | ||
| 255 | 627 | [[package]] | |
| 628 | + | name = "hex" | |
| 629 | + | version = "0.4.3" | |
| 630 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 631 | + | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" | |
| 632 | + | ||
| 633 | + | [[package]] | |
| 256 | 634 | name = "highlight" | |
| 257 | 635 | version = "0.1.0" | |
| 258 | 636 | ||
| 259 | 637 | [[package]] | |
| 638 | + | name = "hkdf" | |
| 639 | + | version = "0.13.0" | |
| 640 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 641 | + | checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" | |
| 642 | + | dependencies = [ | |
| 643 | + | "hmac", | |
| 644 | + | ] | |
| 645 | + | ||
| 646 | + | [[package]] | |
| 647 | + | name = "hmac" | |
| 648 | + | version = "0.13.0" | |
| 649 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 650 | + | checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" | |
| 651 | + | dependencies = [ | |
| 652 | + | "digest 0.11.3", | |
| 653 | + | ] | |
| 654 | + | ||
| 655 | + | [[package]] | |
| 656 | + | name = "hybrid-array" | |
| 657 | + | version = "0.4.13" | |
| 658 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 659 | + | checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" | |
| 660 | + | dependencies = [ | |
| 661 | + | "typenum", | |
| 662 | + | ] | |
| 663 | + | ||
| 664 | + | [[package]] | |
| 260 | 665 | name = "icu_collections" | |
| 261 | 666 | version = "2.1.1" | |
| 262 | 667 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -365,7 +770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 365 | 770 | checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" | |
| 366 | 771 | dependencies = [ | |
| 367 | 772 | "equivalent", | |
| 368 | - | "hashbrown", | |
| 773 | + | "hashbrown 0.17.1", | |
| 369 | 774 | ] | |
| 370 | 775 | ||
| 371 | 776 | [[package]] | |
| @@ -387,12 +792,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 387 | 792 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 388 | 793 | ||
| 389 | 794 | [[package]] | |
| 795 | + | name = "js-sys" | |
| 796 | + | version = "0.3.103" | |
| 797 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 798 | + | checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" | |
| 799 | + | dependencies = [ | |
| 800 | + | "cfg-if", | |
| 801 | + | "futures-util", | |
| 802 | + | "wasm-bindgen", | |
| 803 | + | ] | |
| 804 | + | ||
| 805 | + | [[package]] | |
| 390 | 806 | name = "libc" | |
| 391 | 807 | version = "0.2.189" | |
| 392 | 808 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 393 | 809 | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" | |
| 394 | 810 | ||
| 395 | 811 | [[package]] | |
| 812 | + | name = "libsqlite3-sys" | |
| 813 | + | version = "0.37.0" | |
| 814 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 815 | + | checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" | |
| 816 | + | dependencies = [ | |
| 817 | + | "cc", | |
| 818 | + | "pkg-config", | |
| 819 | + | "vcpkg", | |
| 820 | + | ] | |
| 821 | + | ||
| 822 | + | [[package]] | |
| 396 | 823 | name = "linux-raw-sys" | |
| 397 | 824 | version = "0.12.1" | |
| 398 | 825 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -414,18 +841,57 @@ dependencies = [ | |||
| 414 | 841 | ] | |
| 415 | 842 | ||
| 416 | 843 | [[package]] | |
| 844 | + | name = "log" | |
| 845 | + | version = "0.4.33" | |
| 846 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 847 | + | checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" | |
| 848 | + | ||
| 849 | + | [[package]] | |
| 417 | 850 | name = "mail" | |
| 418 | 851 | version = "0.1.0" | |
| 419 | 852 | ||
| 420 | 853 | [[package]] | |
| 854 | + | name = "md-5" | |
| 855 | + | version = "0.11.0" | |
| 856 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 857 | + | checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" | |
| 858 | + | dependencies = [ | |
| 859 | + | "cfg-if", | |
| 860 | + | "digest 0.11.3", | |
| 861 | + | ] | |
| 862 | + | ||
| 863 | + | [[package]] | |
| 421 | 864 | name = "memchr" | |
| 422 | 865 | version = "2.8.3" | |
| 423 | 866 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 424 | 867 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | |
| 425 | 868 | ||
| 426 | 869 | [[package]] | |
| 427 | - | name = "model" | |
| 428 | - | version = "0.1.0" | |
| 870 | + | name = "mio" | |
| 871 | + | version = "1.2.2" | |
| 872 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 873 | + | checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" | |
| 874 | + | dependencies = [ | |
| 875 | + | "libc", | |
| 876 | + | "wasi", | |
| 877 | + | "windows-sys", | |
| 878 | + | ] | |
| 879 | + | ||
| 880 | + | [[package]] | |
| 881 | + | name = "model" | |
| 882 | + | version = "0.1.0" | |
| 883 | + | dependencies = [ | |
| 884 | + | "thiserror", | |
| 885 | + | ] | |
| 886 | + | ||
| 887 | + | [[package]] | |
| 888 | + | name = "num-traits" | |
| 889 | + | version = "0.2.19" | |
| 890 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 891 | + | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | |
| 892 | + | dependencies = [ | |
| 893 | + | "autocfg", | |
| 894 | + | ] | |
| 429 | 895 | ||
| 430 | 896 | [[package]] | |
| 431 | 897 | name = "once_cell" | |
| @@ -440,6 +906,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 440 | 906 | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" | |
| 441 | 907 | ||
| 442 | 908 | [[package]] | |
| 909 | + | name = "parking" | |
| 910 | + | version = "2.2.1" | |
| 911 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 912 | + | checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" | |
| 913 | + | ||
| 914 | + | [[package]] | |
| 443 | 915 | name = "parking_lot" | |
| 444 | 916 | version = "0.12.5" | |
| 445 | 917 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -492,6 +964,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 492 | 964 | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" | |
| 493 | 965 | ||
| 494 | 966 | [[package]] | |
| 967 | + | name = "pin-project-lite" | |
| 968 | + | version = "0.2.17" | |
| 969 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 970 | + | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | |
| 971 | + | ||
| 972 | + | [[package]] | |
| 973 | + | name = "pkg-config" | |
| 974 | + | version = "0.3.33" | |
| 975 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 976 | + | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" | |
| 977 | + | ||
| 978 | + | [[package]] | |
| 495 | 979 | name = "potential_utf" | |
| 496 | 980 | version = "0.1.5" | |
| 497 | 981 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -501,6 +985,15 @@ dependencies = [ | |||
| 501 | 985 | ] | |
| 502 | 986 | ||
| 503 | 987 | [[package]] | |
| 988 | + | name = "ppv-lite86" | |
| 989 | + | version = "0.2.21" | |
| 990 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 991 | + | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" | |
| 992 | + | dependencies = [ | |
| 993 | + | "zerocopy", | |
| 994 | + | ] | |
| 995 | + | ||
| 996 | + | [[package]] | |
| 504 | 997 | name = "proc-macro2" | |
| 505 | 998 | version = "1.0.107" | |
| 506 | 999 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -533,11 +1026,63 @@ dependencies = [ | |||
| 533 | 1026 | ||
| 534 | 1027 | [[package]] | |
| 535 | 1028 | name = "r-efi" | |
| 1029 | + | version = "5.3.0" | |
| 1030 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1031 | + | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" | |
| 1032 | + | ||
| 1033 | + | [[package]] | |
| 1034 | + | name = "r-efi" | |
| 536 | 1035 | version = "6.0.0" | |
| 537 | 1036 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 538 | 1037 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | |
| 539 | 1038 | ||
| 540 | 1039 | [[package]] | |
| 1040 | + | name = "rand" | |
| 1041 | + | version = "0.9.5" | |
| 1042 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1043 | + | checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" | |
| 1044 | + | dependencies = [ | |
| 1045 | + | "rand_chacha", | |
| 1046 | + | "rand_core 0.9.5", | |
| 1047 | + | ] | |
| 1048 | + | ||
| 1049 | + | [[package]] | |
| 1050 | + | name = "rand" | |
| 1051 | + | version = "0.10.2" | |
| 1052 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1053 | + | checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" | |
| 1054 | + | dependencies = [ | |
| 1055 | + | "chacha20", | |
| 1056 | + | "getrandom 0.4.3", | |
| 1057 | + | "rand_core 0.10.1", | |
| 1058 | + | ] | |
| 1059 | + | ||
| 1060 | + | [[package]] | |
| 1061 | + | name = "rand_chacha" | |
| 1062 | + | version = "0.9.0" | |
| 1063 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1064 | + | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" | |
| 1065 | + | dependencies = [ | |
| 1066 | + | "ppv-lite86", | |
| 1067 | + | "rand_core 0.9.5", | |
| 1068 | + | ] | |
| 1069 | + | ||
| 1070 | + | [[package]] | |
| 1071 | + | name = "rand_core" | |
| 1072 | + | version = "0.9.5" | |
| 1073 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1074 | + | checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" | |
| 1075 | + | dependencies = [ | |
| 1076 | + | "getrandom 0.3.4", | |
| 1077 | + | ] | |
| 1078 | + | ||
| 1079 | + | [[package]] | |
| 1080 | + | name = "rand_core" | |
| 1081 | + | version = "0.10.1" | |
| 1082 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1083 | + | checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" | |
| 1084 | + | ||
| 1085 | + | [[package]] | |
| 541 | 1086 | name = "redox_syscall" | |
| 542 | 1087 | version = "0.5.18" | |
| 543 | 1088 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -560,6 +1105,12 @@ dependencies = [ | |||
| 560 | 1105 | ] | |
| 561 | 1106 | ||
| 562 | 1107 | [[package]] | |
| 1108 | + | name = "rustversion" | |
| 1109 | + | version = "1.0.23" | |
| 1110 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1111 | + | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | |
| 1112 | + | ||
| 1113 | + | [[package]] | |
| 563 | 1114 | name = "scopeguard" | |
| 564 | 1115 | version = "1.2.0" | |
| 565 | 1116 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -618,10 +1169,247 @@ dependencies = [ | |||
| 618 | 1169 | ] | |
| 619 | 1170 | ||
| 620 | 1171 | [[package]] | |
| 1172 | + | name = "sha1" | |
| 1173 | + | version = "0.11.0" | |
| 1174 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1175 | + | checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" | |
| 1176 | + | dependencies = [ | |
| 1177 | + | "cfg-if", | |
| 1178 | + | "cpufeatures 0.3.0", | |
| 1179 | + | "digest 0.11.3", | |
| 1180 | + | ] | |
| 1181 | + | ||
| 1182 | + | [[package]] | |
| 1183 | + | name = "sha2" | |
| 1184 | + | version = "0.10.9" | |
| 1185 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1186 | + | checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" | |
| 1187 | + | dependencies = [ | |
| 1188 | + | "cfg-if", | |
| 1189 | + | "cpufeatures 0.2.17", | |
| 1190 | + | "digest 0.10.7", | |
| 1191 | + | ] | |
| 1192 | + | ||
| 1193 | + | [[package]] | |
| 1194 | + | name = "sha2" | |
| 1195 | + | version = "0.11.0" | |
| 1196 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1197 | + | checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" | |
| 1198 | + | dependencies = [ | |
| 1199 | + | "cfg-if", | |
| 1200 | + | "cpufeatures 0.3.0", | |
| 1201 | + | "digest 0.11.3", | |
| 1202 | + | ] | |
| 1203 | + | ||
| 1204 | + | [[package]] | |
| 1205 | + | name = "shlex" | |
| 1206 | + | version = "2.0.1" | |
| 1207 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1208 | + | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | |
| 1209 | + | ||
| 1210 | + | [[package]] | |
| 1211 | + | name = "slab" | |
| 1212 | + | version = "0.4.12" | |
| 1213 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1214 | + | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | |
| 1215 | + | ||
| 1216 | + | [[package]] | |
| 621 | 1217 | name = "smallvec" | |
| 622 | 1218 | version = "1.15.2" | |
| 623 | 1219 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 624 | 1220 | checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" | |
| 1221 | + | dependencies = [ | |
| 1222 | + | "serde", | |
| 1223 | + | ] | |
| 1224 | + | ||
| 1225 | + | [[package]] | |
| 1226 | + | name = "socket2" | |
| 1227 | + | version = "0.6.5" | |
| 1228 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1229 | + | checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" | |
| 1230 | + | dependencies = [ | |
| 1231 | + | "libc", | |
| 1232 | + | "windows-sys", | |
| 1233 | + | ] | |
| 1234 | + | ||
| 1235 | + | [[package]] | |
| 1236 | + | name = "spin" | |
| 1237 | + | version = "0.9.9" | |
| 1238 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1239 | + | checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" | |
| 1240 | + | dependencies = [ | |
| 1241 | + | "lock_api", | |
| 1242 | + | ] | |
| 1243 | + | ||
| 1244 | + | [[package]] | |
| 1245 | + | name = "sqlx" | |
| 1246 | + | version = "0.9.0" | |
| 1247 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1248 | + | checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" | |
| 1249 | + | dependencies = [ | |
| 1250 | + | "sqlx-core", | |
| 1251 | + | "sqlx-macros", | |
| 1252 | + | "sqlx-mysql", | |
| 1253 | + | "sqlx-postgres", | |
| 1254 | + | "sqlx-sqlite", | |
| 1255 | + | ] | |
| 1256 | + | ||
| 1257 | + | [[package]] | |
| 1258 | + | name = "sqlx-core" | |
| 1259 | + | version = "0.9.0" | |
| 1260 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1261 | + | checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" | |
| 1262 | + | dependencies = [ | |
| 1263 | + | "base64", | |
| 1264 | + | "bytes", | |
| 1265 | + | "cfg-if", | |
| 1266 | + | "crc", | |
| 1267 | + | "crossbeam-queue", | |
| 1268 | + | "either", | |
| 1269 | + | "event-listener", | |
| 1270 | + | "futures-core", | |
| 1271 | + | "futures-intrusive", | |
| 1272 | + | "futures-io", | |
| 1273 | + | "futures-util", | |
| 1274 | + | "hashbrown 0.16.1", | |
| 1275 | + | "hashlink", | |
| 1276 | + | "indexmap", | |
| 1277 | + | "log", | |
| 1278 | + | "memchr", | |
| 1279 | + | "percent-encoding", | |
| 1280 | + | "serde", | |
| 1281 | + | "serde_json", | |
| 1282 | + | "sha2 0.10.9", | |
| 1283 | + | "smallvec", | |
| 1284 | + | "thiserror", | |
| 1285 | + | "tokio", | |
| 1286 | + | "tokio-stream", | |
| 1287 | + | "tracing", | |
| 1288 | + | "url", | |
| 1289 | + | ] | |
| 1290 | + | ||
| 1291 | + | [[package]] | |
| 1292 | + | name = "sqlx-macros" | |
| 1293 | + | version = "0.9.0" | |
| 1294 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1295 | + | checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" | |
| 1296 | + | dependencies = [ | |
| 1297 | + | "proc-macro2", | |
| 1298 | + | "quote", | |
| 1299 | + | "sqlx-core", | |
| 1300 | + | "sqlx-macros-core", | |
| 1301 | + | "syn 2.0.119", | |
| 1302 | + | ] | |
| 1303 | + | ||
| 1304 | + | [[package]] | |
| 1305 | + | name = "sqlx-macros-core" | |
| 1306 | + | version = "0.9.0" | |
| 1307 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1308 | + | checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" | |
| 1309 | + | dependencies = [ | |
| 1310 | + | "cfg-if", | |
| 1311 | + | "dotenvy", | |
| 1312 | + | "either", | |
| 1313 | + | "heck", | |
| 1314 | + | "hex", | |
| 1315 | + | "proc-macro2", | |
| 1316 | + | "quote", | |
| 1317 | + | "serde", | |
| 1318 | + | "serde_json", | |
| 1319 | + | "sha2 0.10.9", | |
| 1320 | + | "sqlx-core", | |
| 1321 | + | "sqlx-postgres", | |
| 1322 | + | "sqlx-sqlite", | |
| 1323 | + | "syn 2.0.119", | |
| 1324 | + | "thiserror", | |
| 1325 | + | "tokio", | |
| 1326 | + | "url", | |
| 1327 | + | ] | |
| 1328 | + | ||
| 1329 | + | [[package]] | |
| 1330 | + | name = "sqlx-mysql" | |
| 1331 | + | version = "0.9.0" | |
| 1332 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1333 | + | checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" | |
| 1334 | + | dependencies = [ | |
| 1335 | + | "bitflags", | |
| 1336 | + | "byteorder", | |
| 1337 | + | "bytes", | |
| 1338 | + | "crc", | |
| 1339 | + | "digest 0.11.3", | |
| 1340 | + | "dotenvy", | |
| 1341 | + | "either", | |
| 1342 | + | "futures-core", | |
| 1343 | + | "futures-util", | |
| 1344 | + | "generic-array", | |
| 1345 | + | "log", | |
| 1346 | + | "percent-encoding", | |
| 1347 | + | "serde", | |
| 1348 | + | "sha1", | |
| 1349 | + | "sha2 0.11.0", | |
| 1350 | + | "sqlx-core", | |
| 1351 | + | "thiserror", | |
| 1352 | + | "tracing", | |
| 1353 | + | ] | |
| 1354 | + | ||
| 1355 | + | [[package]] | |
| 1356 | + | name = "sqlx-postgres" | |
| 1357 | + | version = "0.9.0" | |
| 1358 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1359 | + | checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" | |
| 1360 | + | dependencies = [ | |
| 1361 | + | "atoi", | |
| 1362 | + | "base64", | |
| 1363 | + | "bitflags", | |
| 1364 | + | "byteorder", | |
| 1365 | + | "crc", | |
| 1366 | + | "dotenvy", | |
| 1367 | + | "etcetera", | |
| 1368 | + | "futures-channel", | |
| 1369 | + | "futures-core", | |
| 1370 | + | "futures-util", | |
| 1371 | + | "hex", | |
| 1372 | + | "hkdf", | |
| 1373 | + | "hmac", | |
| 1374 | + | "itoa", | |
| 1375 | + | "log", | |
| 1376 | + | "md-5", | |
| 1377 | + | "memchr", | |
| 1378 | + | "rand 0.10.2", | |
| 1379 | + | "serde", | |
| 1380 | + | "serde_json", | |
| 1381 | + | "sha2 0.11.0", | |
| 1382 | + | "smallvec", | |
| 1383 | + | "sqlx-core", | |
| 1384 | + | "stringprep", | |
| 1385 | + | "thiserror", | |
| 1386 | + | "tracing", | |
| 1387 | + | "whoami", | |
| 1388 | + | ] | |
| 1389 | + | ||
| 1390 | + | [[package]] | |
| 1391 | + | name = "sqlx-sqlite" | |
| 1392 | + | version = "0.9.0" | |
| 1393 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1394 | + | checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" | |
| 1395 | + | dependencies = [ | |
| 1396 | + | "atoi", | |
| 1397 | + | "flume", | |
| 1398 | + | "form_urlencoded", | |
| 1399 | + | "futures-channel", | |
| 1400 | + | "futures-core", | |
| 1401 | + | "futures-executor", | |
| 1402 | + | "futures-intrusive", | |
| 1403 | + | "futures-util", | |
| 1404 | + | "libsqlite3-sys", | |
| 1405 | + | "log", | |
| 1406 | + | "percent-encoding", | |
| 1407 | + | "serde", | |
| 1408 | + | "sqlx-core", | |
| 1409 | + | "thiserror", | |
| 1410 | + | "tracing", | |
| 1411 | + | "url", | |
| 1412 | + | ] | |
| 625 | 1413 | ||
| 626 | 1414 | [[package]] | |
| 627 | 1415 | name = "ssh" | |
| @@ -636,6 +1424,25 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" | |||
| 636 | 1424 | [[package]] | |
| 637 | 1425 | name = "store" | |
| 638 | 1426 | version = "0.1.0" | |
| 1427 | + | dependencies = [ | |
| 1428 | + | "model", | |
| 1429 | + | "sqlx", | |
| 1430 | + | "tempfile", | |
| 1431 | + | "thiserror", | |
| 1432 | + | "tokio", | |
| 1433 | + | "ulid", | |
| 1434 | + | ] | |
| 1435 | + | ||
| 1436 | + | [[package]] | |
| 1437 | + | name = "stringprep" | |
| 1438 | + | version = "0.1.5" | |
| 1439 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1440 | + | checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" | |
| 1441 | + | dependencies = [ | |
| 1442 | + | "unicode-bidi", | |
| 1443 | + | "unicode-normalization", | |
| 1444 | + | "unicode-properties", | |
| 1445 | + | ] | |
| 639 | 1446 | ||
| 640 | 1447 | [[package]] | |
| 641 | 1448 | name = "strsim" | |
| @@ -683,7 +1490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 683 | 1490 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | |
| 684 | 1491 | dependencies = [ | |
| 685 | 1492 | "fastrand", | |
| 686 | - | "getrandom", | |
| 1493 | + | "getrandom 0.4.3", | |
| 687 | 1494 | "once_cell", | |
| 688 | 1495 | "rustix", | |
| 689 | 1496 | "windows-sys", | |
| @@ -720,6 +1527,58 @@ dependencies = [ | |||
| 720 | 1527 | ] | |
| 721 | 1528 | ||
| 722 | 1529 | [[package]] | |
| 1530 | + | name = "tinyvec" | |
| 1531 | + | version = "1.12.0" | |
| 1532 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1533 | + | checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" | |
| 1534 | + | dependencies = [ | |
| 1535 | + | "tinyvec_macros", | |
| 1536 | + | ] | |
| 1537 | + | ||
| 1538 | + | [[package]] | |
| 1539 | + | name = "tinyvec_macros" | |
| 1540 | + | version = "0.1.1" | |
| 1541 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1542 | + | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" | |
| 1543 | + | ||
| 1544 | + | [[package]] | |
| 1545 | + | name = "tokio" | |
| 1546 | + | version = "1.53.1" | |
| 1547 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1548 | + | checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" | |
| 1549 | + | dependencies = [ | |
| 1550 | + | "bytes", | |
| 1551 | + | "libc", | |
| 1552 | + | "mio", | |
| 1553 | + | "pin-project-lite", | |
| 1554 | + | "socket2", | |
| 1555 | + | "tokio-macros", | |
| 1556 | + | "windows-sys", | |
| 1557 | + | ] | |
| 1558 | + | ||
| 1559 | + | [[package]] | |
| 1560 | + | name = "tokio-macros" | |
| 1561 | + | version = "2.7.1" | |
| 1562 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1563 | + | checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" | |
| 1564 | + | dependencies = [ | |
| 1565 | + | "proc-macro2", | |
| 1566 | + | "quote", | |
| 1567 | + | "syn 2.0.119", | |
| 1568 | + | ] | |
| 1569 | + | ||
| 1570 | + | [[package]] | |
| 1571 | + | name = "tokio-stream" | |
| 1572 | + | version = "0.1.19" | |
| 1573 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1574 | + | checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" | |
| 1575 | + | dependencies = [ | |
| 1576 | + | "futures-core", | |
| 1577 | + | "pin-project-lite", | |
| 1578 | + | "tokio", | |
| 1579 | + | ] | |
| 1580 | + | ||
| 1581 | + | [[package]] | |
| 723 | 1582 | name = "toml" | |
| 724 | 1583 | version = "0.8.23" | |
| 725 | 1584 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -761,6 +1620,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 761 | 1620 | checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" | |
| 762 | 1621 | ||
| 763 | 1622 | [[package]] | |
| 1623 | + | name = "tracing" | |
| 1624 | + | version = "0.1.44" | |
| 1625 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1626 | + | checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" | |
| 1627 | + | dependencies = [ | |
| 1628 | + | "log", | |
| 1629 | + | "pin-project-lite", | |
| 1630 | + | "tracing-attributes", | |
| 1631 | + | "tracing-core", | |
| 1632 | + | ] | |
| 1633 | + | ||
| 1634 | + | [[package]] | |
| 1635 | + | name = "tracing-attributes" | |
| 1636 | + | version = "0.1.31" | |
| 1637 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1638 | + | checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" | |
| 1639 | + | dependencies = [ | |
| 1640 | + | "proc-macro2", | |
| 1641 | + | "quote", | |
| 1642 | + | "syn 2.0.119", | |
| 1643 | + | ] | |
| 1644 | + | ||
| 1645 | + | [[package]] | |
| 1646 | + | name = "tracing-core" | |
| 1647 | + | version = "0.1.36" | |
| 1648 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1649 | + | checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" | |
| 1650 | + | dependencies = [ | |
| 1651 | + | "once_cell", | |
| 1652 | + | ] | |
| 1653 | + | ||
| 1654 | + | [[package]] | |
| 1655 | + | name = "typenum" | |
| 1656 | + | version = "1.20.1" | |
| 1657 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1658 | + | checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" | |
| 1659 | + | ||
| 1660 | + | [[package]] | |
| 1661 | + | name = "ulid" | |
| 1662 | + | version = "1.2.1" | |
| 1663 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1664 | + | checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" | |
| 1665 | + | dependencies = [ | |
| 1666 | + | "rand 0.9.5", | |
| 1667 | + | "web-time", | |
| 1668 | + | ] | |
| 1669 | + | ||
| 1670 | + | [[package]] | |
| 764 | 1671 | name = "uncased" | |
| 765 | 1672 | version = "0.9.10" | |
| 766 | 1673 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -770,12 +1677,33 @@ dependencies = [ | |||
| 770 | 1677 | ] | |
| 771 | 1678 | ||
| 772 | 1679 | [[package]] | |
| 1680 | + | name = "unicode-bidi" | |
| 1681 | + | version = "0.3.18" | |
| 1682 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1683 | + | checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" | |
| 1684 | + | ||
| 1685 | + | [[package]] | |
| 773 | 1686 | name = "unicode-ident" | |
| 774 | 1687 | version = "1.0.24" | |
| 775 | 1688 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 776 | 1689 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 777 | 1690 | ||
| 778 | 1691 | [[package]] | |
| 1692 | + | name = "unicode-normalization" | |
| 1693 | + | version = "0.1.25" | |
| 1694 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1695 | + | checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" | |
| 1696 | + | dependencies = [ | |
| 1697 | + | "tinyvec", | |
| 1698 | + | ] | |
| 1699 | + | ||
| 1700 | + | [[package]] | |
| 1701 | + | name = "unicode-properties" | |
| 1702 | + | version = "0.1.4" | |
| 1703 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1704 | + | checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" | |
| 1705 | + | ||
| 1706 | + | [[package]] | |
| 779 | 1707 | name = "url" | |
| 780 | 1708 | version = "2.5.8" | |
| 781 | 1709 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -800,16 +1728,98 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 800 | 1728 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" | |
| 801 | 1729 | ||
| 802 | 1730 | [[package]] | |
| 1731 | + | name = "vcpkg" | |
| 1732 | + | version = "0.2.15" | |
| 1733 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1734 | + | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | |
| 1735 | + | ||
| 1736 | + | [[package]] | |
| 803 | 1737 | name = "version_check" | |
| 804 | 1738 | version = "0.9.5" | |
| 805 | 1739 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 806 | 1740 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" | |
| 807 | 1741 | ||
| 808 | 1742 | [[package]] | |
| 1743 | + | name = "wasi" | |
| 1744 | + | version = "0.11.1+wasi-snapshot-preview1" | |
| 1745 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1746 | + | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" | |
| 1747 | + | ||
| 1748 | + | [[package]] | |
| 1749 | + | name = "wasip2" | |
| 1750 | + | version = "1.0.1+wasi-0.2.4" | |
| 1751 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1752 | + | checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" | |
| 1753 | + | dependencies = [ | |
| 1754 | + | "wit-bindgen", | |
| 1755 | + | ] | |
| 1756 | + | ||
| 1757 | + | [[package]] | |
| 1758 | + | name = "wasm-bindgen" | |
| 1759 | + | version = "0.2.126" | |
| 1760 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1761 | + | checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" | |
| 1762 | + | dependencies = [ | |
| 1763 | + | "cfg-if", | |
| 1764 | + | "once_cell", | |
| 1765 | + | "rustversion", | |
| 1766 | + | "wasm-bindgen-macro", | |
| 1767 | + | "wasm-bindgen-shared", | |
| 1768 | + | ] | |
| 1769 | + | ||
| 1770 | + | [[package]] | |
| 1771 | + | name = "wasm-bindgen-macro" | |
| 1772 | + | version = "0.2.126" | |
| 1773 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1774 | + | checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" | |
| 1775 | + | dependencies = [ | |
| 1776 | + | "quote", | |
| 1777 | + | "wasm-bindgen-macro-support", | |
| 1778 | + | ] | |
| 1779 | + | ||
| 1780 | + | [[package]] | |
| 1781 | + | name = "wasm-bindgen-macro-support" | |
| 1782 | + | version = "0.2.126" | |
| 1783 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1784 | + | checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" | |
| 1785 | + | dependencies = [ | |
| 1786 | + | "bumpalo", | |
| 1787 | + | "proc-macro2", | |
| 1788 | + | "quote", | |
| 1789 | + | "syn 2.0.119", | |
| 1790 | + | "wasm-bindgen-shared", | |
| 1791 | + | ] | |
| 1792 | + | ||
| 1793 | + | [[package]] | |
| 1794 | + | name = "wasm-bindgen-shared" | |
| 1795 | + | version = "0.2.126" | |
| 1796 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1797 | + | checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" | |
| 1798 | + | dependencies = [ | |
| 1799 | + | "unicode-ident", | |
| 1800 | + | ] | |
| 1801 | + | ||
| 1802 | + | [[package]] | |
| 809 | 1803 | name = "web" | |
| 810 | 1804 | version = "0.1.0" | |
| 811 | 1805 | ||
| 812 | 1806 | [[package]] | |
| 1807 | + | name = "web-time" | |
| 1808 | + | version = "1.1.0" | |
| 1809 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1810 | + | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" | |
| 1811 | + | dependencies = [ | |
| 1812 | + | "js-sys", | |
| 1813 | + | "wasm-bindgen", | |
| 1814 | + | ] | |
| 1815 | + | ||
| 1816 | + | [[package]] | |
| 1817 | + | name = "whoami" | |
| 1818 | + | version = "2.1.2" | |
| 1819 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1820 | + | checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" | |
| 1821 | + | ||
| 1822 | + | [[package]] | |
| 813 | 1823 | name = "windows-link" | |
| 814 | 1824 | version = "0.2.1" | |
| 815 | 1825 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -834,6 +1844,12 @@ dependencies = [ | |||
| 834 | 1844 | ] | |
| 835 | 1845 | ||
| 836 | 1846 | [[package]] | |
| 1847 | + | name = "wit-bindgen" | |
| 1848 | + | version = "0.46.0" | |
| 1849 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1850 | + | checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" | |
| 1851 | + | ||
| 1852 | + | [[package]] | |
| 837 | 1853 | name = "writeable" | |
| 838 | 1854 | version = "0.6.3" | |
| 839 | 1855 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -869,6 +1885,26 @@ dependencies = [ | |||
| 869 | 1885 | ] | |
| 870 | 1886 | ||
| 871 | 1887 | [[package]] | |
| 1888 | + | name = "zerocopy" | |
| 1889 | + | version = "0.8.55" | |
| 1890 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1891 | + | checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" | |
| 1892 | + | dependencies = [ | |
| 1893 | + | "zerocopy-derive", | |
| 1894 | + | ] | |
| 1895 | + | ||
| 1896 | + | [[package]] | |
| 1897 | + | name = "zerocopy-derive" | |
| 1898 | + | version = "0.8.55" | |
| 1899 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1900 | + | checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" | |
| 1901 | + | dependencies = [ | |
| 1902 | + | "proc-macro2", | |
| 1903 | + | "quote", | |
| 1904 | + | "syn 2.0.119", | |
| 1905 | + | ] | |
| 1906 | + | ||
| 1907 | + | [[package]] | |
| 872 | 1908 | name = "zerofrom" | |
| 873 | 1909 | version = "0.1.8" | |
| 874 | 1910 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
Cargo.toml +14 −0
| @@ -58,6 +58,20 @@ thiserror = "2" | |||
| 58 | 58 | url = "2" | |
| 59 | 59 | clap = { version = "4", features = ["derive"] } | |
| 60 | 60 | anyhow = "1" | |
| 61 | + | # Database. `any` gives one code path over both backends; TLS is deliberately | |
| 62 | + | # omitted for now (Postgres-over-TLS is a phase-17 deployment concern) to keep | |
| 63 | + | # the dependency tree small and the license set clean — see docs/decisions.md. | |
| 64 | + | sqlx = { version = "0.9", default-features = false, features = [ | |
| 65 | + | "runtime-tokio", | |
| 66 | + | "any", | |
| 67 | + | "sqlite", | |
| 68 | + | "postgres", | |
| 69 | + | "migrate", | |
| 70 | + | "macros", | |
| 71 | + | ] } | |
| 72 | + | ulid = "1" | |
| 73 | + | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } | |
| 74 | + | tempfile = "3" | |
| 61 | 75 | ||
| 62 | 76 | [workspace.lints.rust] | |
| 63 | 77 | unsafe_code = "forbid" | |
crates/model/Cargo.toml +1 −0
| @@ -10,6 +10,7 @@ repository.workspace = true | |||
| 10 | 10 | publish.workspace = true | |
| 11 | 11 | ||
| 12 | 12 | [dependencies] | |
| 13 | + | thiserror = { workspace = true } | |
| 13 | 14 | ||
| 14 | 15 | [lints] | |
| 15 | 16 | workspace = true | |
crates/model/src/entity.rs +75 −0
| @@ -0,0 +1,75 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! In-memory representations of the persisted entities. | |
| 6 | + | //! | |
| 7 | + | //! These are plain data types with no behaviour and no I/O — [`crate::store`] | |
| 8 | + | //! (the `store` crate) is responsible for reading and writing them. Timestamps | |
| 9 | + | //! are Unix milliseconds UTC ([`Timestamp`]); identifiers are ULID strings. | |
| 10 | + | //! | |
| 11 | + | //! Storage-only columns (the normalized `*_lower` uniqueness keys) are | |
| 12 | + | //! deliberately absent: they are an implementation detail of the schema, not | |
| 13 | + | //! part of the domain. | |
| 14 | + | ||
| 15 | + | /// A Unix timestamp in **milliseconds** UTC, matching the `BIGINT` columns in | |
| 16 | + | /// the schema. | |
| 17 | + | pub type Timestamp = i64; | |
| 18 | + | ||
| 19 | + | /// A registered account. | |
| 20 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 21 | + | pub struct User { | |
| 22 | + | /// ULID primary key. | |
| 23 | + | pub id: String, | |
| 24 | + | /// The name as the user typed it (case preserved for display). | |
| 25 | + | pub username: String, | |
| 26 | + | /// The primary email address. | |
| 27 | + | pub email: String, | |
| 28 | + | /// Optional human-friendly display name. | |
| 29 | + | pub display_name: Option<String>, | |
| 30 | + | /// Argon2id PHC hash, or `None` until an invite is completed. | |
| 31 | + | pub password_hash: Option<String>, | |
| 32 | + | /// Whether the user must set a new password on next login. | |
| 33 | + | pub must_change_password: bool, | |
| 34 | + | /// Whether the user is a site administrator. | |
| 35 | + | pub is_admin: bool, | |
| 36 | + | /// When the account was disabled, if it is. | |
| 37 | + | pub disabled_at: Option<Timestamp>, | |
| 38 | + | /// Creation time. | |
| 39 | + | pub created_at: Timestamp, | |
| 40 | + | /// Last modification time. | |
| 41 | + | pub updated_at: Timestamp, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the | |
| 45 | + | /// database is the sole authority mapping `(owner, path)` to this id. | |
| 46 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 47 | + | pub struct Repo { | |
| 48 | + | /// ULID primary key, and the on-disk directory stem. | |
| 49 | + | pub id: String, | |
| 50 | + | /// Owning user. | |
| 51 | + | pub owner_id: String, | |
| 52 | + | /// Containing group, or `None` when the repo hangs directly off the owner. | |
| 53 | + | pub group_id: Option<String>, | |
| 54 | + | /// The repo's own name (the final path segment). | |
| 55 | + | pub name: String, | |
| 56 | + | /// Materialized full path within the owner, e.g. `group/sub/repo`; equal to | |
| 57 | + | /// `name` when ungrouped. | |
| 58 | + | pub path: String, | |
| 59 | + | /// Optional one-line description. | |
| 60 | + | pub description: Option<String>, | |
| 61 | + | /// Private repositories are invisible to non-collaborators. | |
| 62 | + | pub is_private: bool, | |
| 63 | + | /// The branch shown by default and used for HEAD. | |
| 64 | + | pub default_branch: String, | |
| 65 | + | /// When the repo was archived (read-only), if it is. | |
| 66 | + | pub archived_at: Option<Timestamp>, | |
| 67 | + | /// Approximate on-disk size in bytes, refreshed by maintenance. | |
| 68 | + | pub size_bytes: i64, | |
| 69 | + | /// Time of the last push, if any. | |
| 70 | + | pub pushed_at: Option<Timestamp>, | |
| 71 | + | /// Creation time. | |
| 72 | + | pub created_at: Timestamp, | |
| 73 | + | /// Last modification time. | |
| 74 | + | pub updated_at: Timestamp, | |
| 75 | + | } | |
crates/model/src/lib.rs +10 −0
| @@ -3,3 +3,13 @@ | |||
| 3 | 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | 4 | ||
| 5 | 5 | //! Shared domain types. This crate performs no I/O and depends on nothing else in-tree. | |
| 6 | + | //! | |
| 7 | + | //! It holds the in-memory shapes of persisted entities ([`entity`]) and the | |
| 8 | + | //! name-validation rules ([`name`]) that every entry point — CLI, API, web — | |
| 9 | + | //! shares so they cannot drift. | |
| 10 | + | ||
| 11 | + | pub mod entity; | |
| 12 | + | pub mod name; | |
| 13 | + | ||
| 14 | + | pub use entity::{Repo, Timestamp, User}; | |
| 15 | + | pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name}; | |
crates/model/src/name.rs +201 −0
| @@ -0,0 +1,201 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! Validation for user, repository, and group names. | |
| 6 | + | //! | |
| 7 | + | //! One implementation, used by the CLI, the API, and the web UI, so the rules | |
| 8 | + | //! can never drift between entry points. A valid name matches the regular | |
| 9 | + | //! expression | |
| 10 | + | //! | |
| 11 | + | //! ```text | |
| 12 | + | //! ^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$ | |
| 13 | + | //! ``` | |
| 14 | + | //! | |
| 15 | + | //! and additionally must not be `.` or `..`, must not end in `.git` or `.`, and | |
| 16 | + | //! must not collide with a route-reserved word (see [`RESERVED_NAMES`]). The | |
| 17 | + | //! same rules apply to usernames, repository names, and group names — anywhere a | |
| 18 | + | //! name becomes part of a URL path. | |
| 19 | + | ||
| 20 | + | /// The maximum length of a name, in characters (the leading character plus up to | |
| 21 | + | /// 38 more, matching the `{0,38}` quantifier). | |
| 22 | + | pub const MAX_NAME_LEN: usize = 39; | |
| 23 | + | ||
| 24 | + | /// Names that would collide with a top-level route or a reserved path segment. | |
| 25 | + | /// | |
| 26 | + | /// Comparison is case-insensitive, so `Admin` is rejected just as `admin` is. | |
| 27 | + | pub const RESERVED_NAMES: &[&str] = &[ | |
| 28 | + | "api", "assets", "login", "logout", "search", "settings", "admin", "static", "healthz", | |
| 29 | + | "invite", "-", | |
| 30 | + | ]; | |
| 31 | + | ||
| 32 | + | /// Why a candidate name was rejected. Each variant maps to one actionable | |
| 33 | + | /// message; callers surface the `Display` form to the user. | |
| 34 | + | #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] | |
| 35 | + | pub enum NameError { | |
| 36 | + | /// The name was the empty string. | |
| 37 | + | #[error("name must not be empty")] | |
| 38 | + | Empty, | |
| 39 | + | /// The name exceeded [`MAX_NAME_LEN`] characters. | |
| 40 | + | #[error("name must be at most {MAX_NAME_LEN} characters")] | |
| 41 | + | TooLong, | |
| 42 | + | /// The first character was not an ASCII letter or digit. | |
| 43 | + | #[error("name must start with an ASCII letter or digit")] | |
| 44 | + | BadStart, | |
| 45 | + | /// A character after the first was outside `[A-Za-z0-9._-]`. | |
| 46 | + | #[error("name may only contain ASCII letters, digits, '.', '_', or '-' (found {0:?})")] | |
| 47 | + | BadChar(char), | |
| 48 | + | /// The name was `.` or `..`. | |
| 49 | + | #[error("name must not be \".\" or \"..\"")] | |
| 50 | + | DotName, | |
| 51 | + | /// The name ended in `.git`, which would be ambiguous with a bare repo path. | |
| 52 | + | #[error("name must not end in \".git\"")] | |
| 53 | + | GitSuffix, | |
| 54 | + | /// The name ended in `.`. | |
| 55 | + | #[error("name must not end in \".\"")] | |
| 56 | + | DotSuffix, | |
| 57 | + | /// The name matched a reserved word (case-insensitively). | |
| 58 | + | #[error("name {0:?} is reserved")] | |
| 59 | + | Reserved(String), | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Validate a user, repository, or group name against every rule in this module. | |
| 63 | + | /// | |
| 64 | + | /// # Errors | |
| 65 | + | /// | |
| 66 | + | /// Returns the first [`NameError`] that applies. Checks run in a fixed order | |
| 67 | + | /// (emptiness, dot-names, length, character set, suffixes, reserved words) so a | |
| 68 | + | /// given input always yields the same error. | |
| 69 | + | pub fn validate_name(name: &str) -> Result<(), NameError> { | |
| 70 | + | if name.is_empty() { | |
| 71 | + | return Err(NameError::Empty); | |
| 72 | + | } | |
| 73 | + | if name == "." || name == ".." { | |
| 74 | + | return Err(NameError::DotName); | |
| 75 | + | } | |
| 76 | + | // ASCII-only past this point is guaranteed by the character checks below, so | |
| 77 | + | // counting chars is equivalent to counting bytes for any name that could | |
| 78 | + | // pass — but count chars so an over-long multibyte input reports TooLong | |
| 79 | + | // rather than a confusing character error. | |
| 80 | + | if name.chars().count() > MAX_NAME_LEN { | |
| 81 | + | return Err(NameError::TooLong); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | let mut chars = name.chars(); | |
| 85 | + | // `name` is non-empty, so `next` yields. | |
| 86 | + | let first = chars.next().unwrap_or('\0'); | |
| 87 | + | if !first.is_ascii_alphanumeric() { | |
| 88 | + | return Err(NameError::BadStart); | |
| 89 | + | } | |
| 90 | + | for c in chars { | |
| 91 | + | if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) { | |
| 92 | + | return Err(NameError::BadChar(c)); | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // Suffix rules. The character checks above guarantee `name` is pure ASCII | |
| 97 | + | // here, so byte-slicing for the `.git` comparison lands on a char boundary. | |
| 98 | + | if name.ends_with('.') { | |
| 99 | + | return Err(NameError::DotSuffix); | |
| 100 | + | } | |
| 101 | + | if name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(".git") { | |
| 102 | + | return Err(NameError::GitSuffix); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | let lower = name.to_ascii_lowercase(); | |
| 106 | + | if RESERVED_NAMES.contains(&lower.as_str()) { | |
| 107 | + | return Err(NameError::Reserved(name.to_string())); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | Ok(()) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[cfg(test)] | |
| 114 | + | mod tests { | |
| 115 | + | #![allow(clippy::unwrap_used, clippy::panic)] | |
| 116 | + | ||
| 117 | + | use super::*; | |
| 118 | + | ||
| 119 | + | #[test] | |
| 120 | + | fn accepts_ordinary_names() { | |
| 121 | + | for name in [ | |
| 122 | + | "a", "A", "0", "hanna", "fabrica", "my-repo", "my_repo", "v1.2.3", "a.b_c-d", "Repo123", | |
| 123 | + | ] { | |
| 124 | + | assert_eq!(validate_name(name), Ok(()), "should accept {name:?}"); | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | #[test] | |
| 129 | + | fn rejects_empty() { | |
| 130 | + | assert_eq!(validate_name(""), Err(NameError::Empty)); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | #[test] | |
| 134 | + | fn rejects_dot_names() { | |
| 135 | + | assert_eq!(validate_name("."), Err(NameError::DotName)); | |
| 136 | + | assert_eq!(validate_name(".."), Err(NameError::DotName)); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | #[test] | |
| 140 | + | fn enforces_length_boundary() { | |
| 141 | + | let ok = "a".repeat(MAX_NAME_LEN); | |
| 142 | + | assert_eq!( | |
| 143 | + | validate_name(&ok), | |
| 144 | + | Ok(()), | |
| 145 | + | "{MAX_NAME_LEN} chars is allowed" | |
| 146 | + | ); | |
| 147 | + | let too_long = "a".repeat(MAX_NAME_LEN + 1); | |
| 148 | + | assert_eq!(validate_name(&too_long), Err(NameError::TooLong)); | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | #[test] | |
| 152 | + | fn rejects_bad_start() { | |
| 153 | + | for name in ["_foo", "-foo", ".foo", "!foo"] { | |
| 154 | + | assert_eq!(validate_name(name), Err(NameError::BadStart), "{name:?}"); | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | #[test] | |
| 159 | + | fn rejects_bad_chars() { | |
| 160 | + | assert_eq!(validate_name("a b"), Err(NameError::BadChar(' '))); | |
| 161 | + | assert_eq!(validate_name("a/b"), Err(NameError::BadChar('/'))); | |
| 162 | + | assert_eq!(validate_name("a@b"), Err(NameError::BadChar('@'))); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | #[test] | |
| 166 | + | fn rejects_non_ascii() { | |
| 167 | + | // A leading non-ASCII letter fails the start check; an interior one fails | |
| 168 | + | // the character check. | |
| 169 | + | assert_eq!(validate_name("café"), Err(NameError::BadChar('é'))); | |
| 170 | + | assert!(matches!( | |
| 171 | + | validate_name("ünsen"), | |
| 172 | + | Err(NameError::BadStart | NameError::BadChar(_)) | |
| 173 | + | )); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | #[test] | |
| 177 | + | fn rejects_git_suffix_case_insensitively() { | |
| 178 | + | for name in ["repo.git", "repo.GIT", "repo.Git", "x.git"] { | |
| 179 | + | assert_eq!(validate_name(name), Err(NameError::GitSuffix), "{name:?}"); | |
| 180 | + | } | |
| 181 | + | // `.git` embedded but not trailing is fine. | |
| 182 | + | assert_eq!(validate_name("repo.git.bak"), Ok(())); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | #[test] | |
| 186 | + | fn rejects_trailing_dot() { | |
| 187 | + | assert_eq!(validate_name("repo."), Err(NameError::DotSuffix)); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | #[test] | |
| 191 | + | fn rejects_reserved_case_insensitively() { | |
| 192 | + | for name in ["admin", "Admin", "ADMIN", "api", "settings", "healthz"] { | |
| 193 | + | assert!( | |
| 194 | + | matches!(validate_name(name), Err(NameError::Reserved(_))), | |
| 195 | + | "{name:?} should be reserved" | |
| 196 | + | ); | |
| 197 | + | } | |
| 198 | + | // A reserved word as a substring is fine. | |
| 199 | + | assert_eq!(validate_name("administrator"), Ok(())); | |
| 200 | + | } | |
| 201 | + | } | |
crates/store/Cargo.toml +12 −0
| @@ -10,6 +10,18 @@ repository.workspace = true | |||
| 10 | 10 | publish.workspace = true | |
| 11 | 11 | ||
| 12 | 12 | [dependencies] | |
| 13 | + | model = { workspace = true } | |
| 14 | + | sqlx = { workspace = true } | |
| 15 | + | ulid = { workspace = true } | |
| 16 | + | thiserror = { workspace = true } | |
| 17 | + | ||
| 18 | + | [dev-dependencies] | |
| 19 | + | tokio = { workspace = true } | |
| 20 | + | tempfile = { workspace = true } | |
| 21 | + | ||
| 22 | + | [features] | |
| 23 | + | # Opt-in Postgres integration tests, driven by FABRICA_TEST_POSTGRES_URL. | |
| 24 | + | postgres-tests = [] | |
| 13 | 25 | ||
| 14 | 26 | [lints] | |
| 15 | 27 | workspace = true | |
crates/store/src/lib.rs +267 −0
| @@ -3,3 +3,270 @@ | |||
| 3 | 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | 4 | ||
| 5 | 5 | //! Persistence layer: schema, migrations, and portable queries over SQLite and Postgres. | |
| 6 | + | //! | |
| 7 | + | //! # One code path, two backends | |
| 8 | + | //! | |
| 9 | + | //! Rather than duplicate every statement per dialect, the store runs over | |
| 10 | + | //! [`sqlx::Any`]. That is sound here only because we stay inside a deliberately | |
| 11 | + | //! narrow, portable slice of SQL and mediate the two places the backends | |
| 12 | + | //! genuinely differ: | |
| 13 | + | //! | |
| 14 | + | //! * **Placeholders.** Both SQLite and Postgres accept `$1, $2, …` positional | |
| 15 | + | //! placeholders (only MySQL needs `?`), so every query is written once with | |
| 16 | + | //! `$N`. | |
| 17 | + | //! * **Booleans.** Postgres has a real `BOOLEAN`; SQLite stores `INTEGER` 0/1. | |
| 18 | + | //! Binding a Rust `bool` encodes correctly for both, but *reading* one back | |
| 19 | + | //! differs — a SQLite boolean column decodes as an integer. [`Store::get_bool`] | |
| 20 | + | //! is the single place that reconciles this. | |
| 21 | + | //! * **Timestamps** are `BIGINT` Unix milliseconds UTC everywhere; **ids** are | |
| 22 | + | //! lowercase 26-char ULID `TEXT`, lexicographically sortable so they double as | |
| 23 | + | //! the default ordering key. | |
| 24 | + | //! | |
| 25 | + | //! Migrations live in `migrations/{sqlite,postgres}/NNNN_name.sql`; the matching | |
| 26 | + | //! set is embedded at build time and selected at runtime by [`Store::migrate`]. | |
| 27 | + | ||
| 28 | + | mod repos; | |
| 29 | + | mod users; | |
| 30 | + | ||
| 31 | + | use std::sync::Once; | |
| 32 | + | use std::time::{SystemTime, UNIX_EPOCH}; | |
| 33 | + | ||
| 34 | + | use sqlx::AnyPool; | |
| 35 | + | use sqlx::Row; | |
| 36 | + | use sqlx::any::{AnyPoolOptions, AnyRow}; | |
| 37 | + | use sqlx::migrate::Migrator; | |
| 38 | + | ||
| 39 | + | pub use crate::repos::NewRepo; | |
| 40 | + | pub use crate::users::NewUser; | |
| 41 | + | ||
| 42 | + | /// Migrations for the SQLite dialect, embedded at build time. | |
| 43 | + | static SQLITE_MIGRATOR: Migrator = sqlx::migrate!("../../migrations/sqlite"); | |
| 44 | + | /// Migrations for the PostgreSQL dialect, embedded at build time. | |
| 45 | + | static POSTGRES_MIGRATOR: Migrator = sqlx::migrate!("../../migrations/postgres"); | |
| 46 | + | ||
| 47 | + | /// Per-connection SQLite pragmas, applied on every fresh connection. WAL and a | |
| 48 | + | /// busy timeout let the CLI and a running server share the file; foreign keys | |
| 49 | + | /// must be turned on explicitly on each connection. | |
| 50 | + | const SQLITE_PRAGMAS: &[&str] = &[ | |
| 51 | + | "PRAGMA journal_mode = WAL", | |
| 52 | + | "PRAGMA synchronous = NORMAL", | |
| 53 | + | "PRAGMA foreign_keys = ON", | |
| 54 | + | "PRAGMA busy_timeout = 5000", | |
| 55 | + | ]; | |
| 56 | + | ||
| 57 | + | /// Ensure the `Any` driver knows about the SQLite and Postgres backends. Safe to | |
| 58 | + | /// call repeatedly; the work happens exactly once. | |
| 59 | + | fn install_drivers() { | |
| 60 | + | static ONCE: Once = Once::new(); | |
| 61 | + | ONCE.call_once(sqlx::any::install_default_drivers); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// Which concrete database a [`Store`] is talking to. The value is fixed at | |
| 65 | + | /// connect time and drives the handful of dialect-dependent decisions. | |
| 66 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 67 | + | pub enum Backend { | |
| 68 | + | /// A SQLite database (file-backed or in-memory). | |
| 69 | + | Sqlite, | |
| 70 | + | /// A PostgreSQL database. | |
| 71 | + | Postgres, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | impl Backend { | |
| 75 | + | /// Infer the backend from a connection URL's scheme. | |
| 76 | + | /// | |
| 77 | + | /// # Errors | |
| 78 | + | /// | |
| 79 | + | /// Returns [`StoreError::UnsupportedUrl`] for any scheme other than | |
| 80 | + | /// `sqlite:` or `postgres:`/`postgresql:`. | |
| 81 | + | pub fn detect(url: &str) -> Result<Self, StoreError> { | |
| 82 | + | if url.starts_with("sqlite:") { | |
| 83 | + | Ok(Self::Sqlite) | |
| 84 | + | } else if url.starts_with("postgres:") || url.starts_with("postgresql:") { | |
| 85 | + | Ok(Self::Postgres) | |
| 86 | + | } else { | |
| 87 | + | Err(StoreError::UnsupportedUrl { | |
| 88 | + | url: url.to_string(), | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /// An error from the persistence layer. | |
| 95 | + | #[derive(Debug, thiserror::Error)] | |
| 96 | + | pub enum StoreError { | |
| 97 | + | /// The connection URL used an unrecognized scheme. | |
| 98 | + | #[error("unsupported database URL {url:?}: expected a sqlite: or postgres: scheme")] | |
| 99 | + | UnsupportedUrl { | |
| 100 | + | /// The offending URL. Connection URLs can carry credentials, so callers | |
| 101 | + | /// should avoid surfacing this verbatim to end users. | |
| 102 | + | url: String, | |
| 103 | + | }, | |
| 104 | + | /// A query or connection failed. Boxed to keep [`StoreError`] small. | |
| 105 | + | #[error(transparent)] | |
| 106 | + | Query(#[from] Box<sqlx::Error>), | |
| 107 | + | /// A migration failed to apply. | |
| 108 | + | #[error(transparent)] | |
| 109 | + | Migrate(#[from] sqlx::migrate::MigrateError), | |
| 110 | + | /// A name failed validation before it could be written. | |
| 111 | + | #[error("invalid name: {0}")] | |
| 112 | + | Name(#[from] model::NameError), | |
| 113 | + | /// A uniqueness constraint was violated (e.g. a duplicate username). | |
| 114 | + | #[error("{entity} already exists with that {field}")] | |
| 115 | + | Conflict { | |
| 116 | + | /// The kind of entity that collided (`"user"`, `"repo"`, …). | |
| 117 | + | entity: &'static str, | |
| 118 | + | /// The field whose uniqueness was violated. | |
| 119 | + | field: &'static str, | |
| 120 | + | }, | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | impl From<sqlx::Error> for StoreError { | |
| 124 | + | fn from(err: sqlx::Error) -> Self { | |
| 125 | + | Self::Query(Box::new(err)) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// A handle to the database: a connection pool plus the backend it targets. | |
| 130 | + | /// | |
| 131 | + | /// Cloneable and cheap to share — the underlying pool is reference-counted. | |
| 132 | + | #[derive(Debug, Clone)] | |
| 133 | + | pub struct Store { | |
| 134 | + | pool: AnyPool, | |
| 135 | + | backend: Backend, | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | impl Store { | |
| 139 | + | /// Open a pool against `url`, applying SQLite pragmas when appropriate. | |
| 140 | + | /// | |
| 141 | + | /// File-backed SQLite URLs are created if missing. This does **not** run | |
| 142 | + | /// migrations — call [`Store::migrate`] separately so `--no-migrate` can skip | |
| 143 | + | /// it. | |
| 144 | + | /// | |
| 145 | + | /// # Errors | |
| 146 | + | /// | |
| 147 | + | /// Returns [`StoreError::UnsupportedUrl`] for an unknown scheme, or | |
| 148 | + | /// [`StoreError::Query`] if the pool cannot be established. | |
| 149 | + | pub async fn connect(url: &str, max_connections: u32) -> Result<Self, StoreError> { | |
| 150 | + | install_drivers(); | |
| 151 | + | let backend = Backend::detect(url)?; | |
| 152 | + | let url = normalize_url(url, backend); | |
| 153 | + | ||
| 154 | + | let mut options = AnyPoolOptions::new().max_connections(max_connections.max(1)); | |
| 155 | + | if backend == Backend::Sqlite { | |
| 156 | + | options = options.after_connect(|conn, _meta| { | |
| 157 | + | Box::pin(async move { | |
| 158 | + | for pragma in SQLITE_PRAGMAS { | |
| 159 | + | sqlx::query(*pragma).execute(&mut *conn).await?; | |
| 160 | + | } | |
| 161 | + | Ok(()) | |
| 162 | + | }) | |
| 163 | + | }); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | let pool = options.connect(&url).await.map_err(Box::new)?; | |
| 167 | + | Ok(Self { pool, backend }) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// Wrap an already-open pool. Primarily a test seam. | |
| 171 | + | /// | |
| 172 | + | /// The `backend` must match the pool's actual driver, or dialect-dependent | |
| 173 | + | /// decoding (booleans) will misbehave. | |
| 174 | + | #[must_use] | |
| 175 | + | pub fn from_pool(pool: AnyPool, backend: Backend) -> Self { | |
| 176 | + | Self { pool, backend } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | /// The backend this store targets. | |
| 180 | + | #[must_use] | |
| 181 | + | pub fn backend(&self) -> Backend { | |
| 182 | + | self.backend | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /// The underlying connection pool, for callers that need raw access. | |
| 186 | + | #[must_use] | |
| 187 | + | pub fn pool(&self) -> &AnyPool { | |
| 188 | + | &self.pool | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | /// Apply all pending migrations for this backend's dialect. | |
| 192 | + | /// | |
| 193 | + | /// # Errors | |
| 194 | + | /// | |
| 195 | + | /// Returns [`StoreError::Migrate`] if a migration fails or the recorded | |
| 196 | + | /// history is inconsistent with the embedded set. | |
| 197 | + | pub async fn migrate(&self) -> Result<(), StoreError> { | |
| 198 | + | let migrator = match self.backend { | |
| 199 | + | Backend::Sqlite => &SQLITE_MIGRATOR, | |
| 200 | + | Backend::Postgres => &POSTGRES_MIGRATOR, | |
| 201 | + | }; | |
| 202 | + | migrator.run(&self.pool).await?; | |
| 203 | + | Ok(()) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// Read a boolean column, bridging SQLite's integer storage and Postgres's | |
| 207 | + | /// native `BOOLEAN`. This is the one place the two dialects' boolean | |
| 208 | + | /// representations are reconciled on the read path. | |
| 209 | + | fn get_bool(&self, row: &AnyRow, column: &str) -> Result<bool, sqlx::Error> { | |
| 210 | + | match self.backend { | |
| 211 | + | Backend::Sqlite => Ok(row.try_get::<i64, _>(column)? != 0), | |
| 212 | + | Backend::Postgres => row.try_get::<bool, _>(column), | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | /// Generate a fresh identifier: a lowercase, 26-character ULID. ULIDs are | |
| 218 | + | /// lexicographically sortable by creation time, so they double as an ordering | |
| 219 | + | /// key and never need a separate sequence. | |
| 220 | + | fn new_id() -> String { | |
| 221 | + | ulid::Ulid::new().to_string().to_lowercase() | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// The current time as Unix milliseconds UTC, matching the schema's `BIGINT` | |
| 225 | + | /// timestamp columns. Clamps rather than panicking on the impossible cases (a | |
| 226 | + | /// clock before the epoch, or a time past year 292-million). | |
| 227 | + | fn now_ms() -> i64 { | |
| 228 | + | SystemTime::now() | |
| 229 | + | .duration_since(UNIX_EPOCH) | |
| 230 | + | .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | /// Ensure a file-backed SQLite URL will create its database if absent. In-memory | |
| 234 | + | /// URLs and any URL that already specifies a `mode` are left untouched. | |
| 235 | + | fn normalize_url(url: &str, backend: Backend) -> String { | |
| 236 | + | if backend == Backend::Sqlite && !url.contains(":memory:") && !url.contains("mode=") { | |
| 237 | + | let separator = if url.contains('?') { '&' } else { '?' }; | |
| 238 | + | format!("{url}{separator}mode=rwc") | |
| 239 | + | } else { | |
| 240 | + | url.to_string() | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | /// Map a failed write to [`StoreError::Conflict`] when it was a uniqueness | |
| 245 | + | /// violation, attributing it to the first matching field. Any other error is | |
| 246 | + | /// passed through unchanged. | |
| 247 | + | /// | |
| 248 | + | /// `needles` pairs a lowercase substring to look for in the violated | |
| 249 | + | /// constraint's name/message with the field label to report. The database's | |
| 250 | + | /// error text names the offending column (`users.email_lower`, or a Postgres | |
| 251 | + | /// constraint like `users_email_lower_key`), so a substring match reliably | |
| 252 | + | /// identifies the field across both dialects. | |
| 253 | + | fn map_conflict( | |
| 254 | + | err: sqlx::Error, | |
| 255 | + | entity: &'static str, | |
| 256 | + | needles: &[(&str, &'static str)], | |
| 257 | + | ) -> StoreError { | |
| 258 | + | if let sqlx::Error::Database(db) = &err | |
| 259 | + | && db.is_unique_violation() | |
| 260 | + | { | |
| 261 | + | let haystack = format!("{} {}", db.constraint().unwrap_or(""), db.message()).to_lowercase(); | |
| 262 | + | let field = needles | |
| 263 | + | .iter() | |
| 264 | + | .find(|(needle, _)| haystack.contains(needle)) | |
| 265 | + | .map_or("value", |(_, field)| *field); | |
| 266 | + | return StoreError::Conflict { entity, field }; | |
| 267 | + | } | |
| 268 | + | err.into() | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | #[cfg(test)] | |
| 272 | + | mod tests; | |
crates/store/src/repos.rs +127 −0
| @@ -0,0 +1,127 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! Repositories: creation and path lookup. | |
| 6 | + | ||
| 7 | + | use model::Repo; | |
| 8 | + | use sqlx::any::AnyRow; | |
| 9 | + | use sqlx::{AssertSqlSafe, Row}; | |
| 10 | + | ||
| 11 | + | use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | |
| 12 | + | ||
| 13 | + | /// The columns of `repos` in a fixed order, shared by every `SELECT`. | |
| 14 | + | const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, is_private, \ | |
| 15 | + | default_branch, archived_at, size_bytes, pushed_at, created_at, updated_at"; | |
| 16 | + | ||
| 17 | + | /// Fields needed to create a repository. The server fills in the id, timestamps, | |
| 18 | + | /// and the derived size/push bookkeeping. | |
| 19 | + | #[derive(Debug, Clone)] | |
| 20 | + | pub struct NewRepo { | |
| 21 | + | /// Owning user id. | |
| 22 | + | pub owner_id: String, | |
| 23 | + | /// Containing group id, or `None` when the repo hangs directly off the owner. | |
| 24 | + | pub group_id: Option<String>, | |
| 25 | + | /// The repo's own name; validated with [`model::validate_name`]. | |
| 26 | + | pub name: String, | |
| 27 | + | /// Materialized full path within the owner (`group/sub/repo`, or just `name`). | |
| 28 | + | pub path: String, | |
| 29 | + | /// Optional one-line description. | |
| 30 | + | pub description: Option<String>, | |
| 31 | + | /// Whether the repo is private (invisible to non-collaborators). | |
| 32 | + | pub is_private: bool, | |
| 33 | + | /// The repo's default branch. | |
| 34 | + | pub default_branch: String, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | impl Store { | |
| 38 | + | /// Create a repository, returning the persisted row. | |
| 39 | + | /// | |
| 40 | + | /// # Errors | |
| 41 | + | /// | |
| 42 | + | /// * [`StoreError::Name`] if the repo name is invalid. | |
| 43 | + | /// * [`StoreError::Conflict`] if the owner already has a repo at that path. | |
| 44 | + | /// * [`StoreError::Query`] for any other database failure. | |
| 45 | + | pub async fn create_repo(&self, new: NewRepo) -> Result<Repo, StoreError> { | |
| 46 | + | model::validate_name(&new.name)?; | |
| 47 | + | let now = now_ms(); | |
| 48 | + | let repo = Repo { | |
| 49 | + | id: new_id(), | |
| 50 | + | owner_id: new.owner_id, | |
| 51 | + | group_id: new.group_id, | |
| 52 | + | name: new.name, | |
| 53 | + | path: new.path, | |
| 54 | + | description: new.description, | |
| 55 | + | is_private: new.is_private, | |
| 56 | + | default_branch: new.default_branch, | |
| 57 | + | archived_at: None, | |
| 58 | + | size_bytes: 0, | |
| 59 | + | pushed_at: None, | |
| 60 | + | created_at: now, | |
| 61 | + | updated_at: now, | |
| 62 | + | }; | |
| 63 | + | ||
| 64 | + | let sql = "INSERT INTO repos \ | |
| 65 | + | (id, owner_id, group_id, name, path, description, is_private, default_branch, \ | |
| 66 | + | archived_at, size_bytes, pushed_at, created_at, updated_at) \ | |
| 67 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)"; | |
| 68 | + | sqlx::query(sql) | |
| 69 | + | .bind(repo.id.clone()) | |
| 70 | + | .bind(repo.owner_id.clone()) | |
| 71 | + | .bind(repo.group_id.clone()) | |
| 72 | + | .bind(repo.name.clone()) | |
| 73 | + | .bind(repo.path.clone()) | |
| 74 | + | .bind(repo.description.clone()) | |
| 75 | + | .bind(repo.is_private) | |
| 76 | + | .bind(repo.default_branch.clone()) | |
| 77 | + | .bind(repo.archived_at) | |
| 78 | + | .bind(repo.size_bytes) | |
| 79 | + | .bind(repo.pushed_at) | |
| 80 | + | .bind(repo.created_at) | |
| 81 | + | .bind(repo.updated_at) | |
| 82 | + | .execute(&self.pool) | |
| 83 | + | .await | |
| 84 | + | .map_err(|e| map_conflict(e, "repo", &[("path", "path")]))?; | |
| 85 | + | ||
| 86 | + | Ok(repo) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// Resolve a repository by `(owner_id, path)`, or `None` if absent. This is | |
| 90 | + | /// the authoritative name→repo lookup; the on-disk tree is keyed by id. | |
| 91 | + | /// | |
| 92 | + | /// # Errors | |
| 93 | + | /// | |
| 94 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 95 | + | pub async fn repo_by_owner_path( | |
| 96 | + | &self, | |
| 97 | + | owner_id: &str, | |
| 98 | + | path: &str, | |
| 99 | + | ) -> Result<Option<Repo>, StoreError> { | |
| 100 | + | let sql = format!("SELECT {REPO_COLUMNS} FROM repos WHERE owner_id = $1 AND path = $2"); | |
| 101 | + | let row = sqlx::query(AssertSqlSafe(sql)) | |
| 102 | + | .bind(owner_id) | |
| 103 | + | .bind(path) | |
| 104 | + | .fetch_optional(&self.pool) | |
| 105 | + | .await?; | |
| 106 | + | Ok(row.as_ref().map(|r| self.map_repo(r)).transpose()?) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`]. | |
| 110 | + | fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> { | |
| 111 | + | Ok(Repo { | |
| 112 | + | id: row.try_get("id")?, | |
| 113 | + | owner_id: row.try_get("owner_id")?, | |
| 114 | + | group_id: row.try_get("group_id")?, | |
| 115 | + | name: row.try_get("name")?, | |
| 116 | + | path: row.try_get("path")?, | |
| 117 | + | description: row.try_get("description")?, | |
| 118 | + | is_private: self.get_bool(row, "is_private")?, | |
| 119 | + | default_branch: row.try_get("default_branch")?, | |
| 120 | + | archived_at: row.try_get("archived_at")?, | |
| 121 | + | size_bytes: row.try_get("size_bytes")?, | |
| 122 | + | pushed_at: row.try_get("pushed_at")?, | |
| 123 | + | created_at: row.try_get("created_at")?, | |
| 124 | + | updated_at: row.try_get("updated_at")?, | |
| 125 | + | }) | |
| 126 | + | } | |
| 127 | + | } | |
crates/store/src/tests.rs +424 −0
| @@ -0,0 +1,424 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! Store tests. The portable behaviour runs against SQLite both in-memory and | |
| 6 | + | //! file-backed; the same round-trips run against Postgres when the | |
| 7 | + | //! `postgres-tests` feature is enabled and `FABRICA_TEST_POSTGRES_URL` is set. | |
| 8 | + | ||
| 9 | + | #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] | |
| 10 | + | ||
| 11 | + | use tempfile::TempDir; | |
| 12 | + | ||
| 13 | + | use super::{Backend, NewRepo, NewUser, Store, StoreError}; | |
| 14 | + | ||
| 15 | + | /// A migrated store plus any temp directory that must outlive it. | |
| 16 | + | struct Fixture { | |
| 17 | + | store: Store, | |
| 18 | + | _dir: Option<TempDir>, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /// A migrated in-memory SQLite store. A single connection keeps the database | |
| 22 | + | /// alive for the life of the pool. | |
| 23 | + | async fn memory_store() -> Fixture { | |
| 24 | + | let store = Store::connect("sqlite::memory:", 1).await.unwrap(); | |
| 25 | + | store.migrate().await.unwrap(); | |
| 26 | + | Fixture { store, _dir: None } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// A migrated file-backed SQLite store in a fresh temp directory. | |
| 30 | + | async fn file_store() -> Fixture { | |
| 31 | + | let dir = tempfile::tempdir().unwrap(); | |
| 32 | + | let url = format!("sqlite://{}", dir.path().join("fabrica.db").display()); | |
| 33 | + | let store = Store::connect(&url, 5).await.unwrap(); | |
| 34 | + | store.migrate().await.unwrap(); | |
| 35 | + | Fixture { | |
| 36 | + | store, | |
| 37 | + | _dir: Some(dir), | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Both SQLite flavours the portable tests must pass against. | |
| 42 | + | async fn sqlite_fixtures() -> Vec<Fixture> { | |
| 43 | + | vec![memory_store().await, file_store().await] | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | fn sample_user(username: &str, email: &str) -> NewUser { | |
| 47 | + | NewUser { | |
| 48 | + | username: username.to_string(), | |
| 49 | + | email: email.to_string(), | |
| 50 | + | display_name: Some("Sample".to_string()), | |
| 51 | + | password_hash: None, | |
| 52 | + | is_admin: false, | |
| 53 | + | must_change_password: false, | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | #[tokio::test] | |
| 58 | + | async fn migrations_are_idempotent() { | |
| 59 | + | for fx in sqlite_fixtures().await { | |
| 60 | + | // A second run over the already-migrated database is a no-op. | |
| 61 | + | fx.store.migrate().await.unwrap(); | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | #[tokio::test] | |
| 66 | + | async fn create_and_fetch_user() { | |
| 67 | + | for fx in sqlite_fixtures().await { | |
| 68 | + | let created = fx | |
| 69 | + | .store | |
| 70 | + | .create_user(sample_user("hanna", "hanna@example.com")) | |
| 71 | + | .await | |
| 72 | + | .unwrap(); | |
| 73 | + | assert_eq!(created.id.len(), 26, "id should be a 26-char ULID"); | |
| 74 | + | assert_eq!(created.created_at, created.updated_at); | |
| 75 | + | ||
| 76 | + | let by_id = fx.store.user_by_id(&created.id).await.unwrap().unwrap(); | |
| 77 | + | assert_eq!(by_id, created); | |
| 78 | + | ||
| 79 | + | // Username lookup is case-insensitive. | |
| 80 | + | let by_name = fx.store.user_by_username("HANNA").await.unwrap().unwrap(); | |
| 81 | + | assert_eq!(by_name, created); | |
| 82 | + | ||
| 83 | + | assert!(fx.store.user_by_id("nope").await.unwrap().is_none()); | |
| 84 | + | assert!(fx.store.user_by_username("ghost").await.unwrap().is_none()); | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | #[tokio::test] | |
| 89 | + | async fn duplicate_username_conflicts_case_insensitively() { | |
| 90 | + | for fx in sqlite_fixtures().await { | |
| 91 | + | fx.store | |
| 92 | + | .create_user(sample_user("dup", "a@example.com")) | |
| 93 | + | .await | |
| 94 | + | .unwrap(); | |
| 95 | + | let err = fx | |
| 96 | + | .store | |
| 97 | + | .create_user(sample_user("DUP", "b@example.com")) | |
| 98 | + | .await | |
| 99 | + | .unwrap_err(); | |
| 100 | + | assert!( | |
| 101 | + | matches!( | |
| 102 | + | err, | |
| 103 | + | StoreError::Conflict { | |
| 104 | + | entity: "user", | |
| 105 | + | field: "username" | |
| 106 | + | } | |
| 107 | + | ), | |
| 108 | + | "got {err:?}" | |
| 109 | + | ); | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[tokio::test] | |
| 114 | + | async fn duplicate_email_conflicts_case_insensitively() { | |
| 115 | + | for fx in sqlite_fixtures().await { | |
| 116 | + | fx.store | |
| 117 | + | .create_user(sample_user("one", "same@example.com")) | |
| 118 | + | .await | |
| 119 | + | .unwrap(); | |
| 120 | + | let err = fx | |
| 121 | + | .store | |
| 122 | + | .create_user(sample_user("two", "SAME@example.com")) | |
| 123 | + | .await | |
| 124 | + | .unwrap_err(); | |
| 125 | + | assert!( | |
| 126 | + | matches!( | |
| 127 | + | err, | |
| 128 | + | StoreError::Conflict { | |
| 129 | + | entity: "user", | |
| 130 | + | field: "email" | |
| 131 | + | } | |
| 132 | + | ), | |
| 133 | + | "got {err:?}" | |
| 134 | + | ); | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | #[tokio::test] | |
| 139 | + | async fn invalid_username_is_rejected_before_insert() { | |
| 140 | + | for fx in sqlite_fixtures().await { | |
| 141 | + | let err = fx | |
| 142 | + | .store | |
| 143 | + | .create_user(sample_user("bad name", "x@example.com")) | |
| 144 | + | .await | |
| 145 | + | .unwrap_err(); | |
| 146 | + | assert!(matches!(err, StoreError::Name(_)), "got {err:?}"); | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | #[tokio::test] | |
| 151 | + | async fn booleans_round_trip() { | |
| 152 | + | for fx in sqlite_fixtures().await { | |
| 153 | + | let boss = fx | |
| 154 | + | .store | |
| 155 | + | .create_user(NewUser { | |
| 156 | + | is_admin: true, | |
| 157 | + | must_change_password: true, | |
| 158 | + | ..sample_user("boss", "boss@example.com") | |
| 159 | + | }) | |
| 160 | + | .await | |
| 161 | + | .unwrap(); | |
| 162 | + | assert!(boss.is_admin); | |
| 163 | + | assert!(boss.must_change_password); | |
| 164 | + | ||
| 165 | + | let fetched = fx.store.user_by_id(&boss.id).await.unwrap().unwrap(); | |
| 166 | + | assert!(fetched.is_admin, "is_admin must survive the round-trip"); | |
| 167 | + | assert!(fetched.must_change_password); | |
| 168 | + | ||
| 169 | + | // And the default-false path. | |
| 170 | + | let plain = fx.store.user_by_username("boss").await.unwrap(); | |
| 171 | + | assert!(plain.is_some()); | |
| 172 | + | let normal = fx | |
| 173 | + | .store | |
| 174 | + | .create_user(sample_user("normal", "normal@example.com")) | |
| 175 | + | .await | |
| 176 | + | .unwrap(); | |
| 177 | + | let normal = fx.store.user_by_id(&normal.id).await.unwrap().unwrap(); | |
| 178 | + | assert!(!normal.is_admin); | |
| 179 | + | assert!(!normal.must_change_password); | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | #[tokio::test] | |
| 184 | + | async fn create_and_fetch_repo() { | |
| 185 | + | for fx in sqlite_fixtures().await { | |
| 186 | + | let owner = fx | |
| 187 | + | .store | |
| 188 | + | .create_user(sample_user("owner", "owner@example.com")) | |
| 189 | + | .await | |
| 190 | + | .unwrap(); | |
| 191 | + | ||
| 192 | + | let repo = fx | |
| 193 | + | .store | |
| 194 | + | .create_repo(NewRepo { | |
| 195 | + | owner_id: owner.id.clone(), | |
| 196 | + | group_id: None, | |
| 197 | + | name: "project".to_string(), | |
| 198 | + | path: "project".to_string(), | |
| 199 | + | description: Some("a repo".to_string()), | |
| 200 | + | is_private: false, | |
| 201 | + | default_branch: "main".to_string(), | |
| 202 | + | }) | |
| 203 | + | .await | |
| 204 | + | .unwrap(); | |
| 205 | + | assert_eq!(repo.size_bytes, 0); | |
| 206 | + | assert!(repo.pushed_at.is_none()); | |
| 207 | + | assert!(!repo.is_private); | |
| 208 | + | ||
| 209 | + | let fetched = fx | |
| 210 | + | .store | |
| 211 | + | .repo_by_owner_path(&owner.id, "project") | |
| 212 | + | .await | |
| 213 | + | .unwrap() | |
| 214 | + | .unwrap(); | |
| 215 | + | assert_eq!(fetched, repo); | |
| 216 | + | ||
| 217 | + | // Same owner, same path collides. | |
| 218 | + | let err = fx | |
| 219 | + | .store | |
| 220 | + | .create_repo(NewRepo { | |
| 221 | + | owner_id: owner.id.clone(), | |
| 222 | + | group_id: None, | |
| 223 | + | name: "project".to_string(), | |
| 224 | + | path: "project".to_string(), | |
| 225 | + | description: None, | |
| 226 | + | is_private: true, | |
| 227 | + | default_branch: "main".to_string(), | |
| 228 | + | }) | |
| 229 | + | .await | |
| 230 | + | .unwrap_err(); | |
| 231 | + | assert!( | |
| 232 | + | matches!( | |
| 233 | + | err, | |
| 234 | + | StoreError::Conflict { | |
| 235 | + | entity: "repo", | |
| 236 | + | field: "path" | |
| 237 | + | } | |
| 238 | + | ), | |
| 239 | + | "got {err:?}" | |
| 240 | + | ); | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[tokio::test] | |
| 245 | + | async fn foreign_keys_are_enforced() { | |
| 246 | + | // A repo whose owner does not exist must be rejected — proof the SQLite | |
| 247 | + | // `foreign_keys` pragma took effect on the pooled connection. | |
| 248 | + | for fx in sqlite_fixtures().await { | |
| 249 | + | let err = fx | |
| 250 | + | .store | |
| 251 | + | .create_repo(NewRepo { | |
| 252 | + | owner_id: "no-such-owner".to_string(), | |
| 253 | + | group_id: None, | |
| 254 | + | name: "orphan".to_string(), | |
| 255 | + | path: "orphan".to_string(), | |
| 256 | + | description: None, | |
| 257 | + | is_private: true, | |
| 258 | + | default_branch: "main".to_string(), | |
| 259 | + | }) | |
| 260 | + | .await | |
| 261 | + | .unwrap_err(); | |
| 262 | + | assert!( | |
| 263 | + | matches!(err, StoreError::Query(_)), | |
| 264 | + | "expected a database (FK) error, got {err:?}" | |
| 265 | + | ); | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | #[test] | |
| 270 | + | fn unsupported_url_is_rejected() { | |
| 271 | + | let err = Backend::detect("mysql://localhost/db").unwrap_err(); | |
| 272 | + | assert!( | |
| 273 | + | matches!(err, StoreError::UnsupportedUrl { .. }), | |
| 274 | + | "got {err:?}" | |
| 275 | + | ); | |
| 276 | + | assert_eq!(Backend::detect("sqlite::memory:").unwrap(), Backend::Sqlite); | |
| 277 | + | assert_eq!( | |
| 278 | + | Backend::detect("postgres://localhost/db").unwrap(), | |
| 279 | + | Backend::Postgres | |
| 280 | + | ); | |
| 281 | + | assert_eq!( | |
| 282 | + | Backend::detect("postgresql://localhost/db").unwrap(), | |
| 283 | + | Backend::Postgres | |
| 284 | + | ); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | /// The two dialects' initial migrations must define the same tables with the | |
| 288 | + | /// same columns in the same order — a hermetic check that needs no database. | |
| 289 | + | /// The only sanctioned differences are the boolean column type and dialect | |
| 290 | + | /// punctuation. | |
| 291 | + | #[test] | |
| 292 | + | fn schema_files_are_equivalent() { | |
| 293 | + | let sqlite = include_str!("../../../migrations/sqlite/0001_init.sql"); | |
| 294 | + | let postgres = include_str!("../../../migrations/postgres/0001_init.sql"); | |
| 295 | + | ||
| 296 | + | assert_eq!( | |
| 297 | + | columns_by_table(sqlite), | |
| 298 | + | columns_by_table(postgres), | |
| 299 | + | "SQLite and Postgres schemas must define identical tables and columns" | |
| 300 | + | ); | |
| 301 | + | ||
| 302 | + | // The boolean columns must use the dialect-appropriate storage type. | |
| 303 | + | for column in ["must_change_password", "is_admin", "is_private"] { | |
| 304 | + | assert!( | |
| 305 | + | column_line(sqlite, column).contains("INTEGER"), | |
| 306 | + | "SQLite {column} should be INTEGER" | |
| 307 | + | ); | |
| 308 | + | assert!( | |
| 309 | + | column_line(postgres, column).contains("BOOLEAN"), | |
| 310 | + | "Postgres {column} should be BOOLEAN" | |
| 311 | + | ); | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | /// Extract, per table, the ordered list of column names from a migration file. | |
| 316 | + | /// Relies on the house style: each column on its own line beginning with the | |
| 317 | + | /// column name, table-level constraints beginning with an uppercase keyword. | |
| 318 | + | fn columns_by_table(sql: &str) -> std::collections::BTreeMap<String, Vec<String>> { | |
| 319 | + | const CONSTRAINT_KEYWORDS: &[&str] = &["UNIQUE", "PRIMARY", "FOREIGN", "CHECK", "CONSTRAINT"]; | |
| 320 | + | let mut tables = std::collections::BTreeMap::new(); | |
| 321 | + | let mut current: Option<(String, Vec<String>)> = None; | |
| 322 | + | ||
| 323 | + | for raw in sql.lines() { | |
| 324 | + | let line = raw.trim(); | |
| 325 | + | if let Some(rest) = line.strip_prefix("CREATE TABLE ") { | |
| 326 | + | let name = rest.trim_end_matches(" (").trim_end_matches('(').trim(); | |
| 327 | + | current = Some((name.to_string(), Vec::new())); | |
| 328 | + | continue; | |
| 329 | + | } | |
| 330 | + | let Some((name, cols)) = current.as_mut() else { | |
| 331 | + | continue; | |
| 332 | + | }; | |
| 333 | + | if line.starts_with(')') { | |
| 334 | + | let (name, cols) = current.take().expect("current table"); | |
| 335 | + | tables.insert(name, cols); | |
| 336 | + | continue; | |
| 337 | + | } | |
| 338 | + | // A column line starts with an identifier; skip blanks, comments, and | |
| 339 | + | // table-level constraints. | |
| 340 | + | let first = line.split([' ', '\t']).next().unwrap_or(""); | |
| 341 | + | if first.is_empty() | |
| 342 | + | || line.starts_with("--") | |
| 343 | + | || CONSTRAINT_KEYWORDS.contains(&first.to_ascii_uppercase().as_str()) | |
| 344 | + | { | |
| 345 | + | let _ = name; | |
| 346 | + | continue; | |
| 347 | + | } | |
| 348 | + | cols.push(first.to_string()); | |
| 349 | + | } | |
| 350 | + | tables | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | /// Return the first line defining `column` (for type assertions). | |
| 354 | + | fn column_line<'a>(sql: &'a str, column: &str) -> &'a str { | |
| 355 | + | sql.lines() | |
| 356 | + | .map(str::trim) | |
| 357 | + | .find(|line| line.split([' ', '\t']).next() == Some(column)) | |
| 358 | + | .unwrap_or("") | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | /// Postgres round-trips, mirroring the SQLite ones. Ignored unless the | |
| 362 | + | /// `postgres-tests` feature is on and `FABRICA_TEST_POSTGRES_URL` points at a | |
| 363 | + | /// reachable database; the test cleans up the rows it creates so it can rerun. | |
| 364 | + | #[tokio::test] | |
| 365 | + | #[cfg_attr( | |
| 366 | + | not(feature = "postgres-tests"), | |
| 367 | + | ignore = "enable the postgres-tests feature and set FABRICA_TEST_POSTGRES_URL" | |
| 368 | + | )] | |
| 369 | + | async fn postgres_round_trip() { | |
| 370 | + | let Ok(url) = std::env::var("FABRICA_TEST_POSTGRES_URL") else { | |
| 371 | + | eprintln!("skipping postgres_round_trip: FABRICA_TEST_POSTGRES_URL not set"); | |
| 372 | + | return; | |
| 373 | + | }; | |
| 374 | + | ||
| 375 | + | let store = Store::connect(&url, 5).await.unwrap(); | |
| 376 | + | assert_eq!(store.backend(), Backend::Postgres); | |
| 377 | + | store.migrate().await.unwrap(); | |
| 378 | + | ||
| 379 | + | // Unique names so repeated runs against a shared database don't collide. | |
| 380 | + | let suffix = super::new_id(); | |
| 381 | + | let username = format!("pg{suffix}"); | |
| 382 | + | let email = format!("{suffix}@example.com"); | |
| 383 | + | ||
| 384 | + | let admin = store | |
| 385 | + | .create_user(NewUser { | |
| 386 | + | is_admin: true, | |
| 387 | + | must_change_password: true, | |
| 388 | + | ..sample_user(&username, &email) | |
| 389 | + | }) | |
| 390 | + | .await | |
| 391 | + | .unwrap(); | |
| 392 | + | ||
| 393 | + | let fetched = store.user_by_id(&admin.id).await.unwrap().unwrap(); | |
| 394 | + | assert_eq!(fetched, admin); | |
| 395 | + | assert!(fetched.is_admin, "native BOOLEAN must decode as true"); | |
| 396 | + | assert!(fetched.must_change_password); | |
| 397 | + | ||
| 398 | + | let repo = store | |
| 399 | + | .create_repo(NewRepo { | |
| 400 | + | owner_id: admin.id.clone(), | |
| 401 | + | group_id: None, | |
| 402 | + | name: "pgproj".to_string(), | |
| 403 | + | path: "pgproj".to_string(), | |
| 404 | + | description: None, | |
| 405 | + | is_private: false, | |
| 406 | + | default_branch: "main".to_string(), | |
| 407 | + | }) | |
| 408 | + | .await | |
| 409 | + | .unwrap(); | |
| 410 | + | let fetched_repo = store | |
| 411 | + | .repo_by_owner_path(&admin.id, "pgproj") | |
| 412 | + | .await | |
| 413 | + | .unwrap() | |
| 414 | + | .unwrap(); | |
| 415 | + | assert_eq!(fetched_repo, repo); | |
| 416 | + | assert!(!fetched_repo.is_private); | |
| 417 | + | ||
| 418 | + | // Clean up (repo cascades with the user). | |
| 419 | + | sqlx::query("DELETE FROM users WHERE id = $1") | |
| 420 | + | .bind(&admin.id) | |
| 421 | + | .execute(store.pool()) | |
| 422 | + | .await | |
| 423 | + | .unwrap(); | |
| 424 | + | } | |
crates/store/src/users.rs +132 −0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! User accounts: creation and lookup. | |
| 6 | + | ||
| 7 | + | use model::User; | |
| 8 | + | use sqlx::any::AnyRow; | |
| 9 | + | use sqlx::{AssertSqlSafe, Row}; | |
| 10 | + | ||
| 11 | + | use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | |
| 12 | + | ||
| 13 | + | /// The columns of `users` in a fixed order, shared by every `SELECT` so the | |
| 14 | + | /// row-mapping in [`Store::map_user`] can rely on names. | |
| 15 | + | const USER_COLUMNS: &str = "id, username, email, display_name, password_hash, \ | |
| 16 | + | must_change_password, is_admin, disabled_at, created_at, updated_at"; | |
| 17 | + | ||
| 18 | + | /// Fields needed to create a user. The server fills in the id, timestamps, and | |
| 19 | + | /// the normalized `*_lower` uniqueness keys. | |
| 20 | + | #[derive(Debug, Clone)] | |
| 21 | + | pub struct NewUser { | |
| 22 | + | /// Login name; validated with [`model::validate_name`] before insert. | |
| 23 | + | pub username: String, | |
| 24 | + | /// Primary email address. | |
| 25 | + | pub email: String, | |
| 26 | + | /// Optional display name. | |
| 27 | + | pub display_name: Option<String>, | |
| 28 | + | /// Argon2id PHC hash, or `None` for an account awaiting invite completion. | |
| 29 | + | pub password_hash: Option<String>, | |
| 30 | + | /// Whether the account is a site administrator. | |
| 31 | + | pub is_admin: bool, | |
| 32 | + | /// Whether the user must change their password on next login. | |
| 33 | + | pub must_change_password: bool, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | impl Store { | |
| 37 | + | /// Create a user, returning the persisted row. | |
| 38 | + | /// | |
| 39 | + | /// The username is validated and case-folded for uniqueness; the email is | |
| 40 | + | /// likewise unique case-insensitively. | |
| 41 | + | /// | |
| 42 | + | /// # Errors | |
| 43 | + | /// | |
| 44 | + | /// * [`StoreError::Name`] if the username is invalid. | |
| 45 | + | /// * [`StoreError::Conflict`] if the username or email is already taken. | |
| 46 | + | /// * [`StoreError::Query`] for any other database failure. | |
| 47 | + | pub async fn create_user(&self, new: NewUser) -> Result<User, StoreError> { | |
| 48 | + | model::validate_name(&new.username)?; | |
| 49 | + | let now = now_ms(); | |
| 50 | + | let user = User { | |
| 51 | + | id: new_id(), | |
| 52 | + | username: new.username, | |
| 53 | + | email: new.email, | |
| 54 | + | display_name: new.display_name, | |
| 55 | + | password_hash: new.password_hash, | |
| 56 | + | must_change_password: new.must_change_password, | |
| 57 | + | is_admin: new.is_admin, | |
| 58 | + | disabled_at: None, | |
| 59 | + | created_at: now, | |
| 60 | + | updated_at: now, | |
| 61 | + | }; | |
| 62 | + | ||
| 63 | + | let sql = "INSERT INTO users \ | |
| 64 | + | (id, username, username_lower, email, email_lower, display_name, \ | |
| 65 | + | password_hash, must_change_password, is_admin, disabled_at, created_at, updated_at) \ | |
| 66 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)"; | |
| 67 | + | sqlx::query(sql) | |
| 68 | + | .bind(user.id.clone()) | |
| 69 | + | .bind(user.username.clone()) | |
| 70 | + | .bind(user.username.to_lowercase()) | |
| 71 | + | .bind(user.email.clone()) | |
| 72 | + | .bind(user.email.to_lowercase()) | |
| 73 | + | .bind(user.display_name.clone()) | |
| 74 | + | .bind(user.password_hash.clone()) | |
| 75 | + | .bind(user.must_change_password) | |
| 76 | + | .bind(user.is_admin) | |
| 77 | + | .bind(user.disabled_at) | |
| 78 | + | .bind(user.created_at) | |
| 79 | + | .bind(user.updated_at) | |
| 80 | + | .execute(&self.pool) | |
| 81 | + | .await | |
| 82 | + | .map_err(|e| { | |
| 83 | + | map_conflict(e, "user", &[("email", "email"), ("username", "username")]) | |
| 84 | + | })?; | |
| 85 | + | ||
| 86 | + | Ok(user) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// Fetch a user by id, or `None` if there is no such user. | |
| 90 | + | /// | |
| 91 | + | /// # Errors | |
| 92 | + | /// | |
| 93 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 94 | + | pub async fn user_by_id(&self, id: &str) -> Result<Option<User>, StoreError> { | |
| 95 | + | let sql = format!("SELECT {USER_COLUMNS} FROM users WHERE id = $1"); | |
| 96 | + | let row = sqlx::query(AssertSqlSafe(sql)) | |
| 97 | + | .bind(id) | |
| 98 | + | .fetch_optional(&self.pool) | |
| 99 | + | .await?; | |
| 100 | + | Ok(row.as_ref().map(|r| self.map_user(r)).transpose()?) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// Fetch a user by username, case-insensitively, or `None` if absent. | |
| 104 | + | /// | |
| 105 | + | /// # Errors | |
| 106 | + | /// | |
| 107 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 108 | + | pub async fn user_by_username(&self, username: &str) -> Result<Option<User>, StoreError> { | |
| 109 | + | let sql = format!("SELECT {USER_COLUMNS} FROM users WHERE username_lower = $1"); | |
| 110 | + | let row = sqlx::query(AssertSqlSafe(sql)) | |
| 111 | + | .bind(username.to_lowercase()) | |
| 112 | + | .fetch_optional(&self.pool) | |
| 113 | + | .await?; | |
| 114 | + | Ok(row.as_ref().map(|r| self.map_user(r)).transpose()?) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`]. | |
| 118 | + | fn map_user(&self, row: &AnyRow) -> Result<User, sqlx::Error> { | |
| 119 | + | Ok(User { | |
| 120 | + | id: row.try_get("id")?, | |
| 121 | + | username: row.try_get("username")?, | |
| 122 | + | email: row.try_get("email")?, | |
| 123 | + | display_name: row.try_get("display_name")?, | |
| 124 | + | password_hash: row.try_get("password_hash")?, | |
| 125 | + | must_change_password: self.get_bool(row, "must_change_password")?, | |
| 126 | + | is_admin: self.get_bool(row, "is_admin")?, | |
| 127 | + | disabled_at: row.try_get("disabled_at")?, | |
| 128 | + | created_at: row.try_get("created_at")?, | |
| 129 | + | updated_at: row.try_get("updated_at")?, | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | } | |
flake.nix +12 −1
| @@ -34,8 +34,19 @@ | |||
| 34 | 34 | ||
| 35 | 35 | craneLib = (inputs.crane.mkLib pkgs).overrideToolchain fenixToolchain; | |
| 36 | 36 | ||
| 37 | + | # Keep `.sql` migration files in the build source: the `store` crate | |
| 38 | + | # embeds them via `sqlx::migrate!` (and reads them in tests), and | |
| 39 | + | # crane's default cargo-source filter would strip them. | |
| 40 | + | src = pkgs.lib.cleanSourceWith { | |
| 41 | + | src = ./.; | |
| 42 | + | name = "source"; | |
| 43 | + | filter = | |
| 44 | + | path: type: | |
| 45 | + | (builtins.match ".*\\.sql$" path != null) || (craneLib.filterCargoSources path type); | |
| 46 | + | }; | |
| 47 | + | ||
| 37 | 48 | commonArgs = { | |
| 38 | - | src = craneLib.cleanCargoSource ./.; | |
| 49 | + | inherit src; | |
| 39 | 50 | strictDeps = true; | |
| 40 | 51 | nativeBuildInputs = [ | |
| 41 | 52 | pkgs.pkg-config | |
migrations/postgres/0001_init.sql +141 −0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | -- This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | -- License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | -- file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | -- Initial schema (PostgreSQL dialect). | |
| 6 | + | -- | |
| 7 | + | -- This must stay structurally equivalent to migrations/sqlite/0001_init.sql; | |
| 8 | + | -- the only permitted differences are the boolean column type (BOOLEAN here, | |
| 9 | + | -- INTEGER 0/1 there) and dialect punctuation. Timestamps are BIGINT Unix | |
| 10 | + | -- milliseconds UTC; identifiers are 26-char ULID TEXT (lexicographically | |
| 11 | + | -- sortable — order by them). Case-insensitive uniqueness is enforced with a | |
| 12 | + | -- normalized `*_lower` column plus a unique index, never collation. | |
| 13 | + | ||
| 14 | + | CREATE TABLE users ( | |
| 15 | + | id TEXT PRIMARY KEY, | |
| 16 | + | username TEXT NOT NULL, | |
| 17 | + | username_lower TEXT NOT NULL UNIQUE, | |
| 18 | + | email TEXT NOT NULL, | |
| 19 | + | email_lower TEXT NOT NULL UNIQUE, | |
| 20 | + | display_name TEXT, | |
| 21 | + | password_hash TEXT, -- NULL until an invite is completed | |
| 22 | + | must_change_password BOOLEAN NOT NULL DEFAULT FALSE, | |
| 23 | + | is_admin BOOLEAN NOT NULL DEFAULT FALSE, | |
| 24 | + | disabled_at BIGINT, | |
| 25 | + | created_at BIGINT NOT NULL, | |
| 26 | + | updated_at BIGINT NOT NULL | |
| 27 | + | ); | |
| 28 | + | ||
| 29 | + | CREATE TABLE invites ( | |
| 30 | + | id TEXT PRIMARY KEY, | |
| 31 | + | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 32 | + | token_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the emailed token | |
| 33 | + | purpose TEXT NOT NULL, -- 'activate' | 'reset' | |
| 34 | + | expires_at BIGINT NOT NULL, | |
| 35 | + | used_at BIGINT, | |
| 36 | + | created_at BIGINT NOT NULL | |
| 37 | + | ); | |
| 38 | + | ||
| 39 | + | CREATE TABLE sessions ( | |
| 40 | + | id TEXT PRIMARY KEY, -- SHA-256 of the cookie value | |
| 41 | + | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 42 | + | user_agent TEXT, | |
| 43 | + | ip TEXT, | |
| 44 | + | created_at BIGINT NOT NULL, | |
| 45 | + | last_seen_at BIGINT NOT NULL, | |
| 46 | + | expires_at BIGINT NOT NULL | |
| 47 | + | ); | |
| 48 | + | ||
| 49 | + | CREATE TABLE api_tokens ( | |
| 50 | + | id TEXT PRIMARY KEY, -- the JWT `jti` | |
| 51 | + | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 52 | + | name TEXT NOT NULL, | |
| 53 | + | scopes TEXT NOT NULL, -- comma-separated | |
| 54 | + | expires_at BIGINT, | |
| 55 | + | revoked_at BIGINT, | |
| 56 | + | last_used_at BIGINT, | |
| 57 | + | created_at BIGINT NOT NULL | |
| 58 | + | ); | |
| 59 | + | ||
| 60 | + | CREATE TABLE keys ( | |
| 61 | + | id TEXT PRIMARY KEY, | |
| 62 | + | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 63 | + | kind TEXT NOT NULL, -- 'ssh' | 'gpg' | |
| 64 | + | name TEXT, | |
| 65 | + | fingerprint TEXT NOT NULL, -- SHA256:… for ssh, full hex fp for gpg | |
| 66 | + | public_key TEXT NOT NULL, -- authorized_keys line or armored pgp block | |
| 67 | + | ordinal INTEGER NOT NULL, -- stable 1-based index per (user, kind) | |
| 68 | + | last_used_at BIGINT, | |
| 69 | + | created_at BIGINT NOT NULL, | |
| 70 | + | UNIQUE (kind, fingerprint), | |
| 71 | + | UNIQUE (user_id, kind, ordinal) | |
| 72 | + | ); | |
| 73 | + | ||
| 74 | + | CREATE TABLE groups ( | |
| 75 | + | id TEXT PRIMARY KEY, | |
| 76 | + | owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 77 | + | parent_id TEXT REFERENCES groups(id) ON DELETE CASCADE, | |
| 78 | + | name TEXT NOT NULL, | |
| 79 | + | path TEXT NOT NULL, -- materialized 'group/sub/leaf' | |
| 80 | + | description TEXT, | |
| 81 | + | created_at BIGINT NOT NULL, | |
| 82 | + | UNIQUE (owner_id, path) | |
| 83 | + | ); | |
| 84 | + | ||
| 85 | + | CREATE TABLE repos ( | |
| 86 | + | id TEXT PRIMARY KEY, | |
| 87 | + | owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 88 | + | group_id TEXT REFERENCES groups(id) ON DELETE SET NULL, | |
| 89 | + | name TEXT NOT NULL, | |
| 90 | + | path TEXT NOT NULL, -- materialized 'group/sub/repo' (== name when ungrouped) | |
| 91 | + | description TEXT, | |
| 92 | + | is_private BOOLEAN NOT NULL DEFAULT TRUE, | |
| 93 | + | default_branch TEXT NOT NULL, | |
| 94 | + | archived_at BIGINT, | |
| 95 | + | size_bytes BIGINT NOT NULL DEFAULT 0, | |
| 96 | + | pushed_at BIGINT, | |
| 97 | + | created_at BIGINT NOT NULL, | |
| 98 | + | updated_at BIGINT NOT NULL, | |
| 99 | + | UNIQUE (owner_id, path) | |
| 100 | + | ); | |
| 101 | + | ||
| 102 | + | CREATE TABLE repo_collaborators ( | |
| 103 | + | repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 104 | + | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 105 | + | permission TEXT NOT NULL, -- 'read' | 'write' | 'admin' | |
| 106 | + | created_at BIGINT NOT NULL, | |
| 107 | + | PRIMARY KEY (repo_id, user_id) | |
| 108 | + | ); | |
| 109 | + | ||
| 110 | + | -- Reserved and inert until the CI ships (phase 18+). No behaviour reads these | |
| 111 | + | -- yet; they exist so the seams are stable. | |
| 112 | + | CREATE TABLE runs ( | |
| 113 | + | id TEXT PRIMARY KEY, | |
| 114 | + | repo_id TEXT NOT NULL, | |
| 115 | + | ref TEXT NOT NULL, | |
| 116 | + | commit_sha TEXT NOT NULL, | |
| 117 | + | status TEXT NOT NULL, | |
| 118 | + | started_at BIGINT, | |
| 119 | + | finished_at BIGINT, | |
| 120 | + | created_at BIGINT NOT NULL | |
| 121 | + | ); | |
| 122 | + | ||
| 123 | + | CREATE TABLE jobs ( | |
| 124 | + | id TEXT PRIMARY KEY, | |
| 125 | + | run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, | |
| 126 | + | stage TEXT NOT NULL, | |
| 127 | + | name TEXT NOT NULL, | |
| 128 | + | status TEXT NOT NULL, | |
| 129 | + | log_path TEXT, | |
| 130 | + | started_at BIGINT, | |
| 131 | + | finished_at BIGINT | |
| 132 | + | ); | |
| 133 | + | ||
| 134 | + | -- Indexes. The UNIQUE(owner_id, path) constraint on repos already provides the | |
| 135 | + | -- (owner_id, path) lookup index, so it is not repeated here. | |
| 136 | + | CREATE INDEX idx_repos_visibility_pushed ON repos (is_private, pushed_at DESC); | |
| 137 | + | CREATE INDEX idx_groups_owner_parent ON groups (owner_id, parent_id); | |
| 138 | + | CREATE INDEX idx_keys_user_kind ON keys (user_id, kind); | |
| 139 | + | CREATE INDEX idx_sessions_user ON sessions (user_id); | |
| 140 | + | CREATE INDEX idx_sessions_expires ON sessions (expires_at); | |
| 141 | + | CREATE INDEX idx_api_tokens_user ON api_tokens (user_id); | |
migrations/sqlite/0001_init.sql +141 −0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | -- This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | -- License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | -- file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | -- Initial schema (SQLite dialect). | |
| 6 | + | -- | |
| 7 | + | -- This must stay structurally equivalent to migrations/postgres/0001_init.sql; | |
| 8 | + | -- the only permitted differences are the boolean column type (INTEGER 0/1 here, | |
| 9 | + | -- BOOLEAN there) and dialect punctuation. Timestamps are BIGINT Unix | |
| 10 | + | -- milliseconds UTC; identifiers are 26-char ULID TEXT (lexicographically | |
| 11 | + | -- sortable — order by them). Case-insensitive uniqueness is enforced with a | |
| 12 | + | -- normalized `*_lower` column plus a unique index, never collation. | |
| 13 | + | ||
| 14 | + | CREATE TABLE users ( | |
| 15 | + | id TEXT PRIMARY KEY, | |
| 16 | + | username TEXT NOT NULL, | |
| 17 | + | username_lower TEXT NOT NULL UNIQUE, | |
| 18 | + | email TEXT NOT NULL, | |
| 19 | + | email_lower TEXT NOT NULL UNIQUE, | |
| 20 | + | display_name TEXT, | |
| 21 | + | password_hash TEXT, -- NULL until an invite is completed | |
| 22 | + | must_change_password INTEGER NOT NULL DEFAULT 0, | |
| 23 | + | is_admin INTEGER NOT NULL DEFAULT 0, | |
| 24 | + | disabled_at BIGINT, | |
| 25 | + | created_at BIGINT NOT NULL, | |
| 26 | + | updated_at BIGINT NOT NULL | |
| 27 | + | ); | |
| 28 | + | ||
| 29 | + | CREATE TABLE invites ( | |
| 30 | + | id TEXT PRIMARY KEY, | |
| 31 | + | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 32 | + | token_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the emailed token | |
| 33 | + | purpose TEXT NOT NULL, -- 'activate' | 'reset' | |
| 34 | + | expires_at BIGINT NOT NULL, | |
| 35 | + | used_at BIGINT, | |
| 36 | + | created_at BIGINT NOT NULL | |
| 37 | + | ); | |
| 38 | + | ||
| 39 | + | CREATE TABLE sessions ( | |
| 40 | + | id TEXT PRIMARY KEY, -- SHA-256 of the cookie value | |
| 41 | + | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 42 | + | user_agent TEXT, | |
| 43 | + | ip TEXT, | |
| 44 | + | created_at BIGINT NOT NULL, | |
| 45 | + | last_seen_at BIGINT NOT NULL, | |
| 46 | + | expires_at BIGINT NOT NULL | |
| 47 | + | ); | |
| 48 | + | ||
| 49 | + | CREATE TABLE api_tokens ( | |
| 50 | + | id TEXT PRIMARY KEY, -- the JWT `jti` | |
| 51 | + | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 52 | + | name TEXT NOT NULL, | |
| 53 | + | scopes TEXT NOT NULL, -- comma-separated | |
| 54 | + | expires_at BIGINT, | |
| 55 | + | revoked_at BIGINT, | |
| 56 | + | last_used_at BIGINT, | |
| 57 | + | created_at BIGINT NOT NULL | |
| 58 | + | ); | |
| 59 | + | ||
| 60 | + | CREATE TABLE keys ( | |
| 61 | + | id TEXT PRIMARY KEY, | |
| 62 | + | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 63 | + | kind TEXT NOT NULL, -- 'ssh' | 'gpg' | |
| 64 | + | name TEXT, | |
| 65 | + | fingerprint TEXT NOT NULL, -- SHA256:… for ssh, full hex fp for gpg | |
| 66 | + | public_key TEXT NOT NULL, -- authorized_keys line or armored pgp block | |
| 67 | + | ordinal INTEGER NOT NULL, -- stable 1-based index per (user, kind) | |
| 68 | + | last_used_at BIGINT, | |
| 69 | + | created_at BIGINT NOT NULL, | |
| 70 | + | UNIQUE (kind, fingerprint), | |
| 71 | + | UNIQUE (user_id, kind, ordinal) | |
| 72 | + | ); | |
| 73 | + | ||
| 74 | + | CREATE TABLE groups ( | |
| 75 | + | id TEXT PRIMARY KEY, | |
| 76 | + | owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 77 | + | parent_id TEXT REFERENCES groups(id) ON DELETE CASCADE, | |
| 78 | + | name TEXT NOT NULL, | |
| 79 | + | path TEXT NOT NULL, -- materialized 'group/sub/leaf' | |
| 80 | + | description TEXT, | |
| 81 | + | created_at BIGINT NOT NULL, | |
| 82 | + | UNIQUE (owner_id, path) | |
| 83 | + | ); | |
| 84 | + | ||
| 85 | + | CREATE TABLE repos ( | |
| 86 | + | id TEXT PRIMARY KEY, | |
| 87 | + | owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 88 | + | group_id TEXT REFERENCES groups(id) ON DELETE SET NULL, | |
| 89 | + | name TEXT NOT NULL, | |
| 90 | + | path TEXT NOT NULL, -- materialized 'group/sub/repo' (== name when ungrouped) | |
| 91 | + | description TEXT, | |
| 92 | + | is_private INTEGER NOT NULL DEFAULT 1, | |
| 93 | + | default_branch TEXT NOT NULL, | |
| 94 | + | archived_at BIGINT, | |
| 95 | + | size_bytes BIGINT NOT NULL DEFAULT 0, | |
| 96 | + | pushed_at BIGINT, | |
| 97 | + | created_at BIGINT NOT NULL, | |
| 98 | + | updated_at BIGINT NOT NULL, | |
| 99 | + | UNIQUE (owner_id, path) | |
| 100 | + | ); | |
| 101 | + | ||
| 102 | + | CREATE TABLE repo_collaborators ( | |
| 103 | + | repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 104 | + | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 105 | + | permission TEXT NOT NULL, -- 'read' | 'write' | 'admin' | |
| 106 | + | created_at BIGINT NOT NULL, | |
| 107 | + | PRIMARY KEY (repo_id, user_id) | |
| 108 | + | ); | |
| 109 | + | ||
| 110 | + | -- Reserved and inert until the CI ships (phase 18+). No behaviour reads these | |
| 111 | + | -- yet; they exist so the seams are stable. | |
| 112 | + | CREATE TABLE runs ( | |
| 113 | + | id TEXT PRIMARY KEY, | |
| 114 | + | repo_id TEXT NOT NULL, | |
| 115 | + | ref TEXT NOT NULL, | |
| 116 | + | commit_sha TEXT NOT NULL, | |
| 117 | + | status TEXT NOT NULL, | |
| 118 | + | started_at BIGINT, | |
| 119 | + | finished_at BIGINT, | |
| 120 | + | created_at BIGINT NOT NULL | |
| 121 | + | ); | |
| 122 | + | ||
| 123 | + | CREATE TABLE jobs ( | |
| 124 | + | id TEXT PRIMARY KEY, | |
| 125 | + | run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, | |
| 126 | + | stage TEXT NOT NULL, | |
| 127 | + | name TEXT NOT NULL, | |
| 128 | + | status TEXT NOT NULL, | |
| 129 | + | log_path TEXT, | |
| 130 | + | started_at BIGINT, | |
| 131 | + | finished_at BIGINT | |
| 132 | + | ); | |
| 133 | + | ||
| 134 | + | -- Indexes. The UNIQUE(owner_id, path) constraint on repos already provides the | |
| 135 | + | -- (owner_id, path) lookup index, so it is not repeated here. | |
| 136 | + | CREATE INDEX idx_repos_visibility_pushed ON repos (is_private, pushed_at DESC); | |
| 137 | + | CREATE INDEX idx_groups_owner_parent ON groups (owner_id, parent_id); | |
| 138 | + | CREATE INDEX idx_keys_user_kind ON keys (user_id, kind); | |
| 139 | + | CREATE INDEX idx_sessions_user ON sessions (user_id); | |
| 140 | + | CREATE INDEX idx_sessions_expires ON sessions (expires_at); | |
| 141 | + | CREATE INDEX idx_api_tokens_user ON api_tokens (user_id); | |