From 484aaa184902813d99ed99882df99e2dbdcc9c87 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 18 Sep 2026 18:11:28 +0000 Subject: [PATCH] SeaORM beside rusqlite: entities, schema sync, a second connection The first step of moving to SeaORM (#18, phase 1). Nothing a user sees changes. - src/entity.rs: the seven tables as SeaORM entities, matching the SQLite schema. Strings are Text, as the columns are; yes/no columns are bool, which is BOOLEAN on Postgres and stays INTEGER in the existing SQLite file (sync notes the difference and leaves it alone). - Db holds a SeaORM connection to the same SQLite file beside the rusqlite one; functions move to it one at a time, and rusqlite goes with the last of them. - db::sync creates what a database is missing from the entities (SeaORM's schema-sync, experimental, so sea-orm is pinned to ~2.0), plus the two indexes an entity cannot express. Checked against a copy of production: it added the lower(name) index and changed nothing else. - Test databases are now built from the entities alone, in a temporary file (two connections to one ":memory:" are two databases), so every test also checks that the entities describe what the queries need. That caught the one difference: finding a user by name relied on COLLATE NOCASE, which Postgres lacks; it now compares lower() on both sides. - rusqlite steps back to 0.39: 0.40's libsqlite3-sys is newer than sqlx accepts, and only one may link SQLite. It goes away at the end of this phase. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 859 +++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 3 +- src/db.rs | 191 +++++++---- src/entity.rs | 224 ++++++++++++ src/main.rs | 29 +- src/retention.rs | 12 +- 6 files changed, 1213 insertions(+), 105 deletions(-) create mode 100644 src/entity.rs diff --git a/Cargo.lock b/Cargo.lock index 189bb90..ca082d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,7 @@ checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.3.1", "password-hash", ] @@ -181,6 +181,15 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atoi" version = "3.1.0" @@ -344,6 +353,20 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -355,6 +378,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "bitvec" @@ -374,7 +400,16 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest", + "digest 0.11.3", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.9", ] [[package]] @@ -386,6 +421,30 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "bs58" version = "0.5.1" @@ -455,7 +514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -512,7 +571,7 @@ version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 3.0.5", @@ -613,6 +672,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -622,6 +690,21 @@ 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 = "crc32fast" version = "1.5.1" @@ -631,12 +714,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array 0.14.9", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -796,6 +898,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive-where" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -827,14 +940,46 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.12.1", + "crypto-common 0.2.2", "ctutils", ] @@ -891,6 +1036,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dtoa" version = "1.0.11" @@ -923,6 +1074,9 @@ name = "either" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] [[package]] name = "encoding_rs" @@ -955,6 +1109,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[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 = "fallible-iterator" version = "0.3.0" @@ -990,6 +1164,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1065,6 +1250,17 @@ 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" @@ -1126,6 +1322,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1274,19 +1480,22 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "foldhash", -] [[package]] name = "hashlink" -version = "0.12.2" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.17.1", + "hashbrown 0.16.1", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -1299,6 +1508,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "html5ever" version = "0.39.0" @@ -1622,6 +1849,7 @@ dependencies = [ "reqwest", "rss", "rusqlite", + "sea-orm", "serde", "serde_json", "tokio", @@ -1639,6 +1867,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.15.0" @@ -1806,6 +2043,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[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.23" @@ -1838,7 +2081,7 @@ dependencies = [ "hex", "http", "intervaltree", - "itertools", + "itertools 0.15.0", "librqbit-bencode", "librqbit-buffers", "librqbit-clone-to-owned", @@ -1853,7 +2096,7 @@ dependencies = [ "librqbit-utp", "memmap2", "mime_guess", - "nix", + "nix 0.30.1", "parking_lot", "rand 0.10.2", "regex", @@ -1887,7 +2130,7 @@ checksum = "be6410a7434e6c2b148931b6d109b57939cfed04cefa86451197c5f5198edf91" dependencies = [ "anyhow", "arrayvec", - "atoi", + "atoi 3.1.0", "bytes", "librqbit-buffers", "librqbit-clone-to-owned", @@ -1930,7 +2173,7 @@ dependencies = [ "directories", "encoding_rs", "hex", - "itertools", + "itertools 0.15.0", "librqbit-bencode", "librqbit-buffers", "librqbit-clone-to-owned", @@ -2002,7 +2245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d1d2699a46a9dc83693c5fbbf288a17b77a24b9924815a69bfc97853a47af42" dependencies = [ "anyhow", - "atoi", + "atoi 3.1.0", "bstr", "futures", "httparse", @@ -2025,7 +2268,7 @@ dependencies = [ "bitvec", "byteorder", "bytes", - "itertools", + "itertools 0.15.0", "librqbit-bencode", "librqbit-buffers", "librqbit-clone-to-owned", @@ -2057,7 +2300,7 @@ dependencies = [ "backon", "byteorder", "futures", - "itertools", + "itertools 0.15.0", "librqbit-bencode", "librqbit-buffers", "librqbit-core", @@ -2122,9 +2365,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.38.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -2158,6 +2401,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "serde", + "winapi", +] + [[package]] name = "maplit" version = "1.0.2" @@ -2190,6 +2444,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" @@ -2205,6 +2469,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "metrics" version = "0.24.6" @@ -2297,6 +2570,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" @@ -2337,6 +2623,16 @@ dependencies = [ "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-complex" version = "0.2.4" @@ -2427,6 +2723,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +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" @@ -2466,6 +2777,15 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pgvector" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3673cba5b9a124916096a423b806a9f29620972c6c97b08db5f2053e9428b481" +dependencies = [ + "serde", +] + [[package]] name = "phc" version = "0.6.1" @@ -2528,6 +2848,16 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "pluralizer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3eba432a00a1f6c16f39147847a870e94e2e9b992759b503e330efec778cbe" +dependencies = [ + "once_cell", + "regex", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2573,6 +2903,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2701,13 +3040,24 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +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", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -2722,6 +3072,16 @@ 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" @@ -2732,6 +3092,15 @@ 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" @@ -2943,9 +3312,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.40.2" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ "bitflags 2.13.1", "fallible-iterator", @@ -2956,6 +3325,23 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2979,6 +3365,7 @@ checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3106,6 +3493,129 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sea-bae" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "260bbc7148a8d6818ac5032a1b9970da1a68bdd723d45b12d59f7c1bf3565e24" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sea-orm" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01d46a6e22b8ce84aba64fe97011669859bc7f7120a47f5b58839dcaaa4545c" +dependencies = [ + "async-stream", + "async-trait", + "bigdecimal", + "chrono", + "derive-where", + "derive_more", + "futures-util", + "itertools 0.14.0", + "log", + "mac_address", + "pgvector", + "rust_decimal", + "sea-orm-macros", + "sea-query", + "sea-query-sqlx", + "sea-schema", + "serde", + "serde_json", + "sqlx", + "sqlx-core", + "strum", + "thiserror 2.0.20", + "time", + "tracing", + "url", + "uuid", + "web-time", +] + +[[package]] +name = "sea-orm-macros" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24c82fc1e76c014dffe5ecdac8f654da73161d703a16ea13414581e07459c83" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "pluralizer", + "proc-macro2", + "quote", + "sea-bae", + "syn 2.0.119", + "unicode-ident", +] + +[[package]] +name = "sea-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31" +dependencies = [ + "ordered-float", + "sea-query-derive", + "serde_json", +] + +[[package]] +name = "sea-query-derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537" +dependencies = [ + "darling 0.20.11", + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.20", +] + +[[package]] +name = "sea-query-sqlx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eaa419cdb9157da1361186b1959983eb2ea0dcb9a3c69dc45c449ecb2af8fef" +dependencies = [ + "sea-query", + "sqlx", +] + +[[package]] +name = "sea-schema" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3553c77dceed56e95bece9ea876c4dd67ca879ef51055a0b97a7bb89a8ae4fed" +dependencies = [ + "async-trait", + "sea-query", + "sea-query-sqlx", + "sea-schema-derive", + "sqlx", +] + +[[package]] +name = "sea-schema-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdc8729c37fdbf88472f97fd470393089f997a909e535ff67c544d18cfccf0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3256,6 +3766,39 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3315,7 +3858,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ed5f6ab2122c6dec69dca18c72fa4590a27e581ad20d44960fe74c032a0b23b" dependencies = [ - "generic-array", + "generic-array 0.12.4", "num", ] @@ -3330,6 +3873,9 @@ name = "smallvec" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -3341,6 +3887,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "spinning_top" version = "0.3.0" @@ -3362,6 +3917,178 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap 2.14.2", + "log", + "memchr", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "bytes", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array 0.14.9", + "log", + "percent-encoding", + "serde", + "sha1", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi 2.0.0", + "base64 0.22.1", + "bitflags 2.13.1", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi 2.0.0", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.20", + "tracing", + "url", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3392,12 +4119,29 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + [[package]] name = "subtle" version = "2.6.1" @@ -3696,6 +4440,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" @@ -3873,12 +4629,39 @@ 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-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.7.1" @@ -3930,6 +4713,7 @@ checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -3945,6 +4729,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -3988,6 +4778,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -4088,6 +4879,21 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + [[package]] name = "winapi" version = "0.3.9" @@ -4327,6 +5133,9 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" diff --git a/Cargo.toml b/Cargo.toml index 8462ffc..847a9a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,8 @@ percent-encoding = "2.3.2" quick-xml = { version = "0.42.0", features = ["escape-html"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] } rss = "2.1.1" -rusqlite = { version = "0.40.2", features = ["bundled"] } +rusqlite = { version = "0.39", features = ["bundled"] } +sea-orm = { version = "~2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "schema-sync", "sqlite-use-returning-for-3_35"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] } diff --git a/src/db.rs b/src/db.rs index e891c22..13ff5ca 100644 --- a/src/db.rs +++ b/src/db.rs @@ -7,8 +7,63 @@ use std::sync::Mutex; /// ponytail: one global connection mutex. Writes here are tiny and rare; move to a /// spawn_blocking pool if a large feed count ever makes it contend. +/// +/// Mid-way through moving to SeaORM (issue #18): `orm` is the new connection, to the same +/// SQLite file, and functions move to it one at a time; `conn` goes when the last one has. pub struct Db { conn: Mutex, + orm: sea_orm::DatabaseConnection, + /// A test's database file, removed when the test is done with it. + #[cfg(test)] + tmp: Option, +} + +#[cfg(test)] +impl Drop for Db { + fn drop(&mut self) { + if let Some(p) = &self.tmp { + for ext in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{ext}", p.display())); + } + } + } +} + +/// Creates whatever the database is missing from the entities in `crate::entity`: tables, +/// columns, unique keys, foreign keys. It only ever adds. What an entity cannot say -- an +/// index on an expression, or on two columns -- follows as plain SQL both databases accept. +/// +/// ponytail: schema sync is experimental in SeaORM 2 and exempt from semver, hence the +/// `~2.0` pin in Cargo.toml; if it changes shape, move to sea-orm-migration files. +async fn sync(orm: &sea_orm::DatabaseConnection) -> Result<()> { + use crate::entity::*; + use sea_orm::ConnectionTrait; + orm.get_schema_builder() + .register(feeds::Entity) + .register(entries::Entity) + .register(enclosures::Entity) + .register(users::Entity) + .register(subscriptions::Entity) + .register(entry_state::Entity) + .register(sessions::Entity) + .sync(orm) + .await + .context("creating the schema")?; + for sql in [ + "CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid)", + "CREATE UNIQUE INDEX IF NOT EXISTS users_name_lower ON users (lower(name))", + ] { + orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; + } + Ok(()) +} + +async fn connect(path: &Path) -> Result { + let mut opts = sea_orm::ConnectOptions::new(format!("sqlite://{}?mode=rwc", path.display())); + opts.sqlx_logging(false); + sea_orm::Database::connect(opts) + .await + .with_context(|| format!("opening {}", path.display())) } const SCHEMA: &str = " @@ -243,7 +298,7 @@ pub struct FeedSummary { } impl Db { - pub fn open(path: &Path) -> Result { + pub async fn open(path: &Path) -> Result { if let Some(dir) = path.parent() { std::fs::create_dir_all(dir) .with_context(|| format!("creating {}", dir.display()))?; @@ -255,18 +310,33 @@ impl Db { conn.pragma_update(None, "busy_timeout", 5000)?; conn.execute_batch(SCHEMA).context("creating schema")?; migrate(&conn).context("migrating schema")?; - Ok(Self { conn: Mutex::new(conn) }) + let orm = connect(path).await?; + sync(&orm).await?; + Ok(Self { + conn: Mutex::new(conn), + orm, + #[cfg(test)] + tmp: None, + }) } - /// In-memory database, for tests. + /// A fresh database for a test, in a file of its own: two connections to one ":memory:" + /// would be two databases. Its schema comes from the entities alone (`sync`), not SCHEMA, + /// so every test also checks that the entities describe everything the queries need. #[cfg(test)] - pub fn memory() -> Result { - let conn = Connection::open_in_memory()?; - conn.execute_batch(SCHEMA)?; - // Same path as a real open, so a column added only in migrate() cannot pass the - // tests while being missing in production (or the reverse). - migrate(&conn)?; - Ok(Self { conn: Mutex::new(conn) }) + pub async fn memory() -> Result { + static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let path = std::env::temp_dir().join(format!( + "ipx-test-{}-{}.db", + std::process::id(), + N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let orm = connect(&path).await?; + sync(&orm).await?; + let conn = Connection::open(&path)?; + conn.pragma_update(None, "foreign_keys", "ON")?; + conn.pragma_update(None, "busy_timeout", 5000)?; + Ok(Self { conn: Mutex::new(conn), orm, tmp: Some(path) }) } #[cfg(test)] @@ -1134,8 +1204,11 @@ impl Db { Ok(conn.last_insert_rowid()) } + /// Without regard to case, on either database: lower() both sides, which the unique index + /// on lower(name) serves. SQLite's COLLATE NOCASE on the column did this before, and + /// Postgres has no such thing. pub fn user_by_name(&self, name: &str) -> Result> { - self.one_user(&format!("SELECT {USER_COLS} FROM users WHERE name = ?1"), name) + self.one_user(&format!("SELECT {USER_COLS} FROM users WHERE lower(name) = lower(?1)"), name) } pub fn user_by_id(&self, id: i64) -> Result> { @@ -1594,9 +1667,9 @@ pub fn now() -> i64 { mod tests { use super::*; - #[test] - fn a_file_wordpress_listed_twice_is_folded_into_one() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn a_file_wordpress_listed_twice_is_folded_into_one() { + let db = Db::memory().await.unwrap(); db.exec_for_test( "INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES (1,'f','a','https://x/a.mp3','/d/a-2.mp3','done'), @@ -1652,9 +1725,9 @@ mod tests { assert_eq!(row(&conn).0.as_deref(), Some("e2")); } - #[test] - fn every_sort_column_runs_and_orders_both_ways() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn every_sort_column_runs_and_orders_both_ways() { + let db = Db::memory().await.unwrap(); db.exec_for_test( "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1); INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(1,'g'); @@ -1697,9 +1770,9 @@ mod tests { assert!(!order_sql("x'; --", "asc", false).contains("x'")); } - #[test] - fn deleting_a_shared_file_asks_about_everyone_else() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn deleting_a_shared_file_asks_about_everyone_else() { + let db = Db::memory().await.unwrap(); db.exec_for_test( "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0),(3,'kit',0); INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f'),(3,'f'); @@ -1723,9 +1796,9 @@ mod tests { assert_eq!(db.others_wanting(1, 3).unwrap(), (0, 0)); } - #[test] - fn read_state_belongs_to_one_person() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn read_state_belongs_to_one_person() { + let db = Db::memory().await.unwrap(); db.exec_for_test( "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0); INSERT INTO entries (feed_id, guid, title, first_seen) VALUES @@ -1760,9 +1833,9 @@ mod tests { assert_eq!(db.unread_count(1, "f").unwrap(), 1); } - #[test] - fn schema_is_idempotent_and_summary_handles_unknown_feeds() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn schema_is_idempotent_and_summary_handles_unknown_feeds() { + let db = Db::memory().await.unwrap(); // Re-running the schema must not fail: open() does this on every start. db.conn.lock().unwrap().execute_batch(SCHEMA).unwrap(); @@ -1826,9 +1899,9 @@ mod tests { assert_eq!(kept, 1); } - #[test] - fn a_renamed_account_keeps_everything_but_its_name() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn a_renamed_account_keeps_everything_but_its_name() { + let db = Db::memory().await.unwrap(); let ray = db.create_user("rays", None, true).unwrap(); db.create_user("sam", None, false).unwrap(); db.subscribe(ray, "f").unwrap(); @@ -1840,9 +1913,9 @@ mod tests { assert!(db.rename_user(ray, "sam").is_err(), "a taken name is refused"); } - #[test] - fn an_account_knows_when_it_was_made_and_last_signed_in() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn an_account_knows_when_it_was_made_and_last_signed_in() { + let db = Db::memory().await.unwrap(); let id = db.create_user("ray", None, true).unwrap(); let get = || db.user_by_id(id).unwrap().unwrap(); assert!(get().created.is_some_and(|t| t > 0)); @@ -1858,11 +1931,11 @@ mod tests { assert!(get().last_login.unwrap() >= first); } - #[test] - fn the_first_admin_starts_with_the_catalogue_and_only_once() { + #[tokio::test] + async fn the_first_admin_starts_with_the_catalogue_and_only_once() { // Cutting this along with the dead read columns left the browser suite's admin with an // empty sidebar: it is how a fresh install's first account gets config.toml's feeds. - let db = Db::memory().unwrap(); + let db = Db::memory().await.unwrap(); db.exec_for_test("INSERT INTO users (id, name, is_admin) VALUES (1,'admin',1);") .unwrap(); let subs = || -> i64 { @@ -1877,12 +1950,12 @@ mod tests { assert_eq!(subs(), 1); } - #[test] - fn every_filter_works_with_and_without_a_search_term() { + #[tokio::test] + async fn every_filter_works_with_and_without_a_search_term() { // Regression: the search clause used to be omitted when no term was given, while // ?2 was still bound -- rusqlite rejects a parameter the statement never mentions, // so plain filtering failed with "Wrong number of parameters passed to query". - let db = Db::memory().unwrap(); + let db = Db::memory().await.unwrap(); db.exec_for_test( "INSERT INTO entries (feed_id, guid, title, description, first_seen, duration) VALUES ('f','a','Alpha dive','notes one', 100,NULL), @@ -1952,10 +2025,10 @@ mod tests { assert_eq!(listening(), ["h", "e"]); } - #[test] - fn pending_takes_the_latest_episodes_first() { + #[tokio::test] + async fn pending_takes_the_latest_episodes_first() { // A cap of 3 must mean the three newest, not the three recorded first. - let db = Db::memory().unwrap(); + let db = Db::memory().await.unwrap(); db.exec_for_test( "INSERT INTO entries (feed_id, guid, published, first_seen) VALUES ('f','old',100,100), ('f','mid',200,200), ('f','new',300,300); @@ -1969,9 +2042,9 @@ mod tests { assert_eq!(got, vec!["u-new", "u-mid"], "newest first, oldest left for later"); } - #[test] - fn a_restart_requeues_interrupted_downloads() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn a_restart_requeues_interrupted_downloads() { + let db = Db::memory().await.unwrap(); db.exec_for_test( "INSERT INTO enclosures (id, feed_id, guid, url, state, path) VALUES (1,'f','a','u1','downloading',NULL), @@ -1990,9 +2063,9 @@ mod tests { assert_eq!(state(4), "done"); } - #[test] - fn a_show_takes_over_what_its_creator_held() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn a_show_takes_over_what_its_creator_held() { + let db = Db::memory().await.unwrap(); db.exec_for_test( "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1); INSERT INTO enclosures (id, feed_id, guid, url, state, path, last_error) VALUES @@ -2026,9 +2099,9 @@ mod tests { assert!(db.skipped_by_filter("other").unwrap().is_empty(), "torrents disabled is not a filter"); } - #[test] - fn a_feed_in_a_group_follows_your_settings_on_the_group() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn a_feed_in_a_group_follows_your_settings_on_the_group() { + let db = Db::memory().await.unwrap(); db.exec_for_test( "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0); INSERT INTO subscriptions (user_id, feed_id, allow_explicit) VALUES @@ -2045,9 +2118,9 @@ mod tests { assert_eq!(explicit(None), [None, Some(false)], "outside a group nothing is inherited"); } - #[test] - fn error_since_marks_the_start_of_a_run_of_failures_and_clears_on_success() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn error_since_marks_the_start_of_a_run_of_failures_and_clears_on_success() { + let db = Db::memory().await.unwrap(); db.set_feed_error("f", "http://x", "HTTP 404").unwrap(); // Backdate it, as if this feed had already been failing a while, so a second // failure landing "now" is distinguishable from the first. @@ -2065,9 +2138,9 @@ mod tests { assert_eq!(after.error_since, None, "a clean check ends the run of failures"); } - #[test] - fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() { + let db = Db::memory().await.unwrap(); let me = db.create_user("pat", None, false).unwrap(); assert!(!db.set_pinned(me, "f", true).unwrap(), "not subscribed: nothing to pin"); db.subscribe(me, "f").unwrap(); @@ -2080,9 +2153,9 @@ mod tests { assert!(db.pinned_feeds(me).unwrap().is_empty()); } - #[test] - fn enclosure_url_is_the_dedupe_key() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn enclosure_url_is_the_dedupe_key() { + let db = Db::memory().await.unwrap(); let conn = db.conn.lock().unwrap(); let insert = "INSERT INTO enclosures (feed_id, guid, url, state) VALUES ('f', 'g', 'http://x/a.mp3', 'pending')"; conn.execute(insert, []).unwrap(); diff --git a/src/entity.rs b/src/entity.rs new file mode 100644 index 0000000..f8a05cb --- /dev/null +++ b/src/entity.rs @@ -0,0 +1,224 @@ +//! The database's tables as SeaORM entities: the one description of the schema, from which +//! `Db::open` creates what a database is missing, on SQLite or Postgres alike (see `db::sync`). +//! What each column means is in the comments on `db::SCHEMA`, the SQLite schema these match. + +pub mod feeds { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "feeds")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false, column_type = "Text")] + pub id: String, + #[sea_orm(column_type = "Text")] + pub url: String, + #[sea_orm(column_type = "Text", nullable)] + pub title: Option, + #[sea_orm(column_type = "Text", nullable)] + pub image: Option, + #[sea_orm(column_type = "Text", nullable)] + pub category: Option, + #[sea_orm(column_type = "Text", nullable)] + pub etag: Option, + #[sea_orm(column_type = "Text", nullable)] + pub last_modified: Option, + pub last_checked: Option, + pub ttl_mins: Option, + #[sea_orm(column_type = "Text", nullable)] + pub last_error: Option, + pub error_since: Option, + #[sea_orm(default_value = false)] + pub orphaned: bool, + #[sea_orm(column_type = "Text", nullable)] + pub group_id: Option, + #[sea_orm(default_value = false)] + pub managed: bool, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} +} + +pub mod entries { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "entries")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false, column_type = "Text")] + pub feed_id: String, + #[sea_orm(primary_key, auto_increment = false, column_type = "Text")] + pub guid: String, + #[sea_orm(column_type = "Text", nullable)] + pub title: Option, + #[sea_orm(column_type = "Text", nullable)] + pub link: Option, + pub published: Option, + #[sea_orm(column_type = "Text", nullable)] + pub description: Option, + pub first_seen: i64, + #[sea_orm(column_type = "Text", nullable)] + pub image: Option, + pub duration: Option, + pub episode: Option, + pub season: Option, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} +} + +pub mod enclosures { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "enclosures")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + #[sea_orm(column_type = "Text")] + pub feed_id: String, + #[sea_orm(column_type = "Text")] + pub guid: String, + /// The dedupe key: one file serves every subscriber. + #[sea_orm(unique, column_type = "Text")] + pub url: String, + #[sea_orm(column_type = "Text", nullable)] + pub mime: Option, + pub length: Option, + #[sea_orm(column_type = "Text", nullable)] + pub path: Option, + #[sea_orm(column_type = "Text")] + pub state: String, + #[sea_orm(default_value = 0)] + pub bytes_done: i64, + pub downloaded_at: Option, + #[sea_orm(column_type = "Text", nullable)] + pub last_error: Option, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} +} + +pub mod users { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "users")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + /// Unique without regard to case: `db::sync` adds the index on lower(name), which + /// works the same on both databases where SQLite's COLLATE NOCASE does not. + #[sea_orm(column_type = "Text")] + pub name: String, + #[sea_orm(column_type = "Text", nullable)] + pub pass_hash: Option, + #[sea_orm(default_value = false)] + pub is_admin: bool, + pub created: Option, + pub last_login: Option, + #[sea_orm(column_type = "Text", nullable)] + pub theme: Option, + #[sea_orm(column_type = "Text", nullable)] + pub theme_mode: Option, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} +} + +/// A table of one person's rows, gone when they are. +macro_rules! owned_by_user { + () => { + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UserId", + to = "super::users::Column::Id", + on_delete = "Cascade" + )] + User, + } + + impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } + } + + impl ActiveModelBehavior for ActiveModel {} + }; +} + +pub mod subscriptions { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "subscriptions")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: i64, + #[sea_orm(primary_key, auto_increment = false, column_type = "Text")] + pub feed_id: String, + /// JSON array of strings; NULL follows the feed. + #[sea_orm(column_type = "Text", nullable)] + pub keywords: Option, + pub auto_download: Option, + pub allow_explicit: Option, + pub max_new_per_check: Option, + #[sea_orm(default_value = false)] + pub pinned: bool, + } + + owned_by_user!(); +} + +pub mod entry_state { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "entry_state")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: i64, + #[sea_orm(primary_key, auto_increment = false, column_type = "Text")] + pub feed_id: String, + #[sea_orm(primary_key, auto_increment = false, column_type = "Text")] + pub guid: String, + #[sea_orm(default_value = false)] + pub read: bool, + #[sea_orm(default_value = false)] + pub flagged: bool, + #[sea_orm(default_value = 0)] + pub position: i64, + pub duration: Option, + } + + owned_by_user!(); +} + +pub mod sessions { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "sessions")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false, column_type = "Text")] + pub token: String, + pub user_id: i64, + pub seen: i64, + } + + owned_by_user!(); +} diff --git a/src/main.rs b/src/main.rs index 787a4a5..134571d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod auth; mod config; mod db; +mod entity; mod download; mod feed; mod ipc; @@ -179,7 +180,7 @@ async fn main() -> Result<()> { let config_path = cli.config.clone().unwrap_or_else(config::config_path); let cfg = config::Config::load(&config_path)?; - let db = db::Db::open(&config::data_dir().join("state.db"))?; + let db = db::Db::open(&config::data_dir().join("state.db")).await?; // A daemon owns the state; don't have two processes downloading the same thing. let wire_cmd = match &cli.command { @@ -1690,8 +1691,8 @@ mod tests { } } - #[test] - fn a_shared_feed_is_fetched_for_whoever_wants_the_most() { + #[tokio::test] + async fn a_shared_feed_is_fetched_for_whoever_wants_the_most() { // Nobody subscribed: the feed's own settings stand, as in a single-user install. let p = merge_policy(&[], &feed(), 3); assert!(p.auto_download); @@ -1721,10 +1722,10 @@ mod tests { assert!(p.auto_download); } - fn test_ctx(cfg: config::Config) -> Ctx { + async fn test_ctx(cfg: config::Config) -> Ctx { Ctx { cfg: std::sync::RwLock::new(Arc::new(cfg)), - db: db::Db::memory().unwrap(), + db: db::Db::memory().await.unwrap(), client: reqwest::Client::new(), out: Emitter::terminal(), torrents: tokio::sync::OnceCell::new(), @@ -1734,11 +1735,11 @@ mod tests { } } - #[test] - fn a_derived_feed_is_not_scanned_once_its_opml_leaves_config() { + #[tokio::test] + async fn a_derived_feed_is_not_scanned_once_its_opml_leaves_config() { // davewiner: the OPML subscription left config.toml, but its 922 derived rows // stayed in the database and kept being scanned under the no-parent fallback. - let ctx = test_ctx(config::Config::default()); + let ctx = test_ctx(config::Config::default()).await; ctx.db.upsert_managed("child", "http://x/child.xml", "Child", "gone-opml").unwrap(); assert!( subscriptions(&ctx).unwrap().iter().all(|s| s.id != "child"), @@ -1746,9 +1747,9 @@ mod tests { ); } - #[test] - fn retiring_a_group_drops_what_was_never_downloaded_and_orphans_the_rest() { - let ctx = test_ctx(config::Config::default()); + #[tokio::test] + async fn retiring_a_group_drops_what_was_never_downloaded_and_orphans_the_rest() { + let ctx = test_ctx(config::Config::default()).await; ctx.db.upsert_managed("empty", "http://x/empty.xml", "Empty", "parent").unwrap(); ctx.db.upsert_managed("has-file", "http://x/has-file.xml", "Has File", "parent").unwrap(); let enc = feed::Enclosure { url: "http://x/ep.mp3".into(), mime: None, length: None }; @@ -1763,14 +1764,14 @@ mod tests { assert!(ctx.db.feed_summary("has-file").unwrap().orphaned, "and flagged as orphaned"); } - #[test] - fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() { + #[tokio::test] + async fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() { // davewiner: the OPML left config before retire_group existed, and 11 of its feeds // promoted to config since still said managed = 1. let mut cfg = config::Config::default(); cfg.feeds.insert("promoted".into(), feed()); cfg.feeds.insert("live-opml".into(), feed()); - let ctx = test_ctx(cfg); + let ctx = test_ctx(cfg).await; ctx.db.upsert_managed("promoted", "http://x/p.xml", "Promoted", "gone-opml").unwrap(); ctx.db.upsert_managed("empty", "http://x/e.xml", "Empty", "gone-opml").unwrap(); ctx.db.upsert_managed("listed", "http://x/l.xml", "Listed", "live-opml").unwrap(); diff --git a/src/retention.rs b/src/retention.rs index 232288a..66ecf5b 100644 --- a/src/retention.rs +++ b/src/retention.rs @@ -149,10 +149,10 @@ mod tests { // age_key 0 means "never recorded" -- not the same as "infinitely old". } - #[test] - fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() { + #[tokio::test] + async fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() { // One file serves both subscribers, so it takes both of them to release it. - let db = Db::memory().unwrap(); + let db = Db::memory().await.unwrap(); db.exec_for_test( "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0); INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f'); @@ -185,9 +185,9 @@ mod tests { ); } - #[test] - fn prune_keeps_entries_that_still_have_a_file() { - let db = Db::memory().unwrap(); + #[tokio::test] + async fn prune_keeps_entries_that_still_have_a_file() { + let db = Db::memory().await.unwrap(); db.exec_for_test( "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1); INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',1);