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:
@@ -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.';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user