206a780494
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 │
└──────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────┘
```
109 lines
5.1 KiB
PL/PgSQL
109 lines
5.1 KiB
PL/PgSQL
-- ═══════════════════════════════════════════════════════════════════════════
|
|
-- D0-step-8 companion — cascade-delete guard for the orphan-root check.
|
|
--
|
|
-- Fixes a latent bug in `storage.check_no_orphan_root_folder` that
|
|
-- surfaced during user-delete tests. Repro (verified against a fresh
|
|
-- test DB with no other data):
|
|
--
|
|
-- INSERT INTO auth.users … one user
|
|
-- Run the atomic personal-drive create (drive + root folder +
|
|
-- drives.root_folder_id wire-up + owner role_grant)
|
|
-- DELETE FROM auth.users WHERE id = <that user>
|
|
-- → ERROR: Orphan root folder rejected …
|
|
--
|
|
-- Root cause — the FK columns `storage.folders.created_by` and
|
|
-- `storage.folders.updated_by` are declared
|
|
-- `REFERENCES auth.users(id) ON DELETE SET NULL` (D0/M1 migration
|
|
-- `20260802100000_drives_schema_additive.sql`, lines 117-129). So when
|
|
-- `DELETE FROM auth.users` runs, PostgreSQL cascades a SET NULL
|
|
-- update onto every folder row referencing that user — including that
|
|
-- user's own personal-drive root folder. That UPDATE fires the
|
|
-- DEFERRED `trg_no_orphan_root_folder` constraint trigger, which queues
|
|
-- a check on the row's `NEW` state.
|
|
--
|
|
-- Cascade order (all inside the same transaction) is: SET NULL on the
|
|
-- folder → cascade DELETE storage.drives (default_for_user FK) →
|
|
-- cascade DELETE storage.folders (drive_id FK). By COMMIT, the drive
|
|
-- and the folder are both gone. When the deferred trigger fires, its
|
|
-- query `EXISTS (drive d WHERE d.id = NEW.drive_id AND d.root_folder_id
|
|
-- = NEW.id)` finds no drive, so it raises. The check is correct in
|
|
-- isolation — but the row it's checking no longer exists, so the
|
|
-- invariant it's protecting no longer applies.
|
|
--
|
|
-- Fix: add an existence guard before the drive lookup. If the row has
|
|
-- been deleted in the same transaction, skip the check — a deleted row
|
|
-- can't be an orphan by definition.
|
|
--
|
|
-- This preserves the original invariant on all live rows:
|
|
-- * The atomic four-write create transaction still gets checked at
|
|
-- COMMIT and still requires the drive→folder wire-up (the folder
|
|
-- row exists at COMMIT because we didn't delete it).
|
|
-- * Direct SQL that tries to insert an orphan root folder is still
|
|
-- rejected (the INSERT queues a check, the row exists at COMMIT,
|
|
-- the drive lookup fails, exception raised).
|
|
-- * The only new behaviour is "if this row was deleted before COMMIT,
|
|
-- silently skip" — which is what the caller wanted anyway.
|
|
--
|
|
-- No table changes, no data changes, no reverse migration needed —
|
|
-- `CREATE OR REPLACE FUNCTION` is idempotent, and every future call
|
|
-- of the trigger picks up the new body immediately.
|
|
|
|
CREATE OR REPLACE FUNCTION storage.check_no_orphan_root_folder()
|
|
RETURNS trigger AS $$
|
|
BEGIN
|
|
-- Non-root rows are guaranteed correct by their parent_id FK.
|
|
IF NEW.parent_id IS NOT NULL THEN
|
|
RETURN NULL;
|
|
END IF;
|
|
|
|
-- Trashed root folders are soft-deleted in place — the resolver
|
|
-- never lands on them, and they were valid roots before they got
|
|
-- trashed. Skip enforcement; the row's history is preserved.
|
|
IF NEW.is_trashed THEN
|
|
RETURN NULL;
|
|
END IF;
|
|
|
|
-- Cascade-delete guard (NEW in this migration).
|
|
--
|
|
-- The trigger is DEFERRABLE INITIALLY DEFERRED — it fires at COMMIT
|
|
-- with `NEW` captured at trigger-queue time. If the row was
|
|
-- subsequently deleted in the same transaction (e.g. the cascade
|
|
-- path from `DELETE FROM auth.users` → SET NULL on created_by /
|
|
-- updated_by → cascade DELETE storage.drives → cascade DELETE
|
|
-- storage.folders), the invariant no longer applies: there's no
|
|
-- orphan because the row itself is gone.
|
|
IF NOT EXISTS (SELECT 1 FROM storage.folders WHERE id = NEW.id) THEN
|
|
RETURN NULL;
|
|
END IF;
|
|
|
|
-- The core check: some drive must point at this row as its
|
|
-- root_folder_id, AND that drive must be the same one carrying
|
|
-- our drive_id (the 1:1 bidirectional invariant from §3).
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM storage.drives d
|
|
WHERE d.id = NEW.drive_id
|
|
AND d.root_folder_id = NEW.id
|
|
) THEN
|
|
RAISE EXCEPTION
|
|
'Orphan root folder rejected: storage.folders id=% has '
|
|
'parent_id IS NULL and drive_id=%, but no drive has '
|
|
'root_folder_id pointing at it. Root folders must be '
|
|
'created via the atomic four-write transaction (see '
|
|
'docs/plan/drive.md §3 and DrivePgRepository::'
|
|
'create_personal_drive_atomic); direct SQL is not '
|
|
'supported.',
|
|
NEW.id, NEW.drive_id;
|
|
END IF;
|
|
|
|
RETURN NULL;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON FUNCTION storage.check_no_orphan_root_folder() IS
|
|
'DB-level guard for the "every root folder belongs to a drive" '
|
|
'invariant. Wired as a DEFERRABLE INITIALLY DEFERRED constraint '
|
|
'trigger so the atomic create transaction (folder INSERTed before '
|
|
'drive UPDATEd) commits cleanly. Skips the check on rows that were '
|
|
'deleted in the same tx (cascade path from user delete). See '
|
|
'docs/plan/drive.md §3.';
|