diff --git a/Cargo.lock b/Cargo.lock index 73052c6..a25480b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,20 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -123,6 +137,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.1" @@ -150,12 +185,24 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -202,7 +249,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", "windows-link", ] @@ -332,6 +382,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.17.0" @@ -341,6 +397,15 @@ dependencies = [ "serde", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -377,12 +442,40 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "flume" version = "0.12.0" @@ -409,6 +502,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "futures-channel" version = "0.3.33" @@ -501,6 +604,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -509,7 +624,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core", ] @@ -641,6 +756,7 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", ] [[package]] @@ -649,13 +765,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -795,6 +919,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "itoa" version = "1.0.18" @@ -812,6 +942,33 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5" +dependencies = [ + "ahash", + "base64", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "idna", + "itoa", + "num-cmp", + "num-traits", + "once_cell", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "reqwest", + "serde", + "serde_json", + "uuid-simd", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -835,6 +992,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -913,6 +1076,75 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -928,6 +1160,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -1002,6 +1240,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1034,6 +1278,52 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "referencing" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d" +dependencies = [ + "ahash", + "fluent-uri", + "once_cell", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.16" @@ -1051,6 +1341,40 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1065,6 +1389,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.43" @@ -1124,6 +1461,31 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1160,6 +1522,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -1487,14 +1860,18 @@ name = "studio-api" version = "0.1.0" dependencies = [ "axum", + "chrono", "http-body-util", "serde", "serde_json", "sqlx", + "studio-core", "studio-store", + "studio-types", "tokio", "tower", "tracing", + "uuid", ] [[package]] @@ -1509,9 +1886,8 @@ dependencies = [ name = "studio-core" version = "0.1.0" dependencies = [ - "serde", - "serde_json", - "thiserror", + "chrono", + "studio-types", ] [[package]] @@ -1526,12 +1902,28 @@ dependencies = [ name = "studio-store" version = "0.1.0" dependencies = [ + "chrono", + "serde_json", "sqlx", + "studio-types", + "tempfile", "thiserror", "tokio", "tracing", ] +[[package]] +name = "studio-types" +version = "0.1.0" +dependencies = [ + "bech32", + "chrono", + "jsonschema", + "schemars", + "serde", + "serde_json", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1565,6 +1957,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1577,6 +1972,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.19" @@ -1685,6 +2093,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1759,6 +2185,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -1816,6 +2248,28 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1834,12 +2288,36 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -1853,6 +2331,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -1885,6 +2373,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -2041,6 +2539,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" @@ -2070,6 +2574,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 40de49e..67a3060 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ tracing-subscriber = { workspace = true } [workspace] members = [ + "crates/studio-types", "crates/studio-core", "crates/studio-store", "crates/studio-buzz", @@ -44,9 +45,15 @@ axum = { version = "0.8", features = ["macros"] } # Storage — SQLite projection, rebuildable by design (ARCHITECTURE.md §1) sqlx = { version = "0.9", features = ["runtime-tokio", "tls-rustls", "sqlite", "migrate", "chrono", "json"] } -# Serialization +# Serialization — schemars pin matches the buzz workspace (version = "1") serde = { version = "1", features = ["derive"] } serde_json = "1" +schemars = "1" + +# Domain +bech32 = "0.11" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v4"] } # Errors + observability anyhow = "1" @@ -55,6 +62,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Intra-workspace +studio-types = { path = "crates/studio-types" } studio-core = { path = "crates/studio-core" } studio-store = { path = "crates/studio-store" } studio-buzz = { path = "crates/studio-buzz" } diff --git a/crates/studio-api/Cargo.toml b/crates/studio-api/Cargo.toml index 5d530df..0e16c45 100644 --- a/crates/studio-api/Cargo.toml +++ b/crates/studio-api/Cargo.toml @@ -6,13 +6,17 @@ license.workspace = true publish = false [dependencies] +studio-core = { workspace = true } studio-store = { workspace = true } +studio-types = { workspace = true } axum = { workspace = true } +chrono = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sqlx = { workspace = true } tracing = { workspace = true } +uuid = { workspace = true } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/studio-api/src/endpoints/api_index.rs b/crates/studio-api/src/endpoints/api_index.rs new file mode 100644 index 0000000..d48e4a8 --- /dev/null +++ b/crates/studio-api/src/endpoints/api_index.rs @@ -0,0 +1,29 @@ +//! `GET /api/v1` — the discovery index. A client (human, CLI, or agent) that +//! knows only the base URL can enumerate every endpoint and fetch the JSON +//! Schema of every wire type from here. + +use axum::{http::StatusCode, response::IntoResponse, Json}; + +pub async fn handler() -> impl IntoResponse { + let schemas: Vec = studio_types::schemas::all() + .into_iter() + .map(|(name, _)| serde_json::json!({ "name": name, "href": format!("/api/v1/schemas/{name}") })) + .collect(); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "service": "scarce-studio", + "version": env!("CARGO_PKG_VERSION"), + "endpoints": [ + { "method": "GET", "path": "/api/v1", "description": "this index" }, + { "method": "GET", "path": "/api/v1/schemas/{name}", "description": "JSON Schema of a wire type" }, + { "method": "POST", "path": "/api/v1/rfqs", "description": "capture a demand record (schema: rfq)" }, + { "method": "GET", "path": "/api/v1/rfqs", "description": "list captured RFQs, oldest first (?since=)" }, + { "method": "GET", "path": "/api/v1/rfqs/{id}", "description": "fetch one captured RFQ" }, + ], + "schemas": schemas, + "errors": "validation failures return 422 with { errors: [{ field, message }] }", + })), + ) +} diff --git a/crates/studio-api/src/endpoints/create_rfq.rs b/crates/studio-api/src/endpoints/create_rfq.rs new file mode 100644 index 0000000..9569366 --- /dev/null +++ b/crates/studio-api/src/endpoints/create_rfq.rs @@ -0,0 +1,60 @@ +//! `POST /api/v1/rfqs` — demand capture. Free, unsigned, frictionless: the +//! miss record is the studio's order book; never tax it (ARCHITECTURE.md §4). + +use std::sync::Arc; + +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use studio_types::NewRfq; + +use crate::AppState; + +pub async fn handler( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + // Deserialize by hand so shape errors come back as 422 field errors, + // matching the validation contract, instead of axum's opaque rejection. + let new_rfq: NewRfq = match serde_json::from_value(body) { + Ok(rfq) => rfq, + Err(e) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(serde_json::json!({ + "errors": [{ "field": "body", "message": e.to_string() }] + })), + ) + } + }; + + // The handler only supplies identity and time; validation and assembly + // are the core's single capture path, shared with any future CLI/MCP. + let rfq = match studio_core::rfq::capture( + new_rfq, + uuid::Uuid::new_v4().to_string(), + chrono::Utc::now(), + ) { + Ok(rfq) => rfq, + Err(errors) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(serde_json::json!({ "errors": errors })), + ) + } + }; + + match studio_store::rfqs::insert(&state.db, &rfq).await { + Ok(()) => { + tracing::info!(rfq_id = %rfq.id, buyer = %rfq.buyer_npub, "rfq captured"); + (StatusCode::CREATED, Json(serde_json::json!(rfq))) + } + Err(e) => { + tracing::error!(error = %e, "rfq insert failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json( + serde_json::json!({ "errors": [{ "field": "server", "message": "storage failure" }] }), + ), + ) + } + } +} diff --git a/crates/studio-api/src/endpoints/get_rfq.rs b/crates/studio-api/src/endpoints/get_rfq.rs new file mode 100644 index 0000000..a864e7e --- /dev/null +++ b/crates/studio-api/src/endpoints/get_rfq.rs @@ -0,0 +1,32 @@ +//! `GET /api/v1/rfqs/{id}` — free read of a captured demand record. + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; + +use crate::AppState; + +pub async fn handler( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + match studio_store::rfqs::get(&state.db, &id).await { + Ok(Some(rfq)) => (StatusCode::OK, Json(serde_json::json!(rfq))), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "rfq not found" })), + ), + Err(e) => { + tracing::error!(error = %e, rfq_id = %id, "rfq read failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "storage failure" })), + ) + } + } +} diff --git a/crates/studio-api/src/endpoints/get_schema.rs b/crates/studio-api/src/endpoints/get_schema.rs new file mode 100644 index 0000000..d8694e1 --- /dev/null +++ b/crates/studio-api/src/endpoints/get_schema.rs @@ -0,0 +1,24 @@ +//! `GET /api/v1/schemas/{name}` — serves the generated JSON Schemas, so the +//! wire contract is discoverable from the running service itself (same values +//! as the checked-in `schemas/*.json`). + +use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json}; + +pub async fn handler(Path(name): Path) -> impl IntoResponse { + match studio_types::schemas::get(&name) { + Some(schema) => (StatusCode::OK, Json(schema)), + None => { + let available: Vec<&str> = studio_types::schemas::all() + .into_iter() + .map(|(n, _)| n) + .collect(); + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("unknown schema {name:?}"), + "available": available, + })), + ) + } + } +} diff --git a/crates/studio-api/src/endpoints/list_rfqs.rs b/crates/studio-api/src/endpoints/list_rfqs.rs new file mode 100644 index 0000000..32a00e2 --- /dev/null +++ b/crates/studio-api/src/endpoints/list_rfqs.rs @@ -0,0 +1,51 @@ +//! `GET /api/v1/rfqs?since=` — the order book, oldest first. + +use std::sync::Arc; + +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +use crate::AppState; + +#[derive(Deserialize)] +pub struct ListParams { + /// RFC 3339 timestamp; only RFQs captured at or after it are returned. + pub since: Option, +} + +pub async fn handler( + State(state): State>, + Query(params): Query, +) -> impl IntoResponse { + let since: Option> = match params.since.as_deref() { + None => None, + Some(raw) => match DateTime::parse_from_rfc3339(raw) { + Ok(ts) => Some(ts.with_timezone(&Utc)), + Err(e) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(serde_json::json!({ + "errors": [{ "field": "since", "message": format!("must be RFC 3339: {e}") }] + })), + ) + } + }, + }; + + match studio_store::rfqs::list_since(&state.db, since).await { + Ok(rfqs) => (StatusCode::OK, Json(serde_json::json!({ "rfqs": rfqs }))), + Err(e) => { + tracing::error!(error = %e, "rfq list failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "storage failure" })), + ) + } + } +} diff --git a/crates/studio-api/src/endpoints/mod.rs b/crates/studio-api/src/endpoints/mod.rs index 8ce444c..312365a 100644 --- a/crates/studio-api/src/endpoints/mod.rs +++ b/crates/studio-api/src/endpoints/mod.rs @@ -1,3 +1,8 @@ -//! One module per endpoint (SF API conventions). M1 opens with the RFQ -//! routes; `/healthz` lives in the crate root until there is a second -//! resident. +//! One module per endpoint (SF API conventions). `/healthz` lives in the +//! crate root. + +pub mod api_index; +pub mod create_rfq; +pub mod get_rfq; +pub mod get_schema; +pub mod list_rfqs; diff --git a/crates/studio-api/src/lib.rs b/crates/studio-api/src/lib.rs index d4a5f77..8f77c17 100644 --- a/crates/studio-api/src/lib.rs +++ b/crates/studio-api/src/lib.rs @@ -1,10 +1,19 @@ //! HTTP surface of `scarced` — serves projections, never asserts a state it -//! cannot evidence (ARCHITECTURE.md §1). Routes accrete per milestone; M0 is -//! `GET /healthz` only. +//! cannot evidence (ARCHITECTURE.md §1). Routes accrete per milestone under +//! `/api/v1`; the surface is self-describing (`GET /api/v1` lists endpoints, +//! `GET /api/v1/schemas/{name}` serves the generated JSON Schemas). Handlers +//! stay thin: parse, call `studio-core`, serialize — the logic they invoke is +//! reusable from a CLI or MCP surface without HTTP. use std::sync::Arc; -use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; use sqlx::SqlitePool; pub mod endpoints; @@ -16,8 +25,20 @@ pub struct AppState { } pub fn router(state: Arc) -> Router { + // `/healthz` stays unversioned (ops convention); everything else is + // `/api/v1` so the contract can evolve without breaking callers. Router::new() .route("/healthz", get(healthz)) + .route("/api/v1", get(endpoints::api_index::handler)) + .route( + "/api/v1/schemas/{name}", + get(endpoints::get_schema::handler), + ) + .route( + "/api/v1/rfqs", + post(endpoints::create_rfq::handler).get(endpoints::list_rfqs::handler), + ) + .route("/api/v1/rfqs/{id}", get(endpoints::get_rfq::handler)) .with_state(state) } diff --git a/crates/studio-api/tests/rfq_api.rs b/crates/studio-api/tests/rfq_api.rs new file mode 100644 index 0000000..c298bd6 --- /dev/null +++ b/crates/studio-api/tests/rfq_api.rs @@ -0,0 +1,172 @@ +//! M1 contract tests: the curl round-trip from PLAN.md, as code. + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{header, Request, StatusCode}; +use http_body_util::BodyExt; +use studio_api::{router, AppState}; +use tower::ServiceExt; + +const GOOD_NPUB: &str = "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy"; + +async fn app() -> axum::Router { + let db = studio_store::open("sqlite::memory:").await.unwrap(); + router(Arc::new(AppState { db })) +} + +async fn send( + app: &axum::Router, + method: &str, + uri: &str, + body: Option, +) -> (StatusCode, serde_json::Value) { + let request = match body { + Some(json) => Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(json.to_string())) + .unwrap(), + None => Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .unwrap(), + }; + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) +} + +#[tokio::test] +async fn post_get_round_trip() { + let app = app().await; + + let (status, created) = send( + &app, + "POST", + "/api/v1/rfqs", + Some(serde_json::json!({ + "query": "solana priority fee forecast api", + "product": "p50/p90 forecast per program id", + "competition": ["helius fee api"], + "budget_ceiling": { "amount": 250_000_000, "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }, + "buyer_npub": GOOD_NPUB + })), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{created}"); + let id = created["id"].as_str().expect("id assigned"); + assert!(created["created_at"].is_string(), "created_at assigned"); + + let (status, fetched) = send(&app, "GET", &format!("/api/v1/rfqs/{id}"), None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched, created, "GET returns exactly what POST created"); +} + +#[tokio::test] +async fn invalid_rfq_gets_422_with_field_errors() { + let app = app().await; + + let (status, body) = send( + &app, + "POST", + "/api/v1/rfqs", + Some(serde_json::json!({ "query": " ", "buyer_npub": "not-an-npub" })), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + let fields: Vec<&str> = body["errors"] + .as_array() + .unwrap() + .iter() + .map(|e| e["field"].as_str().unwrap()) + .collect(); + assert_eq!(fields, ["query", "buyer_npub"]); +} + +#[tokio::test] +async fn unknown_field_gets_422_not_silent_drop() { + let app = app().await; + let (status, body) = send( + &app, + "POST", + "/api/v1/rfqs", + Some(serde_json::json!({ "query": "x", "buyer_npub": GOOD_NPUB, "surprise": 1 })), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert_eq!(body["errors"][0]["field"], "body"); +} + +#[tokio::test] +async fn missing_rfq_is_404() { + let app = app().await; + let (status, _) = send(&app, "GET", "/api/v1/rfqs/definitely-not-there", None).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn list_supports_since_filter() { + let app = app().await; + for query in ["first", "second"] { + let (status, _) = send( + &app, + "POST", + "/api/v1/rfqs", + Some(serde_json::json!({ "query": query, "buyer_npub": GOOD_NPUB })), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + } + + let (status, body) = send(&app, "GET", "/api/v1/rfqs", None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["rfqs"].as_array().unwrap().len(), 2); + + let (status, body) = send(&app, "GET", "/api/v1/rfqs?since=2099-01-01T00:00:00Z", None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["rfqs"].as_array().unwrap().len(), 0); + + let (status, body) = send(&app, "GET", "/api/v1/rfqs?since=yesterday-ish", None).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body["errors"][0]["field"], "since"); +} + +#[tokio::test] +async fn api_index_lists_every_route_and_schema() { + let app = app().await; + let (status, body) = send(&app, "GET", "/api/v1", None).await; + assert_eq!(status, StatusCode::OK); + + let paths: Vec<&str> = body["endpoints"] + .as_array() + .unwrap() + .iter() + .map(|e| e["path"].as_str().unwrap()) + .collect(); + for expected in [ + "/api/v1", + "/api/v1/schemas/{name}", + "/api/v1/rfqs", + "/api/v1/rfqs/{id}", + ] { + assert!(paths.contains(&expected), "index missing {expected}"); + } + assert_eq!(body["schemas"][0]["name"], "rfq"); +} + +#[tokio::test] +async fn schemas_endpoint_serves_generated_contract() { + let app = app().await; + let (status, body) = send(&app, "GET", "/api/v1/schemas/rfq", None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, studio_types::schemas::rfq()); + + let (status, body) = send(&app, "GET", "/api/v1/schemas/nope", None).await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["available"][0], "rfq", "404 names what exists: {body}"); +} diff --git a/crates/studio-core/Cargo.toml b/crates/studio-core/Cargo.toml index 9c7312b..74bd26b 100644 --- a/crates/studio-core/Cargo.toml +++ b/crates/studio-core/Cargo.toml @@ -6,6 +6,6 @@ license.workspace = true publish = false [dependencies] -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } +studio-types = { workspace = true } + +chrono = { workspace = true } diff --git a/crates/studio-core/src/lib.rs b/crates/studio-core/src/lib.rs index 702a519..f04ea1d 100644 --- a/crates/studio-core/src/lib.rs +++ b/crates/studio-core/src/lib.rs @@ -1,9 +1,12 @@ -//! Domain types, schemas, and the project state machine. **No I/O.** +//! Business logic over the wire types. **No I/O.** //! //! Everything in this crate is a pure function over data: the state machine -//! (PLAN.md §2), the gate engine (PLAN.md §2.1), and the RFQ/Quote schemas. -//! Orchestration, storage, and transport live in the sibling crates; this -//! crate must stay testable without a relay, a chain, or a database. -//! -//! Populated from M1 (Rfq) onward. M0 ships the empty shell so the workspace -//! shape is fixed from the first commit. +//! (PLAN.md §2), the gate engine (PLAN.md §2.1), and record assembly. The +//! types themselves live in `studio-types` (re-exported here); orchestration, +//! storage, and transport live in the sibling crates. This crate must stay +//! testable without a relay, a chain, or a database — and callable from any +//! surface (HTTP, CLI, MCP) without duplicating logic. + +pub mod rfq; + +pub use studio_types::{Amount, FieldError, NewRfq, Rfq}; diff --git a/crates/studio-core/src/rfq.rs b/crates/studio-core/src/rfq.rs new file mode 100644 index 0000000..a4f3c4c --- /dev/null +++ b/crates/studio-core/src/rfq.rs @@ -0,0 +1,70 @@ +//! RFQ capture — validate a submission and assemble the demand record. +//! +//! Pure: the caller supplies identity and time, so every surface (HTTP +//! handler, CLI, MCP tool) produces bit-identical records and the logic is +//! testable without a clock or an RNG. + +use chrono::{DateTime, Utc}; +use studio_types::{FieldError, NewRfq, Rfq}; + +/// Validate `new` and assemble the captured record. The single path from +/// submission to `Rfq` — handlers only supply `id` and `now`. +pub fn capture(new: NewRfq, id: String, now: DateTime) -> Result> { + new.validate()?; + Ok(Rfq { + id, + query: new.query, + product: new.product, + monetization: new.monetization, + competition: new.competition, + budget_ceiling: new.budget_ceiling, + buyer_npub: new.buyer_npub, + created_at: now, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const GOOD_NPUB: &str = "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy"; + + #[test] + fn capture_assigns_exactly_the_given_identity_and_time() { + let now = Utc::now(); + let rfq = capture( + NewRfq { + query: "tls cert chain decoder api".into(), + product: None, + monetization: None, + competition: vec!["openssl s_client".into()], + budget_ceiling: None, + buyer_npub: GOOD_NPUB.into(), + }, + "rfq-1".into(), + now, + ) + .unwrap(); + assert_eq!(rfq.id, "rfq-1"); + assert_eq!(rfq.created_at, now); + assert_eq!(rfq.competition, vec!["openssl s_client".to_string()]); + } + + #[test] + fn capture_refuses_invalid_submissions() { + let errors = capture( + NewRfq { + query: " ".into(), + product: None, + monetization: None, + competition: vec![], + budget_ceiling: None, + buyer_npub: GOOD_NPUB.into(), + }, + "rfq-1".into(), + Utc::now(), + ) + .unwrap_err(); + assert_eq!(errors[0].field, "query"); + } +} diff --git a/crates/studio-store/Cargo.toml b/crates/studio-store/Cargo.toml index 841850c..6b251c1 100644 --- a/crates/studio-store/Cargo.toml +++ b/crates/studio-store/Cargo.toml @@ -6,9 +6,14 @@ license.workspace = true publish = false [dependencies] +studio-types = { workspace = true } + +chrono = { workspace = true } +serde_json = { workspace = true } sqlx = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } [dev-dependencies] +tempfile = "3" tokio = { workspace = true } diff --git a/crates/studio-store/src/lib.rs b/crates/studio-store/src/lib.rs index 7932f59..7557655 100644 --- a/crates/studio-store/src/lib.rs +++ b/crates/studio-store/src/lib.rs @@ -10,6 +10,8 @@ use std::str::FromStr; use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; +pub mod rfqs; + /// Embedded migrations from `/migrations/`. pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations"); @@ -19,6 +21,11 @@ pub enum StoreError { Db(#[from] sqlx::Error), #[error("migration error: {0}")] Migrate(#[from] sqlx::migrate::MigrateError), + /// A row that cannot round-trip back into its domain type. In a pure + /// projection this means the writer and reader disagree — a bug, never + /// user input. + #[error("corrupt projection row: {0}")] + Corrupt(String), } pub type Result = std::result::Result; diff --git a/crates/studio-store/src/rfqs.rs b/crates/studio-store/src/rfqs.rs new file mode 100644 index 0000000..8adf96b --- /dev/null +++ b/crates/studio-store/src/rfqs.rs @@ -0,0 +1,167 @@ +//! Demand-ledger rows. Pure persistence — validation happened upstream +//! (`studio-core::rfq::capture`) before anything reaches here. + +use chrono::{DateTime, Utc}; +use sqlx::{Row, SqlitePool}; +use studio_types::{Amount, Rfq}; + +use crate::Result; + +pub async fn insert(pool: &SqlitePool, rfq: &Rfq) -> Result<()> { + sqlx::query( + "INSERT INTO rfqs (id, query, product, monetization, competition, + budget_amount, budget_mint, buyer_npub, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + ) + .bind(&rfq.id) + .bind(&rfq.query) + .bind(&rfq.product) + .bind(&rfq.monetization) + .bind(serde_json::to_string(&rfq.competition).expect("Vec serializes")) + .bind(rfq.budget_ceiling.as_ref().map(|b| b.amount as i64)) + .bind(rfq.budget_ceiling.as_ref().map(|b| b.mint.clone())) + .bind(&rfq.buyer_npub) + .bind(rfq.created_at.to_rfc3339()) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn get(pool: &SqlitePool, id: &str) -> Result> { + let row = sqlx::query("SELECT * FROM rfqs WHERE id = ?1") + .bind(id) + .fetch_optional(pool) + .await?; + row.map(from_row).transpose() +} + +/// All RFQs captured at or after `since`, oldest first. +pub async fn list_since(pool: &SqlitePool, since: Option>) -> Result> { + let rows = match since { + Some(ts) => { + sqlx::query("SELECT * FROM rfqs WHERE created_at >= ?1 ORDER BY created_at ASC") + .bind(ts.to_rfc3339()) + .fetch_all(pool) + .await? + } + None => { + sqlx::query("SELECT * FROM rfqs ORDER BY created_at ASC") + .fetch_all(pool) + .await? + } + }; + rows.into_iter().map(from_row).collect() +} + +fn from_row(row: sqlx::sqlite::SqliteRow) -> Result { + let competition: String = row.get("competition"); + let budget_amount: Option = row.get("budget_amount"); + let budget_mint: Option = row.get("budget_mint"); + let created_at: String = row.get("created_at"); + Ok(Rfq { + id: row.get("id"), + query: row.get("query"), + product: row.get("product"), + monetization: row.get("monetization"), + competition: serde_json::from_str(&competition).map_err(|e| { + crate::StoreError::Corrupt(format!("rfqs.competition not a JSON array: {e}")) + })?, + budget_ceiling: match (budget_amount, budget_mint) { + (Some(amount), Some(mint)) => Some(Amount { + amount: amount as u64, + mint, + }), + (None, None) => None, + _ => { + return Err(crate::StoreError::Corrupt( + "rfqs.budget_amount/budget_mint must be both set or both null".into(), + )) + } + }, + buyer_npub: row.get("buyer_npub"), + created_at: DateTime::parse_from_rfc3339(&created_at) + .map_err(|e| crate::StoreError::Corrupt(format!("rfqs.created_at: {e}")))? + .with_timezone(&Utc), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(id: &str, created_at: &str) -> Rfq { + Rfq { + id: id.into(), + query: "solana priority fee forecast api".into(), + product: Some("p50/p90 fee forecast per program id".into()), + monetization: None, + competition: vec!["helius fee api".into()], + budget_ceiling: Some(Amount { + amount: 250_000_000, + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), + }), + buyer_npub: "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(), + created_at: DateTime::parse_from_rfc3339(created_at) + .unwrap() + .with_timezone(&Utc), + } + } + + #[tokio::test] + async fn round_trips_through_sqlite() { + let pool = crate::open("sqlite::memory:").await.unwrap(); + let rfq = sample("rfq-1", "2026-08-01T15:00:00Z"); + insert(&pool, &rfq).await.unwrap(); + + let back = get(&pool, "rfq-1").await.unwrap().expect("row exists"); + assert_eq!( + serde_json::to_value(&back).unwrap(), + serde_json::to_value(&rfq).unwrap() + ); + assert!(get(&pool, "rfq-nope").await.unwrap().is_none()); + } + + #[tokio::test] + async fn list_since_filters_and_orders() { + let pool = crate::open("sqlite::memory:").await.unwrap(); + insert(&pool, &sample("a", "2026-08-01T10:00:00Z")) + .await + .unwrap(); + insert(&pool, &sample("b", "2026-08-01T12:00:00Z")) + .await + .unwrap(); + insert(&pool, &sample("c", "2026-08-01T14:00:00Z")) + .await + .unwrap(); + + let all = list_since(&pool, None).await.unwrap(); + assert_eq!( + all.iter().map(|r| r.id.as_str()).collect::>(), + ["a", "b", "c"] + ); + + let since = DateTime::parse_from_rfc3339("2026-08-01T12:00:00Z") + .unwrap() + .with_timezone(&Utc); + let recent = list_since(&pool, Some(since)).await.unwrap(); + assert_eq!( + recent.iter().map(|r| r.id.as_str()).collect::>(), + ["b", "c"] + ); + } + + #[tokio::test] + async fn records_survive_reopen() { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite://{}", dir.path().join("restart.db").display()); + + let pool = crate::open(&url).await.unwrap(); + insert(&pool, &sample("durable", "2026-08-01T15:00:00Z")) + .await + .unwrap(); + pool.close().await; + + let pool = crate::open(&url).await.unwrap(); + assert!(get(&pool, "durable").await.unwrap().is_some()); + } +} diff --git a/crates/studio-types/Cargo.toml b/crates/studio-types/Cargo.toml new file mode 100644 index 0000000..45ec1a4 --- /dev/null +++ b/crates/studio-types/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "studio-types" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +bech32 = { workspace = true } +chrono = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +# Keeps schemas/*.json and the Rust validation honest with each other. +jsonschema = "0.33" + +[[bin]] +name = "gen-schemas" +path = "src/bin/gen_schemas.rs" diff --git a/crates/studio-types/src/bin/gen_schemas.rs b/crates/studio-types/src/bin/gen_schemas.rs new file mode 100644 index 0000000..03b0b60 --- /dev/null +++ b/crates/studio-types/src/bin/gen_schemas.rs @@ -0,0 +1,17 @@ +//! Regenerates `schemas/*.json` from the wire types (`just schemas`). +//! The drift test (`tests/schema_drift.rs`) fails CI when the checked-in +//! files are stale. + +use std::path::Path; + +fn main() -> std::io::Result<()> { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schemas"); + for (name, schema) in studio_types::schemas::all() { + let path = dir.join(format!("{name}.json")); + let mut pretty = serde_json::to_string_pretty(&schema).expect("schema serializes"); + pretty.push('\n'); + std::fs::write(&path, pretty)?; + println!("wrote {}", path.display()); + } + Ok(()) +} diff --git a/crates/studio-types/src/lib.rs b/crates/studio-types/src/lib.rs new file mode 100644 index 0000000..031fd36 --- /dev/null +++ b/crates/studio-types/src/lib.rs @@ -0,0 +1,13 @@ +//! Wire and domain types for scarce-studio — the single source of the API +//! contract. **No I/O, no orchestration.** +//! +//! Every type here derives `serde` for the wire and `schemars::JsonSchema` +//! for discoverability: the checked-in `schemas/*.json` files are *generated* +//! from these types (see [`schemas`]), so the JSON Schema contract cannot +//! drift from the code. Field validation lives next to the types; state +//! machines and orchestration logic stay in `studio-core`. + +pub mod rfq; +pub mod schemas; + +pub use rfq::{Amount, FieldError, NewRfq, Rfq}; diff --git a/crates/studio-types/src/rfq.rs b/crates/studio-types/src/rfq.rs new file mode 100644 index 0000000..8747395 --- /dev/null +++ b/crates/studio-types/src/rfq.rs @@ -0,0 +1,227 @@ +//! RFQ — the demand record (PLAN.md M1, DESIGN.md §2). +//! +//! A catalog miss is a structured, timestamped, identity-attributed statement +//! of willingness to pay for something that does not exist. Capture must be +//! frictionless — validation here is deliberately minimal (a real query and a +//! real buyer identity); everything else is optional signal. Never tax the +//! order book. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Structural npub pattern for the JSON Schema. The bech32 charset excludes +/// `1`, `b`, `i`, `o`; `validate()` additionally checks the checksum, which a +/// regex cannot. +pub const NPUB_PATTERN: &str = "^npub1[02-9ac-hj-np-z]{58}$"; + +/// A buyer-submitted RFQ, before the studio assigns identity and time. +/// Wire shape: `schemas/rfq.json` — generated from this type. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars( + title = "RFQ submission", + description = "A demand record captured from a pay.sh catalog miss. Capture is deliberately frictionless: only the query and the buyer identity are required (DESIGN.md §2 — never tax the order book). This is the wire shape of POST /api/v1/rfqs; the studio assigns id and created_at." +)] +pub struct NewRfq { + /// The failed catalog search — the demand signal itself. + #[schemars(length(min = 1))] + pub query: String, + /// What the buyer wants built. + #[serde(default)] + pub product: Option, + /// How the buyer thinks the artifact should be monetized. + #[serde(default)] + pub monetization: Option, + /// Competing or adjacent offerings the buyer knows about. + #[serde(default)] + pub competition: Vec, + /// Budget signal, not a commitment. + #[serde(default)] + pub budget_ceiling: Option, + /// Buyer identity — the Nostr npub it will later pay with. + #[schemars(regex(pattern = NPUB_PATTERN))] + pub buyer_npub: String, +} + +/// Token amount in minor units of `mint`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Amount { + /// Token amount in minor units of `mint`. + #[schemars(range(min = 1))] + pub amount: u64, + /// SPL mint address (e.g. USDC). Free-form here; enforced at quote time. + #[schemars(length(min = 1))] + pub mint: String, +} + +/// A captured demand record — what `POST /api/v1/rfqs` returns. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Rfq { + pub id: String, + pub query: String, + pub product: Option, + pub monetization: Option, + pub competition: Vec, + pub budget_ceiling: Option, + pub buyer_npub: String, + /// RFC 3339, UTC, server-assigned at capture. + pub created_at: chrono::DateTime, +} + +/// One field-level validation failure — serialized into 422 bodies. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct FieldError { + pub field: &'static str, + pub message: String, +} + +impl NewRfq { + /// Frictionless-capture validation: only reject what would make the + /// record useless (no query) or unattributable (no valid buyer key). + pub fn validate(&self) -> Result<(), Vec> { + let mut errors = Vec::new(); + + if self.query.trim().is_empty() { + errors.push(FieldError { + field: "query", + message: "must be a non-empty string".into(), + }); + } + + if let Err(message) = validate_npub(&self.buyer_npub) { + errors.push(FieldError { + field: "buyer_npub", + message, + }); + } + + if let Some(budget) = &self.budget_ceiling { + if budget.amount == 0 { + errors.push(FieldError { + field: "budget_ceiling.amount", + message: "must be greater than zero when present".into(), + }); + } + if budget.mint.trim().is_empty() { + errors.push(FieldError { + field: "budget_ceiling.mint", + message: "must be a non-empty mint address when present".into(), + }); + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +/// Structural npub check: bech32 decodes, HRP is `npub`, payload is 32 bytes. +/// Signature verification is a substrate concern and arrives with the +/// orchestrator (M3); identity attribution only needs a well-formed key. +fn validate_npub(npub: &str) -> Result<(), String> { + let (hrp, data) = + bech32::decode(npub).map_err(|_| "must be a bech32 npub (npub1…)".to_string())?; + if hrp.as_str() != "npub" { + return Err(format!("expected npub, got {hrp}")); + } + if data.len() != 32 { + return Err("npub payload must be 32 bytes".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ruben's real npub — a known-good bech32 vector. + const GOOD_NPUB: &str = "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy"; + + fn minimal() -> NewRfq { + NewRfq { + query: "solana priority fee forecast api".into(), + product: None, + monetization: None, + competition: vec![], + budget_ceiling: None, + buyer_npub: GOOD_NPUB.into(), + } + } + + #[test] + fn minimal_rfq_is_valid() { + assert!(minimal().validate().is_ok()); + } + + #[test] + fn empty_query_is_rejected_with_field_error() { + let mut rfq = minimal(); + rfq.query = " ".into(); + let errors = rfq.validate().unwrap_err(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].field, "query"); + } + + #[test] + fn bad_npub_is_rejected() { + let truncated = &GOOD_NPUB[..GOOD_NPUB.len() - 2]; + for bad in ["", "npub1notbech32!!!", "hello", truncated] { + let mut rfq = minimal(); + rfq.buyer_npub = bad.into(); + let errors = rfq.validate().unwrap_err(); + assert_eq!(errors[0].field, "buyer_npub", "input: {bad}"); + } + } + + #[test] + fn nsec_hrp_is_rejected() { + // Right shape, wrong HRP — must not attribute demand to a secret key. + let mut rfq = minimal(); + rfq.buyer_npub = + bech32::encode::(bech32::Hrp::parse("nsec").unwrap(), &[7u8; 32]) + .unwrap(); + let errors = rfq.validate().unwrap_err(); + assert_eq!(errors[0].field, "buyer_npub"); + } + + #[test] + fn zero_budget_is_rejected_but_absent_budget_is_fine() { + let mut rfq = minimal(); + rfq.budget_ceiling = Some(Amount { + amount: 0, + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), + }); + let errors = rfq.validate().unwrap_err(); + assert_eq!(errors[0].field, "budget_ceiling.amount"); + } + + #[test] + fn all_errors_are_collected_not_first_only() { + let rfq = NewRfq { + query: "".into(), + product: None, + monetization: None, + competition: vec![], + budget_ceiling: Some(Amount { + amount: 0, + mint: "".into(), + }), + buyer_npub: "nope".into(), + }; + let errors = rfq.validate().unwrap_err(); + let fields: Vec<_> = errors.iter().map(|e| e.field).collect(); + assert_eq!( + fields, + vec![ + "query", + "buyer_npub", + "budget_ceiling.amount", + "budget_ceiling.mint" + ] + ); + } +} diff --git a/crates/studio-types/src/schemas.rs b/crates/studio-types/src/schemas.rs new file mode 100644 index 0000000..a4860f9 --- /dev/null +++ b/crates/studio-types/src/schemas.rs @@ -0,0 +1,38 @@ +//! The schema registry — JSON Schemas *generated* from the wire types. +//! +//! `schemas/*.json` in the repo root are build artifacts of this module +//! (regenerate with `just schemas`); a drift test asserts the checked-in +//! files equal the generated output, so the interoperable contract +//! (DESIGN.md §8) is derived from the Rust types instead of hand-maintained. +//! The API serves the same values at `GET /api/v1/schemas/{name}`. + +use schemars::schema_for; + +/// Stable `$id` base for published schemas. +const ID_BASE: &str = "https://scarce.studio/schemas"; + +/// Every published schema, by wire name (= filename stem under `schemas/`). +pub fn all() -> Vec<(&'static str, serde_json::Value)> { + vec![("rfq", rfq())] +} + +/// Look up one published schema by wire name. +pub fn get(name: &str) -> Option { + all().into_iter().find(|(n, _)| *n == name).map(|(_, s)| s) +} + +/// `schemas/rfq.json` — the RFQ submission contract (`POST /api/v1/rfqs`). +pub fn rfq() -> serde_json::Value { + finalize("rfq", schema_for!(crate::rfq::NewRfq)) +} + +/// Stamp the registry-level `$id` onto a generated schema. `$schema`, title, +/// and descriptions come from the type derives. +fn finalize(name: &str, schema: schemars::Schema) -> serde_json::Value { + let mut value = serde_json::to_value(&schema).expect("schema serializes"); + value + .as_object_mut() + .expect("schema is an object") + .insert("$id".into(), format!("{ID_BASE}/{name}.json").into()); + value +} diff --git a/crates/studio-types/tests/schema_conformance.rs b/crates/studio-types/tests/schema_conformance.rs new file mode 100644 index 0000000..c4c162a --- /dev/null +++ b/crates/studio-types/tests/schema_conformance.rs @@ -0,0 +1,85 @@ +//! Keeps `schemas/rfq.json` (the interoperable wire contract, DESIGN.md §8) +//! and `NewRfq::validate` (the enforcement) honest with each other: every +//! example must be accepted or rejected by *both*, or the schema has drifted +//! from the code. + +use studio_types::NewRfq; + +const RFQ_SCHEMA: &str = include_str!("../../../schemas/rfq.json"); + +const GOOD_NPUB: &str = "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy"; + +fn schema() -> serde_json::Value { + serde_json::from_str(RFQ_SCHEMA).expect("schemas/rfq.json parses") +} + +fn agree(payload: serde_json::Value, expect_valid: bool) { + let schema_verdict = jsonschema::is_valid(&schema(), &payload); + let code_verdict = serde_json::from_value::(payload.clone()) + .ok() + .is_some_and(|rfq| rfq.validate().is_ok()); + assert_eq!( + schema_verdict, expect_valid, + "schema verdict diverged for {payload}" + ); + assert_eq!( + code_verdict, expect_valid, + "code verdict diverged for {payload}" + ); +} + +#[test] +fn minimal_submission_is_valid_in_both() { + agree( + serde_json::json!({ "query": "tls cert chain decoder api", "buyer_npub": GOOD_NPUB }), + true, + ); +} + +#[test] +fn full_submission_is_valid_in_both() { + agree( + serde_json::json!({ + "query": "solana priority fee forecast api", + "product": "REST endpoint forecasting p50/p90 priority fees per program id", + "monetization": "per-call, usd-denominated", + "competition": ["triton", "helius fee api"], + "budget_ceiling": { "amount": 250_000_000, "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }, + "buyer_npub": GOOD_NPUB + }), + true, + ); +} + +#[test] +fn rejects_agree_missing_query() { + agree(serde_json::json!({ "buyer_npub": GOOD_NPUB }), false); +} + +#[test] +fn rejects_agree_bad_npub() { + agree( + serde_json::json!({ "query": "x", "buyer_npub": "npub1short" }), + false, + ); +} + +#[test] +fn rejects_agree_zero_budget() { + agree( + serde_json::json!({ + "query": "x", + "buyer_npub": GOOD_NPUB, + "budget_ceiling": { "amount": 0, "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" } + }), + false, + ); +} + +#[test] +fn rejects_agree_unknown_field() { + agree( + serde_json::json!({ "query": "x", "buyer_npub": GOOD_NPUB, "surprise": true }), + false, + ); +} diff --git a/crates/studio-types/tests/schema_drift.rs b/crates/studio-types/tests/schema_drift.rs new file mode 100644 index 0000000..54eccf9 --- /dev/null +++ b/crates/studio-types/tests/schema_drift.rs @@ -0,0 +1,39 @@ +//! The checked-in `schemas/*.json` must equal what the types generate — +//! the contract is derived from the code, never hand-edited. Fails when a +//! type change lands without `just schemas`. + +use std::path::Path; + +#[test] +fn checked_in_schemas_match_generated() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schemas"); + for (name, generated) in studio_types::schemas::all() { + let path = dir.join(format!("{name}.json")); + let checked_in: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())), + ) + .unwrap_or_else(|e| panic!("parse {}: {e}", path.display())); + assert_eq!( + checked_in, generated, + "schemas/{name}.json has drifted from the types — run `just schemas`" + ); + } +} + +#[test] +fn every_checked_in_schema_is_registered() { + // A schema file with no generating type would silently rot. + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schemas"); + let registered: Vec = studio_types::schemas::all() + .into_iter() + .map(|(n, _)| format!("{n}.json")) + .collect(); + for entry in std::fs::read_dir(&dir).expect("schemas/ exists") { + let file = entry.unwrap().file_name().into_string().unwrap(); + assert!( + registered.contains(&file), + "schemas/{file} is not generated by studio_types::schemas::all()" + ); + } +} diff --git a/justfile b/justfile index 10941fa..24f7ea5 100644 --- a/justfile +++ b/justfile @@ -24,6 +24,11 @@ ci: fmt lint test run: cargo run --bin scarced +# Regenerate schemas/*.json from the studio-types derives. CI fails (drift +# test) when a type change lands without re-running this. +schemas: + cargo run -p studio-types --bin gen-schemas + # Env-gated integration suites (real relay / devnet) land in M3/M5. # Kept separate from `ci` by design: nightly, not per-push (PLAN.md §4). integration-test: diff --git a/migrations/0002_rfqs.sql b/migrations/0002_rfqs.sql new file mode 100644 index 0000000..c795bea --- /dev/null +++ b/migrations/0002_rfqs.sql @@ -0,0 +1,20 @@ +-- Demand ledger (M1). One row per captured catalog miss. +-- +-- Projection caveat, on the record: RFQ capture is currently API-authored — +-- there is no substrate event behind these rows yet, so this is the one +-- table the M3 replay test cannot rebuild from relay+RPC alone. Raised with +-- archy at the M1 boundary; expected resolution is mirroring demand records +-- onto a studio Buzz channel so the invariant (ARCHITECTURE.md §1) holds. +CREATE TABLE rfqs ( + id TEXT PRIMARY KEY, + query TEXT NOT NULL, + product TEXT, + monetization TEXT, + competition TEXT NOT NULL DEFAULT '[]', -- JSON array of strings + budget_amount INTEGER, -- minor units; NULL = no signal + budget_mint TEXT, + buyer_npub TEXT NOT NULL, + created_at TEXT NOT NULL -- RFC 3339, UTC +) STRICT; + +CREATE INDEX idx_rfqs_created_at ON rfqs (created_at); diff --git a/schemas/rfq.json b/schemas/rfq.json new file mode 100644 index 0000000..954d0d9 --- /dev/null +++ b/schemas/rfq.json @@ -0,0 +1,84 @@ +{ + "$defs": { + "Amount": { + "additionalProperties": false, + "description": "Token amount in minor units of `mint`.", + "properties": { + "amount": { + "description": "Token amount in minor units of `mint`.", + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "mint": { + "description": "SPL mint address (e.g. USDC). Free-form here; enforced at quote time.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "amount", + "mint" + ], + "type": "object" + } + }, + "$id": "https://scarce.studio/schemas/rfq.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "A demand record captured from a pay.sh catalog miss. Capture is deliberately frictionless: only the query and the buyer identity are required (DESIGN.md §2 — never tax the order book). This is the wire shape of POST /api/v1/rfqs; the studio assigns id and created_at.", + "properties": { + "budget_ceiling": { + "anyOf": [ + { + "$ref": "#/$defs/Amount" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Budget signal, not a commitment." + }, + "buyer_npub": { + "description": "Buyer identity — the Nostr npub it will later pay with.", + "pattern": "^npub1[02-9ac-hj-np-z]{58}$", + "type": "string" + }, + "competition": { + "default": [], + "description": "Competing or adjacent offerings the buyer knows about.", + "items": { + "type": "string" + }, + "type": "array" + }, + "monetization": { + "default": null, + "description": "How the buyer thinks the artifact should be monetized.", + "type": [ + "string", + "null" + ] + }, + "product": { + "default": null, + "description": "What the buyer wants built.", + "type": [ + "string", + "null" + ] + }, + "query": { + "description": "The failed catalog search — the demand signal itself.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "query", + "buyer_npub" + ], + "title": "RFQ submission", + "type": "object" +}