diff --git a/migrations/20260904000000_drop_files_folders_user_id.sql b/migrations/20260904000000_drop_files_folders_user_id.sql new file mode 100644 index 00000000..5467584a --- /dev/null +++ b/migrations/20260904000000_drop_files_folders_user_id.sql @@ -0,0 +1,113 @@ +-- ───────────────────────────────────────────────────────────────────────── +-- D7 step 6 — drop `user_id` from `storage.files` and `storage.folders`. +-- +-- Companion / final step to: +-- • `20260902000000_files_folders_user_id_nullable.sql` — dropped NOT NULL, +-- swapped uniqueness indexes to drive-scoped, retired the user_id-leading +-- indexes. +-- • `20260902000001_copy_folder_tree_drop_user_id.sql` — stopped writing +-- the column from `storage.copy_folder_tree`. +-- +-- All Rust writers already omit `user_id` from INSERTs (step 4). Every read +-- has been rewritten to drive-membership predicates (step 5). This migration +-- removes the column entirely so no future accidental read/write can bind it. +-- +-- Ownership continues to live in `storage.role_grants` (drive-Owner role); +-- provenance in `created_by` / `updated_by` (§14). +-- +-- ── Dependencies to unpin before ALTER ─────────────────────────────────── +-- +-- `storage.trash_items` is a VIEW that projects both `f.user_id` and +-- `fo.user_id`. `CREATE OR REPLACE VIEW` can only APPEND columns, never +-- drop or reorder — see `bug_create_or_replace_view_column_order`. So we +-- DROP the view, then recreate it without user_id after the column drop. +-- +-- All remaining pre-D7 indexes that referenced `user_id` +-- (`idx_files_trashed`, `idx_files_media_timeline`, and any legacy +-- uniqueness holdovers) are dropped implicitly by `ALTER TABLE DROP +-- COLUMN`. The D0/D7 drive-keyed successors already exist +-- (`idx_files_media_timeline_by_drive`, +-- `idx_files_unique_name_in_folder`, `idx_files_unique_name_at_root`, +-- etc.), so the hot paths retain their O(LIMIT) shape. + +-- ── 1. Drop dependent view so the column drop can proceed ──────────────── + +DROP VIEW IF EXISTS storage.trash_items; + +-- ── 2. Drop the column ─────────────────────────────────────────────────── + +ALTER TABLE storage.files DROP COLUMN IF EXISTS user_id; +ALTER TABLE storage.folders DROP COLUMN IF EXISTS user_id; + +-- ── 3. Recreate the trash view without user_id ─────────────────────────── +-- +-- `drive_id` is still projected (D2b introduced it) and is the scope +-- column for per-drive trash listing; `caller_group_ids($1)` fans it +-- out to the caller's group memberships via role_grants. + +CREATE VIEW storage.trash_items AS + SELECT f.id, f.name, 'file' AS item_type, f.trashed_at, + f.original_folder_id AS original_parent_id, f.created_at, + f.drive_id + FROM storage.files f + WHERE f.is_trashed = TRUE + AND (f.folder_id IS NULL + OR NOT EXISTS ( + SELECT 1 FROM storage.folders p + WHERE p.id = f.folder_id AND p.is_trashed = TRUE)) + UNION ALL + SELECT fo.id, fo.name, 'folder' AS item_type, fo.trashed_at, + fo.original_parent_id, fo.created_at, + fo.drive_id + FROM storage.folders fo + WHERE fo.is_trashed = TRUE + AND (fo.parent_id IS NULL + OR NOT EXISTS ( + SELECT 1 FROM storage.folders p + WHERE p.id = fo.parent_id AND p.is_trashed = TRUE)); + +COMMENT ON VIEW storage.trash_items IS + 'Unified view of all trashed files and folders. Post-D7: `user_id` ' + 'projection removed — the source column is gone. Scope is `drive_id` ' + 'via role_grants membership (see TrashDbRepository::get_trash_items).'; + +-- ── 4. Post-flight sanity ──────────────────────────────────────────────── + +DO $BODY$ +DECLARE + files_has_col BOOLEAN; + folders_has_col BOOLEAN; + view_has_col BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'storage' + AND table_name = 'files' + AND column_name = 'user_id' + ) INTO files_has_col; + + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'storage' + AND table_name = 'folders' + AND column_name = 'user_id' + ) INTO folders_has_col; + + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'storage' + AND table_name = 'trash_items' + AND column_name = 'user_id' + ) INTO view_has_col; + + IF files_has_col THEN + RAISE EXCEPTION 'storage.files.user_id column did not drop'; + END IF; + IF folders_has_col THEN + RAISE EXCEPTION 'storage.folders.user_id column did not drop'; + END IF; + IF view_has_col THEN + RAISE EXCEPTION 'storage.trash_items still projects user_id — view recreate skipped'; + END IF; +END; +$BODY$; diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 00aaef47..0aa2ea83 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -8,6 +8,8 @@ //! materialized path column), so no recursive CTEs or N+1 queries are needed. /// Row shape returned by media-file queries (avoids `clippy::type_complexity`). +/// Post-D7-step-6: `storage.files.user_id` dropped, so it's no +/// longer projected. type MediaFileRow = ( String, // id String, // name @@ -18,7 +20,6 @@ type MediaFileRow = ( i64, // created_at i64, // updated_at String, // blob_hash - Option, // user_id Option, // created_by (§14 provenance) Option, // updated_by (§14 provenance) i64, // sort_date @@ -76,8 +77,11 @@ const CALLER_CAN_READ_DRIVE: &str = "EXISTS (\ /// Type alias for file metadata rows from SQL queries. /// Fields: id, name, folder_id, folder_path, size, mime_type, -/// created_at, updated_at, blob_hash, user_id, created_by, updated_by. +/// created_at, updated_at, blob_hash, created_by, updated_by. /// `created_by` / `updated_by` are the §14 provenance columns. +/// Post-D7-step-6: `storage.files.user_id` dropped, so it's no +/// longer part of the tuple; `row_to_file` populates the entity's +/// legacy `user_id` field with `None`. type FileRow = ( String, String, @@ -90,7 +94,6 @@ type FileRow = ( String, Option, Option, - Option, ); /// Append the optional type/date/size filters from `criteria` to @@ -271,7 +274,7 @@ impl FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, \ EXTRACT(EPOCH FROM fi.updated_at)::bigint, \ fi.blob_hash, \ - fi.user_id, \ + \ fi.created_by, fi.updated_by \ FROM storage.files fi \ LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \ @@ -292,10 +295,8 @@ impl FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { - Self::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, - ) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub) }, ) .collect::, _>>() @@ -323,7 +324,7 @@ impl FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, \ EXTRACT(EPOCH FROM fi.updated_at)::bigint, \ fi.blob_hash, \ - fi.user_id, \ + \ fi.created_by, fi.updated_by \ FROM storage.files fi \ LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \ @@ -338,10 +339,8 @@ impl FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { - Self::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, - ) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub) }, ) .collect::, _>>() @@ -398,6 +397,8 @@ impl FileBlobReadRepository { } } + /// Post-D7-step-6: `storage.files.user_id` dropped; the entity's + /// legacy `user_id` field is populated with `None` here. #[allow(clippy::too_many_arguments)] fn row_to_file( id: String, @@ -409,7 +410,6 @@ impl FileBlobReadRepository { created_at: i64, modified_at: i64, blob_hash: String, - owner_id: Option, created_by: Option, updated_by: Option, ) -> Result { @@ -423,7 +423,7 @@ impl FileBlobReadRepository { folder_id, created_at as u64, modified_at as u64, - owner_id, + None, // Post-D7: `files.user_id` column dropped. blob_hash, created_by, updated_by, @@ -504,7 +504,7 @@ impl FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id, + fi.created_by, fi.updated_by, EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date, fm.width, fm.height @@ -544,9 +544,9 @@ impl FileBlobReadRepository { let mut sort_dates = Vec::with_capacity(rows.len()); let mut dims = Vec::with_capacity(rows.len()); - for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, sd, w, h) in rows { + for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, sd, w, h) in rows { files.push(Self::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, )?); sort_dates.push(sd); dims.push((w, h)); @@ -641,7 +641,6 @@ impl FileReadPort for FileBlobReadRepository { i64, // created_at i64, // updated_at String, // blob_hash - Option, // user_id (owner) Option, // created_by (§14) Option, // updated_by (§14) ), @@ -652,7 +651,6 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id, fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id @@ -670,7 +668,7 @@ impl FileReadPort for FileBlobReadRepository { self.hash_cache.insert(id.to_string(), row.8.clone()); Self::row_to_file( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11, + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, ) } @@ -690,7 +688,6 @@ impl FileReadPort for FileBlobReadRepository { i64, i64, String, - Option, Option, // created_by (§14) Option, // updated_by (§14) ), @@ -701,7 +698,6 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id, fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id @@ -716,7 +712,7 @@ impl FileReadPort for FileBlobReadRepository { self.hash_cache.insert(id.to_string(), row.8.clone()); Self::row_to_file( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11, + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, ) } @@ -730,7 +726,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id @@ -749,7 +745,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id @@ -764,10 +760,8 @@ impl FileReadPort for FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { - Self::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, - ) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub) }, ) .collect() @@ -796,7 +790,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id @@ -818,7 +812,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id @@ -836,10 +830,8 @@ impl FileReadPort for FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { - Self::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, - ) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub) }, ) .collect() @@ -989,7 +981,6 @@ impl FileReadPort for FileBlobReadRepository { i64, i64, String, - Option, Option, // created_by (§14) Option, // updated_by (§14) ), @@ -1000,8 +991,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id, - fi.created_by, fi.updated_by + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.name = $1 AND fi.folder_id IS NULL @@ -1029,7 +1019,6 @@ impl FileReadPort for FileBlobReadRepository { i64, i64, String, - Option, Option, // created_by (§14) Option, // updated_by (§14) ), @@ -1040,8 +1029,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id, - fi.created_by, fi.updated_by + fi.created_by, fi.updated_by FROM storage.files fi JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fo.path = $1 AND fi.name = $2 @@ -1058,7 +1046,7 @@ impl FileReadPort for FileBlobReadRepository { match row { Some(r) => Ok(Some(Self::row_to_file( - r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, r.10, r.11, + r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, r.10, )?)), None => Ok(None), } @@ -1078,8 +1066,8 @@ impl FileReadPort for FileBlobReadRepository { let stream = async_stream::try_stream! { let mut row_stream = sqlx::query_as::<_, ( String, String, Option, Option, - i64, String, i64, i64, String, Option, - Option, Option, + i64, String, i64, i64, String, + Option, Option, // created_by, updated_by (§14) )>( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, @@ -1087,8 +1075,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id, - fi.created_by, fi.updated_by + fi.created_by, fi.updated_by FROM storage.files fi JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) @@ -1102,9 +1089,9 @@ impl FileReadPort for FileBlobReadRepository { while let Some(row) = row_stream.try_next().await.map_err(|e| { DomainError::internal_error("FileBlobRead", format!("subtree stream: {e}")) })? { - let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub) = row; + let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub) = row; let file = FileBlobReadRepository::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, )?; yield file; } @@ -1171,7 +1158,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, \ EXTRACT(EPOCH FROM fi.updated_at)::bigint, \ fi.blob_hash, \ - fi.user_id, \ + \ fi.created_by, fi.updated_by, \ COUNT(*) OVER() AS total_count \ FROM storage.files fi \ @@ -1194,10 +1181,9 @@ impl FileReadPort for FileBlobReadRepository { i64, i64, String, - Option, Option, // created_by (§14) Option, // updated_by (§14) - i64, + i64, // total_count ), >(&sql) .bind(caller_id); @@ -1219,15 +1205,13 @@ impl FileReadPort for FileBlobReadRepository { .map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?; // total_count is the same in every row; 0 when result set is empty. - let total_count = rows.first().map_or(0, |r| r.12) as usize; + let total_count = rows.first().map_or(0, |r| r.11) as usize; let files = rows .into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, _total)| { - Self::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, - ) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub) }, ) .collect::, _>>() @@ -1306,7 +1290,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, \ EXTRACT(EPOCH FROM fi.updated_at)::bigint, \ fi.blob_hash, \ - fi.user_id, \ + \ fi.created_by, fi.updated_by, \ COUNT(*) OVER() AS total_count \ FROM storage.files fi \ @@ -1329,10 +1313,9 @@ impl FileReadPort for FileBlobReadRepository { i64, i64, String, - Option, Option, // created_by (§14) Option, // updated_by (§14) - i64, + i64, // total_count ), >(&sql) .bind(caller_id) @@ -1352,15 +1335,13 @@ impl FileReadPort for FileBlobReadRepository { DomainError::internal_error("FileBlobRead", format!("subtree search: {e}")) })?; - let total_count = rows.first().map_or(0, |r| r.12) as usize; + let total_count = rows.first().map_or(0, |r| r.11) as usize; let files = rows .into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, _total)| { - Self::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, - ) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub) }, ) .collect::, _>>() @@ -1402,7 +1383,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id @@ -1432,7 +1413,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id @@ -1458,10 +1439,8 @@ impl FileReadPort for FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { - Self::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, - ) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub) }, ) .collect() diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index ac87b4e1..3de28382 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -422,7 +422,6 @@ impl FileBlobWriteRepository { _, ( String, - Option, String, i64, i64, @@ -433,7 +432,7 @@ impl FileBlobWriteRepository { ), >( r#" - SELECT f.id::text, f.user_id, fo.path, + SELECT f.id::text, fo.path, EXTRACT(EPOCH FROM f.created_at)::bigint, EXTRACT(EPOCH FROM f.updated_at)::bigint, f.created_by, f.updated_by, f.size, f.mime_type @@ -457,7 +456,6 @@ impl FileBlobWriteRepository { let Some(( id, - user_id, folder_path, created_at, updated_at, @@ -479,7 +477,7 @@ impl FileBlobWriteRepository { mime_type, created_at, updated_at, - user_id, + None, blob_hash.to_string(), created_by, updated_by, @@ -532,11 +530,10 @@ impl FileWritePort for FileBlobWriteRepository { >( r#" WITH dest AS ( - SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid + SELECT drive_id FROM storage.folders WHERE id = $1::uuid ) UPDATE storage.files f SET folder_id = $1::uuid, - user_id = COALESCE((SELECT user_id FROM dest), f.user_id), drive_id = COALESCE((SELECT drive_id FROM dest), f.drive_id), updated_at = NOW(), updated_by = $3 diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index c4491c48..7cd92f8a 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -21,23 +21,22 @@ use crate::domain::services::authorization::ResourceKind; use crate::domain::services::path_service::StoragePath; /// Type alias for folder metadata rows from SQL queries. -/// Tuple order: id, name, path, parent_id, user_id, drive_id, -/// created_at, modified_at, tree_modified_at, created_by, updated_by. +/// Tuple order: id, name, path, parent_id, drive_id, created_at, +/// modified_at, tree_modified_at, created_by, updated_by. /// The trailing `tree_modified_at` feeds [`Folder::etag`] — every /// SELECT here must include `EXTRACT(EPOCH FROM tree_modified_at)::bigint`. /// `drive_id` is the post-D0 `NOT NULL` scope axis for path-based /// lookups. `created_by` / `updated_by` are the §14 provenance /// columns, nullable because the FK is `ON DELETE SET NULL`. /// -/// Post-D7 `user_id` is `Option` — the column is nullable and -/// left NULL on new rows (see migration -/// `20260902000000_files_folders_user_id_nullable.sql`). +/// Post-D7-step-6: `storage.folders.user_id` dropped, so the tuple +/// no longer carries it. The domain entity's `user_id` field is +/// populated with `None` at `row_to_folder` construction. type FolderRow = ( String, String, String, Option, - Option, Uuid, i64, i64, @@ -48,13 +47,12 @@ type FolderRow = ( /// Type alias for paginated folder rows (includes total_count as /// the last element after the §14 provenance columns). Same -/// nullability semantics as [`FolderRow`]. +/// column set as [`FolderRow`] plus the trailing count. type FolderRowPaginated = ( String, String, String, Option, - Option, Uuid, i64, i64, @@ -64,23 +62,6 @@ type FolderRowPaginated = ( i64, ); -/// Type alias for folder rows with optional user_id — kept as a -/// separate name for the search-shape SELECTs. -/// Includes the §14 provenance columns `created_by` / `updated_by`. -type FolderRowOptUser = ( - String, - String, - String, - Option, - Option, - Uuid, - i64, - i64, - i64, - Option, - Option, -); - /// SQL `EXISTS (…)` predicate — true when the caller (bound to `$1`) has /// any active `role_grants` on the drive owning `fo` (the aliased folder /// row). Group memberships (direct + transitive) are expanded inline via @@ -148,13 +129,17 @@ impl FolderDbRepository { /// extra queries needed. `created_by` / `updated_by` carry the /// §14 provenance signal through the entity layer; both are /// `Option` because the FK is `ON DELETE SET NULL`. + /// + /// Post-D7-step-6: the `storage.folders.user_id` column is gone; + /// the entity's legacy `user_id` field is populated with `None` + /// at construction time (removed in the follow-up entity + /// cleanup PR). #[allow(clippy::too_many_arguments)] fn row_to_folder( id: String, name: String, path: String, parent_id: Option, - user_id: Option, drive_id: Uuid, created_at: i64, modified_at: i64, @@ -168,7 +153,7 @@ impl FolderDbRepository { name, storage_path, parent_id, - user_id, + None, // Post-D7: `folders.user_id` column dropped. drive_id, created_at as u64, modified_at as u64, @@ -192,7 +177,7 @@ impl FolderDbRepository { let rows = sqlx::query_as::<_, FolderRow>( r#" - SELECT id::text, name, path, parent_id::text, user_id, drive_id, + SELECT id::text, name, path, parent_id::text, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -207,7 +192,7 @@ impl FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("get_folders_by_ids: {e}")))?; rows.into_iter() - .map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, r.10)) + .map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9)) .collect() } } @@ -290,9 +275,7 @@ impl FolderRepository for FolderDbRepository { })?; Self::row_to_folder( - row.0, name, row.1, parent_id, - None, // Post-D7: `folders.user_id` no longer written on new rows. - drive_id, row.2, row.3, row.4, + row.0, name, row.1, parent_id, drive_id, row.2, row.3, row.4, // Fresh from RETURNING — caller_id was bound to both columns. row.5, row.6, ) @@ -301,7 +284,7 @@ impl FolderRepository for FolderDbRepository { async fn get_folder(&self, id: &str) -> Result { let row = sqlx::query_as::<_, FolderRow>( r#" - SELECT id::text, name, path, parent_id::text, user_id, drive_id, + SELECT id::text, name, path, parent_id::text, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -317,7 +300,7 @@ impl FolderRepository for FolderDbRepository { .ok_or_else(|| DomainError::not_found("Folder", id))?; Self::row_to_folder( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, ) } @@ -343,7 +326,7 @@ impl FolderRepository for FolderDbRepository { // wrapper scoping post-D0). let row = sqlx::query_as::<_, FolderRow>( r#" - SELECT id::text, name, path, parent_id::text, user_id, drive_id, + SELECT id::text, name, path, parent_id::text, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -360,7 +343,7 @@ impl FolderRepository for FolderDbRepository { .ok_or_else(|| DomainError::not_found("Folder", lookup))?; Self::row_to_folder( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, ) } @@ -369,7 +352,7 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, drive_id, + SELECT id::text, name, path, parent_id::text, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -385,7 +368,7 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, drive_id, + SELECT id::text, name, path, parent_id::text, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -401,8 +384,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { - Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) + .map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub) }) .collect() } @@ -429,7 +412,7 @@ impl FolderRepository for FolderDbRepository { // `/api/drives::caller_role` via `folder.drive_id`. let sql = format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, fo.drive_id, \ + fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ @@ -449,8 +432,8 @@ impl FolderRepository for FolderDbRepository { })?; rows.into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { - Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) + .map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub) }) .collect() } @@ -469,7 +452,7 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, drive_id, + SELECT id::text, name, path, parent_id::text, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -489,7 +472,7 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, drive_id, + SELECT id::text, name, path, parent_id::text, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -510,18 +493,16 @@ impl FolderRepository for FolderDbRepository { // total_count is identical in every row; 0 when the result set is empty. let total = if include_total { - Some(rows.first().map_or(0, |r| r.11) as usize) + Some(rows.first().map_or(0, |r| r.10) as usize) } else { None }; let folders: Result, DomainError> = rows .into_iter() - .map( - |(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| { - Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) - }, - ) + .map(|(id, name, path, pid, did, ca, ma, tma, cb, ub, _total)| { + Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub) + }) .collect(); Ok((folders?, total)) } @@ -539,7 +520,7 @@ impl FolderRepository for FolderDbRepository { ) -> Result<(Vec, Option), DomainError> { let sql = format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, fo.drive_id, \ + fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ @@ -563,18 +544,16 @@ impl FolderRepository for FolderDbRepository { })?; let total = if include_total { - Some(rows.first().map_or(0, |r| r.11) as usize) + Some(rows.first().map_or(0, |r| r.10) as usize) } else { None }; let folders: Result, DomainError> = rows .into_iter() - .map( - |(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| { - Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) - }, - ) + .map(|(id, name, path, pid, did, ca, ma, tma, cb, ub, _total)| { + Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub) + }) .collect(); Ok((folders?, total)) } @@ -602,7 +581,7 @@ impl FolderRepository for FolderDbRepository { UPDATE storage.folders SET name = $1, updated_at = NOW(), updated_by = $3 WHERE id = $2::uuid AND NOT is_trashed - RETURNING id::text, name, path, parent_id::text, user_id, drive_id, + RETURNING id::text, name, path, parent_id::text, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -626,7 +605,7 @@ impl FolderRepository for FolderDbRepository { .ok_or_else(|| DomainError::not_found("Folder", id))?; Self::row_to_folder( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, ) } @@ -663,7 +642,7 @@ impl FolderRepository for FolderDbRepository { updated_at = NOW(), updated_by = $3 WHERE f.id = $2::uuid AND NOT f.is_trashed - RETURNING f.id::text, f.name, f.path, f.parent_id::text, f.user_id, f.drive_id, + RETURNING f.id::text, f.name, f.path, f.parent_id::text, f.drive_id, EXTRACT(EPOCH FROM f.created_at)::bigint, EXTRACT(EPOCH FROM f.updated_at)::bigint, EXTRACT(EPOCH FROM f.tree_modified_at)::bigint, @@ -680,7 +659,7 @@ impl FolderRepository for FolderDbRepository { .ok_or_else(|| DomainError::not_found("Folder", id))?; Self::row_to_folder( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, ) } @@ -963,7 +942,7 @@ impl FolderRepository for FolderDbRepository { #[allow(clippy::type_complexity)] async fn list_subtree_folders(&self, folder_id: &str) -> Result, DomainError> { let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, fo.drive_id, \ + fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ @@ -973,7 +952,7 @@ impl FolderRepository for FolderDbRepository { AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \ ORDER BY fo.path"; - let rows: Vec = sqlx::query_as(sql) + let rows: Vec = sqlx::query_as(sql) .bind(folder_id) .fetch_all(self.pool()) .await @@ -982,8 +961,8 @@ impl FolderRepository for FolderDbRepository { })?; rows.into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { - Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) + .map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub) }) .collect() } @@ -1030,7 +1009,7 @@ impl FolderRepository for FolderDbRepository { // Recursive, no folder scope → ALL folders in caller's readable drives let sql = format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, fo.drive_id, \ + fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ @@ -1042,7 +1021,7 @@ impl FolderRepository for FolderDbRepository { ORDER BY fo.name" ); - let rows: Vec = if let Some(ref pattern) = name_pattern { + let rows: Vec = if let Some(ref pattern) = name_pattern { sqlx::query_as(&sql) .bind(caller_id) .bind(pattern) @@ -1058,8 +1037,8 @@ impl FolderRepository for FolderDbRepository { return rows .into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { - Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) + .map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub) }) .collect(); } @@ -1069,7 +1048,7 @@ impl FolderRepository for FolderDbRepository { let sql = if parent_id.is_some() { format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, fo.drive_id, \ + fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ @@ -1089,7 +1068,7 @@ impl FolderRepository for FolderDbRepository { }; format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, fo.drive_id, \ + fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ @@ -1103,7 +1082,7 @@ impl FolderRepository for FolderDbRepository { ) }; - let rows: Vec = if let Some(pid) = parent_id { + let rows: Vec = if let Some(pid) = parent_id { if let Some(ref pattern) = name_pattern { sqlx::query_as(&sql) .bind(caller_id) @@ -1133,8 +1112,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { - Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) + .map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub) }) .collect() } @@ -1162,7 +1141,7 @@ impl FolderRepository for FolderDbRepository { let sql = format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, fo.drive_id, \ + fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ @@ -1176,7 +1155,7 @@ impl FolderRepository for FolderDbRepository { ORDER BY fo.name" ); - let rows: Vec = if let Some(ref pattern) = name_pattern { + let rows: Vec = if let Some(ref pattern) = name_pattern { sqlx::query_as(&sql) .bind(caller_id) .bind(folder_id) @@ -1193,8 +1172,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { - Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) + .map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub) }) .collect() } @@ -1212,7 +1191,7 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, drive_id, + SELECT id::text, name, path, parent_id::text, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -1239,7 +1218,7 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, drive_id, + SELECT id::text, name, path, parent_id::text, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -1266,8 +1245,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { - Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) + .map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub) }) .collect() } diff --git a/src/infrastructure/repositories/pg/trash_db_repository.rs b/src/infrastructure/repositories/pg/trash_db_repository.rs index f547724a..52524383 100644 --- a/src/infrastructure/repositories/pg/trash_db_repository.rs +++ b/src/infrastructure/repositories/pg/trash_db_repository.rs @@ -123,31 +123,19 @@ impl TrashRepository for TrashDbRepository { } async fn get_trash_items(&self, user_id: &Uuid) -> Result> { - // Post-D7: the `WHERE t.user_id = $1` filter no longer works — - // new rows land with `user_id = NULL`, so the trash view's - // `user_id` projection is nullable and can't be the scope - // axis any more. Filter by drive-membership instead: any - // trashed item in a drive the caller has any role_grant on. - // Group memberships expand inline via + // Post-D7: the `WHERE t.user_id = $1` filter is gone — the + // `user_id` column was dropped from `storage.{files,folders}` + // and the view no longer projects it. Scope is drive-membership + // via role_grants; group memberships expand inline through // `storage.caller_group_ids`. Same predicate shape as // `list_root_folders_for_caller` / the file listings. // - // Legacy method — the paginated `list_resources_paged` is - // the modern shape and takes explicit drive_ids from the - // service layer. - let rows = sqlx::query_as::< - _, - ( - Uuid, - String, - String, - Option, - Option>, - String, - ), - >( + // Legacy method — the paginated `list_resources_paged` is the + // modern shape and takes explicit drive_ids from the service + // layer. + let rows = sqlx::query_as::<_, (Uuid, String, String, Option>, String)>( r#" - SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at, + SELECT t.id, t.name, t.item_type, t.trashed_at, COALESCE(p.path || '/' || t.name, t.name) AS original_path FROM storage.trash_items t LEFT JOIN storage.folders p ON p.id = t.original_parent_id @@ -172,15 +160,8 @@ impl TrashRepository for TrashDbRepository { Ok(rows .into_iter() - .map(|(id, name, item_type, uid, trashed_at, path)| { - self.row_to_trashed_item( - id, - name, - item_type, - uid.unwrap_or(*user_id), - trashed_at, - path, - ) + .map(|(id, name, item_type, trashed_at, path)| { + self.row_to_trashed_item(id, name, item_type, *user_id, trashed_at, path) }) .collect()) } @@ -192,24 +173,15 @@ impl TrashRepository for TrashDbRepository { // The drive precheck in `pg_acl_engine` then resolves Owner-on-drive // → Delete-permission for items in shared drives. // - // Post-D7: `t.user_id` is nullable (new rows land NULL). The - // entity's `user_id` field is still non-optional; fall back to - // `Uuid::nil()` when the view row is NULL. AuthZ decisions - // don't consult this field — they've already resolved the - // caller's role on the target's drive. - let row = sqlx::query_as::< - _, - ( - Uuid, - String, - String, - Option, - Option>, - String, - ), - >( + // Post-D7: `t.user_id` no longer exists — the column is dropped + // from `storage.{files,folders}` and no longer projected by the + // view. The entity's `user_id` field is still non-optional; + // synthesize `Uuid::nil()`. AuthZ decisions don't consult this + // field — they've already resolved the caller's role on the + // target's drive. + let row = sqlx::query_as::<_, (Uuid, String, String, Option>, String)>( r#" - SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at, + SELECT t.id, t.name, t.item_type, t.trashed_at, COALESCE(p.path || '/' || t.name, t.name) AS original_path FROM storage.trash_items t LEFT JOIN storage.folders p ON p.id = t.original_parent_id @@ -221,15 +193,8 @@ impl TrashRepository for TrashDbRepository { .await .map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?; - Ok(row.map(|(id, name, item_type, uid, trashed_at, path)| { - self.row_to_trashed_item( - id, - name, - item_type, - uid.unwrap_or_else(Uuid::nil), - trashed_at, - path, - ) + Ok(row.map(|(id, name, item_type, trashed_at, path)| { + self.row_to_trashed_item(id, name, item_type, Uuid::nil(), trashed_at, path) })) } diff --git a/src/infrastructure/services/path_resolver_service.rs b/src/infrastructure/services/path_resolver_service.rs index 342bf46f..205041ca 100644 --- a/src/infrastructure/services/path_resolver_service.rs +++ b/src/infrastructure/services/path_resolver_service.rs @@ -78,7 +78,6 @@ impl PathResolverService { String, // name String, // path Option, // parent_id - Option, // user_id Uuid, // drive_id i64, // created_at i64, // modified_at @@ -90,7 +89,7 @@ impl PathResolverService { ), >( r#" - SELECT resource_type, id, name, path, parent_id, user_id, drive_id, + SELECT resource_type, id, name, path, parent_id, drive_id, created_at, modified_at, size, mime_type, folder_id, blob_hash, tree_modified_at FROM ( @@ -99,7 +98,6 @@ impl PathResolverService { fo.name, fo.path, fo.parent_id::text, - fo.user_id::text, fo.drive_id, EXTRACT(EPOCH FROM fo.created_at)::bigint AS created_at, EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at, @@ -123,7 +121,6 @@ impl PathResolverService { ELSE fi.name END AS path, NULL::text AS parent_id, - fi.user_id::text, fi.drive_id, EXTRACT(EPOCH FROM fi.created_at)::bigint AS created_at, EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at, @@ -160,7 +157,6 @@ impl PathResolverService { name, res_path, parent_id, - _uid, // Post-D7: `user_id` column no longer flowed into the DTO. drive_id, created_at, modified_at, diff --git a/src/infrastructure/services/search_index/content_index_worker.rs b/src/infrastructure/services/search_index/content_index_worker.rs index 11672562..a70a423a 100644 --- a/src/infrastructure/services/search_index/content_index_worker.rs +++ b/src/infrastructure/services/search_index/content_index_worker.rs @@ -243,21 +243,16 @@ impl ContentIndexWorker { // Authoritative state re-read: a queued 'upsert' whose row vanished // or got trashed in the meantime becomes a delete. // - // Post-D7: `fi.user_id` is nullable and new rows land NULL. - // The projected `user_id::text` therefore comes back as - // `Option`; we normalise to `""` at the tuple boundary - // so the downstream indexing code doesn't have to change. - // The Tantivy `user_id` field is defence-in-depth only — every - // query is Must-scoped by `drive_id`. - // (file_id, user_id, drive_id, name, blob_hash, mime, size). - // `user_id` is `Option` because post-D7 `storage.files.user_id` - // is nullable — new rows land NULL. - type FileIndexRow = (Uuid, Option, String, String, String, String, i64); + // Post-D7: `fi.user_id` is dropped — no longer projected. The + // Tantivy `user_id` field survives as defence-in-depth but now + // always indexes `""`. Every query is Must-scoped by `drive_id`. + // (file_id, drive_id, name, blob_hash, mime, size). + type FileIndexRow = (Uuid, String, String, String, String, i64); let files: Vec = if upsert_candidates.is_empty() { Vec::new() } else { sqlx::query_as( - "SELECT fi.id, fi.user_id::text, fi.drive_id::text, fi.name, + "SELECT fi.id, fi.drive_id::text, fi.name, fi.blob_hash, fi.mime_type, fi.size FROM storage.files fi WHERE fi.id = ANY($1) AND NOT fi.is_trashed", @@ -272,10 +267,10 @@ impl ContentIndexWorker { // Per-blob text: batch-read the extraction cache, extract misses. let wanted_hashes: Vec = files .iter() - .filter(|(_, _, _, name, _, mime, size)| { + .filter(|(_, _, name, _, mime, size)| { text_extractor::supports(name, mime) && *size as u64 <= self.max_extract_file_bytes }) - .map(|f| f.4.clone()) + .map(|f| f.3.clone()) .collect(); let mut text_by_hash: HashMap> = HashMap::new(); if !wanted_hashes.is_empty() { @@ -292,7 +287,7 @@ impl ContentIndexWorker { } let mut records = Vec::with_capacity(files.len()); - for (file_id, user_id, drive_id, name, blob_hash, mime, size) in files { + for (file_id, drive_id, name, blob_hash, mime, size) in files { let supported = text_extractor::supports(&name, &mime); let content = if !supported { None @@ -311,7 +306,7 @@ impl ContentIndexWorker { .map(|t| truncate_on_char(t, PREVIEW_BYTES)); records.push(IndexDocRecord { file_id: file_id.to_string(), - user_id: user_id.unwrap_or_default(), + user_id: String::new(), drive_id, name, content, diff --git a/tests/common/init-test-schema.sh b/tests/common/init-test-schema.sh index fb36972d..7eceb93f 100644 --- a/tests/common/init-test-schema.sh +++ b/tests/common/init-test-schema.sh @@ -75,9 +75,11 @@ BEGIN VALUES ('personal', admin_id, NULL) RETURNING id INTO drive_id; + -- Post-D7: `storage.folders.user_id` dropped. Ownership lives on the + -- drive-Owner role_grant below; provenance in `created_by`/`updated_by`. INSERT INTO storage.folders - (name, parent_id, user_id, drive_id, created_by, updated_by) - VALUES ('Personal', NULL, admin_id, drive_id, admin_id, admin_id) + (name, parent_id, drive_id, created_by, updated_by) + VALUES ('Personal', NULL, drive_id, admin_id, admin_id) RETURNING id INTO folder_id; UPDATE storage.drives SET root_folder_id = folder_id WHERE id = drive_id;