Files
Oxicloud/migrations/20260902000000_files_folders_user_id_nullable.sql
T
Edouard Vanbelle 206a780494 feat(drive): stop writing in {file,folder}.user_id
plan:

    ```
    What step 5 is actually doing

    Goal: stop writing to storage.files.user_id / storage.folders.user_id on new INSERTs. This is a prerequisite for step 6 (dropping the column entirely). The column has existed since the pre-D0 single-owner era; every read that used to key on it has already been migrated to drive-membership grants over the last N days.

    What has to change together for this to be safe:

    ┌──────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────┐
    │    Piece     │                                                          What changes                                                           │                                                  Why                                                  │
    ├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
    │ Migration    │ Swap storage.files uniqueness indexes from (folder_id, name, user_id) to (drive_id, folder_id, name)                            │ Otherwise, two new rows with user_id = NULL would both be allowed (PG treats NULLs as distinct) —     │
    │ (a)          │                                                                                                                                 │ uniqueness silently breaks                                                                            │
    ├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
    │ Migration    │ ALTER user_id DROP NOT NULL on both tables                                                                                      │ Otherwise, dropping the INSERT bind violates NOT NULL and every write 500s                            │
    │ (b)          │                                                                                                                                 │                                                                                                       │
    ├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
    │ Migration    │ Drop dead user_id-leading indexes                                                                                               │ Cheap cleanup — nothing scans them anymore                                                            │
    │ (c)          │                                                                                                                                 │                                                                                                       │
    ├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
    │ PL/pgSQL (d) │ Rewrite storage.copy_folder_tree without user_id in the INSERT column list                                                      │ Cross-drive copy runs entirely in SQL, needs the same treatment                                       │
    ├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
    │ Rust (e)     │ ~12 INSERT column-list drops across folder_db_repository, file_blob_write_repository, drive_pg_repository, dedup_service,       │ Actual write path                                                                                     │
    │              │ folder_service, load-seed.rs                                                                                                    │                                                                                                       │
    ├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
    │ Rust (f)     │ Simplify folder_db_repository::create_folder's parent lookup to only fetch drive_id (was fetching (user_id, drive_id))          │ It's fetching a value it no longer needs                                                              │
    └──────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────┘
    ```
2026-07-03 00:23:35 +02:00

109 lines
5.0 KiB
SQL

-- ─────────────────────────────────────────────────────────────────────────
-- D7 step 5 — retire `user_id` as a write/uniqueness axis on
-- `storage.files` and `storage.folders`.
--
-- Every read that used to filter by `files.user_id = $caller` or
-- `folders.user_id = $caller` has already been migrated to a
-- drive-membership predicate (see D7-pass §6/§10 changes:
-- `file_blob_read_repository`, `folder_db_repository`,
-- `path_resolver_service`, `dedup_service`, plus `authz.require(Read, …)`
-- at every WebDAV consumer site). This migration removes the last
-- reason to keep binding `user_id` on writes:
--
-- 1. Files uniqueness indexes swap from `(folder_id, name, user_id)` /
-- `(name, user_id)` → `(drive_id, folder_id, name)` /
-- `(drive_id, name)`. Post-D0 `files.drive_id` is `NOT NULL`, so
-- the drive-scoped form is strictly stronger — a file is unique
-- by its position within its drive, not by "who used to own it".
-- The folder side already got this treatment in D0
-- (`20260802100002_drives_not_null.sql`).
--
-- 2. Dead user_id-leading indexes get dropped:
-- - `idx_files_user_id`, `idx_folders_user_id` — nothing scans by
-- `WHERE user_id = $1` any more.
-- - `idx_folders_trashed` — was `(user_id, is_trashed)`; the
-- trash listing moved to `(drive_id, is_trashed)` via the
-- same D7 rewrite.
-- - `idx_files_user_size_active` — was the per-user storage
-- usage summary; the reconciliation sweep now GROUPs by
-- `drive_id` (`storage_usage_service::update_all_drives_storage_usage`).
--
-- 3. `ALTER COLUMN user_id DROP NOT NULL` on both tables. The
-- column stays for compat with the follow-up column-drop
-- migration (D7 step 6) but new INSERTs will leave it NULL.
-- Existing rows keep their backfilled values until the drop.
--
-- Steps 4-6 (Rust INSERT binds dropped + PL/pgSQL copy_folder_tree
-- update) ship in the same commit so no in-flight INSERT ever
-- tries to bind a NOT NULL that just went away.
-- ── 1. Swap files uniqueness indexes ─────────────────────────────────────
--
-- Pre-D7: name unique within (folder, user). Post-D7: name unique within
-- (drive, folder). Since a drive has exactly one root folder tree and
-- a given file lives in exactly one drive, this is a strict tightening.
--
-- The `IF EXISTS` guards let this migration re-run cleanly against a DB
-- that's already been partially migrated (dev workflow).
DROP INDEX IF EXISTS storage.idx_files_unique_name_in_folder;
DROP INDEX IF EXISTS storage.idx_files_unique_name_at_root;
CREATE UNIQUE INDEX IF NOT EXISTS idx_files_unique_name_in_folder
ON storage.files (drive_id, folder_id, name)
WHERE NOT is_trashed AND folder_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_files_unique_name_at_root
ON storage.files (drive_id, name)
WHERE NOT is_trashed AND folder_id IS NULL;
-- ── 2. Drop dead user_id-leading indexes ─────────────────────────────────
DROP INDEX IF EXISTS storage.idx_files_user_id;
DROP INDEX IF EXISTS storage.idx_files_user_size_active;
DROP INDEX IF EXISTS storage.idx_folders_user_id;
DROP INDEX IF EXISTS storage.idx_folders_trashed;
-- ── 3. Allow NULL user_id on both tables ─────────────────────────────────
ALTER TABLE storage.files ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE storage.folders ALTER COLUMN user_id DROP NOT NULL;
-- ── 4. Post-flight sanity ────────────────────────────────────────────────
DO $BODY$
DECLARE
files_nullable BOOLEAN;
folders_nullable BOOLEAN;
new_files_uniq BOOLEAN;
BEGIN
SELECT is_nullable::boolean INTO files_nullable
FROM information_schema.columns
WHERE table_schema = 'storage'
AND table_name = 'files'
AND column_name = 'user_id';
SELECT is_nullable::boolean INTO folders_nullable
FROM information_schema.columns
WHERE table_schema = 'storage'
AND table_name = 'folders'
AND column_name = 'user_id';
SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = 'storage'
AND indexname = 'idx_files_unique_name_in_folder'
) INTO new_files_uniq;
IF NOT files_nullable THEN
RAISE EXCEPTION 'storage.files.user_id NOT NULL constraint did not drop';
END IF;
IF NOT folders_nullable THEN
RAISE EXCEPTION 'storage.folders.user_id NOT NULL constraint did not drop';
END IF;
IF NOT new_files_uniq THEN
RAISE EXCEPTION 'drive-scoped files uniqueness index did not land';
END IF;
END;
$BODY$;