175 lines
8.8 KiB
PL/PgSQL
175 lines
8.8 KiB
PL/PgSQL
-- ════════════════════════════════════════════════════════════════════════════
|
|
-- D0 / M1 — Drive foundation: additive schema only
|
|
-- ════════════════════════════════════════════════════════════════════════════
|
|
-- First of three D0 migrations.
|
|
-- M1 (this file) additive — creates storage.drives + nullable columns.
|
|
-- M2 (next) backfill — promotes wrapper folders, fills drive_id.
|
|
-- M3 (final) constraints — NOT NULL + FKs + drive_id indexes.
|
|
--
|
|
-- This file is **safe to run on a populated database without an outage**.
|
|
-- It only ADDs structure (new table, new nullable columns, extended CHECK
|
|
-- constraints, new FK targets). No row is modified; no existing query
|
|
-- needs to be aware of the new columns yet.
|
|
--
|
|
-- The migration is reversible at this stage: dropping the new table and
|
|
-- the new columns leaves the database identical to its pre-D0 state. The
|
|
-- dual-write / data-movement phase (M2) is where rollback becomes
|
|
-- progressively harder.
|
|
|
|
-- ── 1. storage.drives — the central drive entity ────────────────────────────
|
|
|
|
CREATE TABLE IF NOT EXISTS storage.drives (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Discriminant. Two kinds today; extending the set is a DROP + ADD
|
|
-- CHECK constraint pair (no separate lookup table).
|
|
-- 'personal' = single-owner, no membership API
|
|
-- 'shared' = multi-member, full role roster, group-aware
|
|
kind TEXT NOT NULL
|
|
CHECK (kind IN ('personal', 'shared')),
|
|
|
|
-- Set iff this is the user's default personal drive. The partial
|
|
-- unique index below enforces "one default drive per user" without
|
|
-- blocking secondaries (NULL means "not the default"); shared drives
|
|
-- always have NULL here.
|
|
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. **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
|
|
-- counters; on D0 backfilled from the per-user counters as a
|
|
-- starting baseline.
|
|
used_bytes BIGINT NOT NULL DEFAULT 0,
|
|
|
|
-- Capability flags / feature toggles bag (see docs/plan/drive.md §8
|
|
-- and §15 for the known keys: forbid_public_links,
|
|
-- forbid_external_sharing, include_in_photo_index, forbid_music_index,
|
|
-- etc.). Unknown keys preserved verbatim — the schema is
|
|
-- intentionally permissive so future flags land without migration.
|
|
policies JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
COMMENT ON TABLE storage.drives IS
|
|
'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.';
|
|
|
|
-- "One default drive per user." Partial unique index — NULLs (every
|
|
-- non-default row) are excluded from the constraint surface.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_drives_default_for_user_unique
|
|
ON storage.drives (default_for_user)
|
|
WHERE default_for_user IS NOT NULL;
|
|
|
|
-- Hot-path "what's this drive?" lookup by kind for admin / D3 flows
|
|
-- ("list every shared drive").
|
|
CREATE INDEX IF NOT EXISTS idx_drives_kind ON storage.drives (kind);
|
|
|
|
|
|
-- ── 2. drive_id columns on folders + files (NULL during M1) ────────────────
|
|
-- M2 fills these in for every existing row; M3 promotes them to NOT NULL
|
|
-- and adds the FK + index. Keep nullable here so the migration runs on a
|
|
-- populated DB without violating a constraint.
|
|
|
|
ALTER TABLE storage.folders ADD COLUMN IF NOT EXISTS drive_id UUID;
|
|
ALTER TABLE storage.files ADD COLUMN IF NOT EXISTS drive_id UUID;
|
|
|
|
|
|
-- ── 3. Provenance columns: created_by / updated_by ─────────────────────────
|
|
-- D0 adds these on folders + files (see docs/plan/drive.md §14). FKs use
|
|
-- ON DELETE SET NULL so deleting a user nulls these out instead of
|
|
-- cascading the resource away. M2 backfills from the existing `user_id`
|
|
-- column so pre-Drive content carries authentic provenance from day one.
|
|
|
|
ALTER TABLE storage.folders
|
|
ADD COLUMN IF NOT EXISTS created_by UUID
|
|
REFERENCES auth.users(id) ON DELETE SET NULL;
|
|
ALTER TABLE storage.folders
|
|
ADD COLUMN IF NOT EXISTS updated_by UUID
|
|
REFERENCES auth.users(id) ON DELETE SET NULL;
|
|
|
|
ALTER TABLE storage.files
|
|
ADD COLUMN IF NOT EXISTS created_by UUID
|
|
REFERENCES auth.users(id) ON DELETE SET NULL;
|
|
ALTER TABLE storage.files
|
|
ADD COLUMN IF NOT EXISTS updated_by UUID
|
|
REFERENCES auth.users(id) ON DELETE SET NULL;
|
|
|
|
COMMENT ON COLUMN storage.folders.created_by IS
|
|
'Who originally created the folder. NULL when the original creator''s '
|
|
'auth.users row has since been deleted.';
|
|
COMMENT ON COLUMN storage.folders.updated_by IS
|
|
'Who last touched the folder (rename, move, metadata change). Same '
|
|
'write-path discipline as updated_at.';
|
|
COMMENT ON COLUMN storage.files.created_by IS
|
|
'Who originally uploaded the file. NULL when the original uploader''s '
|
|
'auth.users row has since been deleted.';
|
|
COMMENT ON COLUMN storage.files.updated_by IS
|
|
'Who last touched the file (rename, move, overwrite, restore). Same '
|
|
'write-path discipline as updated_at.';
|
|
|
|
|
|
-- ── 4. role_grants resource_type CHECK — admit 'drive' ─────────────────────
|
|
-- The D-Prep migration's CHECK only listed 'folder' and 'file'. Drives
|
|
-- need to be a valid resource_type so the lifecycle hook (D0-9) and the
|
|
-- membership API (D2) can write `role_grants` rows with
|
|
-- resource_type='drive'.
|
|
|
|
ALTER TABLE storage.role_grants
|
|
DROP CONSTRAINT IF EXISTS role_grants_resource_type_check;
|
|
ALTER TABLE storage.role_grants
|
|
ADD CONSTRAINT role_grants_resource_type_check
|
|
CHECK (resource_type IN ('folder', 'file', 'drive'));
|
|
|
|
|
|
-- ── 5. updated_at trigger for storage.drives ───────────────────────────────
|
|
-- Mirror the convention from auth.users / storage.folders / storage.files
|
|
-- so rename / quota-change / policy-toggle bumps updated_at automatically.
|
|
-- Drive owners shouldn't have to remember to maintain this.
|
|
|
|
CREATE OR REPLACE FUNCTION storage.drives_touch_updated_at()
|
|
RETURNS trigger AS $$
|
|
BEGIN
|
|
NEW.updated_at := now();
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS trg_drives_touch_updated_at ON storage.drives;
|
|
CREATE TRIGGER trg_drives_touch_updated_at
|
|
BEFORE UPDATE ON storage.drives
|
|
FOR EACH ROW EXECUTE FUNCTION storage.drives_touch_updated_at();
|