personal activity index (bluesky, leaflet, substack) pai.desertthunder.dev
rss bluesky

feat: implement HTTP server with Axum

* add deployment guide

+535 -4
+105
Cargo.lock
··· 71 ] 72 73 [[package]] 74 name = "atomic-waker" 75 version = "1.1.2" 76 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 83 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 84 85 [[package]] 86 name = "base64" 87 version = "0.22.1" 88 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 484 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 485 486 [[package]] 487 name = "hyper" 488 version = "1.8.1" 489 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 497 "http", 498 "http-body", 499 "httparse", 500 "itoa", 501 "pin-project-lite", 502 "pin-utils", ··· 781 version = "0.4.28" 782 source = "registry+https://github.com/rust-lang/crates.io-index" 783 checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 784 785 [[package]] 786 name = "mediatype" ··· 912 name = "pai" 913 version = "0.1.0" 914 dependencies = [ 915 "chrono", 916 "clap", 917 "dirs", 918 "owo-colors", 919 "pai-core", 920 "rusqlite", 921 "serde_json", 922 ] 923 924 [[package]] ··· 1248 ] 1249 1250 [[package]] 1251 name = "serde_spanned" 1252 version = "1.0.3" 1253 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1275 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1276 1277 [[package]] 1278 name = "siphasher" 1279 version = "1.0.1" 1280 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1425 "libc", 1426 "mio", 1427 "pin-project-lite", 1428 "socket2", 1429 "tokio-macros", 1430 "windows-sys 0.61.2", ··· 1526 "tokio", 1527 "tower-layer", 1528 "tower-service", 1529 ] 1530 1531 [[package]] ··· 1564 source = "registry+https://github.com/rust-lang/crates.io-index" 1565 checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 1566 dependencies = [ 1567 "pin-project-lite", 1568 "tracing-core", 1569 ]
··· 71 ] 72 73 [[package]] 74 + name = "async-trait" 75 + version = "0.1.89" 76 + source = "registry+https://github.com/rust-lang/crates.io-index" 77 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 78 + dependencies = [ 79 + "proc-macro2", 80 + "quote", 81 + "syn", 82 + ] 83 + 84 + [[package]] 85 name = "atomic-waker" 86 version = "1.1.2" 87 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 94 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 95 96 [[package]] 97 + name = "axum" 98 + version = "0.7.9" 99 + source = "registry+https://github.com/rust-lang/crates.io-index" 100 + checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" 101 + dependencies = [ 102 + "async-trait", 103 + "axum-core", 104 + "bytes", 105 + "futures-util", 106 + "http", 107 + "http-body", 108 + "http-body-util", 109 + "hyper", 110 + "hyper-util", 111 + "itoa", 112 + "matchit", 113 + "memchr", 114 + "mime", 115 + "percent-encoding", 116 + "pin-project-lite", 117 + "rustversion", 118 + "serde", 119 + "serde_json", 120 + "serde_path_to_error", 121 + "serde_urlencoded", 122 + "sync_wrapper", 123 + "tokio", 124 + "tower", 125 + "tower-layer", 126 + "tower-service", 127 + "tracing", 128 + ] 129 + 130 + [[package]] 131 + name = "axum-core" 132 + version = "0.4.5" 133 + source = "registry+https://github.com/rust-lang/crates.io-index" 134 + checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" 135 + dependencies = [ 136 + "async-trait", 137 + "bytes", 138 + "futures-util", 139 + "http", 140 + "http-body", 141 + "http-body-util", 142 + "mime", 143 + "pin-project-lite", 144 + "rustversion", 145 + "sync_wrapper", 146 + "tower-layer", 147 + "tower-service", 148 + "tracing", 149 + ] 150 + 151 + [[package]] 152 name = "base64" 153 version = "0.22.1" 154 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 550 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 551 552 [[package]] 553 + name = "httpdate" 554 + version = "1.0.3" 555 + source = "registry+https://github.com/rust-lang/crates.io-index" 556 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 557 + 558 + [[package]] 559 name = "hyper" 560 version = "1.8.1" 561 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 569 "http", 570 "http-body", 571 "httparse", 572 + "httpdate", 573 "itoa", 574 "pin-project-lite", 575 "pin-utils", ··· 854 version = "0.4.28" 855 source = "registry+https://github.com/rust-lang/crates.io-index" 856 checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 857 + 858 + [[package]] 859 + name = "matchit" 860 + version = "0.7.3" 861 + source = "registry+https://github.com/rust-lang/crates.io-index" 862 + checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" 863 864 [[package]] 865 name = "mediatype" ··· 991 name = "pai" 992 version = "0.1.0" 993 dependencies = [ 994 + "axum", 995 "chrono", 996 "clap", 997 "dirs", 998 "owo-colors", 999 "pai-core", 1000 "rusqlite", 1001 + "serde", 1002 "serde_json", 1003 + "tokio", 1004 ] 1005 1006 [[package]] ··· 1330 ] 1331 1332 [[package]] 1333 + name = "serde_path_to_error" 1334 + version = "0.1.20" 1335 + source = "registry+https://github.com/rust-lang/crates.io-index" 1336 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" 1337 + dependencies = [ 1338 + "itoa", 1339 + "serde", 1340 + "serde_core", 1341 + ] 1342 + 1343 + [[package]] 1344 name = "serde_spanned" 1345 version = "1.0.3" 1346 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1368 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1369 1370 [[package]] 1371 + name = "signal-hook-registry" 1372 + version = "1.4.7" 1373 + source = "registry+https://github.com/rust-lang/crates.io-index" 1374 + checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" 1375 + dependencies = [ 1376 + "libc", 1377 + ] 1378 + 1379 + [[package]] 1380 name = "siphasher" 1381 version = "1.0.1" 1382 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1527 "libc", 1528 "mio", 1529 "pin-project-lite", 1530 + "signal-hook-registry", 1531 "socket2", 1532 "tokio-macros", 1533 "windows-sys 0.61.2", ··· 1629 "tokio", 1630 "tower-layer", 1631 "tower-service", 1632 + "tracing", 1633 ] 1634 1635 [[package]] ··· 1668 source = "registry+https://github.com/rust-lang/crates.io-index" 1669 checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 1670 dependencies = [ 1671 + "log", 1672 "pin-project-lite", 1673 "tracing-core", 1674 ]
+171
DEPLOYMENT.md
···
··· 1 + # Personal Activity Index – Deployment Guide 2 + 3 + This guide walks through two common reverse proxy setups for `pai serve`: **nginx** and **Caddy**. Both sections include native (host binary) instructions and optional Docker paths if you prefer containerized deployments. 4 + 5 + ## Prerequisites 6 + 7 + 1. Build the CLI binary: 8 + 9 + ```sh 10 + cargo build --release -p pai 11 + ``` 12 + 13 + The binary will live at `target/release/pai`. 14 + 15 + 2. Prepare a configuration + database location. The default locations follow the XDG spec, but you can override them with `-C` (config dir) and `-d` (database path). 16 + 17 + 3. Run a sync at least once so the database has data: 18 + 19 + ```sh 20 + ./target/release/pai sync -C /etc/pai -d /var/lib/pai/pai.db -a 21 + ``` 22 + 23 + 4. Start the server (example binds to localhost so the proxy terminates TLS): 24 + 25 + ```sh 26 + ./target/release/pai serve -d /var/lib/pai/pai.db -a 127.0.0.1:8080 27 + ``` 28 + 29 + ## nginx Deployment 30 + 31 + ### Host Setup 32 + 33 + 1. Install nginx via your package manager (`apt`, `dnf`, `brew`, etc.). 34 + 2. Create a systemd service for `pai` (optional but recommended): 35 + 36 + ```ini 37 + [Unit] 38 + Description=Personal Activity Index 39 + After=network.target 40 + 41 + [Service] 42 + ExecStart=/usr/local/bin/pai serve -d /var/lib/pai/pai.db -a 127.0.0.1:8080 43 + Restart=on-failure 44 + User=pai 45 + Group=pai 46 + WorkingDirectory=/var/lib/pai 47 + 48 + [Install] 49 + WantedBy=multi-user.target 50 + ``` 51 + 52 + 3. Enable and start it: 53 + 54 + ```sh 55 + sudo systemctl daemon-reload 56 + sudo systemctl enable --now pai.service 57 + ``` 58 + 59 + ### nginx Config 60 + 61 + Create `/etc/nginx/conf.d/pai.conf`: 62 + 63 + ```nginx 64 + server { 65 + listen 80; 66 + server_name pai.example.com; 67 + 68 + location / { 69 + proxy_pass http://127.0.0.1:8080; 70 + proxy_set_header Host $host; 71 + proxy_set_header X-Real-IP $remote_addr; 72 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 73 + proxy_set_header X-Forwarded-Proto $scheme; 74 + } 75 + } 76 + ``` 77 + 78 + Reload nginx: `sudo nginx -s reload`. 79 + 80 + ### Optional: nginx via Docker 81 + 82 + Use an `nginx` image + bind-mount config: 83 + 84 + ```yaml 85 + services: 86 + pai: 87 + image: ghcr.io/your-namespace/pai:latest 88 + command: ["serve", "-d", "/data/pai.db", "-a", "0.0.0.0:8080"] 89 + volumes: 90 + - ./data:/data 91 + expose: 92 + - "8080" 93 + 94 + nginx: 95 + image: nginx:1.27 96 + volumes: 97 + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro 98 + ports: 99 + - "80:80" 100 + depends_on: 101 + - pai 102 + ``` 103 + 104 + `nginx.conf` should proxy to `http://pai:8080` instead of localhost. 105 + 106 + ## Caddy Deployment 107 + 108 + ### Host Setup 109 + 110 + 1. Install Caddy (<https://caddyserver.com/docs/install>). 111 + 2. Keep the same `pai` systemd service from above (or run manually). 112 + 113 + ### Caddyfile Example 114 + 115 + Create `/etc/caddy/Caddyfile`: 116 + 117 + ```caddyfile 118 + pai.example.com { 119 + reverse_proxy 127.0.0.1:8080 120 + encode gzip zstd 121 + header { 122 + Referrer-Policy "no-referrer-when-downgrade" 123 + X-Content-Type-Options "nosniff" 124 + } 125 + } 126 + ``` 127 + 128 + Caddy automatically provisions TLS certificates with Let’s Encrypt. Reload with `sudo systemctl reload caddy`. 129 + 130 + ### Optional: Caddy + Docker Compose 131 + 132 + ```yaml 133 + services: 134 + pai: 135 + image: ghcr.io/your-namespace/pai:latest 136 + command: ["serve", "-d", "/data/pai.db", "-a", "0.0.0.0:8080"] 137 + volumes: 138 + - ./data:/data 139 + expose: 140 + - "8080" 141 + 142 + caddy: 143 + image: caddy:2 144 + volumes: 145 + - ./Caddyfile:/etc/caddy/Caddyfile:ro 146 + - caddy_data:/data 147 + - caddy_config:/config 148 + ports: 149 + - "80:80" 150 + - "443:443" 151 + depends_on: 152 + - pai 153 + 154 + volumes: 155 + caddy_data: 156 + caddy_config: 157 + ``` 158 + 159 + Use the same `Caddyfile` contents as above, but point `reverse_proxy` to `pai:8080`. 160 + 161 + ## Health Checks & Monitoring 162 + 163 + - `GET /api/feed?limit=1` ensures the server can read from SQLite. 164 + - `GET /api/item/{id}` is handy for debugging a specific record. 165 + - Consider adding nginx/Caddy health check endpoints (`/healthz`) that proxy to `/api/feed?limit=1` and monitor via your platform. 166 + 167 + ## Security Tips 168 + 169 + - Bind the `pai serve` process to `127.0.0.1` and let the proxy handle TLS. 170 + - Run the binary as an unprivileged user with read/write access only to the DB path. 171 + - Regularly rotate TLS certificates (Caddy does this automatically; for nginx use certbot or similar).
+3
cli/Cargo.toml
··· 15 dirs = "6.0" 16 owo-colors = "4.1" 17 serde_json = "1.0"
··· 15 dirs = "6.0" 16 owo-colors = "4.1" 17 serde_json = "1.0" 18 + serde = { version = "1.0", features = ["derive"] } 19 + axum = "0.7" 20 + tokio = { version = "1.40", features = ["macros", "rt-multi-thread", "signal"] }
+2 -4
cli/src/main.rs
··· 1 mod paths; 2 mod storage; 3 4 use chrono::{DateTime, Duration, Utc}; ··· 231 232 fn handle_serve(db_path: Option<PathBuf>, address: String) -> Result<(), PaiError> { 233 let db_path = paths::resolve_db_path(db_path)?; 234 - let _storage = SqliteStorage::new(db_path)?; 235 - 236 - println!("serve command - address: {address}"); 237 - Ok(()) 238 } 239 240 fn handle_db_check(db_path: Option<PathBuf>) -> Result<(), PaiError> {
··· 1 mod paths; 2 + mod server; 3 mod storage; 4 5 use chrono::{DateTime, Duration, Utc}; ··· 232 233 fn handle_serve(db_path: Option<PathBuf>, address: String) -> Result<(), PaiError> { 234 let db_path = paths::resolve_db_path(db_path)?; 235 + server::serve(db_path, address) 236 } 237 238 fn handle_db_check(db_path: Option<PathBuf>) -> Result<(), PaiError> {
+203
cli/src/server.rs
···
··· 1 + use crate::storage::SqliteStorage; 2 + use crate::{ensure_positive_limit, normalize_optional_string, normalize_since_input}; 3 + use axum::{ 4 + extract::{Path, Query, State}, 5 + http::StatusCode, 6 + response::{IntoResponse, Response}, 7 + routing::get, 8 + Json, Router, 9 + }; 10 + use owo_colors::OwoColorize; 11 + use pai_core::{Item, ListFilter, PaiError, SourceKind}; 12 + use serde::{Deserialize, Serialize}; 13 + use std::{net::SocketAddr, path::PathBuf, sync::Arc}; 14 + use tokio::net::TcpListener; 15 + 16 + const DEFAULT_LIMIT: usize = 20; 17 + 18 + /// Launches the HTTP server using the provided SQLite database path and address. 19 + pub(crate) fn serve(db_path: PathBuf, address: String) -> Result<(), PaiError> { 20 + let addr: SocketAddr = address 21 + .parse() 22 + .map_err(|e| PaiError::Config(format!("Invalid listen address '{address}': {e}")))?; 23 + 24 + let runtime = tokio::runtime::Builder::new_multi_thread() 25 + .enable_all() 26 + .build() 27 + .map_err(PaiError::Io)?; 28 + 29 + runtime.block_on(async move { run_server(db_path, addr).await }) 30 + } 31 + 32 + async fn run_server(db_path: PathBuf, addr: SocketAddr) -> Result<(), PaiError> { 33 + // Ensure the database exists and schema is ready before serving requests. 34 + let storage = SqliteStorage::new(&db_path)?; 35 + storage.verify_schema()?; 36 + drop(storage); 37 + 38 + let state = AppState { db_path: Arc::new(db_path) }; 39 + 40 + let app = Router::new() 41 + .route("/api/feed", get(feed_handler)) 42 + .route("/api/item/:id", get(item_handler)) 43 + .with_state(state); 44 + 45 + let listener = TcpListener::bind(addr).await.map_err(PaiError::Io)?; 46 + let local_addr = listener.local_addr().map_err(PaiError::Io)?; 47 + println!("{} Listening on http://{}", "Info:".cyan(), local_addr); 48 + 49 + axum::serve(listener, app.into_make_service()) 50 + .with_graceful_shutdown(shutdown_signal()) 51 + .await 52 + .map_err(|err| PaiError::Io(std::io::Error::new(std::io::ErrorKind::Other, err))) 53 + } 54 + 55 + #[derive(Clone)] 56 + struct AppState { 57 + db_path: Arc<PathBuf>, 58 + } 59 + 60 + impl AppState { 61 + fn open_storage(&self) -> Result<SqliteStorage, PaiError> { 62 + SqliteStorage::new(self.db_path.as_ref()) 63 + } 64 + } 65 + 66 + #[derive(Debug, Default, Deserialize)] 67 + struct FeedQuery { 68 + source_kind: Option<SourceKind>, 69 + source_id: Option<String>, 70 + limit: Option<usize>, 71 + since: Option<String>, 72 + q: Option<String>, 73 + } 74 + 75 + impl FeedQuery { 76 + fn into_filter(self) -> Result<ListFilter, PaiError> { 77 + let limit = match self.limit { 78 + Some(value) => ensure_positive_limit(value)?, 79 + None => DEFAULT_LIMIT, 80 + }; 81 + 82 + Ok(ListFilter { 83 + source_kind: self.source_kind, 84 + source_id: normalize_optional_string(self.source_id), 85 + limit: Some(limit), 86 + since: normalize_since_input(self.since)?, 87 + query: normalize_optional_string(self.q), 88 + }) 89 + } 90 + } 91 + 92 + #[derive(Serialize)] 93 + struct FeedResponse { 94 + count: usize, 95 + items: Vec<Item>, 96 + } 97 + 98 + async fn feed_handler( 99 + State(state): State<AppState>, Query(query): Query<FeedQuery>, 100 + ) -> Result<Json<FeedResponse>, ApiError> { 101 + let filter = query.into_filter()?; 102 + let storage = state.open_storage()?; 103 + let items = pai_core::Storage::list_items(&storage, &filter)?; 104 + 105 + Ok(Json(FeedResponse { count: items.len(), items })) 106 + } 107 + 108 + async fn item_handler(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Item>, ApiError> { 109 + let storage = state.open_storage()?; 110 + let item = storage 111 + .get_item(&id)? 112 + .ok_or_else(|| ApiError::not_found(format!("Item '{id}' not found")))?; 113 + 114 + Ok(Json(item)) 115 + } 116 + 117 + struct ApiError { 118 + status: StatusCode, 119 + message: String, 120 + } 121 + 122 + impl ApiError { 123 + fn bad_request(msg: impl Into<String>) -> Self { 124 + Self { status: StatusCode::BAD_REQUEST, message: msg.into() } 125 + } 126 + 127 + fn not_found(msg: impl Into<String>) -> Self { 128 + Self { status: StatusCode::NOT_FOUND, message: msg.into() } 129 + } 130 + 131 + fn internal(msg: impl Into<String>) -> Self { 132 + Self { status: StatusCode::INTERNAL_SERVER_ERROR, message: msg.into() } 133 + } 134 + } 135 + 136 + impl From<PaiError> for ApiError { 137 + fn from(err: PaiError) -> Self { 138 + match err { 139 + PaiError::InvalidArgument(msg) => Self::bad_request(msg), 140 + other => Self::internal(other.to_string()), 141 + } 142 + } 143 + } 144 + 145 + #[derive(Serialize)] 146 + struct ErrorBody { 147 + error: String, 148 + } 149 + 150 + impl IntoResponse for ApiError { 151 + fn into_response(self) -> Response { 152 + (self.status, Json(ErrorBody { error: self.message })).into_response() 153 + } 154 + } 155 + 156 + async fn shutdown_signal() { 157 + let _ = tokio::signal::ctrl_c().await; 158 + } 159 + 160 + #[cfg(test)] 161 + mod tests { 162 + use super::*; 163 + 164 + #[test] 165 + fn feed_query_defaults() { 166 + let filter = FeedQuery::default().into_filter().unwrap(); 167 + assert_eq!(filter.limit, Some(DEFAULT_LIMIT)); 168 + assert!(filter.source_kind.is_none()); 169 + assert!(filter.source_id.is_none()); 170 + } 171 + 172 + #[test] 173 + fn feed_query_respects_parameters() { 174 + let query = FeedQuery { 175 + source_kind: Some(SourceKind::Bluesky), 176 + source_id: Some(" desertthunder.dev ".to_string()), 177 + limit: Some(5), 178 + since: Some("2024-01-01T00:00:00Z".to_string()), 179 + q: Some(" rust ".to_string()), 180 + }; 181 + 182 + let filter = query.into_filter().unwrap(); 183 + assert_eq!(filter.limit, Some(5)); 184 + assert_eq!(filter.source_kind, Some(SourceKind::Bluesky)); 185 + assert_eq!(filter.source_id.unwrap(), "desertthunder.dev"); 186 + assert_eq!(filter.query.unwrap(), "rust"); 187 + assert_eq!(filter.since.unwrap(), "2024-01-01T00:00:00+00:00"); 188 + } 189 + 190 + #[test] 191 + fn feed_query_rejects_zero_limit() { 192 + let err = FeedQuery { limit: Some(0), ..Default::default() } 193 + .into_filter() 194 + .unwrap_err(); 195 + assert!(matches!(err, PaiError::InvalidArgument(_))); 196 + } 197 + 198 + #[test] 199 + fn api_error_into_response_sets_status() { 200 + let resp = ApiError::bad_request("oops").into_response(); 201 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 202 + } 203 + }
+51
cli/src/storage/sqlite.rs
··· 135 136 Ok(()) 137 } 138 } 139 140 impl Storage for SqliteStorage { ··· 403 404 let count = storage.count_items().expect("Failed to count items"); 405 assert_eq!(count, 3); 406 } 407 }
··· 135 136 Ok(()) 137 } 138 + 139 + /// Fetches a single item by ID, if it exists 140 + pub fn get_item(&self, id: &str) -> Result<Option<Item>> { 141 + let mut stmt = self 142 + .conn 143 + .prepare( 144 + "SELECT id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at 145 + FROM items WHERE id = ?1 LIMIT 1", 146 + ) 147 + .map_err(|e| PaiError::Storage(format!("Failed to prepare get_item query: {e}")))?; 148 + 149 + stmt.query_row([id], |row| { 150 + let source_kind_str: String = row.get(1)?; 151 + let source_kind = source_kind_str 152 + .parse::<SourceKind>() 153 + .map_err(|e| rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e)))?; 154 + 155 + Ok(Item { 156 + id: row.get(0)?, 157 + source_kind, 158 + source_id: row.get(2)?, 159 + author: row.get(3)?, 160 + title: row.get(4)?, 161 + summary: row.get(5)?, 162 + url: row.get(6)?, 163 + content_html: row.get(7)?, 164 + published_at: row.get(8)?, 165 + created_at: row.get(9)?, 166 + }) 167 + }) 168 + .optional() 169 + .map_err(|e| PaiError::Storage(format!("Failed to fetch item by id: {e}"))) 170 + } 171 } 172 173 impl Storage for SqliteStorage { ··· 436 437 let count = storage.count_items().expect("Failed to count items"); 438 assert_eq!(count, 3); 439 + } 440 + 441 + #[test] 442 + fn get_item_returns_record() { 443 + let storage = create_test_storage(); 444 + let item = create_test_item("test-1", SourceKind::Substack, "test.substack.com"); 445 + storage.insert_or_replace_item(&item).expect("Failed to insert"); 446 + 447 + let fetched = storage.get_item("test-1").expect("query failed").unwrap(); 448 + assert_eq!(fetched.id, "test-1"); 449 + assert_eq!(fetched.source_kind, SourceKind::Substack); 450 + } 451 + 452 + #[test] 453 + fn get_item_returns_none_for_missing() { 454 + let storage = create_test_storage(); 455 + let result = storage.get_item("nope").expect("query failed"); 456 + assert!(result.is_none()); 457 } 458 }