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