fabrica

hanna/fabrica

feat(store): add schema, migrations, and portable SQLite/Postgres store

9458665 · hanna committed on 2026-07-25

Implements phase 3. The store runs over a single `sqlx::Any` pool with every
statement written once in the portable SQL subset; the three dialect
differences are mediated explicitly — `$1,$2` placeholders (both backends
accept them), booleans bound as `bool` and read back through `get_bool`
(SQLite INTEGER vs Postgres BOOLEAN), and BIGINT-ms / TEXT-ULID columns that
are already portable.

- model: single-source name validation (`validate_name`, reserved list) plus
  `User`/`Repo` domain types, with exhaustive validation tests.
- store: `Backend` detection, SQLite pragmas (WAL, foreign_keys, busy_timeout)
  applied per connection, embedded per-dialect migrators selected at runtime,
  `create_user`/`create_repo` with unique-violation → `Conflict` mapping, and
  lookups.
- migrations: full schema for both dialects (users, invites, sessions,
  api_tokens, keys, groups, repos, repo_collaborators, plus inert runs/jobs)
  and the indexes from the spec.
- tests: run against SQLite in-memory and file-backed; a hermetic
  schema-equivalence check across dialects; Postgres round-trips gated behind
  `postgres-tests` + FABRICA_TEST_POSTGRES_URL.
- build(nix): keep `.sql` files in the crane build source so `sqlx::migrate!`
  can embed them.

sqlx is built with no TLS backend for now (keeps `ring` out of the graph and
cargo-deny's license set clean); revisit for remote Postgres in phase 17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
14 files changed · +2598 −6UnifiedSplit
Cargo.lock +1041 −5
@@ -3,6 +3,12 @@
3version = 43version = 4
44
5[[package]]5[[package]]
6name = "allocator-api2"
7version = "0.2.21"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
10
11[[package]]
6name = "anstream"12name = "anstream"
7version = "1.0.0"13version = "1.0.0"
8source = "registry+https://github.com/rust-lang/crates.io-index"14source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -63,6 +69,15 @@ name = "api"
63version = "0.1.0"69version = "0.1.0"
6470
65[[package]]71[[package]]
72name = "atoi"
73version = "2.0.0"
74source = "registry+https://github.com/rust-lang/crates.io-index"
75checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
76dependencies = [
77 "num-traits",
78]
79
80[[package]]
66name = "atomic"81name = "atomic"
67version = "0.6.1"82version = "0.6.1"
68source = "registry+https://github.com/rust-lang/crates.io-index"83source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -76,10 +91,49 @@ name = "auth"
76version = "0.1.0"91version = "0.1.0"
7792
78[[package]]93[[package]]
94name = "autocfg"
95version = "1.5.1"
96source = "registry+https://github.com/rust-lang/crates.io-index"
97checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
98
99[[package]]
100name = "base64"
101version = "0.22.1"
102source = "registry+https://github.com/rust-lang/crates.io-index"
103checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
104
105[[package]]
79name = "bitflags"106name = "bitflags"
80version = "2.13.1"107version = "2.13.1"
81source = "registry+https://github.com/rust-lang/crates.io-index"108source = "registry+https://github.com/rust-lang/crates.io-index"
82checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"109checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
110dependencies = [
111 "serde_core",
112]
113
114[[package]]
115name = "block-buffer"
116version = "0.10.4"
117source = "registry+https://github.com/rust-lang/crates.io-index"
118checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
119dependencies = [
120 "generic-array",
121]
122
123[[package]]
124name = "block-buffer"
125version = "0.12.1"
126source = "registry+https://github.com/rust-lang/crates.io-index"
127checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
128dependencies = [
129 "hybrid-array",
130]
131
132[[package]]
133name = "bumpalo"
134version = "3.20.3"
135source = "registry+https://github.com/rust-lang/crates.io-index"
136checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
83137
84[[package]]138[[package]]
85name = "bytemuck"139name = "bytemuck"
@@ -88,12 +142,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
88checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"142checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
89143
90[[package]]144[[package]]
145name = "byteorder"
146version = "1.5.0"
147source = "registry+https://github.com/rust-lang/crates.io-index"
148checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
149
150[[package]]
151name = "bytes"
152version = "1.12.1"
153source = "registry+https://github.com/rust-lang/crates.io-index"
154checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
155
156[[package]]
157name = "cc"
158version = "1.4.0"
159source = "registry+https://github.com/rust-lang/crates.io-index"
160checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
161dependencies = [
162 "find-msvc-tools",
163 "shlex",
164]
165
166[[package]]
91name = "cfg-if"167name = "cfg-if"
92version = "1.0.4"168version = "1.0.4"
93source = "registry+https://github.com/rust-lang/crates.io-index"169source = "registry+https://github.com/rust-lang/crates.io-index"
94checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"170checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
95171
96[[package]]172[[package]]
173name = "chacha20"
174version = "0.10.1"
175source = "registry+https://github.com/rust-lang/crates.io-index"
176checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
177dependencies = [
178 "cfg-if",
179 "cpufeatures 0.3.0",
180 "rand_core 0.10.1",
181]
182
183[[package]]
97name = "clap"184name = "clap"
98version = "4.6.4"185version = "4.6.4"
99source = "registry+https://github.com/rust-lang/crates.io-index"186source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -144,12 +231,27 @@ dependencies = [
144]231]
145232
146[[package]]233[[package]]
234name = "cmov"
235version = "0.5.4"
236source = "registry+https://github.com/rust-lang/crates.io-index"
237checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
238
239[[package]]
147name = "colorchoice"240name = "colorchoice"
148version = "1.0.5"241version = "1.0.5"
149source = "registry+https://github.com/rust-lang/crates.io-index"242source = "registry+https://github.com/rust-lang/crates.io-index"
150checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"243checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
151244
152[[package]]245[[package]]
246name = "concurrent-queue"
247version = "2.5.0"
248source = "registry+https://github.com/rust-lang/crates.io-index"
249checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
250dependencies = [
251 "crossbeam-utils",
252]
253
254[[package]]
153name = "config"255name = "config"
154version = "0.1.0"256version = "0.1.0"
155dependencies = [257dependencies = [
@@ -161,6 +263,103 @@ dependencies = [
161]263]
162264
163[[package]]265[[package]]
266name = "cpufeatures"
267version = "0.2.17"
268source = "registry+https://github.com/rust-lang/crates.io-index"
269checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
270dependencies = [
271 "libc",
272]
273
274[[package]]
275name = "cpufeatures"
276version = "0.3.0"
277source = "registry+https://github.com/rust-lang/crates.io-index"
278checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
279dependencies = [
280 "libc",
281]
282
283[[package]]
284name = "crc"
285version = "3.4.0"
286source = "registry+https://github.com/rust-lang/crates.io-index"
287checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
288dependencies = [
289 "crc-catalog",
290]
291
292[[package]]
293name = "crc-catalog"
294version = "2.5.0"
295source = "registry+https://github.com/rust-lang/crates.io-index"
296checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
297
298[[package]]
299name = "crossbeam-queue"
300version = "0.3.13"
301source = "registry+https://github.com/rust-lang/crates.io-index"
302checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
303dependencies = [
304 "crossbeam-utils",
305]
306
307[[package]]
308name = "crossbeam-utils"
309version = "0.8.22"
310source = "registry+https://github.com/rust-lang/crates.io-index"
311checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
312
313[[package]]
314name = "crypto-common"
315version = "0.1.6"
316source = "registry+https://github.com/rust-lang/crates.io-index"
317checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
318dependencies = [
319 "generic-array",
320 "typenum",
321]
322
323[[package]]
324name = "crypto-common"
325version = "0.2.2"
326source = "registry+https://github.com/rust-lang/crates.io-index"
327checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
328dependencies = [
329 "hybrid-array",
330]
331
332[[package]]
333name = "ctutils"
334version = "0.4.2"
335source = "registry+https://github.com/rust-lang/crates.io-index"
336checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
337dependencies = [
338 "cmov",
339]
340
341[[package]]
342name = "digest"
343version = "0.10.7"
344source = "registry+https://github.com/rust-lang/crates.io-index"
345checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
346dependencies = [
347 "block-buffer 0.10.4",
348 "crypto-common 0.1.6",
349]
350
351[[package]]
352name = "digest"
353version = "0.11.3"
354source = "registry+https://github.com/rust-lang/crates.io-index"
355checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
356dependencies = [
357 "block-buffer 0.12.1",
358 "crypto-common 0.2.2",
359 "ctutils",
360]
361
362[[package]]
164name = "displaydoc"363name = "displaydoc"
165version = "0.2.6"364version = "0.2.6"
166source = "registry+https://github.com/rust-lang/crates.io-index"365source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -172,6 +371,21 @@ dependencies = [
172]371]
173372
174[[package]]373[[package]]
374name = "dotenvy"
375version = "0.15.7"
376source = "registry+https://github.com/rust-lang/crates.io-index"
377checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
378
379[[package]]
380name = "either"
381version = "1.17.0"
382source = "registry+https://github.com/rust-lang/crates.io-index"
383checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
384dependencies = [
385 "serde",
386]
387
388[[package]]
175name = "equivalent"389name = "equivalent"
176version = "1.0.2"390version = "1.0.2"
177source = "registry+https://github.com/rust-lang/crates.io-index"391source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -188,6 +402,27 @@ dependencies = [
188]402]
189403
190[[package]]404[[package]]
405name = "etcetera"
406version = "0.11.0"
407source = "registry+https://github.com/rust-lang/crates.io-index"
408checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96"
409dependencies = [
410 "cfg-if",
411 "windows-sys",
412]
413
414[[package]]
415name = "event-listener"
416version = "5.4.1"
417source = "registry+https://github.com/rust-lang/crates.io-index"
418checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
419dependencies = [
420 "concurrent-queue",
421 "parking",
422 "pin-project-lite",
423]
424
425[[package]]
191name = "fabrica"426name = "fabrica"
192version = "0.1.0"427version = "0.1.0"
193dependencies = [428dependencies = [
@@ -217,6 +452,29 @@ dependencies = [
217]452]
218453
219[[package]]454[[package]]
455name = "find-msvc-tools"
456version = "0.1.9"
457source = "registry+https://github.com/rust-lang/crates.io-index"
458checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
459
460[[package]]
461name = "flume"
462version = "0.12.0"
463source = "registry+https://github.com/rust-lang/crates.io-index"
464checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
465dependencies = [
466 "futures-core",
467 "futures-sink",
468 "spin",
469]
470
471[[package]]
472name = "foldhash"
473version = "0.2.0"
474source = "registry+https://github.com/rust-lang/crates.io-index"
475checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
476
477[[package]]
220name = "form_urlencoded"478name = "form_urlencoded"
221version = "1.2.2"479version = "1.2.2"
222source = "registry+https://github.com/rust-lang/crates.io-index"480source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -226,6 +484,99 @@ dependencies = [
226]484]
227485
228[[package]]486[[package]]
487name = "futures-channel"
488version = "0.3.33"
489source = "registry+https://github.com/rust-lang/crates.io-index"
490checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
491dependencies = [
492 "futures-core",
493 "futures-sink",
494]
495
496[[package]]
497name = "futures-core"
498version = "0.3.33"
499source = "registry+https://github.com/rust-lang/crates.io-index"
500checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
501
502[[package]]
503name = "futures-executor"
504version = "0.3.33"
505source = "registry+https://github.com/rust-lang/crates.io-index"
506checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
507dependencies = [
508 "futures-core",
509 "futures-task",
510 "futures-util",
511]
512
513[[package]]
514name = "futures-intrusive"
515version = "0.5.0"
516source = "registry+https://github.com/rust-lang/crates.io-index"
517checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
518dependencies = [
519 "futures-core",
520 "lock_api",
521 "parking_lot",
522]
523
524[[package]]
525name = "futures-io"
526version = "0.3.33"
527source = "registry+https://github.com/rust-lang/crates.io-index"
528checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
529
530[[package]]
531name = "futures-sink"
532version = "0.3.33"
533source = "registry+https://github.com/rust-lang/crates.io-index"
534checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
535
536[[package]]
537name = "futures-task"
538version = "0.3.33"
539source = "registry+https://github.com/rust-lang/crates.io-index"
540checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
541
542[[package]]
543name = "futures-util"
544version = "0.3.33"
545source = "registry+https://github.com/rust-lang/crates.io-index"
546checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
547dependencies = [
548 "futures-core",
549 "futures-io",
550 "futures-sink",
551 "futures-task",
552 "memchr",
553 "pin-project-lite",
554 "slab",
555]
556
557[[package]]
558name = "generic-array"
559version = "0.14.9"
560source = "registry+https://github.com/rust-lang/crates.io-index"
561checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
562dependencies = [
563 "typenum",
564 "version_check",
565]
566
567[[package]]
568name = "getrandom"
569version = "0.3.4"
570source = "registry+https://github.com/rust-lang/crates.io-index"
571checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
572dependencies = [
573 "cfg-if",
574 "libc",
575 "r-efi 5.3.0",
576 "wasip2",
577]
578
579[[package]]
229name = "getrandom"580name = "getrandom"
230version = "0.4.3"581version = "0.4.3"
231source = "registry+https://github.com/rust-lang/crates.io-index"582source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -233,7 +584,8 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
233dependencies = [584dependencies = [
234 "cfg-if",585 "cfg-if",
235 "libc",586 "libc",
236 "r-efi",587 "r-efi 6.0.0",
588 "rand_core 0.10.1",
237]589]
238590
239[[package]]591[[package]]
@@ -242,21 +594,74 @@ version = "0.1.0"
242594
243[[package]]595[[package]]
244name = "hashbrown"596name = "hashbrown"
597version = "0.16.1"
598source = "registry+https://github.com/rust-lang/crates.io-index"
599checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
600dependencies = [
601 "allocator-api2",
602 "equivalent",
603 "foldhash",
604]
605
606[[package]]
607name = "hashbrown"
245version = "0.17.1"608version = "0.17.1"
246source = "registry+https://github.com/rust-lang/crates.io-index"609source = "registry+https://github.com/rust-lang/crates.io-index"
247checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"610checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
248611
249[[package]]612[[package]]
613name = "hashlink"
614version = "0.11.1"
615source = "registry+https://github.com/rust-lang/crates.io-index"
616checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
617dependencies = [
618 "hashbrown 0.16.1",
619]
620
621[[package]]
250name = "heck"622name = "heck"
251version = "0.5.0"623version = "0.5.0"
252source = "registry+https://github.com/rust-lang/crates.io-index"624source = "registry+https://github.com/rust-lang/crates.io-index"
253checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"625checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
254626
255[[package]]627[[package]]
628name = "hex"
629version = "0.4.3"
630source = "registry+https://github.com/rust-lang/crates.io-index"
631checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
632
633[[package]]
256name = "highlight"634name = "highlight"
257version = "0.1.0"635version = "0.1.0"
258636
259[[package]]637[[package]]
638name = "hkdf"
639version = "0.13.0"
640source = "registry+https://github.com/rust-lang/crates.io-index"
641checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
642dependencies = [
643 "hmac",
644]
645
646[[package]]
647name = "hmac"
648version = "0.13.0"
649source = "registry+https://github.com/rust-lang/crates.io-index"
650checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
651dependencies = [
652 "digest 0.11.3",
653]
654
655[[package]]
656name = "hybrid-array"
657version = "0.4.13"
658source = "registry+https://github.com/rust-lang/crates.io-index"
659checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
660dependencies = [
661 "typenum",
662]
663
664[[package]]
260name = "icu_collections"665name = "icu_collections"
261version = "2.1.1"666version = "2.1.1"
262source = "registry+https://github.com/rust-lang/crates.io-index"667source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -365,7 +770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
365checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"770checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
366dependencies = [771dependencies = [
367 "equivalent",772 "equivalent",
368 "hashbrown",773 "hashbrown 0.17.1",
369]774]
370775
371[[package]]776[[package]]
@@ -387,12 +792,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
387checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"792checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
388793
389[[package]]794[[package]]
795name = "js-sys"
796version = "0.3.103"
797source = "registry+https://github.com/rust-lang/crates.io-index"
798checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
799dependencies = [
800 "cfg-if",
801 "futures-util",
802 "wasm-bindgen",
803]
804
805[[package]]
390name = "libc"806name = "libc"
391version = "0.2.189"807version = "0.2.189"
392source = "registry+https://github.com/rust-lang/crates.io-index"808source = "registry+https://github.com/rust-lang/crates.io-index"
393checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"809checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
394810
395[[package]]811[[package]]
812name = "libsqlite3-sys"
813version = "0.37.0"
814source = "registry+https://github.com/rust-lang/crates.io-index"
815checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
816dependencies = [
817 "cc",
818 "pkg-config",
819 "vcpkg",
820]
821
822[[package]]
396name = "linux-raw-sys"823name = "linux-raw-sys"
397version = "0.12.1"824version = "0.12.1"
398source = "registry+https://github.com/rust-lang/crates.io-index"825source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -414,18 +841,57 @@ dependencies = [
414]841]
415842
416[[package]]843[[package]]
844name = "log"
845version = "0.4.33"
846source = "registry+https://github.com/rust-lang/crates.io-index"
847checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
848
849[[package]]
417name = "mail"850name = "mail"
418version = "0.1.0"851version = "0.1.0"
419852
420[[package]]853[[package]]
854name = "md-5"
855version = "0.11.0"
856source = "registry+https://github.com/rust-lang/crates.io-index"
857checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
858dependencies = [
859 "cfg-if",
860 "digest 0.11.3",
861]
862
863[[package]]
421name = "memchr"864name = "memchr"
422version = "2.8.3"865version = "2.8.3"
423source = "registry+https://github.com/rust-lang/crates.io-index"866source = "registry+https://github.com/rust-lang/crates.io-index"
424checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"867checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
425868
426[[package]]869[[package]]
427name = "model"870name = "mio"
428version = "0.1.0"871version = "1.2.2"
872source = "registry+https://github.com/rust-lang/crates.io-index"
873checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
874dependencies = [
875 "libc",
876 "wasi",
877 "windows-sys",
878]
879
880[[package]]
881name = "model"
882version = "0.1.0"
883dependencies = [
884 "thiserror",
885]
886
887[[package]]
888name = "num-traits"
889version = "0.2.19"
890source = "registry+https://github.com/rust-lang/crates.io-index"
891checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
892dependencies = [
893 "autocfg",
894]
429895
430[[package]]896[[package]]
431name = "once_cell"897name = "once_cell"
@@ -440,6 +906,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
440checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"906checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
441907
442[[package]]908[[package]]
909name = "parking"
910version = "2.2.1"
911source = "registry+https://github.com/rust-lang/crates.io-index"
912checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
913
914[[package]]
443name = "parking_lot"915name = "parking_lot"
444version = "0.12.5"916version = "0.12.5"
445source = "registry+https://github.com/rust-lang/crates.io-index"917source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -492,6 +964,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
492checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"964checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
493965
494[[package]]966[[package]]
967name = "pin-project-lite"
968version = "0.2.17"
969source = "registry+https://github.com/rust-lang/crates.io-index"
970checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
971
972[[package]]
973name = "pkg-config"
974version = "0.3.33"
975source = "registry+https://github.com/rust-lang/crates.io-index"
976checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
977
978[[package]]
495name = "potential_utf"979name = "potential_utf"
496version = "0.1.5"980version = "0.1.5"
497source = "registry+https://github.com/rust-lang/crates.io-index"981source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -501,6 +985,15 @@ dependencies = [
501]985]
502986
503[[package]]987[[package]]
988name = "ppv-lite86"
989version = "0.2.21"
990source = "registry+https://github.com/rust-lang/crates.io-index"
991checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
992dependencies = [
993 "zerocopy",
994]
995
996[[package]]
504name = "proc-macro2"997name = "proc-macro2"
505version = "1.0.107"998version = "1.0.107"
506source = "registry+https://github.com/rust-lang/crates.io-index"999source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -533,11 +1026,63 @@ dependencies = [
5331026
534[[package]]1027[[package]]
535name = "r-efi"1028name = "r-efi"
1029version = "5.3.0"
1030source = "registry+https://github.com/rust-lang/crates.io-index"
1031checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1032
1033[[package]]
1034name = "r-efi"
536version = "6.0.0"1035version = "6.0.0"
537source = "registry+https://github.com/rust-lang/crates.io-index"1036source = "registry+https://github.com/rust-lang/crates.io-index"
538checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"1037checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
5391038
540[[package]]1039[[package]]
1040name = "rand"
1041version = "0.9.5"
1042source = "registry+https://github.com/rust-lang/crates.io-index"
1043checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
1044dependencies = [
1045 "rand_chacha",
1046 "rand_core 0.9.5",
1047]
1048
1049[[package]]
1050name = "rand"
1051version = "0.10.2"
1052source = "registry+https://github.com/rust-lang/crates.io-index"
1053checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
1054dependencies = [
1055 "chacha20",
1056 "getrandom 0.4.3",
1057 "rand_core 0.10.1",
1058]
1059
1060[[package]]
1061name = "rand_chacha"
1062version = "0.9.0"
1063source = "registry+https://github.com/rust-lang/crates.io-index"
1064checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1065dependencies = [
1066 "ppv-lite86",
1067 "rand_core 0.9.5",
1068]
1069
1070[[package]]
1071name = "rand_core"
1072version = "0.9.5"
1073source = "registry+https://github.com/rust-lang/crates.io-index"
1074checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1075dependencies = [
1076 "getrandom 0.3.4",
1077]
1078
1079[[package]]
1080name = "rand_core"
1081version = "0.10.1"
1082source = "registry+https://github.com/rust-lang/crates.io-index"
1083checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
1084
1085[[package]]
541name = "redox_syscall"1086name = "redox_syscall"
542version = "0.5.18"1087version = "0.5.18"
543source = "registry+https://github.com/rust-lang/crates.io-index"1088source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -560,6 +1105,12 @@ dependencies = [
560]1105]
5611106
562[[package]]1107[[package]]
1108name = "rustversion"
1109version = "1.0.23"
1110source = "registry+https://github.com/rust-lang/crates.io-index"
1111checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
1112
1113[[package]]
563name = "scopeguard"1114name = "scopeguard"
564version = "1.2.0"1115version = "1.2.0"
565source = "registry+https://github.com/rust-lang/crates.io-index"1116source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -618,10 +1169,247 @@ dependencies = [
618]1169]
6191170
620[[package]]1171[[package]]
1172name = "sha1"
1173version = "0.11.0"
1174source = "registry+https://github.com/rust-lang/crates.io-index"
1175checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
1176dependencies = [
1177 "cfg-if",
1178 "cpufeatures 0.3.0",
1179 "digest 0.11.3",
1180]
1181
1182[[package]]
1183name = "sha2"
1184version = "0.10.9"
1185source = "registry+https://github.com/rust-lang/crates.io-index"
1186checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1187dependencies = [
1188 "cfg-if",
1189 "cpufeatures 0.2.17",
1190 "digest 0.10.7",
1191]
1192
1193[[package]]
1194name = "sha2"
1195version = "0.11.0"
1196source = "registry+https://github.com/rust-lang/crates.io-index"
1197checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
1198dependencies = [
1199 "cfg-if",
1200 "cpufeatures 0.3.0",
1201 "digest 0.11.3",
1202]
1203
1204[[package]]
1205name = "shlex"
1206version = "2.0.1"
1207source = "registry+https://github.com/rust-lang/crates.io-index"
1208checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1209
1210[[package]]
1211name = "slab"
1212version = "0.4.12"
1213source = "registry+https://github.com/rust-lang/crates.io-index"
1214checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1215
1216[[package]]
621name = "smallvec"1217name = "smallvec"
622version = "1.15.2"1218version = "1.15.2"
623source = "registry+https://github.com/rust-lang/crates.io-index"1219source = "registry+https://github.com/rust-lang/crates.io-index"
624checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"1220checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
1221dependencies = [
1222 "serde",
1223]
1224
1225[[package]]
1226name = "socket2"
1227version = "0.6.5"
1228source = "registry+https://github.com/rust-lang/crates.io-index"
1229checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
1230dependencies = [
1231 "libc",
1232 "windows-sys",
1233]
1234
1235[[package]]
1236name = "spin"
1237version = "0.9.9"
1238source = "registry+https://github.com/rust-lang/crates.io-index"
1239checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
1240dependencies = [
1241 "lock_api",
1242]
1243
1244[[package]]
1245name = "sqlx"
1246version = "0.9.0"
1247source = "registry+https://github.com/rust-lang/crates.io-index"
1248checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
1249dependencies = [
1250 "sqlx-core",
1251 "sqlx-macros",
1252 "sqlx-mysql",
1253 "sqlx-postgres",
1254 "sqlx-sqlite",
1255]
1256
1257[[package]]
1258name = "sqlx-core"
1259version = "0.9.0"
1260source = "registry+https://github.com/rust-lang/crates.io-index"
1261checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
1262dependencies = [
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]]
1292name = "sqlx-macros"
1293version = "0.9.0"
1294source = "registry+https://github.com/rust-lang/crates.io-index"
1295checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
1296dependencies = [
1297 "proc-macro2",
1298 "quote",
1299 "sqlx-core",
1300 "sqlx-macros-core",
1301 "syn 2.0.119",
1302]
1303
1304[[package]]
1305name = "sqlx-macros-core"
1306version = "0.9.0"
1307source = "registry+https://github.com/rust-lang/crates.io-index"
1308checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
1309dependencies = [
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]]
1330name = "sqlx-mysql"
1331version = "0.9.0"
1332source = "registry+https://github.com/rust-lang/crates.io-index"
1333checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
1334dependencies = [
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]]
1356name = "sqlx-postgres"
1357version = "0.9.0"
1358source = "registry+https://github.com/rust-lang/crates.io-index"
1359checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
1360dependencies = [
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]]
1391name = "sqlx-sqlite"
1392version = "0.9.0"
1393source = "registry+https://github.com/rust-lang/crates.io-index"
1394checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
1395dependencies = [
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]
6251413
626[[package]]1414[[package]]
627name = "ssh"1415name = "ssh"
@@ -636,6 +1424,25 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
636[[package]]1424[[package]]
637name = "store"1425name = "store"
638version = "0.1.0"1426version = "0.1.0"
1427dependencies = [
1428 "model",
1429 "sqlx",
1430 "tempfile",
1431 "thiserror",
1432 "tokio",
1433 "ulid",
1434]
1435
1436[[package]]
1437name = "stringprep"
1438version = "0.1.5"
1439source = "registry+https://github.com/rust-lang/crates.io-index"
1440checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
1441dependencies = [
1442 "unicode-bidi",
1443 "unicode-normalization",
1444 "unicode-properties",
1445]
6391446
640[[package]]1447[[package]]
641name = "strsim"1448name = "strsim"
@@ -683,7 +1490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
683checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"1490checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
684dependencies = [1491dependencies = [
685 "fastrand",1492 "fastrand",
686 "getrandom",1493 "getrandom 0.4.3",
687 "once_cell",1494 "once_cell",
688 "rustix",1495 "rustix",
689 "windows-sys",1496 "windows-sys",
@@ -720,6 +1527,58 @@ dependencies = [
720]1527]
7211528
722[[package]]1529[[package]]
1530name = "tinyvec"
1531version = "1.12.0"
1532source = "registry+https://github.com/rust-lang/crates.io-index"
1533checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
1534dependencies = [
1535 "tinyvec_macros",
1536]
1537
1538[[package]]
1539name = "tinyvec_macros"
1540version = "0.1.1"
1541source = "registry+https://github.com/rust-lang/crates.io-index"
1542checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
1543
1544[[package]]
1545name = "tokio"
1546version = "1.53.1"
1547source = "registry+https://github.com/rust-lang/crates.io-index"
1548checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
1549dependencies = [
1550 "bytes",
1551 "libc",
1552 "mio",
1553 "pin-project-lite",
1554 "socket2",
1555 "tokio-macros",
1556 "windows-sys",
1557]
1558
1559[[package]]
1560name = "tokio-macros"
1561version = "2.7.1"
1562source = "registry+https://github.com/rust-lang/crates.io-index"
1563checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
1564dependencies = [
1565 "proc-macro2",
1566 "quote",
1567 "syn 2.0.119",
1568]
1569
1570[[package]]
1571name = "tokio-stream"
1572version = "0.1.19"
1573source = "registry+https://github.com/rust-lang/crates.io-index"
1574checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
1575dependencies = [
1576 "futures-core",
1577 "pin-project-lite",
1578 "tokio",
1579]
1580
1581[[package]]
723name = "toml"1582name = "toml"
724version = "0.8.23"1583version = "0.8.23"
725source = "registry+https://github.com/rust-lang/crates.io-index"1584source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -761,6 +1620,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
761checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"1620checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
7621621
763[[package]]1622[[package]]
1623name = "tracing"
1624version = "0.1.44"
1625source = "registry+https://github.com/rust-lang/crates.io-index"
1626checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1627dependencies = [
1628 "log",
1629 "pin-project-lite",
1630 "tracing-attributes",
1631 "tracing-core",
1632]
1633
1634[[package]]
1635name = "tracing-attributes"
1636version = "0.1.31"
1637source = "registry+https://github.com/rust-lang/crates.io-index"
1638checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
1639dependencies = [
1640 "proc-macro2",
1641 "quote",
1642 "syn 2.0.119",
1643]
1644
1645[[package]]
1646name = "tracing-core"
1647version = "0.1.36"
1648source = "registry+https://github.com/rust-lang/crates.io-index"
1649checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
1650dependencies = [
1651 "once_cell",
1652]
1653
1654[[package]]
1655name = "typenum"
1656version = "1.20.1"
1657source = "registry+https://github.com/rust-lang/crates.io-index"
1658checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
1659
1660[[package]]
1661name = "ulid"
1662version = "1.2.1"
1663source = "registry+https://github.com/rust-lang/crates.io-index"
1664checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
1665dependencies = [
1666 "rand 0.9.5",
1667 "web-time",
1668]
1669
1670[[package]]
764name = "uncased"1671name = "uncased"
765version = "0.9.10"1672version = "0.9.10"
766source = "registry+https://github.com/rust-lang/crates.io-index"1673source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -770,12 +1677,33 @@ dependencies = [
770]1677]
7711678
772[[package]]1679[[package]]
1680name = "unicode-bidi"
1681version = "0.3.18"
1682source = "registry+https://github.com/rust-lang/crates.io-index"
1683checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
1684
1685[[package]]
773name = "unicode-ident"1686name = "unicode-ident"
774version = "1.0.24"1687version = "1.0.24"
775source = "registry+https://github.com/rust-lang/crates.io-index"1688source = "registry+https://github.com/rust-lang/crates.io-index"
776checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"1689checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
7771690
778[[package]]1691[[package]]
1692name = "unicode-normalization"
1693version = "0.1.25"
1694source = "registry+https://github.com/rust-lang/crates.io-index"
1695checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
1696dependencies = [
1697 "tinyvec",
1698]
1699
1700[[package]]
1701name = "unicode-properties"
1702version = "0.1.4"
1703source = "registry+https://github.com/rust-lang/crates.io-index"
1704checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
1705
1706[[package]]
779name = "url"1707name = "url"
780version = "2.5.8"1708version = "2.5.8"
781source = "registry+https://github.com/rust-lang/crates.io-index"1709source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -800,16 +1728,98 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
800checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"1728checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
8011729
802[[package]]1730[[package]]
1731name = "vcpkg"
1732version = "0.2.15"
1733source = "registry+https://github.com/rust-lang/crates.io-index"
1734checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
1735
1736[[package]]
803name = "version_check"1737name = "version_check"
804version = "0.9.5"1738version = "0.9.5"
805source = "registry+https://github.com/rust-lang/crates.io-index"1739source = "registry+https://github.com/rust-lang/crates.io-index"
806checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"1740checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
8071741
808[[package]]1742[[package]]
1743name = "wasi"
1744version = "0.11.1+wasi-snapshot-preview1"
1745source = "registry+https://github.com/rust-lang/crates.io-index"
1746checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1747
1748[[package]]
1749name = "wasip2"
1750version = "1.0.1+wasi-0.2.4"
1751source = "registry+https://github.com/rust-lang/crates.io-index"
1752checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
1753dependencies = [
1754 "wit-bindgen",
1755]
1756
1757[[package]]
1758name = "wasm-bindgen"
1759version = "0.2.126"
1760source = "registry+https://github.com/rust-lang/crates.io-index"
1761checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
1762dependencies = [
1763 "cfg-if",
1764 "once_cell",
1765 "rustversion",
1766 "wasm-bindgen-macro",
1767 "wasm-bindgen-shared",
1768]
1769
1770[[package]]
1771name = "wasm-bindgen-macro"
1772version = "0.2.126"
1773source = "registry+https://github.com/rust-lang/crates.io-index"
1774checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
1775dependencies = [
1776 "quote",
1777 "wasm-bindgen-macro-support",
1778]
1779
1780[[package]]
1781name = "wasm-bindgen-macro-support"
1782version = "0.2.126"
1783source = "registry+https://github.com/rust-lang/crates.io-index"
1784checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
1785dependencies = [
1786 "bumpalo",
1787 "proc-macro2",
1788 "quote",
1789 "syn 2.0.119",
1790 "wasm-bindgen-shared",
1791]
1792
1793[[package]]
1794name = "wasm-bindgen-shared"
1795version = "0.2.126"
1796source = "registry+https://github.com/rust-lang/crates.io-index"
1797checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
1798dependencies = [
1799 "unicode-ident",
1800]
1801
1802[[package]]
809name = "web"1803name = "web"
810version = "0.1.0"1804version = "0.1.0"
8111805
812[[package]]1806[[package]]
1807name = "web-time"
1808version = "1.1.0"
1809source = "registry+https://github.com/rust-lang/crates.io-index"
1810checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
1811dependencies = [
1812 "js-sys",
1813 "wasm-bindgen",
1814]
1815
1816[[package]]
1817name = "whoami"
1818version = "2.1.2"
1819source = "registry+https://github.com/rust-lang/crates.io-index"
1820checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
1821
1822[[package]]
813name = "windows-link"1823name = "windows-link"
814version = "0.2.1"1824version = "0.2.1"
815source = "registry+https://github.com/rust-lang/crates.io-index"1825source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -834,6 +1844,12 @@ dependencies = [
834]1844]
8351845
836[[package]]1846[[package]]
1847name = "wit-bindgen"
1848version = "0.46.0"
1849source = "registry+https://github.com/rust-lang/crates.io-index"
1850checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
1851
1852[[package]]
837name = "writeable"1853name = "writeable"
838version = "0.6.3"1854version = "0.6.3"
839source = "registry+https://github.com/rust-lang/crates.io-index"1855source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -869,6 +1885,26 @@ dependencies = [
869]1885]
8701886
871[[package]]1887[[package]]
1888name = "zerocopy"
1889version = "0.8.55"
1890source = "registry+https://github.com/rust-lang/crates.io-index"
1891checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
1892dependencies = [
1893 "zerocopy-derive",
1894]
1895
1896[[package]]
1897name = "zerocopy-derive"
1898version = "0.8.55"
1899source = "registry+https://github.com/rust-lang/crates.io-index"
1900checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
1901dependencies = [
1902 "proc-macro2",
1903 "quote",
1904 "syn 2.0.119",
1905]
1906
1907[[package]]
872name = "zerofrom"1908name = "zerofrom"
873version = "0.1.8"1909version = "0.1.8"
874source = "registry+https://github.com/rust-lang/crates.io-index"1910source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +14 −0
@@ -58,6 +58,20 @@ thiserror = "2"
58url = "2"58url = "2"
59clap = { version = "4", features = ["derive"] }59clap = { version = "4", features = ["derive"] }
60anyhow = "1"60anyhow = "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.
64sqlx = { version = "0.9", default-features = false, features = [
65 "runtime-tokio",
66 "any",
67 "sqlite",
68 "postgres",
69 "migrate",
70 "macros",
71] }
72ulid = "1"
73tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
74tempfile = "3"
6175
62[workspace.lints.rust]76[workspace.lints.rust]
63unsafe_code = "forbid"77unsafe_code = "forbid"
crates/model/Cargo.toml +1 −0
@@ -10,6 +10,7 @@ repository.workspace = true
10publish.workspace = true10publish.workspace = true
1111
12[dependencies]12[dependencies]
13thiserror = { workspace = true }
1314
14[lints]15[lints]
15workspace = true16workspace = 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.
17pub type Timestamp = i64;
18
19/// A registered account.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub 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)]
47pub 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// file, You can obtain one at https://mozilla.org/MPL/2.0/.3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
5//! Shared domain types. This crate performs no I/O and depends on nothing else in-tree.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
11pub mod entity;
12pub mod name;
13
14pub use entity::{Repo, Timestamp, User};
15pub 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).
22pub 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.
27pub 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)]
35pub 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.
69pub 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)]
114mod 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
10publish.workspace = true10publish.workspace = true
1111
12[dependencies]12[dependencies]
13model = { workspace = true }
14sqlx = { workspace = true }
15ulid = { workspace = true }
16thiserror = { workspace = true }
17
18[dev-dependencies]
19tokio = { workspace = true }
20tempfile = { workspace = true }
21
22[features]
23# Opt-in Postgres integration tests, driven by FABRICA_TEST_POSTGRES_URL.
24postgres-tests = []
1325
14[lints]26[lints]
15workspace = true27workspace = true
crates/store/src/lib.rs +267 −0
@@ -3,3 +3,270 @@
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
5//! Persistence layer: schema, migrations, and portable queries over SQLite and Postgres.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
28mod repos;
29mod users;
30
31use std::sync::Once;
32use std::time::{SystemTime, UNIX_EPOCH};
33
34use sqlx::AnyPool;
35use sqlx::Row;
36use sqlx::any::{AnyPoolOptions, AnyRow};
37use sqlx::migrate::Migrator;
38
39pub use crate::repos::NewRepo;
40pub use crate::users::NewUser;
41
42/// Migrations for the SQLite dialect, embedded at build time.
43static SQLITE_MIGRATOR: Migrator = sqlx::migrate!("../../migrations/sqlite");
44/// Migrations for the PostgreSQL dialect, embedded at build time.
45static 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.
50const 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.
59fn 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)]
67pub enum Backend {
68 /// A SQLite database (file-backed or in-memory).
69 Sqlite,
70 /// A PostgreSQL database.
71 Postgres,
72}
73
74impl 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)]
96pub 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
123impl 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)]
133pub struct Store {
134 pool: AnyPool,
135 backend: Backend,
136}
137
138impl 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.
220fn 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).
227fn 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.
235fn 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.
253fn 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)]
272mod 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
7use model::Repo;
8use sqlx::any::AnyRow;
9use sqlx::{AssertSqlSafe, Row};
10
11use crate::{Store, StoreError, map_conflict, new_id, now_ms};
12
13/// The columns of `repos` in a fixed order, shared by every `SELECT`.
14const 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)]
20pub 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
37impl 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
11use tempfile::TempDir;
12
13use super::{Backend, NewRepo, NewUser, Store, StoreError};
14
15/// A migrated store plus any temp directory that must outlive it.
16struct 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.
23async 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.
30async 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.
42async fn sqlite_fixtures() -> Vec<Fixture> {
43 vec![memory_store().await, file_store().await]
44}
45
46fn 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]
58async 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]
66async 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]
89async 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]
114async 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]
139async 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]
151async 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]
184async 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]
245async 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]
270fn 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]
292fn 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.
318fn 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).
354fn 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)]
369async 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
7use model::User;
8use sqlx::any::AnyRow;
9use sqlx::{AssertSqlSafe, Row};
10
11use 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.
15const 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)]
21pub 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
36impl 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 @@
3434
35 craneLib = (inputs.crane.mkLib pkgs).overrideToolchain fenixToolchain;35 craneLib = (inputs.crane.mkLib pkgs).overrideToolchain fenixToolchain;
3636
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 commonArgs = {48 commonArgs = {
38 src = craneLib.cleanCargoSource ./.;49 inherit src;
39 strictDeps = true;50 strictDeps = true;
40 nativeBuildInputs = [51 nativeBuildInputs = [
41 pkgs.pkg-config52 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
14CREATE 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
29CREATE 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
39CREATE 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
49CREATE 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
60CREATE 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
74CREATE 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
85CREATE 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
102CREATE 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.
112CREATE 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
123CREATE 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.
136CREATE INDEX idx_repos_visibility_pushed ON repos (is_private, pushed_at DESC);
137CREATE INDEX idx_groups_owner_parent ON groups (owner_id, parent_id);
138CREATE INDEX idx_keys_user_kind ON keys (user_id, kind);
139CREATE INDEX idx_sessions_user ON sessions (user_id);
140CREATE INDEX idx_sessions_expires ON sessions (expires_at);
141CREATE 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
14CREATE 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
29CREATE 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
39CREATE 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
49CREATE 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
60CREATE 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
74CREATE 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
85CREATE 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
102CREATE 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.
112CREATE 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
123CREATE 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.
136CREATE INDEX idx_repos_visibility_pushed ON repos (is_private, pushed_at DESC);
137CREATE INDEX idx_groups_owner_parent ON groups (owner_id, parent_id);
138CREATE INDEX idx_keys_user_kind ON keys (user_id, kind);
139CREATE INDEX idx_sessions_user ON sessions (user_id);
140CREATE INDEX idx_sessions_expires ON sessions (expires_at);
141CREATE INDEX idx_api_tokens_user ON api_tokens (user_id);