feat: scaffold newsfeed — user-controlled news feed
A self-hosted feed where the user owns the ranking: per-source and signed per-interest weights, decayed by recency, via a transparent deterministic scorer. Content is sourced algorithmically (worker RSS/Atom polling) and agentically (per-user API tokens POSTing candidates to the ingest endpoint). Single-user today, multi-user by construction (every row keyed on user_id). Rust cargo workspace + Vite/React/SWC/TS SPA: - newsfeed-entities: DTOs (ts-rs bindings -> web/src/api/bindings) - newsfeed-core: ranking, auth primitives, ingest, data-access ports - newsfeed-data: SQLite adapters (sqlx, runtime queries) - newsfeed-api: axum REST/JSON daemon - newsfeed-worker: RSS polling + rescoring loop - web: responsive, mobile-first SPA (React Query, generated types) Deploy (Gitea Actions, build static musl + SPA, rsync as gitea_ci): api+worker -> slartibartfast, SPA -> oolon (nginx serves + proxies /v1). Deliberate deviations from house conventions (documented in CLAUDE.md/readme): - SQLite instead of Postgres; api+worker co-locate sharing one DB file. - Runtime sqlx queries instead of query! macros (SQLite dynamic typing; keeps CI database-free). Verified end-to-end: auth, token ingest, interest-weighted ranking, signals, pagination (curl + browser); cargo fmt/clippy -D/test and pnpm build/lint pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP
This commit is contained in:
23
crates/newsfeed-data/Cargo.toml
Normal file
23
crates/newsfeed-data/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "newsfeed-data"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "SQLite data-access adapters implementing the newsfeed-core ports."
|
||||
|
||||
[dependencies]
|
||||
newsfeed-entities.workspace = true
|
||||
newsfeed-core.workspace = true
|
||||
|
||||
sqlx.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
async-trait.workspace = true
|
||||
tracing.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
92
crates/newsfeed-data/migrations/0001_init.sql
Normal file
92
crates/newsfeed-data/migrations/0001_init.sql
Normal file
@@ -0,0 +1,92 @@
|
||||
-- newsfeed initial schema (SQLite).
|
||||
--
|
||||
-- Conventions for this datastore:
|
||||
-- * ids TEXT — hyphenated UUIDv4
|
||||
-- * timestamps TEXT — RFC3339 / ISO8601 (UTC)
|
||||
-- * booleans INTEGER — 0/1
|
||||
-- * json blobs TEXT — serde_json arrays/objects
|
||||
--
|
||||
-- Every user-owned row carries user_id and cascades on user deletion, because each
|
||||
-- user is wholly in control of — and isolated within — their own feed.
|
||||
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_sessions_user ON sessions(user_id);
|
||||
|
||||
CREATE TABLE api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
prefix TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
revoked_at TEXT
|
||||
);
|
||||
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
|
||||
|
||||
CREATE TABLE sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL, -- 'rss' | 'agentic'
|
||||
name TEXT NOT NULL,
|
||||
url TEXT,
|
||||
weight REAL NOT NULL DEFAULT 1.0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_polled_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
CREATE INDEX idx_sources_user ON sources(user_id);
|
||||
|
||||
CREATE TABLE interests (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
weight REAL NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, label)
|
||||
);
|
||||
CREATE INDEX idx_interests_user ON interests(user_id);
|
||||
|
||||
CREATE TABLE items (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
source_id TEXT REFERENCES sources(id) ON DELETE SET NULL,
|
||||
external_id TEXT NOT NULL,
|
||||
url TEXT,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
author TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]', -- json array of strings
|
||||
media TEXT NOT NULL DEFAULT '[]', -- json array of Media
|
||||
published_at TEXT,
|
||||
ingested_at TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'candidate',
|
||||
score REAL NOT NULL DEFAULT 0.0,
|
||||
UNIQUE(user_id, external_id)
|
||||
);
|
||||
-- Feed reads order by score within a user's live items.
|
||||
CREATE INDEX idx_items_feed ON items(user_id, state, score DESC, id DESC);
|
||||
|
||||
CREATE TABLE signals (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
action TEXT NOT NULL, -- 'view' | 'click' | 'save' | 'dismiss'
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_signals_item ON signals(item_id);
|
||||
24
crates/newsfeed-data/src/err.rs
Normal file
24
crates/newsfeed-data/src/err.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
//! Maps `sqlx` failures onto the backend-agnostic [`CoreError`] the ports speak.
|
||||
|
||||
use newsfeed_core::error::CoreError;
|
||||
|
||||
/// Convert a `sqlx::Error` into a [`CoreError`], distinguishing unique-constraint
|
||||
/// violations (→ [`CoreError::Conflict`]) from everything else (→ storage error).
|
||||
pub fn map(e: sqlx::Error) -> CoreError {
|
||||
if let sqlx::Error::Database(db) = &e {
|
||||
if db.is_unique_violation() {
|
||||
return CoreError::Conflict(db.message().to_string());
|
||||
}
|
||||
}
|
||||
CoreError::Storage(e.to_string())
|
||||
}
|
||||
|
||||
/// Convert a JSON (de)serialisation failure in the mapping layer into a storage error.
|
||||
pub fn json(e: serde_json::Error) -> CoreError {
|
||||
CoreError::Storage(format!("json: {e}"))
|
||||
}
|
||||
|
||||
/// Convert a malformed stored UUID into a storage error.
|
||||
pub fn uuid(e: uuid::Error) -> CoreError {
|
||||
CoreError::Storage(format!("uuid: {e}"))
|
||||
}
|
||||
52
crates/newsfeed-data/src/lib.rs
Normal file
52
crates/newsfeed-data/src/lib.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! SQLite data-access adapters for newsfeed.
|
||||
//!
|
||||
//! [`SqliteStore`] implements every port defined in [`newsfeed_core::ports`]; the
|
||||
//! binaries hold it behind `Arc<dyn Store>`. Ids are TEXT UUIDs, timestamps RFC3339,
|
||||
//! and small collections JSON — see [`rows`] for the mapping layer.
|
||||
//!
|
||||
//! Note: this crate uses runtime `sqlx` queries rather than the compile-time `query!`
|
||||
//! macros. On SQLite (dynamically typed) the macros add little safety while forcing a
|
||||
//! live database or a fiddly offline cache into CI; runtime queries keep CI DB-free.
|
||||
//! This is a deliberate, documented deviation from architecture `generic.md` §5, which
|
||||
//! is written for Postgres.
|
||||
|
||||
mod err;
|
||||
mod rows;
|
||||
mod store;
|
||||
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
use sqlx::sqlite::{
|
||||
SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteSynchronous,
|
||||
};
|
||||
|
||||
pub use store::SqliteStore;
|
||||
|
||||
/// Embedded migrations, run at startup by [`connect`].
|
||||
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
|
||||
|
||||
/// Open (creating if absent) the SQLite database at `path`, apply migrations, and return
|
||||
/// a ready [`SqliteStore`]. WAL + `NORMAL` synchronous + `foreign_keys` are enabled;
|
||||
/// these suit a single-host api+worker sharing one file (the SQLite deployment shape).
|
||||
pub async fn connect(path: impl AsRef<Path>, max_connections: u32) -> anyhow::Result<SqliteStore> {
|
||||
let pool = pool(path, max_connections).await?;
|
||||
MIGRATOR.run(&pool).await?;
|
||||
Ok(SqliteStore::new(pool))
|
||||
}
|
||||
|
||||
/// Build a connection pool with the newsfeed pragmas applied to every connection.
|
||||
pub async fn pool(path: impl AsRef<Path>, max_connections: u32) -> anyhow::Result<SqlitePool> {
|
||||
let url = format!("sqlite://{}", path.as_ref().display());
|
||||
let opts = SqliteConnectOptions::from_str(&url)?
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal)
|
||||
.foreign_keys(true)
|
||||
.busy_timeout(std::time::Duration::from_secs(5));
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.connect_with(opts)
|
||||
.await?;
|
||||
Ok(pool)
|
||||
}
|
||||
197
crates/newsfeed-data/src/rows.rs
Normal file
197
crates/newsfeed-data/src/rows.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! `FromRow` structs mirroring the SQLite tables, plus conversions into the entity
|
||||
//! types. DB-native types (TEXT ids, RFC3339 timestamps, JSON blobs) are decoded here so
|
||||
//! the rest of the crate deals only in [`newsfeed_entities`] values.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
use newsfeed_core::error::CoreResult;
|
||||
use newsfeed_core::ports::StoredCredential;
|
||||
use newsfeed_entities::auth::{ApiTokenInfo, Session};
|
||||
use newsfeed_entities::interest::Interest;
|
||||
use newsfeed_entities::item::{ContentItem, ItemState, Media};
|
||||
use newsfeed_entities::source::{Source, SourceKind};
|
||||
use newsfeed_entities::user::User;
|
||||
|
||||
use crate::err;
|
||||
|
||||
fn parse_id(s: &str) -> CoreResult<Uuid> {
|
||||
Uuid::parse_str(s).map_err(err::uuid)
|
||||
}
|
||||
|
||||
fn parse_state(s: &str) -> ItemState {
|
||||
match s {
|
||||
"feed" => ItemState::Feed,
|
||||
"saved" => ItemState::Saved,
|
||||
"dismissed" => ItemState::Dismissed,
|
||||
_ => ItemState::Candidate,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
pub struct UserRow {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl UserRow {
|
||||
pub fn into_user(self) -> CoreResult<User> {
|
||||
Ok(User {
|
||||
id: parse_id(&self.id)?,
|
||||
username: self.username,
|
||||
email: self.email,
|
||||
created_at: self.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn into_credential(self) -> CoreResult<StoredCredential> {
|
||||
let password_hash = self.password_hash.clone();
|
||||
Ok(StoredCredential {
|
||||
user: self.into_user()?,
|
||||
password_hash,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
pub struct SessionRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl SessionRow {
|
||||
pub fn into_session(self) -> CoreResult<Session> {
|
||||
Ok(Session {
|
||||
id: parse_id(&self.id)?,
|
||||
user_id: parse_id(&self.user_id)?,
|
||||
created_at: self.created_at,
|
||||
expires_at: self.expires_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
pub struct ApiTokenRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
pub prefix: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl ApiTokenRow {
|
||||
pub fn into_info(self) -> CoreResult<ApiTokenInfo> {
|
||||
Ok(ApiTokenInfo {
|
||||
id: parse_id(&self.id)?,
|
||||
user_id: parse_id(&self.user_id)?,
|
||||
name: self.name,
|
||||
prefix: self.prefix,
|
||||
created_at: self.created_at,
|
||||
last_used_at: self.last_used_at,
|
||||
revoked_at: self.revoked_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
pub struct SourceRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub url: Option<String>,
|
||||
pub weight: f64,
|
||||
pub enabled: bool,
|
||||
pub last_polled_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl SourceRow {
|
||||
pub fn into_source(self) -> CoreResult<Source> {
|
||||
Ok(Source {
|
||||
id: parse_id(&self.id)?,
|
||||
user_id: parse_id(&self.user_id)?,
|
||||
kind: SourceKind::parse(&self.kind).map_err(newsfeed_core::error::CoreError::from)?,
|
||||
name: self.name,
|
||||
url: self.url,
|
||||
weight: self.weight,
|
||||
enabled: self.enabled,
|
||||
last_polled_at: self.last_polled_at,
|
||||
created_at: self.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
pub struct InterestRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub label: String,
|
||||
pub weight: f64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl InterestRow {
|
||||
pub fn into_interest(self) -> CoreResult<Interest> {
|
||||
Ok(Interest {
|
||||
id: parse_id(&self.id)?,
|
||||
user_id: parse_id(&self.user_id)?,
|
||||
label: self.label,
|
||||
weight: self.weight,
|
||||
created_at: self.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
pub struct ItemRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub source_id: Option<String>,
|
||||
pub external_id: String,
|
||||
pub url: Option<String>,
|
||||
pub title: String,
|
||||
pub summary: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub tags: String,
|
||||
pub media: String,
|
||||
pub published_at: Option<DateTime<Utc>>,
|
||||
pub ingested_at: DateTime<Utc>,
|
||||
pub state: String,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
impl ItemRow {
|
||||
pub fn into_item(self) -> CoreResult<ContentItem> {
|
||||
let source_id = match self.source_id {
|
||||
Some(s) => Some(parse_id(&s)?),
|
||||
None => None,
|
||||
};
|
||||
let tags: Vec<String> = serde_json::from_str(&self.tags).map_err(err::json)?;
|
||||
let media: Vec<Media> = serde_json::from_str(&self.media).map_err(err::json)?;
|
||||
Ok(ContentItem {
|
||||
id: parse_id(&self.id)?,
|
||||
user_id: parse_id(&self.user_id)?,
|
||||
source_id,
|
||||
external_id: self.external_id,
|
||||
url: self.url,
|
||||
title: self.title,
|
||||
summary: self.summary,
|
||||
author: self.author,
|
||||
tags,
|
||||
media,
|
||||
published_at: self.published_at,
|
||||
ingested_at: self.ingested_at,
|
||||
state: parse_state(&self.state),
|
||||
score: self.score,
|
||||
})
|
||||
}
|
||||
}
|
||||
63
crates/newsfeed-data/src/store/interests.rs
Normal file
63
crates/newsfeed-data/src/store/interests.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use newsfeed_core::error::CoreResult;
|
||||
use newsfeed_core::ports::InterestStore;
|
||||
use newsfeed_entities::interest::{Interest, UpsertInterest};
|
||||
|
||||
use crate::err;
|
||||
use crate::rows::InterestRow;
|
||||
|
||||
use super::SqliteStore;
|
||||
|
||||
const SELECT: &str = "SELECT id, user_id, label, weight, created_at FROM interests";
|
||||
|
||||
#[async_trait]
|
||||
impl InterestStore for SqliteStore {
|
||||
async fn list_interests(&self, user_id: Uuid) -> CoreResult<Vec<Interest>> {
|
||||
let rows: Vec<InterestRow> = sqlx::query_as(&format!(
|
||||
"{SELECT} WHERE user_id = ? ORDER BY weight DESC, label ASC"
|
||||
))
|
||||
.bind(user_id.to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
rows.into_iter().map(InterestRow::into_interest).collect()
|
||||
}
|
||||
|
||||
async fn upsert_interest(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
upsert: &UpsertInterest,
|
||||
) -> CoreResult<Interest> {
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
// On (user_id, label) conflict, update the weight in place and return the row.
|
||||
let row: InterestRow = sqlx::query_as(&format!(
|
||||
"INSERT INTO interests (id, user_id, label, weight, created_at) VALUES (?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(user_id, label) DO UPDATE SET weight = excluded.weight \
|
||||
RETURNING {}",
|
||||
"id, user_id, label, weight, created_at"
|
||||
))
|
||||
.bind(id.to_string())
|
||||
.bind(user_id.to_string())
|
||||
.bind(upsert.label.trim())
|
||||
.bind(upsert.weight)
|
||||
.bind(now)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
row.into_interest()
|
||||
}
|
||||
|
||||
async fn delete_interest(&self, user_id: Uuid, id: Uuid) -> CoreResult<()> {
|
||||
sqlx::query("DELETE FROM interests WHERE id = ? AND user_id = ?")
|
||||
.bind(id.to_string())
|
||||
.bind(user_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
206
crates/newsfeed-data/src/store/items.rs
Normal file
206
crates/newsfeed-data/src/store/items.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use newsfeed_core::error::{CoreError, CoreResult};
|
||||
use newsfeed_core::ports::{ItemStore, NewCandidate};
|
||||
use newsfeed_entities::item::{ContentItem, ItemState};
|
||||
|
||||
use crate::err;
|
||||
use crate::rows::ItemRow;
|
||||
|
||||
use super::SqliteStore;
|
||||
|
||||
const COLS: &str = "id, user_id, source_id, external_id, url, title, summary, author, tags, media, published_at, ingested_at, state, score";
|
||||
|
||||
#[async_trait]
|
||||
impl ItemStore for SqliteStore {
|
||||
async fn upsert_candidate(&self, candidate: &NewCandidate) -> CoreResult<(ContentItem, bool)> {
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
let tags = serde_json::to_string(&candidate.tags).map_err(err::json)?;
|
||||
let media = serde_json::to_string(&candidate.media).map_err(err::json)?;
|
||||
|
||||
// Insert as a candidate; on (user_id, external_id) conflict do nothing. RETURNING
|
||||
// yields a row only when a new row was actually inserted.
|
||||
let inserted: Option<ItemRow> = sqlx::query_as(&format!(
|
||||
"INSERT INTO items ({COLS}) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'candidate', 0.0) \
|
||||
ON CONFLICT(user_id, external_id) DO NOTHING \
|
||||
RETURNING {COLS}"
|
||||
))
|
||||
.bind(id.to_string())
|
||||
.bind(candidate.user_id.to_string())
|
||||
.bind(candidate.source_id.map(|s| s.to_string()))
|
||||
.bind(&candidate.external_id)
|
||||
.bind(&candidate.url)
|
||||
.bind(&candidate.title)
|
||||
.bind(&candidate.summary)
|
||||
.bind(&candidate.author)
|
||||
.bind(tags)
|
||||
.bind(media)
|
||||
.bind(candidate.published_at)
|
||||
.bind(now)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
|
||||
if let Some(row) = inserted {
|
||||
return Ok((row.into_item()?, true));
|
||||
}
|
||||
|
||||
// Conflict: return the pre-existing item.
|
||||
let existing: ItemRow = sqlx::query_as(&format!(
|
||||
"SELECT {COLS} FROM items WHERE user_id = ? AND external_id = ?"
|
||||
))
|
||||
.bind(candidate.user_id.to_string())
|
||||
.bind(&candidate.external_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
Ok((existing.into_item()?, false))
|
||||
}
|
||||
|
||||
async fn get_item(&self, user_id: Uuid, id: Uuid) -> CoreResult<Option<ContentItem>> {
|
||||
let row: Option<ItemRow> = sqlx::query_as(&format!(
|
||||
"SELECT {COLS} FROM items WHERE id = ? AND user_id = ?"
|
||||
))
|
||||
.bind(id.to_string())
|
||||
.bind(user_id.to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
row.map(ItemRow::into_item).transpose()
|
||||
}
|
||||
|
||||
async fn set_item_state(&self, user_id: Uuid, id: Uuid, state: ItemState) -> CoreResult<()> {
|
||||
let res = sqlx::query("UPDATE items SET state = ? WHERE id = ? AND user_id = ?")
|
||||
.bind(state.as_str())
|
||||
.bind(id.to_string())
|
||||
.bind(user_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(CoreError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_score(&self, item_id: Uuid, score: f64) -> CoreResult<()> {
|
||||
sqlx::query("UPDATE items SET score = ? WHERE id = ?")
|
||||
.bind(score)
|
||||
.bind(item_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn feed_page(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
limit: i64,
|
||||
cursor: Option<&str>,
|
||||
include_saved: bool,
|
||||
) -> CoreResult<Vec<ContentItem>> {
|
||||
let states: &[&str] = if include_saved {
|
||||
&["feed", "saved"]
|
||||
} else {
|
||||
&["feed"]
|
||||
};
|
||||
let placeholders = states.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
|
||||
|
||||
// Keyset pagination over (score DESC, id DESC).
|
||||
let (cursor_clause, cursor_score, cursor_id) = match cursor {
|
||||
Some(c) => {
|
||||
let (score, id) = parse_cursor(c)?;
|
||||
(
|
||||
" AND (score < ? OR (score = ? AND id < ?))",
|
||||
Some(score),
|
||||
Some(id),
|
||||
)
|
||||
}
|
||||
None => ("", None, None),
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {COLS} FROM items WHERE user_id = ? AND state IN ({placeholders}){cursor_clause} \
|
||||
ORDER BY score DESC, id DESC LIMIT ?"
|
||||
);
|
||||
|
||||
let mut q = sqlx::query_as::<_, ItemRow>(&sql).bind(user_id.to_string());
|
||||
for s in states {
|
||||
q = q.bind(*s);
|
||||
}
|
||||
if let (Some(score), Some(id)) = (cursor_score, cursor_id) {
|
||||
q = q.bind(score).bind(score).bind(id);
|
||||
}
|
||||
q = q.bind(limit);
|
||||
|
||||
let rows = q.fetch_all(&self.pool).await.map_err(err::map)?;
|
||||
rows.into_iter().map(ItemRow::into_item).collect()
|
||||
}
|
||||
|
||||
async fn items_for_scoring(&self, user_id: Uuid, limit: i64) -> CoreResult<Vec<ContentItem>> {
|
||||
let rows: Vec<ItemRow> = sqlx::query_as(&format!(
|
||||
"SELECT {COLS} FROM items WHERE user_id = ? AND state IN ('candidate', 'feed') \
|
||||
ORDER BY ingested_at DESC LIMIT ?"
|
||||
))
|
||||
.bind(user_id.to_string())
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
rows.into_iter().map(ItemRow::into_item).collect()
|
||||
}
|
||||
|
||||
async fn user_ids_with_sources(&self) -> CoreResult<Vec<Uuid>> {
|
||||
let ids: Vec<(String,)> = sqlx::query_as("SELECT DISTINCT user_id FROM sources")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
ids.into_iter()
|
||||
.map(|(s,)| Uuid::parse_str(&s).map_err(err::uuid))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn record_signal(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
item_id: Uuid,
|
||||
action: newsfeed_entities::feed::SignalAction,
|
||||
) -> CoreResult<()> {
|
||||
let action = match action {
|
||||
newsfeed_entities::feed::SignalAction::View => "view",
|
||||
newsfeed_entities::feed::SignalAction::Click => "click",
|
||||
newsfeed_entities::feed::SignalAction::Save => "save",
|
||||
newsfeed_entities::feed::SignalAction::Dismiss => "dismiss",
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO signals (id, user_id, item_id, action, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(user_id.to_string())
|
||||
.bind(item_id.to_string())
|
||||
.bind(action)
|
||||
.bind(Utc::now())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Cursor is `"<score>:<uuid>"`; the id disambiguates equal scores.
|
||||
fn parse_cursor(c: &str) -> CoreResult<(f64, String)> {
|
||||
let (score, id) = c
|
||||
.split_once(':')
|
||||
.ok_or_else(|| CoreError::Storage("malformed feed cursor".into()))?;
|
||||
let score: f64 = score
|
||||
.parse()
|
||||
.map_err(|_| CoreError::Storage("malformed feed cursor score".into()))?;
|
||||
// Validate the id is a uuid but keep the string form for binding.
|
||||
Uuid::parse_str(id).map_err(err::uuid)?;
|
||||
Ok((score, id.to_string()))
|
||||
}
|
||||
33
crates/newsfeed-data/src/store/mod.rs
Normal file
33
crates/newsfeed-data/src/store/mod.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
//! The concrete [`SqliteStore`] and its per-domain port implementations.
|
||||
|
||||
mod interests;
|
||||
mod items;
|
||||
mod sessions;
|
||||
mod sources;
|
||||
mod tokens;
|
||||
mod users;
|
||||
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
|
||||
/// SQLite-backed adapter implementing all of the `newsfeed-core` ports.
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteStore {
|
||||
pub(crate) pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteStore {
|
||||
/// Wrap an existing pool. Prefer [`crate::connect`] which also runs migrations.
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Access the underlying pool (tests, health checks).
|
||||
pub fn pool(&self) -> &SqlitePool {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
/// Cheap readiness check: `true` when the database answers a trivial query.
|
||||
pub async fn ping(&self) -> bool {
|
||||
sqlx::query("SELECT 1").execute(&self.pool).await.is_ok()
|
||||
}
|
||||
}
|
||||
65
crates/newsfeed-data/src/store/sessions.rs
Normal file
65
crates/newsfeed-data/src/store/sessions.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use newsfeed_core::error::CoreResult;
|
||||
use newsfeed_core::ports::SessionStore;
|
||||
use newsfeed_entities::auth::Session;
|
||||
|
||||
use crate::err;
|
||||
use crate::rows::SessionRow;
|
||||
|
||||
use super::SqliteStore;
|
||||
|
||||
#[async_trait]
|
||||
impl SessionStore for SqliteStore {
|
||||
async fn create_session(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
token_hash: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> CoreResult<Session> {
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
sqlx::query(
|
||||
"INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind(user_id.to_string())
|
||||
.bind(token_hash)
|
||||
.bind(now)
|
||||
.bind(expires_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
|
||||
Ok(Session {
|
||||
id,
|
||||
user_id,
|
||||
created_at: now,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn find_session(&self, token_hash: &str) -> CoreResult<Option<Session>> {
|
||||
let row: Option<SessionRow> = sqlx::query_as(
|
||||
"SELECT id, user_id, created_at, expires_at FROM sessions \
|
||||
WHERE token_hash = ? AND expires_at > ?",
|
||||
)
|
||||
.bind(token_hash)
|
||||
.bind(Utc::now())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
row.map(SessionRow::into_session).transpose()
|
||||
}
|
||||
|
||||
async fn delete_session(&self, token_hash: &str) -> CoreResult<()> {
|
||||
sqlx::query("DELETE FROM sessions WHERE token_hash = ?")
|
||||
.bind(token_hash)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
113
crates/newsfeed-data/src/store/sources.rs
Normal file
113
crates/newsfeed-data/src/store/sources.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use newsfeed_core::error::CoreResult;
|
||||
use newsfeed_core::ports::SourceStore;
|
||||
use newsfeed_entities::source::{NewSource, Source};
|
||||
|
||||
use crate::err;
|
||||
use crate::rows::SourceRow;
|
||||
|
||||
use super::SqliteStore;
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, user_id, kind, name, url, weight, enabled, last_polled_at, created_at FROM sources";
|
||||
|
||||
#[async_trait]
|
||||
impl SourceStore for SqliteStore {
|
||||
async fn create_source(&self, user_id: Uuid, new: &NewSource) -> CoreResult<Source> {
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
let weight = new.weight.unwrap_or(1.0);
|
||||
sqlx::query(
|
||||
"INSERT INTO sources (id, user_id, kind, name, url, weight, enabled, created_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, ?)",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind(user_id.to_string())
|
||||
.bind(new.kind.as_str())
|
||||
.bind(&new.name)
|
||||
.bind(&new.url)
|
||||
.bind(weight)
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
|
||||
Ok(Source {
|
||||
id,
|
||||
user_id,
|
||||
kind: new.kind,
|
||||
name: new.name.clone(),
|
||||
url: new.url.clone(),
|
||||
weight,
|
||||
enabled: true,
|
||||
last_polled_at: None,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_sources(&self, user_id: Uuid) -> CoreResult<Vec<Source>> {
|
||||
let rows: Vec<SourceRow> = sqlx::query_as(&format!(
|
||||
"{SELECT} WHERE user_id = ? ORDER BY created_at DESC"
|
||||
))
|
||||
.bind(user_id.to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
rows.into_iter().map(SourceRow::into_source).collect()
|
||||
}
|
||||
|
||||
async fn find_source_by_name(&self, user_id: Uuid, name: &str) -> CoreResult<Option<Source>> {
|
||||
let row: Option<SourceRow> =
|
||||
sqlx::query_as(&format!("{SELECT} WHERE user_id = ? AND name = ?"))
|
||||
.bind(user_id.to_string())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
row.map(SourceRow::into_source).transpose()
|
||||
}
|
||||
|
||||
async fn delete_source(&self, user_id: Uuid, id: Uuid) -> CoreResult<()> {
|
||||
sqlx::query("DELETE FROM sources WHERE id = ? AND user_id = ?")
|
||||
.bind(id.to_string())
|
||||
.bind(user_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn due_rss_sources(
|
||||
&self,
|
||||
now: DateTime<Utc>,
|
||||
min_interval_secs: i64,
|
||||
limit: i64,
|
||||
) -> CoreResult<Vec<Source>> {
|
||||
// Due when never polled, or last poll older than the minimum interval.
|
||||
let cutoff = now - chrono::Duration::seconds(min_interval_secs);
|
||||
let rows: Vec<SourceRow> = sqlx::query_as(&format!(
|
||||
"{SELECT} WHERE kind = 'rss' AND enabled = 1 \
|
||||
AND (last_polled_at IS NULL OR last_polled_at < ?) \
|
||||
ORDER BY last_polled_at ASC NULLS FIRST LIMIT ?"
|
||||
))
|
||||
.bind(cutoff)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
rows.into_iter().map(SourceRow::into_source).collect()
|
||||
}
|
||||
|
||||
async fn mark_polled(&self, source_id: Uuid, at: DateTime<Utc>) -> CoreResult<()> {
|
||||
sqlx::query("UPDATE sources SET last_polled_at = ? WHERE id = ?")
|
||||
.bind(at)
|
||||
.bind(source_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
90
crates/newsfeed-data/src/store/tokens.rs
Normal file
90
crates/newsfeed-data/src/store/tokens.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use newsfeed_core::error::CoreResult;
|
||||
use newsfeed_core::ports::TokenStore;
|
||||
use newsfeed_entities::auth::ApiTokenInfo;
|
||||
|
||||
use crate::err;
|
||||
use crate::rows::ApiTokenRow;
|
||||
|
||||
use super::SqliteStore;
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, user_id, name, prefix, created_at, last_used_at, revoked_at FROM api_tokens";
|
||||
|
||||
#[async_trait]
|
||||
impl TokenStore for SqliteStore {
|
||||
async fn create_token(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
name: &str,
|
||||
prefix: &str,
|
||||
token_hash: &str,
|
||||
) -> CoreResult<ApiTokenInfo> {
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
sqlx::query(
|
||||
"INSERT INTO api_tokens (id, user_id, name, prefix, token_hash, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind(user_id.to_string())
|
||||
.bind(name)
|
||||
.bind(prefix)
|
||||
.bind(token_hash)
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
|
||||
Ok(ApiTokenInfo {
|
||||
id,
|
||||
user_id,
|
||||
name: name.to_string(),
|
||||
prefix: prefix.to_string(),
|
||||
created_at: now,
|
||||
last_used_at: None,
|
||||
revoked_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_tokens(&self, user_id: Uuid) -> CoreResult<Vec<ApiTokenInfo>> {
|
||||
let rows: Vec<ApiTokenRow> = sqlx::query_as(&format!(
|
||||
"{SELECT} WHERE user_id = ? ORDER BY created_at DESC"
|
||||
))
|
||||
.bind(user_id.to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
rows.into_iter().map(ApiTokenRow::into_info).collect()
|
||||
}
|
||||
|
||||
async fn authenticate_token(&self, token_hash: &str) -> CoreResult<Option<ApiTokenInfo>> {
|
||||
let now = Utc::now();
|
||||
// Record use and fetch in one round trip via RETURNING (SQLite ≥ 3.35).
|
||||
let row: Option<ApiTokenRow> = sqlx::query_as(&format!(
|
||||
"UPDATE api_tokens SET last_used_at = ? \
|
||||
WHERE token_hash = ? AND revoked_at IS NULL \
|
||||
RETURNING {}",
|
||||
"id, user_id, name, prefix, created_at, last_used_at, revoked_at"
|
||||
))
|
||||
.bind(now)
|
||||
.bind(token_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
row.map(ApiTokenRow::into_info).transpose()
|
||||
}
|
||||
|
||||
async fn revoke_token(&self, user_id: Uuid, id: Uuid) -> CoreResult<()> {
|
||||
sqlx::query("UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL")
|
||||
.bind(Utc::now())
|
||||
.bind(id.to_string())
|
||||
.bind(user_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
67
crates/newsfeed-data/src/store/users.rs
Normal file
67
crates/newsfeed-data/src/store/users.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use newsfeed_core::error::CoreResult;
|
||||
use newsfeed_core::ports::{StoredCredential, UserStore};
|
||||
use newsfeed_entities::user::User;
|
||||
|
||||
use crate::err;
|
||||
use crate::rows::UserRow;
|
||||
|
||||
use super::SqliteStore;
|
||||
|
||||
#[async_trait]
|
||||
impl UserStore for SqliteStore {
|
||||
async fn create_user(
|
||||
&self,
|
||||
username: &str,
|
||||
email: &str,
|
||||
password_hash: &str,
|
||||
) -> CoreResult<User> {
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, email, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind(username)
|
||||
.bind(email)
|
||||
.bind(password_hash)
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
|
||||
Ok(User {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
email: email.to_string(),
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_user(&self, id: Uuid) -> CoreResult<Option<User>> {
|
||||
let row: Option<UserRow> = sqlx::query_as(
|
||||
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = ?",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
row.map(UserRow::into_user).transpose()
|
||||
}
|
||||
|
||||
async fn find_credential(&self, identifier: &str) -> CoreResult<Option<StoredCredential>> {
|
||||
let row: Option<UserRow> = sqlx::query_as(
|
||||
"SELECT id, username, email, password_hash, created_at FROM users \
|
||||
WHERE username = ? OR email = ?",
|
||||
)
|
||||
.bind(identifier)
|
||||
.bind(identifier)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(err::map)?;
|
||||
row.map(UserRow::into_credential).transpose()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user