feat(drive): improve Drive model

now Drive is purely a metadata
    each drive has always a root folder
    this model minimize Oxicloud changes, and simplify
    the Drive name is simply the folder's root's name
    note: owner of Drive has more permission that an owner of the root folder
This commit is contained in:
Edouard Vanbelle
2026-06-18 23:02:17 +02:00
parent eab7a609b9
commit 16ea08b093
26 changed files with 1067 additions and 460 deletions
@@ -20,7 +20,6 @@
CREATE TABLE IF NOT EXISTS storage.drives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
-- Discriminant. Two kinds today; extending the set is a DROP + ADD
-- CHECK constraint pair (no separate lookup table).
@@ -36,10 +35,24 @@ CREATE TABLE IF NOT EXISTS storage.drives (
default_for_user UUID
REFERENCES auth.users(id) ON DELETE CASCADE,
-- The drive's mount-point folder. The display name lives here (drives
-- have no `name` column — see docs/plan/drive.md §3). NULL at the
-- column type level so the atomic creation CTE can INSERT the drive
-- row before the root folder exists, then UPDATE this column from
-- a later CTE branch within the same statement (a column-level
-- NOT NULL would refuse that initial INSERT). The invariant
-- "every drive has a root folder" is enforced by the CTE being
-- the only creation path, plus the M2 backfill populating this
-- column for migrated drives. Code reading this column may treat
-- it as Uuid (not Option<Uuid>); a NULL here is a bug.
root_folder_id UUID
REFERENCES storage.folders(id) ON DELETE CASCADE,
-- Storage quota in bytes. NULL = no quota (admin override / system
-- drives). Initial value on personal-drive creation is taken from
-- the owner's `auth.users.storage_quota_bytes` at the application
-- layer.
-- layer. **Mutation is OxiCloud-admin only** (docs/plan/drive.md §7) —
-- not in the drive `owner` role bundle.
quota_bytes BIGINT,
-- Running total of bytes consumed. Maintained by D4's incremental
@@ -59,13 +72,19 @@ CREATE TABLE IF NOT EXISTS storage.drives (
);
COMMENT ON TABLE storage.drives IS
'Drive entity. Top-level container that owns a tree of folders/files; '
'membership lives in storage.role_grants with resource_type=''drive''. '
'Replaced the per-user My Folder wrapper at D0 (see docs/plan/drive.md).';
'Drive entity — pure metadata. The display name and mount point live '
'on the root folder (root_folder_id). Membership lives in '
'storage.role_grants with resource_type=''drive''. Replaces the '
'per-user My Folder wrapper at D0 (see docs/plan/drive.md §3).';
COMMENT ON COLUMN storage.drives.kind IS
'personal = single-owner (no add_member); shared = multi-member with full role roster.';
COMMENT ON COLUMN storage.drives.default_for_user IS
'Set iff this is the user''s default personal drive. NULL on secondaries and shared drives.';
COMMENT ON COLUMN storage.drives.root_folder_id IS
'Drive''s root folder. NULLable at the column level only so the '
'atomic creation CTE can write it mid-statement; populated invariant '
'enforced by application. Display name = SELECT name FROM '
'storage.folders WHERE id = root_folder_id.';
COMMENT ON COLUMN storage.drives.policies IS
'JSONB capability-flag bag; see docs/plan/drive.md §8 §15 for known keys.';
+107 -24
View File
@@ -1,25 +1,29 @@
-- ════════════════════════════════════════════════════════════════════════════
-- D0 / M2 — Drive backfill: create drives + stamp drive_id + provenance
-- D0 / M2 — Drive backfill: adopt wrappers + stamp drive_id + provenance
-- ════════════════════════════════════════════════════════════════════════════
-- Second of the D0 migration trio. Half (1) of the §A backfill — the safe,
-- focused half:
-- Second of the D0 migration trio. Implements §A of the migration plan in
-- docs/plan/drive.md — the "rename-and-adopt" model:
--
-- * For every internal user with a root folder, create a Personal drive.
-- * The folder literally named `My Folder - <username>` becomes the
-- user's default Personal drive (`default_for_user = <uid>`).
-- * Any sibling root folders become secondary Personal drives
-- (`default_for_user = NULL`, name carried over verbatim).
-- * For every internal user with a root folder, create a Personal drive
-- (metadata only — no `name` column; the display name lives on the
-- root folder).
-- * The folder literally named `My Folder - <username>` is **adopted
-- in place** as the user's default Personal drive's root folder:
-- `drives.root_folder_id` points at it, its `drive_id` is stamped,
-- and it is renamed to `Personal`. The wrapper row is NOT deleted;
-- descendants are NOT promoted. The AFTER-UPDATE folder cascade
-- trigger rewrites descendant `path`/`lpath` automatically when the
-- wrapper rename fires — no bulk path UPDATE in this migration.
-- * Any sibling root folders become secondary Personal drives'
-- root folders (`default_for_user = NULL`, original folder name
-- preserved). Same adoption pattern: drive_id stamped, drives.root_folder_id
-- wired, no rename.
-- * One owner role_grants row per new drive.
-- * Every existing folder/file row gets a `drive_id` (cascaded down the
-- ltree from the wrapper).
-- * Every existing folder/file row gets `created_by` and `updated_by`
-- backfilled from the existing `user_id` column.
--
-- The aggressive half (drop the wrapper folder, rewrite path columns,
-- strip the `My Folder - <username>/` prefix from every path/lpath value)
-- lands in M2b — kept separate so the tree-shape rewrite can be reviewed
-- in isolation.
--
-- External users (`auth.users.is_external = TRUE`) are intentionally
-- skipped — they have no root folder of their own, only role_grants
-- against other users' resources.
@@ -56,6 +60,49 @@ BEGIN
END $BODY$;
-- ── Pre-flight 1b: refuse on rename collision with sibling root 'Personal' ─
-- The default-wrapper rename in step 4 changes `My Folder - <username>` →
-- `Personal`. The pre-M3 folder unique index is user_id-scoped
-- (`(name, user_id) WHERE parent_id IS NULL`), so a user who already has
-- a SQL-created sibling root literally named `Personal` would trip the
-- index when M2 tries to rename the wrapper. Surface the collision now —
-- operator renames the offending sibling before retrying, then it gets
-- adopted as a secondary drive with whatever new name it carries.
DO $BODY$
DECLARE
collisions BIGINT;
BEGIN
SELECT count(*) INTO collisions
FROM auth.users u
JOIN storage.folders wrapper
ON wrapper.user_id = u.id
AND wrapper.parent_id IS NULL
AND NOT wrapper.is_trashed
AND wrapper.name = 'My Folder - ' || u.username
JOIN storage.folders sibling
ON sibling.user_id = u.id
AND sibling.parent_id IS NULL
AND NOT sibling.is_trashed
AND sibling.id != wrapper.id
AND sibling.name = 'Personal'
WHERE NOT u.is_external;
IF collisions > 0 THEN
RAISE EXCEPTION
'D0 backfill refused: % user(s) have both a `My Folder - <username>` '
'wrapper AND a sibling root named ''Personal''. The wrapper rename '
'step would collide on the user_id-scoped folder unique index. '
'Rename the offending sibling first. Query to inspect: SELECT u.id, '
'u.username FROM auth.users u JOIN storage.folders w ON w.user_id=u.id '
'AND w.parent_id IS NULL AND w.name=''My Folder - ''||u.username '
'JOIN storage.folders s ON s.user_id=u.id AND s.parent_id IS NULL '
'AND s.id!=w.id AND s.name=''Personal'' WHERE NOT u.is_external;',
collisions;
END IF;
END $BODY$;
-- ── Pre-flight 2: report sibling-root distribution (informational) ─────────
-- Most users have exactly one root (`My Folder - <username>`). Some may
-- have SQL-added siblings — those become secondary drives. Surface the
@@ -173,16 +220,15 @@ BEGIN
END $BODY$;
-- ── 2. Insert the drive rows ───────────────────────────────────────────────
-- Default drives carry the i18n-neutral name 'Personal' (renameable
-- later via the drive settings panel). Secondary drives carry their
-- original folder name verbatim.
-- ── 2. Insert the drive rows (metadata only — no `name` column) ───────────
-- Drives are pure metadata under the new design (docs/plan/drive.md §3).
-- The display name lives on the root folder; the wrapper is renamed in
-- step 4b for default drives and kept as-is for secondaries.
INSERT INTO storage.drives
(id, name, kind, default_for_user, quota_bytes)
(id, kind, default_for_user, quota_bytes)
SELECT
p.new_drive_id,
CASE WHEN p.is_default THEN 'Personal' ELSE p.wrapper_name END,
'personal',
CASE WHEN p.is_default THEN p.user_id ELSE NULL END,
p.quota
@@ -199,16 +245,29 @@ SELECT 'user', p.user_id, 'drive', p.new_drive_id, 'owner', p.user_id
FROM _drive_plan p;
-- ── 4. Stamp drive_id on each wrapper folder ──────────────────────────────
-- The wrapper still exists as a folder during M2 (the wrapper-drop lives
-- in M2b). Setting drive_id on the wrapper lets the cascade in §5 walk
-- the ltree subtree without needing a separate index.
-- ── 4. Adopt the wrapper as the drive's root folder ───────────────────────
-- 4a. Stamp drive_id on each wrapper so the cascade in §5 can walk the
-- ltree subtree without a separate index.
-- 4b. Rename the default-drive wrapper from `My Folder - <username>` to
-- `Personal` (the canonical default name; renameable via the folder
-- API later). The BEFORE-UPDATE folder path trigger fires on the
-- rename and the AFTER-UPDATE cascade trigger rewrites every
-- descendant `path` / `lpath` automatically — no per-row UPDATE here.
-- 4c. Wire drives.root_folder_id to the wrapper. This is the adoption
-- step: the wrapper row IS the drive's root folder after M2 (no
-- wrapper-deletion, no descendant promotion).
UPDATE storage.folders f
SET drive_id = p.new_drive_id
SET drive_id = p.new_drive_id,
name = CASE WHEN p.is_default THEN 'Personal' ELSE f.name END
FROM _drive_plan p
WHERE f.id = p.wrapper_id;
UPDATE storage.drives d
SET root_folder_id = p.wrapper_id
FROM _drive_plan p
WHERE d.id = p.new_drive_id;
-- ── 5. Cascade drive_id down the folder tree ──────────────────────────────
-- For every folder descended from a wrapper, set drive_id to that
@@ -284,6 +343,7 @@ DECLARE
grantless_drives BIGINT;
null_folder_drive_id BIGINT;
null_file_drive_id BIGINT;
rootless_drives BIGINT;
BEGIN
SELECT count(*) INTO missing_default
FROM auth.users u
@@ -320,6 +380,29 @@ BEGIN
grantless_drives;
END IF;
-- Root-folder adoption invariant (docs/plan/drive.md §3): every
-- drive must point at a real folder row whose drive_id closes the
-- cycle. The column is NULLable at the type level so the atomic
-- CTE can write it mid-statement; this check enforces the data
-- invariant after the migration.
SELECT count(*) INTO rootless_drives
FROM storage.drives d
WHERE d.root_folder_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM storage.folders f
WHERE f.id = d.root_folder_id
AND f.drive_id = d.id
AND f.parent_id IS NULL
);
IF rootless_drives > 0 THEN
RAISE EXCEPTION
'D0 backfill consistency check failed: % drive(s) have no '
'valid root_folder_id (NULL, or pointing at a folder that '
'isn''t a root in this drive). Investigate before declaring '
'the migration successful.',
rootless_drives;
END IF;
SELECT count(*) INTO null_folder_drive_id
FROM storage.folders f
WHERE f.drive_id IS NULL
@@ -56,6 +56,28 @@ CREATE INDEX IF NOT EXISTS idx_folders_drive_id ON storage.folders (drive_id);
CREATE INDEX IF NOT EXISTS idx_files_drive_id ON storage.files (drive_id);
-- ── 3b. Drive-scoped folder uniqueness indexes ─────────────────────────────
-- Pre-D0 the "no duplicate folder name under the same parent for the same
-- user" constraint was user_id-scoped (docs/plan/drive.md §10). The
-- semantics users actually want is "no duplicate names *within a drive*"
-- — a folder named "Reports" in your Personal drive shouldn't preclude
-- another "Reports" in a shared "Team" drive. Flip the scope here, now
-- that every row has a drive_id.
--
-- Same partial predicate as the originals (NOT is_trashed, plus the
-- root-vs-non-root split via parent_id IS NULL).
DROP INDEX IF EXISTS storage.idx_folders_unique_name;
DROP INDEX IF EXISTS storage.idx_folders_unique_name_root;
CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_unique_name
ON storage.folders(parent_id, name, drive_id)
WHERE NOT is_trashed AND parent_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_unique_name_root
ON storage.folders(name, drive_id)
WHERE NOT is_trashed AND parent_id IS NULL;
-- ── 4. Post-flight: confirm constraints landed ────────────────────────────
-- Belt-and-suspenders verification that the NOT NULL + FK actually
-- exist after the ALTERs above. Any failure here means PostgreSQL