feat(drive): start implementation of drive
- add storage.drives
- prepare migration phase
- add created_by and updated_by on storage.folders
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
//! Drive — the top-level container that owns a tree of folders/files.
|
||||
//!
|
||||
//! Drives replaced the per-user `My Folder - <username>` wrapper at D0.
|
||||
//! Every folder and file row carries a `drive_id` (added by D0's
|
||||
//! migration); a drive is the natural unit of quota, sharing, and
|
||||
//! lifecycle. Membership is expressed through `storage.role_grants` rows
|
||||
//! with `resource_type='drive'` — there is no separate `drive_members`
|
||||
//! table.
|
||||
//!
|
||||
//! ## Kinds
|
||||
//!
|
||||
//! Two kinds today; the discriminant is the `kind` column with a CHECK
|
||||
//! constraint.
|
||||
//!
|
||||
//! - **`personal`** — single-user, single-owner. The owner is captured
|
||||
//! by `default_for_user` (for the default Personal drive) or by an
|
||||
//! Owner role_grant on a secondary personal drive. Personal drives
|
||||
//! refuse `add_member`, `remove_member`, and `delete_drive` (when
|
||||
//! it's the user's only or default drive). A user can have multiple
|
||||
//! personal drives — one is marked default (`default_for_user =
|
||||
//! <uid>`), the others are secondaries (`default_for_user = NULL`,
|
||||
//! one Owner row in role_grants pinning them to the same user).
|
||||
//!
|
||||
//! - **`shared`** — multi-member, group-aware, full role roster
|
||||
//! (viewer / commenter / contributor / editor / owner). Members
|
||||
//! come from role_grants; group subjects expand transitively via
|
||||
//! the existing `subject_groups` machinery. Last-owner protection
|
||||
//! applies on member removal and drive deletion. Quota is set by
|
||||
//! the drive owner (or admin); `used_bytes` tracks consumption.
|
||||
//!
|
||||
//! Future kinds (e.g. `system` for built-in scratch space) drop in by
|
||||
//! extending the CHECK + the `DriveKind` enum.
|
||||
//!
|
||||
//! ## Policies
|
||||
//!
|
||||
//! `policies` is a JSONB bag carrying feature flags / capability toggles
|
||||
//! that drive owners can flip without a schema change. Known keys live in
|
||||
//! `docs/plan/drive.md` §8 and §15 (e.g. `forbid_public_links`,
|
||||
//! `include_in_photo_index`, `forbid_music_index`). Unknown keys are
|
||||
//! preserved by the application — the schema is intentionally permissive
|
||||
//! so future capability flags can land without a migration.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Drive kind discriminant. Mirrors the `storage.drives.kind` CHECK
|
||||
/// constraint values.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DriveKind {
|
||||
/// Single-owner storage compartment. Cannot have members added or
|
||||
/// removed via the membership API; the owner is fixed for the drive's
|
||||
/// lifetime.
|
||||
Personal,
|
||||
/// Multi-member drive supporting the full role roster. Membership is
|
||||
/// open to admin/owner-driven changes through the membership API.
|
||||
Shared,
|
||||
}
|
||||
|
||||
impl DriveKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
DriveKind::Personal => "personal",
|
||||
DriveKind::Shared => "shared",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"personal" => Some(DriveKind::Personal),
|
||||
"shared" => Some(DriveKind::Shared),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Domain entity for a row in `storage.drives`.
|
||||
///
|
||||
/// 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).
|
||||
#[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>,
|
||||
/// 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`).
|
||||
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
|
||||
/// per-user counters via the backfill.
|
||||
pub used_bytes: i64,
|
||||
/// Capability flags / feature toggles. Extensible JSONB — see
|
||||
/// `docs/plan/drive.md` §8 and §15 for the known keys.
|
||||
pub policies: serde_json::Value,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl Drive {
|
||||
/// `true` for the user's default personal drive (the only drive for
|
||||
/// which `default_for_user` is set to that user's id).
|
||||
pub fn is_default_for(&self, user_id: Uuid) -> bool {
|
||||
self.default_for_user == Some(user_id)
|
||||
}
|
||||
|
||||
/// `true` if this drive is a personal drive of any kind (default or
|
||||
/// secondary). Encapsulates the kind check at the call site.
|
||||
pub fn is_personal(&self) -> bool {
|
||||
matches!(self.kind, DriveKind::Personal)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub mod calendar;
|
||||
pub mod calendar_event;
|
||||
pub mod contact;
|
||||
pub mod device_code;
|
||||
pub mod drive;
|
||||
pub mod entity_errors;
|
||||
pub mod face;
|
||||
pub mod file;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Repository for [`Drive`] entities backed by `storage.drives`.
|
||||
//!
|
||||
//! Drives have no separate membership table — owner/editor/viewer
|
||||
//! membership lives in `storage.role_grants` with
|
||||
//! `resource_type='drive'`. That means **listing the drives a user can
|
||||
//! reach goes through the role-grant query, not through this
|
||||
//! repository**. This repo handles:
|
||||
//!
|
||||
//! * Creating a drive (used by the user-creation lifecycle hook and
|
||||
//! by D3's shared-drive flow).
|
||||
//! * Looking up a single drive by id (used by the engine's owner_of /
|
||||
//! check paths, by `/api/drives/{id}`, and by the drive picker).
|
||||
//! * Finding the caller's default drive (used by the Photos / Music
|
||||
//! endpoints and by D1's redirect-from-`/` logic).
|
||||
//!
|
||||
//! Membership-flavoured queries (e.g. "list every drive user X can
|
||||
//! read") live in `DriveListingService` (post-D0) which reads
|
||||
//! `role_grants` and resolves the matching drive rows here.
|
||||
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::entities::drive::{Drive, DriveKind};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DriveRepositoryError {
|
||||
#[error("Drive not found: {0}")]
|
||||
NotFound(String),
|
||||
/// A user already has a default drive set — partial unique index on
|
||||
/// `default_for_user` rejects a second one. Surfaces the constraint
|
||||
/// explicitly so the lifecycle hook can no-op idempotently.
|
||||
#[error("User already has a default drive: {0}")]
|
||||
DefaultDriveAlreadyExists(String),
|
||||
#[error("Invalid drive kind: {0}")]
|
||||
InvalidKind(String),
|
||||
#[error("Storage error: {0}")]
|
||||
StorageError(String),
|
||||
}
|
||||
|
||||
/// Input parameters for creating a new personal drive.
|
||||
///
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
#[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).
|
||||
///
|
||||
/// 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(
|
||||
&self,
|
||||
input: CreatePersonalDriveInput,
|
||||
) -> Result<Drive, DriveRepositoryError>;
|
||||
|
||||
/// Fetch a drive by id. `NotFound` when no row matches.
|
||||
async fn get_by_id(&self, id: Uuid) -> Result<Drive, 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>;
|
||||
|
||||
/// List drives the caller can read, resolved via `role_grants` for
|
||||
/// `resource_type='drive'`. The caller's group memberships are
|
||||
/// expanded by the engine's `subject_match_set`; that expanded set
|
||||
/// 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.
|
||||
async fn list_for_subjects(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
) -> Result<Vec<Drive>, DriveRepositoryError>;
|
||||
}
|
||||
|
||||
/// Convenience: convert the canonical kind discriminator from its SQL
|
||||
/// form into the typed enum. Mirrored on the entity for symmetry.
|
||||
impl DriveKind {
|
||||
pub fn from_sql(s: &str) -> Result<Self, DriveRepositoryError> {
|
||||
DriveKind::parse(s).ok_or_else(|| DriveRepositoryError::InvalidKind(s.to_owned()))
|
||||
}
|
||||
}
|
||||
@@ -98,9 +98,17 @@ pub trait FolderRepository: Send + Sync + 'static {
|
||||
/// Permanently deletes a folder (used by the trash)
|
||||
async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Creates a root-level home folder for a user.
|
||||
/// This is used during user registration to create the user's personal folder.
|
||||
async fn create_home_folder(&self, user_id: Uuid, name: String) -> Result<Folder, DomainError>;
|
||||
/// Creates a root-level home folder for a user inside their personal drive.
|
||||
/// Called during user registration / first login to maintain the wrapper-
|
||||
/// folder convention through the D0 dual-write window (the wrapper itself
|
||||
/// retires in a follow-up migration; for now it stays as a real folder
|
||||
/// row stamped with `drive_id`).
|
||||
async fn create_home_folder(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
name: String,
|
||||
) -> Result<Folder, DomainError>;
|
||||
|
||||
/// Lists every folder in a subtree rooted at `folder_id` (inclusive).
|
||||
///
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod address_book_repository;
|
||||
pub mod calendar_event_repository;
|
||||
pub mod calendar_repository;
|
||||
pub mod contact_repository;
|
||||
pub mod drive_repository;
|
||||
pub mod file_repository;
|
||||
pub mod folder_repository;
|
||||
pub mod magic_link_token_repository;
|
||||
|
||||
@@ -74,6 +74,10 @@ impl fmt::Display for Subject {
|
||||
pub enum Resource {
|
||||
Folder(Uuid),
|
||||
File(Uuid),
|
||||
/// A drive — root scope for a tree of folders/files plus its own
|
||||
/// membership and policy bag. Added in D0; membership lives in
|
||||
/// `storage.role_grants` (no separate `drive_members` table).
|
||||
Drive(Uuid),
|
||||
// Reserved for future use:
|
||||
// Calendar(Uuid),
|
||||
// Reserved for future use:
|
||||
@@ -87,6 +91,7 @@ impl Resource {
|
||||
match self {
|
||||
Resource::Folder(_) => "folder",
|
||||
Resource::File(_) => "file",
|
||||
Resource::Drive(_) => "drive",
|
||||
//Resource::Calendar(_) => "calendar",
|
||||
//Resource::AddressBook(_) => "adressbook",
|
||||
//Resource::Playlist(_) => "playlist",
|
||||
@@ -95,12 +100,10 @@ impl Resource {
|
||||
|
||||
pub fn id(&self) -> Uuid {
|
||||
match self {
|
||||
Resource::Folder(id)
|
||||
| Resource::File(id)
|
||||
Resource::Folder(id) | Resource::File(id) | Resource::Drive(id) => *id,
|
||||
//| Resource::Calendar(id)
|
||||
//| Resource::AddressBook(id)
|
||||
//| Resource::Playlist(id)
|
||||
=> *id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +111,7 @@ impl Resource {
|
||||
match resource_type {
|
||||
"folder" => Some(Resource::Folder(id)),
|
||||
"file" => Some(Resource::File(id)),
|
||||
"drive" => Some(Resource::Drive(id)),
|
||||
//"calendar" => Some(Resource::Calendar(id)),
|
||||
//"adressbook" => Some(Resource::AddressBook(id)),
|
||||
//"playlist" => Some(Resource::Playlist(id)),
|
||||
@@ -351,6 +355,7 @@ impl Grant {
|
||||
pub enum ResourceKind {
|
||||
File,
|
||||
Folder,
|
||||
Drive,
|
||||
// Future: Calendar, AddressBook, Playlist, …
|
||||
}
|
||||
|
||||
@@ -359,6 +364,7 @@ impl ResourceKind {
|
||||
match self {
|
||||
ResourceKind::File => "file",
|
||||
ResourceKind::Folder => "folder",
|
||||
ResourceKind::Drive => "drive",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +372,7 @@ impl ResourceKind {
|
||||
match s {
|
||||
"file" => Some(ResourceKind::File),
|
||||
"folder" => Some(ResourceKind::Folder),
|
||||
"drive" => Some(ResourceKind::Drive),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user