strip session-added comments
Some checks failed
CI / Check / Test (push) Has been cancelled
CI / Check / Test (pull_request) Has been cancelled

This commit is contained in:
2026-10-26 10:32:38 +01:00
parent a270632e87
commit f020108024
138 changed files with 0 additions and 719 deletions

View File

@@ -22,8 +22,6 @@ jobs:
with:
components: rustfmt, clippy
# Caches ~/.cargo registry/git and target/, keyed on toolchain + Cargo.lock;
# prunes workspace-crate artifacts so the cache holds only compiled deps.
- name: Cache cargo
uses: Swatinem/rust-cache@v2
@@ -33,7 +31,6 @@ jobs:
- name: clippy
run: cargo clippy --workspace --all-targets -- -D warnings
# --workspace covers tui (it is outside default-members).
- name: test
run: cargo test --workspace

View File

@@ -22,8 +22,6 @@ jobs:
with:
components: rustfmt, clippy
# Caches ~/.cargo registry/git and target/, keyed on toolchain + Cargo.lock;
# prunes workspace-crate artifacts so the cache holds only compiled deps.
- name: Cache cargo
uses: Swatinem/rust-cache@v2
@@ -33,7 +31,6 @@ jobs:
- name: clippy
run: cargo clippy --workspace --all-targets -- -D warnings
# --workspace covers tui (it is outside default-members).
- name: test
run: cargo test --workspace

View File

@@ -16,7 +16,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN cargo install cargo-chef --locked --version 0.1.78
WORKDIR /build
# ----- planner: dependency recipe (changes only when manifests/lockfile change) -----
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
@@ -27,14 +26,10 @@ FROM chef AS builder
# Features: federation (ActivityPub), nats (EVENT_BUS_BACKEND=nats).
# Build without federation: --build-arg FEATURES=
# To add NATS support: --build-arg FEATURES=federation,nats
# Declared before cook so cook and build compile identical feature sets.
ARG FEATURES=federation
# .cargo carries the registry config needed to fetch private crates
COPY .cargo ./.cargo
COPY --from=planner /build/recipe.json recipe.json
# target/ is a normal layer (no cache mount) so the cooked deps live in the image
# layer cache; only the registry/git download caches are mounted.
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
cargo chef cook --release -p server -p worker --no-default-features --features "${FEATURES}" --recipe-path recipe.json

View File

@@ -10,11 +10,6 @@ use k_ap::ActivityPubService;
use crate::urls::actor_url;
/// Broadcasts the actor `Delete` to the user's accepted followers.
///
/// k-ap only enqueues the per-inbox deliveries; they are signed later from the
/// tombstoned user row and keypair, which the purge keeps until the tombstone
/// cleanup (see `AccountPurger`).
pub struct ApDeletionNotifier {
service: Arc<ActivityPubService>,
instance: InstanceIdentity,

View File

@@ -63,9 +63,6 @@ impl EventHandler for ActivityPubEventHandler {
}
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
// A deleted account must not speak again: its queued events would
// otherwise sign and send from the tombstone after the Delete. Only
// the Delete itself (sent by the deletion notifier) goes out.
if let Some(author) = self.outbound_author(event)
&& self.is_tombstoned(&author).await
{
@@ -173,8 +170,6 @@ impl EventHandler for ActivityPubEventHandler {
}
impl ActivityPubEventHandler {
/// The local account an outbound activity would be sent as. A queued
/// delivery counts unless it is the account's `Delete`.
fn outbound_author(&self, event: &DomainEvent) -> Option<UserId> {
match event {
DomainEvent::ReviewLogged { user_id, .. }

View File

@@ -14,7 +14,6 @@ pub struct RemoteReviewUpdate<'a> {
pub spoilers: bool,
}
/// A remote review to persist, with the events that ride the same write.
pub struct NewRemoteReview<'a> {
pub review: &'a Review,
pub ap_id: &'a str,

View File

@@ -159,7 +159,6 @@ impl ApObjectHandler for ReviewObjectHandler {
spoilers: obj.contains_spoilers,
});
// The enrichment request rides the review write (one transaction).
let events: Vec<DomainEvent> = obj
.external_metadata_id
.as_ref()

View File

@@ -39,7 +39,6 @@ impl CompositeSocialAdapter {
}
}
/// Best-effort publish for writes k_ap owns (no shared transaction).
async fn announce(&self, events: &[DomainEvent]) {
for event in events {
if let Err(e) = self.event_publisher.publish(event).await {
@@ -84,8 +83,6 @@ impl SocialCommand for CompositeSocialAdapter {
SocialIdentity::Remote { actor_url } => actor_url.clone(),
},
};
// Not atomic: k_ap owns the transaction, so the events are published
// best-effort after its write succeeds.
self.ap_service
.follow(follower.value(), &handle)
.await
@@ -102,7 +99,6 @@ impl SocialCommand for CompositeSocialAdapter {
) -> Result<(), DomainError> {
match target {
SocialIdentity::Local(_) => self.local.unfollow(follower, target, events).await,
// Not atomic: k_ap owns the transaction.
SocialIdentity::Remote { .. } => {
self.ap_service
.unfollow(follower.value(), &self.instance.actor_url_of(target))
@@ -122,7 +118,6 @@ impl SocialCommand for CompositeSocialAdapter {
) -> Result<(), DomainError> {
match requester {
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester, events).await,
// Not atomic: k_ap owns the transaction.
SocialIdentity::Remote { .. } => {
self.ap_service
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
@@ -142,7 +137,6 @@ impl SocialCommand for CompositeSocialAdapter {
) -> Result<(), DomainError> {
match requester {
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester, events).await,
// Not atomic: k_ap owns the transaction.
SocialIdentity::Remote { .. } => {
self.ap_service
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
@@ -162,7 +156,6 @@ impl SocialCommand for CompositeSocialAdapter {
) -> Result<(), DomainError> {
match follower {
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower, events).await,
// Not atomic: k_ap owns the transaction.
SocialIdentity::Remote { .. } => {
self.ap_service
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
@@ -174,7 +167,6 @@ impl SocialCommand for CompositeSocialAdapter {
}
}
/// Not atomic: k_ap owns the transaction.
async fn block(
&self,
blocker: &UserId,
@@ -190,7 +182,6 @@ impl SocialCommand for CompositeSocialAdapter {
Ok(())
}
/// Not atomic: k_ap owns the transaction.
async fn unblock(
&self,
blocker: &UserId,

View File

@@ -8,9 +8,6 @@ use axum::{
};
use domain::{ports::UserRepository, value_objects::UserId};
/// Answers `410 Gone` for the per-user ActivityPub endpoints of a deleted
/// account. k-ap resolves tombstones so it can sign the account's `Delete`
/// deliveries; this keeps that from making the actor look alive.
pub async fn tombstone_gate(
State(users): State<Arc<dyn UserRepository>>,
request: Request,
@@ -27,9 +24,6 @@ pub async fn tombstone_gate(
next.run(request).await
}
/// The user id in any `/users/{id}/...` path (outbox, inbox, collections,
/// content objects such as `/users/{id}/reviews/{rid}`). The bare actor path
/// has its own tombstone response.
pub fn gated_user(path: &str) -> Option<uuid::Uuid> {
let rest = path.strip_prefix("/users/")?;
let (id, endpoint) = rest.split_once('/')?;

View File

@@ -64,10 +64,6 @@ impl DomainUserRepoAdapter {
impl ApUserRepository for DomainUserRepoAdapter {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
let user_id = UserId::from_uuid(id);
// A deleted account still resolves here, so k-ap can sign the queued
// `Delete` deliveries until the tombstone cleanup. Username lookups
// below stay live-only, and the AP endpoints answer 410 for it
// (`tombstone_gate`, `actor_document_response`).
let user = match self.repo.find_by_id(&user_id).await? {
Some(u) => u,
None => match self.repo.find_tombstoned_by_id(&user_id).await? {

View File

@@ -1,11 +1,3 @@
//! Outbound activity ids across a redelivered event, through the real k-ap
//! `ActivityPubService` (delivery captured via its `EventPublisher` seam).
//!
//! k-ap 0.5.0 derives Create ids from the object id (UUIDv5,
//! `service/broadcast.rs:285-288`) but mints Update/Delete/Move/actor-update ids
//! randomly (`broadcast.rs:81,129,157,210,321,343,377` -> `url_scheme.rs:53`,
//! `Uuid::new_v4`) with no override.
use std::sync::Arc;
use std::time::Duration;
@@ -189,8 +181,6 @@ async fn activitypub_is_idempotent_when_run_twice() {
assert_eq!(first, second, "a redelivered Create keeps its activity id");
}
/// Characterises the k-ap 0.5.0 limitation recorded in ADR-0015. If k-ap starts
/// minting deterministic Delete ids this fails: upgrade the ADR and flip it.
#[tokio::test]
async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() {
let mut h = harness().await;
@@ -207,8 +197,6 @@ async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() {
assert_ne!(first, second);
}
/// Queued events of a deleted account must not sign and send from its
/// tombstone; the account's own Delete still goes out.
#[tokio::test]
async fn a_deleted_account_sends_nothing_but_its_delete() {
let mut h = harness().await;
@@ -240,7 +228,6 @@ async fn a_deleted_account_sends_nothing_but_its_delete() {
signing_actor_id: h.user.value(),
};
// The review repository panics if the handler gets past the guard.
for event in [&logged, &added, &queued] {
h.handler.handle(event).await.unwrap();
}

View File

@@ -7,7 +7,6 @@ license = "MIT"
[features]
default = ["federation"]
# Enables federation-gated handlers; forwards to application/federation
federation = ["application/federation"]
[dependencies]

View File

@@ -16,9 +16,6 @@ use axum::{Router, routing};
use crate::state::AppState;
/// JSON API routes (mounted under `/api/v1`), minus the webhook ingestion
/// endpoints ([`webhook_router`], separately rate limited) and the
/// federation-gated admin endpoints (`federation::api_routes`).
pub fn router() -> Router<AppState> {
let base = Router::new()
.route("/diary", routing::get(diary::get_diary))
@@ -150,7 +147,6 @@ pub fn router() -> Router<AppState> {
base.merge(social_routes())
}
/// Media-server webhook ingestion endpoints (mounted under `/api/v1`).
pub fn webhook_router() -> Router<AppState> {
Router::new()
.route(

View File

@@ -14,7 +14,6 @@ pub const CONFLICT: &str = "CONFLICT";
pub const RATE_LIMITED: &str = "RATE_LIMITED";
pub const INTERNAL: &str = "INTERNAL";
/// The only constructor of JSON error bodies in this crate.
pub fn refuse(status: StatusCode, code: &'static str, message: impl Into<String>) -> Response {
(
status,

View File

@@ -1,5 +1,3 @@
//! JSON API extractors whose rejections are refused in the one error shape.
use axum::{
extract::{
FromRequest, FromRequestParts, Multipart, Path, Query, Request,
@@ -13,10 +11,6 @@ use serde::de::DeserializeOwned;
use crate::errors::{INTERNAL, INVALID_INPUT, refuse};
/// Malformed request (bad JSON, query or path), refused as `INVALID_INPUT`.
/// Status follows axum: 400 syntax, 415 content type, 422 data errors, 413 size.
/// A rejection with a 5xx status is a server-side fault (e.g. a route that
/// declares no path parameter), refused as `INTERNAL`.
pub struct MalformedRequest {
status: StatusCode,
message: String,
@@ -69,7 +63,6 @@ impl From<MultipartRejection> for MalformedRequest {
}
}
/// `axum::extract::Multipart` with refusals in the one error shape.
pub struct Upload(pub Multipart);
impl<S> FromRequest<S> for Upload
@@ -83,7 +76,6 @@ where
}
}
/// `axum::Json` with refusals in the one error shape.
pub struct Body<T>(pub T);
impl<S, T> FromRequest<S> for Body<T>
@@ -99,7 +91,6 @@ where
}
}
/// `axum::extract::Query` with refusals in the one error shape.
pub struct Params<T>(pub T);
impl<S, T> FromRequestParts<S> for Params<T>
@@ -115,7 +106,6 @@ where
}
}
/// `axum::extract::Path` with refusals in the one error shape.
pub struct PathId<T>(pub T);
impl<S, T> FromRequestParts<S> for PathId<T>

View File

@@ -146,7 +146,6 @@ where
)
.await
.map_err(ApiError)?
// `None` only if the account vanished between `ensure_active` and here.
.ok_or_else(|| ApiError(DomainError::NotFound("user not found".into())))?;
if is_admin {
Ok(AdminApiUser(user_id))
@@ -175,7 +174,6 @@ where
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
// `None` only if the account vanished between `ensure_active` and here.
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
if is_admin {
Ok(AdminUser(user_id))

View File

@@ -1,7 +1,3 @@
//! Federation-only HTTP surface. This is the one module that decides, per
//! build, whether federation routes exist: both builds expose the same
//! functions, so `router.rs` and the api/html modules stay free of `cfg`.
#[cfg(feature = "federation")]
pub(crate) mod blocklist_api;
#[cfg(feature = "federation")]
@@ -18,7 +14,6 @@ use axum::{Router, routing};
use crate::state::AppState;
/// Classic-UI routes that only exist with federation (AP collections, admin pages).
#[cfg(feature = "federation")]
pub fn html_routes() -> Router<AppState> {
Router::new()
@@ -53,7 +48,6 @@ pub fn html_routes() -> Router<AppState> {
)
}
/// JSON API routes that only exist with federation (blocklist administration).
#[cfg(feature = "federation")]
pub fn api_routes() -> Router<AppState> {
Router::new()
@@ -80,8 +74,6 @@ pub fn api_routes() -> Router<AppState> {
)
}
/// Without federation the follower/following collection URLs fall back to the
/// classic list pages.
#[cfg(not(feature = "federation"))]
pub fn html_routes() -> Router<AppState> {
Router::new()
@@ -108,8 +100,6 @@ pub fn api_routes() -> Router<AppState> {
Router::new()
}
/// ActivityPub content negotiation for `GET /users/{id}`: federation builds
/// answer AP clients with the actor document, others always render HTML.
#[cfg(not(feature = "federation"))]
pub async fn actor_document_response(
_state: &AppState,

View File

@@ -6,8 +6,6 @@ use uuid::Uuid;
use crate::state::AppState;
/// Answers ActivityPub clients (`Accept: application/activity+json` or
/// `application/ld+json`) with the actor document; `None` means "render HTML".
pub async fn actor_document_response(
state: &AppState,
headers: &HeaderMap,

View File

@@ -19,7 +19,6 @@ pub fn extract_from_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
})
}
/// Router state for [`csrf_middleware`]: whether cookies carry the `Secure` attribute.
#[derive(Clone, Copy)]
pub struct SecureCookies(pub bool);

View File

@@ -23,8 +23,6 @@ use axum::{Router, routing};
use crate::state::AppState;
/// Classic server-rendered UI routes, minus the rate-limited auth forms
/// ([`auth_router`]) and the federation-gated pages (`federation::html_routes`).
pub fn router() -> Router<AppState> {
let base = Router::new()
.route("/", routing::get(diary::get_activity_feed_html))
@@ -127,7 +125,6 @@ pub fn router() -> Router<AppState> {
base.merge(social_routes())
}
/// Login, logout and registration forms; the caller adds the auth rate limiter.
pub fn auth_router() -> Router<AppState> {
Router::new()
.route(

View File

@@ -233,11 +233,8 @@ pub async fn post_delete_user(
let target_id = UserId::from_uuid(target);
let own = actor == target_id;
match delete_account(&state, actor, target_id, &form.password).await {
// An admin removing their own account ends their own session too.
Ok(()) if own => super::auth::get_logout(State(state)).await.into_response(),
Ok(()) => axum::response::Redirect::to("/users").into_response(),
// Same as other forbidden classic actions: a plain 403, not a redirect
// to a page that would not show the error.
Err(e @ DomainError::Forbidden(_)) => crate::errors::domain_error_response(e),
Err(e) => axum::response::Redirect::to(&format!(
"/users/{target}?error={}",

View File

@@ -48,7 +48,6 @@ async fn health_handler() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({ "status": "ok" }))
}
/// JSON API rate-limit refusals use the one error shape.
fn slow_down(reason: RejectionReason) -> axum::response::Response {
match reason {
RejectionReason::QuotaExceeded { .. } => refuse(
@@ -64,7 +63,6 @@ fn slow_down(reason: RejectionReason) -> axum::response::Response {
}
}
/// Every `/api/v1` limiter is built here so its refusals use the one error shape.
fn api_governor(quota: Quota) -> axum_governor::GovernorConfig<std::net::IpAddr> {
GovernorConfigBuilder::default()
.with_extractor(PeerIp::default())

View File

@@ -1,5 +1,3 @@
//! Request/response helpers shared by the JSON API and the classic HTML UI.
use crate::state::AppState;
use domain::models::import::DomainField;

View File

@@ -1,5 +1,3 @@
//! Fixtures shared by the in-crate mapper tests.
use domain::value_objects::{SocialActor, SocialIdentity, UserId};
pub(crate) fn local_actor(uid: &UserId) -> SocialActor {

View File

@@ -45,9 +45,6 @@ impl EventHandler for ImageConversionHandler {
return Ok(());
}
// `get` falls back to `{key}.webp` / `{key}.avif`, which would re-encode
// an already converted image on redelivery. Probe the exact key first:
// once the original is gone, an earlier delivery finished the conversion.
if !self.storage.exists(&key).await? {
tracing::warn!(%key, "original image already gone, treating conversion as done");
return Ok(());

View File

@@ -181,7 +181,6 @@ async fn image_conversion_is_idempotent_when_run_twice() {
handler.handle(&event).await.unwrap();
let converted = storage.get(&new_key).await.unwrap();
// Redelivery after the original was swapped and deleted.
handler.handle(&event).await.unwrap();
assert_eq!(image_ref.swaps().len(), 1, "one swap");

View File

@@ -16,7 +16,6 @@ pub(crate) struct OmdbProvider {
impl OmdbProvider {
pub(crate) fn new(api_key: String) -> Self {
// reqwest is built with rustls-no-provider; install ring so this works without bootstrap.
let _ = rustls::crypto::ring::default_provider().install_default();
Self {
client: reqwest::Client::new(),

View File

@@ -15,7 +15,6 @@ pub(crate) struct TmdbProvider {
impl TmdbProvider {
pub(crate) fn new(api_key: String) -> Self {
// reqwest is built with rustls-no-provider; install ring so this works without bootstrap.
let _ = rustls::crypto::ring::default_provider().install_default();
Self {
client: reqwest::Client::new(),

View File

@@ -1,5 +1,3 @@
//! Fresh test binary, no bootstrap: constructing the client must not panic
//! for lack of a rustls CryptoProvider.
#[test]
fn omdb_client_builds_without_bootstrap() {
let _ = metadata::MetadataClientImpl::new_omdb("key".into());

View File

@@ -1,5 +1,3 @@
//! Fresh test binary, no bootstrap: constructing the client must not panic
//! for lack of a rustls CryptoProvider.
#[test]
fn tmdb_client_builds_without_bootstrap() {
let _ = metadata::MetadataClientImpl::new_tmdb("key".into());

View File

@@ -11,7 +11,6 @@ pub struct NatsConfig {
pub subject_prefix: String,
pub stream_name: String,
pub consumer_name: String,
/// JetStream redelivery bound, same as the DB queue's attempt limit.
pub max_deliver: i64,
}

View File

@@ -25,11 +25,8 @@ struct NatsJetStreamAckHandle {
max_deliver: i64,
}
/// How to nak a failed delivery: the same backoff schedule as the SQLite
/// queue, keyed by JetStream's delivery count (1 on first delivery).
struct NakPlan {
delay: std::time::Duration,
/// This was the last delivery `max_deliver` allows.
exhausted: bool,
}
@@ -82,9 +79,6 @@ impl AckHandle for NatsJetStreamAckHandle {
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
/// JetStream counts every redelivery toward `max_deliver`, deferrals
/// included. The delay is at least the remaining claim lease, so the
/// deliveries allowed by `max_deliver` still span more than one lease.
async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError> {
self.message
.ack_with(AckKind::Nak(Some(delay)))
@@ -159,20 +153,10 @@ fn consume_from_rx(rx: EnvelopeRx) -> BoxStream<'static, Result<EventEnvelope, D
}))
}
/// How long JetStream remembers `Nats-Msg-Id`s. A row re-relayed within this
/// window (crash between publish and mark-done, or a lease reclaim) is dropped
/// by the broker; a duplicate arriving later is caught by the processed-event
/// ledger, which keeps its rows for 30 days. Set explicitly so it does not
/// silently follow the server default.
const DEDUP_WINDOW: std::time::Duration = std::time::Duration::from_secs(60 * 60);
const ACK_WAIT: std::time::Duration = std::time::Duration::from_secs(600);
/// `Some(updated)` when an existing stream's config differs from what this
/// build needs. `get_or_create_stream` only applies config on creation, so a
/// stream created by an older build would otherwise keep the server default
/// duplicate window (2 minutes). Only the fields this code owns are changed;
/// everything else the operator set is preserved.
fn stream_config_update(existing: &StreamConfig) -> Option<StreamConfig> {
(existing.duplicate_window != DEDUP_WINDOW).then(|| StreamConfig {
duplicate_window: DEDUP_WINDOW,
@@ -180,10 +164,6 @@ fn stream_config_update(existing: &StreamConfig) -> Option<StreamConfig> {
})
}
/// `Some(updated)` when an existing durable consumer disagrees on `max_deliver`
/// or `ack_wait`. The update is a clone of the existing config with only those
/// two fields changed, so operator-set fields survive and immutable ones
/// (durable name, filter subject, deliver policy) cannot mismatch.
fn consumer_config_update(
existing: &jetstream::consumer::Config,
desired_max_deliver: i64,

View File

@@ -12,8 +12,6 @@ use std::sync::Arc;
use domain::ports::{EventConsumer, EventRelaySink};
/// The worker's NATS side: where handlers read events from, and where the
/// outbox relay writes them to.
pub async fn create_bus(
cfg: NatsConfig,
) -> anyhow::Result<(Arc<dyn EventConsumer>, Arc<dyn EventRelaySink>)> {
@@ -29,7 +27,6 @@ pub async fn create_bus(
Arc::new(NatsRelaySink::new_core(client, cfg.subject_prefix)),
)
}
// The consumer creates the stream the relay publishes into.
NatsMode::JetStream => (
Arc::new(NatsJetStreamConsumer::create(&cfg, client.clone()).await?),
Arc::new(NatsRelaySink::new_jetstream(client, cfg.subject_prefix)),

View File

@@ -9,13 +9,9 @@ use event_payload::EventPayload;
use crate::{payload::NatsEventMessage, subject::event_to_subject};
/// What goes on the wire for one outbox row. Built purely from the row, so
/// relaying it again (after a crash between publish and mark-done) yields the
/// same `Nats-Msg-Id` and JetStream drops the duplicate.
pub(crate) struct RelayMessage {
pub subject: String,
pub payload: Vec<u8>,
/// `None` for rows queued before identities existed: those get no dedup.
pub msg_id: Option<String>,
}
@@ -42,8 +38,6 @@ enum Transport {
JetStream(jetstream::Context),
}
/// Publishes outbox rows to NATS. JetStream mode waits for the broker ack and
/// sets `Nats-Msg-Id`; core mode is fire-and-forget with no broker-side dedup.
pub struct NatsRelaySink {
transport: Transport,
subject_prefix: String,

View File

@@ -16,7 +16,6 @@ pub struct ReqwestPosterFetcher {
impl ReqwestPosterFetcher {
pub fn new(config: PosterFetcherConfig) -> anyhow::Result<Self> {
// reqwest is built with rustls-no-provider; install ring so this works without bootstrap.
let _ = rustls::crypto::ring::default_provider().install_default();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(config.timeout_seconds))

View File

@@ -1,5 +1,3 @@
//! Fresh test binary, no bootstrap: constructing the client must not panic
//! for lack of a rustls CryptoProvider.
use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher};
#[test]

View File

@@ -1,5 +1,3 @@
//! A remote review and the enrichment request it triggers commit together.
use activitypub::{NewRemoteReview, RemoteReviewRepository};
use domain::{
events::DomainEvent,

View File

@@ -16,9 +16,6 @@ use tokio::sync::{Mutex, mpsc};
use crate::shared::enqueue_events;
pub(crate) use payload::DbEventPayload;
/// How long a claim holds a row. A worker that dies between claim and
/// ack/nack strands its rows in `processing`; once the lease lapses the claim
/// query takes them again. Must exceed the slowest handler.
pub(crate) const CLAIM_LEASE_SECONDS: i64 = 600;
pub struct DbEventQueueConfig {
@@ -140,9 +137,6 @@ struct QueueRow {
claimed_at: Option<String>,
}
/// Claims a batch in one atomic statement. A read-then-write transaction can
/// fail with SQLITE_BUSY_SNAPSHOT (517) when another connection commits in
/// between, which `busy_timeout` cannot retry.
async fn claim_batch(
pool: &SqlitePool,
config: &DbEventQueueConfig,
@@ -168,13 +162,10 @@ async fn claim_batch(
.await
.map_err(|e| DomainError::InfrastructureError(format!("claim batch: {e}")))?;
// RETURNING order is unspecified; deliver oldest first.
rows.sort_by(|a, b| (&a.next_attempt_at, a.id).cmp(&(&b.next_attempt_at, b.id)));
Ok(rows)
}
/// A row that cannot be decoded can never succeed, and has no ack handle to
/// settle it with; park it so it is neither retried forever nor left claimed.
async fn dead_letter_undecodable(pool: &SqlitePool, row_id: i64, error: &DomainError) {
tracing::error!(
row_id,
@@ -223,14 +214,10 @@ struct DbAckHandle {
row_id: i64,
attempts: i32,
max_attempts: i32,
/// The claim this handle belongs to. Settling is conditional on it, so a
/// worker whose lease lapsed and was reclaimed cannot flip the new claim's row.
claimed_at: Option<String>,
}
impl DbAckHandle {
/// Runs a settle statement whose last two binds are the row id and claim
/// stamp. Zero rows changed means the claim is no longer ours.
async fn settle<'q>(
&self,
what: &str,

View File

@@ -1,7 +1,3 @@
//! Inline (not under `tests/`) because `claim_batch` is private and the race
//! only shows on its claim path; two pools on a file-backed WAL database are
//! needed, as in production.
use std::str::FromStr;
use sqlx::SqlitePool;
@@ -73,7 +69,6 @@ async fn claiming_while_another_connection_writes_does_not_fail() {
.await
.expect("claim must not fail while another connection writes");
if batch.is_empty() {
// an empty UPDATE still takes the write lock; don't starve the writer
tokio::task::yield_now().await;
}
claimed += batch.len();
@@ -315,8 +310,6 @@ mod stale_handle {
use super::{claim_batch, config};
use crate::event_queue::{CLAIM_LEASE_SECONDS, SqliteEventQueue};
/// Claims a row, lets its lease lapse, and has another worker reclaim it.
/// Returns the first (now stale) worker's envelope.
async fn reclaimed() -> (sqlx::SqlitePool, EventEnvelope) {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
@@ -340,8 +333,6 @@ mod stale_handle {
.await
.unwrap();
assert_eq!(claim_batch(&pool, &config(10)).await.unwrap().len(), 1);
// Real reclaims happen a lease later, so their timestamp differs from
// the stale handle's; the claim clock has only second resolution.
let second_claim = (chrono::Utc::now() - chrono::Duration::seconds(5))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string();

View File

@@ -20,8 +20,6 @@ pub use repositories::{
create_federated_profile_query, create_image_ref, create_person_adapter, create_search_adapter,
};
/// Row helpers shared with the `sqlite-federation` crate (which cannot live
/// here because it depends on `activitypub`/`k-ap`).
pub mod persistence {
pub use crate::shared::{datetime_to_str, enqueue_events};
}
@@ -79,7 +77,6 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
wire_pool(pool).await
}
/// Migrates an already-connected pool and builds every repository on it.
pub async fn wire_pool(pool: SqlitePool) -> anyhow::Result<SqliteWireOutput> {
use anyhow::Context;

View File

@@ -3,10 +3,6 @@ use std::time::Duration;
use domain::errors::DomainError;
use sqlx::SqlitePool;
/// Server and worker both migrate at startup, so two processes can race on the
/// same database. The loser's migration fails (e.g. duplicate column) because
/// the winner already applied it; re-running re-reads `_sqlx_migrations` and
/// skips what is done. Only an error that persists across all retries is returned.
const RETRY_BACKOFFS: [Duration; 5] = [
Duration::from_millis(100),
Duration::from_millis(250),

View File

@@ -9,10 +9,6 @@ use crate::shared::map_sqlx_error;
const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
/// Every table the purge empties for the account, with the column that holds
/// the user id. `reviews` is further restricted to the user's own (non-remote)
/// rows. A guard test compares this list with the schema, so a new
/// user-owned table cannot be forgotten.
pub const PURGED_USER_TABLES: &[(&str, &str)] = &[
("reviews", "user_id"),
("watchlist_entries", "user_id"),
@@ -30,13 +26,8 @@ pub const PURGED_USER_TABLES: &[(&str, &str)] = &[
("blocked_actors", "local_user_id"),
];
/// Tables that reference a user but are kept until the tombstone cleanup:
/// the keypair is needed to sign queued federation deliveries.
pub const RETAINED_USER_TABLES: &[&str] = &["ap_local_actors"];
/// Columns that identify the user by actor or content URL rather than by id.
/// The purge matches rows on them; the guard test asserts each still exists so
/// a rename cannot silently stop the purge from clearing them.
pub const URL_KEYED_USER_COLUMNS: &[(&str, &str)] = &[
("ap_announces", "object_url"),
("ap_announces", "actor_url"),
@@ -44,9 +35,6 @@ pub const URL_KEYED_USER_COLUMNS: &[(&str, &str)] = &[
("ap_following", "remote_actor_url"),
];
/// Columns holding serialized events and activities that carry the user's id
/// (the outbox log keeps finished rows, including authored activity JSON) and
/// the global wrap-up reports, whose `most_active_user` names a member.
pub const PAYLOAD_KEYED_USER_COLUMNS: &[(&str, &str)] = &[
("event_queue", "payload"),
("wrap_up_records", "report_json"),
@@ -95,8 +83,6 @@ impl AccountPurger for SqliteAccountPurger {
let base = self.instance.base_url().to_string();
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
// Strip first: it claims the purge. Only a tombstoned, not yet purged
// account matches, so a live account (or a re-run) changes nothing.
let claimed = sqlx::query(
"UPDATE users SET email = 'deleted-' || id || '@deleted.invalid',
password_hash = '!purged', display_name = NULL, bio = NULL,
@@ -113,7 +99,6 @@ impl AccountPurger for SqliteAccountPurger {
return Ok(());
}
// Announces of the user's content, matched before the reviews go.
sqlx::query(
"DELETE FROM ap_announces
WHERE object_url IN (
@@ -130,11 +115,6 @@ impl AccountPurger for SqliteAccountPurger {
.await
.map_err(map_sqlx_error)?;
// The outbox log: finished events and activities authored as, or
// mentioning, this user. Unfinished rows are never touched (they
// belong to other users' work too and finish or dead-letter on their
// own); the tombstone cleanup removes their history later. The
// `UserDeleted` row that triggered this run stays until then as well.
sqlx::query(
"DELETE FROM event_queue
WHERE instr(payload, ?1) > 0
@@ -146,8 +126,6 @@ impl AccountPurger for SqliteAccountPurger {
.await
.map_err(map_sqlx_error)?;
// Global wrap-up reports name their most active member; redact it
// (the type allows none) and keep the rest of the report.
sqlx::query(
"UPDATE wrap_up_records
SET report_json = json_set(report_json, '$.most_active_user', NULL)
@@ -159,7 +137,6 @@ impl AccountPurger for SqliteAccountPurger {
.await
.map_err(map_sqlx_error)?;
// Other accounts' follows of this one.
for table in ["ap_followers", "ap_following"] {
sqlx::query(&format!("DELETE FROM {table} WHERE remote_actor_url = ?"))
.bind(&actor_url)
@@ -201,9 +178,6 @@ impl AccountPurger for SqliteAccountPurger {
) -> Result<u64, DomainError> {
let cutoff = cutoff.format(TIMESTAMP_FORMAT).to_string();
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
// A tombstone whose id is still named by an unfinished event waits for
// the next run: that event may yet be handled, and its history row
// must be erased with the rest.
let expiring: Vec<String> = sqlx::query_scalar(
"SELECT id FROM users u
WHERE purged_at IS NOT NULL AND purged_at < ?
@@ -217,15 +191,11 @@ impl AccountPurger for SqliteAccountPurger {
.await
.map_err(map_sqlx_error)?;
for id in &expiring {
// Includes the `UserDeleted` row and the `Delete` deliveries.
sqlx::query("DELETE FROM event_queue WHERE instr(payload, ?) > 0")
.bind(id)
.execute(&mut *tx)
.await
.map_err(map_sqlx_error)?;
// A Follow arriving on the shared inbox after the purge can leave
// a late row for the tombstone; delete it explicitly rather than
// rely on the foreign-key cascade of the final `users` delete.
for table in ["ap_followers", "ap_following"] {
sqlx::query(&format!("DELETE FROM {table} WHERE local_user_id = ?"))
.bind(id)

View File

@@ -131,8 +131,6 @@ impl DiaryQuery for SqliteDiaryRepository {
FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC",
};
// Tombstoned local authors vanish from the feed; remote and orphan
// rows have no `users` match, so `deleted_at` is NULL for them.
where_parts.push("u.deleted_at IS NULL".to_string());
let where_clause = where_parts.join(" AND ");

View File

@@ -26,8 +26,6 @@ pub fn create_image_ref(pool: SqlitePool) -> (Arc<dyn ImageRefCommand>, Arc<dyn
#[async_trait]
impl ImageRefCommand for SqliteImageRefAdapter {
// Tombstoned users (`deleted_at` set) are intentionally included: their
// avatar stays referenced, and so protected from GC, until the purge.
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
let mut tx = self
.pool
@@ -54,8 +52,6 @@ impl ImageRefCommand for SqliteImageRefAdapter {
#[async_trait]
impl ImageRefQuery for SqliteImageRefAdapter {
// Tombstoned users are intentionally visible here until the purge removes
// the row, so their avatar is not garbage-collected early.
async fn list_keys(&self) -> Result<Vec<String>, DomainError> {
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT avatar_path FROM users WHERE avatar_path IS NOT NULL

View File

@@ -37,8 +37,6 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.await
.map_err(crate::shared::map_sqlx_error)?;
// The old row still holds the external id (it is deleted last); release it
// so the canonical upsert does not violate UNIQUE(external_metadata_id).
sqlx::query("UPDATE movies SET external_metadata_id = NULL WHERE id = ?")
.bind(&old)
.execute(&mut *tx)

View File

@@ -6,7 +6,6 @@ use domain::{
};
use sqlx::SqlitePool;
/// Matches the column default in migration 0101, so string comparison orders rows by time.
const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
const CLAIM_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.3fZ";
@@ -23,8 +22,6 @@ impl SqliteProcessedEventLedger {
)
}
/// A shorter lease than production's, so lease takeover can be tested
/// without waiting half an hour.
pub fn with_lease(pool: SqlitePool, lease: std::time::Duration) -> Self {
Self {
pool,
@@ -37,7 +34,6 @@ fn stamp(at: chrono::NaiveDateTime) -> String {
at.format(TIMESTAMP_FORMAT).to_string()
}
/// Millisecond precision, so two claims taken in one second have distinct tokens.
fn claim_stamp(at: chrono::NaiveDateTime) -> String {
at.format(CLAIM_FORMAT).to_string()
}
@@ -51,13 +47,9 @@ fn parse_claim_stamp(value: &str) -> Option<chrono::NaiveDateTime> {
#[async_trait]
impl ProcessedEventLedger for SqliteProcessedEventLedger {
async fn begin(&self, id: &EventId, handler: &str) -> Result<Begin, DomainError> {
// Retried once: the row can vanish (released) between the claim attempt
// and the state lookup.
for _ in 0..2 {
let now = chrono::Utc::now().naive_utc();
let started = claim_stamp(now);
// One statement, so two workers cannot both win: insert if absent,
// or take over an in_progress claim past its lease.
let claimed = sqlx::query(
"INSERT INTO processed_events (event_id, handler, status, started_at, processed_at)
VALUES (?, ?, 'in_progress', ?, ?)
@@ -214,7 +206,6 @@ mod tests {
token(ledger.begin(&id, "b").await.unwrap());
token(ledger.begin(&EventId::new(), "a").await.unwrap());
// The blocked delivery is told to come back when the lease runs out.
let lease = std::time::Duration::from_secs(HANDLER_LEASE_SECONDS as u64);
assert!(retry_after <= lease && retry_after > lease - std::time::Duration::from_secs(5));
}
@@ -260,7 +251,6 @@ mod tests {
insert(&pool, &id, "in_progress", "2020-01-01T00:00:00Z").await;
token(ledger.begin(&id, "h").await.unwrap());
// The takeover refreshed the lease, so a third delivery is blocked.
blocked(ledger.begin(&id, "h").await.unwrap());
let processed_at: String = sqlx::query_scalar("SELECT processed_at FROM processed_events")
.fetch_one(&pool)
@@ -295,7 +285,6 @@ mod tests {
ledger.release(&id, "a", &lapsed).await.unwrap();
ledger.complete(&id, "a", &lapsed).await.unwrap();
// Still the new owner's live claim: neither call touched it.
blocked(ledger.begin(&id, "a").await.unwrap());
ledger.complete(&id, "a", &owner).await.unwrap();
assert_eq!(ledger.begin(&id, "a").await.unwrap(), Begin::AlreadyDone);

View File

@@ -169,9 +169,6 @@ impl UserRepository for SqliteUserRepository {
.begin()
.await
.map_err(crate::shared::map_sqlx_error)?;
// The last-admin guard lives in this single statement, so two admins
// deleting each other concurrently cannot both pass it: the write lock
// serialises them and the loser sees the winner's tombstone.
let stamped = sqlx::query(
"UPDATE users SET deleted_at = ?1 WHERE id = ?2 AND deleted_at IS NULL
AND (role != 'admin' OR EXISTS (
@@ -283,8 +280,6 @@ impl UserRepository for SqliteUserRepository {
}
impl SqliteUserRepository {
/// Uniqueness check over every row, tombstones included: a deleted
/// account keeps its email and username until the purge removes the row.
async fn row_exists(&self, column: &str, value: &str) -> Result<bool, DomainError> {
sqlx::query_scalar::<_, i64>(&format!(
"SELECT EXISTS(SELECT 1 FROM users WHERE {column} = ?)"

View File

@@ -52,7 +52,6 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.map_err(crate::shared::map_sqlx_error)?
.rows_affected()
> 0;
// A repeat add changed nothing, so it announces nothing.
if inserted {
enqueue_events(&mut tx, events).await?;
}

View File

@@ -10,9 +10,6 @@ use domain::{
},
};
/// THE outbox write path: inserts one `event_queue` row per event, each with a
/// fresh `EventId`, inside the caller's transaction so a change and the events
/// announcing it commit or roll back together.
pub async fn enqueue_events(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
events: &[domain::events::DomainEvent],

View File

@@ -1,6 +1,3 @@
//! The purge removes every row a deleted account owns, strips the user row to
//! a data-free tombstone, and the cleanup later drops the tombstone itself.
use std::collections::BTreeSet;
use std::str::FromStr;
@@ -69,8 +66,6 @@ struct Seeded {
movie_id: String,
}
/// Alice owns a row in every table; bob has a little of everything, including
/// a follow of alice and an announce of his own review, which must survive.
async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
let alice = test_user("alice");
let bob = test_user("bob");
@@ -205,7 +200,6 @@ async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
follower_actor_url: REMOTE_ACTOR.into(),
status: FollowStatus::Accepted,
},
// bob follows alice, and alice follows bob
FollowWrite::AddFollow {
follower_id: b.value(),
target_actor_url: alice_actor.clone(),
@@ -221,7 +215,6 @@ async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
)
.await
.unwrap();
// A remote review attributed to the same account id is not the user's.
exec(
pool,
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
@@ -234,7 +227,6 @@ async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
],
)
.await;
// Announces of alice's review and goal, of bob's review, and one by alice.
let alice_review: String =
sqlx::query_scalar("SELECT id FROM reviews WHERE user_id = ? AND remote_actor_url IS NULL")
.bind(&a_str)
@@ -352,7 +344,6 @@ async fn purge_removes_every_user_owned_row() {
purger(&pool).purge(&s.user).await.unwrap();
for (table, column) in PURGED_USER_TABLES {
// Reviews: only the remote one, asserted below, is left.
let left = if *table == "reviews" { 1 } else { 0 };
assert_eq!(
count_for(&pool, table, column, &s.user).await,
@@ -406,14 +397,12 @@ async fn purge_removes_every_user_owned_row() {
"finished rows mentioning the user are gone; pending/processing rows (incl. other users' events about her) and bob's own rows stay"
);
// The other account is untouched.
for (table, column) in PURGED_USER_TABLES {
assert!(
count_for(&pool, table, column, &s.other).await > 0,
"{table} of the other user must survive"
);
}
// The keypair stays for queued deliveries.
for table in RETAINED_USER_TABLES {
assert_eq!(count_for(&pool, table, "user_id", &s.user).await, 1);
}
@@ -458,8 +447,6 @@ async fn the_purge_table_lists_cover_every_user_referencing_column_in_the_schema
"{t} has a foreign key to users but is not in the purge lists"
);
}
// Columns the purge matches by URL or payload must still exist, so a
// rename fails here instead of silently leaving rows behind.
for (table, column) in URL_KEYED_USER_COLUMNS
.iter()
.chain(PAYLOAD_KEYED_USER_COLUMNS)
@@ -474,9 +461,6 @@ async fn the_purge_table_lists_cover_every_user_referencing_column_in_the_schema
}
}
/// `ap_activities` (k-ap's inbound dedup) holds only remote activity ids and a
/// timestamp: nothing in it is attributable to a local user. If it ever grows a
/// column, the purge must start clearing it.
#[tokio::test]
async fn ap_activities_holds_nothing_attributable_to_a_local_user() {
let pool = migrated_pool().await;
@@ -555,7 +539,6 @@ async fn purge_strips_the_user_row_but_keeps_the_username_and_state() {
.await
.unwrap()
);
// Images stay referenced until the objects are gone.
let mut keys = p.owned_image_keys(&s.user).await.unwrap();
keys.sort();
assert_eq!(keys, vec!["avatars/a.webp", "banners/a.webp"]);
@@ -571,8 +554,6 @@ async fn purging_twice_is_a_no_op() {
parts.user.mark_deleted(&s.user, &[]).await.unwrap();
let p = purger(&pool);
p.purge(&s.user).await.unwrap();
// New data cannot appear for a purged account, but a re-run must not
// touch anything else or fail.
let before: i64 = count(&pool, "SELECT COUNT(*) FROM reviews").await;
let stamp: String = sqlx::query_scalar("SELECT purged_at FROM users WHERE id = ?")
.bind(s.user.value().to_string())
@@ -589,7 +570,6 @@ async fn purging_twice_is_a_no_op() {
.await
.unwrap();
assert_eq!(stamp, again);
// And an id that never existed is fine too.
p.purge(&UserId::generate()).await.unwrap();
}
@@ -666,7 +646,6 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
)
.await;
// Younger than the cutoff: kept.
let removed = p
.purge_expired_tombstones(now - chrono::Duration::days(9))
.await
@@ -674,9 +653,6 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
assert_eq!(removed, 0);
assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1);
// Unfinished events still name the user (the pending Delete, bob's
// pending FollowRequested, a processing row): the tombstone waits and no
// unfinished row is ever deleted.
let alice = s.user.value().to_string();
let unfinished = || async {
sqlx::query_scalar::<_, i64>(
@@ -696,7 +672,6 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1);
assert_eq!(unfinished().await, before);
// A Follow that reached the shared inbox after the purge left a row.
exec(
&pool,
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at)
@@ -704,7 +679,6 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
&[&alice],
)
.await;
// Once they finish, the cleanup takes the tombstone and the history.
exec(&pool, "UPDATE event_queue SET status = 'done'", &[]).await;
let removed = p
.purge_expired_tombstones(now - chrono::Duration::days(7))

View File

@@ -1,7 +1,3 @@
//! Real-database counterpart of the `InMemoryStore` cascade tests in the domain
//! crate. Both iterate the same table lists (`domain::testing::*_CASCADE_TABLES`),
//! so the fake and the schema cannot drift apart unnoticed.
use std::collections::BTreeSet;
use std::str::FromStr;
@@ -28,8 +24,6 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
const REMOTE_ACTOR: &str = "https://remote.example/users/ann";
/// Private in-memory database with plain `SqliteConnectOptions`; relies on
/// sqlx's default `foreign_keys = ON`, which production also depends on.
async fn migrated_pool() -> SqlitePool {
let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
let pool = SqlitePoolOptions::new()
@@ -202,7 +196,6 @@ async fn seed_user_with_dependents(
)
.await
.unwrap();
// No repository in this crate owns these two tables (federation crate).
sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES (?, ?, 'now')")
.bind(user_id.value().to_string())
.bind(REMOTE_ACTOR)

View File

@@ -46,8 +46,6 @@ async fn insert_review(pool: &SqlitePool, movie: &MovieId) {
.unwrap();
}
/// Mirrors what `application::movies::merge_duplicates` builds when the
/// canonical row does not exist.
fn canonical_from(old: &Movie, canonical_id: MovieId) -> Movie {
Movie::from_persistence(
canonical_id,

View File

@@ -1,5 +1,3 @@
//! Shared helpers for the transactional-outbox repository tests.
use std::str::FromStr;
use sqlx::{
@@ -25,8 +23,6 @@ pub async fn count(pool: &SqlitePool, table: &str) -> i64 {
.unwrap()
}
/// Makes every later `event_queue` insert abort, so a write whose own row
/// already landed inside the transaction must be rolled back with it.
pub async fn fail_event_inserts(pool: &SqlitePool) {
sqlx::query(
"CREATE TRIGGER fail_event_insert BEFORE INSERT ON event_queue \

View File

@@ -1,5 +1,3 @@
//! Goal, watchlist and profile writes commit with their events or not at all.
use domain::{
events::DomainEvent,
models::{ProfileField, UserProfile, WatchlistEntry},
@@ -796,8 +794,6 @@ async fn a_failing_follow_write_rolls_back_the_rows_already_applied() {
use domain::ports::{FollowCommand, FollowWrite};
let (pool, user) = pool_with_user().await;
let mut writes = follow_writes(user.id());
// An unknown local user violates the ap_following foreign key after the
// follower row was already written.
writes.push(FollowWrite::AddFollow {
follower_id: uuid::Uuid::new_v4(),
target_actor_url: "https://md.example/users/other".into(),

View File

@@ -120,7 +120,6 @@ async fn marking_a_user_deleted_hides_them_from_every_lookup() {
repo.save(&admin).await.unwrap();
let other = test_user("other");
repo.save(&other).await.unwrap();
// A second admin, so the first is not the last one standing.
sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?")
.bind(other.id().value().to_string())
.execute(&pool)
@@ -145,7 +144,6 @@ async fn marking_a_user_deleted_hides_them_from_every_lookup() {
assert!(emails.contains(&other.email().value()));
assert!(!emails.contains(&admin.email().value()));
assert_eq!(repo.count_active_admins().await.unwrap(), 1);
// The purge can still read the tombstone; a live user is not one.
assert!(
repo.find_tombstoned_by_id(admin.id())
.await

View File

@@ -12,7 +12,6 @@ impl TmdbEnrichmentClient {
.tmdb_api_key
.clone()
.ok_or_else(|| DomainError::InfrastructureError("TMDB_API_KEY is not set".into()))?;
// reqwest is built with rustls-no-provider; install ring so this works without bootstrap.
let _ = rustls::crypto::ring::default_provider().install_default();
Ok(Self {
api_key,

View File

@@ -1,5 +1,3 @@
//! Fresh test binary, no bootstrap: constructing the client must not panic
//! for lack of a rustls CryptoProvider.
use tmdb_enrichment::TmdbEnrichmentClient;
#[test]

View File

@@ -22,11 +22,8 @@ pub type WatchlistResponse = PaginatedResponse<crate::watchlist::WatchlistEntryD
pub type PaginatedMovieHits = PaginatedResponse<crate::search::MovieSearchHitDto>;
pub type PaginatedPersonHits = PaginatedResponse<crate::search::PersonSearchHitDto>;
/// The one body every JSON API refusal carries.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ErrorResponse {
/// Human-readable message.
pub error: String,
/// Stable machine-readable code, e.g. `NOT_FOUND`.
pub code: String,
}

View File

@@ -1,4 +1,3 @@
//! Pure domain -> DTO mappers (depend only on `domain` and this crate).
mod diary;
mod goals;
mod movies;

View File

@@ -126,7 +126,6 @@ pub struct UserProfileResponse {
pub goals: Option<Vec<GoalDto>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_federated: bool,
/// The instance's service account: never deletable, so UIs hide the action.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_service_account: bool,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -156,7 +155,6 @@ pub struct UpdateProfileFieldsRequest {
pub fields: Vec<ProfileFieldDto>,
}
/// Proof of intent for account deletion: the acting user's own password.
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct DeleteAccountRequest {
pub password: String,

View File

@@ -20,7 +20,6 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul
}
let movie_id = review.movie_id().clone();
// Read before the write: the orphan event carries the poster to clean up.
let poster_path = deps
.diary
.get_review_history(&movie_id)
@@ -29,7 +28,6 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul
.poster_path()
.cloned();
// One write: the review, plus its movie when this was the last review.
deps.review
.delete_review_and_orphaned_movie(
&review_id,

View File

@@ -76,7 +76,6 @@ impl ReviewLogger for DefaultReviewLogger {
watched_at: *review.watched_at(),
};
// Events ride the review save (the decisive write) so they commit with it.
let mut events = Vec::with_capacity(2);
if let Some(ext_id) = movie.external_metadata_id() {
events.push(DomainEvent::MovieEnrichmentRequested {
@@ -87,7 +86,6 @@ impl ReviewLogger for DefaultReviewLogger {
events.push(review_event);
self.review_repo.save_review(&review, &events).await?;
// The removal event is recorded only if an entry was actually removed.
self.watchlist_repo
.remove_if_present(
review.user_id(),

View File

@@ -29,7 +29,6 @@ pub async fn execute(
cmd.target_count,
GoalType::Movies,
)?;
// Reads that can fail happen before the write, so an error never follows a commit.
let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?;
let event = DomainEvent::GoalCreated {
goal_id: g.id().clone(),

View File

@@ -17,7 +17,6 @@ pub async fn execute(
.ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?;
g.update_target(cmd.target_count)?;
// Reads that can fail happen before the write, so an error never follows a commit.
let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?;
let event = DomainEvent::GoalUpdated {
goal_id: g.id().clone(),

View File

@@ -88,7 +88,6 @@ pub async fn execute(
})
}
/// Row-level validation failures keep their bare message in the summary.
fn import_error_message(e: DomainError) -> String {
match e {
DomainError::ValidationError(msg) => msg,

View File

@@ -7,8 +7,6 @@ use domain::{
ports::{PeriodicJob, ProcessedEventLedger},
};
/// Ledger rows older than this can no longer matter: the queue gives up on an
/// event long before, so it cannot be redelivered.
pub const PROCESSED_EVENTS_RETENTION_DAYS: i64 = 30;
pub struct ProcessedEventsCleanupJob {

View File

@@ -7,9 +7,6 @@ use domain::{
ports::{AccountPurger, PeriodicJob},
};
/// How long a purged account's stripped user row and signing keypair stay.
/// Federation deliveries of the account's `Delete` are signed from them, and
/// their retries are long over by then.
pub const TOMBSTONE_RETENTION_DAYS: i64 = 7;
pub struct TombstoneCleanupJob {

View File

@@ -14,8 +14,6 @@ pub async fn execute(deps: &EnrichMovieDeps, cmd: EnrichMovieCommand) -> Result<
}
let Some(movie) = deps.movie_query.get_movie_by_id(&cmd.movie_id).await? else {
// The movie row is written with the event, so a missing row means it was
// deleted. Do not store the profile: it is the "enrichment done" marker.
tracing::warn!(movie_id = %cmd.movie_id.value(), "enrich_movie: movie not found, skipping");
return Ok(());
};
@@ -37,8 +35,6 @@ pub async fn execute(deps: &EnrichMovieDeps, cmd: EnrichMovieCommand) -> Result<
.await?;
}
// Written last: a stored fresh profile is what makes a redelivered
// enrichment skip, so it must only exist once every other effect landed.
deps.movie_profile.upsert(&cmd.profile).await?;
tracing::info!(

View File

@@ -110,10 +110,6 @@ impl EventHandler for PosterSyncHandler {
movie_id,
external_metadata_id,
} => {
// The poster is stored under the movie id (the converter later
// appends `.webp`/`.avif`) and the movie row is updated
// atomically with `PosterSynced`; exactly that path on the movie
// means an earlier delivery completed.
let id_str = movie_id.value().to_string();
let canonical = [
id_str.clone(),

View File

@@ -2,8 +2,6 @@ use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
use super::{commands::SocialCmd, deps::SocialCommandDeps};
/// The command's event rides the write: the social port records it with the
/// change (atomically where the adapter owns the transaction).
pub async fn execute_command(deps: &SocialCommandDeps, cmd: SocialCmd) -> Result<(), DomainError> {
let command = &deps.social_command;
match cmd {

View File

@@ -2,8 +2,6 @@ use domain::{errors::DomainError, value_objects::UserId};
use crate::users::deps::EnsureActiveDeps;
/// A token outlives its account; this is the check that stops a deleted
/// (or vanished) user from acting on one.
pub async fn execute(deps: &EnsureActiveDeps, user: &UserId) -> Result<(), DomainError> {
match deps.user.find_by_id(user).await? {
Some(_) => Ok(()),

View File

@@ -9,21 +9,6 @@ use domain::{
},
};
/// Purges a deleted account once its `UserDeleted` event arrives.
///
/// Order matters. Federation is told first (deliveries are only enqueued, and
/// signed later from the tombstone the purge leaves behind). A notifier
/// failure never blocks the purge: unreachable servers must not keep a
/// person's data. Then the database rows go in one transaction, and the
/// image objects after the commit. Image references are dropped only once the
/// objects are gone, so a crash in between is recovered by the redelivery.
///
/// Idempotent: every step is a no-op on a re-run, and the notification is
/// skipped once the account is purged.
///
/// A purge that fails after the notification was queued is redelivered and
/// queues the `Delete` again. That is harmless: a repeated `Delete` of an
/// already deleted actor is idempotent on remote servers.
pub struct AccountPurgeHandler {
user: Arc<dyn UserRepository>,
notifier: Arc<dyn FederationDeletionNotifier>,
@@ -57,7 +42,6 @@ impl EventHandler for AccountPurgeHandler {
let DomainEvent::UserDeleted { user_id } = event else {
return Ok(());
};
// Gone already (removed by the tombstone cleanup): nothing left to do.
if self.user.find_tombstoned_by_id(user_id).await?.is_none() {
return Ok(());
}

View File

@@ -2,10 +2,6 @@ use domain::{errors::DomainError, models::UserRole, value_objects::UserId};
use crate::users::deps::RequestAccountDeletionDeps;
/// Tombstones `target`. A user may delete themselves; an admin may delete
/// anyone. Either way the *actor's* password is the proof, and the last
/// active admin can never be removed. `UserDeleted` rides the repository
/// write, so the tombstone and its event commit together.
pub async fn execute(
deps: &RequestAccountDeletionDeps,
actor: UserId,

View File

@@ -19,8 +19,6 @@ pub struct WorkerService {
}
impl WorkerService {
/// Fails if two handlers share a name: the name keys the processed-event
/// ledger, so a collision would make one handler's success skip the other.
pub fn new(
consumer: Arc<dyn EventConsumer>,
handlers: Vec<Arc<dyn EventHandler>>,
@@ -89,8 +87,6 @@ impl WorkerService {
}
}
/// Margin added to a claim's remaining lease before a blocked delivery is
/// retried, so the retry lands after the lease has lapsed rather than on it.
const DEFER_MARGIN: Duration = Duration::from_secs(5);
async fn dispatch(
@@ -100,7 +96,6 @@ async fn dispatch(
) {
let id = envelope.id().copied();
let mut any_transient = false;
// Longest wait among handlers blocked behind another delivery's live claim.
let mut blocked_for: Option<Duration> = None;
for handler in handlers.iter() {
let name = handler.name();
@@ -118,7 +113,6 @@ async fn dispatch(
continue;
}
Err(e) => {
// Running anyway could repeat a side effect; retry the delivery instead.
tracing::warn!(handler = name, "ledger claim failed, nacking delivery: {e}");
any_transient = true;
continue;
@@ -131,15 +125,10 @@ async fn dispatch(
if let (Some(id), Some(token)) = (&id, &claim)
&& let Err(e) = ledger.complete(id, name, token).await
{
// The work is done; failing the delivery would only repeat it.
// The claim lapses after its lease and the handler may run once more.
tracing::warn!(handler = name, "completing ledger claim failed: {e}");
}
}
Err(e) => {
// Permanent failures are released too, not recorded: if a sibling
// handler's transient failure redelivers this event, a handler that
// failed permanently runs (and fails) again.
if let (Some(id), Some(token)) = (&id, &claim)
&& let Err(le) = ledger.release(id, name, token).await
{
@@ -160,10 +149,6 @@ async fn dispatch(
}
}
}
// A blocked handler cannot be retried before its claim is done or lapsed,
// so wait that long without spending a retry attempt; any transient failure
// in the same delivery is retried then too. A plain nack would burn the
// retry budget (and dead-letter) long before a crashed claim's lease ends.
if let Some(wait) = blocked_for {
if let Err(e) = envelope.defer(wait + DEFER_MARGIN).await {
tracing::error!("defer failed: {e}");

View File

@@ -17,9 +17,6 @@ pub async fn execute(
tracing::debug!("wrapup {} already ready, skipping", wrapup_id.value());
return Ok(());
}
// A record still `Generating` is an earlier delivery that died mid-way. The
// ledger guarantees no concurrent run, so resume it: generation is a pure
// read of stats and `set_complete` is the single atomic completion write.
deps.wrapup_repo
.update_status(&wrapup_id, &WrapUpStatus::Generating, None)

View File

@@ -90,7 +90,6 @@ async fn registration_refuses_a_retired_username() {
.unwrap()
.unwrap();
users.mark_deleted(old.id(), &[]).await.unwrap();
// The purge removes the row; only the retired name keeps the username taken.
users.delete_user(old.id());
let err = register::execute(&deps, register_command("new@example.com", "Ghost"))

View File

@@ -78,7 +78,6 @@ async fn edit_other_users_review_returns_forbidden() {
assert!(matches!(err, domain::errors::DomainError::Forbidden(_)));
}
/// Loads from the store but refuses every write.
pub(crate) struct WriteRefusingReviews(pub(crate) Arc<InMemoryStore>);
#[async_trait::async_trait]

View File

@@ -1,6 +1,3 @@
//! Every event handler may run twice for the same event (lease takeover after
//! a crash mid-handler). Running it twice must leave the world as if it ran once.
use std::collections::HashMap;
use std::sync::{
Arc, Mutex,
@@ -36,7 +33,6 @@ use application::person::event_handler::PersonEnrichmentHandler;
// ── fakes ───────────────────────────────────────────────────────────────────
/// Search index keyed like a real one: indexing the same id twice is one doc.
#[derive(Default)]
struct RecordingSearch {
docs: Mutex<HashMap<(String, String), u32>>,
@@ -79,7 +75,6 @@ impl SearchCommand for RecordingSearch {
}
}
/// Fails the first `index` call, succeeds afterwards.
struct FlakySearch {
inner: Arc<RecordingSearch>,
failed_once: AtomicUsize,
@@ -103,7 +98,6 @@ fn person(tmdb: u64, name: &str) -> Person {
Person::basic(PersonId::from_external(&ext), ext, name.into(), None, None)
}
/// One person whose `enriched_at` is set by `update_enrichment`, like the real store.
struct FakePersons {
person: Mutex<Person>,
enrich_writes: AtomicUsize,
@@ -239,7 +233,6 @@ impl domain::ports::ImageFetcher for NoImages {
}
}
/// Returns the key it was given, like the real adapter.
struct KeyStorage;
#[async_trait]
@@ -371,8 +364,6 @@ async fn a_movie_enrichment_that_failed_midway_is_redone_on_redelivery() {
};
assert!(handler.handle(&event).await.is_err());
// The profile row is the "enrichment done" marker: it must not exist yet,
// or the redelivery below would skip and the search index would stay empty.
assert!(store.get_by_movie_id(movie.id()).await.unwrap().is_none());
handler.handle(&event).await.unwrap();
@@ -517,13 +508,11 @@ async fn poster_sync_skips_converted_poster_keys_but_not_lookalike_paths() {
external_metadata_id: ext,
};
// The converter's output is the canonical synced key: skipped.
movie.update_poster(PosterPath::new(format!("{id}.webp")).unwrap());
store.upsert_movie(&movie, &[]).await.unwrap();
handler.handle(&event).await.unwrap();
assert!(publisher.published().is_empty());
// A foreign path that merely contains the id is not a synced poster.
movie.update_poster(PosterPath::new(format!("https://cdn.example/{id}/p.jpg")).unwrap());
store.upsert_movie(&movie, &[]).await.unwrap();
handler.handle(&event).await.unwrap();

View File

@@ -1,7 +1,3 @@
//! Command builders for tests. Each builder returns a command with sensible
//! defaults; tests override individual fields with struct-update syntax:
//! `LogReviewCommand { rating: 6, ..log_review_command(user, movie) }`.
use chrono::NaiveDate;
use uuid::Uuid;
@@ -66,7 +62,6 @@ pub fn log_review_command_manual(user_id: Uuid, title: &str, year: u16) -> LogRe
}
}
/// Edits only the rating; `review_id` and `requesting_user_id` are random.
pub fn edit_review_command(rating: u8) -> EditReviewCommand {
EditReviewCommand {
review_id: Uuid::new_v4(),
@@ -115,7 +110,6 @@ pub fn update_goal_command(year: u16, target_count: u32) -> UpdateGoalCommand {
}
}
/// Confirms indices `[0]`.
pub fn execute_import_command(user_id: Uuid, session_id: Uuid) -> ExecuteImportCommand {
ExecuteImportCommand {
user_id,
@@ -132,7 +126,6 @@ pub fn create_import_session_command(user_id: Uuid, csv: &[u8]) -> CreateImportS
}
}
/// No field mappings.
pub fn apply_import_mapping_command(user_id: Uuid, session_id: Uuid) -> ApplyImportMappingCommand {
ApplyImportMappingCommand {
user_id,
@@ -153,7 +146,6 @@ pub fn apply_import_profile_command(
}
}
/// One confirmation, rating as given, no comment.
pub fn confirm_watch_events_command(
user_id: Uuid,
watch_event_id: Uuid,
@@ -176,7 +168,6 @@ pub fn dismiss_watch_events_command(
DismissWatchEventsCommand { user_id, event_ids }
}
/// Jellyfin provider, no label.
pub fn generate_webhook_token_command(user_id: Uuid) -> GenerateWebhookTokenCommand {
GenerateWebhookTokenCommand {
user_id,
@@ -185,7 +176,6 @@ pub fn generate_webhook_token_command(user_id: Uuid) -> GenerateWebhookTokenComm
}
}
/// Every field unset; override what the test cares about.
pub fn update_profile_command(user_id: Uuid) -> UpdateProfileCommand {
UpdateProfileCommand {
user_id,
@@ -199,7 +189,6 @@ pub fn update_profile_command(user_id: Uuid) -> UpdateProfileCommand {
}
}
/// All federation toggles on.
pub fn update_user_settings_command(user_id: Uuid) -> UpdateUserSettingsCommand {
UpdateUserSettingsCommand {
user_id,
@@ -216,7 +205,6 @@ pub fn add_to_watchlist_command(user_id: Uuid, movie_id: Uuid) -> AddToWatchlist
}
}
/// Window is 1 Jan of `year` to 1 Jan of `year + 1`.
pub fn request_wrap_up_command(user_id: Option<Uuid>, year: i32) -> RequestWrapUpCommand {
RequestWrapUpCommand {
user_id,

View File

@@ -1,6 +1,3 @@
//! Shared fakes and builders for the application integration tests.
//! Included via `mod support;` from each `tests/<module>.rs`; not every
//! binary uses every helper.
#![allow(dead_code, unused_imports)]
mod commands;

View File

@@ -139,7 +139,6 @@ fn entries(log: &Log) -> Vec<String> {
#[test]
fn the_handler_name_is_stable() {
// The name keys the processed-event ledger; renaming re-runs events.
let f = tokio::runtime::Runtime::new()
.unwrap()
.block_on(fixture(false, true));
@@ -255,9 +254,6 @@ async fn a_redelivered_event_is_skipped_by_the_ledger() {
assert_eq!(purges, 1);
}
/// The rows were committed but the worker died before deleting the image
/// objects: the redelivery finds the account purged, still sees the image
/// references, deletes the objects and clears the references.
#[tokio::test]
async fn a_rerun_after_a_crash_following_the_commit_deletes_the_images() {
let f = fixture(false, true).await;

View File

@@ -21,8 +21,6 @@ use domain::models::{User, UserSummary};
use domain::ports::UserRepository as UserRepositoryPort;
use domain::value_objects::Username;
/// Delegates to a store but reports a stale admin count, as a concurrent
/// deletion would leave the use case's pre-check.
struct StaleAdminCount(Arc<InMemoryStore>);
#[async_trait]

View File

@@ -273,7 +273,6 @@ async fn changing_also_known_as_records_the_move_with_the_update() {
assert!(matches!(recorded[1], DomainEvent::UserAccountMoved { .. }));
}
/// Loads from the store but refuses the profile write.
struct ProfileWriteRefusingUsers(Arc<InMemoryStore>);
#[async_trait::async_trait]

View File

@@ -377,7 +377,6 @@ impl EventHandler for CountingHandler {
}
}
/// Delivers the same event (same id) `times` times, recording ack/nack per delivery.
struct RedeliveringConsumer {
id: Option<domain::events::EventId>,
event: DomainEvent,
@@ -416,8 +415,6 @@ impl EventConsumer for RedeliveringConsumer {
Box::new(CountNack(Arc::clone(&nacks), Arc::clone(&defers))),
);
async move {
// Let the previous delivery's handlers finish first: a real
// broker redelivers only after the nack.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
Ok(envelope)
}
@@ -498,7 +495,6 @@ async fn a_handler_already_running_elsewhere_is_not_run_concurrently() {
let (handler, calls) = CountingHandler::new("slow", 0);
let store = InMemoryStore::new();
let id = domain::events::EventId::new();
// Another delivery holds a live claim on this (event, handler).
assert!(matches!(
store.begin(&id, "slow").await.unwrap(),
Begin::Run(_)
@@ -560,7 +556,6 @@ async fn a_delivery_blocked_by_a_live_claim_is_deferred_without_consuming_an_att
assert_eq!(*nacks.lock().unwrap(), 0, "a nack would consume an attempt");
let defers = defers.lock().unwrap();
assert_eq!(defers.len(), 1);
// Retried only once the claim's lease has run out (plus a margin).
let lease = std::time::Duration::from_secs(HANDLER_LEASE_SECONDS as u64);
assert!(
defers[0] > lease - std::time::Duration::from_secs(5),
@@ -588,7 +583,6 @@ async fn a_lapsed_owner_cannot_release_a_claim_taken_over_by_another() {
let Begin::Run(lapsed) = store.begin(&id, "h").await.unwrap() else {
panic!("stale claim should be taken over");
};
// Simulate: the original owner's token is from before the takeover.
let original = domain::ports::ClaimToken::new("2020-01-01T00:00:00.000Z".into());
assert_ne!(original, lapsed);
@@ -652,7 +646,6 @@ async fn a_failed_handler_releases_its_claim_so_redelivery_retries() {
.run(tokio::sync::watch::channel(false).1)
.await;
// Delivery 1 fails and releases; 2 succeeds; 3 is skipped as done.
assert_eq!(*calls.lock().unwrap(), 2);
assert_eq!(*nacks.lock().unwrap(), 1);
}

View File

@@ -82,7 +82,6 @@ async fn generates_wrapup_and_marks_complete() {
assert_eq!(final_rec.status, WrapUpStatus::Ready);
assert!(final_rec.report.is_some());
// The completion event is recorded together with the completed row.
let recorded = repo.recorded_events();
assert_eq!(recorded.len(), 1);
assert!(matches!(
@@ -93,8 +92,6 @@ async fn generates_wrapup_and_marks_complete() {
#[tokio::test]
async fn a_wrapup_left_generating_is_completed_on_redelivery() {
// A crash after `Generating` was written (or a failed `set_complete`) leaves
// the record stuck; the redelivered event must finish it, not skip it.
let repo = InMemoryWrapUpRepository::new();
let wrapup_id = WrapUpId::generate();

View File

@@ -58,8 +58,6 @@ pub async fn build_database_adapters(
assemble(w, instance)
}
/// Like [`build_database_adapters`], on a pool the caller already connected
/// (migrations still run). Lets tests drive the real wiring on an in-memory pool.
pub async fn build_database_adapters_on(
pool: sqlx::SqlitePool,
instance: &domain::value_objects::InstanceIdentity,

View File

@@ -27,8 +27,6 @@ impl EventBusBackend {
}
}
/// The server's publisher is always the outbox writer: it never talks to the
/// bus, whatever `EVENT_BUS_BACKEND` says. The worker owns delivery.
pub async fn create_publisher(
db_pool: &sqlx::SqlitePool,
config: &config::AppConfig,
@@ -37,18 +35,12 @@ pub async fn create_publisher(
sqlite::SqliteEventQueue::create_publisher(db_pool.clone(), &config.events).await
}
/// What the worker gets from the bus: a publisher for its own events, the
/// consumer its handlers read, and, when the bus is not the outbox, the relay
/// that moves outbox rows onto it.
struct Wiring {
publisher: Arc<dyn EventPublisher>,
consumer: Arc<dyn EventConsumer>,
relay: Option<OutboxRelay>,
}
/// The worker always writes to the outbox. With an external bus
/// (`bus = Some((consumer, sink))`) handlers read from the bus and the relay
/// drains the outbox into it; without one, handlers drain the outbox directly.
fn compose(
(publisher, outbox): (Arc<dyn EventPublisher>, Arc<dyn EventConsumer>),
bus: Option<(Arc<dyn EventConsumer>, Arc<dyn EventRelaySink>)>,

View File

@@ -1,8 +1,3 @@
//! Social and federation wiring for the server and worker.
//!
//! Federation off still wires real local social behaviour (`LocalSocialService`):
//! the follow graph and block queries must never silently report empty.
use std::sync::Arc;
use domain::ports::{
@@ -123,9 +118,6 @@ pub async fn wire_server_social(
})
}
/// The worker's federation event handlers (the ActivityPub delivery handler and
/// the follow-accepted outbox backfill) and the notifier that tells followers
/// an account was deleted.
#[cfg(feature = "federation")]
pub async fn wire_worker_handlers(
db: &DatabaseOutput,

View File

@@ -62,11 +62,6 @@ mod tests {
use super::*;
/// The handler has no state of its own: idempotency lives behind the port
/// (remote imports are keyed upserts; backfill Creates use the deterministic
/// k-ap id `activities/create/{uuidv5(object id)}`, see activitypub
/// `tests/activity_ids.rs`). What this proves is that a redelivery replays
/// exactly the same port calls, with no accumulated or derived state.
#[derive(Default)]
struct CallLog(Mutex<Vec<String>>);

View File

@@ -1,9 +1,3 @@
//! The composition root: the only crate that names concrete adapters.
//!
//! `server` and `worker` are thin binaries over [`build_server`] and
//! [`build_worker`]; everything between configuration and a running service is
//! assembled here (ADR-0005, ADR-0013).
mod build;
mod database;
mod event_bus;

View File

@@ -1,22 +1,3 @@
//! Moves outbox rows onto the message bus. The worker runs it when the bus is
//! not the outbox itself (`EVENT_BUS_BACKEND=nats`).
//!
//! Rows are claimed by the SQLite queue's own atomic claim, so a row is
//! relayed by one claimant at a time, and failures take the queue's backoff
//! and dead-letter path. A row is marked done only after the sink reports the
//! bus accepted it.
//!
//! A sink (transport) failure is the bus's fault, not the row's: the row is
//! deferred without spending an attempt, and the relay pauses with a growing
//! backoff instead of churning through the rest of the outbox. Attempts, and
//! so dead-lettering, are only ever spent by consumers on handler failures.
//!
//! Delivery is at-least-once. A crash between publish and mark-done leaves the
//! row `processing`; when its claim lease lapses it is claimed and relayed
//! again. The bus drops that repeat while it is inside the broker's dedup
//! window (same `Nats-Msg-Id`); beyond the window, or in core mode, the
//! consumer ledger makes the handlers skip it.
use std::{sync::Arc, time::Duration};
use domain::{
@@ -41,16 +22,11 @@ impl OutboxRelay {
}
}
/// Overrides the pause/defer schedule, keyed by consecutive sink failures.
pub fn with_backoff(mut self, backoff: fn(u32) -> Duration) -> Self {
self.backoff = backoff;
self
}
/// Runs until `shutdown` fires or the source ends. A shutdown never
/// interrupts a row mid-relay: it is acked or nacked before this returns.
/// Rows the claim already took but the relay had not started stay
/// `processing` and come back when their lease lapses.
pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
let mut rows = self.source.consume();
let mut sink_failures: u32 = 0;
@@ -80,7 +56,6 @@ impl OutboxRelay {
}
}
}
// The queue already dead-lettered it and logged the row id.
Some(Err(e)) => tracing::error!("outbox relay: skipped undecodable row: {e}"),
None => {
tracing::error!("outbox relay: source closed");
@@ -92,7 +67,6 @@ impl OutboxRelay {
}
}
/// Returns how long the relay should pause when the sink failed.
async fn relay_envelope(
envelope: EventEnvelope,
sink: &dyn EventRelaySink,

View File

@@ -19,7 +19,6 @@ pub struct ServerApp {
pub addr: SocketAddr,
}
/// Wires the whole HTTP application from configuration.
pub async fn build_server(config: &config::AppConfig) -> anyhow::Result<ServerApp> {
database::warn_if_database_backend_set();
let instance = InstanceIdentity::new(config.server.base_url.clone());
@@ -28,7 +27,6 @@ pub async fn build_server(config: &config::AppConfig) -> anyhow::Result<ServerAp
.await
.context("Failed to wire dependencies")?;
// Resolved like `TcpListener::bind(&str)` would, so a hostname HOST keeps working.
let bind = format!("{}:{}", config.server.host, config.server.port);
let addr = tokio::net::lookup_host(&bind)
.await
@@ -38,8 +36,6 @@ pub async fn build_server(config: &config::AppConfig) -> anyhow::Result<ServerAp
Ok(ServerApp { router, addr })
}
/// Builds the same router as [`build_server`] on a caller-provided pool.
/// Migrations run on it exactly as they do for the normal path.
pub async fn build_server_on(
config: &config::AppConfig,
pool: sqlx::SqlitePool,

View File

@@ -1,14 +1,5 @@
//! Process-wide rustls crypto provider.
//!
//! Every HTTPS client in the graph (reqwest, object_store, async-nats,
//! k-ap) is built without a bundled provider so the binaries link `ring`
//! only. rustls therefore needs one installed before the first client is
//! built, or client construction panics.
use std::sync::Once;
/// Installs `ring` as the process default. Idempotent; a provider installed
/// earlier by someone else is left in place.
pub fn install_crypto_provider() {
static INSTALL: Once = Once::new();
INSTALL.call_once(|| {
@@ -20,8 +11,6 @@ pub fn install_crypto_provider() {
mod tests {
use super::*;
/// Only test in this crate's lib test binary, so nothing else in this
/// process installs a provider before `install_crypto_provider` runs.
#[test]
fn https_client_builds_after_install() {
install_crypto_provider();

View File

@@ -1,6 +1,5 @@
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
/// Installs the global subscriber. `RUST_LOG` overrides `default_directives`.
pub fn init_tracing(default_directives: &str) {
tracing_subscriber::registry()
.with(log_filter(default_directives))

View File

@@ -13,23 +13,14 @@ use tokio::sync::watch;
pub struct WorkerApp {
service: WorkerService,
/// Present when the bus is external: drains the outbox into it.
relay: Option<OutboxRelay>,
}
impl WorkerApp {
/// Names of the registered event handlers, in registration order.
pub fn handler_names(&self) -> Vec<&'static str> {
self.service.handler_names()
}
/// Runs the handlers and, when there is one, the outbox relay, until
/// `shutdown` fires. Both stop together and finish their in-flight work.
///
/// If the relay stops or panics on its own, the worker stops too and
/// returns an error so the process exits non-zero and the supervisor
/// restarts it: a worker that keeps handling events but no longer
/// delivers the outbox would fail silently.
pub async fn run(self, mut shutdown: watch::Receiver<bool>) -> anyhow::Result<()> {
let (stop_tx, stop_rx) = watch::channel(false);
let mut relay = self.relay.map(|r| tokio::spawn(r.run(stop_rx.clone())));
@@ -74,8 +65,6 @@ impl WorkerApp {
}
}
/// Wires the background worker: event handlers, periodic jobs (already
/// spawned) and the consumer. Run it with `service.run(shutdown)`.
pub async fn build_worker(config: &config::AppConfig) -> anyhow::Result<WorkerApp> {
database::warn_if_database_backend_set();
let instance = InstanceIdentity::new(config.server.base_url.clone());
@@ -83,8 +72,6 @@ pub async fn build_worker(config: &config::AppConfig) -> anyhow::Result<WorkerAp
assemble(config, db, instance).await
}
/// Builds the same worker as [`build_worker`] on a caller-provided pool.
/// Migrations run on it exactly as they do for the normal path.
pub async fn build_worker_on(
config: &config::AppConfig,
pool: sqlx::SqlitePool,

View File

@@ -1,5 +1,3 @@
//! Account deletion over HTTP: JSON and classic endpoints on the real wiring.
mod support;
use axum::{
@@ -253,7 +251,6 @@ async fn classic_admin_self_delete_logs_out_unless_last_admin() {
promote(&pool, &alice.user_id).await;
let uri = format!("/users/{}/delete", alice.user_id);
// Sole admin: refused, redirected back with an error, still logged in.
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 303);
assert!(location(&headers).contains("error="), "{headers:?}");
@@ -261,7 +258,6 @@ async fn classic_admin_self_delete_logs_out_unless_last_admin() {
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 200);
// With a second admin the self-delete goes through and ends the session.
promote(&pool, &bob.user_id).await;
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 303);
@@ -283,7 +279,6 @@ async fn delete_forms_render_only_where_they_apply() {
let (_, _, body) = send(&router, get("/settings/profile", Some(&alice.cookie))).await;
assert!(body.contains(own_delete), "owner settings page");
// A non-admin viewing someone else, and anonymous viewers, see no form.
let bob_page = format!("/users/{}", bob.user_id);
let (_, _, body) = send(&router, get(&bob_page, None)).await;
assert!(!body.contains(&user_delete(&bob.user_id)), "anonymous");

View File

@@ -1,7 +1,3 @@
//! End to end on the real wiring: a `UserDeleted` event in the outbox makes
//! the worker tell followers, purge the account, and leave the tombstone that
//! the queued deliveries are signed from.
mod support;
use std::time::Duration;
@@ -59,8 +55,6 @@ async fn the_worker_purges_a_deleted_account_and_frees_its_email() {
assert!(!again.user_id.is_empty(), "the email can register again");
}
/// With a follower, the Delete is enqueued for delivery even though the
/// account is already tombstoned, and the keypair outlives the purge.
#[cfg(feature = "federation")]
#[tokio::test]
async fn followers_are_told_before_the_purge_and_the_keypair_survives_it() {
@@ -103,7 +97,6 @@ async fn followers_are_told_before_the_purge_and_the_keypair_survives_it() {
"SELECT COUNT(*) FROM users WHERE username = 'alice' AND purged_at IS NOT NULL",
)
.await;
// Deliveries are enqueued from a spawned task, so wait for them too.
let enqueued = wait_for(
&pool,
"SELECT COUNT(*) FROM event_queue WHERE event_type = 'FederationDeliveryRequested'",

View File

@@ -1,6 +1,3 @@
//! The HTTP surface of the production app, built through `bootstrap` on an
//! in-memory database with real adapters.
mod support;
use axum::{
@@ -16,7 +13,6 @@ async fn test_app() -> Router {
support::server().await.0
}
/// An app with one registered user; returns its pool, id and bearer token.
async fn test_app_with_user() -> (Router, SqlitePool, Uuid, String) {
let (app, pool) = support::server().await;
let session = support::register(&app, "insights", "insights@example.com").await;

View File

@@ -1,5 +1,3 @@
//! The composition root assembles on a fresh database, with every adapter real.
mod support;
#[tokio::test]
@@ -21,9 +19,6 @@ async fn worker_builds_on_a_fresh_database() {
.expect("worker builds");
}
/// Without the federation feature, following another local user still works
/// (the request lands as pending for the target):
/// the local social ports are wired in place of the ActivityPub ones.
#[cfg(not(feature = "federation"))]
#[tokio::test]
async fn federation_off_build_wires_local_social() {
@@ -68,8 +63,6 @@ async fn federation_off_build_wires_local_social() {
);
}
/// The server never talks to the bus: whatever `EVENT_BUS_BACKEND` says, a
/// change and its event land in the outbox table. No NATS server is reachable here.
#[tokio::test]
async fn server_publishes_to_the_outbox_even_when_bus_is_nats() {
let pool = support::memory_pool().await;
@@ -108,8 +101,6 @@ async fn server_publishes_to_the_outbox_even_when_bus_is_nats() {
assert_eq!(queued, 1);
}
/// Handler names key the processed-event ledger, so each must be unique. The
/// check runs over the real wiring, in both federation builds.
#[tokio::test]
async fn handler_names_are_unique() {
let pool = support::memory_pool().await;
@@ -126,8 +117,6 @@ async fn handler_names_are_unique() {
);
let mut sorted = names.clone();
sorted.sort_unstable();
// Names key the ledger: a rename re-runs events inside the retention
// window, so changing this list must be a deliberate edit.
let mut expected = vec![
"account_purge",
"image_cleanup",
@@ -143,7 +132,6 @@ async fn handler_names_are_unique() {
expected.extend(["activitypub", "follow_backfill"]);
}
expected.sort_unstable();
// Image conversion ("image_conversion") is disabled in the test config.
assert_eq!(sorted, expected);
}
@@ -157,8 +145,6 @@ async fn queued(pool: &sqlx::SqlitePool, event_type: &str) -> i64 {
.unwrap()
}
/// A local follow writes both follow rows and its event in one commit, in
/// both federation builds.
#[tokio::test]
async fn a_local_follow_lands_with_its_event_in_the_outbox() {
let (router, pool) = support::server().await;
@@ -189,8 +175,6 @@ async fn a_local_follow_lands_with_its_event_in_the_outbox() {
assert_eq!(queued(&pool, "FollowRequested").await, 1);
}
/// The k_ap path owns its transaction, so the adapter publishes the event
/// itself once the k_ap write has succeeded.
#[cfg(feature = "federation")]
#[tokio::test]
async fn a_remote_block_publishes_its_event_after_the_k_ap_write() {
@@ -217,9 +201,6 @@ async fn a_remote_block_publishes_its_event_after_the_k_ap_write() {
assert_eq!(queued(&pool, "ActorBlocked").await, 1);
}
/// A token outlives the account that owns it, so every authenticated route
/// must re-check the account: a deleted user gets exactly what an anonymous
/// caller gets.
#[tokio::test]
async fn a_deleted_users_token_is_rejected() {
let (router, pool) = support::server().await;
@@ -255,9 +236,6 @@ async fn a_deleted_users_token_is_rejected() {
assert_eq!(headers.get("location").unwrap(), "/login");
}
/// A deleted account stays resolvable inside k-ap (to sign its `Delete`), but
/// every ActivityPub document for it answers 410, while the classic profile
/// page stays 404.
#[cfg(feature = "federation")]
#[tokio::test]
async fn actor_document_for_a_tombstone_is_gone() {
@@ -301,8 +279,6 @@ async fn actor_document_for_a_tombstone_is_gone() {
assert_eq!(status, 404, "the classic profile page stays 404");
}
/// Remote servers posting to a deleted account's inbox are told it is gone,
/// before any signature check.
#[cfg(feature = "federation")]
#[tokio::test]
async fn posting_to_a_tombstones_inbox_is_gone() {

View File

@@ -1,5 +1,3 @@
//! Configuration values reach the running app through the composition root.
mod support;
use axum::{

Some files were not shown because too many files have changed in this diff Show More