feat(drive): improve Drive model
now Drive is purely a metadata
each drive has always a root folder
this model minimize Oxicloud changes, and simplify
the Drive name is simply the folder's root's name
note: owner of Drive has more permission that an owner of the root folder
This commit is contained in:
@@ -15,7 +15,7 @@ use sqlx::{PgPool, Row, types::Uuid};
|
||||
|
||||
use crate::domain::entities::drive::{Drive, DriveKind};
|
||||
use crate::domain::repositories::drive_repository::{
|
||||
CreatePersonalDriveInput, DriveRepository, DriveRepositoryError,
|
||||
DriveRepository, DriveRepositoryError, DriveWithRootName,
|
||||
};
|
||||
|
||||
pub struct DrivePgRepository {
|
||||
@@ -35,67 +35,159 @@ impl DrivePgRepository {
|
||||
// unique_violation. With drives, the only relevant unique is
|
||||
// the partial index `idx_drives_default_for_user_unique` —
|
||||
// surface the typed variant so the lifecycle hook can detect
|
||||
// idempotent re-runs (D0-9 calls create_personal during
|
||||
// user provisioning).
|
||||
// idempotent re-runs (D0-9 calls create_personal_drive_atomic
|
||||
// during user provisioning).
|
||||
return DriveRepositoryError::DefaultDriveAlreadyExists(dberr.to_string());
|
||||
}
|
||||
DriveRepositoryError::StorageError(format!("{context}: {e}"))
|
||||
}
|
||||
|
||||
fn row_to_drive(row: &sqlx::postgres::PgRow) -> Result<Drive, DriveRepositoryError> {
|
||||
/// Map a row carrying both the drive's columns AND a `root_folder_name`
|
||||
/// column (sourced via JOIN with `storage.folders`) into the view-model.
|
||||
fn row_to_drive_with_name(
|
||||
row: &sqlx::postgres::PgRow,
|
||||
) -> Result<DriveWithRootName, DriveRepositoryError> {
|
||||
let kind_str: String = row.get("kind");
|
||||
let kind = DriveKind::from_sql(&kind_str)?;
|
||||
Ok(Drive {
|
||||
let drive = Drive {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
kind,
|
||||
default_for_user: row.get("default_for_user"),
|
||||
root_folder_id: row.get("root_folder_id"),
|
||||
quota_bytes: row.get("quota_bytes"),
|
||||
used_bytes: row.get("used_bytes"),
|
||||
policies: row.get("policies"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
};
|
||||
Ok(DriveWithRootName {
|
||||
drive,
|
||||
root_folder_name: row.get("root_folder_name"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DriveRepository for DrivePgRepository {
|
||||
async fn create_personal(
|
||||
async fn create_personal_drive_atomic(
|
||||
&self,
|
||||
input: CreatePersonalDriveInput,
|
||||
) -> Result<Drive, DriveRepositoryError> {
|
||||
let default_for_user = if input.is_default {
|
||||
Some(input.owner_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let row = sqlx::query(
|
||||
owner_id: Uuid,
|
||||
quota_bytes: Option<i64>,
|
||||
) -> Result<DriveWithRootName, DriveRepositoryError> {
|
||||
// Four writes wrapped in a single transaction so either all
|
||||
// commit or none does (docs/plan/drive.md §3). A single CTE
|
||||
// statement would be cleaner on paper but doesn't work in
|
||||
// PostgreSQL: CTE sub-statements share an MVCC snapshot, so
|
||||
// `UPDATE storage.drives WHERE id = …` cannot match a row
|
||||
// inserted by an earlier CTE branch. We use plain sequential
|
||||
// statements inside `pool.begin()` instead — each statement
|
||||
// sees the prior ones' writes (transaction-local visibility),
|
||||
// and FK constraints are satisfied at insert time because the
|
||||
// referenced rows already exist.
|
||||
//
|
||||
// Rollback semantics: any error before `tx.commit()` (FK
|
||||
// violation, unique_violation on `default_for_user`, server
|
||||
// crash) discards every partial write. No orphan drive, no
|
||||
// folder without a drive, no drive without an owner.
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.begin", e))?;
|
||||
|
||||
// 1. Drive row (root_folder_id NULL — populated in step 3).
|
||||
let drive_id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO storage.drives
|
||||
(name, kind, default_for_user, quota_bytes, policies)
|
||||
VALUES ($1, 'personal', $2, $3, '{}'::jsonb)
|
||||
RETURNING id, name, kind, default_for_user, quota_bytes,
|
||||
used_bytes, policies, created_at, updated_at
|
||||
(kind, default_for_user, quota_bytes, policies)
|
||||
VALUES ('personal', $1, $2, '{}'::jsonb)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(default_for_user)
|
||||
.bind(input.quota_bytes)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.bind(owner_id)
|
||||
.bind(quota_bytes)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("create_personal", e))?;
|
||||
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.drive", e))?;
|
||||
|
||||
Self::row_to_drive(&row)
|
||||
}
|
||||
// 2. Root folder. `parent_id IS NULL` makes it a root in the
|
||||
// drive; `drive_id` closes the FK in this direction.
|
||||
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)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(owner_id)
|
||||
.bind(drive_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.folder", e))?;
|
||||
|
||||
async fn get_by_id(&self, id: Uuid) -> Result<Drive, DriveRepositoryError> {
|
||||
// 3. Close the other side of the circular reference.
|
||||
sqlx::query(
|
||||
r#"UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2"#,
|
||||
)
|
||||
.bind(folder_id)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.wire", e))?;
|
||||
|
||||
// 4. Owner role_grant — the caller becomes the drive's sole
|
||||
// owner (single-user invariant on personal drives, §2).
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO storage.role_grants
|
||||
(subject_type, subject_id, resource_type, resource_id,
|
||||
role, granted_by)
|
||||
VALUES ('user', $1, 'drive', $2, 'owner', $1)
|
||||
"#,
|
||||
)
|
||||
.bind(owner_id)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.grant", e))?;
|
||||
|
||||
// Fetch the row in its final state so the caller gets a
|
||||
// consistent view (including DB-computed defaults like
|
||||
// `created_at`, `used_bytes`).
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, name, kind, default_for_user, quota_bytes,
|
||||
used_bytes, policies, created_at, updated_at
|
||||
FROM storage.drives
|
||||
WHERE id = $1
|
||||
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
|
||||
FROM storage.drives d
|
||||
JOIN storage.folders f ON f.id = d.root_folder_id
|
||||
WHERE d.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(drive_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.read", e))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.commit", e))?;
|
||||
|
||||
Self::row_to_drive_with_name(&row)
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: Uuid) -> Result<DriveWithRootName, DriveRepositoryError> {
|
||||
let row = 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
|
||||
FROM storage.drives d
|
||||
JOIN storage.folders f ON f.id = d.root_folder_id
|
||||
WHERE d.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
@@ -104,16 +196,22 @@ impl DriveRepository for DrivePgRepository {
|
||||
.map_err(|e| Self::map_sqlx_err("get_by_id", e))?
|
||||
.ok_or_else(|| DriveRepositoryError::NotFound(id.to_string()))?;
|
||||
|
||||
Self::row_to_drive(&row)
|
||||
Self::row_to_drive_with_name(&row)
|
||||
}
|
||||
|
||||
async fn find_default_for_user(&self, user_id: Uuid) -> Result<Drive, DriveRepositoryError> {
|
||||
async fn find_default_for_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<DriveWithRootName, DriveRepositoryError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, name, kind, default_for_user, quota_bytes,
|
||||
used_bytes, policies, created_at, updated_at
|
||||
FROM storage.drives
|
||||
WHERE default_for_user = $1
|
||||
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
|
||||
FROM storage.drives d
|
||||
JOIN storage.folders f ON f.id = d.root_folder_id
|
||||
WHERE d.default_for_user = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
@@ -122,39 +220,41 @@ 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(&row)
|
||||
Self::row_to_drive_with_name(&row)
|
||||
}
|
||||
|
||||
async fn list_for_subjects(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
) -> Result<Vec<Drive>, DriveRepositoryError> {
|
||||
// Joining `role_grants` → `storage.drives` returns every drive
|
||||
// the expanded subject set can read. ORDER BY puts default
|
||||
// drives first (so the picker UI doesn't need a follow-up
|
||||
// sort), then alphabetical by name. DISTINCT collapses the
|
||||
// case where a caller has multiple role_grants on the same
|
||||
// drive (e.g. direct + group-mediated); a GROUP BY on the
|
||||
// drive id sidesteps PostgreSQL's "ORDER BY expression must
|
||||
// appear in select list" rule that `SELECT DISTINCT` imposes.
|
||||
) -> 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.
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT d.id, d.name, d.kind, d.default_for_user,
|
||||
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
|
||||
d.created_at, d.updated_at,
|
||||
f.name AS root_folder_name
|
||||
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.name, d.kind, d.default_for_user,
|
||||
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
|
||||
d.created_at, d.updated_at, f.name
|
||||
ORDER BY (d.default_for_user IS NULL) ASC,
|
||||
LOWER(d.name) ASC
|
||||
LOWER(f.name) ASC
|
||||
"#,
|
||||
)
|
||||
.bind(
|
||||
@@ -168,6 +268,6 @@ impl DriveRepository for DrivePgRepository {
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?;
|
||||
|
||||
rows.iter().map(Self::row_to_drive).collect()
|
||||
rows.iter().map(Self::row_to_drive_with_name).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +236,11 @@ impl FolderRepository for FolderDbRepository {
|
||||
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
|
||||
}
|
||||
|
||||
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
storage_path: &StoragePath,
|
||||
user_id: Uuid,
|
||||
) -> Result<Folder, DomainError> {
|
||||
let path_str = storage_path.to_string();
|
||||
// Strip leading '/' if present — DB stores "Home - user/Docs", not "/Home - user/Docs"
|
||||
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
|
||||
@@ -245,6 +249,13 @@ impl FolderRepository for FolderDbRepository {
|
||||
return Err(DomainError::not_found("Folder", "empty path"));
|
||||
}
|
||||
|
||||
// Scoped by user_id: post-D0 the wrapper folder is named
|
||||
// "Personal" for every user, so `path = 'Personal'` matches
|
||||
// every user's root folder. Without the user_id filter, this
|
||||
// returns a non-deterministic row (whichever the planner emits
|
||||
// first) — which broke owner-short-circuit checks for the
|
||||
// caller whose folder wasn't returned. See bug-fix on rewind
|
||||
// commit.
|
||||
let row = sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
@@ -252,10 +263,11 @@ impl FolderRepository for FolderDbRepository {
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
FROM storage.folders
|
||||
WHERE path = $1 AND NOT is_trashed
|
||||
WHERE path = $1 AND user_id = $2 AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(lookup)
|
||||
.bind(user_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))?
|
||||
|
||||
Reference in New Issue
Block a user