Merge upstream/main into feat/external-file-mounts

Resolve conflicts between the external-file-mounts feature and upstream's
D5/D7 refactor (per-file provenance, keyset pagination, cross-drive move
gates, resource-access hook, folder-cascade lifecycle hook).

Key resolutions:
- FolderService::new now takes (repo, authz, file_lifecycle, mount_router);
  all callers + DI updated.
- FileRetrievalService / FileManagementService keep both the mount_router
  and the new resource_access_hook / drive_repo / storage_usage wiring.
- list_files_batch_with_perms: adapt the mount branch from offset- to
  keyset (after_name) pagination, mirroring paginate_mount_entries.
- download_file_impl: keep upstream's &HeaderMap + `impl IntoResponse + use<>`
  signature, retain the mount-download branch.
- Mount DTOs: the retired `owner_id` field maps onto created_by/updated_by
  (the mount owner) — the fields the frontend now uses for owner display.
- admin/+page.svelte: keep upstream's user-delete modal + the 'mounts' tab.
- Bump memmap2 0.9.10 -> 0.9.11 (RUSTSEC critical advisory fix) and
  regenerate Cargo.lock against the merged Cargo.toml.
This commit is contained in:
Bradley Nelson
2026-07-21 17:09:36 -06:00
600 changed files with 105575 additions and 14440 deletions
@@ -16,6 +16,23 @@ impl AddressBookPgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
/// `EXISTS` short-circuit for the login provisioning hook — the old
/// `get_address_books_by_owner(..).is_empty()` hydrated every owned
/// `AddressBook` row on EVERY login just to test emptiness (the ROUND9
/// §7 COUNT→EXISTS pattern; benches/ROUND13.md §Q2).
pub async fn has_owned_address_book(&self, owner_id: Uuid) -> Result<bool, DomainError> {
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM carddav.address_books WHERE owner_id = $1)",
)
.bind(owner_id)
.fetch_one(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to probe owned address books: {}", e))
})?;
Ok(exists)
}
}
impl AddressBookRepository for AddressBookPgRepository {
@@ -110,6 +127,45 @@ impl AddressBookRepository for AddressBookPgRepository {
Ok(())
}
async fn get_address_books_by_ids(
&self,
ids: &[Uuid],
) -> AddressBookRepositoryResult<Vec<AddressBook>> {
if ids.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query(
r#"
SELECT id, name, owner_id, description, color, is_public, created_at, updated_at
FROM carddav.address_books
WHERE id = ANY($1)
"#,
)
.bind(ids)
.fetch_all(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to get address books by ids: {}", e))
})?;
Ok(rows
.iter()
.map(|row| {
let owner_id: Uuid = row.get("owner_id");
AddressBook::from_raw(
row.get("id"),
row.get("name"),
owner_id.to_string(),
row.get("description"),
row.get("color"),
row.get("is_public"),
row.get("created_at"),
row.get("updated_at"),
)
})
.collect())
}
async fn get_address_book_by_id(
&self,
id: &Uuid,
@@ -184,44 +240,6 @@ impl AddressBookRepository for AddressBookPgRepository {
Ok(result)
}
async fn get_shared_address_books(
&self,
user_id: Uuid,
) -> AddressBookRepositoryResult<Vec<AddressBook>> {
let rows = sqlx::query(
r#"
SELECT a.id, a.name, a.owner_id, a.description, a.color, a.is_public, a.created_at, a.updated_at
FROM carddav.address_books a
INNER JOIN carddav.address_book_shares s ON a.id = s.address_book_id
WHERE s.user_id = $1
ORDER BY a.name
"#
)
.bind(user_id)
.fetch_all(&*self.pool)
.await
.map_err(|e| DomainError::database_error(format!("Failed to get shared address books: {}", e)))?;
let result = rows
.into_iter()
.map(|row| {
let owner_id: Uuid = row.get("owner_id");
AddressBook::from_raw(
row.get("id"),
row.get("name"),
owner_id.to_string(),
row.get("description"),
row.get("color"),
row.get("is_public"),
row.get("created_at"),
row.get("updated_at"),
)
})
.collect();
Ok(result)
}
async fn get_public_address_books(&self) -> AddressBookRepositoryResult<Vec<AddressBook>> {
let rows = sqlx::query(
r#"
@@ -256,79 +274,4 @@ impl AddressBookRepository for AddressBookPgRepository {
Ok(result)
}
async fn share_address_book(
&self,
address_book_id: &Uuid,
user_id: Uuid,
can_write: bool,
) -> AddressBookRepositoryResult<()> {
sqlx::query(
r#"
INSERT INTO carddav.address_book_shares (address_book_id, user_id, can_write)
VALUES ($1, $2, $3)
ON CONFLICT (address_book_id, user_id) DO UPDATE SET can_write = $3
"#,
)
.bind(address_book_id)
.bind(user_id)
.bind(can_write)
.execute(&*self.pool)
.await
.map_err(|e| DomainError::database_error(format!("Failed to share address book: {}", e)))?;
Ok(())
}
async fn unshare_address_book(
&self,
address_book_id: &Uuid,
user_id: Uuid,
) -> AddressBookRepositoryResult<()> {
sqlx::query(
r#"
DELETE FROM carddav.address_book_shares
WHERE address_book_id = $1 AND user_id = $2
"#,
)
.bind(address_book_id)
.bind(user_id)
.execute(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to unshare address book: {}", e))
})?;
Ok(())
}
async fn get_address_book_shares(
&self,
address_book_id: &Uuid,
) -> AddressBookRepositoryResult<Vec<(String, bool)>> {
let rows = sqlx::query(
r#"
SELECT user_id, can_write
FROM carddav.address_book_shares
WHERE address_book_id = $1
ORDER BY user_id
"#,
)
.bind(address_book_id)
.fetch_all(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to get address book shares: {}", e))
})?;
let result = rows
.into_iter()
.map(|row| {
let user_id: Uuid = row.get("user_id");
(user_id.to_string(), row.get("can_write"))
})
.collect();
Ok(result)
}
}
@@ -16,6 +16,31 @@ impl CalendarEventPgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
/// Shared row → entity mapping (the inline shape every listing
/// method uses, factored for the cursor stream).
fn row_to_event(row: &sqlx::postgres::PgRow) -> CalendarEventRepositoryResult<CalendarEvent> {
let mut event = CalendarEvent::with_id(
row.get("id"),
row.get("calendar_id"),
row.get("summary"),
row.get::<Option<String>, _>("description"),
row.get::<Option<String>, _>("location"),
row.get("start_time"),
row.get("end_time"),
row.get("all_day"),
row.get::<Option<String>, _>("rrule"),
row.get("ical_uid"),
row.get("ical_data"),
row.get("created_at"),
row.get("updated_at"),
)
.map_err(|e| {
DomainError::database_error(format!("Error creating calendar event: {}", e))
})?;
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
Ok(event)
}
}
impl CalendarEventRepository for CalendarEventPgRepository {
@@ -30,10 +55,11 @@ impl CalendarEventRepository for CalendarEventPgRepository {
sqlx::query(
r#"
INSERT INTO caldav.calendar_events (
id, calendar_id, summary, description, location, start_time, end_time,
all_day, rrule, created_at, updated_at, ical_uid, ical_data
id, calendar_id, summary, description, location, start_time, end_time,
all_day, rrule, created_at, updated_at, ical_uid, ical_data,
recurrence_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
"#,
)
.bind(event.id())
@@ -49,6 +75,10 @@ impl CalendarEventRepository for CalendarEventPgRepository {
.bind(event.updated_at())
.bind(event.ical_uid())
.bind(event.ical_data())
// NULL on masters, non-NULL on exception overrides — see the
// `20260913000001_calendar_events_recurrence_id.sql` migration
// and `docs/architecture/rebac-authorization.md` follow-up doc.
.bind(event.recurrence_id().copied())
.execute(&*self.pool)
.await
.map_err(|e| {
@@ -68,16 +98,17 @@ impl CalendarEventRepository for CalendarEventPgRepository {
sqlx::query(
r#"
UPDATE caldav.calendar_events
SET summary = $1,
description = $2,
location = $3,
start_time = $4,
end_time = $5,
all_day = $6,
SET summary = $1,
description = $2,
location = $3,
start_time = $4,
end_time = $5,
all_day = $6,
rrule = $7,
ical_data = $8,
updated_at = $9
WHERE id = $10
recurrence_id = $9,
updated_at = $10
WHERE id = $11
"#,
)
.bind(event.summary())
@@ -88,6 +119,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
.bind(event.all_day())
.bind(event.rrule())
.bind(event.ical_data())
.bind(event.recurrence_id().copied())
.bind(now)
.bind(event.id())
.execute(&*self.pool)
@@ -126,12 +158,12 @@ impl CalendarEventRepository for CalendarEventPgRepository {
) -> CalendarEventRepositoryResult<Vec<CalendarEvent>> {
let rows = sqlx::query(
r#"
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data, recurrence_id
FROM caldav.calendar_events
WHERE calendar_id = $1
WHERE calendar_id = $1
AND (
(start_time >= $2 AND start_time < $3) OR
(end_time > $2 AND end_time <= $3) OR
@@ -150,9 +182,9 @@ impl CalendarEventRepository for CalendarEventPgRepository {
DomainError::database_error(format!("Failed to get events in time range: {}", e))
})?;
let mut events = Vec::new();
let mut events = Vec::with_capacity(rows.len());
for row in rows {
let event = CalendarEvent::with_id(
let mut event = CalendarEvent::with_id(
row.get("id"),
row.get("calendar_id"),
row.get("summary"),
@@ -170,19 +202,35 @@ impl CalendarEventRepository for CalendarEventPgRepository {
.map_err(|e| {
DomainError::database_error(format!("Error creating calendar event: {}", e))
})?;
// Rehydrate the RECURRENCE-ID after entity construction —
// `with_id` initialises to `None` because the field predates
// the rest of the constructor signature (#528). Keeping
// `with_id` unchanged avoids ripple-changing every caller.
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
events.push(event);
}
Ok(events)
}
async fn find_calendar_id_by_event_id(&self, id: &Uuid) -> CalendarEventRepositoryResult<Uuid> {
sqlx::query_scalar("SELECT calendar_id FROM caldav.calendar_events WHERE id = $1")
.bind(id)
.fetch_optional(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to get event calendar id: {}", e))
})?
.ok_or_else(|| DomainError::not_found("Calendar Event", id.to_string()))
}
async fn find_event_by_id(&self, id: &Uuid) -> CalendarEventRepositoryResult<CalendarEvent> {
let row = sqlx::query(
r#"
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data, recurrence_id
FROM caldav.calendar_events
WHERE id = $1
"#,
@@ -195,11 +243,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
})?
.ok_or_else(|| DomainError::not_found("Calendar Event", id.to_string()))?;
// In a real implementation, we would build a complete CalendarEvent object
// For simplicity, we create an object with default values to
// demonstrate the approach without macros
let event = CalendarEvent::with_id(
let mut event = CalendarEvent::with_id(
row.get("id"),
row.get("calendar_id"),
row.get("summary"),
@@ -217,6 +261,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
.map_err(|e| {
DomainError::database_error(format!("Error creating calendar event: {}", e))
})?;
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
Ok(event)
}
@@ -227,10 +272,10 @@ impl CalendarEventRepository for CalendarEventPgRepository {
) -> CalendarEventRepositoryResult<Vec<CalendarEvent>> {
let rows = sqlx::query(
r#"
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data, recurrence_id
FROM caldav.calendar_events
WHERE calendar_id = $1
ORDER BY start_time
@@ -243,9 +288,9 @@ impl CalendarEventRepository for CalendarEventPgRepository {
DomainError::database_error(format!("Failed to get events by calendar: {}", e))
})?;
let mut events = Vec::new();
let mut events = Vec::with_capacity(rows.len());
for row in rows {
let event = CalendarEvent::with_id(
let mut event = CalendarEvent::with_id(
row.get("id"),
row.get("calendar_id"),
row.get("summary"),
@@ -263,6 +308,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
.map_err(|e| {
DomainError::database_error(format!("Error creating calendar event: {}", e))
})?;
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
events.push(event);
}
@@ -278,10 +324,10 @@ impl CalendarEventRepository for CalendarEventPgRepository {
let rows = sqlx::query(
r#"
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data, recurrence_id
FROM caldav.calendar_events
WHERE calendar_id = $1 AND summary ILIKE $2
ORDER BY start_time
@@ -295,9 +341,9 @@ impl CalendarEventRepository for CalendarEventPgRepository {
DomainError::database_error(format!("Failed to find events by summary: {}", e))
})?;
let mut events = Vec::new();
let mut events = Vec::with_capacity(rows.len());
for row in rows {
let event = CalendarEvent::with_id(
let mut event = CalendarEvent::with_id(
row.get("id"),
row.get("calendar_id"),
row.get("summary"),
@@ -315,6 +361,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
.map_err(|e| {
DomainError::database_error(format!("Error creating calendar event: {}", e))
})?;
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
events.push(event);
}
@@ -326,14 +373,21 @@ impl CalendarEventRepository for CalendarEventPgRepository {
calendar_id: &Uuid,
ical_uid: &str,
) -> CalendarEventRepositoryResult<Option<CalendarEvent>> {
// Phase 2 note: this method looks up "an event with this UID"
// — the SELECT still isn't filtered on `recurrence_id IS NULL`
// because the phase-3 handler routing (which will distinguish
// master vs. exception override at PUT time) is where the
// filter actually needs to live. For phase 2 the invariant is
// enforced only at INSERT time via the two partial unique
// indexes; reads see whatever's there.
let row_opt = sqlx::query(
r#"
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data, recurrence_id
FROM caldav.calendar_events
WHERE calendar_id = $1 AND ical_uid = $2
WHERE calendar_id = $1 AND ical_uid = $2 AND recurrence_id IS NULL
"#,
)
.bind(calendar_id)
@@ -346,7 +400,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
match row_opt {
Some(row) => {
let event = CalendarEvent::with_id(
let mut event = CalendarEvent::with_id(
row.get("id"),
row.get("calendar_id"),
row.get("summary"),
@@ -364,6 +418,67 @@ impl CalendarEventRepository for CalendarEventPgRepository {
.map_err(|e| {
DomainError::database_error(format!("Error creating calendar event: {}", e))
})?;
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
Ok(Some(event))
}
None => Ok(None),
}
}
async fn find_event_by_ical_uid_and_recurrence_id(
&self,
calendar_id: &Uuid,
ical_uid: &str,
recurrence_id: &DateTime<Utc>,
) -> CalendarEventRepositoryResult<Option<CalendarEvent>> {
// Uses idx_calendar_events_exception_unique — the partial
// unique index on (calendar_id, ical_uid, recurrence_id)
// WHERE recurrence_id IS NOT NULL — for the exact-match seek.
let row_opt = sqlx::query(
r#"
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data, recurrence_id
FROM caldav.calendar_events
WHERE calendar_id = $1
AND ical_uid = $2
AND recurrence_id = $3
"#,
)
.bind(calendar_id)
.bind(ical_uid)
.bind(recurrence_id)
.fetch_optional(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!(
"Failed to get calendar event exception by UID+RECURRENCE-ID: {}",
e
))
})?;
match row_opt {
Some(row) => {
let mut event = CalendarEvent::with_id(
row.get("id"),
row.get("calendar_id"),
row.get("summary"),
row.get::<Option<String>, _>("description"),
row.get::<Option<String>, _>("location"),
row.get("start_time"),
row.get("end_time"),
row.get("all_day"),
row.get::<Option<String>, _>("rrule"),
row.get("ical_uid"),
row.get("ical_data"),
row.get("created_at"),
row.get("updated_at"),
)
.map_err(|e| {
DomainError::database_error(format!("Error creating calendar event: {}", e))
})?;
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
Ok(Some(event))
}
None => Ok(None),
@@ -375,12 +490,18 @@ impl CalendarEventRepository for CalendarEventPgRepository {
calendar_id: &Uuid,
ical_uids: &[String],
) -> CalendarEventRepositoryResult<Vec<CalendarEvent>> {
// Batch UID lookup returns ALL rows for the given UIDs, both
// masters and exception overrides. Callers that want just
// masters filter downstream. Same phase-2 policy as the
// single-UID variant — read-side filtering is a phase-3
// concern; the DB unique indexes are what guarantee at most
// one master + N distinct exceptions per (calendar, UID).
let rows = sqlx::query(
r#"
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data
created_at, updated_at, ical_uid, ical_data, recurrence_id
FROM caldav.calendar_events
WHERE calendar_id = $1 AND ical_uid = ANY($2)
ORDER BY start_time
@@ -394,9 +515,9 @@ impl CalendarEventRepository for CalendarEventPgRepository {
DomainError::database_error(format!("Failed to get calendar events by UIDs: {}", e))
})?;
let mut events = Vec::new();
let mut events = Vec::with_capacity(rows.len());
for row in rows {
let event = CalendarEvent::with_id(
let mut event = CalendarEvent::with_id(
row.get("id"),
row.get("calendar_id"),
row.get("summary"),
@@ -414,6 +535,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
.map_err(|e| {
DomainError::database_error(format!("Error creating calendar event: {}", e))
})?;
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
events.push(event);
}
@@ -461,6 +583,57 @@ impl CalendarEventRepository for CalendarEventPgRepository {
Ok(result.rows_affected() as i64)
}
fn stream_events_uid_order(
&self,
calendar_id: Uuid,
) -> futures::stream::BoxStream<'static, CalendarEventRepositoryResult<CalendarEvent>> {
// ONE ordered scan for the whole calendar, served through a PG
// cursor (`fetch`) so only a window of rows is in flight. The
// window function puts every UID's rows adjacent, bundles
// ordered by first occurrence — exactly the first-appearance
// order the buffered `ORDER BY start_time` listing produced
// after grouping — with the master row first inside each UID.
//
// The first streaming shape hydrated pages via
// `ical_uid = ANY(page)`: ~20 µs per index descent made the
// total wall 3-4x the buffered single scan (measured in
// benches/ROUND5.md). This keeps the buffered path's one
// scan+sort while bounding memory to a page.
let pool = self.pool.clone();
let stream: futures::stream::BoxStream<
'static,
CalendarEventRepositoryResult<CalendarEvent>,
> = Box::pin(async_stream::try_stream! {
let mut conn = pool.acquire().await.map_err(|e| {
DomainError::database_error(format!("Failed to acquire connection: {}", e))
})?;
let mut rows = sqlx::query(
r#"
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data, recurrence_id
FROM caldav.calendar_events
WHERE calendar_id = $1
ORDER BY MIN(start_time) OVER (PARTITION BY ical_uid),
ical_uid,
(recurrence_id IS NOT NULL),
start_time
"#,
)
.bind(calendar_id)
.fetch(&mut *conn);
use futures::TryStreamExt;
while let Some(row) = rows.try_next().await.map_err(|e| {
DomainError::database_error(format!("Failed to stream events: {}", e))
})? {
yield Self::row_to_event(&row)?;
}
});
stream
}
async fn list_events_by_calendar_paginated(
&self,
calendar_id: &Uuid,
@@ -491,7 +664,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
))
})?;
let mut events = Vec::new();
let mut events = Vec::with_capacity(rows.len());
for row in rows {
let event = CalendarEvent::with_id(
row.get("id"),
@@ -546,7 +719,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
DomainError::database_error(format!("Failed to find recurring events in range: {}", e))
})?;
let mut events = Vec::new();
let mut events = Vec::with_capacity(rows.len());
for row in rows {
let event = CalendarEvent::with_id(
row.get("id"),
@@ -16,6 +16,24 @@ impl CalendarPgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
/// `EXISTS` short-circuit for the login provisioning hook, which only
/// needs to know whether the user owns ANY calendar. The old
/// `list_calendars_by_owner(..).is_empty()` hydrated every owned
/// `Calendar` row (8 cols incl. description/color TEXT) on EVERY login
/// just to test emptiness — the ROUND9 §7 `Drive::is_empty` COUNT→EXISTS
/// pattern (benches/ROUND13.md §Q2).
pub async fn has_owned_calendar(&self, owner_id: Uuid) -> CalendarRepositoryResult<bool> {
let exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM caldav.calendars WHERE owner_id = $1)")
.bind(owner_id)
.fetch_one(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to probe owned calendars: {}", e))
})?;
Ok(exists)
}
}
impl CalendarRepository for CalendarPgRepository {
@@ -138,6 +156,42 @@ impl CalendarRepository for CalendarPgRepository {
Ok(calendar)
}
async fn find_calendars_by_ids(&self, ids: &[Uuid]) -> CalendarRepositoryResult<Vec<Calendar>> {
if ids.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query(
r#"
SELECT id, name, owner_id, description, color, is_public, created_at, updated_at
FROM caldav.calendars
WHERE id = ANY($1)
"#,
)
.bind(ids)
.fetch_all(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to get calendars by ids: {}", e))
})?;
rows.iter()
.map(|row| {
Calendar::with_id(
row.get("id"),
row.get("name"),
row.get("owner_id"),
row.get("description"),
row.get("color"),
row.get("created_at"),
row.get("updated_at"),
)
.map_err(|e| {
DomainError::database_error(format!("Failed to create calendar object: {}", e))
})
})
.collect()
}
async fn list_calendars_by_owner(
&self,
owner_id: Uuid,
@@ -157,7 +211,7 @@ impl CalendarRepository for CalendarPgRepository {
DomainError::database_error(format!("Failed to get calendars by owner: {}", e))
})?;
let mut calendars = Vec::new();
let mut calendars = Vec::with_capacity(rows.len());
for row in rows {
let calendar = Calendar::with_id(
row.get("id"),
@@ -216,44 +270,6 @@ impl CalendarRepository for CalendarPgRepository {
Ok(calendar)
}
async fn list_calendars_shared_with_user(
&self,
user_id: Uuid,
) -> CalendarRepositoryResult<Vec<Calendar>> {
let rows = sqlx::query(
r#"
SELECT c.id, c.name, c.owner_id, c.description, c.color, c.is_public, c.created_at, c.updated_at
FROM caldav.calendars c
INNER JOIN caldav.calendar_shares s ON c.id = s.calendar_id
WHERE s.user_id = $1
ORDER BY c.name
"#
)
.bind(user_id)
.fetch_all(&*self.pool)
.await
.map_err(|e| DomainError::database_error(format!("Failed to get shared calendars: {}", e)))?;
let mut calendars = Vec::new();
for row in rows {
let calendar = Calendar::with_id(
row.get("id"),
row.get("name"),
row.get("owner_id"),
row.get("description"),
row.get("color"),
row.get("created_at"),
row.get("updated_at"),
)
.map_err(|e| {
DomainError::database_error(format!("Failed to create calendar object: {}", e))
})?;
calendars.push(calendar);
}
Ok(calendars)
}
async fn list_public_calendars(
&self,
limit: i64,
@@ -276,7 +292,7 @@ impl CalendarRepository for CalendarPgRepository {
DomainError::database_error(format!("Failed to get public calendars: {}", e))
})?;
let mut calendars = Vec::new();
let mut calendars = Vec::with_capacity(rows.len());
for row in rows {
let calendar = Calendar::with_id(
row.get("id"),
@@ -296,112 +312,6 @@ impl CalendarRepository for CalendarPgRepository {
Ok(calendars)
}
async fn user_has_calendar_access(
&self,
calendar_id: &Uuid,
user_id: Uuid,
) -> CalendarRepositoryResult<bool> {
// Check if the user is the owner of the calendar or has a share
let row = sqlx::query(
r#"
SELECT EXISTS (
SELECT 1 FROM caldav.calendars c
WHERE c.id = $1 AND (c.owner_id = $2 OR c.is_public = true)
UNION
SELECT 1 FROM caldav.calendar_shares s
WHERE s.calendar_id = $1 AND s.user_id = $2
) as has_access
"#,
)
.bind(calendar_id)
.bind(user_id)
.fetch_one(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to check calendar access: {}", e))
})?;
Ok(row.get::<bool, _>("has_access"))
}
async fn share_calendar(
&self,
calendar_id: &Uuid,
user_id: Uuid,
access_level: &str,
) -> CalendarRepositoryResult<()> {
// Validate access level
if !["read", "write", "owner"].contains(&access_level) {
return Err(DomainError::validation_error(format!(
"Invalid access level: '{}'. Must be 'read', 'write', or 'owner'",
access_level
)));
}
sqlx::query(
r#"
INSERT INTO caldav.calendar_shares (calendar_id, user_id, access_level)
VALUES ($1, $2, $3)
ON CONFLICT (calendar_id, user_id) DO UPDATE SET access_level = $3
"#,
)
.bind(calendar_id)
.bind(user_id)
.bind(access_level)
.execute(&*self.pool)
.await
.map_err(|e| DomainError::database_error(format!("Failed to share calendar: {}", e)))?;
Ok(())
}
async fn remove_calendar_sharing(
&self,
calendar_id: &Uuid,
user_id: Uuid,
) -> CalendarRepositoryResult<()> {
sqlx::query(
r#"
DELETE FROM caldav.calendar_shares
WHERE calendar_id = $1 AND user_id = $2
"#,
)
.bind(calendar_id)
.bind(user_id)
.execute(&*self.pool)
.await
.map_err(|e| DomainError::database_error(format!("Failed to unshare calendar: {}", e)))?;
Ok(())
}
async fn get_calendar_shares(
&self,
calendar_id: &Uuid,
) -> CalendarRepositoryResult<Vec<(String, String)>> {
let rows = sqlx::query(
r#"
SELECT user_id, access_level
FROM caldav.calendar_shares
WHERE calendar_id = $1
ORDER BY user_id
"#,
)
.bind(calendar_id)
.fetch_all(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to get calendar shares: {}", e))
})?;
let mut shares = Vec::new();
for row in rows {
shares.push((row.get("user_id"), row.get("access_level")));
}
Ok(shares)
}
async fn get_calendar_property(
&self,
calendar_id: &Uuid,
@@ -490,7 +400,7 @@ impl CalendarRepository for CalendarPgRepository {
DomainError::database_error(format!("Failed to get calendar properties: {}", e))
})?;
let mut properties = std::collections::HashMap::new();
let mut properties = std::collections::HashMap::with_capacity(rows.len());
for row in rows {
properties.insert(row.get("name"), row.get("value"));
}
@@ -1,5 +1,4 @@
use chrono::Utc;
use serde_json::Value as JsonValue;
use sqlx::{PgPool, Row, types::Uuid};
use std::sync::Arc;
@@ -177,16 +176,30 @@ impl ContactGroupRepository for ContactGroupPgRepository {
Ok(())
}
async fn count_contacts_in_group(&self, group_id: &Uuid) -> ContactRepositoryResult<i64> {
sqlx::query_scalar("SELECT COUNT(*) FROM carddav.group_memberships WHERE group_id = $1")
.bind(group_id)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::new(
ErrorKind::InternalError,
"ContactGroup",
format!("Failed to count contacts in group: {}", e),
)
})
}
async fn get_contacts_in_group(
&self,
group_id: &Uuid,
) -> ContactRepositoryResult<Vec<Contact>> {
let rows = sqlx::query(
r#"
SELECT
SELECT
c.id, c.address_book_id, c.uid, c.full_name, c.first_name, c.last_name, c.nickname,
c.email, c.phone, c.address, c.organization, c.title, c.notes, c.photo_url,
c.birthday, c.anniversary, c.vcard, c.etag, c.created_at, c.updated_at
c.birthday, c.anniversary, c.etag, c.created_at, c.updated_at
FROM carddav.contacts c
INNER JOIN carddav.group_memberships gm ON c.id = gm.contact_id
WHERE gm.group_id = $1
@@ -204,20 +217,23 @@ impl ContactGroupRepository for ContactGroupPgRepository {
)
})?;
let mut contacts = Vec::new();
let mut contacts = Vec::with_capacity(rows.len());
for row in &rows {
let email_json: JsonValue = row.get("email");
let phone_json: JsonValue = row.get("phone");
let address_json: JsonValue = row.get("address");
let emails = serde_json::from_value::<Vec<EmailPersistenceDto>>(email_json)
.map(emails_from_persistence)
// Typed `Json<T>` decode (one `from_slice` pass) instead of the
// `Value` DOM + `from_value` re-walk — the contact_pg_repository
// §J1 fix applied to this inlined sibling. Byte-identical result,
// 3 fewer throwaway DOMs per contact. (benches/ROUND23.md §J1)
let emails = row
.try_get::<sqlx::types::Json<Vec<EmailPersistenceDto>>, _>("email")
.map(|j| emails_from_persistence(j.0))
.unwrap_or_default();
let phones = serde_json::from_value::<Vec<PhonePersistenceDto>>(phone_json)
.map(phones_from_persistence)
let phones = row
.try_get::<sqlx::types::Json<Vec<PhonePersistenceDto>>, _>("phone")
.map(|j| phones_from_persistence(j.0))
.unwrap_or_default();
let addresses = serde_json::from_value::<Vec<AddressPersistenceDto>>(address_json)
.map(addresses_from_persistence)
let addresses = row
.try_get::<sqlx::types::Json<Vec<AddressPersistenceDto>>, _>("address")
.map(|j| addresses_from_persistence(j.0))
.unwrap_or_default();
contacts.push(Contact::from_raw(
@@ -237,7 +253,13 @@ impl ContactGroupRepository for ContactGroupPgRepository {
row.get::<Option<String>, _>("photo_url"),
row.get("birthday"),
row.get("anniversary"),
row.get("vcard"),
// vcard column intentionally NOT selected — the sole live caller
// (`list_contacts_in_group`) maps to `ContactDto`, which has no
// vcard field, so fetching the multi-KB serialized vCard (with an
// embedded base64 PHOTO) only to drop it wastes bandwidth + a
// per-row String. Mirrors `row_to_contact_lite` (benches/ROUND29.md
// §F / ROUND25 §Q2, applied to the LIVE group method this time).
String::new(),
row.get("etag"),
row.get("created_at"),
row.get("updated_at"),
@@ -1,5 +1,4 @@
use chrono::Utc;
use serde_json::Value as JsonValue;
use sqlx::{PgPool, Row, types::Uuid};
use std::sync::Arc;
@@ -21,20 +20,49 @@ impl ContactPgRepository {
Self { pool }
}
/// Maps a database row to a Contact domain entity
/// Maps a database row to a Contact domain entity (reads the `vcard` column).
fn row_to_contact(row: &sqlx::postgres::PgRow) -> Result<Contact, DomainError> {
let email_json: JsonValue = row.get("email");
let phone_json: JsonValue = row.get("phone");
let address_json: JsonValue = row.get("address");
Self::row_to_contact_with_vcard(row, row.get("vcard"))
}
let emails = serde_json::from_value::<Vec<EmailPersistenceDto>>(email_json)
.map(emails_from_persistence)
/// Maps a row whose SELECT omitted the `vcard` column — used by the REST
/// listings (paginated / search / by-group) whose `ContactDto` drops vcard
/// anyway, so the multi-KB vCard TEXT (which can embed a base64 PHOTO) is
/// never SELECTed, shipped over the wire, or allocated (benches/ROUND25.md
/// §Q2). The domain `Contact` keeps an empty vcard; these paths never
/// re-emit it. Do NOT use for CardDAV sync / whole-book export, which need
/// the round-trip vCard.
fn row_to_contact_lite(row: &sqlx::postgres::PgRow) -> Result<Contact, DomainError> {
Self::row_to_contact_with_vcard(row, String::new())
}
/// Shared row → `Contact` mapper; `vcard` is supplied by the caller so the
/// TEXT column can be omitted from listings that don't consume it.
fn row_to_contact_with_vcard(
row: &sqlx::postgres::PgRow,
vcard: String,
) -> Result<Contact, DomainError> {
// Decode each JSONB column straight into its typed Vec via
// `sqlx::types::Json<T>` (a single `serde_json::from_slice` pass over
// the raw JSONB bytes) instead of `row.get::<serde_json::Value>` +
// `serde_json::from_value`, which built a throwaway `Value` DOM per
// column and then walked it a SECOND time to produce the typed Vec —
// 3 discarded DOMs per contact row on every list / multiget / CardDAV
// sync. `try_get` preserves the exact malformed-shape fallback (the old
// `from_value(...).unwrap_or_default()`; a bare `row.get` would panic on
// a decode error); the columns are `JSONB NOT NULL DEFAULT '[]'`, so SQL
// NULL never occurs. (benches/ROUND23.md §J1)
let emails = row
.try_get::<sqlx::types::Json<Vec<EmailPersistenceDto>>, _>("email")
.map(|j| emails_from_persistence(j.0))
.unwrap_or_default();
let phones = serde_json::from_value::<Vec<PhonePersistenceDto>>(phone_json)
.map(phones_from_persistence)
let phones = row
.try_get::<sqlx::types::Json<Vec<PhonePersistenceDto>>, _>("phone")
.map(|j| phones_from_persistence(j.0))
.unwrap_or_default();
let addresses = serde_json::from_value::<Vec<AddressPersistenceDto>>(address_json)
.map(addresses_from_persistence)
let addresses = row
.try_get::<sqlx::types::Json<Vec<AddressPersistenceDto>>, _>("address")
.map(|j| addresses_from_persistence(j.0))
.unwrap_or_default();
Ok(Contact::from_raw(
@@ -54,7 +82,7 @@ impl ContactPgRepository {
row.get::<Option<String>, _>("photo_url"),
row.get("birthday"),
row.get("anniversary"),
row.get("vcard"),
vcard,
row.get("etag"),
row.get("created_at"),
row.get("updated_at"),
@@ -69,10 +97,6 @@ impl ContactRepository for ContactPgRepository {
let phone_dtos = phones_to_persistence(contact.phone());
let address_dtos = addresses_to_persistence(contact.address());
let email_json = serde_json::to_value(&email_dtos).unwrap_or(JsonValue::Null);
let phone_json = serde_json::to_value(&phone_dtos).unwrap_or(JsonValue::Null);
let address_json = serde_json::to_value(&address_dtos).unwrap_or(JsonValue::Null);
let row = sqlx::query(
r#"
INSERT INTO carddav.contacts (
@@ -97,9 +121,9 @@ impl ContactRepository for ContactPgRepository {
.bind(contact.first_name_owned())
.bind(contact.last_name_owned())
.bind(contact.nickname_owned())
.bind(email_json)
.bind(phone_json)
.bind(address_json)
.bind(sqlx::types::Json(&email_dtos))
.bind(sqlx::types::Json(&phone_dtos))
.bind(sqlx::types::Json(&address_dtos))
.bind(contact.organization_owned())
.bind(contact.title_owned())
.bind(contact.notes_owned())
@@ -124,10 +148,6 @@ impl ContactRepository for ContactPgRepository {
let phone_dtos = phones_to_persistence(contact.phone());
let address_dtos = addresses_to_persistence(contact.address());
let email_json = serde_json::to_value(&email_dtos).unwrap_or(JsonValue::Null);
let phone_json = serde_json::to_value(&phone_dtos).unwrap_or(JsonValue::Null);
let address_json = serde_json::to_value(&address_dtos).unwrap_or(JsonValue::Null);
// Create a clone of the contact with the updated timestamp
let mut updated_contact = contact.clone();
updated_contact.set_updated_at(now);
@@ -163,9 +183,9 @@ impl ContactRepository for ContactPgRepository {
.bind(updated_contact.first_name_owned())
.bind(updated_contact.last_name_owned())
.bind(updated_contact.nickname_owned())
.bind(email_json)
.bind(phone_json)
.bind(address_json)
.bind(sqlx::types::Json(&email_dtos))
.bind(sqlx::types::Json(&phone_dtos))
.bind(sqlx::types::Json(&address_dtos))
.bind(updated_contact.organization_owned())
.bind(updated_contact.title_owned())
.bind(updated_contact.notes_owned())
@@ -271,13 +291,52 @@ impl ContactRepository for ContactPgRepository {
DomainError::database_error(format!("Failed to get contacts by uids: {}", e))
})?;
let mut contacts = Vec::new();
let mut contacts = Vec::with_capacity(rows.len());
for row in &rows {
contacts.push(Self::row_to_contact(row)?);
}
Ok(contacts)
}
fn stream_contacts_by_book(
&self,
address_book_id: Uuid,
) -> futures::stream::BoxStream<'static, ContactRepositoryResult<Contact>> {
// ONE ordered scan served through a PG cursor — the CardDAV
// multistatus emitters page over this stream so only a page of
// contacts is resident (same design as the CalDAV round-5
// cursor; contacts have no master/exception bundling, so pages
// can cut anywhere).
let pool = self.pool.clone();
let stream: futures::stream::BoxStream<'static, ContactRepositoryResult<Contact>> =
Box::pin(async_stream::try_stream! {
let mut conn = pool.acquire().await.map_err(|e| {
DomainError::database_error(format!("Failed to acquire connection: {}", e))
})?;
let mut rows = sqlx::query(
r#"
SELECT
id, address_book_id, uid, full_name, first_name, last_name, nickname,
email, phone, address, organization, title, notes, photo_url,
birthday, anniversary, vcard, etag, created_at, updated_at
FROM carddav.contacts
WHERE address_book_id = $1
ORDER BY full_name, first_name, last_name
"#,
)
.bind(address_book_id)
.fetch(&mut *conn);
use futures::TryStreamExt;
while let Some(row) = rows.try_next().await.map_err(|e| {
DomainError::database_error(format!("Failed to stream contacts: {}", e))
})? {
yield Self::row_to_contact(&row)?;
}
});
stream
}
async fn get_contacts_by_address_book(
&self,
address_book_id: &Uuid,
@@ -300,7 +359,7 @@ impl ContactRepository for ContactPgRepository {
DomainError::database_error(format!("Failed to get contacts by address book: {}", e))
})?;
let mut contacts = Vec::new();
let mut contacts = Vec::with_capacity(rows.len());
for row in &rows {
contacts.push(Self::row_to_contact(row)?);
}
@@ -318,7 +377,7 @@ impl ContactRepository for ContactPgRepository {
SELECT
id, address_book_id, uid, full_name, first_name, last_name, nickname,
email, phone, address, organization, title, notes, photo_url,
birthday, anniversary, vcard, etag, created_at, updated_at
birthday, anniversary, etag, created_at, updated_at
FROM carddav.contacts
WHERE address_book_id = $1
ORDER BY full_name, first_name, last_name
@@ -337,9 +396,9 @@ impl ContactRepository for ContactPgRepository {
))
})?;
let mut contacts = Vec::new();
let mut contacts = Vec::with_capacity(rows.len());
for row in &rows {
contacts.push(Self::row_to_contact(row)?);
contacts.push(Self::row_to_contact_lite(row)?);
}
Ok(contacts)
}
@@ -365,7 +424,7 @@ impl ContactRepository for ContactPgRepository {
DomainError::database_error(format!("Failed to get contacts by email: {}", e))
})?;
let mut contacts = Vec::new();
let mut contacts = Vec::with_capacity(rows.len());
for row in &rows {
contacts.push(Self::row_to_contact(row)?);
}
@@ -381,7 +440,7 @@ impl ContactRepository for ContactPgRepository {
SELECT
c.id, c.address_book_id, c.uid, c.full_name, c.first_name, c.last_name, c.nickname,
c.email, c.phone, c.address, c.organization, c.title, c.notes, c.photo_url,
c.birthday, c.anniversary, c.vcard, c.etag, c.created_at, c.updated_at
c.birthday, c.anniversary, c.etag, c.created_at, c.updated_at
FROM carddav.contacts c
INNER JOIN carddav.group_memberships m ON c.id = m.contact_id
WHERE m.group_id = $1
@@ -395,9 +454,9 @@ impl ContactRepository for ContactPgRepository {
DomainError::database_error(format!("Failed to get contacts by group: {}", e))
})?;
let mut contacts = Vec::new();
let mut contacts = Vec::with_capacity(rows.len());
for row in &rows {
contacts.push(Self::row_to_contact(row)?);
contacts.push(Self::row_to_contact_lite(row)?);
}
Ok(contacts)
}
@@ -414,9 +473,9 @@ impl ContactRepository for ContactPgRepository {
SELECT
id, address_book_id, uid, full_name, first_name, last_name, nickname,
email, phone, address, organization, title, notes, photo_url,
birthday, anniversary, vcard, etag, created_at, updated_at
birthday, anniversary, etag, created_at, updated_at
FROM carddav.contacts
WHERE address_book_id = $1
WHERE address_book_id = $1
AND (
full_name ILIKE $2
OR first_name ILIKE $2
@@ -435,9 +494,9 @@ impl ContactRepository for ContactPgRepository {
.await
.map_err(|e| DomainError::database_error(format!("Failed to search contacts: {}", e)))?;
let mut contacts = Vec::new();
let mut contacts = Vec::with_capacity(rows.len());
for row in &rows {
contacts.push(Self::row_to_contact(row)?);
contacts.push(Self::row_to_contact_lite(row)?);
}
Ok(contacts)
}
@@ -3,14 +3,16 @@
//! The repo deals only with the `storage.drives` table itself. Drive
//! membership lives in `storage.role_grants` (`resource_type='drive'`)
//! and is queried through the engine's existing grant paths;
//! `list_for_subjects` below resolves `role_grants` → `storage.drives`
//! `list_readable_by` below resolves `role_grants` → `storage.drives`
//! via a single join.
//!
//! See `migrations/20260802000000_drives_schema_additive.sql` for the
//! schema and `docs/plan/drive.md` §3 / §15 for the locked design.
use std::sync::Arc;
use std::time::Duration;
use moka::future::Cache;
use sqlx::{PgPool, Row, types::Uuid};
use crate::domain::entities::drive::{Drive, DriveKind};
@@ -18,13 +20,108 @@ use crate::domain::repositories::drive_repository::{
DriveRepository, DriveRepositoryError, DriveWithRootName,
};
/// Decode a `d.policies` JSONB column straight into `DrivePolicies` via
/// `sqlx::types::Json<T>` — a single `serde_json::from_slice` over the raw JSONB
/// bytes — instead of fetching a throwaway `serde_json::Value` DOM and walking it
/// once with `DrivePolicies::from_value`. The §J1 pattern (ROUND23) applied to
/// the drive-policy path §J2 left behind (benches/ROUND26.md §P1). The lenient
/// `unwrap_or_default` fallback (a malformed bag decodes to all-false rather than
/// erroring the read) is preserved exactly.
fn policies_from_row(row: &sqlx::postgres::PgRow) -> crate::domain::entities::drive::DrivePolicies {
row.try_get::<sqlx::types::Json<crate::domain::entities::drive::DrivePolicies>, _>("policies")
.map(|j| j.0)
.unwrap_or_default()
}
/// `default_drive_cache` TTL. The default-drive → root-folder binding is
/// nearly immutable (changes only on provisioning / drive deletion /
/// policy edits — all of which invalidate explicitly below), yet it is
/// re-resolved on EVERY NextCloud request (basic-auth chroot), every
/// native `/webdav` request (Mode-B scope resolution) and every WOPI
/// call. 30 s mirrors `drive_role_cache` in `pg_acl_engine.rs`. Root-
/// folder renames — which don't pass through this repository directly
/// — invalidate via the `DriveRepository::invalidate_default_drive_all`
/// trait hook called from `folder_service::rename_folder_with_perms`
/// when `parent_id IS NULL`. Measured in `benches/CHROOT-CACHE.md`.
const DEFAULT_DRIVE_CACHE_TTL: Duration = Duration::from_secs(30);
/// One entry per active user; entries are small (a `Drive` + a name).
const DEFAULT_DRIVE_CACHE_CAPACITY: u64 = 100_000;
pub struct DrivePgRepository {
pool: Arc<PgPool>,
/// user_id → default drive (+ root folder name). See
/// [`DEFAULT_DRIVE_CACHE_TTL`]. Only `Ok` results are cached, so the
/// provisioning idempotency check (`NotFound` → create) always sees
/// the live table.
default_drive_cache: Cache<Uuid, DriveWithRootName>,
/// caller_id → every drive the caller can read (the full
/// role_grants ⋈ drives ⋈ folders join of [`list_readable_by`],
/// including the transitive-group expansion).
///
/// Re-resolved before this cache existed on EVERY native `/webdav`
/// request that names an explicit drive selector (all verbs; MOVE
/// and COPY twice), plus per-request in search, trash listing and
/// the `GET /api/drives` picker — the heaviest per-request query
/// left on the DAV path after CHROOT-CACHE. Concurrent misses are
/// coalesced (`try_get_with`), errors are never cached.
///
/// Freshness: every membership/lifecycle mutation that flows
/// through this repository or `DriveManagementService` invalidates
/// explicitly (per-user when the subject is a User, whole cache for
/// Group subjects, whose transitive membership is not resolvable
/// here). Root-folder renames — which update `drive.name` because it
/// reads through `folders.name` of the root row — also invalidate,
/// via the trait's `invalidate_readable_all` hook called from
/// `folder_service::rename_folder_with_perms` when
/// `parent_id IS NULL`. That path was missed by the perf commit
/// that introduced this cache (`12dc648c`) and surfaced by
/// `drives_membership.hurl` Step 23; the trait hook closes it
/// without folder_service knowing about the concrete moka cache.
///
/// Residual staleness — a grant written by a path that can't reach
/// this cache — is bounded by the same 30 s TTL the sibling caches
/// accept; actual permission enforcement is unaffected (the ACL
/// engine re-checks per operation with its own invalidation).
readable_cache: Cache<Uuid, Arc<Vec<DriveWithRootName>>>,
}
impl DrivePgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
Self {
pool,
default_drive_cache: Cache::builder()
.max_capacity(DEFAULT_DRIVE_CACHE_CAPACITY)
.time_to_live(DEFAULT_DRIVE_CACHE_TTL)
.build(),
readable_cache: Cache::builder()
.max_capacity(DEFAULT_DRIVE_CACHE_CAPACITY)
.time_to_live(DEFAULT_DRIVE_CACHE_TTL)
.build(),
}
}
/// Drop the cached readable-drive list for one user (their grant set
/// changed: membership write, personal-drive provisioning, …).
pub async fn invalidate_readable_for_user(&self, user_id: Uuid) {
self.readable_cache.invalidate(&user_id).await;
}
/// Drop every cached readable-drive list. Used when the affected
/// user set is unknown at this layer: group-subject grants, drive
/// deletion, policy edits. All are admin-rare; repopulation costs
/// one join per active caller.
pub fn invalidate_readable_all(&self) {
self.readable_cache.invalidate_all();
}
/// Drop every cached `default_drive_cache` entry. Exposed as a
/// `pub` sibling of the whole-cache invalidators above so trait
/// callers holding a `dyn DriveRepository` can trigger the same
/// cleanup path (e.g. `folder_service` on root-folder rename —
/// see `impl DriveRepository` below).
pub fn invalidate_default_drive_all(&self) {
self.default_drive_cache.invalidate_all();
}
fn map_sqlx_err(context: &'static str, e: sqlx::Error) -> DriveRepositoryError {
@@ -75,7 +172,7 @@ impl DrivePgRepository {
/// is declared owner→viewer (strongest→weakest), so `MIN` picks the
/// strongest of the caller's grants on the drive (direct +
/// group-mediated collapsed by GROUP BY). Used only by
/// `list_for_subjects`.
/// `list_readable_by`.
fn row_to_drive_with_name_and_role(
row: &sqlx::postgres::PgRow,
) -> Result<DriveWithRootName, DriveRepositoryError> {
@@ -85,10 +182,83 @@ impl DrivePgRepository {
dwr.caller_role = role_str.as_deref().and_then(Role::parse);
Ok(dwr)
}
/// The uncached grants join behind [`DriveRepository::list_readable_by`].
///
/// Joining role_grants → drives → folders returns every drive the
/// caller can read, paired with its display name. Group
/// memberships (direct + transitive) are expanded inline by
/// `storage.caller_group_ids($caller)` — no Rust-side ceremony.
///
/// ORDER BY puts default drives first (so the picker UI doesn't
/// need a follow-up sort), then alphabetical by name. GROUP BY
/// collapses duplicate role_grants on the same drive (direct +
/// group-mediated) and sidesteps PostgreSQL's "ORDER BY
/// expression must appear in select list" rule that SELECT
/// DISTINCT imposes.
/// `MIN(g.role)` picks the caller's strongest role on each drive:
/// `storage.grant_role` is declared `owner → viewer` (strongest →
/// weakest), so MIN returns the strongest. Cast `::text` matches
/// the codebase convention for reading enum columns into Rust
/// (see `pg_acl_engine.rs`); `Role::parse` handles the trip back.
async fn query_readable_by(
&self,
caller_id: Uuid,
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
let rows = sqlx::query(
r#"
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at,
f.name AS root_folder_name,
MIN(g.role)::text AS caller_role
FROM storage.drives d
JOIN storage.folders f ON f.id = d.root_folder_id
JOIN storage.role_grants g
ON g.resource_type = 'drive'
AND g.resource_id = d.id
WHERE (
(g.subject_type = 'user' AND g.subject_id = $1)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($1)))
)
AND (g.expires_at IS NULL OR g.expires_at > NOW())
GROUP BY d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at, f.name
ORDER BY (d.default_for_user IS NULL) ASC,
LOWER(f.name) ASC
"#,
)
.bind(caller_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("list_readable_by", e))?;
rows.iter()
.map(Self::row_to_drive_with_name_and_role)
.collect()
}
}
#[async_trait::async_trait]
impl DriveRepository for DrivePgRepository {
async fn invalidate_readable_for_user(&self, user_id: Uuid) {
// Delegate to the inherent method — the trait forwarding lets
// callers holding a `dyn DriveRepository` (e.g. `folder_service`
// on a root-folder rename) trigger invalidation without knowing
// about the concrete cache.
DrivePgRepository::invalidate_readable_for_user(self, user_id).await;
}
fn invalidate_readable_all(&self) {
DrivePgRepository::invalidate_readable_all(self);
}
fn invalidate_default_drive_all(&self) {
DrivePgRepository::invalidate_default_drive_all(self);
}
async fn create_personal_drive_atomic(
&self,
owner_id: Uuid,
@@ -116,11 +286,22 @@ impl DriveRepository for DrivePgRepository {
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.begin", e))?;
// 1. Drive row (root_folder_id NULL — populated in step 3).
//
// Default personal drives are seeded with `include_in_photo_index`
// + `include_in_music_index` = true so the Photos / Music
// predicates (§15) can be a single positive rule keyed off the
// JSONB flag — no per-kind carve-out needed at query time. Any
// future admin PATCH toggling either flag off shows a confirm
// dialog in the UI (unusual action; empties the user's Photos
// timeline / Music library).
let drive_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.drives
(kind, default_for_user, quota_bytes, policies)
VALUES ('personal', $1, $2, '{}'::jsonb)
VALUES (
'personal', $1, $2,
'{"include_in_photo_index": true, "include_in_music_index": true}'::jsonb
)
RETURNING id
"#,
)
@@ -132,11 +313,16 @@ impl DriveRepository for DrivePgRepository {
// 2. Root folder. `parent_id IS NULL` makes it a root in the
// drive; `drive_id` closes the FK in this direction.
//
// Post-D7: `user_id` omitted from the INSERT column list —
// the column is nullable and no longer written to on new
// rows. `created_by` / `updated_by` bind to the owner
// (§14 provenance).
let folder_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
VALUES ('Personal', NULL, $1, $2, $1, $1)
(name, parent_id, drive_id, created_by, updated_by)
VALUES ('Personal', NULL, $2, $1, $1)
RETURNING id
"#,
)
@@ -193,6 +379,12 @@ impl DriveRepository for DrivePgRepository {
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.commit", e))?;
// Drop any cached default-drive resolution for this user (a stale
// NotFound is never cached, but be explicit about the write path).
self.default_drive_cache.invalidate(&owner_id).await;
// The owner gained a drive — their readable list changed too.
self.invalidate_readable_for_user(owner_id).await;
Self::row_to_drive_with_name(&row)
}
@@ -233,14 +425,14 @@ impl DriveRepository for DrivePgRepository {
.await
.map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.drive", e))?;
// 2. Root folder. The folder's `user_id` carries the admin (legacy
// column still NOT NULL during the dual-write window — D7
// drops it once `drive_id` is the canonical ownership signal).
// 2. Root folder. Post-D7: `user_id` omitted — the column is
// nullable and unused on new rows. `created_by` / `updated_by`
// bind to `granted_by` (§14 provenance).
let folder_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
VALUES ($1, NULL, $2, $3, $2, $2)
(name, parent_id, drive_id, created_by, updated_by)
VALUES ($1, NULL, $3, $2, $2)
RETURNING id
"#,
)
@@ -300,9 +492,107 @@ impl DriveRepository for DrivePgRepository {
.await
.map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.commit", e))?;
// The owner grant written above changes the grantee's readable
// list. User subjects invalidate precisely; Group subjects fall
// back to a full clear (transitive members unknown here).
match owner_subject {
crate::domain::services::authorization::Subject::User(uid) => {
self.invalidate_readable_for_user(uid).await;
}
_ => self.invalidate_readable_all(),
}
Self::row_to_drive_with_name(&row)
}
async fn is_empty(&self, drive_id: Uuid) -> Result<bool, DriveRepositoryError> {
// A "live" non-root folder = any folder with `parent_id IS NOT
// NULL` (root is the only NULL-parent row per drive) and not in
// the trash. Trashed items don't count — owners can delete a
// drive even when its trash bin still holds rows; the trash GC
// will clean those up after the standard retention window.
//
// EXISTS instead of COUNT(*): only emptiness is tested, so the
// planner stops at the first matching row — a populated drive
// answers from one index probe instead of aggregating every
// live file + folder it contains.
let occupied: (bool,) = sqlx::query_as(
r#"
SELECT EXISTS(
SELECT 1 FROM storage.folders
WHERE drive_id = $1 AND parent_id IS NOT NULL AND NOT is_trashed)
OR EXISTS(
SELECT 1 FROM storage.files
WHERE drive_id = $1 AND NOT is_trashed)
"#,
)
.bind(drive_id)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("is_empty", e))?;
Ok(!occupied.0)
}
async fn delete_atomic(&self, drive_id: Uuid) -> Result<(), DriveRepositoryError> {
// Three-statement transaction:
// 1. Drop every role_grants row scoped to the drive itself
// (folder/file grants under it are gone by step 3 cascade).
// 2. Look up the root folder id (we'll need it to delete the
// folder row AFTER the drive row releases its FK).
// 3. Delete the drive — release the drive→root FK first.
// 4. Delete the root folder (drive_id FK on folders cascades
// from this row going away; only the root remains because
// is_empty was true).
//
// `drive_id` is bound once per statement; failure at any step
// rolls back. Caller (`DriveManagementService::delete_drive`)
// is responsible for the `is_empty` precheck.
let mut tx = self
.pool
.begin()
.await
.map_err(|e| Self::map_sqlx_err("delete_atomic.begin", e))?;
sqlx::query(
"DELETE FROM storage.role_grants \
WHERE resource_type = 'drive' AND resource_id = $1",
)
.bind(drive_id)
.execute(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("delete_atomic.grants", e))?;
let root: (Uuid,) =
sqlx::query_as("SELECT root_folder_id FROM storage.drives WHERE id = $1")
.bind(drive_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("delete_atomic.lookup_root", e))?
.ok_or_else(|| DriveRepositoryError::NotFound(drive_id.to_string()))?;
sqlx::query("DELETE FROM storage.drives WHERE id = $1")
.bind(drive_id)
.execute(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("delete_atomic.drive", e))?;
sqlx::query("DELETE FROM storage.folders WHERE id = $1")
.bind(root.0)
.execute(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("delete_atomic.root", e))?;
tx.commit()
.await
.map_err(|e| Self::map_sqlx_err("delete_atomic.commit", e))?;
// We only have the drive id here; the caches are keyed by user.
// Deletion is rare — clearing them whole is the simple,
// always-correct move (repopulates at one query per active user).
self.default_drive_cache.invalidate_all();
self.invalidate_readable_all();
Ok(())
}
async fn get_by_id(&self, id: Uuid) -> Result<DriveWithRootName, DriveRepositoryError> {
let row = sqlx::query(
r#"
@@ -354,6 +644,10 @@ impl DriveRepository for DrivePgRepository {
&self,
user_id: Uuid,
) -> Result<DriveWithRootName, DriveRepositoryError> {
if let Some(cached) = self.default_drive_cache.get(&user_id).await {
return Ok(cached);
}
let row = sqlx::query(
r#"
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
@@ -371,65 +665,30 @@ impl DriveRepository for DrivePgRepository {
.map_err(|e| Self::map_sqlx_err("find_default_for_user", e))?
.ok_or_else(|| DriveRepositoryError::NotFound(user_id.to_string()))?;
Self::row_to_drive_with_name(&row)
let dwr = Self::row_to_drive_with_name(&row)?;
self.default_drive_cache.insert(user_id, dwr.clone()).await;
Ok(dwr)
}
async fn list_for_subjects(
async fn list_readable_by(
&self,
subject_types: &[&str],
subject_ids: &[Uuid],
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
// Joining role_grants → drives → folders returns every drive the
// expanded subject set can read, paired with its display name.
// ORDER BY puts default drives first (so the picker UI doesn't
// need a follow-up sort), then alphabetical by name. GROUP BY
// collapses duplicate role_grants on the same drive (direct +
// group-mediated) and sidesteps PostgreSQL's "ORDER BY
// expression must appear in select list" rule that SELECT
// DISTINCT imposes.
// `MIN(g.role)` picks the caller's strongest role on each drive:
// `storage.grant_role` is declared `owner → viewer` (strongest →
// weakest), so MIN returns the strongest. Cast `::text` matches
// the codebase convention for reading enum columns into Rust
// (see `pg_acl_engine.rs`); `Role::parse` handles the trip back.
// Collapses direct + group-mediated grants on the same drive
// into one row alongside the existing GROUP BY.
let rows = sqlx::query(
r#"
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at,
f.name AS root_folder_name,
MIN(g.role)::text AS caller_role
FROM storage.drives d
JOIN storage.folders f ON f.id = d.root_folder_id
JOIN storage.role_grants g
ON g.resource_type = 'drive'
AND g.resource_id = d.id
WHERE g.subject_type = ANY($1)
AND g.subject_id = ANY($2)
AND (g.expires_at IS NULL OR g.expires_at > NOW())
GROUP BY d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at, f.name
ORDER BY (d.default_for_user IS NULL) ASC,
LOWER(f.name) ASC
"#,
)
.bind(
subject_types
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>(),
)
.bind(subject_ids)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?;
rows.iter()
.map(Self::row_to_drive_with_name_and_role)
.collect()
caller_id: Uuid,
) -> Result<Arc<Vec<DriveWithRootName>>, DriveRepositoryError> {
// Serve from the per-user cache; concurrent misses for the same
// caller are coalesced into one join (`try_get_with`), and errors
// are never cached. See the `readable_cache` field docs for the
// freshness/invalidation contract. The Arc is handed to callers
// directly — a warm hit is a refcount bump, not a deep clone of
// every row's Strings.
self.readable_cache
.try_get_with(caller_id, async move {
self.query_readable_by(caller_id).await.map(Arc::new)
})
.await
.map_err(|e: Arc<DriveRepositoryError>| {
Arc::try_unwrap(e)
.unwrap_or_else(|shared| DriveRepositoryError::StorageError(shared.to_string()))
})
}
async fn list_all(&self) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
@@ -455,4 +714,166 @@ impl DriveRepository for DrivePgRepository {
rows.iter().map(Self::row_to_drive_with_name).collect()
}
async fn get_policies_for_file(
&self,
file_id: Uuid,
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError> {
let row = sqlx::query(
"SELECT d.policies \
FROM storage.drives d \
JOIN storage.files f ON f.drive_id = d.id \
WHERE f.id = $1",
)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("get_policies_for_file", e))?
.ok_or_else(|| DriveRepositoryError::NotFound(file_id.to_string()))?;
Ok(policies_from_row(&row))
}
async fn get_policies_for_folder(
&self,
folder_id: Uuid,
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError> {
let row = sqlx::query(
"SELECT d.policies \
FROM storage.drives d \
JOIN storage.folders fo ON fo.drive_id = d.id \
WHERE fo.id = $1",
)
.bind(folder_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("get_policies_for_folder", e))?
.ok_or_else(|| DriveRepositoryError::NotFound(folder_id.to_string()))?;
Ok(policies_from_row(&row))
}
async fn get_drive_id_and_policies_for_file(
&self,
file_id: Uuid,
) -> Result<(Uuid, crate::domain::entities::drive::DrivePolicies), DriveRepositoryError> {
let row = sqlx::query(
"SELECT d.id, d.policies \
FROM storage.drives d \
JOIN storage.files f ON f.drive_id = d.id \
WHERE f.id = $1",
)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("get_drive_id_and_policies_for_file", e))?
.ok_or_else(|| DriveRepositoryError::NotFound(file_id.to_string()))?;
let drive_id: Uuid = row
.try_get("id")
.map_err(|e| Self::map_sqlx_err("get_drive_id_and_policies_for_file", e))?;
Ok((drive_id, policies_from_row(&row)))
}
async fn get_drive_id_and_policies_for_folder(
&self,
folder_id: Uuid,
) -> Result<(Uuid, crate::domain::entities::drive::DrivePolicies), DriveRepositoryError> {
let row = sqlx::query(
"SELECT d.id, d.policies \
FROM storage.drives d \
JOIN storage.folders fo ON fo.drive_id = d.id \
WHERE fo.id = $1",
)
.bind(folder_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("get_drive_id_and_policies_for_folder", e))?
.ok_or_else(|| DriveRepositoryError::NotFound(folder_id.to_string()))?;
let drive_id: Uuid = row
.try_get("id")
.map_err(|e| Self::map_sqlx_err("get_drive_id_and_policies_for_folder", e))?;
Ok((drive_id, policies_from_row(&row)))
}
async fn drive_id_for_folder(&self, folder_id: Uuid) -> Result<Uuid, DriveRepositoryError> {
let row: Option<(Uuid,)> =
sqlx::query_as("SELECT drive_id FROM storage.folders WHERE id = $1")
.bind(folder_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("drive_id_for_folder", e))?;
row.map(|(id,)| id)
.ok_or_else(|| DriveRepositoryError::NotFound(folder_id.to_string()))
}
async fn update_policies(
&self,
drive_id: Uuid,
partial: &serde_json::Value,
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError> {
// JSONB-level merge (`||`) keeps unknown keys already on disk —
// the column remains the canonical bag (see
// `DrivePolicies::from_value` — typed read is lenient, untyped
// write is preserving). The caller passes a raw `Value` with
// ONLY the keys it wants to change (never a full `DrivePolicies`
// round-trip, which would serialise all-false defaults into the
// merge and clobber other flags). RETURNING surfaces the
// post-merge bag so the audit log shows what the row actually
// carries afterwards.
let row: Option<(serde_json::Value,)> = sqlx::query_as(
"UPDATE storage.drives \
SET policies = policies || $2, \
updated_at = now() \
WHERE id = $1 \
RETURNING policies",
)
.bind(drive_id)
.bind(partial)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("update_policies", e))?;
let raw = row
.ok_or_else(|| DriveRepositoryError::NotFound(drive_id.to_string()))?
.0;
// Policy edits must not serve a stale `policies` bag from the
// user-keyed caches (we only have the drive id) — clear both;
// policy edits are admin-rare.
self.default_drive_cache.invalidate_all();
self.invalidate_readable_all();
Ok(crate::domain::entities::drive::DrivePolicies::from_value(
&raw,
))
}
async fn update_quota(
&self,
drive_id: Uuid,
quota_bytes: Option<i64>,
) -> Result<Option<i64>, DriveRepositoryError> {
// RETURNING gives the persisted value so the caller (service
// layer) has authoritative data for the audit line + API
// response without a second read.
let row: Option<(Option<i64>,)> = sqlx::query_as(
"UPDATE storage.drives \
SET quota_bytes = $2, \
updated_at = now() \
WHERE id = $1 \
RETURNING quota_bytes",
)
.bind(drive_id)
.bind(quota_bytes)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("update_quota", e))?;
let persisted = row
.ok_or_else(|| DriveRepositoryError::NotFound(drive_id.to_string()))?
.0;
// Same invalidation strategy as `update_policies` — both
// user-keyed caches (`default_drive_cache`, the readable-drive
// list) carry the whole DriveWithRootName / DriveDto rows and
// would serve a stale quota otherwise. Admin-rare mutation,
// so blowing the whole cache is fine (no per-user pinpointing
// needed).
self.default_drive_cache.invalidate_all();
self.invalidate_readable_all();
Ok(persisted)
}
}
@@ -13,7 +13,7 @@ use uuid::Uuid;
use crate::application::ports::face_ports::FaceRepository;
use crate::common::errors::DomainError;
use crate::domain::entities::face::{BoundingBox, Face, Person};
use crate::domain::entities::face::{BoundingBox, Face, FaceBox, Person};
/// Row shape for `faces.faces` selects (avoids `clippy::type_complexity`).
type FaceRow = (
@@ -115,40 +115,98 @@ impl FaceRepository for FacePgRepository {
if faces.is_empty() {
return Ok(());
}
let mut tx = self.pool.begin().await.map_err(|e| db_err("begin", e))?;
// One multi-row INSERT over parallel UNNEST arrays instead of one
// round-trip per face — a group photo yields many faces per indexed
// image. The `bbox` float4[] can't ride an array-of-arrays through
// unnest (PG flattens), so its 4 components travel as 4 parallel
// arrays and are reassembled server-side. A single statement is
// atomic on its own; the per-row transaction wrapper is gone.
let n = faces.len();
let mut ids = Vec::with_capacity(n);
let mut file_ids = Vec::with_capacity(n);
let mut user_ids = Vec::with_capacity(n);
let mut person_ids: Vec<Option<Uuid>> = Vec::with_capacity(n);
let (mut bx, mut by, mut bw, mut bh) = (
Vec::with_capacity(n),
Vec::with_capacity(n),
Vec::with_capacity(n),
Vec::with_capacity(n),
);
let mut det_scores = Vec::with_capacity(n);
let mut qualities: Vec<Option<f32>> = Vec::with_capacity(n);
let mut embeddings = Vec::with_capacity(n);
let mut blob_hashes: Vec<Option<&str>> = Vec::with_capacity(n);
for f in faces {
sqlx::query(
r#"
INSERT INTO faces.faces
(id, file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#,
)
.bind(f.id)
.bind(f.file_id)
.bind(f.user_id)
.bind(f.person_id)
.bind(f.bbox.to_array())
.bind(f.det_score)
.bind(f.quality)
.bind(embedding_to_bytes(&f.embedding))
.bind(f.blob_hash.as_deref())
.execute(&mut *tx)
.await
.map_err(|e| db_err("save_faces", e))?;
ids.push(f.id);
file_ids.push(f.file_id);
user_ids.push(f.user_id);
person_ids.push(f.person_id);
bx.push(f.bbox.x);
by.push(f.bbox.y);
bw.push(f.bbox.w);
bh.push(f.bbox.h);
det_scores.push(f.det_score);
qualities.push(f.quality);
embeddings.push(embedding_to_bytes(&f.embedding));
blob_hashes.push(f.blob_hash.as_deref());
}
tx.commit().await.map_err(|e| db_err("commit", e))?;
sqlx::query(
r#"
INSERT INTO faces.faces
(id, file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash)
SELECT t.id, t.file_id, t.user_id, t.person_id,
ARRAY[t.bx, t.by, t.bw, t.bh]::real[],
t.det_score, t.quality, t.embedding, t.blob_hash
FROM unnest($1::uuid[], $2::uuid[], $3::uuid[], $4::uuid[],
$5::real[], $6::real[], $7::real[], $8::real[],
$9::real[], $10::real[], $11::bytea[], $12::text[])
AS t(id, file_id, user_id, person_id,
bx, by, bw, bh, det_score, quality, embedding, blob_hash)
"#,
)
.bind(&ids)
.bind(&file_ids)
.bind(&user_ids)
.bind(&person_ids)
.bind(&bx)
.bind(&by)
.bind(&bw)
.bind(&bh)
.bind(&det_scores)
.bind(&qualities)
.bind(&embeddings)
.bind(&blob_hashes)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("save_faces", e))?;
Ok(())
}
async fn faces_for_file(&self, file_id: Uuid) -> Result<Vec<Face>, DomainError> {
let sql = format!("SELECT {FACE_COLS} FROM faces.faces WHERE file_id = $1");
let rows: Vec<FaceRow> = sqlx::query_as(&sql)
.bind(file_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("faces_for_file", e))?;
Ok(rows.into_iter().map(row_to_face).collect())
async fn face_boxes_for_file(
&self,
file_id: Uuid,
user_id: Uuid,
) -> Result<Vec<FaceBox>, DomainError> {
// Narrow projection: the lightbox needs only (id, person_id, bbox), so
// the 2 KiB embedding BYTEA + 6 unused columns stay in the DB and the
// caller filter runs in SQL (idx_faces_file drives it) rather than in
// Rust after a full-row fetch. See benches/ROUND14.md §Q1.
let rows: Vec<(Uuid, Option<Uuid>, Vec<f32>)> = sqlx::query_as(
"SELECT id, person_id, bbox FROM faces.faces WHERE file_id = $1 AND user_id = $2",
)
.bind(file_id)
.bind(user_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("face_boxes_for_file", e))?;
Ok(rows
.into_iter()
.map(|(id, person_id, bbox)| FaceBox {
id,
person_id,
bbox: BoundingBox::from_slice(&bbox),
})
.collect())
}
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError> {
@@ -186,6 +244,60 @@ impl FaceRepository for FacePgRepository {
Ok(rows.into_iter().map(row_to_face).collect())
}
async fn person_face_stats(&self, user_id: Uuid) -> Result<Vec<(Uuid, i64)>, DomainError> {
// Grouped COUNT — the People tab only needs per-person counts, so
// this replaces a full faces_for_user scan that shipped a 2 KiB
// embedding BYTEA per row (benches/PEOPLE-LIST.md).
let rows: Vec<(Uuid, i64)> = sqlx::query_as(
"SELECT person_id, COUNT(*) FROM faces.faces
WHERE user_id = $1 AND person_id IS NOT NULL
GROUP BY person_id",
)
.bind(user_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("person_face_stats", e))?;
Ok(rows)
}
async fn file_ids_for_faces(
&self,
user_id: Uuid,
face_ids: &[Uuid],
) -> Result<std::collections::HashMap<Uuid, Uuid>, DomainError> {
if face_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
let rows: Vec<(Uuid, Uuid)> = sqlx::query_as(
"SELECT id, file_id FROM faces.faces WHERE user_id = $1 AND id = ANY($2)",
)
.bind(user_id)
.bind(face_ids)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("file_ids_for_faces", e))?;
Ok(rows.into_iter().collect())
}
async fn reassign_person_faces(
&self,
user_id: Uuid,
from: Uuid,
into: Uuid,
) -> Result<u64, DomainError> {
let result = sqlx::query(
"UPDATE faces.faces SET person_id = $3
WHERE user_id = $1 AND person_id = $2",
)
.bind(user_id)
.bind(from)
.bind(into)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("reassign_person_faces", e))?;
Ok(result.rows_affected())
}
async fn assign_person(
&self,
face_id: Uuid,
@@ -200,6 +312,28 @@ impl FaceRepository for FacePgRepository {
Ok(())
}
async fn assign_person_batch(
&self,
assignments: &[(Uuid, Option<Uuid>)],
) -> Result<(), DomainError> {
if assignments.is_empty() {
return Ok(());
}
let (face_ids, person_ids): (Vec<Uuid>, Vec<Option<Uuid>>) =
assignments.iter().cloned().unzip();
sqlx::query(
"UPDATE faces.faces f SET person_id = u.pid
FROM (SELECT unnest($1::uuid[]) AS fid, unnest($2::uuid[]) AS pid) u
WHERE f.id = u.fid",
)
.bind(&face_ids)
.bind(&person_ids)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("assign_person_batch", e))?;
Ok(())
}
async fn create_person(&self, person: &Person) -> Result<(), DomainError> {
sqlx::query(
r#"
@@ -24,25 +24,27 @@ impl FavoritesPgRepository {
impl FavoritesRepositoryPort for FavoritesPgRepository {
async fn get_favorites(&self, user_id: Uuid) -> Result<Vec<FavoriteItemDto>> {
// `id`/`user_id`/`parent_id` decode as binary UUIDs (16 B on the wire,
// no server-side `::TEXT` cast) and render app-side — the ROUND6 §10
// pattern the two legacy listing methods here never picked up.
let rows = sqlx::query(
r#"
SELECT
uf.id::TEXT AS "id",
uf.user_id::TEXT AS "user_id",
uf.id AS "id",
uf.user_id AS "user_id",
uf.item_id AS "item_id",
uf.item_type AS "item_type",
uf.created_at AS "created_at",
COALESCE(f.name, fld.name) AS "item_name",
f.size AS "item_size",
f.mime_type AS "item_mime_type",
COALESCE(f.folder_id::TEXT, fld.parent_id::TEXT) AS "parent_id",
COALESCE(f.folder_id, fld.parent_id) AS "parent_id",
COALESCE(f.updated_at, fld.updated_at) AS "modified_at",
CASE
WHEN uf.item_type = 'folder' THEN fld.path
WHEN uf.item_type = 'file' THEN COALESCE(pfld.path || '/' || f.name, f.name)
ELSE NULL
END AS "item_path",
COALESCE(f.user_id, fld.user_id)::TEXT AS "owner_id"
END AS "item_path"
FROM auth.user_favorites uf
LEFT JOIN storage.files f ON uf.item_type = 'file'
AND f.id = uf.item_id::UUID
@@ -71,18 +73,21 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
.iter()
.map(|row| {
FavoriteItemDto {
id: row.get("id"),
user_id: row.get("user_id"),
id: row.get::<i32, _>("id").to_string(),
user_id: row.get::<Uuid, _>("user_id").to_string(),
item_id: row.get("item_id"),
item_type: row.get("item_type"),
created_at: row.get("created_at"),
item_name: row.try_get("item_name").ok(),
item_size: row.try_get("item_size").ok(),
item_mime_type: row.try_get("item_mime_type").ok(),
parent_id: row.try_get("parent_id").ok(),
parent_id: row
.try_get::<Option<Uuid>, _>("parent_id")
.ok()
.flatten()
.map(|u| u.to_string()),
modified_at: row.try_get("modified_at").ok(),
item_path: row.try_get("item_path").ok(),
owner_id: row.try_get("owner_id").ok(),
// Temporary defaults; with_display_fields() computes the real values
icon_class: String::new(),
icon_special_class: String::new(),
@@ -261,8 +266,9 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
return Ok(HashSet::new());
}
// Collect just the IDs for the IN clause
let ids: Vec<String> = item_ids.iter().map(|(id, _)| id.to_string()).collect();
// Collect just the IDs for the IN clause — sqlx binds `&[&str]` as
// text[], so no per-id String is needed.
let ids: Vec<&str> = item_ids.iter().map(|(id, _)| *id).collect();
let rows = sqlx::query(
"SELECT item_id FROM auth.user_favorites WHERE user_id = $1 AND item_id = ANY($2)",
@@ -309,9 +315,19 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
-1::bigint AS size,
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
fld.drive_id AS drive_id,
NULL::text AS blob_hash,
(fld.user_id = $1::uuid) AS is_owner,
fld.created_by AS created_by,
fld.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = fld.drive_id
AND g.role = 'owner'
AND g.subject_type = 'user'
AND g.subject_id = $1::uuid
AND (g.expires_at IS NULL OR g.expires_at > NOW())
) AS is_owner,
uf.created_at AS favorited_at,
fld.path::text AS resource_path,
LOWER(fld.name) AS sort_str,
@@ -332,9 +348,19 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
f.size::bigint,
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.drive_id AS drive_id,
f.blob_hash,
(f.user_id = $1::uuid) AS is_owner,
f.created_by AS created_by,
f.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND g.role = 'owner'
AND g.subject_type = 'user'
AND g.subject_id = $1::uuid
AND (g.expires_at IS NULL OR g.expires_at > NOW())
) AS is_owner,
uf.created_at AS favorited_at,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
LOWER(f.name) AS sort_str,
@@ -487,7 +513,8 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
};
let user_join = if need_user_join {
"LEFT JOIN auth.users u ON u.id = r.owner_id"
// Post-D7: `owner_id` retired; join by `created_by`.
"LEFT JOIN auth.users u ON u.id = r.created_by"
} else {
""
};
@@ -504,7 +531,8 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.owner_id, r.is_owner, r.favorited_at, r.resource_path,
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.is_owner, r.favorited_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r
{user_join}
@@ -575,8 +603,10 @@ LIMIT $6"
size,
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
drive_id: row.get("drive_id"),
blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false),
favorited_at: row.get("favorited_at"),
path: row.try_get("resource_path").ok(),
File diff suppressed because it is too large Load Diff
@@ -17,7 +17,6 @@ use crate::application::dtos::display_helpers::category_order_for;
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
use crate::common::errors::DomainError;
use crate::domain::entities::file::File;
use crate::domain::services::path_service::StoragePath;
use super::transaction_utils::retry_on_deadlock;
use crate::infrastructure::services::dedup_service::DedupService;
@@ -61,14 +60,6 @@ impl FileBlobWriteRepository {
}
}
/// Build a `StoragePath` from the materialized folder path + file name.
fn make_file_path(folder_path: Option<&str>, file_name: &str) -> StoragePath {
match folder_path {
Some(fp) if !fp.is_empty() => StoragePath::from_string(&format!("{fp}/{file_name}")),
_ => StoragePath::from_string(file_name),
}
}
/// Look up the materialized folder path. O(1) — no recursive CTE.
async fn lookup_folder_path(
&self,
@@ -104,22 +95,19 @@ impl FileBlobWriteRepository {
mime_type: String,
created_at: i64,
modified_at: i64,
owner_id: Option<Uuid>,
blob_hash: String,
created_by: Option<Uuid>,
updated_by: Option<Uuid>,
) -> Result<File, DomainError> {
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
File::with_timestamps_blob_hash_and_provenance(
File::from_materialized_row(
id,
name,
storage_path,
folder_path.as_deref(),
size as u64,
mime_type,
folder_id,
created_at as u64,
modified_at as u64,
owner_id,
blob_hash,
created_by,
updated_by,
@@ -127,30 +115,24 @@ impl FileBlobWriteRepository {
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
}
/// Derive `(user_id, drive_id)` from the parent folder. Both are
/// needed during the D0 dual-write window: `user_id` for the legacy
/// column (dropped in D7) and `drive_id` for the new owning-drive
/// reference.
async fn resolve_owner_and_drive(
&self,
folder_id: Option<&str>,
) -> Result<(Uuid, Uuid), DomainError> {
/// Derive `drive_id` from the parent folder. Post-D7: only the
/// drive is needed — the legacy `user_id` column is no longer
/// written on new rows.
async fn resolve_parent_drive(&self, folder_id: Option<&str>) -> Result<Uuid, DomainError> {
match folder_id {
Some(fid) => {
let row: Option<(Uuid, Uuid)> = sqlx::query_as::<_, (Uuid, Uuid)>(
"SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid",
)
.bind(fid)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("FileBlobWrite", format!("parent lookup: {e}"))
})?;
row.ok_or_else(|| DomainError::not_found("Folder", fid))
}
Some(fid) => sqlx::query_scalar::<_, Uuid>(
"SELECT drive_id FROM storage.folders WHERE id = $1::uuid",
)
.bind(fid)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("FileBlobWrite", format!("parent lookup: {e}"))
})?
.ok_or_else(|| DomainError::not_found("Folder", fid)),
None => Err(DomainError::internal_error(
"FileBlobWrite",
"folder_id is required to determine file owner",
"folder_id is required to determine the target drive",
)),
}
}
@@ -171,6 +153,15 @@ impl FileBlobWriteRepository {
/// row — not the row's owner. D2 shared drives let non-owners
/// overwrite content; the previous `updated_by = f.user_id` would
/// have silently recorded the wrong principal.
/// `expected_hash`, when `Some`, turns this into a real
/// compare-and-swap: the SET clause only takes effect if the row's
/// `blob_hash` still matches at the moment the `FOR UPDATE` lock is
/// held (same statement, same transaction — no gap a concurrent
/// writer can land in). A mismatch leaves the row untouched and is
/// reported back via the `matched` flag rather than silently
/// overwriting a sibling PATCH's content. `None` preserves the old
/// blind-overwrite behaviour for PUT/WOPI/chunked-upload finalize,
/// where last-write-wins is the intended HTTP semantics.
async fn swap_blob_hash(
&self,
file_id: &str,
@@ -178,57 +169,83 @@ impl FileBlobWriteRepository {
new_size: i64,
modified_at: Option<i64>,
caller_id: Uuid,
expected_hash: Option<&str>,
) -> Result<(String, i64), DomainError> {
// Atomic CTE: capture old hash then update in one round-trip, no TOCTOU.
// Atomic CTE: capture old hash then conditionally update in one
// round-trip, no TOCTOU. The CASE arms make the SET a no-op when
// `expected_hash` is given and doesn't match `old.blob_hash` —
// the row is still returned (with its unchanged values) so the
// caller can tell "mismatch" apart from "file not found".
// Deadlock victims (40P01) retry before the compensation below runs —
// a successful retry must keep the new blob reference alive.
let (old_hash, updated_at) = match retry_on_deadlock("files.swap_blob_hash", || {
sqlx::query_as::<_, (String, i64)>(
r#"
let (old_hash, updated_at, matched) =
match retry_on_deadlock("files.swap_blob_hash", || {
sqlx::query_as::<_, (String, i64, bool)>(
r#"
WITH old AS (
SELECT id, blob_hash FROM storage.files WHERE id = $3::uuid FOR UPDATE
)
UPDATE storage.files f
SET blob_hash = $1, size = $2,
updated_at = COALESCE(to_timestamp($4), NOW()),
updated_by = $5
SET blob_hash = CASE WHEN $6::text IS NULL OR old.blob_hash = $6
THEN $1 ELSE f.blob_hash END,
size = CASE WHEN $6::text IS NULL OR old.blob_hash = $6
THEN $2 ELSE f.size END,
updated_at = CASE WHEN $6::text IS NULL OR old.blob_hash = $6
THEN COALESCE(to_timestamp($4), NOW()) ELSE f.updated_at END,
updated_by = CASE WHEN $6::text IS NULL OR old.blob_hash = $6
THEN $5 ELSE f.updated_by END
FROM old
WHERE f.id = old.id
RETURNING old.blob_hash, EXTRACT(EPOCH FROM f.updated_at)::bigint
RETURNING old.blob_hash, EXTRACT(EPOCH FROM f.updated_at)::bigint,
($6::text IS NULL OR old.blob_hash = $6)
"#,
)
.bind(new_hash)
.bind(new_size)
.bind(file_id)
.bind(modified_at.map(|t| t as f64))
.bind(caller_id)
.fetch_optional(self.pool.as_ref())
})
.await
{
Ok(Some(row)) => row,
Ok(None) => {
// File not found — compensate: remove the new blob ref
if let Err(e) = self.dedup.remove_reference(new_hash).await {
tracing::error!("Blob orphaned after missing file: {}", e);
)
.bind(new_hash)
.bind(new_size)
.bind(file_id)
.bind(modified_at.map(|t| t as f64))
.bind(caller_id)
.bind(expected_hash)
.fetch_optional(self.pool.as_ref())
})
.await
{
Ok(Some(row)) => row,
Ok(None) => {
// File not found — compensate: remove the new blob ref
if let Err(e) = self.dedup.remove_reference(new_hash).await {
tracing::error!("Blob orphaned after missing file: {}", e);
}
return Err(DomainError::not_found("File", file_id));
}
return Err(DomainError::not_found("File", file_id));
}
Err(e) => {
// UPDATE failed — compensate: remove the new blob ref
if let Err(rollback_err) = self.dedup.remove_reference(new_hash).await {
tracing::error!(
"Blob orphaned after failed UPDATE — hash: {}, err: {}",
&new_hash[..12],
rollback_err
);
Err(e) => {
// UPDATE failed — compensate: remove the new blob ref
if let Err(rollback_err) = self.dedup.remove_reference(new_hash).await {
tracing::error!(
"Blob orphaned after failed UPDATE — hash: {}, err: {}",
&new_hash[..12],
rollback_err
);
}
return Err(DomainError::internal_error(
"FileBlobWrite",
format!("update: {e}"),
));
}
return Err(DomainError::internal_error(
"FileBlobWrite",
format!("update: {e}"),
));
};
if !matched {
// CAS lost the race — some other writer's content is now the
// row's truth. Release the blob we ingested for nothing;
// nothing was written.
if let Err(e) = self.dedup.remove_reference(new_hash).await {
tracing::error!("Blob orphaned after CAS mismatch: {}", e);
}
};
return Err(DomainError::precondition_failed(
"File",
"content was modified concurrently",
));
}
// Decrement old blob ref (only if hash changed, best-effort)
if old_hash != new_hash
@@ -290,20 +307,22 @@ impl FileBlobWriteRepository {
// attempt's error falls through untouched so the 23505 mapping holds
// (a retried INSERT can legitimately lose to a concurrent identical
// upload).
// Post-D7: `user_id` omitted from the INSERT column list and the
// parent CTE. `drive_id` alone is the inherit-from-parent axis;
// provenance is `created_by` / `updated_by` (§14).
let result = retry_on_deadlock("files.insert", || {
sqlx::query_as::<_, (String, Uuid, String, i64, i64, Option<Uuid>, Option<Uuid>)>(
sqlx::query_as::<_, (String, String, i64, i64, Option<Uuid>, Option<Uuid>)>(
r#"
WITH parent AS (
SELECT id, user_id, drive_id, path FROM storage.folders WHERE id = $2::uuid
SELECT id, drive_id, path FROM storage.folders WHERE id = $2::uuid
)
INSERT INTO storage.files
(name, folder_id, user_id, drive_id, blob_hash, size,
(name, folder_id, drive_id, blob_hash, size,
mime_type, category_order, created_by, updated_by)
SELECT $1, parent.id, parent.user_id, parent.drive_id, $3, $4,
SELECT $1, parent.id, parent.drive_id, $3, $4,
$5, $6, $7, $7
FROM parent
RETURNING id::text,
user_id,
(SELECT path FROM parent),
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
@@ -322,71 +341,70 @@ impl FileBlobWriteRepository {
})
.await;
let (id, user_id, folder_path, created_at, updated_at, created_by, updated_by) =
match result {
Ok(Some(row)) => row,
Ok(None) => {
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
tracing::error!(
"Blob orphaned after missing parent folder — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
}
return Err(DomainError::not_found("Folder", fid));
let (id, folder_path, created_at, updated_at, created_by, updated_by) = match result {
Ok(Some(row)) => row,
Ok(None) => {
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
tracing::error!(
"Blob orphaned after missing parent folder — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
}
Err(e) => {
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
tracing::error!(
"Blob orphaned after failed INSERT — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
}
if let sqlx::Error::Database(ref db_err) = e
&& db_err.code().as_deref() == Some("23505")
{
// Idempotent re-upload: if the conflicting file already
// holds IDENTICAL content (same folder, same name, same
// blob hash), treat this as success and return that file
// instead of erroring. Re-uploading a partially-uploaded
// folder then becomes a clean no-op for everything that
// already landed — only the genuinely missing files
// transfer — instead of surfacing hundreds of spurious
// "already exists" failures. The duplicate blob reference
// taken during ingest was just released above, so the
// existing file's own reference is the only one (correct);
// a different-content clash still returns the conflict.
match self.fetch_identical_file(fid, &name, blob_hash).await {
Ok(Some(existing)) => {
tracing::info!(
"♻️ IDEMPOTENT UPLOAD: {} already present, identical content (hash: {})",
name,
&blob_hash[..12]
);
return Ok(existing);
}
Ok(None) => {} // genuine conflict (different content)
Err(lookup_err) => {
tracing::warn!(
"idempotency lookup failed for {} (hash {}): {} — returning conflict",
name,
&blob_hash[..12],
lookup_err
);
}
return Err(DomainError::not_found("Folder", fid));
}
Err(e) => {
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
tracing::error!(
"Blob orphaned after failed INSERT — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
}
if let sqlx::Error::Database(ref db_err) = e
&& db_err.code().as_deref() == Some("23505")
{
// Idempotent re-upload: if the conflicting file already
// holds IDENTICAL content (same folder, same name, same
// blob hash), treat this as success and return that file
// instead of erroring. Re-uploading a partially-uploaded
// folder then becomes a clean no-op for everything that
// already landed — only the genuinely missing files
// transfer — instead of surfacing hundreds of spurious
// "already exists" failures. The duplicate blob reference
// taken during ingest was just released above, so the
// existing file's own reference is the only one (correct);
// a different-content clash still returns the conflict.
match self.fetch_identical_file(fid, &name, blob_hash).await {
Ok(Some(existing)) => {
tracing::info!(
"♻️ IDEMPOTENT UPLOAD: {} already present, identical content (hash: {})",
name,
&blob_hash[..12]
);
return Ok(existing);
}
Ok(None) => {} // genuine conflict (different content)
Err(lookup_err) => {
tracing::warn!(
"idempotency lookup failed for {} (hash {}): {} — returning conflict",
name,
&blob_hash[..12],
lookup_err
);
}
return Err(DomainError::already_exists(
"File",
format!("'{name}' already exists in this folder"),
));
}
return Err(DomainError::internal_error(
"FileBlobWrite",
format!("insert: {e}"),
return Err(DomainError::already_exists(
"File",
format!("'{name}' already exists in this folder"),
));
}
};
return Err(DomainError::internal_error(
"FileBlobWrite",
format!("insert: {e}"),
));
}
};
tracing::info!(
"📡 STREAMING WRITE: {} ({} bytes, hash: {})",
@@ -404,7 +422,6 @@ impl FileBlobWriteRepository {
content_type,
created_at,
updated_at,
Some(user_id),
blob_hash.to_string(),
created_by,
updated_by,
@@ -421,11 +438,12 @@ impl FileBlobWriteRepository {
name: &str,
blob_hash: &str,
) -> Result<Option<File>, DomainError> {
// Post-D7: `f.user_id` is nullable on new rows; use
// `Option<Uuid>` to accept NULL.
let row = sqlx::query_as::<
_,
(
String,
Uuid,
String,
i64,
i64,
@@ -436,7 +454,7 @@ impl FileBlobWriteRepository {
),
>(
r#"
SELECT f.id::text, f.user_id, fo.path,
SELECT f.id::text, fo.path,
EXTRACT(EPOCH FROM f.created_at)::bigint,
EXTRACT(EPOCH FROM f.updated_at)::bigint,
f.created_by, f.updated_by, f.size, f.mime_type
@@ -460,7 +478,6 @@ impl FileBlobWriteRepository {
let Some((
id,
user_id,
folder_path,
created_at,
updated_at,
@@ -482,7 +499,6 @@ impl FileBlobWriteRepository {
mime_type,
created_at,
updated_at,
Some(user_id),
blob_hash.to_string(),
created_by,
updated_by,
@@ -535,11 +551,10 @@ impl FileWritePort for FileBlobWriteRepository {
>(
r#"
WITH dest AS (
SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid
SELECT drive_id FROM storage.folders WHERE id = $1::uuid
)
UPDATE storage.files f
SET folder_id = $1::uuid,
user_id = COALESCE((SELECT user_id FROM dest), f.user_id),
drive_id = COALESCE((SELECT drive_id FROM dest), f.drive_id),
updated_at = NOW(),
updated_by = $3
@@ -568,7 +583,6 @@ impl FileWritePort for FileBlobWriteRepository {
row.4,
row.5,
row.6,
None,
String::new(),
row.7,
row.8,
@@ -611,29 +625,27 @@ impl FileWritePort for FileBlobWriteRepository {
>(
r#"
WITH src AS (
SELECT name, folder_id, user_id, blob_hash, size, mime_type, category_order
SELECT name, folder_id, blob_hash, size, mime_type, category_order
FROM storage.files
WHERE id = $1::uuid AND NOT is_trashed
),
-- The destination folder may differ from the source's
-- folder (when $2 is set); derive drive_id from the
-- DESTINATION so cross-drive copies land in the right
-- drive. Files in personal drives only copy within the
-- same drive today, but the join makes the migration
-- future-proof for D2's cross-drive copy story.
-- drive. Post-D7: `user_id` no longer projected — the
-- column is not written on new rows.
dest_folder AS (
SELECT id, user_id, drive_id
SELECT id, drive_id
FROM storage.folders
WHERE id = COALESCE($2::uuid,
(SELECT folder_id FROM src))
),
new_file AS (
INSERT INTO storage.files
(name, folder_id, user_id, drive_id, blob_hash, size,
(name, folder_id, drive_id, blob_hash, size,
mime_type, category_order, created_by, updated_by)
SELECT COALESCE($3::text, src.name),
dest_folder.id,
dest_folder.user_id,
dest_folder.drive_id,
src.blob_hash,
src.size,
@@ -642,14 +654,33 @@ impl FileWritePort for FileBlobWriteRepository {
$4,
$4
FROM src, dest_folder
RETURNING id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
RETURNING id,
id::text AS id_text,
name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint AS created_at,
EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at,
blob_hash,
created_by,
updated_by
),
-- RFC 4918 §8.8 — dead properties MUST be duplicated on
-- COPY. With the id-keyed store (migration
-- 20260830000001) this is a single batch INSERT keyed on
-- the new file's id. Runs in the same query as the file
-- INSERT so either both land or neither does — atomic
-- by virtue of being one statement.
dead_prop_copy AS (
INSERT INTO storage.webdav_dead_properties
(file_id, namespace, local_name, value)
SELECT (SELECT id FROM new_file),
dp.namespace, dp.local_name, dp.value
FROM storage.webdav_dead_properties dp
WHERE dp.file_id = $1::uuid
)
SELECT * FROM new_file
SELECT id_text, name, folder_id, size, mime_type,
created_at, updated_at,
blob_hash, created_by, updated_by
FROM new_file
"#,
)
.bind(file_id)
@@ -699,7 +730,6 @@ impl FileWritePort for FileBlobWriteRepository {
row.4,
row.5,
row.6,
None,
row.7,
row.8,
row.9,
@@ -762,7 +792,6 @@ impl FileWritePort for FileBlobWriteRepository {
row.4,
row.5,
row.6,
None,
String::new(),
row.7,
row.8,
@@ -796,12 +825,20 @@ impl FileWritePort for FileBlobWriteRepository {
size: u64,
modified_at: Option<i64>,
caller_id: Uuid,
expected_hash: Option<&str>,
) -> Result<(String, i64), DomainError> {
// The content was already ingested into the chunk store by the
// upload-ingest layer; swap_blob_hash consumes its reference and
// releases it on failure.
let swapped = self
.swap_blob_hash(file_id, blob_hash, size as i64, modified_at, caller_id)
.swap_blob_hash(
file_id,
blob_hash,
size as i64,
modified_at,
caller_id,
expected_hash,
)
.await?;
// The file now maps to a different blob — drop the read-side cache
// entry so streaming downloads cannot serve the previous content
@@ -818,45 +855,84 @@ impl FileWritePort for FileBlobWriteRepository {
size: u64,
caller_id: Uuid,
) -> Result<(File, PathBuf), DomainError> {
let (user_id, drive_id) = self.resolve_owner_and_drive(folder_id.as_deref()).await?;
// For deferred registration we use a placeholder hash.
// The write-behind cache will call update_file_content later.
let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000";
// §14: `created_by = $9 = updated_by = caller_id`. The legacy
// `user_id` column (dropped in D7) stays bound to the parent
// folder's owner; only the two provenance columns flip to the
// caller — see save_file_with_blob_impl.
let row = retry_on_deadlock("files.insert_deferred", || {
sqlx::query_as::<_, (String, i64, i64, Option<Uuid>, Option<Uuid>)>(
r#"
INSERT INTO storage.files
(name, folder_id, user_id, drive_id, blob_hash, size,
mime_type, category_order, created_by, updated_by)
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $9)
RETURNING id::text,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
created_by,
updated_by
"#,
)
.bind(&name)
.bind(&folder_id)
.bind(user_id)
.bind(drive_id)
.bind(placeholder_hash)
.bind(size as i64)
.bind(&content_type)
.bind(category_order_for(&name, &content_type))
.bind(caller_id)
.fetch_one(self.pool.as_ref())
})
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
// Post-D7: `user_id` omitted from the INSERT column list.
// §14: `created_by = <caller> = updated_by`.
//
// With a parent folder this is the SAME single-round-trip `WITH
// parent AS (…) INSERT … RETURNING` template `persist_file` uses:
// the old shape ran three queries per uploaded file — parent drive
// SELECT, INSERT, parent path SELECT — with the first and third
// re-reading the identical folders row (benches/ROUND11.md
// §Q1: 3 → 1 round-trips on the default REST upload path).
let (row, folder_path) = if let Some(fid) = folder_id.as_deref() {
let row = retry_on_deadlock("files.insert_deferred", || {
sqlx::query_as::<_, (String, String, i64, i64, Option<Uuid>, Option<Uuid>)>(
r#"
WITH parent AS (
SELECT id, drive_id, path FROM storage.folders WHERE id = $2::uuid
)
INSERT INTO storage.files
(name, folder_id, drive_id, blob_hash, size,
mime_type, category_order, created_by, updated_by)
SELECT $1, parent.id, parent.drive_id, $3, $4, $5, $6, $7, $7
FROM parent
RETURNING id::text,
(SELECT path FROM parent),
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
created_by,
updated_by
"#,
)
.bind(&name)
.bind(fid)
.bind(placeholder_hash)
.bind(size as i64)
.bind(&content_type)
.bind(category_order_for(&name, &content_type))
.bind(caller_id)
.fetch_optional(self.pool.as_ref())
})
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?
// 0 rows ⇒ the parent folder doesn't exist — same not-found the
// old `resolve_parent_drive` first query produced.
.ok_or_else(|| DomainError::not_found("Folder", fid))?;
((row.0, row.2, row.3, row.4, row.5), Some(row.1))
} else {
let drive_id = self.resolve_parent_drive(None).await?;
let row = retry_on_deadlock("files.insert_deferred", || {
sqlx::query_as::<_, (String, i64, i64, Option<Uuid>, Option<Uuid>)>(
r#"
INSERT INTO storage.files
(name, folder_id, drive_id, blob_hash, size,
mime_type, category_order, created_by, updated_by)
VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $7)
RETURNING id::text,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
created_by,
updated_by
"#,
)
.bind(&name)
.bind(drive_id)
.bind(placeholder_hash)
.bind(size as i64)
.bind(&content_type)
.bind(category_order_for(&name, &content_type))
.bind(caller_id)
.fetch_one(self.pool.as_ref())
})
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
(row, None)
};
let folder_path = self.lookup_folder_path(folder_id.as_deref()).await?;
let file = Self::row_to_file(
row.0.clone(),
name,
@@ -866,7 +942,6 @@ impl FileWritePort for FileBlobWriteRepository {
content_type,
row.1,
row.2,
Some(user_id),
String::new(),
row.3,
row.4,
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,21 @@ struct PlaylistRow {
updated_at: DateTime<Utc>,
}
/// A public playlist row carrying its aggregated track count, produced by the
/// single `LEFT JOIN … GROUP BY` that replaces the per-playlist `COUNT(*)` N+1.
#[derive(FromRow)]
struct PublicPlaylistCountRow {
id: Uuid,
name: String,
description: Option<String>,
owner_id: Uuid,
is_public: bool,
cover_file_id: Option<Uuid>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
track_count: i64,
}
#[derive(FromRow)]
struct PlaylistItemRow {
id: Uuid,
@@ -79,6 +94,52 @@ impl PlaylistPgRepository {
pub fn pool(&self) -> &PgPool {
&self.pool
}
/// Public playlists together with their track counts in a **single**
/// round-trip. Replaces the adapter's 1 + N shape (one listing SELECT then
/// one `SELECT COUNT(*) FROM audio.playlist_items` per returned playlist —
/// up to 101 round-trips at `limit = 100`) with one `LEFT JOIN … GROUP BY`,
/// backed by `idx_playlist_items_playlist_id` (benches/ROUND25.md §Q1).
pub async fn list_public_playlists_with_counts(
&self,
limit: i64,
offset: i64,
) -> PlaylistRepositoryResult<Vec<(Playlist, i64)>> {
let rows = sqlx::query_as::<_, PublicPlaylistCountRow>(
"SELECT p.id, p.name, p.description, p.owner_id, p.is_public, p.cover_file_id, \
p.created_at, p.updated_at, COUNT(pi.id) AS track_count \
FROM audio.playlists p \
LEFT JOIN audio.playlist_items pi ON pi.playlist_id = p.id \
WHERE p.is_public = TRUE \
GROUP BY p.id \
ORDER BY p.updated_at DESC LIMIT $1 OFFSET $2",
)
.bind(limit)
.bind(offset)
.fetch_all(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to list public playlists: {}", e))
})?;
rows.into_iter()
.map(|row| {
let track_count = row.track_count;
Playlist::with_id(
row.id,
row.name,
row.description,
row.owner_id,
row.is_public,
row.cover_file_id,
row.created_at,
row.updated_at,
)
.map(|p| (p, track_count))
.map_err(|e| DomainError::new(ErrorKind::InternalError, "Playlist", e.to_string()))
})
.collect()
}
}
impl PlaylistRepository for PlaylistPgRepository {
@@ -180,6 +241,35 @@ impl PlaylistRepository for PlaylistPgRepository {
.map_err(|e| DomainError::new(ErrorKind::InternalError, "Playlist", e.to_string()))
}
async fn find_playlists_by_ids(&self, ids: &[Uuid]) -> PlaylistRepositoryResult<Vec<Playlist>> {
if ids.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query_as::<_, PlaylistRow>(
"SELECT id, name, description, owner_id, is_public, cover_file_id, created_at, updated_at FROM audio.playlists WHERE id = ANY($1)",
)
.bind(ids)
.fetch_all(&*self.pool)
.await
.map_err(|e| DomainError::database_error(format!("Failed to find playlists: {}", e)))?;
rows.into_iter()
.map(|row| {
Playlist::with_id(
row.id,
row.name,
row.description,
row.owner_id,
row.is_public,
row.cover_file_id,
row.created_at,
row.updated_at,
)
.map_err(|e| DomainError::new(ErrorKind::InternalError, "Playlist", e.to_string()))
})
.collect()
}
async fn list_playlists_by_owner(
&self,
owner_id: Uuid,
@@ -515,17 +605,27 @@ impl PlaylistItemRepository for PlaylistItemPgRepository {
playlist_id: &Uuid,
item_ids: &[Uuid],
) -> PlaylistItemRepositoryResult<()> {
for (index, item_id) in item_ids.iter().enumerate() {
sqlx::query(
"UPDATE audio.playlist_items SET position = $2 WHERE id = $1 AND playlist_id = $3",
)
.bind(item_id)
.bind(index as i32)
.bind(playlist_id)
.execute(&*self.pool)
.await
.map_err(|e| DomainError::database_error(format!("Failed to reorder: {}", e)))?;
if item_ids.is_empty() {
return Ok(());
}
// One UNNEST-driven UPDATE instead of one autocommit round-trip per
// track — a full drag-reorder of an N-track playlist was N statements
// (and non-atomic: a mid-loop failure left a half-applied order).
// `WITH ORDINALITY` numbers the ids in array order, 1-based, so
// `ord - 1` reproduces the historical 0-based positions.
sqlx::query(
r#"
UPDATE audio.playlist_items AS pi
SET position = (t.ord - 1)::int
FROM unnest($1::uuid[]) WITH ORDINALITY AS t(id, ord)
WHERE pi.id = t.id AND pi.playlist_id = $2
"#,
)
.bind(item_ids)
.bind(playlist_id)
.execute(&*self.pool)
.await
.map_err(|e| DomainError::database_error(format!("Failed to reorder: {}", e)))?;
Ok(())
}
@@ -21,18 +21,20 @@ impl RecentItemsPgRepository {
impl RecentItemsRepositoryPort for RecentItemsPgRepository {
async fn get_recent_items(&self, user_id: Uuid, limit: i32) -> Result<Vec<RecentItemDto>> {
// Binary UUID decode + app-side render (ROUND6 §10 pattern) — no
// server-side `::TEXT` casts, 16 B per id on the wire instead of 36.
let rows = sqlx::query(
r#"
SELECT
ur.id::TEXT AS "id",
ur.user_id::TEXT AS "user_id",
ur.id AS "id",
ur.user_id AS "user_id",
ur.item_id AS "item_id",
ur.item_type AS "item_type",
ur.accessed_at AS "accessed_at",
COALESCE(f.name, fld.name) AS "item_name",
f.size AS "item_size",
f.mime_type AS "item_mime_type",
COALESCE(f.folder_id::TEXT, fld.parent_id::TEXT) AS "parent_id",
COALESCE(f.folder_id, fld.parent_id) AS "parent_id",
CASE
WHEN ur.item_type = 'folder' THEN fld.path
WHEN ur.item_type = 'file' THEN COALESCE(pfld.path || '/' || f.name, f.name)
@@ -67,15 +69,19 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
.iter()
.map(|row| {
RecentItemDto {
id: row.get("id"),
user_id: row.get("user_id"),
id: row.get::<i32, _>("id").to_string(),
user_id: row.get::<Uuid, _>("user_id").to_string(),
item_id: row.get("item_id"),
item_type: row.get("item_type"),
accessed_at: row.get("accessed_at"),
item_name: row.try_get("item_name").ok(),
item_size: row.try_get("item_size").ok(),
item_mime_type: row.try_get("item_mime_type").ok(),
parent_id: row.try_get("parent_id").ok(),
parent_id: row
.try_get::<Option<Uuid>, _>("parent_id")
.ok()
.flatten()
.map(|u| u.to_string()),
item_path: row.try_get("item_path").ok(),
// Temporary defaults; with_display_fields() computes the real values
icon_class: String::new(),
@@ -90,19 +96,24 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
Ok(items)
}
async fn upsert_access(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result<()> {
sqlx::query(
async fn upsert_access(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result<bool> {
// `xmax = 0` on the affected row is the canonical upsert idiom for
// "this was an INSERT, not a DO UPDATE" — lets the caller skip the
// prune round-trip on the common re-access (UPDATE) path
// (benches/ROUND13.md §Q3).
let inserted: bool = sqlx::query_scalar(
r#"
INSERT INTO auth.user_recent_files (user_id, item_id, item_type, accessed_at)
VALUES ($1, $2, $3, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, item_id, item_type)
DO UPDATE SET accessed_at = CURRENT_TIMESTAMP
RETURNING (xmax = 0)
"#,
)
.bind(user_id)
.bind(item_id)
.bind(item_type)
.execute(&*self.db_pool)
.fetch_one(&*self.db_pool)
.await
.map_err(|e| {
error!("Database error upserting recent item access: {}", e);
@@ -113,7 +124,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
)
})?;
Ok(())
Ok(inserted)
}
async fn remove_item(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result<bool> {
@@ -206,6 +217,20 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
// ── Build the UNION ALL CTE ─────────────────────────────────────────
let mut cte_branches: Vec<&str> = Vec::new();
// Post-D7: `is_owner` means "the caller holds an Owner
// role_grant on the drive owning this row". Personal drives:
// the single-owner invariant makes this trivially true for the
// owner and false for anyone else. Shared drives: multiple
// Owners possible; each of them gets `true`. Used only to gate
// whether the handler exposes the full path (path-hierarchy
// hiding for share recipients — see `recent_handler.rs`).
//
// The `created_by` projection is separate — §14 provenance,
// used for the "Owner" column and the owner sort's username
// JOIN. The two signals genuinely differ post-D2: e.g. Bob
// (Editor on Alice's shared drive) making a file has
// `created_by = Bob` but `is_owner = false` because Alice owns
// the drive.
let folder_branch = r#"
SELECT
'folder'::text AS resource_type,
@@ -216,9 +241,19 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
-1::bigint AS size,
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
fld.drive_id AS drive_id,
NULL::text AS blob_hash,
(fld.user_id = $1::uuid) AS is_owner,
fld.created_by AS created_by,
fld.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = fld.drive_id
AND g.role = 'owner'
AND g.subject_type = 'user'
AND g.subject_id = $1::uuid
AND (g.expires_at IS NULL OR g.expires_at > NOW())
) AS is_owner,
ur.accessed_at AS accessed_at,
fld.path::text AS resource_path,
LOWER(fld.name) AS sort_str,
@@ -239,9 +274,19 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
f.size::bigint,
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.drive_id AS drive_id,
f.blob_hash,
(f.user_id = $1::uuid) AS is_owner,
f.created_by AS created_by,
f.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND g.role = 'owner'
AND g.subject_type = 'user'
AND g.subject_id = $1::uuid
AND (g.expires_at IS NULL OR g.expires_at > NOW())
) AS is_owner,
ur.accessed_at AS accessed_at,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
LOWER(f.name) AS sort_str,
@@ -392,7 +437,9 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
};
let user_join = if need_user_join {
"LEFT JOIN auth.users u ON u.id = r.owner_id"
// Post-D7: `owner_id` column retired; use `created_by`
// (§14 provenance) as the "owner" identity for the sort.
"LEFT JOIN auth.users u ON u.id = r.created_by"
} else {
""
};
@@ -409,7 +456,8 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.owner_id, r.is_owner, r.accessed_at, r.resource_path,
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.is_owner, r.accessed_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r
{user_join}
@@ -484,8 +532,10 @@ LIMIT $6"
size,
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
drive_id: row.get("drive_id"),
blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false),
accessed_at: row.get("accessed_at"),
path: row.try_get("resource_path").ok(),
@@ -327,6 +327,77 @@ impl SessionStoragePort for SessionPgRepository {
.map_err(DomainError::from)
}
/// Revoke + insert + last-login stamp in ONE transaction — the refresh
/// rotation used to pay two full BEGIN/COMMIT round-trip pairs
/// (`revoke_session` then `create_session`) per token refresh.
async fn rotate_session(
&self,
old_session_id: Uuid,
new_session: Session,
) -> Result<Session, DomainError> {
let session_clone = new_session.clone();
with_transaction(&self.pool, "rotate_session", |tx| {
Box::pin(async move {
sqlx::query("UPDATE auth.sessions SET revoked = true WHERE id = $1")
.bind(old_session_id)
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
sqlx::query(
r#"
INSERT INTO auth.sessions (
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9
)
"#,
)
.bind(session_clone.id())
.bind(session_clone.user_id())
.bind(session_clone.refresh_token())
.bind(session_clone.expires_at())
.bind(session_clone.ip_address())
.bind(session_clone.user_agent())
.bind(session_clone.created_at())
.bind(session_clone.is_revoked())
.bind(session_clone.family_id())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
sqlx::query(
r#"
UPDATE auth.users
SET last_login_at = NOW(), updated_at = NOW()
WHERE id = $1
"#,
)
.bind(session_clone.user_id())
.execute(&mut **tx)
.await
.map_err(|e| {
tracing::warn!(
"Could not update last_login_at for user {}: {}",
session_clone.user_id(),
e
);
SessionRepositoryError::DatabaseError(format!(
"Session rotated but could not update last_login_at: {}",
e
))
})?;
Ok(session_clone)
}) as BoxFuture<'_, SessionRepositoryResult<Session>>
})
.await
.map_err(DomainError::from)?;
Ok(new_session)
}
async fn get_session_by_refresh_token(
&self,
refresh_token: &str,
@@ -122,6 +122,35 @@ impl ShareStoragePort for SharePgRepository {
Self::row_to_entity(&row)
}
async fn increment_access_count(&self, token: &str) -> Result<u64, DomainError> {
// One atomic statement — the relative bump can't lose concurrent
// increments and never rewrites unrelated columns (the legacy
// read-modify-write wrote back item_name/password_hash wholesale,
// silently clobbering concurrent owner edits). The expiry guard
// mirrors find_share_by_token's MIN(expires_at) subquery: NULL =
// never expires.
let result = sqlx::query(
r#"
UPDATE storage.shares s
SET access_count = s.access_count + 1
WHERE s.token = $1
AND COALESCE(
(SELECT MIN(ag.expires_at)
FROM storage.role_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) > NOW(),
TRUE)
"#,
)
.bind(token)
.execute(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error incrementing share access count: {}", e);
DomainError::internal_error("Share", format!("Failed to register access: {e}"))
})?;
Ok(result.rows_affected())
}
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
let row = sqlx::query(
r#"
@@ -123,26 +123,45 @@ impl TrashRepository for TrashDbRepository {
}
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> {
let rows =
sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>, String)>(
r#"
SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at,
// Post-D7: the `WHERE t.user_id = $1` filter is gone — the
// `user_id` column was dropped from `storage.{files,folders}`
// and the view no longer projects it. Scope is drive-membership
// via role_grants; group memberships expand inline through
// `storage.caller_group_ids`. Same predicate shape as
// `list_root_folders_for_caller` / the file listings.
//
// Legacy method — the paginated `list_resources_paged` is the
// modern shape and takes explicit drive_ids from the service
// layer.
let rows = sqlx::query_as::<_, (Uuid, String, String, Option<DateTime<Utc>>, String)>(
r#"
SELECT t.id, t.name, t.item_type, t.trashed_at,
COALESCE(p.path || '/' || t.name, t.name) AS original_path
FROM storage.trash_items t
LEFT JOIN storage.folders p ON p.id = t.original_parent_id
WHERE t.user_id = $1
WHERE EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = t.drive_id
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $1)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($1)))
)
)
ORDER BY t.trashed_at DESC
"#,
)
.bind(user_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?;
)
.bind(user_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?;
Ok(rows
.into_iter()
.map(|(id, name, item_type, uid, trashed_at, path)| {
self.row_to_trashed_item(id, name, item_type, uid, trashed_at, path)
.map(|(id, name, item_type, trashed_at, path)| {
self.row_to_trashed_item(id, name, item_type, *user_id, trashed_at, path)
})
.collect())
}
@@ -153,9 +172,16 @@ impl TrashRepository for TrashDbRepository {
// …)` in the service callers (`restore_item`, `delete_permanently`).
// The drive precheck in `pg_acl_engine` then resolves Owner-on-drive
// → Delete-permission for items in shared drives.
let row = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>, String)>(
//
// Post-D7: `t.user_id` no longer exists — the column is dropped
// from `storage.{files,folders}` and no longer projected by the
// view. The entity's `user_id` field is still non-optional;
// synthesize `Uuid::nil()`. AuthZ decisions don't consult this
// field — they've already resolved the caller's role on the
// target's drive.
let row = sqlx::query_as::<_, (Uuid, String, String, Option<DateTime<Utc>>, String)>(
r#"
SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at,
SELECT t.id, t.name, t.item_type, t.trashed_at,
COALESCE(p.path || '/' || t.name, t.name) AS original_path
FROM storage.trash_items t
LEFT JOIN storage.folders p ON p.id = t.original_parent_id
@@ -167,8 +193,8 @@ impl TrashRepository for TrashDbRepository {
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?;
Ok(row.map(|(id, name, item_type, uid, trashed_at, path)| {
self.row_to_trashed_item(id, name, item_type, uid, trashed_at, path)
Ok(row.map(|(id, name, item_type, trashed_at, path)| {
self.row_to_trashed_item(id, name, item_type, Uuid::nil(), trashed_at, path)
}))
}
@@ -226,15 +252,35 @@ impl TrashRepository for TrashDbRepository {
async fn delete_expired_bulk(&self) -> Result<(u64, u64)> {
let cutoff = Utc::now() - chrono::Duration::days(self.retention_days);
// The `read_only` policy on a drive is a compliance-grade freeze:
// NO state on the drive changes while the policy is on, including
// background retention. The `JOIN storage.drives d ... AND
// (d.policies->>'read_only')::boolean IS NOT TRUE` filter excludes
// frozen drives at SELECT time. Retention clock keeps ticking; on
// unfreeze, the next sweep tick catches up on anything past its
// TTL. Legal-hold guarantee documented in `docs/plan/drive.md` §8
// and `docs/guide/trash.md`.
//
// `(policies->>'read_only')::boolean IS NOT TRUE` semantics:
// - key missing → NULL::boolean → IS NOT TRUE → included
// - explicit `false` → FALSE → IS NOT TRUE → included
// - explicit `true` → TRUE → IS TRUE → excluded
// Correct for both current data (most drives omit the key) and
// freshly-frozen drives.
// 1. Bulk-delete expired trashed files in batches.
// The PG trigger `trg_files_decrement_blob_ref` automatically
// decrements blob ref_count for every deleted row.
let files_deleted = self
.delete_expired_batch_loop(
"DELETE FROM storage.files
WHERE id IN (SELECT id FROM storage.files
WHERE is_trashed = TRUE AND trashed_at < $1
ORDER BY trashed_at
WHERE id IN (SELECT f.id
FROM storage.files f
JOIN storage.drives d ON d.id = f.drive_id
WHERE f.is_trashed = TRUE
AND f.trashed_at < $1
AND (d.policies->>'read_only')::boolean IS NOT TRUE
ORDER BY f.trashed_at
LIMIT $2)",
cutoff,
1_000,
@@ -244,13 +290,19 @@ impl TrashRepository for TrashDbRepository {
// 2. Bulk-delete expired trashed folders in batches.
// FK ON DELETE CASCADE handles descendant folders and their
// files, so each row can fan out to an entire subtree — hence
// the smaller batch size.
// the smaller batch size. Same read_only exclusion applies:
// a subtree rooted in a frozen drive isn't purged even if the
// folder's own trashed_at is past retention.
let folders_deleted = self
.delete_expired_batch_loop(
"DELETE FROM storage.folders
WHERE id IN (SELECT id FROM storage.folders
WHERE is_trashed = TRUE AND trashed_at < $1
ORDER BY trashed_at
WHERE id IN (SELECT f.id
FROM storage.folders f
JOIN storage.drives d ON d.id = f.drive_id
WHERE f.is_trashed = TRUE
AND f.trashed_at < $1
AND (d.policies->>'read_only')::boolean IS NOT TRUE
ORDER BY f.trashed_at
LIMIT $2)",
cutoff,
100,
@@ -313,9 +365,10 @@ impl TrashDbRepository {
-1::bigint AS size,
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
fld.drive_id AS drive_id,
NULL::text AS blob_hash,
fld.created_by AS created_by,
fld.updated_by AS updated_by,
fld.trashed_at AS trashed_at,
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
fld.path::text AS resource_path,
@@ -340,9 +393,10 @@ impl TrashDbRepository {
f.size::bigint AS size,
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.drive_id AS drive_id,
f.blob_hash,
f.created_by AS created_by,
f.updated_by AS updated_by,
f.trashed_at AS trashed_at,
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
@@ -472,7 +526,8 @@ impl TrashDbRepository {
SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.owner_id, r.drive_id, r.trashed_at, r.deletion_date, r.resource_path,
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.trashed_at, r.deletion_date, r.resource_path,
r.sort_str, r.type_order, r.folder_first
FROM resources r
{keyset}
@@ -529,9 +584,10 @@ LIMIT $6"
size,
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
drive_id: row.get("drive_id"),
blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(),
trashed_at,
deletion_date,
path: row.try_get("resource_path").ok(),
@@ -85,6 +85,33 @@ impl UserPgRepository {
})
}
/// Fetch only `(storage_used_bytes, storage_quota_bytes)`. Not part of
/// the `UserRepository` trait — called from `StorageUsageService`.
///
/// Same rationale as [`Self::get_user_flags`]: the full-row SELECT drags
/// `image` (a data URI of up to 512 KiB), `password_hash`,
/// `ui_preferences`, … across the wire, and the quota path runs on every
/// folder PROPFIND and every upload quota check just to read two i64s.
/// Measured in `benches/QUOTA-PATH.md`.
pub async fn get_storage_usage(&self, id: Uuid) -> UserRepositoryResult<(i64, i64)> {
let row = sqlx::query(
r#"
SELECT storage_used_bytes, storage_quota_bytes
FROM auth.users
WHERE id = $1
"#,
)
.bind(id)
.fetch_one(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok((
row.get("storage_used_bytes"),
row.get("storage_quota_bytes"),
))
}
/// Updates a user's profile image (URL or data URI). Not part of the
/// `UserRepository` trait — called directly from `AuthApplicationService`.
pub async fn update_image(
@@ -106,6 +133,48 @@ impl UserPgRepository {
.map_err(Self::map_sqlx_error)?;
Ok(())
}
/// Shallow-merge a partial UI-preferences patch into
/// `ui_preferences`. The Postgres `||` operator merges top-level
/// keys — `{"a":1,"b":2} || {"b":3,"c":4}` → `{"a":1,"b":3,"c":4}`,
/// which is exactly the semantic PATCH callers want: a partial
/// write only touches the keys it mentions, so a preference set on
/// one device isn't wiped by a partial write from another.
///
/// `jsonb_strip_nulls` removes any key whose incoming value is
/// null, giving callers a documented delete-a-key path (`PATCH
/// {"foo": null}` clears `foo`). Nested nulls inside a value
/// object survive — we only strip at the top level via the merge
/// result.
///
/// Not part of the `UserRepository` trait — called directly from
/// `AuthApplicationService::update_profile`. Bumps `updated_at`
/// so the standard "when did this row change" audits stay useful.
///
/// The CHECK constraints
/// (`users_ui_preferences_is_object` + `_size_cap`) enforce shape
/// and cap at the schema layer; a violating patch surfaces as an
/// sqlx error and returns to the handler as 400.
pub async fn update_ui_preferences(
&self,
user_id: Uuid,
patch: &serde_json::Value,
) -> UserRepositoryResult<()> {
sqlx::query(
r#"
UPDATE auth.users
SET ui_preferences = jsonb_strip_nulls(ui_preferences || $2::jsonb),
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(user_id)
.bind(patch)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(())
}
}
impl UserRepository for UserPgRepository {
@@ -123,18 +192,25 @@ impl UserRepository for UserPgRepository {
let role_str = user_clone.role().to_string();
// Modify the SQL to do an explicit cast to the auth.userrole type
// `image` is included here (was missing pre-fix); without
// it a JIT-provisioned OIDC user landed in the row with
// a NULL profile picture even when the IdP's `picture`
// claim was non-empty. `update_user` already wrote the
// column so existing-user re-logins worked, but the
// first-time INSERT silently dropped it — surfaced by
// tests/oidc/oidc.hurl Step 6 asserting on `$.image`.
let _result = sqlx::query(
r#"
INSERT INTO auth.users (
id, username, email, password_hash, role,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, is_external,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at,
preferred_locale, notify_on_share
preferred_locale, notify_on_share, ui_preferences
) VALUES (
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, $17, $18, $19
$12, $13, $14, $15, $16, $17, $18, $19, $20, $21
)
RETURNING *
"#,
@@ -152,12 +228,17 @@ impl UserRepository for UserPgRepository {
.bind(user_clone.is_active())
.bind(user_clone.oidc_provider())
.bind(user_clone.oidc_subject())
.bind(user_clone.image())
.bind(user_clone.is_external())
.bind(user_clone.given_name())
.bind(user_clone.family_name())
.bind(user_clone.email_verified_at())
.bind(user_clone.preferred_locale())
.bind(user_clone.notify_on_share())
// ui_preferences bind: always a JSON object. `User::new`
// initialises the bag to `{}`; ownership stays with the
// repo for shallow-merge writes via `update_ui_preferences`.
.bind(user_clone.ui_preferences())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -182,7 +263,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE id = $1
"#,
@@ -220,6 +302,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
))
}
@@ -232,7 +315,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE username = $1
"#,
@@ -270,6 +354,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
))
}
@@ -282,7 +367,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE email = $1
"#,
@@ -320,6 +406,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
))
}
@@ -327,6 +414,16 @@ impl UserRepository for UserPgRepository {
/// recipient expansion). Missing ids are silently skipped — the
/// caller treats absent rows as "no such recipient", same as
/// `get_user_by_id` returning `NotFound` for a single lookup.
///
/// Notification-recipient projection: the up-to-512 KiB avatar `image`
/// and the `ui_preferences` JSONB are NOT hydrated (both come back as
/// `None`/`Null`) — the sole caller
/// (`RecipientNotificationService`) reads only the email/eligibility
/// fields, and a group fan-out of M members otherwise detoasted +
/// shipped + parsed M avatars purely to discard them (the ROUND12 §Q1
/// avatar-narrowing pattern; benches/ROUND13.md §Q1). If a future
/// caller needs the avatar, add a wide sibling rather than widening
/// this one back.
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> UserRepositoryResult<Vec<User>> {
if ids.is_empty() {
return Ok(Vec::new());
@@ -338,7 +435,7 @@ impl UserRepository for UserPgRepository {
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
oidc_provider, oidc_subject, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
FROM auth.users
WHERE id = ANY($1)
@@ -372,13 +469,14 @@ impl UserRepository for UserPgRepository {
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get("image"),
None, // image — not projected (notification-recipient path)
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
serde_json::Value::Null, // ui_preferences — not projected
)
})
.collect())
@@ -410,7 +508,19 @@ impl UserRepository for UserPgRepository {
family_name = $13,
email_verified_at = $14,
preferred_locale = $15,
notify_on_share = $16
notify_on_share = $16,
-- Include `is_external` so the external →
-- internal upgrade path
-- (`AuthApplicationService::upgrade_to_internal`)
-- can flip this flag. Previously omitted
-- because no code path mutated it after
-- creation. The DB CHECK
-- `users_external_no_storage`
-- (`is_external=false OR quota=0`) is
-- satisfied by the upgrade because it
-- writes both fields in the same UPDATE:
-- `is_external=false, quota>0`.
is_external = $17
WHERE id = $1
"#,
)
@@ -430,6 +540,7 @@ impl UserRepository for UserPgRepository {
.bind(user_clone.email_verified_at())
.bind(user_clone.preferred_locale())
.bind(user_clone.notify_on_share())
.bind(user_clone.is_external())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -506,7 +617,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE ($3 OR is_external = FALSE)
ORDER BY created_at DESC
@@ -551,6 +663,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
)
})
.collect();
@@ -572,7 +685,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE (username ILIKE $1 OR email ILIKE $1)
AND ($3 OR is_external = FALSE)
@@ -617,6 +731,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
)
})
.collect();
@@ -695,6 +810,15 @@ impl UserRepository for UserPgRepository {
Ok(())
}
/// Counts users by role with a scalar `COUNT(*)` — no row hydration.
async fn count_users_by_role(&self, role: &str) -> UserRepositoryResult<i64> {
sqlx::query_scalar("SELECT COUNT(*) FROM auth.users WHERE role::text = $1")
.bind(role)
.fetch_one(&*self.pool)
.await
.map_err(Self::map_sqlx_error)
}
/// Lists users by role
async fn list_users_by_role(&self, role: &str) -> UserRepositoryResult<Vec<User>> {
let rows = sqlx::query(
@@ -704,7 +828,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE role::text = $1
ORDER BY created_at DESC
@@ -746,6 +871,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
)
})
.collect();
@@ -782,7 +908,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE oidc_provider = $1 AND oidc_subject = $2
"#,
@@ -820,6 +947,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
))
}
@@ -957,12 +1085,93 @@ impl UserStoragePort for UserPgRepository {
.map_err(DomainError::from)
}
async fn search_usernames(
&self,
query: &str,
limit: i64,
include_external: bool,
) -> Result<Vec<Option<String>>, DomainError> {
// Same predicate / order / limit as `search_users`, username-only
// projection — the sharee autocomplete path reads nothing else, and
// the wide row drags the avatar `image` per matched user.
let pattern = format!("%{}%", query);
let rows = sqlx::query(
r#"
SELECT username
FROM auth.users
WHERE (username ILIKE $1 OR email ILIKE $1)
AND ($3 OR is_external = FALSE)
ORDER BY username
LIMIT $2
"#,
)
.bind(&pattern)
.bind(limit)
.bind(include_external)
.fetch_all(&*self.pool)
.await
.map_err(Self::map_sqlx_error)
.map_err(DomainError::from)?;
Ok(rows.into_iter().map(|row| row.get("username")).collect())
}
async fn mark_email_verified(&self, user_id: Uuid) -> Result<(), DomainError> {
// SQL twin of `User::mark_email_verified` — stamps once, keeps the
// first timestamp, and touches only the two columns involved.
sqlx::query(
r#"
UPDATE auth.users
SET email_verified_at = NOW(), updated_at = NOW()
WHERE id = $1 AND email_verified_at IS NULL
"#,
)
.bind(user_id)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)
.map_err(DomainError::from)?;
Ok(())
}
async fn sync_oidc_login_profile(
&self,
user_id: Uuid,
image: Option<&str>,
) -> Result<(), DomainError> {
// `IS DISTINCT FROM` guard (the `update_storage_usage` pattern): the
// common repeat-login case — same IdP avatar, already verified —
// writes nothing at all (no dead tuple, no WAL).
sqlx::query(
r#"
UPDATE auth.users
SET image = $2,
email_verified_at = COALESCE(email_verified_at, NOW()),
updated_at = NOW()
WHERE id = $1
AND (image IS DISTINCT FROM $2 OR email_verified_at IS NULL)
"#,
)
.bind(user_id)
.bind(image)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)
.map_err(DomainError::from)?;
Ok(())
}
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError> {
UserRepository::list_users_by_role(self, role)
.await
.map_err(DomainError::from)
}
async fn count_users_by_role(&self, role: &str) -> Result<i64, DomainError> {
UserRepository::count_users_by_role(self, role)
.await
.map_err(DomainError::from)
}
async fn delete_user(&self, user_id: Uuid) -> Result<(), DomainError> {
UserRepository::delete_user(self, user_id)
.await