From a8d4599a03ab941683a3c10d889a40288cc7c5ac Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Fri, 21 Aug 2026 11:04:38 +0200 Subject: [PATCH] Replace sqlx with Diesel + diesel-async for compile-time-checked queries sqlx's query!/query_as! macros only check raw SQL strings against the live schema; Diesel's table!-derived DSL type-checks query structure itself at compile time. SQLite has no native async driver, so diesel-async wraps a blocking SqliteConnection via SyncConnectionWrapper, pooled with bb8. Migrations move from sqlx's single-file-per-migration format to Diesel's up.sql/down.sql pairs, run transactionally via diesel_migrations against a throwaway sync connection at boot (MigrationHarness needs a sync Connection), giving revertable migrations that sqlx::migrate! doesn't support. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 824 ++++-------------- Cargo.toml | 6 +- crates/db/Cargo.toml | 6 +- .../2026-08-21-000000_init/down.sql | 8 + .../up.sql} | 0 crates/db/src/lib.rs | 236 +++-- crates/db/src/schema.rs | 60 ++ crates/web/Cargo.toml | 2 - crates/web/src/server.rs | 24 +- crates/web/src/server/pipeline.rs | 26 +- 10 files changed, 412 insertions(+), 780 deletions(-) create mode 100644 crates/db/migrations/2026-08-21-000000_init/down.sql rename crates/db/migrations/{0001_init.sql => 2026-08-21-000000_init/up.sql} (100%) create mode 100644 crates/db/src/schema.rs diff --git a/Cargo.lock b/Cargo.lock index b4a2ed2..77ae511 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,15 +93,6 @@ dependencies = [ "tungstenite 0.27.0", ] -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -241,10 +232,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "base64ct" -version = "1.8.3" +name = "bb8" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +checksum = "457d7ed3f888dfd2c7af56d4975cade43c622f74bdcddfed6d4352f57acc6310" +dependencies = [ + "futures-util", + "parking_lot", + "portable-atomic", + "tokio", +] [[package]] name = "bitflags" @@ -407,12 +404,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-serialize" version = "0.7.2" @@ -591,21 +582,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - [[package]] name = "croner" version = "2.2.0" @@ -615,15 +591,6 @@ dependencies = [ "chrono", ] -[[package]] -name = "crossbeam-queue" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -666,6 +633,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", + "strsim", "syn 2.0.119", ] @@ -700,17 +668,6 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - [[package]] name = "deranged" version = "0.5.8" @@ -740,6 +697,67 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diesel" +version = "2.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "715377c6e464cb44bb89bd8487584240516c8d5052bc645d6babc50bb8be46c3" +dependencies = [ + "chrono", + "diesel_derives", + "downcast-rs", + "libsqlite3-sys", + "sqlite-wasm-rs", + "time", +] + +[[package]] +name = "diesel-async" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd39af30158d444884f166fe4c58f35dc40ad71ad017bb59408a3448526ff4bd" +dependencies = [ + "bb8", + "diesel", + "futures-core", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "diesel_derives" +version = "2.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "diesel_migrations" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -747,9 +765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid", "crypto-common", - "subtle", ] [[package]] @@ -1367,10 +1383,24 @@ dependencies = [ ] [[package]] -name = "dotenvy" -version = "0.15.7" +name = "downcast-rs" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling", + "either", + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "dunce" @@ -1389,9 +1419,6 @@ name = "either" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" -dependencies = [ - "serde", -] [[package]] name = "encoding_rs" @@ -1439,17 +1466,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "etcetera" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.48.0", -] - [[package]] name = "euclid" version = "0.22.14" @@ -1460,16 +1476,6 @@ dependencies = [ "serde", ] -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "parking", - "pin-project-lite", -] - [[package]] name = "eventsource-stream" version = "0.2.3" @@ -1519,11 +1525,15 @@ name = "feedsignal-db" version = "0.1.0" dependencies = [ "anyhow", + "bb8", "chrono", + "diesel", + "diesel-async", + "diesel_migrations", "feedsignal-core", + "libsqlite3-sys", "serde", "serde_json", - "sqlx", "tokio", "uuid", ] @@ -1564,7 +1574,6 @@ dependencies = [ "feedsignal-llm", "serde", "serde_json", - "sqlx", "tokio", "tokio-cron-scheduler", "tracing", @@ -1578,29 +1587,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1664,17 +1656,6 @@ dependencies = [ "futures-util", ] -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - [[package]] name = "futures-io" version = "0.3.34" @@ -1890,17 +1871,6 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -1909,7 +1879,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1918,15 +1888,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "headers" version = "0.4.1" @@ -1957,39 +1918,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "http" version = "1.5.0" @@ -2410,9 +2338,6 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] [[package]] name = "libc" @@ -2430,24 +2355,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" -dependencies = [ - "bitflags", - "libc", - "plain", - "redox_syscall 0.9.2", -] - [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -2583,16 +2490,6 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - [[package]] name = "mediatype" version = "0.21.0" @@ -2626,6 +2523,27 @@ dependencies = [ "libc", ] +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + [[package]] name = "mime" version = "0.3.17" @@ -2725,22 +2643,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.7", - "smallvec", - "zeroize", -] - [[package]] name = "num-conv" version = "0.2.2" @@ -2758,25 +2660,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -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-traits" version = "0.2.19" @@ -2784,7 +2667,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -2845,12 +2727,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -2869,20 +2745,11 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -2915,27 +2782,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.34" @@ -2943,10 +2789,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] -name = "plain" -version = "0.2.3" +name = "portable-atomic" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" @@ -3106,24 +2952,13 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] @@ -3138,16 +2973,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - [[package]] name = "rand_chacha" version = "0.9.0" @@ -3158,15 +2983,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "rand_core" version = "0.9.5" @@ -3206,15 +3022,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_syscall" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1c93da5bb2c5d4e6c0ef7abeead62c89169a0a4882bfb83ac892f2423aea2fe" -dependencies = [ - "bitflags", -] - [[package]] name = "ref-cast" version = "1.0.27" @@ -3415,23 +3222,13 @@ dependencies = [ ] [[package]] -name = "rsa" -version = "0.9.10" +name = "rsqlite-vfs" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", + "hashbrown 0.16.1", + "thiserror 2.0.20", ] [[package]] @@ -3741,6 +3538,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3800,16 +3606,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[package]] name = "simd_cesu8" version = "1.2.0" @@ -3882,9 +3678,6 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -dependencies = [ - "serde", -] [[package]] name = "socket2" @@ -3901,214 +3694,17 @@ name = "spin" version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" -dependencies = [ - "lock_api", -] [[package]] -name = "spki" -version = "0.7.3" +name = "sqlite-wasm-rs" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink", - "indexmap", - "log", - "memchr", - "once_cell", - "percent-encoding", - "serde", - "serde_json", - "sha2", - "smallvec", - "thiserror 2.0.20", - "tokio", - "tokio-stream", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.119", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn 2.0.119", - "tokio", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "bytes", - "chrono", - "crc", - "digest", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "percent-encoding", - "rand 0.8.7", - "rsa", - "serde", - "sha1", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.20", - "tracing", - "uuid", - "whoami", -] - -[[package]] -name = "sqlx-postgres" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand 0.8.7", - "serde", - "serde_json", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.20", - "tracing", - "uuid", - "whoami", -] - -[[package]] -name = "sqlx-sqlite" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" -dependencies = [ - "atoi", - "chrono", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror 2.0.20", - "tracing", - "url", - "uuid", + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", ] [[package]] @@ -4118,15 +3714,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "stringprep" -version = "0.1.5" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subsecond" @@ -4438,6 +4029,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4454,7 +4067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.4", ] @@ -4678,33 +4291,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4834,12 +4426,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -4968,16 +4554,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] - [[package]] name = "winapi-util" version = "0.1.11" @@ -5066,15 +4642,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -5108,21 +4675,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -5145,12 +4697,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5163,12 +4709,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5181,12 +4721,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5205,12 +4739,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5223,12 +4751,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5241,12 +4763,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5259,12 +4775,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index bc6537d..d3b9ca3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,11 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde", "js"] } -sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono", "uuid", "migrate"] } +diesel = { version = "2.3", features = ["sqlite", "chrono"] } +diesel-async = { version = "0.9", features = ["sqlite", "bb8"] } +diesel_migrations = { version = "2.3", features = ["sqlite"] } +bb8 = "0.9" +libsqlite3-sys = { version = "0.30", features = ["bundled"] } feedsignal-core = { path = "crates/core" } feedsignal-db = { path = "crates/db" } diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index 4b18119..eb79846 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -6,7 +6,11 @@ license.workspace = true [dependencies] feedsignal-core.workspace = true -sqlx.workspace = true +diesel.workspace = true +diesel-async.workspace = true +diesel_migrations.workspace = true +bb8.workspace = true +libsqlite3-sys.workspace = true tokio.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/db/migrations/2026-08-21-000000_init/down.sql b/crates/db/migrations/2026-08-21-000000_init/down.sql new file mode 100644 index 0000000..ba5546e --- /dev/null +++ b/crates/db/migrations/2026-08-21-000000_init/down.sql @@ -0,0 +1,8 @@ +DROP TABLE preferences; +DROP TABLE topic_affinities; +DROP INDEX idx_reading_events_article_id; +DROP TABLE reading_events; +DROP INDEX idx_articles_final_score; +DROP INDEX idx_articles_feed_id; +DROP TABLE articles; +DROP TABLE feeds; diff --git a/crates/db/migrations/0001_init.sql b/crates/db/migrations/2026-08-21-000000_init/up.sql similarity index 100% rename from crates/db/migrations/0001_init.sql rename to crates/db/migrations/2026-08-21-000000_init/up.sql diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 40ffa81..84dcc11 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -1,122 +1,206 @@ +mod schema; + use anyhow::Result; +use chrono::Utc; +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; +use diesel_async::pooled_connection::bb8::Pool; +use diesel_async::pooled_connection::AsyncDieselConnectionManager; +use diesel_async::sync_connection_wrapper::SyncConnectionWrapper; +use diesel_async::RunQueryDsl; +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use feedsignal_core::{Article, ReadingEvent, ReadingOutcome, TopicAffinities}; -use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; use uuid::Uuid; +const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations"); + +/// SQLite has no native async driver, so `diesel-async` wraps a blocking +/// `SqliteConnection` and runs it on a blocking thread under the hood. +type AsyncSqliteConnection = SyncConnectionWrapper; + #[derive(Clone)] pub struct Db { - pool: SqlitePool, + pool: Pool, } impl Db { - /// `path` e.g. "sqlite://feedsignal.db?mode=rwc" — everything lives in - /// one file, no separate database server to run. - pub async fn connect(url: &str) -> Result { - let pool = SqlitePoolOptions::new().max_connections(5).connect(url).await?; - sqlx::migrate!("./migrations").run(&pool).await?; + /// `database_url` is a plain SQLite file path (e.g. "feedsignal.db"), + /// created if it doesn't exist. Everything lives in one file, no + /// separate database server to run. + pub async fn connect(database_url: &str) -> Result { + // `MigrationHarness` only works on a synchronous `Connection`, and + // this only runs once at boot, so open a throwaway sync connection + // just for it rather than pulling migrations through the async pool. + // Each pending migration runs in its own transaction (SQLite + // supports transactional DDL), and `down.sql` gives each one a + // matching revert. + let url = database_url.to_string(); + tokio::task::spawn_blocking(move || -> Result<()> { + let mut conn = SqliteConnection::establish(&url)?; + conn.run_pending_migrations(MIGRATIONS).map_err(|e| anyhow::anyhow!(e))?; + Ok(()) + }) + .await??; + + let manager = AsyncDieselConnectionManager::::new(database_url); + let pool = Pool::builder().max_size(5).build(manager).await?; Ok(Self { pool }) } pub async fn upsert_feed(&self, url: &str, title: &str) -> Result { - let id = Uuid::new_v4(); - sqlx::query( - "INSERT INTO feeds (id, url, title) VALUES (?, ?, ?) - ON CONFLICT(url) DO UPDATE SET title = excluded.title", - ) - .bind(id.to_string()) - .bind(url) - .bind(title) - .execute(&self.pool) - .await?; - let row: (String,) = sqlx::query_as("SELECT id FROM feeds WHERE url = ?") - .bind(url) - .fetch_one(&self.pool) + use schema::feeds::dsl; + let mut conn = self.pool.get().await?; + let new_id = Uuid::new_v4().to_string(); + diesel::insert_into(dsl::feeds) + .values((dsl::id.eq(&new_id), dsl::url.eq(url), dsl::title.eq(title))) + .on_conflict(dsl::url) + .do_update() + .set(dsl::title.eq(title)) + .execute(&mut conn) .await?; - Ok(Uuid::parse_str(&row.0)?) + let id: String = dsl::feeds.filter(dsl::url.eq(url)).select(dsl::id).first(&mut conn).await?; + Ok(Uuid::parse_str(&id)?) } pub async fn insert_article(&self, article: &Article) -> Result<()> { + use schema::articles::dsl; + let mut conn = self.pool.get().await?; let topics = serde_json::to_string(&article.topics)?; - sqlx::query( - "INSERT OR IGNORE INTO articles - (id, feed_id, url, title, summary, fetched_at, topics, embedding_score, llm_score, final_score, estimated_read_seconds) - VALUES (?, ?, ?, ?, ?, datetime('now'), ?, ?, ?, ?, ?)", - ) - .bind(article.id.to_string()) - .bind(article.feed_id.to_string()) - .bind(&article.url) - .bind(&article.title) - .bind(&article.summary) - .bind(topics) - .bind(article.embedding_score) - .bind(article.llm_score) - .bind(article.final_score) - .bind(article.estimated_read_seconds) - .execute(&self.pool) - .await?; + diesel::insert_into(dsl::articles) + .values(( + dsl::id.eq(article.id.to_string()), + dsl::feed_id.eq(article.feed_id.to_string()), + dsl::url.eq(&article.url), + dsl::title.eq(&article.title), + dsl::summary.eq(&article.summary), + dsl::fetched_at.eq(Utc::now().to_rfc3339()), + dsl::topics.eq(topics), + dsl::embedding_score.eq(article.embedding_score), + dsl::llm_score.eq(article.llm_score), + dsl::final_score.eq(article.final_score), + dsl::estimated_read_seconds.eq(article.estimated_read_seconds.map(|v| v as i32)), + )) + .on_conflict_do_nothing() + .execute(&mut conn) + .await?; Ok(()) } /// Articles above the embedding-similarity threshold that haven't been /// through the (slower) LLM scoring stage yet. pub async fn shortlist_for_llm_scoring(&self, threshold: f32, limit: i64) -> Result> { - let rows: Vec<(String,)> = sqlx::query_as( - "SELECT id FROM articles - WHERE embedding_score >= ? AND llm_score IS NULL - ORDER BY embedding_score DESC - LIMIT ?", - ) - .bind(threshold) - .bind(limit) - .fetch_all(&self.pool) - .await?; - rows.into_iter().map(|(s,)| Ok(Uuid::parse_str(&s)?)).collect() + use schema::articles::dsl; + let mut conn = self.pool.get().await?; + let ids: Vec = dsl::articles + .filter(dsl::embedding_score.ge(threshold)) + .filter(dsl::llm_score.is_null()) + .order(dsl::embedding_score.desc()) + .limit(limit) + .select(dsl::id) + .load(&mut conn) + .await?; + ids.into_iter().map(|s| Ok(Uuid::parse_str(&s)?)).collect() + } + + /// Title/summary/topics/embedding_score for one article, used to build + /// the LLM-judging prompt for it. + pub async fn article_scoring_fields(&self, article_id: Uuid) -> Result, f32)>> { + use schema::articles::dsl; + let mut conn = self.pool.get().await?; + let row: Option<(String, String, String, Option)> = dsl::articles + .filter(dsl::id.eq(article_id.to_string())) + .select((dsl::title, dsl::summary, dsl::topics, dsl::embedding_score)) + .first(&mut conn) + .await + .optional()?; + Ok(match row { + Some((title, summary, topics_json, score)) => { + let topics: Vec = serde_json::from_str(&topics_json).unwrap_or_default(); + Some((title, summary, topics, score.unwrap_or(0.0))) + } + None => None, + }) + } + + pub async fn store_llm_result(&self, article_id: Uuid, llm_score: f32, rationale: &str, final_score: f32) -> Result<()> { + use schema::articles::dsl; + let mut conn = self.pool.get().await?; + diesel::update(dsl::articles.filter(dsl::id.eq(article_id.to_string()))) + .set((dsl::llm_score.eq(llm_score), dsl::llm_rationale.eq(rationale), dsl::final_score.eq(final_score))) + .execute(&mut conn) + .await?; + Ok(()) + } + + /// Highest-ranked articles for display, most relevant first. + pub async fn list_ranked_articles(&self, limit: i64) -> Result, Option)>> { + use schema::articles::dsl; + let mut conn = self.pool.get().await?; + // SQLite sorts NULL before any value, so `DESC` already puts NULL + // `final_score`s last — no separate NULLS LAST clause needed here. + let rows: Vec<(String, String, String, String, String, Option)> = dsl::articles + .order(dsl::final_score.desc()) + .limit(limit) + .select((dsl::id, dsl::title, dsl::url, dsl::summary, dsl::topics, dsl::final_score)) + .load(&mut conn) + .await?; + Ok(rows + .into_iter() + .map(|(id, title, url, summary, topics_json, final_score)| { + (id, title, url, summary, serde_json::from_str(&topics_json).unwrap_or_default(), final_score) + }) + .collect()) } pub async fn record_event(&self, event: &ReadingEvent) -> Result<()> { + use schema::reading_events::dsl; + let mut conn = self.pool.get().await?; let (outcome, dwell) = match &event.outcome { ReadingOutcome::Impression => ("impression", None), - ReadingOutcome::Opened { dwell_seconds } => ("opened", *dwell_seconds), + ReadingOutcome::Opened { dwell_seconds } => ("opened", dwell_seconds.map(|v| v as i32)), ReadingOutcome::Starred => ("starred", None), ReadingOutcome::Dismissed => ("dismissed", None), }; - sqlx::query( - "INSERT INTO reading_events (id, article_id, occurred_at, outcome, dwell_seconds) - VALUES (?, ?, ?, ?, ?)", - ) - .bind(event.id.to_string()) - .bind(event.article_id.to_string()) - .bind(event.occurred_at.to_rfc3339()) - .bind(outcome) - .bind(dwell) - .execute(&self.pool) - .await?; + diesel::insert_into(dsl::reading_events) + .values(( + dsl::id.eq(event.id.to_string()), + dsl::article_id.eq(event.article_id.to_string()), + dsl::occurred_at.eq(event.occurred_at.to_rfc3339()), + dsl::outcome.eq(outcome), + dsl::dwell_seconds.eq(dwell), + )) + .execute(&mut conn) + .await?; Ok(()) } pub async fn load_affinities(&self) -> Result { - let row: Option<(String,)> = sqlx::query_as("SELECT affinities FROM topic_affinities WHERE user_id = 'default'") - .fetch_optional(&self.pool) - .await?; + use schema::topic_affinities::dsl; + let mut conn = self.pool.get().await?; + let row: Option = dsl::topic_affinities + .filter(dsl::user_id.eq("default")) + .select(dsl::affinities) + .first(&mut conn) + .await + .optional()?; Ok(match row { - Some((json,)) => serde_json::from_str(&json)?, + Some(json) => serde_json::from_str(&json)?, None => TopicAffinities::default(), }) } pub async fn save_affinities(&self, affinities: &TopicAffinities) -> Result<()> { + use schema::topic_affinities::dsl; + let mut conn = self.pool.get().await?; let json = serde_json::to_string(affinities)?; - sqlx::query( - "INSERT INTO topic_affinities (user_id, affinities, updated_at) VALUES ('default', ?, datetime('now')) - ON CONFLICT(user_id) DO UPDATE SET affinities = excluded.affinities, updated_at = excluded.updated_at", - ) - .bind(json) - .execute(&self.pool) - .await?; + let updated_at = Utc::now().to_rfc3339(); + diesel::insert_into(dsl::topic_affinities) + .values((dsl::user_id.eq("default"), dsl::affinities.eq(&json), dsl::updated_at.eq(&updated_at))) + .on_conflict(dsl::user_id) + .do_update() + .set((dsl::affinities.eq(&json), dsl::updated_at.eq(&updated_at))) + .execute(&mut conn) + .await?; Ok(()) } - - pub fn pool(&self) -> &SqlitePool { - &self.pool - } } diff --git a/crates/db/src/schema.rs b/crates/db/src/schema.rs new file mode 100644 index 0000000..8b5fe2a --- /dev/null +++ b/crates/db/src/schema.rs @@ -0,0 +1,60 @@ +// Hand-maintained to match `migrations/2026-08-21-000000_init/up.sql`. Diesel +// checks every query in `lib.rs` against these table! definitions at compile +// time, so a query referencing a dropped column or wrong type fails to build +// rather than failing at runtime. +// +// id/date columns are plain `Text` (not a native Uuid/DateTime SQL type): +// Diesel's SQLite backend has no built-in UUID or timestamp type (that's +// Postgres-only), so ids are stored as UUID strings and dates as RFC3339 +// strings, parsed at the Rust boundary in `lib.rs`. + +diesel::table! { + feeds (id) { + id -> Text, + url -> Text, + title -> Text, + last_fetched_at -> Nullable, + } +} + +diesel::table! { + articles (id) { + id -> Text, + feed_id -> Text, + url -> Text, + title -> Text, + summary -> Text, + content -> Nullable, + published_at -> Nullable, + fetched_at -> Text, + topics -> Text, + embedding -> Nullable, + embedding_score -> Nullable, + llm_score -> Nullable, + llm_rationale -> Nullable, + final_score -> Nullable, + estimated_read_seconds -> Nullable, + } +} + +diesel::table! { + reading_events (id) { + id -> Text, + article_id -> Text, + occurred_at -> Text, + outcome -> Text, + dwell_seconds -> Nullable, + } +} + +diesel::table! { + topic_affinities (user_id) { + user_id -> Text, + affinities -> Text, + updated_at -> Text, + } +} + +diesel::joinable!(articles -> feeds (feed_id)); +diesel::joinable!(reading_events -> articles (article_id)); +diesel::allow_tables_to_appear_in_same_query!(feeds, articles, reading_events, topic_affinities,); diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 8cbc870..a885fae 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -19,7 +19,6 @@ tokio = { workspace = true, optional = true } tracing = { workspace = true, optional = true } tracing-subscriber = { workspace = true, optional = true } anyhow = { workspace = true, optional = true } -sqlx = { workspace = true, optional = true } uuid = { workspace = true, optional = true } chrono = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } @@ -37,7 +36,6 @@ server = [ "dep:tracing", "dep:tracing-subscriber", "dep:anyhow", - "dep:sqlx", "dep:uuid", "dep:chrono", "dep:serde_json", diff --git a/crates/web/src/server.rs b/crates/web/src/server.rs index 5291986..dd83db1 100644 --- a/crates/web/src/server.rs +++ b/crates/web/src/server.rs @@ -35,7 +35,7 @@ async fn ensure_background_jobs_started(db: Arc) { pub async fn db() -> Result { // TODO: hold this in a `OnceCell`/app-wide state instead of reconnecting // per request once the server-state story is wired up. - let db = Db::connect("sqlite://feedsignal.db?mode=rwc") + let db = Db::connect("feedsignal.db") .await .map_err(|e| dioxus::prelude::ServerFnError::new(e.to_string()))?; ensure_background_jobs_started(Arc::new(db.clone())).await; @@ -43,25 +43,11 @@ pub async fn db() -> Result { } pub async fn list_ranked_articles_impl(db: Db) -> Result> { - let rows: Vec<(String, String, String, String, String, Option)> = sqlx::query_as( - "SELECT id, title, url, summary, topics, final_score - FROM articles - ORDER BY final_score DESC NULLS LAST - LIMIT 100", - ) - .fetch_all(db.pool()) - .await?; - - Ok(rows + Ok(db + .list_ranked_articles(100) + .await? .into_iter() - .map(|(id, title, url, summary, topics_json, final_score)| ArticleView { - id, - title, - url, - summary, - topics: serde_json::from_str(&topics_json).unwrap_or_default(), - final_score, - }) + .map(|(id, title, url, summary, topics, final_score)| ArticleView { id, title, url, summary, topics, final_score }) .collect()) } diff --git a/crates/web/src/server/pipeline.rs b/crates/web/src/server/pipeline.rs index 7696c87..4abfe27 100644 --- a/crates/web/src/server/pipeline.rs +++ b/crates/web/src/server/pipeline.rs @@ -72,10 +72,9 @@ async fn run_scoring_pipeline(db: &Db, llm: &Llm) -> Result<()> { for article_id in shortlist { // Fetch just the fields needed for the prompt; a real implementation // would batch this rather than one query per article. - let Some((title, summary, topics_json, embedding_score)) = fetch_article_fields(db, article_id).await? else { + let Some((title, summary, topics, embedding_score)) = db.article_scoring_fields(article_id).await? else { continue; }; - let topics: Vec = serde_json::from_str(&topics_json).unwrap_or_default(); let judgment = llm .judge_relevance( @@ -93,33 +92,12 @@ async fn run_scoring_pipeline(db: &Db, llm: &Llm) -> Result<()> { affinities: &affinities, }); - store_llm_result(db, article_id, judgment.score, &judgment.rationale, final_score).await?; + db.store_llm_result(article_id, judgment.score, &judgment.rationale, final_score).await?; } Ok(()) } -async fn fetch_article_fields(db: &Db, article_id: uuid::Uuid) -> Result> { - let row: Option<(String, String, String, Option)> = sqlx::query_as( - "SELECT title, summary, topics, embedding_score FROM articles WHERE id = ?", - ) - .bind(article_id.to_string()) - .fetch_optional(db.pool()) - .await?; - Ok(row.map(|(t, s, topics, score)| (t, s, topics, score.unwrap_or(0.0)))) -} - -async fn store_llm_result(db: &Db, article_id: uuid::Uuid, llm_score: f32, rationale: &str, final_score: f32) -> Result<()> { - sqlx::query("UPDATE articles SET llm_score = ?, llm_rationale = ?, final_score = ? WHERE id = ?") - .bind(llm_score) - .bind(rationale) - .bind(final_score) - .bind(article_id.to_string()) - .execute(db.pool()) - .await?; - Ok(()) -} - async fn decay_affinities(db: &Db) -> Result<()> { let mut affinities = db.load_affinities().await?; affinities.decay();