165 lines
7.5 KiB
PL/PgSQL
165 lines
7.5 KiB
PL/PgSQL
-- ════════════════════════════════════════════════════════════════════════════
|
|
-- D0 / Step 8 — No-orphan-root-folder constraint trigger
|
|
-- ════════════════════════════════════════════════════════════════════════════
|
|
-- Closes the "every root folder must belong to a drive" invariant at the DB
|
|
-- level (docs/plan/drive.md §3 "DB-level invariant: no orphan root folder"
|
|
-- and §10 Phase A step 8).
|
|
--
|
|
-- The application-layer guarantee is the atomic four-write transaction in
|
|
-- DrivePgRepository::create_personal_drive_atomic — drive + root folder +
|
|
-- drives.root_folder_id wire-up + Owner role_grant, all-or-nothing. This
|
|
-- migration adds the DB-level belt for the suspenders: a CONSTRAINT TRIGGER
|
|
-- that refuses any `storage.folders` row with `parent_id IS NULL` unless
|
|
-- some drive's `root_folder_id` points back at it.
|
|
--
|
|
-- DEFERRABLE INITIALLY DEFERRED is mandatory because the atomic creation
|
|
-- order is folder-INSERTed → drive-UPDATEd → COMMIT; an immediate trigger
|
|
-- would fire after the folder INSERT (before the drive UPDATE) and refuse
|
|
-- the row even though the transaction would close cleanly. Deferred to
|
|
-- COMMIT, the check sees the wired state.
|
|
--
|
|
-- What this migration does NOT cover:
|
|
-- * The reverse direction — "drives.root_folder_id must point at a folder
|
|
-- whose parent_id IS NULL". A follow-up trigger on storage.drives can
|
|
-- close that seam; today the cascade FKs and the application invariant
|
|
-- keep it correct.
|
|
-- * DELETE handling — folder DELETE cascades to drive DELETE via the
|
|
-- drives.root_folder_id ON DELETE CASCADE FK from M1, so the
|
|
-- "deleting the root would leave the drive dangling" path is
|
|
-- structurally prevented.
|
|
|
|
|
|
-- ── Pre-flight: refuse if any existing orphan root folders are present ────
|
|
-- Same pattern as M2: scan current data, RAISE EXCEPTION on any violation
|
|
-- so the trigger doesn't land into an inconsistent dataset that would
|
|
-- silently break later as soon as one of those rows gets UPDATEd.
|
|
--
|
|
-- A row is an "orphan root folder" iff `parent_id IS NULL` and no drive's
|
|
-- `root_folder_id` equals its id. Trashed rows are exempt (they're soft-
|
|
-- deleted in place and the chroot resolver never lands on them).
|
|
|
|
DO $BODY$
|
|
DECLARE
|
|
orphan_count BIGINT;
|
|
BEGIN
|
|
SELECT count(*) INTO orphan_count
|
|
FROM storage.folders f
|
|
WHERE f.parent_id IS NULL
|
|
AND NOT f.is_trashed
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM storage.drives d
|
|
WHERE d.root_folder_id = f.id
|
|
);
|
|
|
|
IF orphan_count > 0 THEN
|
|
RAISE EXCEPTION
|
|
'D0 step-8 migration refused: % root folder(s) (parent_id IS NULL, '
|
|
'not trashed) have no drive pointing at them via root_folder_id. '
|
|
'These would silently fail the constraint trigger on their next '
|
|
'UPDATE. Inspect with: SELECT f.id, f.user_id, f.drive_id, f.name '
|
|
'FROM storage.folders f WHERE f.parent_id IS NULL AND NOT f.is_trashed '
|
|
'AND NOT EXISTS (SELECT 1 FROM storage.drives d WHERE d.root_folder_id '
|
|
'= f.id); — then either wire each row to a drive or trash it before '
|
|
'retrying.',
|
|
orphan_count;
|
|
END IF;
|
|
END $BODY$;
|
|
|
|
|
|
-- ── 1. The check function ─────────────────────────────────────────────────
|
|
-- AFTER trigger so the row is already in the snapshot — the lookup against
|
|
-- storage.drives correctly sees the UPDATE that closed the cycle (when
|
|
-- called from the atomic transaction, that UPDATE happens later in the
|
|
-- same tx; the DEFERRED firing time waits for COMMIT so visibility is
|
|
-- correct).
|
|
|
|
CREATE OR REPLACE FUNCTION storage.check_no_orphan_root_folder()
|
|
RETURNS trigger AS $$
|
|
BEGIN
|
|
-- The trigger fires for every INSERT/UPDATE on storage.folders. We
|
|
-- only need to enforce the invariant on root rows; non-root folders
|
|
-- 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;
|
|
|
|
-- The core check: some drive must be pointing 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. See docs/plan/drive.md §3.';
|
|
|
|
|
|
-- ── 2. The constraint trigger ─────────────────────────────────────────────
|
|
-- CONSTRAINT TRIGGER (vs regular trigger) is what lets us declare
|
|
-- DEFERRABLE INITIALLY DEFERRED. Without it the row-level fire happens
|
|
-- immediately after the INSERT and the atomic transaction can't possibly
|
|
-- have UPDATEd drives.root_folder_id yet — every legitimate create would
|
|
-- be rejected.
|
|
--
|
|
-- CONSTRAINT TRIGGERs don't support a WHEN clause; the parent_id /
|
|
-- is_trashed filtering lives inside the function above.
|
|
|
|
DROP TRIGGER IF EXISTS trg_no_orphan_root_folder ON storage.folders;
|
|
CREATE CONSTRAINT TRIGGER trg_no_orphan_root_folder
|
|
AFTER INSERT OR UPDATE ON storage.folders
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW EXECUTE FUNCTION storage.check_no_orphan_root_folder();
|
|
|
|
|
|
-- ── 3. Post-flight: confirm the trigger landed ────────────────────────────
|
|
-- Belt-and-suspenders: PostgreSQL silently does nothing if CREATE TRIGGER
|
|
-- fails to attach (unlikely, but a cosmic-ray check). Refusing the
|
|
-- migration here surfaces the bug rather than letting it commit silently.
|
|
|
|
DO $BODY$
|
|
DECLARE
|
|
trigger_exists BOOLEAN;
|
|
BEGIN
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM pg_trigger t
|
|
JOIN pg_class c ON c.oid = t.tgrelid
|
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE n.nspname = 'storage'
|
|
AND c.relname = 'folders'
|
|
AND t.tgname = 'trg_no_orphan_root_folder'
|
|
AND NOT t.tgisinternal
|
|
) INTO trigger_exists;
|
|
|
|
IF NOT trigger_exists THEN
|
|
RAISE EXCEPTION
|
|
'D0 step-8 migration post-flight failed: trigger '
|
|
'trg_no_orphan_root_folder did not attach to storage.folders.';
|
|
END IF;
|
|
END $BODY$;
|