feat: scaffold newsfeed — user-controlled news feed
Some checks failed
deploy / build (push) Failing after 7s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped

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:
2026-07-08 11:36:57 +03:00
commit 5ce52fff4d
104 changed files with 10963 additions and 0 deletions

View 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

View 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);

View 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}"))
}

View 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)
}

View 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,
})
}
}

View 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(())
}
}

View 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()))
}

View 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()
}
}

View 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(())
}
}

View 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(())
}
}

View 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(())
}
}

View 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()
}
}