fix(migrations): renumber drive migrations to avoid collision with 20260802000000_blob_gc_grace
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- 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();
|
||||
Reference in New Issue
Block a user