domain: add Storage & Sources entities (StorageVolume, LibraryPath, IngestSession, Quota)
This commit is contained in:
73
crates/domain/src/entities/ingest_session.rs
Normal file
73
crates/domain/src/entities/ingest_session.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use crate::errors::DomainError;
|
||||||
|
use crate::value_objects::{Checksum, DateTimeStamp, SystemId};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum IngestStatus {
|
||||||
|
Uploading,
|
||||||
|
AwaitingProcessing,
|
||||||
|
Processing,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct IngestSession {
|
||||||
|
pub session_id: SystemId,
|
||||||
|
pub uploader_user_id: SystemId,
|
||||||
|
pub client_device_id: String,
|
||||||
|
pub original_filename: String,
|
||||||
|
pub client_checksum: Checksum,
|
||||||
|
pub target_library_path_id: SystemId,
|
||||||
|
pub status: IngestStatus,
|
||||||
|
pub created_at: DateTimeStamp,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IngestSession {
|
||||||
|
pub fn new(
|
||||||
|
uploader: SystemId,
|
||||||
|
device_id: impl Into<String>,
|
||||||
|
filename: impl Into<String>,
|
||||||
|
checksum: Checksum,
|
||||||
|
target_path: SystemId,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
session_id: SystemId::new(),
|
||||||
|
uploader_user_id: uploader,
|
||||||
|
client_device_id: device_id.into(),
|
||||||
|
original_filename: filename.into(),
|
||||||
|
client_checksum: checksum,
|
||||||
|
target_library_path_id: target_path,
|
||||||
|
status: IngestStatus::Uploading,
|
||||||
|
created_at: DateTimeStamp::now(),
|
||||||
|
error_message: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn advance_to(&mut self, status: IngestStatus) -> Result<(), DomainError> {
|
||||||
|
let valid = matches!(
|
||||||
|
(self.status, status),
|
||||||
|
(IngestStatus::Uploading, IngestStatus::AwaitingProcessing)
|
||||||
|
| (IngestStatus::AwaitingProcessing, IngestStatus::Processing)
|
||||||
|
| (IngestStatus::Processing, IngestStatus::Completed)
|
||||||
|
) || (status == IngestStatus::Failed && !self.is_terminal());
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return Err(DomainError::Validation(format!(
|
||||||
|
"Invalid transition from {:?} to {:?}",
|
||||||
|
self.status, status
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.status = status;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fail(&mut self, message: impl Into<String>) {
|
||||||
|
self.status = IngestStatus::Failed;
|
||||||
|
self.error_message = Some(message.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_terminal(&self) -> bool {
|
||||||
|
matches!(self.status, IngestStatus::Completed | IngestStatus::Failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
crates/domain/src/entities/library_path.rs
Normal file
47
crates/domain/src/entities/library_path.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use crate::value_objects::SystemId;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum OwnershipPolicy {
|
||||||
|
UserOwned,
|
||||||
|
GroupOwned,
|
||||||
|
Unassigned,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct LibraryPath {
|
||||||
|
pub path_id: SystemId,
|
||||||
|
pub volume_id: SystemId,
|
||||||
|
pub relative_path: String,
|
||||||
|
pub is_ingest_destination: bool,
|
||||||
|
pub ownership_policy: OwnershipPolicy,
|
||||||
|
pub designated_owner_id: Option<SystemId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibraryPath {
|
||||||
|
pub fn new_user_owned(
|
||||||
|
volume_id: SystemId,
|
||||||
|
relative_path: impl Into<String>,
|
||||||
|
owner_id: SystemId,
|
||||||
|
is_ingest_destination: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
path_id: SystemId::new(),
|
||||||
|
volume_id,
|
||||||
|
relative_path: relative_path.into(),
|
||||||
|
is_ingest_destination,
|
||||||
|
ownership_policy: OwnershipPolicy::UserOwned,
|
||||||
|
designated_owner_id: Some(owner_id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_unassigned(volume_id: SystemId, relative_path: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
path_id: SystemId::new(),
|
||||||
|
volume_id,
|
||||||
|
relative_path: relative_path.into(),
|
||||||
|
is_ingest_destination: false,
|
||||||
|
ownership_policy: OwnershipPolicy::Unassigned,
|
||||||
|
designated_owner_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,16 @@ pub mod permission;
|
|||||||
pub mod role;
|
pub mod role;
|
||||||
mod user;
|
mod user;
|
||||||
mod group;
|
mod group;
|
||||||
|
mod storage_volume;
|
||||||
|
mod library_path;
|
||||||
|
mod ingest_session;
|
||||||
|
mod quota;
|
||||||
|
|
||||||
pub use permission::{Permission, PermissionAction, ResourceType};
|
pub use permission::{Permission, PermissionAction, ResourceType};
|
||||||
pub use role::Role;
|
pub use role::Role;
|
||||||
pub use user::User;
|
pub use user::User;
|
||||||
pub use group::Group;
|
pub use group::Group;
|
||||||
|
pub use storage_volume::StorageVolume;
|
||||||
|
pub use library_path::{LibraryPath, OwnershipPolicy};
|
||||||
|
pub use ingest_session::{IngestSession, IngestStatus};
|
||||||
|
pub use quota::{QuotaDefinition, QuotaRule, TimePeriod, UsageLedgerEntry, UsageType};
|
||||||
|
|||||||
92
crates/domain/src/entities/quota.rs
Normal file
92
crates/domain/src/entities/quota.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
use crate::value_objects::{DateTimeStamp, SystemId};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum UsageType {
|
||||||
|
StorageBytes,
|
||||||
|
ProcessJobs,
|
||||||
|
ApiCalls,
|
||||||
|
IndexingSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum TimePeriod {
|
||||||
|
Daily,
|
||||||
|
Monthly,
|
||||||
|
Lifetime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct QuotaRule {
|
||||||
|
pub rule_id: SystemId,
|
||||||
|
pub dimension: UsageType,
|
||||||
|
pub limit_value: u64,
|
||||||
|
pub time_period: TimePeriod,
|
||||||
|
pub is_unlimited: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct QuotaDefinition {
|
||||||
|
pub quota_id: SystemId,
|
||||||
|
pub owner_scope: SystemId,
|
||||||
|
pub is_enforced: bool,
|
||||||
|
pub rules: Vec<QuotaRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuotaDefinition {
|
||||||
|
pub fn new(owner_scope: SystemId) -> Self {
|
||||||
|
Self {
|
||||||
|
quota_id: SystemId::new(),
|
||||||
|
owner_scope,
|
||||||
|
is_enforced: true,
|
||||||
|
rules: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_rule(&mut self, dimension: UsageType, limit_value: u64, time_period: TimePeriod) {
|
||||||
|
self.rules.push(QuotaRule {
|
||||||
|
rule_id: SystemId::new(),
|
||||||
|
dimension,
|
||||||
|
limit_value,
|
||||||
|
time_period,
|
||||||
|
is_unlimited: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_unlimited_rule(&mut self, dimension: UsageType) {
|
||||||
|
self.rules.push(QuotaRule {
|
||||||
|
rule_id: SystemId::new(),
|
||||||
|
dimension,
|
||||||
|
limit_value: 0,
|
||||||
|
time_period: TimePeriod::Lifetime,
|
||||||
|
is_unlimited: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct UsageLedgerEntry {
|
||||||
|
pub entry_id: SystemId,
|
||||||
|
pub user_id: SystemId,
|
||||||
|
pub usage_type: UsageType,
|
||||||
|
pub consumed_amount: u64,
|
||||||
|
pub timestamp: DateTimeStamp,
|
||||||
|
pub context: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsageLedgerEntry {
|
||||||
|
pub fn new(
|
||||||
|
user_id: SystemId,
|
||||||
|
usage_type: UsageType,
|
||||||
|
amount: u64,
|
||||||
|
context: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
entry_id: SystemId::new(),
|
||||||
|
user_id,
|
||||||
|
usage_type,
|
||||||
|
consumed_amount: amount,
|
||||||
|
timestamp: DateTimeStamp::now(),
|
||||||
|
context: context.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
crates/domain/src/entities/storage_volume.rs
Normal file
22
crates/domain/src/entities/storage_volume.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use crate::value_objects::SystemId;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct StorageVolume {
|
||||||
|
pub volume_id: SystemId,
|
||||||
|
pub volume_name: String,
|
||||||
|
pub uri_prefix: String,
|
||||||
|
pub is_writable: bool,
|
||||||
|
pub available_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StorageVolume {
|
||||||
|
pub fn new(name: impl Into<String>, uri_prefix: impl Into<String>, is_writable: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
volume_id: SystemId::new(),
|
||||||
|
volume_name: name.into(),
|
||||||
|
uri_prefix: uri_prefix.into(),
|
||||||
|
is_writable,
|
||||||
|
available_bytes: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
crates/domain/tests/entities/ingest_session.rs
Normal file
49
crates/domain/tests/entities/ingest_session.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
use domain::entities::{IngestSession, IngestStatus};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::value_objects::{Checksum, SystemId};
|
||||||
|
|
||||||
|
fn make_session() -> IngestSession {
|
||||||
|
let checksum = Checksum::new("a".repeat(64)).unwrap();
|
||||||
|
IngestSession::new(
|
||||||
|
SystemId::new(),
|
||||||
|
"device-1",
|
||||||
|
"photo.jpg",
|
||||||
|
checksum,
|
||||||
|
SystemId::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_state_transitions() {
|
||||||
|
let mut s = make_session();
|
||||||
|
assert_eq!(s.status, IngestStatus::Uploading);
|
||||||
|
s.advance_to(IngestStatus::AwaitingProcessing).unwrap();
|
||||||
|
assert_eq!(s.status, IngestStatus::AwaitingProcessing);
|
||||||
|
s.advance_to(IngestStatus::Processing).unwrap();
|
||||||
|
assert_eq!(s.status, IngestStatus::Processing);
|
||||||
|
s.advance_to(IngestStatus::Completed).unwrap();
|
||||||
|
assert_eq!(s.status, IngestStatus::Completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_transition_rejected() {
|
||||||
|
let mut s = make_session();
|
||||||
|
let result = s.advance_to(IngestStatus::Completed);
|
||||||
|
assert!(matches!(result, Err(DomainError::Validation(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn can_fail_from_any_non_terminal() {
|
||||||
|
for target in [IngestStatus::Uploading, IngestStatus::AwaitingProcessing, IngestStatus::Processing] {
|
||||||
|
let mut s = make_session();
|
||||||
|
// advance to target state
|
||||||
|
if target == IngestStatus::AwaitingProcessing || target == IngestStatus::Processing {
|
||||||
|
s.advance_to(IngestStatus::AwaitingProcessing).unwrap();
|
||||||
|
}
|
||||||
|
if target == IngestStatus::Processing {
|
||||||
|
s.advance_to(IngestStatus::Processing).unwrap();
|
||||||
|
}
|
||||||
|
s.advance_to(IngestStatus::Failed).unwrap();
|
||||||
|
assert_eq!(s.status, IngestStatus::Failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/domain/tests/entities/library_path.rs
Normal file
13
crates/domain/tests/entities/library_path.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use domain::entities::{LibraryPath, OwnershipPolicy};
|
||||||
|
use domain::value_objects::SystemId;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_owned_path() {
|
||||||
|
let vol = SystemId::new();
|
||||||
|
let owner = SystemId::new();
|
||||||
|
let lp = LibraryPath::new_user_owned(vol, "/photos", owner, true);
|
||||||
|
assert_eq!(lp.ownership_policy, OwnershipPolicy::UserOwned);
|
||||||
|
assert_eq!(lp.designated_owner_id, Some(owner));
|
||||||
|
assert!(lp.is_ingest_destination);
|
||||||
|
assert_eq!(lp.volume_id, vol);
|
||||||
|
}
|
||||||
@@ -2,3 +2,7 @@ mod group;
|
|||||||
mod permission;
|
mod permission;
|
||||||
mod role;
|
mod role;
|
||||||
mod user;
|
mod user;
|
||||||
|
mod storage_volume;
|
||||||
|
mod library_path;
|
||||||
|
mod ingest_session;
|
||||||
|
mod quota;
|
||||||
|
|||||||
17
crates/domain/tests/entities/quota.rs
Normal file
17
crates/domain/tests/entities/quota.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use domain::entities::{QuotaDefinition, TimePeriod, UsageType};
|
||||||
|
use domain::value_objects::SystemId;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quota_with_rules() {
|
||||||
|
let mut q = QuotaDefinition::new(SystemId::new());
|
||||||
|
assert!(q.is_enforced);
|
||||||
|
assert!(q.rules.is_empty());
|
||||||
|
|
||||||
|
q.add_rule(UsageType::StorageBytes, 1_000_000, TimePeriod::Monthly);
|
||||||
|
q.add_unlimited_rule(UsageType::ApiCalls);
|
||||||
|
|
||||||
|
assert_eq!(q.rules.len(), 2);
|
||||||
|
assert!(!q.rules[0].is_unlimited);
|
||||||
|
assert_eq!(q.rules[0].limit_value, 1_000_000);
|
||||||
|
assert!(q.rules[1].is_unlimited);
|
||||||
|
}
|
||||||
10
crates/domain/tests/entities/storage_volume.rs
Normal file
10
crates/domain/tests/entities/storage_volume.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use domain::entities::StorageVolume;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creates_read_only_volume() {
|
||||||
|
let vol = StorageVolume::new("archive", "s3://bucket/", false);
|
||||||
|
assert_eq!(vol.volume_name, "archive");
|
||||||
|
assert_eq!(vol.uri_prefix, "s3://bucket/");
|
||||||
|
assert!(!vol.is_writable);
|
||||||
|
assert_eq!(vol.available_bytes, 0);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user