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:
@@ -76,29 +76,39 @@ impl DriveKind {
|
||||
|
||||
/// Domain entity for a row in `storage.drives`.
|
||||
///
|
||||
/// Drives are pure metadata under the D0 design (docs/plan/drive.md §3):
|
||||
/// no `name` column — the display name lives on the root folder pointed
|
||||
/// at by `root_folder_id`. Code that needs the name pairs this struct
|
||||
/// with a JOIN through `storage.folders`; see the repository's
|
||||
/// `DriveWithRootName` view-model.
|
||||
///
|
||||
/// Field-level constraints are enforced at the SQL layer (CHECK on
|
||||
/// `kind`, partial UNIQUE on `default_for_user`). The struct mirrors
|
||||
/// the column set 1:1; behaviour beyond field access lives in
|
||||
/// `DriveRepository` (D0-5) and `DriveService` (post-D0).
|
||||
/// `DriveRepository` and `DriveService` (post-D0).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Drive {
|
||||
/// Stable identifier. Generated server-side at creation.
|
||||
pub id: Uuid,
|
||||
/// Display name. Renameable by owners; defaults to "Personal" for
|
||||
/// the user's default personal drive, or the original sibling-root
|
||||
/// folder name for secondaries promoted by the D0 backfill.
|
||||
pub name: String,
|
||||
/// Discriminant — see [`DriveKind`].
|
||||
pub kind: DriveKind,
|
||||
/// Set iff this is the user's default personal drive (UNIQUE in SQL
|
||||
/// via a partial index `WHERE default_for_user IS NOT NULL`). NULL
|
||||
/// on shared drives and on secondary personal drives.
|
||||
pub default_for_user: Option<Uuid>,
|
||||
/// The drive's mount-point folder. The column is NULLable in SQL
|
||||
/// only because the atomic creation CTE writes it mid-statement
|
||||
/// (a column-level `NOT NULL` would refuse the initial drive INSERT
|
||||
/// — see docs/plan/drive.md §3). After any successful creation path,
|
||||
/// this is populated; code reading `Drive` may treat it as `Uuid`,
|
||||
/// not `Option<Uuid>`. A NULL at read time is a data-invariant bug.
|
||||
pub root_folder_id: Uuid,
|
||||
/// Soft cap on this drive's storage usage, in bytes. `None` means
|
||||
/// "no quota" (rare; reserved for admin overrides). The default
|
||||
/// initial quota for a fresh personal drive is taken from the
|
||||
/// owner's `auth.users.storage_quota_bytes` at creation time (see
|
||||
/// Open Question 2 in `docs/plan/drive.md`).
|
||||
/// owner's `auth.users.storage_quota_bytes` at creation time.
|
||||
/// **Mutation is OxiCloud-admin only** (docs/plan/drive.md §7) —
|
||||
/// not in the drive `owner` role bundle.
|
||||
pub quota_bytes: Option<i64>,
|
||||
/// Running total of bytes consumed. Maintained incrementally by
|
||||
/// upload/delete paths in D4; on D0 still reflects the pre-Drive
|
||||
|
||||
@@ -37,52 +37,56 @@ pub enum DriveRepositoryError {
|
||||
StorageError(String),
|
||||
}
|
||||
|
||||
/// Input parameters for creating a new personal drive.
|
||||
/// A drive paired with the display name from its root folder.
|
||||
///
|
||||
/// Shared drives land in D3 with their own creation surface
|
||||
/// (`create_shared_drive`). For now D0 only mints personal drives —
|
||||
/// either as the default for a fresh user (via the lifecycle hook) or
|
||||
/// as a secondary promoted by the M2 backfill.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreatePersonalDriveInput {
|
||||
/// Display name. The lifecycle hook passes `"Personal"`; the M2
|
||||
/// backfill carries over the original sibling-root folder name for
|
||||
/// secondaries.
|
||||
pub name: String,
|
||||
/// The owner. For personal drives the owner is exactly one user.
|
||||
pub owner_id: Uuid,
|
||||
/// `true` when this is the user's default drive (sets the partial-
|
||||
/// unique `default_for_user` column). `false` for secondaries.
|
||||
pub is_default: bool,
|
||||
/// Initial storage quota in bytes. `None` defers to admin policy
|
||||
/// (typically copied from `auth.users.storage_quota_bytes` at the
|
||||
/// call site).
|
||||
pub quota_bytes: Option<i64>,
|
||||
/// `storage.drives` has no `name` column under the D0 design
|
||||
/// (docs/plan/drive.md §3) — the display name lives on
|
||||
/// `storage.folders.name` of the row pointed at by `drive.root_folder_id`.
|
||||
/// Read paths join the two tables and hand callers this view-model so the
|
||||
/// API surface can continue to expose a single "drive with name" shape
|
||||
/// without a follow-up query per drive.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DriveWithRootName {
|
||||
pub drive: Drive,
|
||||
/// The drive's display name. Sourced from `storage.folders.name`
|
||||
/// of the root folder via JOIN at read time.
|
||||
pub root_folder_name: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait DriveRepository: Send + Sync + 'static {
|
||||
/// Insert a personal drive row. The caller is responsible for
|
||||
/// inserting the matching owner row in `storage.role_grants` in the
|
||||
/// same transaction (the lifecycle hook handles this; M2's backfill
|
||||
/// did it directly in SQL).
|
||||
/// Atomically create a personal drive together with its root folder
|
||||
/// and the owner role_grant — all four DB writes in a single SQL
|
||||
/// statement (docs/plan/drive.md §3 "Atomic creation"). The
|
||||
/// statement runs as its own implicit transaction in autocommit mode
|
||||
/// so a server crash mid-statement leaves no half-row state.
|
||||
///
|
||||
/// Returns `DefaultDriveAlreadyExists` when `is_default=true` and the
|
||||
/// owner already has a default drive — relies on the partial UNIQUE
|
||||
/// index on `default_for_user`.
|
||||
async fn create_personal(
|
||||
/// The root folder is created with name `"Personal"` (the canonical
|
||||
/// default) and `parent_id IS NULL`. The drive's `root_folder_id`
|
||||
/// is wired to point at it before the statement commits.
|
||||
///
|
||||
/// Returns `DefaultDriveAlreadyExists` when the owner already has a
|
||||
/// default drive — relies on the partial UNIQUE index on
|
||||
/// `default_for_user`.
|
||||
async fn create_personal_drive_atomic(
|
||||
&self,
|
||||
input: CreatePersonalDriveInput,
|
||||
) -> Result<Drive, DriveRepositoryError>;
|
||||
owner_id: Uuid,
|
||||
quota_bytes: Option<i64>,
|
||||
) -> Result<DriveWithRootName, DriveRepositoryError>;
|
||||
|
||||
/// Fetch a drive by id. `NotFound` when no row matches.
|
||||
async fn get_by_id(&self, id: Uuid) -> Result<Drive, DriveRepositoryError>;
|
||||
/// Fetch a drive by id together with its display name. `NotFound`
|
||||
/// when no row matches.
|
||||
async fn get_by_id(&self, id: Uuid) -> Result<DriveWithRootName, DriveRepositoryError>;
|
||||
|
||||
/// Return the caller's default personal drive, or `NotFound` if they
|
||||
/// don't have one (e.g. external users; users created before the
|
||||
/// lifecycle hook fired). Drives the Photos timeline scope, the
|
||||
/// `/api/recent/*` scope, and D1's redirect-from-`/`.
|
||||
async fn find_default_for_user(&self, user_id: Uuid) -> Result<Drive, DriveRepositoryError>;
|
||||
/// Return the caller's default personal drive paired with its
|
||||
/// display name, or `NotFound` if they don't have one (e.g.
|
||||
/// external users; users created before the lifecycle hook fired).
|
||||
/// Drives the Photos timeline scope, the `/api/recent/*` scope, and
|
||||
/// D1's redirect-from-`/`.
|
||||
async fn find_default_for_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<DriveWithRootName, DriveRepositoryError>;
|
||||
|
||||
/// List drives the caller can read, resolved via `role_grants` for
|
||||
/// `resource_type='drive'`. The caller's group memberships are
|
||||
@@ -90,13 +94,13 @@ pub trait DriveRepository: Send + Sync + 'static {
|
||||
/// is what this method's `subject_ids` argument carries.
|
||||
///
|
||||
/// Returns rows in a stable order: default drive first (if any),
|
||||
/// then by name. The `/api/drives` handler relies on that order for
|
||||
/// the picker UI without a follow-up sort.
|
||||
/// then by display name. The `/api/drives` handler relies on that
|
||||
/// order for the picker UI without a follow-up sort.
|
||||
async fn list_for_subjects(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
) -> Result<Vec<Drive>, DriveRepositoryError>;
|
||||
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
|
||||
}
|
||||
|
||||
/// Convenience: convert the canonical kind discriminator from its SQL
|
||||
|
||||
@@ -28,8 +28,18 @@ pub trait FolderRepository: Send + Sync + 'static {
|
||||
/// Gets a folder by its ID
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
|
||||
|
||||
/// Gets a folder by its storage path
|
||||
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError>;
|
||||
/// Gets a folder by its storage path within the caller's tree.
|
||||
///
|
||||
/// Post-D0, `storage.folders.path` is no longer globally unique —
|
||||
/// multiple users share root-folder names like `"Personal"`. The
|
||||
/// `user_id` filter scopes the lookup to the caller's own folders
|
||||
/// (the equivalent of the pre-D0 implicit user-namespacing that
|
||||
/// came from `My Folder - <username>` paths).
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
storage_path: &StoragePath,
|
||||
user_id: Uuid,
|
||||
) -> Result<Folder, DomainError>;
|
||||
|
||||
/// Lists folders within a parent folder
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError>;
|
||||
|
||||
Reference in New Issue
Block a user