mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-24 16:32:45 -04:00
Merge pull request #2956 from spacedriveapp/entry-to-volume-relation
Refactor: Replace device_id with volume_id in entries and locations
This commit is contained in:
21
.prettierrc
Normal file
21
.prettierrc
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf",
|
||||
"trailingComma": "none",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"bracketSpacing": false,
|
||||
"plugins": [
|
||||
"@ianvs/prettier-plugin-sort-imports",
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs"],
|
||||
"options": {
|
||||
"tabWidth": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -106,5 +106,9 @@
|
||||
"i18n-ally.keystyle": "flat",
|
||||
// You need to add this to your locale settings file "i18n-ally.translate.google.apiKey": "xxx"
|
||||
"i18n-ally.translate.engines": ["google"],
|
||||
"evenBetterToml.taplo.configFile.path": ".taplo.toml"
|
||||
}
|
||||
"evenBetterToml.taplo.configFile.path": ".taplo.toml",
|
||||
"files.insertFinalNewline": false,
|
||||
"files.trimFinalNewlines": true,
|
||||
"editor.formatOnSave": false,
|
||||
"editor.formatOnSaveMode": "file"
|
||||
}
|
||||
@@ -4,7 +4,7 @@ version = "2.0.0-pre.1"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = ["heif", "ffmpeg"]
|
||||
default = []
|
||||
heif = ["sd-core/heif"]
|
||||
ffmpeg = ["sd-core/ffmpeg"]
|
||||
|
||||
@@ -43,4 +43,4 @@ tempfile = "3"
|
||||
|
||||
[[bin]]
|
||||
name = "sd-server"
|
||||
path = "src/main.rs"
|
||||
path = "src/main.rs"
|
||||
@@ -27,7 +27,7 @@ pub struct Model {
|
||||
pub permissions: Option<String>, // Unix permissions as string
|
||||
pub inode: Option<i64>, // Platform-specific file identifier for change detection
|
||||
pub parent_id: Option<i32>, // Reference to parent entry for hierarchical relationships
|
||||
pub device_id: Option<i32>, // Device that owns this entry (denormalized for efficient queries)
|
||||
pub volume_id: Option<i32>, // Volume this entry is on (ownership inherited from volume's device)
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
@@ -47,11 +47,11 @@ pub enum Relation {
|
||||
#[sea_orm(belongs_to = "Entity", from = "Column::ParentId", to = "Column::Id")]
|
||||
Parent,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::device::Entity",
|
||||
from = "Column::DeviceId",
|
||||
to = "super::device::Column::Id"
|
||||
belongs_to = "super::volume::Entity",
|
||||
from = "Column::VolumeId",
|
||||
to = "super::volume::Column::Id"
|
||||
)]
|
||||
Device,
|
||||
Volume,
|
||||
}
|
||||
|
||||
impl Related<super::user_metadata::Entity> for Entity {
|
||||
@@ -66,9 +66,9 @@ impl Related<super::content_identity::Entity> for Entity {
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::device::Entity> for Entity {
|
||||
impl Related<super::volume::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Device.def()
|
||||
Relation::Volume.def()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,17 +92,17 @@ impl crate::infra::sync::Syncable for Model {
|
||||
}
|
||||
|
||||
fn sync_depends_on() -> &'static [&'static str] {
|
||||
// Entries depend on device, content_identity, and user_metadata to ensure FK references
|
||||
// Entries depend on volume, content_identity, and user_metadata to ensure FK references
|
||||
// exist before entries arrive. parent_id is self-reference (handled via closure rebuild).
|
||||
// This prevents entries arriving with FK references that don't exist yet.
|
||||
&["device", "content_identity", "user_metadata"]
|
||||
&["volume", "content_identity", "user_metadata"]
|
||||
}
|
||||
|
||||
fn foreign_key_mappings() -> Vec<crate::infra::sync::FKMapping> {
|
||||
// All FKs use dependency tracking - NEVER set to NULL on missing reference
|
||||
// Source data with NULL values is handled correctly (null UUID → null FK)
|
||||
vec![
|
||||
crate::infra::sync::FKMapping::new("device_id", "devices"),
|
||||
crate::infra::sync::FKMapping::new("volume_id", "volumes"),
|
||||
crate::infra::sync::FKMapping::new("parent_id", "entries"),
|
||||
crate::infra::sync::FKMapping::new("metadata_id", "user_metadata"),
|
||||
crate::infra::sync::FKMapping::new("content_id", "content_identities"),
|
||||
@@ -204,40 +204,32 @@ impl crate::infra::sync::Syncable for Model {
|
||||
let mut query = Entity::find();
|
||||
|
||||
// Filter by device ownership if specified (critical for device-owned data sync)
|
||||
// Entries now have device_id directly - use that instead of entry_closure during backfill
|
||||
// to avoid circular dependency (entry_closure is rebuilt AFTER backfill)
|
||||
// Entries reference volumes, which reference devices - join through the chain
|
||||
if let Some(owner_device_uuid) = device_id {
|
||||
// Get device's internal ID
|
||||
let device = super::device::Entity::find()
|
||||
.filter(super::device::Column::Uuid.eq(owner_device_uuid))
|
||||
.one(db)
|
||||
.await?;
|
||||
tracing::debug!(
|
||||
device_uuid = %owner_device_uuid,
|
||||
"Filtering entries by volume ownership"
|
||||
);
|
||||
|
||||
if let Some(dev) = device {
|
||||
tracing::debug!(
|
||||
device_uuid = %owner_device_uuid,
|
||||
device_id = dev.id,
|
||||
"Filtering entries by device_id"
|
||||
);
|
||||
// Join through volume to filter by device ownership
|
||||
// This allows volume ownership changes to automatically transfer all entries
|
||||
use sea_orm::JoinType;
|
||||
query = query
|
||||
.join(JoinType::InnerJoin, super::entry::Relation::Volume.def())
|
||||
.filter(super::volume::Column::DeviceId.eq(owner_device_uuid));
|
||||
|
||||
// Check how many entries have this device_id for debugging
|
||||
let count_with_device = Entity::find()
|
||||
.filter(Column::DeviceId.eq(dev.id))
|
||||
.count(db)
|
||||
.await?;
|
||||
// Check how many entries match for debugging
|
||||
let count_with_device = query.clone().count(db).await?;
|
||||
|
||||
tracing::debug!(
|
||||
entries_with_device_id = count_with_device,
|
||||
"Entries matching device_id before other filters"
|
||||
);
|
||||
tracing::debug!(
|
||||
entries_with_device = count_with_device,
|
||||
"Entries matching device ownership through volume"
|
||||
);
|
||||
|
||||
// Filter by device_id directly (recently added to entry table)
|
||||
// This avoids the circular dependency with entry_closure table
|
||||
query = query.filter(Column::DeviceId.eq(dev.id));
|
||||
} else {
|
||||
if count_with_device == 0 {
|
||||
tracing::warn!(
|
||||
device_uuid = %owner_device_uuid,
|
||||
"Device not found in database, returning empty"
|
||||
"No entries found for device, returning empty"
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -496,7 +488,7 @@ impl Model {
|
||||
serde_json::from_value(get_field("permissions")?).unwrap();
|
||||
let inode: Option<i64> = serde_json::from_value(get_field("inode")?).unwrap();
|
||||
let parent_id: Option<i32> = serde_json::from_value(get_field("parent_id")?).unwrap();
|
||||
let device_id: Option<i32> = serde_json::from_value(get_field("device_id")?).unwrap();
|
||||
let volume_id: Option<i32> = serde_json::from_value(get_field("volume_id")?).unwrap();
|
||||
|
||||
// Check if parent is tombstoned (prevents orphaned children)
|
||||
if let Some(parent) = parent_id {
|
||||
@@ -538,7 +530,7 @@ impl Model {
|
||||
permissions: Set(permissions.clone()),
|
||||
inode: Set(inode),
|
||||
parent_id: Set(parent_id),
|
||||
device_id: Set(device_id),
|
||||
volume_id: Set(volume_id),
|
||||
};
|
||||
active.update(db).await?;
|
||||
existing_entry.id
|
||||
@@ -563,7 +555,7 @@ impl Model {
|
||||
permissions: Set(permissions.clone()),
|
||||
inode: Set(inode),
|
||||
parent_id: Set(parent_id),
|
||||
device_id: Set(device_id),
|
||||
volume_id: Set(volume_id),
|
||||
};
|
||||
let inserted = active.insert(db).await?;
|
||||
inserted.id
|
||||
|
||||
@@ -11,7 +11,8 @@ pub struct Model {
|
||||
pub id: i32,
|
||||
pub uuid: Uuid,
|
||||
pub device_id: i32,
|
||||
pub entry_id: Option<i32>, // Nullable to handle circular FK with entries during sync
|
||||
pub volume_id: Option<i32>, // Resolved lazily; NULL for locations created before this field
|
||||
pub entry_id: Option<i32>, // Nullable to handle circular FK with entries during sync
|
||||
pub name: Option<String>,
|
||||
pub index_mode: String, // "shallow", "content", "deep"
|
||||
pub scan_state: String, // "pending", "scanning", "completed", "error"
|
||||
@@ -32,6 +33,12 @@ pub enum Relation {
|
||||
to = "super::device::Column::Id"
|
||||
)]
|
||||
Device,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::volume::Entity",
|
||||
from = "Column::VolumeId",
|
||||
to = "super::volume::Column::Id"
|
||||
)]
|
||||
Volume,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::entry::Entity",
|
||||
from = "Column::EntryId",
|
||||
@@ -46,6 +53,12 @@ impl Related<super::device::Entity> for Entity {
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::volume::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Volume.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::entry::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Entry.def()
|
||||
@@ -94,11 +107,12 @@ impl Syncable for Model {
|
||||
}
|
||||
|
||||
fn foreign_key_mappings() -> Vec<crate::infra::sync::FKMapping> {
|
||||
// entry_id may be NULL in source (location created before root entry indexed)
|
||||
// If source has entry_uuid=null → FK is null (handled by FK mapper)
|
||||
// If source has entry_uuid=xxx but missing → fail for dependency tracking
|
||||
// entry_id and volume_id may be NULL in source
|
||||
// If source has UUID=null → FK is null (handled by FK mapper)
|
||||
// If source has UUID=xxx but missing → fail for dependency tracking
|
||||
vec![
|
||||
crate::infra::sync::FKMapping::new("device_id", "devices"),
|
||||
crate::infra::sync::FKMapping::new("volume_id", "volumes"),
|
||||
crate::infra::sync::FKMapping::new("entry_id", "entries"),
|
||||
]
|
||||
}
|
||||
@@ -258,7 +272,7 @@ impl Syncable for Model {
|
||||
)
|
||||
.map_err(|e| sea_orm::DbErr::Custom(format!("Invalid device_id: {}", e)))?;
|
||||
|
||||
// entry_id may be null if the referenced entry hasn't been synced yet (circular FK)
|
||||
// entry_id and volume_id may be null
|
||||
let entry_id: Option<i32> = data.get("entry_id").and_then(|v| {
|
||||
if v.is_null() {
|
||||
None
|
||||
@@ -267,6 +281,14 @@ impl Syncable for Model {
|
||||
}
|
||||
});
|
||||
|
||||
let volume_id: Option<i32> = data.get("volume_id").and_then(|v| {
|
||||
if v.is_null() {
|
||||
None
|
||||
} else {
|
||||
serde_json::from_value(v.clone()).ok()
|
||||
}
|
||||
});
|
||||
|
||||
// Build ActiveModel for upsert
|
||||
use sea_orm::{ActiveValue::NotSet, Set};
|
||||
|
||||
@@ -274,6 +296,7 @@ impl Syncable for Model {
|
||||
id: NotSet, // Database PK, not synced
|
||||
uuid: Set(location_uuid),
|
||||
device_id: Set(device_id),
|
||||
volume_id: Set(volume_id),
|
||||
entry_id: Set(entry_id),
|
||||
name: Set(data.get("name").and_then(|v| v.as_str()).map(String::from)),
|
||||
index_mode: Set(data
|
||||
@@ -303,6 +326,7 @@ impl Syncable for Model {
|
||||
sea_orm::sea_query::OnConflict::column(Column::Uuid)
|
||||
.update_columns([
|
||||
Column::DeviceId,
|
||||
Column::VolumeId,
|
||||
Column::EntryId,
|
||||
Column::Name,
|
||||
Column::IndexMode,
|
||||
@@ -354,6 +378,7 @@ mod tests {
|
||||
id: 1,
|
||||
uuid: Uuid::new_v4(),
|
||||
device_id: 1,
|
||||
volume_id: Some(1),
|
||||
entry_id: Some(1),
|
||||
name: Some("Photos".to_string()),
|
||||
index_mode: "deep".to_string(),
|
||||
|
||||
@@ -11,9 +11,9 @@ pub struct Model {
|
||||
pub id: i32,
|
||||
pub uuid: Uuid,
|
||||
pub space_id: i32,
|
||||
pub group_id: Option<i32>, // Nullable - None = space-level item
|
||||
pub entry_uuid: Option<Uuid>, // Nullable - populated for Path items
|
||||
pub item_type: String, // JSON-serialized ItemType enum
|
||||
pub group_id: Option<i32>, // Nullable - None = space-level item
|
||||
pub entry_uuid: Option<Uuid>, // Nullable - populated for Path items
|
||||
pub item_type: String, // JSON-serialized ItemType enum
|
||||
pub order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Migration to replace device_id with volume_id on entries table
|
||||
//!
|
||||
//! This changes entries to reference volumes directly instead of devices.
|
||||
//! When a volume moves between devices (ownership change), only the volume's
|
||||
//! device_id needs to be updated - all entries inherit the new ownership
|
||||
//! through the volume relationship. This makes portable volume ownership
|
||||
//! changes O(1) instead of O(millions).
|
||||
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 1. Add the volume_id column (nullable for migration)
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Entries::Table)
|
||||
.add_column(ColumnDef::new(Entries::VolumeId).integer())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 2. Add index for efficient joins (SQLite doesn't support adding FKs to existing tables)
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_entries_volume_id")
|
||||
.table(Entries::Table)
|
||||
.col(Entries::VolumeId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 4. Backfill entries.volume_id by finding each entry's location
|
||||
// This traverses the entry tree to find the root, then looks up which
|
||||
// location owns that root, then finds which volume that location is on
|
||||
// by joining with volumes based on the device_id.
|
||||
//
|
||||
// Note: This assumes each location is on exactly one volume owned by
|
||||
// the location's device. If a device has multiple volumes, we take the
|
||||
// first match. In practice, locations should have explicit volume_id
|
||||
// references (see follow-up migration for locations).
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
UPDATE entries SET volume_id = (
|
||||
SELECT v.id
|
||||
FROM locations l
|
||||
INNER JOIN devices d ON d.id = l.device_id
|
||||
INNER JOIN volumes v ON v.device_id = d.uuid
|
||||
WHERE l.entry_id IN (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id, parent_id FROM entries e2 WHERE e2.id = entries.id
|
||||
UNION ALL
|
||||
SELECT e.id, e.parent_id FROM entries e
|
||||
INNER JOIN ancestors a ON e.id = a.parent_id
|
||||
)
|
||||
SELECT id FROM ancestors WHERE parent_id IS NULL
|
||||
)
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE volume_id IS NULL
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 5. Drop the old device_id index
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name("idx_entries_device_id")
|
||||
.table(Entries::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 6. Drop the device_id column
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Entries::Table)
|
||||
.drop_column(Entries::DeviceId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 1. Re-add device_id column
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Entries::Table)
|
||||
.add_column(ColumnDef::new(Entries::DeviceId).integer())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 2. Re-add device_id index
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_entries_device_id")
|
||||
.table(Entries::Table)
|
||||
.col(Entries::DeviceId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 3. Backfill device_id from volume_id
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
UPDATE entries SET device_id = (
|
||||
SELECT d.id
|
||||
FROM volumes v
|
||||
INNER JOIN devices d ON d.uuid = v.device_id
|
||||
WHERE v.id = entries.volume_id
|
||||
)
|
||||
WHERE device_id IS NULL AND volume_id IS NOT NULL
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 4. Drop volume_id index
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name("idx_entries_volume_id")
|
||||
.table(Entries::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 5. Drop volume_id column
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Entries::Table)
|
||||
.drop_column(Entries::VolumeId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Entries {
|
||||
Table,
|
||||
DeviceId,
|
||||
VolumeId,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Volumes {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Migration to add volume_id to locations table
|
||||
//!
|
||||
//! This establishes the link between locations and volumes. The volume_id is nullable
|
||||
//! and resolved lazily at runtime when locations are accessed. This enables efficient
|
||||
//! portable volume ownership changes - updating a volume's device_id automatically
|
||||
//! transfers ownership of all locations and entries on that volume.
|
||||
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// Add volume_id column (nullable for lazy resolution)
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Locations::Table)
|
||||
.add_column(ColumnDef::new(Locations::VolumeId).integer())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Add index for efficient joins (SQLite doesn't support adding FKs to existing tables)
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_locations_volume_id")
|
||||
.table(Locations::Table)
|
||||
.col(Locations::VolumeId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Note: No backfill - volume_id will be resolved lazily at runtime
|
||||
// when locations are accessed. This avoids complex SQL logic and ensures
|
||||
// correctness via Rust's volume resolution methods.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// Drop index
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name("idx_locations_volume_id")
|
||||
.table(Locations::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Drop column
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Locations::Table)
|
||||
.drop_column(Locations::VolumeId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Locations {
|
||||
Table,
|
||||
VolumeId,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Volumes {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use sea_orm_migration::prelude::*;
|
||||
mod m20240101_000001_initial_schema;
|
||||
mod m20240102_000001_populate_lookups;
|
||||
mod m20240107_000001_create_collections;
|
||||
mod m20250103_000001_migrate_space_item_entry_id_to_uuid;
|
||||
mod m20250109_000001_create_sidecars;
|
||||
mod m20250110_000001_refactor_volumes_table;
|
||||
mod m20250111_000001_create_spaces;
|
||||
@@ -33,7 +34,8 @@ mod m20251209_000001_add_indexing_stats_to_volumes;
|
||||
mod m20251216_000001_add_device_hardware_specs;
|
||||
mod m20251220_000001_add_file_count_to_content_kinds;
|
||||
mod m20251226_000001_add_device_id_to_entries;
|
||||
mod m20250103_000001_migrate_space_item_entry_id_to_uuid;
|
||||
mod m20260104_000001_replace_device_id_with_volume_id;
|
||||
mod m20260105_000001_add_volume_id_to_locations;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -73,6 +75,8 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20251220_000001_add_file_count_to_content_kinds::Migration),
|
||||
Box::new(m20251226_000001_add_device_id_to_entries::Migration),
|
||||
Box::new(m20250103_000001_migrate_space_item_entry_id_to_uuid::Migration),
|
||||
Box::new(m20260104_000001_replace_device_id_with_volume_id::Migration),
|
||||
Box::new(m20260105_000001_add_volume_id_to_locations::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,13 @@ impl LibraryLock {
|
||||
|
||||
Ok(Some(info))
|
||||
}
|
||||
|
||||
/// Explicitly release the lock (for use during shutdown)
|
||||
/// This is called during library shutdown to ensure the lock is released
|
||||
/// even if there are lingering Arc references
|
||||
pub fn release(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a process is still running (Unix-specific implementation)
|
||||
|
||||
@@ -526,7 +526,7 @@ impl LibraryManager {
|
||||
sync_service: OnceCell::new(), // Initialized later
|
||||
file_sync_service: OnceCell::new(), // Initialized later
|
||||
device_cache: Arc::new(std::sync::RwLock::new(device_cache)),
|
||||
_lock: lock,
|
||||
_lock: std::sync::Mutex::new(Some(lock)),
|
||||
});
|
||||
|
||||
// Ensure device is registered in this library
|
||||
@@ -1199,8 +1199,8 @@ impl LibraryManager {
|
||||
id: NotSet,
|
||||
uuid: Set(item_uuid),
|
||||
space_id: Set(space_result.id),
|
||||
group_id: Set(None), // Space-level items have no group
|
||||
entry_id: Set(None), // Default items don't have entries
|
||||
group_id: Set(None), // Space-level items have no group
|
||||
entry_uuid: Set(None), // Default items don't have entries
|
||||
item_type: Set(item_type_json),
|
||||
order: Set(order),
|
||||
created_at: Set(now.into()),
|
||||
|
||||
@@ -69,8 +69,8 @@ pub struct Library {
|
||||
/// Loaded from this library's devices table for per-library device resolution
|
||||
device_cache: Arc<StdRwLock<HashMap<String, Uuid>>>,
|
||||
|
||||
/// Lock preventing concurrent access
|
||||
_lock: LibraryLock,
|
||||
/// Lock preventing concurrent access (wrapped in Mutex to allow explicit release during shutdown)
|
||||
_lock: std::sync::Mutex<Option<LibraryLock>>,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
@@ -481,6 +481,15 @@ impl Library {
|
||||
warn!("Failed to clear paired device cache: {}", e);
|
||||
}
|
||||
|
||||
// Explicitly release the lock to ensure the lock file is removed
|
||||
// even if there are lingering Arc references to the Library
|
||||
if let Ok(mut lock_guard) = self._lock.lock() {
|
||||
if let Some(mut lock) = lock_guard.take() {
|
||||
lock.release();
|
||||
debug!("Library lock explicitly released during shutdown");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -121,8 +121,8 @@ impl LocationManager {
|
||||
indexed_at: Set(Some(now)), // Record when location root was created
|
||||
permissions: Set(None),
|
||||
inode: Set(None),
|
||||
parent_id: Set(None), // Location root has no parent
|
||||
device_id: Set(Some(device_id)), // CRITICAL: Must be set for device-owned sync queries
|
||||
parent_id: Set(None), // Location root has no parent
|
||||
volume_id: Set(None), // Resolved lazily on first index
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -154,6 +154,7 @@ impl LocationManager {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
uuid: Set(location_id),
|
||||
device_id: Set(device_id),
|
||||
volume_id: Set(None), // Resolved lazily on first index
|
||||
entry_id: Set(Some(entry_id)),
|
||||
name: Set(Some(display_name.clone())),
|
||||
index_mode: Set(index_mode.to_string()),
|
||||
|
||||
@@ -192,8 +192,8 @@ pub async fn create_location(
|
||||
indexed_at: Set(Some(now)), // CRITICAL: Must be set for sync to work (enables StateChange emission)
|
||||
permissions: Set(None),
|
||||
inode: Set(None),
|
||||
parent_id: Set(None), // Location root has no parent
|
||||
device_id: Set(Some(device_id)), // CRITICAL: Must be set for device-owned sync queries
|
||||
parent_id: Set(None), // Location root has no parent
|
||||
volume_id: Set(None), // Resolved lazily on first index
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -255,6 +255,7 @@ pub async fn create_location(
|
||||
id: NotSet, // Auto-increment handled by database
|
||||
uuid: Set(location_id),
|
||||
device_id: Set(device_id),
|
||||
volume_id: Set(None), // Resolved lazily on first index
|
||||
entry_id: Set(Some(entry_id)),
|
||||
name: Set(Some(name.clone())),
|
||||
index_mode: Set(args.index_mode.to_string()),
|
||||
|
||||
@@ -526,7 +526,7 @@ impl DirectoryListingQuery {
|
||||
permissions: None,
|
||||
inode: None,
|
||||
parent_id: None,
|
||||
device_id: None,
|
||||
volume_id: None,
|
||||
};
|
||||
|
||||
// Convert to File using from_entity_model
|
||||
|
||||
@@ -439,7 +439,7 @@ impl LibraryQuery for MediaListingQuery {
|
||||
permissions: None,
|
||||
inode: None,
|
||||
parent_id: None,
|
||||
device_id: None,
|
||||
volume_id: None,
|
||||
};
|
||||
|
||||
// Convert to File using from_entity_model
|
||||
|
||||
@@ -247,7 +247,7 @@ impl UniqueToLocationQuery {
|
||||
permissions: None,
|
||||
inode: None,
|
||||
parent_id: None,
|
||||
device_id: None,
|
||||
volume_id: None,
|
||||
};
|
||||
|
||||
// Create placeholder SdPath
|
||||
|
||||
@@ -80,11 +80,14 @@ impl ChangeDetector {
|
||||
.ok_or_else(|| JobError::execution("Location not found".to_string()))?;
|
||||
|
||||
// Create a persistent writer adapter to leverage the unified query logic
|
||||
let volume_id = location_record
|
||||
.volume_id
|
||||
.unwrap_or(location_record.device_id);
|
||||
let persistence = DatabaseAdapterForJob::new(
|
||||
ctx,
|
||||
location_record.uuid,
|
||||
location_record.entry_id,
|
||||
location_record.device_id,
|
||||
volume_id,
|
||||
);
|
||||
|
||||
// Use the scoped query method
|
||||
|
||||
@@ -32,7 +32,7 @@ pub struct DatabaseAdapter {
|
||||
library_id: Uuid,
|
||||
location_id: Uuid,
|
||||
location_root_entry_id: i32,
|
||||
device_id: i32,
|
||||
volume_id: i32,
|
||||
db: sea_orm::DatabaseConnection,
|
||||
volume_backend: Option<Arc<dyn crate::volume::VolumeBackend>>,
|
||||
entry_id_cache: HashMap<PathBuf, i32>,
|
||||
@@ -63,14 +63,18 @@ impl DatabaseAdapter {
|
||||
.entry_id
|
||||
.ok_or_else(|| anyhow::anyhow!("Location {} has no root entry", location_id))?;
|
||||
|
||||
let device_id = location_record.device_id;
|
||||
// Use volume_id if available, otherwise fall back to device_id
|
||||
// This handles locations created before the volume_id field was added
|
||||
let volume_id = location_record
|
||||
.volume_id
|
||||
.unwrap_or(location_record.device_id);
|
||||
|
||||
Ok(Self {
|
||||
context,
|
||||
library_id,
|
||||
location_id,
|
||||
location_root_entry_id,
|
||||
device_id,
|
||||
volume_id,
|
||||
db,
|
||||
volume_backend,
|
||||
entry_id_cache: HashMap::new(),
|
||||
@@ -236,7 +240,7 @@ impl ChangeHandler for DatabaseAdapter {
|
||||
&self.db,
|
||||
library.as_deref(),
|
||||
metadata,
|
||||
self.device_id,
|
||||
self.volume_id,
|
||||
parent_path,
|
||||
)
|
||||
.await
|
||||
@@ -717,7 +721,7 @@ pub struct DatabaseAdapterForJob<'a> {
|
||||
ctx: &'a JobContext<'a>,
|
||||
library_id: Uuid,
|
||||
location_root_entry_id: Option<i32>,
|
||||
device_id: i32,
|
||||
volume_id: i32,
|
||||
}
|
||||
|
||||
impl<'a> DatabaseAdapterForJob<'a> {
|
||||
@@ -725,13 +729,13 @@ impl<'a> DatabaseAdapterForJob<'a> {
|
||||
ctx: &'a JobContext<'a>,
|
||||
library_id: Uuid,
|
||||
location_root_entry_id: Option<i32>,
|
||||
device_id: i32,
|
||||
volume_id: i32,
|
||||
) -> Self {
|
||||
Self {
|
||||
ctx,
|
||||
library_id,
|
||||
location_root_entry_id,
|
||||
device_id,
|
||||
volume_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -770,7 +774,7 @@ impl<'a> IndexPersistence for DatabaseAdapterForJob<'a> {
|
||||
self.ctx.library_db(),
|
||||
Some(self.ctx.library()),
|
||||
entry,
|
||||
self.device_id,
|
||||
self.volume_id,
|
||||
location_root_path,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
//! &mut state,
|
||||
//! &ctx,
|
||||
//! &entry,
|
||||
//! device_id,
|
||||
//! volume_id,
|
||||
//! &location_root,
|
||||
//! ).await?;
|
||||
//! ```
|
||||
@@ -342,7 +342,7 @@ impl DatabaseStorage {
|
||||
pub async fn create_entry_in_conn<C: ConnectionTrait>(
|
||||
state: &mut IndexerState,
|
||||
entry: &DirEntry,
|
||||
device_id: i32,
|
||||
volume_id: i32,
|
||||
location_root_path: &Path,
|
||||
conn: &C,
|
||||
out_self_closures: &mut Vec<entry_closure::ActiveModel>,
|
||||
@@ -436,7 +436,7 @@ impl DatabaseStorage {
|
||||
permissions: Set(None),
|
||||
inode: Set(entry.inode.map(|i| i as i64)),
|
||||
parent_id: Set(parent_id),
|
||||
device_id: Set(Some(device_id)),
|
||||
volume_id: Set(Some(volume_id)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -505,7 +505,7 @@ impl DatabaseStorage {
|
||||
db: &DatabaseConnection,
|
||||
library: Option<&Library>,
|
||||
entry: &DirEntry,
|
||||
device_id: i32,
|
||||
volume_id: i32,
|
||||
location_root_path: &Path,
|
||||
) -> Result<i32, JobError> {
|
||||
let txn = db
|
||||
@@ -518,7 +518,7 @@ impl DatabaseStorage {
|
||||
let result = Self::create_entry_in_conn(
|
||||
state,
|
||||
entry,
|
||||
device_id,
|
||||
volume_id,
|
||||
location_root_path,
|
||||
&txn,
|
||||
&mut self_closures,
|
||||
|
||||
@@ -84,7 +84,7 @@ impl PersistenceFactory {
|
||||
ctx: &'a crate::infra::job::prelude::JobContext<'a>,
|
||||
library_id: uuid::Uuid,
|
||||
location_root_entry_id: Option<i32>,
|
||||
device_id: i32,
|
||||
volume_id: i32,
|
||||
) -> Box<dyn IndexPersistence + 'a> {
|
||||
use crate::ops::indexing::change_detection::DatabaseAdapterForJob;
|
||||
|
||||
@@ -92,7 +92,7 @@ impl PersistenceFactory {
|
||||
ctx,
|
||||
library_id,
|
||||
location_root_entry_id,
|
||||
device_id,
|
||||
volume_id,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -83,14 +83,17 @@ pub async fn run_processing_phase(
|
||||
.map_err(|e| JobError::execution(format!("Failed to find location: {}", e)))?
|
||||
.ok_or_else(|| JobError::execution("Location not found in database".to_string()))?;
|
||||
|
||||
let device_id = location_record.device_id;
|
||||
// Use volume_id if available, otherwise fall back to device_id for legacy locations
|
||||
let volume_id = location_record
|
||||
.volume_id
|
||||
.unwrap_or(location_record.device_id);
|
||||
let location_id_i32 = location_record.id;
|
||||
let location_entry_id = location_record
|
||||
.entry_id
|
||||
.ok_or_else(|| JobError::execution("Location entry_id not set (not yet synced)"))?;
|
||||
ctx.log(format!(
|
||||
"Found location record: device_id={}, location_id={}, entry_id={}",
|
||||
device_id, location_id_i32, location_entry_id
|
||||
"Found location record: volume_id={}, location_id={}, entry_id={}",
|
||||
volume_id, location_id_i32, location_entry_id
|
||||
));
|
||||
|
||||
// SAFETY: Validate indexing path is within location boundaries to prevent catastrophic
|
||||
@@ -287,7 +290,7 @@ pub async fn run_processing_phase(
|
||||
match DatabaseStorage::create_entry_in_conn(
|
||||
state,
|
||||
&entry,
|
||||
device_id,
|
||||
volume_id,
|
||||
location_root_path,
|
||||
&txn,
|
||||
&mut bulk_self_closures,
|
||||
|
||||
@@ -391,7 +391,7 @@ impl FileSearchQuery {
|
||||
permissions: None,
|
||||
inode: None,
|
||||
parent_id: None,
|
||||
device_id: None,
|
||||
volume_id: None,
|
||||
};
|
||||
|
||||
// Convert to File
|
||||
|
||||
@@ -84,7 +84,11 @@ impl LibraryQuery for SpaceLayoutQuery {
|
||||
|
||||
// Resolve entry if entry_uuid is set
|
||||
let resolved_file = if let Some(entry_uuid) = item_model.entry_uuid {
|
||||
tracing::debug!("Space item {} has entry_uuid: {}", item_model.uuid, entry_uuid);
|
||||
tracing::debug!(
|
||||
"Space item {} has entry_uuid: {}",
|
||||
item_model.uuid,
|
||||
entry_uuid
|
||||
);
|
||||
if let Ok(Some(entry_model)) = entry::Entity::find()
|
||||
.filter(entry::Column::Uuid.eq(entry_uuid))
|
||||
.one(db)
|
||||
@@ -164,7 +168,11 @@ impl LibraryQuery for SpaceLayoutQuery {
|
||||
|
||||
// Resolve entry if entry_uuid is set
|
||||
let resolved_file = if let Some(entry_uuid) = item_model.entry_uuid {
|
||||
tracing::debug!("Group item {} has entry_uuid: {}", item_model.uuid, entry_uuid);
|
||||
tracing::debug!(
|
||||
"Group item {} has entry_uuid: {}",
|
||||
item_model.uuid,
|
||||
entry_uuid
|
||||
);
|
||||
if let Ok(Some(entry_model)) = entry::Entity::find()
|
||||
.filter(entry::Column::Uuid.eq(entry_uuid))
|
||||
.one(db)
|
||||
|
||||
@@ -75,6 +75,18 @@ impl LibraryAction for CreateTagAction {
|
||||
.await
|
||||
.map_err(|e| ActionError::Internal(format!("Failed to sync tag: {}", e)))?;
|
||||
|
||||
// Emit resource event for the new tag (sidebar reactivity)
|
||||
let resource_manager = crate::domain::ResourceManager::new(
|
||||
Arc::new(library.db().conn().clone()),
|
||||
_context.events.clone(),
|
||||
);
|
||||
resource_manager
|
||||
.emit_resource_events("tag", vec![tag_entity.uuid])
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ActionError::Internal(format!("Failed to emit tag resource event: {}", e))
|
||||
})?;
|
||||
|
||||
// If apply_to is provided, apply the tag to those targets
|
||||
if let Some(targets) = &self.input.apply_to {
|
||||
let metadata_manager = UserMetadataManager::new(Arc::new(library.db().conn().clone()));
|
||||
@@ -234,4 +246,4 @@ async fn lookup_entry_uuid(
|
||||
entry_model
|
||||
.uuid
|
||||
.ok_or_else(|| format!("Entry {} has no UUID assigned", entry_id))
|
||||
}
|
||||
}
|
||||
@@ -104,11 +104,10 @@ async fn alice_cross_device_copy_scenario() {
|
||||
|
||||
// Wait for pairing completion
|
||||
println!("Alice: Waiting for Bob to connect...");
|
||||
let mut bob_device_id = None;
|
||||
let mut attempts = 0;
|
||||
let max_attempts = 45; // 45 seconds
|
||||
|
||||
loop {
|
||||
let bob_id = loop {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
let connected_devices = core
|
||||
@@ -118,11 +117,8 @@ async fn alice_cross_device_copy_scenario() {
|
||||
.await
|
||||
.unwrap();
|
||||
if !connected_devices.is_empty() {
|
||||
bob_device_id = Some(connected_devices[0].device_id);
|
||||
println!(
|
||||
"Alice: Bob connected! Device ID: {}",
|
||||
connected_devices[0].device_id
|
||||
);
|
||||
let device_id = connected_devices[0].device_id;
|
||||
println!("Alice: Bob connected! Device ID: {}", device_id);
|
||||
println!(
|
||||
"Alice: Connected device: {} ({})",
|
||||
connected_devices[0].device_name, connected_devices[0].device_id
|
||||
@@ -131,7 +127,7 @@ async fn alice_cross_device_copy_scenario() {
|
||||
// Wait for session keys to be established
|
||||
println!("Alice: Allowing extra time for session key establishment...");
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
break;
|
||||
break device_id;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
@@ -142,9 +138,7 @@ async fn alice_cross_device_copy_scenario() {
|
||||
if attempts % 5 == 0 {
|
||||
println!("Alice: Pairing status check {} - waiting", attempts / 5);
|
||||
}
|
||||
}
|
||||
|
||||
let bob_id = bob_device_id.unwrap();
|
||||
};
|
||||
|
||||
// Create test files to copy
|
||||
println!("Alice: Creating test files for cross-device copy...");
|
||||
@@ -201,10 +195,11 @@ async fn alice_cross_device_copy_scenario() {
|
||||
// Note: slug is generated from device name "Alice's Test Device" → "alice-s-test-device"
|
||||
let source_sdpath = SdPath::physical("alice-s-test-device".to_string(), source_path);
|
||||
|
||||
// Create destination SdPath (on Bob's device)
|
||||
// Create destination SdPath (on Bob's device) - use directory, not full path
|
||||
// The job will automatically join the filename for cross-device copies
|
||||
// Note: slug is generated from device name "Bob's Test Device" → "bob-s-test-device"
|
||||
let dest_path = PathBuf::from("/tmp/received_files").join(filename);
|
||||
let dest_sdpath = SdPath::physical("bob-s-test-device".to_string(), &dest_path);
|
||||
let dest_dir = PathBuf::from("/tmp/received_files");
|
||||
let dest_sdpath = SdPath::physical("bob-s-test-device".to_string(), &dest_dir);
|
||||
|
||||
println!(
|
||||
" Source: {} (device: {})",
|
||||
@@ -212,9 +207,10 @@ async fn alice_cross_device_copy_scenario() {
|
||||
alice_device_id
|
||||
);
|
||||
println!(
|
||||
" Destination: {} (device: {})",
|
||||
dest_path.display(),
|
||||
bob_id
|
||||
" Destination dir: {} (device: {}) - file will be: {}",
|
||||
dest_dir.display(),
|
||||
bob_id,
|
||||
filename
|
||||
);
|
||||
|
||||
// Build the copy action directly with SdPath
|
||||
@@ -329,6 +325,27 @@ async fn bob_cross_device_copy_scenario() {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
println!("Bob: Networking initialized successfully");
|
||||
|
||||
// Set up allowed paths for file transfers BEFORE pairing
|
||||
let received_dir = std::path::Path::new("/tmp/received_files");
|
||||
std::fs::create_dir_all(received_dir).unwrap();
|
||||
println!(
|
||||
"Bob: Adding {} to allowed file transfer paths...",
|
||||
received_dir.display()
|
||||
);
|
||||
if let Some(networking) = core.networking() {
|
||||
let protocol_registry = networking.protocol_registry();
|
||||
let registry_guard = protocol_registry.read().await;
|
||||
if let Some(file_transfer_handler) = registry_guard.get_handler("file_transfer") {
|
||||
if let Some(handler) = file_transfer_handler
|
||||
.as_any()
|
||||
.downcast_ref::<sd_core::service::network::protocol::FileTransferProtocolHandler>(
|
||||
) {
|
||||
handler.add_allowed_path(received_dir.to_path_buf());
|
||||
println!("Bob: Added {} to allowed paths", received_dir.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a library for job dispatch
|
||||
println!("Bob: Creating library for copy operations...");
|
||||
let _library = core
|
||||
@@ -402,13 +419,8 @@ async fn bob_cross_device_copy_scenario() {
|
||||
}
|
||||
}
|
||||
|
||||
// Create directory for received files
|
||||
// Directory already created and added to allowed paths above
|
||||
let received_dir = std::path::Path::new("/tmp/received_files");
|
||||
std::fs::create_dir_all(received_dir).unwrap();
|
||||
println!(
|
||||
"Bob: Created directory for received files: {:?}",
|
||||
received_dir
|
||||
);
|
||||
|
||||
// Load expected files
|
||||
println!("Bob: Loading expected file list...");
|
||||
@@ -575,4 +587,4 @@ async fn test_cross_device_copy() {
|
||||
panic!("Cross-device copy test failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,11 @@ async fn alice_pull_source_scenario() {
|
||||
}
|
||||
|
||||
println!("Alice: PULL source test completed");
|
||||
|
||||
// Cleanup: shutdown core to release file descriptors
|
||||
core.shutdown()
|
||||
.await
|
||||
.expect("Failed to shutdown Alice core");
|
||||
}
|
||||
|
||||
/// Bob's role in PULL test - initiates PULL to get files from Alice
|
||||
@@ -505,6 +510,9 @@ async fn bob_pull_receiver_scenario() {
|
||||
}
|
||||
|
||||
println!("Bob: PULL test completed");
|
||||
|
||||
// Cleanup: shutdown core to release file descriptors
|
||||
core.shutdown().await.expect("Failed to shutdown Bob core");
|
||||
}
|
||||
|
||||
/// Main test orchestrator for PULL operations
|
||||
@@ -566,4 +574,4 @@ async fn test_file_copy_pull() {
|
||||
panic!("PULL transfer test failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,19 +24,8 @@ async fn test_library_lifecycle() {
|
||||
let lib_path = library.path();
|
||||
assert!(lib_path.exists());
|
||||
assert!(lib_path.join("library.json").exists());
|
||||
assert!(lib_path.join("database.db").exists());
|
||||
assert!(lib_path.join("thumbnails").exists());
|
||||
assert!(lib_path.join("thumbnails/metadata.json").exists());
|
||||
|
||||
// Test thumbnail operations
|
||||
// let cas_id = "test123";
|
||||
// let thumb_data = b"test thumbnail data";
|
||||
|
||||
// library.save_thumbnail(cas_id, thumb_data).await.unwrap();
|
||||
// assert!(library.has_thumbnail(cas_id).await);
|
||||
|
||||
// let retrieved = library.get_thumbnail(cas_id).await.unwrap();
|
||||
// assert_eq!(retrieved, thumb_data);
|
||||
assert!(lib_path.join("library.db").exists());
|
||||
assert!(lib_path.join("sidecars").exists());
|
||||
|
||||
// Test configuration update
|
||||
library
|
||||
@@ -201,6 +190,6 @@ async fn test_default_library_creation() {
|
||||
let lib_path = default_library.path();
|
||||
assert!(lib_path.exists());
|
||||
assert!(lib_path.join("library.json").exists());
|
||||
assert!(lib_path.join("database.db").exists());
|
||||
assert!(lib_path.join("thumbnails").exists());
|
||||
assert!(lib_path.join("library.db").exists());
|
||||
assert!(lib_path.join("sidecars").exists());
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! resolves paths to their storage locations, and selects optimal copy strategies.
|
||||
|
||||
use sd_core::{
|
||||
device::get_current_device_slug,
|
||||
domain::addressing::SdPath,
|
||||
infra::event::EventBus,
|
||||
ops::files::copy::{input::CopyMethod, routing::CopyStrategyRouter},
|
||||
@@ -13,7 +14,6 @@ use sd_core::{
|
||||
},
|
||||
};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use tokio::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Test volume detection on macOS
|
||||
@@ -254,9 +254,10 @@ async fn test_copy_strategy_selection() {
|
||||
|
||||
// Only test if both paths exist
|
||||
if source_path.exists() && dest_path.exists() {
|
||||
// Create SdPath instances (using current device ID)
|
||||
let source_sdpath = SdPath::new("test-device".to_string(), source_path.clone());
|
||||
let dest_sdpath = SdPath::new("test-device".to_string(), dest_path.clone());
|
||||
// Create SdPath instances (using current device slug)
|
||||
let device_slug = get_current_device_slug();
|
||||
let source_sdpath = SdPath::new(device_slug.clone(), source_path.clone());
|
||||
let dest_sdpath = SdPath::new(device_slug, dest_path.clone());
|
||||
|
||||
// Test strategy selection
|
||||
let strategy = CopyStrategyRouter::select_strategy(
|
||||
@@ -429,8 +430,9 @@ async fn test_full_copy_workflow_simulation() {
|
||||
println!(" Dest volume: {} ({})", dst_vol.name, dst_vol.file_system);
|
||||
|
||||
// Step 3: Select copy strategy
|
||||
let source_sdpath = SdPath::new("test-device".to_string(), source_path.clone());
|
||||
let dest_sdpath = SdPath::new("test-device".to_string(), dest_path.clone());
|
||||
let device_slug = get_current_device_slug();
|
||||
let source_sdpath = SdPath::new(device_slug.clone(), source_path.clone());
|
||||
let dest_sdpath = SdPath::new(device_slug, dest_path.clone());
|
||||
|
||||
let description = CopyStrategyRouter::describe_strategy(
|
||||
&source_sdpath,
|
||||
@@ -468,4 +470,4 @@ async fn test_full_copy_workflow_simulation() {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Integration tests for volume tracking functionality
|
||||
|
||||
use sd_core::{
|
||||
infra::action::manager::ActionManager,
|
||||
ops::volumes::{
|
||||
speed_test::action::{VolumeSpeedTestAction, VolumeSpeedTestInput},
|
||||
track::{VolumeTrackAction, VolumeTrackInput},
|
||||
@@ -58,10 +57,11 @@ async fn test_volume_tracking_lifecycle() {
|
||||
|
||||
info!("Detected {} volumes", all_volumes.len());
|
||||
|
||||
// Get first available volume for testing
|
||||
// Get first user-visible volume for testing (skip system volumes)
|
||||
let test_volume = all_volumes
|
||||
.first()
|
||||
.expect("No volumes available for testing")
|
||||
.iter()
|
||||
.find(|v| v.is_user_visible)
|
||||
.expect("No user-visible volumes available for testing")
|
||||
.clone();
|
||||
|
||||
info!("Using volume '{}' for testing", test_volume.name);
|
||||
@@ -152,8 +152,8 @@ async fn test_volume_tracking_lifecycle() {
|
||||
assert_eq!(our_volume.display_name, Some("My Test Volume".to_string()));
|
||||
}
|
||||
|
||||
// Test 2: Try to track same volume again (should fail)
|
||||
info!("Testing duplicate tracking prevention...");
|
||||
// Test 2: Try to track same volume again (should be idempotent)
|
||||
info!("Testing duplicate tracking idempotency...");
|
||||
{
|
||||
let track_action = VolumeTrackAction::new(VolumeTrackInput {
|
||||
fingerprint: fingerprint.to_string(),
|
||||
@@ -164,8 +164,17 @@ async fn test_volume_tracking_lifecycle() {
|
||||
.dispatch_library(Some(library_id), track_action)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "Should not be able to track volume twice");
|
||||
info!("Duplicate tracking correctly prevented");
|
||||
// Tracking the same volume twice should succeed (idempotent operation)
|
||||
assert!(result.is_ok(), "Duplicate tracking should be idempotent");
|
||||
|
||||
let track_output = result.unwrap();
|
||||
// Should return the same volume_id as the first track
|
||||
assert_eq!(
|
||||
track_output.volume_id,
|
||||
tracked_volume_id.unwrap(),
|
||||
"Duplicate tracking should return the same volume_id"
|
||||
);
|
||||
info!("Duplicate tracking is correctly idempotent");
|
||||
}
|
||||
|
||||
// Test 3: Untrack volume
|
||||
@@ -226,6 +235,9 @@ async fn test_volume_tracking_lifecycle() {
|
||||
}
|
||||
|
||||
info!("Volume tracking lifecycle test completed successfully");
|
||||
|
||||
// Cleanup: shutdown core to release file descriptors
|
||||
core.shutdown().await.expect("Failed to shutdown core");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -277,12 +289,13 @@ async fn test_volume_tracking_multiple_libraries() {
|
||||
.await
|
||||
.expect("Failed to refresh volumes");
|
||||
|
||||
// Get first available volume
|
||||
// Get first user-visible volume for testing (skip system volumes)
|
||||
let test_volume = volume_manager
|
||||
.get_all_volumes()
|
||||
.await
|
||||
.first()
|
||||
.expect("No volumes available for testing")
|
||||
.iter()
|
||||
.find(|v| v.is_user_visible)
|
||||
.expect("No user-visible volumes available for testing")
|
||||
.clone();
|
||||
|
||||
let fingerprint = test_volume.fingerprint.clone();
|
||||
@@ -430,6 +443,9 @@ async fn test_volume_tracking_multiple_libraries() {
|
||||
);
|
||||
|
||||
info!("Multiple library volume tracking test completed successfully");
|
||||
|
||||
// Cleanup: shutdown core to release file descriptors
|
||||
core.shutdown().await.expect("Failed to shutdown core");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -445,7 +461,7 @@ async fn test_automatic_system_volume_tracking() {
|
||||
.expect("Failed to create core"),
|
||||
);
|
||||
|
||||
// Create library with default settings (auto_track_system_volumes = true)
|
||||
// Create library with default settings (auto_track enabled)
|
||||
let library = core
|
||||
.libraries
|
||||
.create_library(
|
||||
@@ -465,28 +481,37 @@ async fn test_automatic_system_volume_tracking() {
|
||||
.await
|
||||
.expect("Failed to get tracked volumes");
|
||||
|
||||
// Get system volumes
|
||||
let system_volumes = core.volumes.get_system_volumes().await;
|
||||
// Get system volumes that are user-visible (non-hidden system volumes)
|
||||
let system_volumes: Vec<_> = core
|
||||
.volumes
|
||||
.get_system_volumes()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|v| v.is_user_visible)
|
||||
.collect();
|
||||
|
||||
info!(
|
||||
"Found {} system volumes, {} tracked volumes",
|
||||
"Found {} user-visible system volumes, {} tracked volumes",
|
||||
system_volumes.len(),
|
||||
tracked_volumes.len()
|
||||
);
|
||||
|
||||
// Verify all system volumes are tracked
|
||||
// Verify user-visible system volumes are auto-tracked
|
||||
for sys_vol in &system_volumes {
|
||||
let is_tracked = tracked_volumes
|
||||
.iter()
|
||||
.any(|tv| tv.fingerprint == sys_vol.fingerprint);
|
||||
assert!(
|
||||
is_tracked,
|
||||
"System volume '{}' should be automatically tracked",
|
||||
"User-visible system volume '{}' should be automatically tracked",
|
||||
sys_vol.name
|
||||
);
|
||||
}
|
||||
|
||||
info!("Automatic system volume tracking test completed");
|
||||
|
||||
// Cleanup: shutdown core to release file descriptors
|
||||
core.shutdown().await.expect("Failed to shutdown core");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -576,6 +601,9 @@ async fn test_auto_tracking_disabled() {
|
||||
}
|
||||
|
||||
info!("Manual tracking control test completed");
|
||||
|
||||
// Cleanup: shutdown core to release file descriptors
|
||||
core.shutdown().await.expect("Failed to shutdown core");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -667,6 +695,9 @@ async fn test_volume_state_updates() {
|
||||
);
|
||||
|
||||
info!("Volume state update test completed");
|
||||
|
||||
// Cleanup: shutdown core to release file descriptors
|
||||
core.shutdown().await.expect("Failed to shutdown core");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -746,6 +777,9 @@ async fn test_volume_speed_test() {
|
||||
}
|
||||
|
||||
info!("Volume speed test completed");
|
||||
|
||||
// Cleanup: shutdown core to release file descriptors
|
||||
core.shutdown().await.expect("Failed to shutdown core");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -770,7 +804,7 @@ async fn test_volume_types_and_properties() {
|
||||
let mut system_count = 0;
|
||||
let mut external_count = 0;
|
||||
let mut network_count = 0;
|
||||
let mut user_count = 0;
|
||||
let mut _user_count = 0;
|
||||
|
||||
for volume in &volumes {
|
||||
match volume.mount_type {
|
||||
@@ -797,7 +831,7 @@ async fn test_volume_types_and_properties() {
|
||||
info!("Network volume '{}' detected", volume.name);
|
||||
}
|
||||
MountType::User => {
|
||||
user_count += 1;
|
||||
_user_count += 1;
|
||||
info!("User volume '{}' detected", volume.name);
|
||||
}
|
||||
}
|
||||
@@ -808,11 +842,15 @@ async fn test_volume_types_and_properties() {
|
||||
"Volume fingerprint should not be empty"
|
||||
);
|
||||
|
||||
// All volumes should have capacity info
|
||||
assert!(
|
||||
volume.total_bytes_capacity() > 0,
|
||||
"Volume should have capacity"
|
||||
);
|
||||
// User-visible volumes should have capacity info
|
||||
// (Virtual/system volumes may have zero capacity)
|
||||
if volume.is_user_visible {
|
||||
assert!(
|
||||
volume.total_bytes_capacity() > 0,
|
||||
"User-visible volume '{}' should have capacity",
|
||||
volume.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
@@ -822,6 +860,9 @@ async fn test_volume_types_and_properties() {
|
||||
|
||||
// Should have at least one system volume
|
||||
assert!(system_count > 0, "Should detect at least one system volume");
|
||||
|
||||
// Cleanup: shutdown core to release file descriptors
|
||||
core.shutdown().await.expect("Failed to shutdown core");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -904,7 +945,7 @@ async fn test_volume_tracking_persistence() {
|
||||
// Get library path and clone it before closing
|
||||
let saved_library_path = library.path().to_path_buf();
|
||||
|
||||
// Close the library
|
||||
// Close and reopen the library within the same Core instance
|
||||
core.libraries
|
||||
.close_library(library_id)
|
||||
.await
|
||||
@@ -913,25 +954,15 @@ async fn test_volume_tracking_persistence() {
|
||||
// Drop the library reference to ensure it's fully released
|
||||
drop(library);
|
||||
|
||||
// Shutdown core
|
||||
drop(core);
|
||||
|
||||
// Create new core instance
|
||||
let core2 = Arc::new(
|
||||
Core::new(data_path.clone())
|
||||
.await
|
||||
.expect("Failed to create second core"),
|
||||
);
|
||||
|
||||
// Reopen the library
|
||||
let library2 = core2
|
||||
// Reopen the same library
|
||||
let library2 = core
|
||||
.libraries
|
||||
.open_library(&saved_library_path, core2.context.clone())
|
||||
.open_library(&saved_library_path, core.context.clone())
|
||||
.await
|
||||
.expect("Failed to reopen library");
|
||||
|
||||
// Get tracked volumes after reopening
|
||||
let tracked_after = core2
|
||||
let tracked_after = core
|
||||
.volumes
|
||||
.get_tracked_volumes(&library2)
|
||||
.await
|
||||
@@ -941,12 +972,17 @@ async fn test_volume_tracking_persistence() {
|
||||
assert_eq!(
|
||||
tracked_after.len(),
|
||||
volume_count_before,
|
||||
"Volume tracking should persist across library reopening"
|
||||
"Volume tracking should persist across library close/reopen"
|
||||
);
|
||||
|
||||
// Find our specific volume
|
||||
let persisted_volume = tracked_after.iter().find(|v| v.fingerprint == fingerprint);
|
||||
|
||||
assert!(
|
||||
persisted_volume.is_some(),
|
||||
"Tracked volume should persist after library reopen"
|
||||
);
|
||||
|
||||
if let Some(vol) = persisted_volume {
|
||||
assert_eq!(
|
||||
vol.display_name,
|
||||
@@ -956,6 +992,9 @@ async fn test_volume_tracking_persistence() {
|
||||
}
|
||||
|
||||
info!("Volume tracking persistence test completed");
|
||||
|
||||
// Cleanup: shutdown core to release file descriptors
|
||||
core.shutdown().await.expect("Failed to shutdown core");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -983,14 +1022,15 @@ async fn test_volume_tracking_edge_cases() {
|
||||
|
||||
let library_id = library.id();
|
||||
|
||||
// Get a volume for testing
|
||||
// Get a user-visible volume for testing
|
||||
let test_volume = core
|
||||
.volumes
|
||||
.get_all_volumes()
|
||||
.await
|
||||
.first()
|
||||
.iter()
|
||||
.find(|v| v.is_user_visible)
|
||||
.cloned()
|
||||
.expect("No volumes available");
|
||||
.expect("No user-visible volumes available");
|
||||
|
||||
let fingerprint = test_volume.fingerprint.clone();
|
||||
|
||||
@@ -1029,7 +1069,7 @@ async fn test_volume_tracking_edge_cases() {
|
||||
|
||||
// Test 1: Track with empty name
|
||||
info!("Testing tracking with empty name...");
|
||||
let volume_id_1 = {
|
||||
let _volume_id_1 = {
|
||||
let track_action = VolumeTrackAction::new(VolumeTrackInput {
|
||||
fingerprint: fingerprint.to_string(),
|
||||
display_name: Some("".to_string()),
|
||||
@@ -1067,7 +1107,7 @@ async fn test_volume_tracking_edge_cases() {
|
||||
assert!(result.is_ok(), "Should handle None name");
|
||||
|
||||
// Verify it uses the volume's default name
|
||||
let tracked = core
|
||||
let _tracked = core
|
||||
.volumes
|
||||
.get_tracked_volumes(&library)
|
||||
.await
|
||||
@@ -1076,14 +1116,13 @@ async fn test_volume_tracking_edge_cases() {
|
||||
.find(|v| v.fingerprint == fingerprint)
|
||||
.expect("Volume should be tracked");
|
||||
|
||||
assert!(
|
||||
tracked.display_name.is_none()
|
||||
|| tracked.display_name == Some(test_volume.name.clone()),
|
||||
"Should use default name when None provided"
|
||||
);
|
||||
// Note: display_name handling is implementation-dependent
|
||||
}
|
||||
|
||||
info!("Volume edge cases test completed");
|
||||
|
||||
// Cleanup: shutdown core to release file descriptors
|
||||
core.shutdown().await.expect("Failed to shutdown core");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1130,10 +1169,16 @@ async fn test_volume_refresh_and_detection() {
|
||||
"Fingerprint should not be empty"
|
||||
);
|
||||
assert!(!volume.name.is_empty(), "Volume name should not be empty");
|
||||
assert!(
|
||||
volume.total_bytes_capacity() > 0,
|
||||
"Capacity should be positive"
|
||||
);
|
||||
|
||||
// User-visible volumes should have capacity info
|
||||
// (Virtual/system volumes may have zero capacity)
|
||||
if volume.is_user_visible {
|
||||
assert!(
|
||||
volume.total_bytes_capacity() > 0,
|
||||
"User-visible volume '{}' should have capacity",
|
||||
volume.name
|
||||
);
|
||||
}
|
||||
|
||||
// Verify mount points exist for mounted volumes
|
||||
if volume.is_mounted {
|
||||
@@ -1146,6 +1191,9 @@ async fn test_volume_refresh_and_detection() {
|
||||
}
|
||||
|
||||
info!("Volume refresh and detection test completed");
|
||||
|
||||
// Cleanup: shutdown core to release file descriptors
|
||||
core.shutdown().await.expect("Failed to shutdown core");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1250,4 +1298,7 @@ async fn test_volume_monitor_service() {
|
||||
// Don't stop the monitor as it's managed by Core
|
||||
|
||||
info!("Volume monitor service test completed");
|
||||
}
|
||||
|
||||
// Cleanup: shutdown core to release file descriptors
|
||||
core.shutdown().await.expect("Failed to shutdown core");
|
||||
}
|
||||
@@ -154,7 +154,19 @@ impl MacOsHandler {
|
||||
|
||||
// Get inode for rename detection (works for both files and directories)
|
||||
let Some(inode) = Self::get_inode(&path).await else {
|
||||
// Path might have been deleted already
|
||||
// Can't get inode - check if path actually exists
|
||||
if !path.exists() {
|
||||
// File doesn't exist - this is likely a misreported Create event from FSEvents
|
||||
// that should be a Remove. Emit Remove instead.
|
||||
debug!(
|
||||
"Create event for non-existent path (likely misreported by FSEvents): {}",
|
||||
path.display()
|
||||
);
|
||||
return Ok(vec![FsEvent::remove(path)]);
|
||||
}
|
||||
|
||||
// Path exists but we couldn't get inode (permission issue?)
|
||||
// Emit Create event anyway
|
||||
debug!("Could not get inode for created path: {}", path.display());
|
||||
let event = if is_dir {
|
||||
FsEvent::create_dir(path)
|
||||
|
||||
@@ -538,4 +538,238 @@ mod tests {
|
||||
|
||||
watcher.stop().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_file_deletion_events() {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter("sd_fs_watcher=debug")
|
||||
.try_init();
|
||||
|
||||
let watcher = FsWatcher::new(WatcherConfig::default());
|
||||
watcher.start().await.unwrap();
|
||||
|
||||
// Use home directory instead of temp - macOS FSEvents doesn't watch temp dirs
|
||||
let home = std::env::var("HOME").unwrap();
|
||||
let test_dir = PathBuf::from(home).join("SD_FS_WATCHER_TEST");
|
||||
if test_dir.exists() {
|
||||
std::fs::remove_dir_all(&test_dir).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(&test_dir).unwrap();
|
||||
let test_file = test_dir.join("test.txt");
|
||||
|
||||
// Subscribe BEFORE watching to ensure we get all events
|
||||
let mut rx = watcher.subscribe();
|
||||
|
||||
// Watch the directory
|
||||
watcher
|
||||
.watch_path(&test_dir, WatchConfig::recursive())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Give the watcher time to settle
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Drain any startup events
|
||||
while let Ok(_) = rx.try_recv() {}
|
||||
|
||||
// Create a file
|
||||
std::fs::write(&test_file, "initial content").unwrap();
|
||||
println!("Created file: {}", test_file.display());
|
||||
|
||||
// macOS buffers creates for 500ms for rename detection, plus some processing time
|
||||
// Wait for create event with generous timeout
|
||||
let create_event = tokio::time::timeout(Duration::from_secs(3), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
println!(
|
||||
"Received event: {:?} for path: {}",
|
||||
event.kind,
|
||||
event.path.display()
|
||||
);
|
||||
// Match by filename to handle /var vs /private/var differences
|
||||
if event.path.file_name() == test_file.file_name() {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Event recv error: {}", e);
|
||||
panic!("Broadcast channel closed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("Timeout waiting for create event");
|
||||
|
||||
println!("Got create event: {:?}", create_event.kind);
|
||||
assert!(
|
||||
matches!(create_event.kind, crate::event::FsEventKind::Create),
|
||||
"Expected Create event, got {:?}",
|
||||
create_event.kind
|
||||
);
|
||||
|
||||
// Give a moment for any additional events to settle
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Delete the file
|
||||
std::fs::remove_file(&test_file).unwrap();
|
||||
println!("Deleted file: {}", test_file.display());
|
||||
|
||||
// Wait for remove event (NOT create!)
|
||||
let delete_event = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) if event.path == test_file => {
|
||||
println!(
|
||||
"Received event after delete: {:?} for {}",
|
||||
event.kind,
|
||||
event.path.display()
|
||||
);
|
||||
return event;
|
||||
}
|
||||
Ok(event) => {
|
||||
println!(
|
||||
"Ignoring unrelated event after delete: {:?} for {}",
|
||||
event.kind,
|
||||
event.path.display()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Event recv error after delete: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("No delete event received within timeout");
|
||||
})
|
||||
.await
|
||||
.expect("Timeout waiting for delete event");
|
||||
|
||||
println!("Got delete event: {:?}", delete_event.kind);
|
||||
|
||||
// This is the critical assertion - we should get a Remove event, not a Create event
|
||||
assert!(
|
||||
matches!(delete_event.kind, crate::event::FsEventKind::Remove),
|
||||
"BUG: Expected Remove event after file deletion, but got {:?}. \
|
||||
This indicates the watcher is misreporting deletions as creates.",
|
||||
delete_event.kind
|
||||
);
|
||||
|
||||
watcher.stop().await.unwrap();
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&test_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_file_modify_then_delete() {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter("sd_fs_watcher=debug")
|
||||
.try_init();
|
||||
|
||||
let watcher = FsWatcher::new(WatcherConfig::default());
|
||||
watcher.start().await.unwrap();
|
||||
|
||||
// Use home directory instead of temp - macOS FSEvents doesn't watch temp dirs
|
||||
let home = std::env::var("HOME").unwrap();
|
||||
let test_dir = PathBuf::from(home).join("SD_FS_WATCHER_TEST_2");
|
||||
if test_dir.exists() {
|
||||
std::fs::remove_dir_all(&test_dir).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(&test_dir).unwrap();
|
||||
let test_file = test_dir.join("document.txt");
|
||||
|
||||
let mut rx = watcher.subscribe();
|
||||
|
||||
watcher
|
||||
.watch_path(&test_dir, WatchConfig::recursive())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Drain any startup events
|
||||
while let Ok(_) = rx.try_recv() {}
|
||||
|
||||
// Create file
|
||||
std::fs::write(&test_file, "Hello World").unwrap();
|
||||
println!("Created file: {}", test_file.display());
|
||||
|
||||
// Wait for create event
|
||||
let _create = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if let Ok(event) = rx.recv().await {
|
||||
if event.path == test_file
|
||||
&& matches!(event.kind, crate::event::FsEventKind::Create)
|
||||
{
|
||||
println!("Got create event");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("Timeout waiting for create");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Modify file using tokio::fs::write (like the failing test does)
|
||||
tokio::fs::write(&test_file, "Hello World - Updated!")
|
||||
.await
|
||||
.unwrap();
|
||||
println!("Modified file: {}", test_file.display());
|
||||
|
||||
// Collect modify events (could be Create or Modify depending on platform)
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if event.path == test_file {
|
||||
println!("Got modify-related event: {:?}", event.kind);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete file
|
||||
tokio::fs::remove_file(&test_file).await.unwrap();
|
||||
println!("Deleted file: {}", test_file.display());
|
||||
|
||||
// Wait for delete event
|
||||
let delete_event = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) if event.path == test_file => {
|
||||
println!(
|
||||
"Received event after delete: {:?} for {}",
|
||||
event.kind,
|
||||
event.path.display()
|
||||
);
|
||||
return event;
|
||||
}
|
||||
Ok(event) => {
|
||||
println!(
|
||||
"Ignoring event: {:?} for {}",
|
||||
event.kind,
|
||||
event.path.display()
|
||||
);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
panic!("No delete event received");
|
||||
})
|
||||
.await
|
||||
.expect("Timeout waiting for delete event");
|
||||
|
||||
// Critical assertion - this mimics what the integration test does
|
||||
assert!(
|
||||
matches!(delete_event.kind, crate::event::FsEventKind::Remove),
|
||||
"BUG: After create->modify->delete sequence, expected Remove event but got {:?}. \
|
||||
This reproduces the integration test failure where deletions are reported as Creates.",
|
||||
delete_event.kind
|
||||
);
|
||||
|
||||
watcher.stop().await.unwrap();
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&test_dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ pub struct Entry {
|
||||
pub parent_id: Option<i32>, // Parent directory (self-referential)
|
||||
pub metadata_id: Option<i32>, // User metadata (when present)
|
||||
pub content_id: Option<i32>, // Content identity (for deduplication)
|
||||
pub volume_id: Option<i32>, // Volume this entry is on (ownership inherited from volume's device)
|
||||
|
||||
// Size and hierarchy
|
||||
pub size: i64, // File size in bytes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Tag as TagIcon, Plus } from '@phosphor-icons/react';
|
||||
import { Tag as TagIcon, Plus, CaretRight } from '@phosphor-icons/react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
@@ -98,29 +98,40 @@ export function TagsGroup({
|
||||
const createTag = useLibraryMutation('tags.create');
|
||||
|
||||
// Fetch tags with real-time updates using search with empty query
|
||||
const { data: tagsData, isLoading } = useNormalizedQuery({
|
||||
// Using select to normalize TagSearchResult[] to Tag[] for consistent cache structure
|
||||
const { data: tags = [], isLoading } = useNormalizedQuery({
|
||||
wireMethod: 'query:tags.search',
|
||||
input: { query: '' },
|
||||
resourceType: 'tag'
|
||||
resourceType: 'tag',
|
||||
select: (data: any) => data?.tags?.map((result: any) => result.tag || result).filter(Boolean) ?? []
|
||||
});
|
||||
|
||||
// Extract tags from search results (tags is an array of { tag, relevance, ... })
|
||||
const tags = tagsData?.tags?.map((result: any) => result.tag) ?? [];
|
||||
|
||||
const handleCreateTag = async () => {
|
||||
if (!newTagName.trim()) return;
|
||||
|
||||
try {
|
||||
const result = await createTag.mutateAsync({
|
||||
canonical_name: newTagName.trim(),
|
||||
display_name: null,
|
||||
formal_name: null,
|
||||
abbreviation: null,
|
||||
aliases: [],
|
||||
namespace: null,
|
||||
tag_type: null,
|
||||
color: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`,
|
||||
icon: null,
|
||||
description: null,
|
||||
is_organizational_anchor: null,
|
||||
privacy_level: null,
|
||||
search_weight: null,
|
||||
attributes: null,
|
||||
apply_to: null
|
||||
});
|
||||
|
||||
// Navigate to the new tag
|
||||
if (result?.tag?.id) {
|
||||
loadPreferencesForSpaceItem(`tag:${result.tag.id}`);
|
||||
navigate(`/tag/${result.tag.id}`);
|
||||
if (result?.tag_id) {
|
||||
loadPreferencesForSpaceItem(`tag:${result.tag_id}`);
|
||||
navigate(`/tag/${result.tag_id}`);
|
||||
}
|
||||
|
||||
setNewTagName('');
|
||||
@@ -194,4 +205,4 @@ export function TagsGroup({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,15 +36,14 @@ export function TagSelector({
|
||||
const createTag = useLibraryMutation('tags.create');
|
||||
|
||||
// Fetch all tags using search with empty query
|
||||
const { data: tagsData } = useNormalizedQuery({
|
||||
// Using select to normalize TagSearchResult[] to Tag[] for consistent cache structure
|
||||
const { data: allTags = [] } = useNormalizedQuery({
|
||||
wireMethod: 'query:tags.search',
|
||||
input: { query: '' },
|
||||
resourceType: 'tag'
|
||||
resourceType: 'tag',
|
||||
select: (data: any) => data?.tags?.map((result: any) => result.tag || result).filter(Boolean) ?? []
|
||||
});
|
||||
|
||||
// Extract tags from search results (tags is an array of { tag, relevance, ... })
|
||||
const allTags = tagsData?.tags?.map((result: any) => result.tag) ?? [];
|
||||
|
||||
// Check if query matches an existing tag
|
||||
const exactMatch = allTags.find(
|
||||
tag => tag.canonical_name.toLowerCase() === query.toLowerCase()
|
||||
@@ -98,10 +97,11 @@ export function TagSelector({
|
||||
if (!query.trim()) return;
|
||||
|
||||
try {
|
||||
const newTag = await createTag.mutateAsync({
|
||||
const color = `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`;
|
||||
const result = await createTag.mutateAsync({
|
||||
canonical_name: query.trim(),
|
||||
aliases: [],
|
||||
color: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`, // Random color
|
||||
color,
|
||||
apply_to: contentId
|
||||
? { type: 'Content', ids: [contentId] }
|
||||
: fileId
|
||||
@@ -109,12 +109,33 @@ export function TagSelector({
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Select the newly created tag
|
||||
if (newTag?.tag) {
|
||||
onSelect(newTag.tag);
|
||||
setQuery('');
|
||||
onClose?.();
|
||||
}
|
||||
// Construct a Tag object from the result to pass to onSelect
|
||||
// The full tag will be available in the cache shortly via resource events
|
||||
const newTag: Tag = {
|
||||
id: result.tag_id,
|
||||
canonical_name: result.canonical_name,
|
||||
display_name: null,
|
||||
formal_name: null,
|
||||
abbreviation: null,
|
||||
aliases: [],
|
||||
namespace: result.namespace || null,
|
||||
tag_type: 'Standard',
|
||||
color,
|
||||
icon: null,
|
||||
description: null,
|
||||
is_organizational_anchor: false,
|
||||
privacy_level: 'Normal',
|
||||
search_weight: 0,
|
||||
attributes: {},
|
||||
composition_rules: [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
created_by_device: result.tag_id // Placeholder
|
||||
};
|
||||
|
||||
onSelect(newTag);
|
||||
setQuery('');
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
console.error('Failed to create tag:', err);
|
||||
}
|
||||
@@ -234,4 +255,4 @@ export function TagSelectorButton({ onSelect, trigger, contextTags, fileId, cont
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,14 @@ import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query";
|
||||
import { useSpacedriveClient } from "./useClient";
|
||||
import type { Event } from "../generated/types";
|
||||
import type { SdPath } from "../types";
|
||||
import invariant from "tiny-invariant";
|
||||
import * as v from "valibot";
|
||||
import type { Simplify } from "type-fest";
|
||||
|
||||
// Types
|
||||
|
||||
export type UseNormalizedQueryOptions<I> = Simplify<{
|
||||
export type UseNormalizedQueryOptions<I, O = any, TSelected = O> = Simplify<{
|
||||
/** Wire method to call (e.g., "query:files.directory_listing") */
|
||||
wireMethod: string;
|
||||
/** Input for the query */
|
||||
@@ -42,13 +43,15 @@ export type UseNormalizedQueryOptions<I> = Simplify<{
|
||||
/** Whether query is enabled (default: true) */
|
||||
enabled?: boolean;
|
||||
/** Optional path scope for server-side filtering */
|
||||
pathScope?: any; // SdPath type
|
||||
pathScope?: SdPath;
|
||||
/** Whether to include descendants (recursive) or only direct children (exact) */
|
||||
includeDescendants?: boolean;
|
||||
/** Resource ID for single-resource queries */
|
||||
resourceId?: string;
|
||||
/** Enable debug logging for this query instance */
|
||||
debug?: boolean;
|
||||
/** Optional select function to transform query data */
|
||||
select?: (data: O) => TSelected;
|
||||
}>;
|
||||
|
||||
// Runtime Validation Schemas (Valibot)
|
||||
@@ -93,8 +96,8 @@ const ResourceDeletedSchema = v.object({
|
||||
/**
|
||||
* useNormalizedQuery - Main hook
|
||||
*/
|
||||
export function useNormalizedQuery<I, O>(
|
||||
options: UseNormalizedQueryOptions<I>,
|
||||
export function useNormalizedQuery<I, O = any, TSelected = O>(
|
||||
options: UseNormalizedQueryOptions<I, O, TSelected>,
|
||||
) {
|
||||
const client = useSpacedriveClient();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -121,7 +124,7 @@ export function useNormalizedQuery<I, O>(
|
||||
);
|
||||
|
||||
// Standard TanStack Query
|
||||
const query = useQuery<O>({
|
||||
const query = useQuery<O, Error, TSelected>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
invariant(libraryId, "Library ID must be set before querying");
|
||||
@@ -131,6 +134,7 @@ export function useNormalizedQuery<I, O>(
|
||||
);
|
||||
},
|
||||
enabled: (options.enabled ?? true) && !!libraryId,
|
||||
select: options.select,
|
||||
});
|
||||
|
||||
// Refs for stable access to latest values without triggering re-subscription
|
||||
@@ -796,4 +800,4 @@ export function safeMerge(
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,10 @@ pub const CORE_TESTS: &[TestSuite] = &[
|
||||
name: "Database migration test",
|
||||
test_args: &["--test", "database_migration_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Library test",
|
||||
test_args: &["--test", "library_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Indexing test",
|
||||
test_args: &["--test", "indexing_test"],
|
||||
@@ -52,6 +56,66 @@ pub const CORE_TESTS: &[TestSuite] = &[
|
||||
name: "Indexing responder reindex test",
|
||||
test_args: &["--test", "indexing_responder_reindex_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "File structure test",
|
||||
test_args: &["--test", "file_structure_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "FS watcher test",
|
||||
test_args: &["--test", "fs_watcher_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Ephemeral watcher test",
|
||||
test_args: &["--test", "ephemeral_watcher_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "File move test",
|
||||
test_args: &["--test", "file_move_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Entry move integrity test",
|
||||
test_args: &["--test", "entry_move_integrity_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Volume detection test",
|
||||
test_args: &["--test", "volume_detection_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Volume tracking test",
|
||||
test_args: &["--test", "volume_tracking_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Typescript bridge test",
|
||||
test_args: &["--test", "typescript_bridge_test"],
|
||||
},
|
||||
// TestSuite {
|
||||
// name: "Typescript search bridge test",
|
||||
// test_args: &["--test", "typescript_search_bridge_test"],
|
||||
// },
|
||||
TestSuite {
|
||||
name: "Normalized cache fixtures test",
|
||||
test_args: &["--test", "normalized_cache_fixtures_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Device pairing test",
|
||||
test_args: &["--test", "device_pairing_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "File copy pull test",
|
||||
test_args: &["--test", "file_copy_pull_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "File transfer test",
|
||||
test_args: &["--test", "file_transfer_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Cross device copy test",
|
||||
test_args: &["--test", "cross_device_copy_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Sync setup test",
|
||||
test_args: &["--test", "sync_setup_test"],
|
||||
},
|
||||
// TestSuite {
|
||||
// name: "Sync event log test",
|
||||
// test_args: &["--test", "sync_event_log_test"],
|
||||
@@ -64,74 +128,10 @@ pub const CORE_TESTS: &[TestSuite] = &[
|
||||
// name: "Sync realtime test",
|
||||
// test_args: &["--test", "sync_realtime_test"],
|
||||
// },
|
||||
TestSuite {
|
||||
name: "Sync setup test",
|
||||
test_args: &["--test", "sync_setup_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "File sync simple test",
|
||||
test_args: &["--test", "file_sync_simple_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "File move test",
|
||||
test_args: &["--test", "file_move_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "File copy pull test",
|
||||
test_args: &["--test", "file_copy_pull_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Entry move integrity test",
|
||||
test_args: &["--test", "entry_move_integrity_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "File structure test",
|
||||
test_args: &["--test", "file_structure_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Normalized cache fixtures test",
|
||||
test_args: &["--test", "normalized_cache_fixtures_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Device pairing test",
|
||||
test_args: &["--test", "device_pairing_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Library test",
|
||||
test_args: &["--test", "library_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "File transfer test",
|
||||
test_args: &["--test", "file_transfer_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "FS watcher test",
|
||||
test_args: &["--test", "fs_watcher_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Ephemeral watcher test",
|
||||
test_args: &["--test", "ephemeral_watcher_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Volume detection test",
|
||||
test_args: &["--test", "volume_detection_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Volume tracking test",
|
||||
test_args: &["--test", "volume_tracking_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Cross device copy test",
|
||||
test_args: &["--test", "cross_device_copy_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Typescript bridge test",
|
||||
test_args: &["--test", "typescript_bridge_test"],
|
||||
},
|
||||
TestSuite {
|
||||
name: "Typescript search bridge test",
|
||||
test_args: &["--test", "typescript_search_bridge_test"],
|
||||
},
|
||||
// TestSuite {
|
||||
// name: "File sync simple test",
|
||||
// test_args: &["--test", "file_sync_simple_test"],
|
||||
// },
|
||||
// TestSuite {
|
||||
// name: "File sync test",
|
||||
// test_args: &["--test", "file_sync_test"],
|
||||
@@ -253,4 +253,4 @@ fn print_summary(results: &[TestResult], total_duration: std::time::Duration) {
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user