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

@@ -148,22 +148,14 @@ pub enum DomainEvent {
pub trait AckHandle: Send + Sync {
async fn ack(&self) -> Result<(), DomainError>;
async fn nack(&self) -> Result<(), DomainError>;
/// Redeliver after `delay` without counting a failed attempt: for a
/// delivery that was not tried (it is blocked behind a live claim), so it
/// must not burn the retry budget that `nack` spends. A broker may still
/// count the redelivery itself: JetStream does, toward `max_deliver`.
async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError>;
}
/// Wait before retrying an event whose `attempt`-th delivery just failed:
/// 5s doubling per attempt, capped at 5 minutes. Every backend (SQLite queue,
/// JetStream nak, outbox relay) uses this one schedule.
pub fn retry_backoff(attempt: u32) -> std::time::Duration {
let secs = 5u64 * (1u64 << attempt.min(6));
std::time::Duration::from_secs(secs.min(300))
}
/// Stable identity of one queued event; the key for de-duplicating deliveries.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct EventId(uuid::Uuid);
@@ -194,7 +186,6 @@ pub struct EventEnvelope {
}
impl EventEnvelope {
/// `id` is `None` only for events queued before identities existed.
pub fn new(id: Option<EventId>, event: DomainEvent, ack: Box<dyn AckHandle>) -> Self {
Self { event, id, ack }
}

View File

@@ -2,41 +2,21 @@ use async_trait::async_trait;
use crate::{errors::DomainError, value_objects::UserId};
/// Tells the fediverse that a local account is gone (an ActivityPub `Delete`
/// to followers). Federation-off builds wire a no-op.
///
/// Delivery is asynchronous: the implementation may only enqueue deliveries.
/// Those are signed later with the actor's keypair and the tombstoned user row,
/// which is why the purge keeps both until the tombstone cleanup.
#[async_trait]
pub trait FederationDeletionNotifier: Send + Sync {
async fn notify_deleted(&self, user: &UserId) -> Result<(), DomainError>;
}
/// Removes everything a deleted account owns, in one transaction, and strips
/// the user row down to a data-free tombstone.
///
/// What stays: the username (retired for good), role, `deleted_at`, the
/// `purged_at` stamp and the signing keypair, so queued federation deliveries
/// can still be signed. `purge_expired_tombstones` removes those later.
#[async_trait]
pub trait AccountPurger: Send + Sync {
/// Whether the account's data has already been purged.
async fn is_purged(&self, user: &UserId) -> Result<bool, DomainError>;
/// Object-storage keys the account owns (avatar, banner). Still readable
/// after `purge`, until `forget_images`.
async fn owned_image_keys(&self, user: &UserId) -> Result<Vec<String>, DomainError>;
/// One transaction; every delete is "if present", so a re-run is a no-op.
/// Frees the email. Does nothing for an account that is gone or purged.
async fn purge(&self, user: &UserId) -> Result<(), DomainError>;
/// Drops the image references once their objects are deleted.
async fn forget_images(&self, user: &UserId) -> Result<(), DomainError>;
/// Deletes tombstones (user row and keypair) purged before `cutoff`.
/// Returns how many were removed.
async fn purge_expired_tombstones(
&self,
cutoff: chrono::NaiveDateTime,

View File

@@ -14,8 +14,6 @@ pub trait AuthService: Send + Sync {
#[async_trait]
pub trait UserRepository: Send + Sync {
// The finders and `list_with_stats` serve live users only: tombstoned
// (deleted) accounts are invisible to them.
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
@@ -27,17 +25,12 @@ pub trait UserRepository: Send + Sync {
profile: &crate::models::UserProfile,
events: &[crate::events::DomainEvent],
) -> Result<(), DomainError>;
/// Tombstones the account in one transaction: stamps `deleted_at`,
/// retires the username, drops the user's refresh sessions and records
/// `events`. Afterwards every reader below behaves as if the user is gone.
async fn mark_deleted(
&self,
user_id: &UserId,
events: &[crate::events::DomainEvent],
) -> Result<(), DomainError>;
/// The tombstoned user, for the purge. Live users are not returned.
async fn find_tombstoned_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
/// Whether the name belonged to a deleted account (case-insensitive).
async fn is_username_retired(&self, username: &Username) -> Result<bool, DomainError>;
async fn count_active_admins(&self) -> Result<u64, DomainError>;
}

View File

@@ -51,9 +51,6 @@ pub trait ReviewRepository: Send + Sync {
review: &Review,
events: &[DomainEvent],
) -> Result<(), DomainError>;
/// Deletes the review and, in the same transaction, its movie when no
/// review of any kind references it any more. `review_events` are always
/// recorded; `orphan_events` only when the movie row was deleted.
async fn delete_review_and_orphaned_movie(
&self,
review_id: &ReviewId,

View File

@@ -10,8 +10,6 @@ pub trait EventPublisher: Send + Sync {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
}
/// Delivers one outbox row to the message bus. `Ok` means the bus accepted it
/// (for a brokered bus: acknowledged), so the row may be marked done.
#[async_trait]
pub trait EventRelaySink: Send + Sync {
async fn relay(&self, id: Option<&EventId>, event: &DomainEvent) -> Result<(), DomainError>;
@@ -23,8 +21,6 @@ pub trait EventConsumer: Send + Sync {
#[async_trait]
pub trait EventHandler: Send + Sync {
/// Stable, unique snake_case identity. It keys the processed-event ledger,
/// so renaming a handler makes it re-run events still inside the retention window.
fn name(&self) -> &'static str;
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>;
}

View File

@@ -8,8 +8,6 @@ use crate::{
#[async_trait]
pub trait ApDocumentPort: Send + Sync {
/// Whether the actor belonged to a deleted account: its documents are
/// answered `410 Gone` instead of being served.
async fn actor_is_gone(&self, _user_id: Uuid) -> Result<bool, DomainError> {
Ok(false)
}

View File

@@ -6,8 +6,6 @@ use crate::{
value_objects::{FollowRelation, FollowStatus, SocialActor},
};
/// One row-level change to the local follow tables. A local follow operation
/// is a batch of these applied in a single transaction with its events.
#[derive(Clone, Debug, PartialEq)]
pub enum FollowWrite {
AddFollow {
@@ -42,8 +40,6 @@ pub enum FollowWrite {
#[async_trait]
pub trait FollowCommand: Send + Sync {
/// Applies every write and records `events` in one transaction: all rows
/// and events land, or none do.
async fn apply(
&self,
writes: &[FollowWrite],

View File

@@ -11,7 +11,6 @@ pub trait ObjectStorage: Send + Sync {
key: &str,
) -> Result<futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>>, DomainError>;
async fn delete(&self, key: &str) -> Result<(), DomainError>;
/// Whether exactly `key` exists. Unlike `get`, no extension fallback.
async fn exists(&self, key: &str) -> Result<bool, DomainError>;
}

View File

@@ -36,7 +36,6 @@ impl super::RemoteWatchlistRepository for NoopRemoteWatchlistRepository {
// ── NoopFederationDeletionNotifier ───────────────────────────────────────────
/// Federation compiled out: nobody remote to tell.
pub struct NoopFederationDeletionNotifier;
#[async_trait]

View File

@@ -2,14 +2,8 @@ use async_trait::async_trait;
use crate::{errors::DomainError, events::EventId};
/// How long an `in_progress` claim blocks other deliveries of the same
/// (event, handler). Past it the claim is presumed dead (crashed worker) and
/// the next `begin` takes it over. Must exceed any handler's runtime.
pub const HANDLER_LEASE_SECONDS: i64 = 1800;
/// Proof of ownership of one claim, returned by `begin`. `complete` and
/// `release` act only while the claim still carries this token, so an owner
/// whose lease lapsed cannot disturb whoever took the claim over.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClaimToken(String);
@@ -25,31 +19,15 @@ impl ClaimToken {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Begin {
/// The caller now holds the claim and must run the handler, then
/// `complete` or `release` with the token.
Run(ClaimToken),
/// The handler already finished this event; skip it.
AlreadyDone,
/// Another delivery holds a live claim; do not run. Retry the delivery
/// after `retry_after`, when the claim is done or its lease has lapsed.
InProgressElsewhere { retry_after: std::time::Duration },
}
/// Per-(event, handler) claim ledger that stops redelivered events from
/// re-running handlers.
///
/// Guarantee: two deliveries never run the same handler for the same event
/// concurrently (`begin` is atomic across workers). After a crash mid-handler
/// the claim lapses after `HANDLER_LEASE_SECONDS` and the handler runs again,
/// so delivery is at-least-once and handlers must be idempotent.
#[async_trait]
pub trait ProcessedEventLedger: Send + Sync {
/// Atomically claims (event, handler): inserts a claim if there is none, or
/// takes over an `in_progress` claim older than the lease; otherwise
/// reports the existing state.
async fn begin(&self, id: &EventId, handler: &str) -> Result<Begin, DomainError>;
/// Marks the claim done, if `token` still owns it: the handler will not run for this event again.
async fn complete(
&self,
id: &EventId,
@@ -57,7 +35,6 @@ pub trait ProcessedEventLedger: Send + Sync {
token: &ClaimToken,
) -> Result<(), DomainError>;
/// Drops an `in_progress` claim, if `token` still owns it, so a retry can run immediately.
async fn release(
&self,
id: &EventId,
@@ -65,7 +42,5 @@ pub trait ProcessedEventLedger: Send + Sync {
token: &ClaimToken,
) -> Result<(), DomainError>;
/// Removes rows (done, or abandoned `in_progress`) first recorded before
/// `cutoff`. Returns the number removed.
async fn delete_older_than(&self, cutoff: chrono::NaiveDateTime) -> Result<u64, DomainError>;
}

View File

@@ -1,13 +1,9 @@
use crate::errors::DomainError;
use crate::models::DiaryEntry;
/// Channel-level metadata for a rendered feed. Paths are site-relative
/// (e.g. `/users/{id}`); the renderer resolves them against its base URL.
pub struct FeedChannel<'a> {
pub title: &'a str,
/// Human-readable page the channel represents.
pub link_path: &'a str,
/// Where the feed itself is served, advertised as `atom:link rel="self"`.
pub self_path: &'a str,
}

View File

@@ -21,7 +21,6 @@ pub trait WatchlistRepository: Send + Sync {
events: &[DomainEvent],
) -> Result<(), DomainError>;
/// `events` are recorded only when an entry was actually removed.
async fn remove_if_present(
&self,
user_id: &UserId,

View File

@@ -11,7 +11,6 @@ use crate::value_objects::{
const DEFAULT_YEAR: u16 = 2000;
/// Fixed watch timestamp used by review factories (2024-03-15 20:00:00).
pub fn test_watched_at() -> NaiveDateTime {
NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()

View File

@@ -1,5 +1,3 @@
//! Fakes whose every call fails with an infrastructure error, for failure-path tests.
use async_trait::async_trait;
use chrono::NaiveDateTime;

View File

@@ -9,7 +9,6 @@ use crate::models::{
};
use crate::value_objects::SocialIdentity;
/// One `processed_events` row.
pub(super) struct LedgerRow {
pub(super) done: bool,
pub(super) started_at: chrono::NaiveDateTime,
@@ -22,9 +21,6 @@ pub(super) enum FollowState {
Accepted,
}
/// Tables whose rows the schema removes when a user row is deleted
/// (`ON DELETE CASCADE` on a column referencing `users(id)`), and which
/// `InMemoryStore` models. The sqlite adapter's cascade test asserts the same list.
pub const USER_CASCADE_TABLES: &[&str] = &[
"watchlist_entries",
"goals",
@@ -37,11 +33,8 @@ pub const USER_CASCADE_TABLES: &[&str] = &[
"blocked_actors",
];
/// Tables that cascade from a user but whose aggregate this store does not hold
/// (they belong to the federation crate).
pub const USER_CASCADE_TABLES_OUTSIDE_STORE: &[&str] = &["ap_local_actors"];
/// Tables whose rows the schema removes when a movie row is deleted.
pub const MOVIE_CASCADE_TABLES: &[&str] = &[
"watchlist_entries",
"movie_profiles",
@@ -51,17 +44,12 @@ pub const MOVIE_CASCADE_TABLES: &[&str] = &[
"movie_crew",
];
/// One in-memory implementation of every persistence port, mirroring the
/// relational constraints of the sqlite schema (cascades, uniqueness, FK
/// restrictions). Share one `Arc<InMemoryStore>` across all port slots.
pub struct InMemoryStore {
pub(super) movies: RwLock<HashMap<Uuid, Movie>>,
pub(super) movie_profiles: RwLock<HashMap<Uuid, MovieProfile>>,
pub(super) reviews: RwLock<HashMap<Uuid, Review>>,
pub(super) users: RwLock<HashMap<Uuid, User>>,
/// Ids of `users` rows carrying a `deleted_at` tombstone.
pub(super) tombstoned: RwLock<std::collections::HashSet<Uuid>>,
/// Lower-cased `retired_usernames` (the column is NOCASE).
pub(super) retired_usernames: RwLock<std::collections::HashSet<String>>,
pub(super) profile_fields: RwLock<HashMap<Uuid, Vec<ProfileField>>>,
pub(super) user_settings: RwLock<HashMap<Uuid, UserSettings>>,
@@ -111,8 +99,6 @@ impl InMemoryStore {
store
}
/// Events recorded atomically with a change (the outbox rows the sqlite
/// adapter would have written in the same transaction), in write order.
pub fn recorded_events(&self) -> Vec<crate::events::DomainEvent> {
self.recorded_events.read().unwrap().clone()
}
@@ -156,7 +142,6 @@ impl InMemoryStore {
self.processed_events.read().unwrap().len()
}
/// Inserts a ledger row with explicit timestamps (retention and lease tests).
pub fn seed_processed_event(
&self,
id: &crate::events::EventId,
@@ -178,13 +163,10 @@ impl InMemoryStore {
self.refresh_sessions.read().unwrap().len()
}
/// Inserts a user bypassing the uniqueness checks of `UserRepository::save`.
pub fn seed_user(&self, user: User) {
self.users.write().unwrap().insert(user.id().value(), user);
}
/// Row count of the sqlite table of the same name, for the tables this
/// store models. Panics on a table the store does not hold.
pub fn rows_in(&self, table: &str) -> usize {
match table {
"watchlist_entries" => self.watchlist_count(),

View File

@@ -12,7 +12,6 @@ use super::InMemoryStore;
#[async_trait]
impl GoalCommand for InMemoryStore {
/// `goals` is UNIQUE(user_id, year).
async fn save(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError> {
let mut goals = self.goals.write().unwrap();
let duplicate = goals.values().any(|g| {

View File

@@ -15,9 +15,6 @@ use super::InMemoryStore;
#[async_trait]
impl MovieCommand for InMemoryStore {
/// `movies.external_metadata_id` is UNIQUE: another movie row holding the
/// same external id is a constraint violation, which the sqlite adapter
/// surfaces as an infrastructure error.
async fn upsert_movie(&self, movie: &Movie, events: &[DomainEvent]) -> Result<(), DomainError> {
let mut movies = self.movies.write().unwrap();
if let Some(external) = movie.external_metadata_id() {
@@ -37,9 +34,6 @@ impl MovieCommand for InMemoryStore {
Ok(())
}
/// Schema: `reviews.movie_id REFERENCES movies(id)` has no cascade, so a
/// movie with reviews cannot be deleted. `watchlist_entries` and the
/// `movie_*` profile tables cascade; `watch_events.movie_id` is set to NULL.
async fn delete_movie(
&self,
movie_id: &MovieId,
@@ -67,7 +61,6 @@ impl MovieCommand for InMemoryStore {
}
impl InMemoryStore {
/// Removes the movie row and applies the schema's cascades.
pub(super) fn remove_movie_with_cascades(&self, id: uuid::Uuid) {
self.movies.write().unwrap().remove(&id);
self.watchlist
@@ -155,7 +148,6 @@ impl MovieQuery for InMemoryStore {
page: &PageParams,
filter: &MovieFilter,
) -> Result<Paginated<MovieSummary>, DomainError> {
// Mirrors the sqlite adapter: filter, ORDER BY title ASC, then page.
let movies = self.movies.read().unwrap();
let profiles = self.movie_profiles.read().unwrap();
let search = filter.search.as_deref().map(str::to_lowercase);

View File

@@ -10,7 +10,6 @@ use crate::{
use super::InMemoryStore;
use super::store::LedgerRow;
/// Claim stamps are millisecond-precision UTC, as in the sqlite adapter.
const STAMP: &str = "%Y-%m-%dT%H:%M:%S%.3fZ";
fn now_millis() -> NaiveDateTime {
@@ -23,9 +22,6 @@ fn token(at: NaiveDateTime) -> ClaimToken {
ClaimToken::new(at.format(STAMP).to_string())
}
/// Mirrors the sqlite adapter: `begin` inserts, or takes over only a stale
/// `in_progress` claim; `(event_id, handler)` is the key; `complete` and
/// `release` act only for the token that owns the claim.
#[async_trait]
impl ProcessedEventLedger for InMemoryStore {
async fn begin(&self, id: &EventId, handler: &str) -> Result<Begin, DomainError> {

View File

@@ -10,7 +10,6 @@ use super::InMemoryStore;
#[async_trait]
impl RefreshSessionRepository for InMemoryStore {
/// `refresh_sessions.token` is UNIQUE.
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
let mut sessions = self.refresh_sessions.write().unwrap();
if sessions.iter().any(|s| s.token == session.token) {

View File

@@ -34,8 +34,6 @@ impl UserRepository for InMemoryStore {
.cloned())
}
/// `users.email` and `users.username` are UNIQUE; the sqlite adapter checks
/// both up front and reports a validation error.
async fn save(&self, user: &User) -> Result<(), DomainError> {
let mut users = self.users.write().unwrap();
if users
@@ -129,8 +127,6 @@ impl UserRepository for InMemoryStore {
.count() as u64)
}
/// Ordered by email ascending like the real query. Diary stats are stubbed
/// (review count 0, no last activity); only the identity fields are real.
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
let store = self.users.read().unwrap();
let gone = self.tombstoned.read().unwrap();
@@ -229,10 +225,6 @@ impl UserFederationSettingsQuery for InMemoryStore {
}
impl InMemoryStore {
/// Deletes a user the way `DELETE FROM users` does under the production
/// schema: rows in `USER_CASCADE_TABLES` go with it. Reviews, refresh
/// sessions, import sessions and import profiles carry a plain `user_id`
/// column with no foreign key, so they stay.
pub fn delete_user(&self, user_id: &UserId) {
let id = user_id.value();
self.users.write().unwrap().remove(&id);

View File

@@ -16,8 +16,6 @@ use super::InMemoryStore;
#[async_trait]
impl WatchlistRepository for InMemoryStore {
/// `INSERT OR IGNORE` on UNIQUE(user_id, movie_id): adding twice is a no-op
/// and, like the adapter, records events only for the row it inserted.
async fn add(&self, entry: &WatchlistEntry, events: &[DomainEvent]) -> Result<(), DomainError> {
let key = (entry.user_id.value(), entry.movie_id.value());
let inserted = {

View File

@@ -81,8 +81,6 @@ impl InMemoryWrapUpRepository {
})
}
/// Events recorded atomically with a change (the outbox rows the sqlite
/// adapter writes in the same transaction).
pub fn recorded_events(&self) -> Vec<DomainEvent> {
self.recorded.lock().unwrap().clone()
}
@@ -268,9 +266,6 @@ impl WrapUpRepository for PanicWrapUpRepository {
// ── RefusingWrapUpWrites ───────────────────────────────────────────────────
/// Delegates to an in-memory repository but refuses the two writes that carry
/// outbox events (`create`, `set_complete`), leaving `update_status` and reads
/// working so a refusal can be observed mid-flow.
pub struct RefusingWrapUpWrites {
pub inner: Arc<InMemoryWrapUpRepository>,
}

View File

@@ -33,8 +33,6 @@ impl MovieId {
uuid_id!(ReviewId);
uuid_id!(UserId);
/// Id of the instance's service actor (seeded by migration 0022). It signs
/// fetches for the whole instance and is never a deletable account.
pub const SERVICE_ACTOR_UUID: Uuid =
Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]);

View File

@@ -61,8 +61,6 @@ fn movie_profile(movie_id: &MovieId) -> MovieProfile {
}
}
/// Saves a user and one row in every table the schema cascades from a user,
/// plus rows in the tables that hang off a user without a foreign key.
async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie) {
let user = test_user("alice");
let user_id = user.id().clone();