Merge upstream/main into feat/external-file-mounts

Resolve conflicts between the external-file-mounts feature and upstream's
D5/D7 refactor (per-file provenance, keyset pagination, cross-drive move
gates, resource-access hook, folder-cascade lifecycle hook).

Key resolutions:
- FolderService::new now takes (repo, authz, file_lifecycle, mount_router);
  all callers + DI updated.
- FileRetrievalService / FileManagementService keep both the mount_router
  and the new resource_access_hook / drive_repo / storage_usage wiring.
- list_files_batch_with_perms: adapt the mount branch from offset- to
  keyset (after_name) pagination, mirroring paginate_mount_entries.
- download_file_impl: keep upstream's &HeaderMap + `impl IntoResponse + use<>`
  signature, retain the mount-download branch.
- Mount DTOs: the retired `owner_id` field maps onto created_by/updated_by
  (the mount owner) — the fields the frontend now uses for owner display.
- admin/+page.svelte: keep upstream's user-delete modal + the 'mounts' tab.
- Bump memmap2 0.9.10 -> 0.9.11 (RUSTSEC critical advisory fix) and
  regenerate Cargo.lock against the merged Cargo.toml.
This commit is contained in:
Bradley Nelson
2026-07-21 17:09:36 -06:00
600 changed files with 105575 additions and 14440 deletions
@@ -0,0 +1,14 @@
-- Trigram indexes for the user search path (NC sharee autocomplete + admin
-- user search), which filters with a leading-wildcard `ILIKE '%q%'` that no
-- btree can serve — every keystroke was a full `auth.users` seq scan.
--
-- Mirrors the existing `gin_trgm_ops` indexes on contacts / files / folders
-- (pg_trgm is a hard startup requirement, see 20260307000000). Measured in
-- benches/ROUND12.md §1: 26-row sharee page over 3 000 users drops from
-- 2.37 ms (narrow read, seq scan) to 0.22 ms; the gap widens with user count.
CREATE INDEX IF NOT EXISTS idx_users_username_trgm
ON auth.users USING gin (username gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_users_email_trgm
ON auth.users USING gin (email gin_trgm_ops);
@@ -0,0 +1,44 @@
-- Switch personal-drive quota semantics from "every drive owns its quota"
-- to "user envelope on the SUM of personal-drive `used_bytes`".
-- See docs/plan/drive.md §7.
--
-- Two idempotent steps:
-- 1. NULL `drives.quota_bytes` for every `kind='personal'` row. After this
-- migration the column is meaningful only for shared drives; personal
-- drives' cap is `auth.users.storage_quota_bytes`.
-- 2. Resync `auth.users.storage_used_bytes` to the sum-of-personal-drives
-- formula. Prior deltas may have over-counted by including shared-drive
-- uploads in the user counter; this snaps every user back to the new
-- envelope. Same shape the periodic sweep uses going forward.
--
-- Both statements `IS DISTINCT FROM`-guarded so reruns are cheap no-ops on
-- already-migrated databases. The order — NULL first, then resync — doesn't
-- matter for correctness but follows the doc's narrative.
-- 1. Drop per-drive quotas for personal drives (no-op for already-NULL rows).
UPDATE storage.drives
SET quota_bytes = NULL
WHERE kind = 'personal'
AND quota_bytes IS NOT NULL;
-- 2. Resync user-side cached counter to the new sum-of-personal-drives
-- semantics. Mirrors `update_all_users_storage_usage` in
-- `storage_usage_service.rs`. External users excluded (no storage).
UPDATE auth.users u
SET storage_used_bytes = COALESCE(t.total, 0)
FROM auth.users u2
LEFT JOIN (
SELECT g.subject_id AS user_id,
SUM(d.used_bytes)::bigint AS total
FROM storage.drives d
JOIN storage.role_grants g
ON g.resource_type = 'drive'
AND g.resource_id = d.id
AND g.role = 'owner'
AND g.subject_type = 'user'
WHERE d.kind = 'personal'
GROUP BY g.subject_id
) t ON t.user_id = u2.id
WHERE u.id = u2.id
AND NOT u2.is_external
AND u.storage_used_bytes IS DISTINCT FROM COALESCE(t.total, 0);
@@ -0,0 +1,73 @@
-- D6: cross-drive folder moves must propagate `drive_id` to the moved
-- folder's subtree (descendant folders + files), not just `lpath`.
--
-- Today's `cascade_folder_path()` trigger only rewrites `path` + `lpath`
-- on descendants — it leaves `drive_id` untouched. That worked when
-- moves were intra-drive (drive_id never changed), but after D5 the
-- `forbid_cross_drive_move` policy gate exposed the gap: a successful
-- cross-drive move (gate off OR not yet enforced) leaves the subtree
-- in an inconsistent state — lpath rooted in drive B but `drive_id`
-- column still drive A on every descendant row. Any drive-id-scoped
-- query then returns the wrong drive's content.
--
-- The fix is to extend the cascade trigger so a change in the parent
-- folder's `drive_id` (the only thing that changes drive_id during a
-- move) cascades to every descendant folder + every descendant file.
-- Files cascade too because `storage.files.drive_id` is the canonical
-- per-file drive-membership signal (D0 dual-write).
--
-- Migration is idempotent via `CREATE OR REPLACE FUNCTION`.
CREATE OR REPLACE FUNCTION storage.cascade_folder_path()
RETURNS trigger AS $$
BEGIN
IF pg_trigger_depth() > 1 THEN
RETURN NEW;
END IF;
IF OLD.path IS DISTINCT FROM NEW.path OR OLD.lpath IS DISTINCT FROM NEW.lpath THEN
-- Single batch update: rewrite path/lpath for every descendant
-- folder at once via the GiST lpath index.
UPDATE storage.folders
SET path = NEW.path || substr(path, length(OLD.path) + 1),
lpath = NEW.lpath || subpath(lpath, nlevel(OLD.lpath))
WHERE lpath <@ OLD.lpath
AND id != NEW.id;
END IF;
-- D6: cascade `drive_id` to every descendant folder + file when the
-- moved row's drive_id has changed (cross-drive move). The GiST
-- index covers the folder predicate; `storage.files.drive_id` is
-- updated through the folder→file FK relation since files only
-- carry `folder_id` directly (drive_id is a denormalised dual-write).
--
-- Triggered on the column-list `AFTER UPDATE OF path, lpath, drive_id`
-- registration below — so this branch only runs when the explicit
-- move statement on the moved row sets `drive_id` to a new value.
-- The descendant batch UPDATE that fires from the path/lpath branch
-- above doesn't touch drive_id, so the trigger doesn't recurse on
-- the per-descendant rewrite.
IF OLD.drive_id IS DISTINCT FROM NEW.drive_id THEN
UPDATE storage.folders
SET drive_id = NEW.drive_id
WHERE lpath <@ NEW.lpath
AND drive_id = OLD.drive_id;
UPDATE storage.files f
SET drive_id = NEW.drive_id
FROM storage.folders fo
WHERE f.folder_id = fo.id
AND fo.lpath <@ NEW.lpath
AND f.drive_id = OLD.drive_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Re-register the trigger with `drive_id` added to the column list so the
-- trigger fires when a move sets a new drive_id on the moved row. (CREATE
-- OR REPLACE TRIGGER replaces the same name in place; no DROP needed.)
CREATE OR REPLACE TRIGGER trg_folders_cascade_path
AFTER UPDATE OF path, lpath, drive_id ON storage.folders
FOR EACH ROW EXECUTE FUNCTION storage.cascade_folder_path();
@@ -0,0 +1,167 @@
-- ════════════════════════════════════════════════════════════════════════════
-- D6 — storage.copy_folder_tree cross-drive support
-- ════════════════════════════════════════════════════════════════════════════
-- D0/M5 (`20260802100004_copy_folder_tree_drive_id.sql`) introduced drive_id
-- into this function but pulled it from the SOURCE folder for every level —
-- a deliberate "intra-drive only" limitation called out in that migration's
-- header. After D6 landed cross-drive moves end-to-end (cascade trigger +
-- WITH dest CTE on file_move/folder_move) copies were the lone holdout: a
-- batch-copy of a folder tree into another drive left every new row with
-- the SOURCE's drive_id while parent_id pointed into the DESTINATION drive.
-- Net effect: the per-drive quota sweep (`SUM(size) WHERE drive_id = d.id`)
-- charged the SOURCE drive for size physically living under the dest tree.
--
-- The fix mirrors `copy_file` SQL in
-- `infrastructure/repositories/pg/file_blob_write_repository.rs::copy_file`
-- (the single-file copy path already gets drive_id from the destination via
-- a `dest_folder` CTE) — here we resolve the destination drive ONCE at the
-- top of the function and bind it for every level of folders + every file.
--
-- Provenance contract: `created_by` / `updated_by` on the copied rows STAY
-- as the source row's values. A copy is a duplicate, not a new authoring
-- event; preserving the original author across copies is the correct
-- semantic. Subsequent edits to the copy bump `updated_by` through the
-- normal write path. This makes the previously-deferred caller_id thread
-- (memory: project_copy_folder_tree_caller_id.md) unnecessary — drive_id
-- is the only field that needs the destination's perspective.
--
-- Preserved semantics from the prior body:
-- - level-by-level folder INSERTs so trg_folders_path can resolve
-- parent's path/lpath from rows inserted in the previous level.
-- - One batched file INSERT (zero-copy via blob hash) at the end.
-- - Returns the same shape: (new_root_id::text, folders_copied, files_copied).
-- - Error codes (P0002 missing source, 23505 duplicate name) unchanged.
CREATE OR REPLACE FUNCTION storage.copy_folder_tree(
p_source_id UUID,
p_target_parent_id UUID, -- NULL = copy to root (keeps source drive)
p_dest_name TEXT DEFAULT NULL -- NULL = keep source folder name
) RETURNS TABLE(new_root_id TEXT, folders_copied BIGINT, files_copied BIGINT) AS $$
DECLARE
v_root_lpath ltree;
v_root_depth INT;
v_max_depth INT;
v_level INT;
v_folders BIGINT := 0;
v_files BIGINT := 0;
v_inserted BIGINT;
v_new_root UUID;
v_dest_drive_id UUID;
BEGIN
-- Validate source exists
SELECT fo.lpath, nlevel(fo.lpath)
INTO v_root_lpath, v_root_depth
FROM storage.folders fo
WHERE fo.id = p_source_id AND NOT fo.is_trashed;
IF v_root_lpath IS NULL THEN
RAISE EXCEPTION 'Source folder not found: %', p_source_id
USING ERRCODE = 'P0002'; -- no_data_found
END IF;
-- Resolve the destination drive_id ONCE up front. The whole copied
-- subtree lands in this drive; pulling it per-row from `fo.drive_id`
-- (the previous body) was the cross-drive bug.
--
-- When p_target_parent_id is NULL the caller asked for "copy to
-- root" — there is no global root in the multi-drive world, so we
-- preserve the source's drive_id (legacy behaviour, defensive).
-- Real API call sites always pass a concrete target folder.
IF p_target_parent_id IS NULL THEN
SELECT fo.drive_id INTO v_dest_drive_id
FROM storage.folders fo
WHERE fo.id = p_source_id;
ELSE
SELECT fo.drive_id INTO v_dest_drive_id
FROM storage.folders fo
WHERE fo.id = p_target_parent_id AND NOT fo.is_trashed;
IF v_dest_drive_id IS NULL THEN
RAISE EXCEPTION 'Target parent folder not found: %', p_target_parent_id
USING ERRCODE = 'P0002'; -- no_data_found
END IF;
END IF;
-- Temp mapping: every folder in the subtree → new UUID
CREATE TEMP TABLE IF NOT EXISTS _copy_map(
old_id UUID PRIMARY KEY,
new_id UUID NOT NULL DEFAULT gen_random_uuid()
) ON COMMIT DROP;
TRUNCATE _copy_map;
INSERT INTO _copy_map(old_id)
SELECT fo.id
FROM storage.folders fo
WHERE NOT fo.is_trashed
AND fo.lpath <@ v_root_lpath;
-- Remember new root ID
SELECT cm.new_id INTO v_new_root
FROM _copy_map cm WHERE cm.old_id = p_source_id;
-- Max depth for level iteration
SELECT MAX(nlevel(fo.lpath))
INTO v_max_depth
FROM storage.folders fo
JOIN _copy_map cm ON fo.id = cm.old_id;
-- ── Insert folders level by level ──
-- Each level is a separate INSERT so the BEFORE INSERT trigger
-- (trg_folders_path) can resolve the parent's path/lpath from rows
-- inserted in the previous level. drive_id is the destination's
-- (resolved once above); user_id + provenance preserved from source.
FOR v_level IN v_root_depth .. v_max_depth LOOP
INSERT INTO storage.folders(
id, name, parent_id, user_id,
drive_id, created_by, updated_by
)
SELECT cm.new_id,
CASE WHEN fo.id = p_source_id AND p_dest_name IS NOT NULL
THEN p_dest_name ELSE fo.name END,
CASE WHEN fo.id = p_source_id THEN p_target_parent_id
ELSE pm.new_id END,
fo.user_id,
v_dest_drive_id,
fo.created_by,
fo.updated_by
FROM storage.folders fo
JOIN _copy_map cm ON fo.id = cm.old_id
LEFT JOIN _copy_map pm ON fo.parent_id = pm.old_id
WHERE NOT fo.is_trashed
AND nlevel(fo.lpath) = v_level;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
v_folders := v_folders + v_inserted;
END LOOP;
-- ── Batch copy all files (zero-copy: same blob_hash) ──
-- drive_id from destination; everything else (user_id, created_by,
-- updated_by) preserved from source so authorship survives the copy.
INSERT INTO storage.files(
name, folder_id, user_id, blob_hash, size, mime_type,
media_sort_date, drive_id, created_by, updated_by
)
SELECT f.name, cm.new_id, f.user_id, f.blob_hash, f.size, f.mime_type,
f.media_sort_date, v_dest_drive_id, f.created_by, f.updated_by
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.old_id
WHERE NOT f.is_trashed;
GET DIAGNOSTICS v_files = ROW_COUNT;
-- ── Batch increment blob ref_counts ──
IF v_files > 0 THEN
UPDATE storage.blobs b
SET ref_count = ref_count + hc.cnt
FROM (
SELECT f.blob_hash, COUNT(*)::int AS cnt
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.new_id
WHERE NOT f.is_trashed
GROUP BY f.blob_hash
) hc
WHERE b.hash = hc.blob_hash;
END IF;
RETURN QUERY SELECT v_new_root::text, v_folders, v_files;
END;
$$ LANGUAGE plpgsql;
@@ -0,0 +1,20 @@
-- WebDAV dead properties storage (RFC 4918 §9.2).
-- Stores arbitrary user-defined XML properties set via PROPPATCH.
-- Keyed by (resource_path, user_id, namespace, local_name) — the
-- same property on different resources or for different users is
-- a distinct row.
CREATE TABLE IF NOT EXISTS storage.webdav_dead_properties (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
resource_path TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
namespace TEXT NOT NULL DEFAULT '',
local_name TEXT NOT NULL,
value TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (resource_path, user_id, namespace, local_name)
);
CREATE INDEX IF NOT EXISTS idx_webdav_dead_properties_path_user
ON storage.webdav_dead_properties (resource_path, user_id);
@@ -0,0 +1,96 @@
-- ════════════════════════════════════════════════════════════════════════════
-- Fix: descendant path/lpath cascade silently stopped firing after D6
-- ════════════════════════════════════════════════════════════════════════════
-- The D6 migration `20260807000000_cascade_drive_id_on_folder_move.sql` was
-- written to add `drive_id` to the cascade trigger's column list. Its stated
-- intent (per its own comment) was "add `drive_id` to the column list", but
-- the re-registration replaced `name, parent_id, path, lpath` with
-- `path, lpath, drive_id` — dropping `name` and `parent_id` in the process:
--
-- -- D6 as shipped (BUG):
-- CREATE OR REPLACE TRIGGER trg_folders_cascade_path
-- AFTER UPDATE OF path, lpath, drive_id ON storage.folders
-- FOR EACH ROW EXECUTE FUNCTION storage.cascade_folder_path();
--
-- PostgreSQL's `UPDATE OF <cols>` predicate matches against the statement's
-- explicit SET clause — NOT against what a BEFORE trigger derives. The
-- rename SQL the app issues is `UPDATE storage.folders SET name = $1, ...`
-- and the move SQL is `UPDATE storage.folders SET parent_id = $1, ...`.
-- Neither touches path/lpath/drive_id in its SET list. Net effect of D6:
--
-- * Folder rename: BEFORE trigger (trg_folders_path) correctly rewrites
-- the renamed row's `path` and `lpath` columns directly. AFTER cascade
-- trigger never fires → every DESCENDANT folder retains its old `path`
-- and `lpath` indefinitely. Hidden until a path-keyed lookup misses.
-- * Folder move (intra-drive): same regression, same hidden state.
-- * Folder move (cross-drive): drive_id IS in the SET clause for some of
-- the cross-drive code paths, so D6's drive_id branch fires there. But
-- the path/lpath branch in the same function never fires on rename/move
-- because the trigger gate excludes the SET columns the app uses.
--
-- Discovery: litmus `copymove → move_coll` (test #10) — `DELETE
-- /webdav/litmus/mvdest/subcoll/` returns 404 because `subcoll`'s path
-- column is still `Personal/litmus/mvsrc/subcoll`. The 10 leaf files
-- foo.0..foo.9 directly under mvdest delete fine because their lookup
-- joins through their parent folder's row (mvdest itself), and the BEFORE
-- trigger DID update mvdest's own path correctly on rename. Only DESCENDANT
-- folder rows are affected.
--
-- Fix: re-register the trigger with the column list that covers every
-- statement the app actually issues against storage.folders:
-- - `name` — folder rename
-- - `parent_id` — folder move (intra-drive)
-- - `path`, `lpath` — direct rewrites (migrations, future tooling)
-- - `drive_id` — folder move (cross-drive); preserved from D6
--
-- The cascade function body itself is unchanged. The pg_trigger_depth() > 1
-- guard inside it still stops the descendant-rewrite UPDATE from
-- recursively re-firing the trigger on its own writes.
-- DROP-then-CREATE for PG 13 compatibility (no CREATE OR REPLACE TRIGGER
-- pre-14). Idempotent thanks to IF EXISTS / IF NOT EXISTS semantics.
DROP TRIGGER IF EXISTS trg_folders_cascade_path ON storage.folders;
CREATE TRIGGER trg_folders_cascade_path
AFTER UPDATE OF name, parent_id, path, lpath, drive_id ON storage.folders
FOR EACH ROW EXECUTE FUNCTION storage.cascade_folder_path();
-- ── Repair: rebuild stale descendant path/lpath on existing databases ────
-- Any folder rename or intra-drive move that happened between D6 deploying
-- and this fix landing left descendants stranded at their pre-rename path
-- and lpath. The same canonical-rebuild CTE used in
-- `20260730000001_statement_tree_etag.sql` heals the pile in a single
-- statement: walk the tree from each root, derive (path, lpath) from the
-- parent chain, write back only the stale rows.
--
-- Two safety properties of this repair:
-- * The repair UPDATE sets `path` and `lpath` directly. The newly-
-- correct trigger column list above DOES include those columns, but
-- `cascade_folder_path()` only descends to children when OLD differs
-- from NEW *for that row* — descendants are walked level by level by
-- the recursive CTE, so by the time the trigger fires on a child, the
-- child's parent already has its correct path and the child's row is
-- also being rewritten to its correct path. No double-write, no fan-
-- out: the CTE finishes before any trigger could redo the work.
-- * The statement-level tree-ETag bump triggers run their column filter
-- against `(name, parent_id, is_trashed, updated_at)` — none of which
-- change in this UPDATE — so existing sync clients see no spurious
-- ETag churn.
WITH RECURSIVE canon AS (
SELECT id,
name::text AS path,
replace(id::text, '-', '_')::ltree AS lpath
FROM storage.folders
WHERE parent_id IS NULL
UNION ALL
SELECT f.id,
c.path || '/' || f.name,
c.lpath || replace(f.id::text, '-', '_')::ltree
FROM storage.folders f
JOIN canon c ON f.parent_id = c.id
)
UPDATE storage.folders f
SET path = c.path, lpath = c.lpath
FROM canon c
WHERE f.id = c.id
AND (f.path IS DISTINCT FROM c.path OR f.lpath IS DISTINCT FROM c.lpath);
@@ -0,0 +1,112 @@
-- ════════════════════════════════════════════════════════════════════════════
-- WebDAV dead properties: rekey from (resource_path, user_id) to resource id
-- ════════════════════════════════════════════════════════════════════════════
-- The original schema (20260825000000) keyed dead properties on
-- `(resource_path, user_id, namespace, local_name)`. That model was wrong on
-- two counts:
--
-- 1. Dead properties are RESOURCE state per RFC 4918 §4.2 — not user
-- state. Two users on a shared drive PROPFIND'ing the same resource
-- must see the same dead-properties. The user_id key siloed them.
-- 2. Every non-WebDAV delete path (REST `DELETE /api/files/{id}`, bulk
-- delete, trash empty, folder cascade) operates on a resource id —
-- not a path. None of those code paths could cheaply call
-- `remove_resource(path, user_id)`, so they leaked dead-property
-- tombstones. WebDAV DELETE itself had a workaround explicit-cleanup
-- call, but the REST surface (which the SvelteKit web UI uses) is the
-- dominant delete path in practice.
--
-- This migration switches the key to a polymorphic resource reference:
-- exactly one of `folder_id` / `file_id` is set, each with `ON DELETE
-- CASCADE` to its owning table. After this lands every existing
-- delete code path — REST, WebDAV, NextCloud DAV, trash, folder
-- cascade — automatically reaps dead-property rows when the underlying
-- file or folder is removed, with no service-layer changes.
--
-- MOVE / RENAME also become no-ops at the dead-properties layer: a
-- folder's id is stable across renames, so its dead properties move
-- with it for free. The `rename_resource()` method on the store is
-- removed in the matching Rust change.
--
-- ── Migration shape ─────────────────────────────────────────────────────────
-- 1. ADD COLUMN folder_id / file_id (NULL-able for now).
-- 2. Backfill folder_id from any row whose resource_path matches a
-- folder row's `path` + `user_id`.
-- 3. Backfill file_id for the rest by joining through the parent folder
-- and matching `parent.path || '/' || fi.name`.
-- 4. Reap rows that didn't resolve — they're tombstones from before
-- the FK-cascade fix, and there's no resource left to attach them to.
-- 5. Add the CHECK constraint that exactly one column is set.
-- 6. Add two partial unique indexes (one per kind).
-- 7. DROP the old columns; PG drops the inline UNIQUE constraint and
-- the explicit path/user index along with them.
--
-- The migration runs in a single sqlx transaction. If any step fails
-- the schema rolls back to (20260825000000) intact.
ALTER TABLE storage.webdav_dead_properties
ADD COLUMN folder_id UUID NULL REFERENCES storage.folders(id) ON DELETE CASCADE,
ADD COLUMN file_id UUID NULL REFERENCES storage.files(id) ON DELETE CASCADE;
-- Backfill: every row whose resource_path matches an existing folder
-- row's `path` + `user_id` gets its folder_id stamped. `NOT is_trashed`
-- mirrors what the handler does at lookup time — trashed rows can't be
-- the live target of a PROPPATCH anyway, so any old row pointing at a
-- trashed folder is a tombstone (handled in step 4).
UPDATE storage.webdav_dead_properties d
SET folder_id = fo.id
FROM storage.folders fo
WHERE fo.path = d.resource_path
AND fo.user_id = d.user_id
AND NOT fo.is_trashed;
-- Backfill: any remaining row must be a file's properties. Match the
-- same path-computation the resolver uses for files —
-- `parent.path || '/' || fi.name` — so the rewrite mirrors the
-- handler's runtime behaviour exactly.
UPDATE storage.webdav_dead_properties d
SET file_id = fi.id
FROM storage.files fi
JOIN storage.folders parent ON parent.id = fi.folder_id
WHERE d.folder_id IS NULL
AND fi.user_id = d.user_id
AND NOT fi.is_trashed
AND parent.path || '/' || fi.name = d.resource_path;
-- Reap orphans. A row that didn't resolve to a folder or file is a
-- tombstone left by some pre-fix delete path: the resource is long
-- gone but the dead-property row was never reaped because the old
-- `(path, user_id)` key kept it disconnected from the resource's
-- lifecycle. The FK-cascade era makes this category structurally
-- impossible, so dropping them on migration is the right cleanup.
DELETE FROM storage.webdav_dead_properties
WHERE folder_id IS NULL AND file_id IS NULL;
-- Exactly-one-is-set: defends against future code accidentally
-- writing both columns or neither. `<>` between two boolean
-- IS NULL probes is the idiomatic PG shape for XOR.
ALTER TABLE storage.webdav_dead_properties
ADD CONSTRAINT webdav_dead_properties_one_resource_chk
CHECK ((folder_id IS NULL) <> (file_id IS NULL));
-- Partial unique indexes — one per resource kind. PG's ON CONFLICT
-- can infer either via `(folder_id, namespace, local_name)
-- WHERE folder_id IS NOT NULL`, matching the partial index, so
-- upsert continues to work without quirky ON CONSTRAINT plumbing.
CREATE UNIQUE INDEX IF NOT EXISTS idx_webdav_dead_props_folder_unique
ON storage.webdav_dead_properties (folder_id, namespace, local_name)
WHERE folder_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_webdav_dead_props_file_unique
ON storage.webdav_dead_properties (file_id, namespace, local_name)
WHERE file_id IS NOT NULL;
-- Drop the old key columns. PG cascades the auto-named inline UNIQUE
-- constraint and the explicit `(resource_path, user_id)` lookup index
-- along with the columns (idx is on resource_path which is going away,
-- so CASCADE is required).
DROP INDEX IF EXISTS storage.idx_webdav_dead_properties_path_user;
ALTER TABLE storage.webdav_dead_properties
DROP COLUMN resource_path CASCADE,
DROP COLUMN user_id CASCADE;
@@ -0,0 +1,223 @@
-- ════════════════════════════════════════════════════════════════════════════
-- COPY: duplicate dead properties along with files and folders
-- ════════════════════════════════════════════════════════════════════════════
-- RFC 4918 §8.8 — "If a property cannot be copied live, then its value
-- MUST be duplicated, exactly as it would be for a PROPPATCH SET
-- operation, in the copy." Dead properties are by definition not live
-- (the server stores them verbatim with no interpretation), so every
-- COPY MUST duplicate the source's dead properties onto the new
-- resource.
--
-- The pre-rekey path-based store handled this by accident in some
-- cases and missed it in others; the id-keyed store (migration
-- 20260830000001) makes the requirement explicit — dead properties
-- key on `folder_id` / `file_id`, so a copy that doesn't insert new
-- rows for the destination's ids loses the properties entirely.
--
-- This migration replaces `storage.copy_folder_tree` with a version
-- that:
--
-- 1. Pre-allocates destination file ids in a new temp table
-- `_copy_file_map(old_id, new_id)` — analogous to the
-- pre-existing `_copy_map` that already does this for folders.
-- Previously, file ids were generated by the `gen_random_uuid()`
-- DEFAULT during the batch INSERT, leaving no way to relate src
-- and dst files afterward.
-- 2. Switches the batch file INSERT to use the explicit
-- pre-allocated id, so src→dst is bidirectionally known by
-- `_copy_file_map`.
-- 3. Adds two `INSERT INTO storage.webdav_dead_properties` SELECTs
-- at the end that duplicate dead-property rows for every copied
-- folder (via `_copy_map`) and every copied file (via
-- `_copy_file_map`). Each duplicated row carries the same
-- `(namespace, local_name, value)` triple as the source — the
-- definition of "duplicate" in RFC 4918 §8.8.
--
-- Idempotent via CREATE OR REPLACE FUNCTION. No callers change (the
-- function signature and return shape are unchanged).
--
-- COPY semantics out of scope for this migration:
-- * Cross-user permission handling on the copied resources is the
-- caller's responsibility (the `_with_perms` service variant
-- already enforces this on the source side). Dead properties
-- hitch a ride on the resource's ACL; nothing additional needed.
-- * Trash: trashed source rows are excluded by the existing
-- `NOT is_trashed` filter; dead-props on trashed rows live on
-- until the resource itself is hard-deleted, at which point
-- CASCADE handles them. Same model holds in the new COPY path.
CREATE OR REPLACE FUNCTION storage.copy_folder_tree(
p_source_id UUID,
p_target_parent_id UUID, -- NULL = copy to root (keeps source drive)
p_dest_name TEXT DEFAULT NULL -- NULL = keep source folder name
) RETURNS TABLE(new_root_id TEXT, folders_copied BIGINT, files_copied BIGINT) AS $$
DECLARE
v_root_lpath ltree;
v_root_depth INT;
v_max_depth INT;
v_level INT;
v_folders BIGINT := 0;
v_files BIGINT := 0;
v_inserted BIGINT;
v_new_root UUID;
v_dest_drive_id UUID;
BEGIN
-- Validate source exists
SELECT fo.lpath, nlevel(fo.lpath)
INTO v_root_lpath, v_root_depth
FROM storage.folders fo
WHERE fo.id = p_source_id AND NOT fo.is_trashed;
IF v_root_lpath IS NULL THEN
RAISE EXCEPTION 'Source folder not found: %', p_source_id
USING ERRCODE = 'P0002'; -- no_data_found
END IF;
-- Resolve the destination drive_id ONCE up front. The whole copied
-- subtree lands in this drive; pulling it per-row from `fo.drive_id`
-- (the previous body) was the cross-drive bug.
--
-- When p_target_parent_id is NULL the caller asked for "copy to
-- root" — there is no global root in the multi-drive world, so we
-- preserve the source's drive_id (legacy behaviour, defensive).
-- Real API call sites always pass a concrete target folder.
IF p_target_parent_id IS NULL THEN
SELECT fo.drive_id INTO v_dest_drive_id
FROM storage.folders fo
WHERE fo.id = p_source_id;
ELSE
SELECT fo.drive_id INTO v_dest_drive_id
FROM storage.folders fo
WHERE fo.id = p_target_parent_id AND NOT fo.is_trashed;
IF v_dest_drive_id IS NULL THEN
RAISE EXCEPTION 'Target parent folder not found: %', p_target_parent_id
USING ERRCODE = 'P0002'; -- no_data_found
END IF;
END IF;
-- Temp mapping: every folder in the subtree → new UUID
CREATE TEMP TABLE IF NOT EXISTS _copy_map(
old_id UUID PRIMARY KEY,
new_id UUID NOT NULL DEFAULT gen_random_uuid()
) ON COMMIT DROP;
TRUNCATE _copy_map;
INSERT INTO _copy_map(old_id)
SELECT fo.id
FROM storage.folders fo
WHERE NOT fo.is_trashed
AND fo.lpath <@ v_root_lpath;
-- Remember new root ID
SELECT cm.new_id INTO v_new_root
FROM _copy_map cm WHERE cm.old_id = p_source_id;
-- Max depth for level iteration
SELECT MAX(nlevel(fo.lpath))
INTO v_max_depth
FROM storage.folders fo
JOIN _copy_map cm ON fo.id = cm.old_id;
-- ── Insert folders level by level ──
-- Each level is a separate INSERT so the BEFORE INSERT trigger
-- (trg_folders_path) can resolve the parent's path/lpath from rows
-- inserted in the previous level. drive_id is the destination's
-- (resolved once above); user_id + provenance preserved from source.
FOR v_level IN v_root_depth .. v_max_depth LOOP
INSERT INTO storage.folders(
id, name, parent_id, user_id,
drive_id, created_by, updated_by
)
SELECT cm.new_id,
CASE WHEN fo.id = p_source_id AND p_dest_name IS NOT NULL
THEN p_dest_name ELSE fo.name END,
CASE WHEN fo.id = p_source_id THEN p_target_parent_id
ELSE pm.new_id END,
fo.user_id,
v_dest_drive_id,
fo.created_by,
fo.updated_by
FROM storage.folders fo
JOIN _copy_map cm ON fo.id = cm.old_id
LEFT JOIN _copy_map pm ON fo.parent_id = pm.old_id
WHERE NOT fo.is_trashed
AND nlevel(fo.lpath) = v_level;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
v_folders := v_folders + v_inserted;
END LOOP;
-- ── NEW: temp mapping for files src→dst ───────────────────────────
-- Pre-allocate destination ids so we can:
-- (a) reference each dst file by id in the dead-property INSERT
-- below — a batched INSERT...RETURNING couldn't tell us which
-- new id corresponded to which source id, so the mapping
-- has to be stamped at planning time, not after the fact;
-- (b) batch the file INSERT with explicit ids exactly the same
-- way folders are batched.
CREATE TEMP TABLE IF NOT EXISTS _copy_file_map(
old_id UUID PRIMARY KEY,
new_id UUID NOT NULL DEFAULT gen_random_uuid()
) ON COMMIT DROP;
TRUNCATE _copy_file_map;
INSERT INTO _copy_file_map(old_id)
SELECT f.id
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.old_id
WHERE NOT f.is_trashed;
-- ── Batch copy all files (zero-copy: same blob_hash) ──
-- drive_id from destination; everything else (user_id, created_by,
-- updated_by) preserved from source so authorship survives the copy.
-- `id` is the pre-allocated dst id from _copy_file_map.
INSERT INTO storage.files(
id, name, folder_id, user_id, blob_hash, size, mime_type,
media_sort_date, drive_id, created_by, updated_by
)
SELECT fm.new_id, f.name, cm.new_id, f.user_id, f.blob_hash, f.size,
f.mime_type, f.media_sort_date, v_dest_drive_id, f.created_by,
f.updated_by
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.old_id
JOIN _copy_file_map fm ON fm.old_id = f.id
WHERE NOT f.is_trashed;
GET DIAGNOSTICS v_files = ROW_COUNT;
-- ── Batch increment blob ref_counts ──
IF v_files > 0 THEN
UPDATE storage.blobs b
SET ref_count = ref_count + hc.cnt
FROM (
SELECT f.blob_hash, COUNT(*)::int AS cnt
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.new_id
WHERE NOT f.is_trashed
GROUP BY f.blob_hash
) hc
WHERE b.hash = hc.blob_hash;
END IF;
-- ── NEW: duplicate dead properties for every copied folder ────────
-- RFC 4918 §8.8 — dead properties MUST be duplicated. The id-keyed
-- store (migration 20260830000001) keys on `folder_id`, so we
-- emit a new row per source dead-property pointing at the
-- destination folder id. `(namespace, local_name, value)` is
-- preserved verbatim — that's the "duplicate" definition.
INSERT INTO storage.webdav_dead_properties
(folder_id, namespace, local_name, value)
SELECT cm.new_id, dp.namespace, dp.local_name, dp.value
FROM storage.webdav_dead_properties dp
JOIN _copy_map cm ON dp.folder_id = cm.old_id;
-- ── NEW: duplicate dead properties for every copied file ──────────
INSERT INTO storage.webdav_dead_properties
(file_id, namespace, local_name, value)
SELECT fm.new_id, dp.namespace, dp.local_name, dp.value
FROM storage.webdav_dead_properties dp
JOIN _copy_file_map fm ON dp.file_id = fm.old_id;
RETURN QUERY SELECT v_new_root::text, v_folders, v_files;
END;
$$ LANGUAGE plpgsql;
@@ -0,0 +1,32 @@
-- ════════════════════════════════════════════════════════════════════════════
-- PR-A / §15 — default personal drives get include_in_photo_index +
-- include_in_music_index materialised on the JSONB `policies` bag
-- ════════════════════════════════════════════════════════════════════════════
-- `docs/plan/drive.md` §15 locks the two policies as symmetric per-drive
-- opt-in flags. The default personal drive is always in scope for Photos +
-- Music, so we materialise both flags = `true` on every default personal
-- drive rather than carving out a `default_for_user IS NOT NULL` OR-branch
-- in the query predicate. Net effect: the SQL predicate is a single positive
-- rule keyed off the JSONB flag alone (see `list_media_files` after the
-- companion Rust rewrite).
--
-- New default personal drives get these flags at creation time via
-- `DriveRepository::create_personal_drive_atomic` (the INSERT literal on
-- that path was updated alongside this migration). This migration handles
-- the existing rows, seeded by the D0 backfill.
--
-- Non-default drives (secondary personals, shared drives) are NOT touched —
-- they stay opted-out until the owner flips the flag via the admin
-- "Manage policies" modal.
--
-- Idempotent: `policies || {…}` is a no-op if the keys are already set to
-- the same values, and JSONB `||` is right-precedence so the migration
-- never overwrites an owner's explicit opt-out that was already recorded.
-- (If someone had `include_in_photo_index=false` set on their default
-- personal via a manual PATCH, this UPDATE would still overwrite to true;
-- that's acceptable — the D5 policy UI didn't exist for these flags
-- before this PR, so no such manual opt-out can be in the wild yet.)
UPDATE storage.drives
SET policies = policies || '{"include_in_photo_index": true, "include_in_music_index": true}'::jsonb
WHERE default_for_user IS NOT NULL;
@@ -0,0 +1,25 @@
-- ════════════════════════════════════════════════════════════════════════════
-- PR-A / §15 — partial covering index for the drive-scoped Photos timeline
-- ════════════════════════════════════════════════════════════════════════════
-- Sibling of `idx_files_media_timeline` (initial_schema.sql:581), keyed on
-- `drive_id` instead of `user_id`. The Photos handler predicate is being
-- rewritten to `fi.drive_id IN (drives with include_in_photo_index = true
-- AND caller has Read)` — that subquery produces a small drive-id set,
-- and this index gives Postgres one IndexScan per drive_id already
-- ordered by `media_sort_date DESC`, so LIMIT stops the scan early.
-- Same O(LIMIT) shape as the pre-D7 user_id-keyed hot path.
--
-- The old `idx_files_media_timeline (user_id, media_sort_date DESC)` index
-- is intentionally kept for now — it still backs the dedup / storage sweep
-- paths that D7 will migrate separately. Once D7 drops the `user_id`
-- column those paths lose their backing index at the same moment; that PR
-- can drop the old index in the same migration.
--
-- Partial WHERE clause is identical to the existing sibling so the index
-- stays as compact as its predecessor: only image/video rows that aren't
-- trashed.
CREATE INDEX IF NOT EXISTS idx_files_media_timeline_by_drive
ON storage.files (drive_id, media_sort_date DESC)
WHERE NOT is_trashed
AND (mime_type LIKE 'image/%' OR mime_type LIKE 'video/%');
@@ -0,0 +1,74 @@
-- ════════════════════════════════════════════════════════════════════════════
-- PR-B — storage.caller_group_ids: recursive group-membership expansion in SQL
-- ════════════════════════════════════════════════════════════════════════════
-- Every listing surface that scopes by "drives the caller can Read" (Photos,
-- Places, GET /api/drives, Trash, Search, root-folder listing) needs the
-- caller's *effective subject set* = caller_id ∪ every group they belong to
-- transitively.
--
-- Pre-Option-A the Rust-side `PgAclEngine::expand_subject_for_listing` did
-- the walk once (via `WITH RECURSIVE` in `subject_group_pg_repository.rs::
-- groups_for_user`) and cached the result in a Moka table with 30-second
-- TTL; every listing handler then passed the two parallel arrays
-- `subject_types` + `subject_ids` into the SQL. That leaked the expansion
-- ceremony into every caller (7+ sites).
--
-- Option A pushes the walk into a `STABLE` SQL function so each listing
-- query embeds the expansion inline:
--
-- WHERE (g.subject_type = 'user' AND g.subject_id = $caller)
-- OR (g.subject_type = 'group' AND g.subject_id IN
-- (SELECT storage.caller_group_ids($caller)))
--
-- Callers pass a bare `caller_id: Uuid` — no more expand-then-bind
-- ceremony. Postgres re-runs the walk per listing (~1-3 ms against the
-- indexed `auth.subject_group_members` table); we lose the Moka cache
-- benefit but gain a single audit trail for "how does group access
-- cascade" (this function) and drop ~15 lines of Rust glue per listing.
--
-- Cycle safety: `subject_group_pg_repository.rs::add_member` enforces
-- an INSERT-time cycle check via `WITH RECURSIVE descendants`, so the
-- membership DAG is guaranteed acyclic. Depth is capped at
-- MAX_GROUP_DEPTH by the same INSERT path. The recursion below always
-- terminates.
--
-- `STABLE`: the function reads DB state but never modifies it, and the
-- result is deterministic within a transaction. Postgres can memoise
-- calls within a single query plan (e.g. multiple references in the
-- same SELECT) and inline the CTE into the surrounding query where
-- beneficial. Marking it `VOLATILE` would forbid both optimisations.
--
-- `LEAKPROOF` is deliberately NOT set: the function reads a private
-- auth table, so it must not be pushed below a security barrier.
--
-- `SECURITY INVOKER` (the default) — runs with the calling role's
-- permissions, so RLS on `auth.subject_group_members` (if ever added)
-- applies consistently.
CREATE OR REPLACE FUNCTION storage.caller_group_ids(caller UUID)
RETURNS SETOF UUID
LANGUAGE sql
STABLE
AS $$
WITH RECURSIVE user_groups AS (
-- Direct memberships: groups the caller is listed in as a user.
SELECT group_id
FROM auth.subject_group_members
WHERE member_user_id = caller
UNION
-- Transitive memberships: groups that contain a group the caller
-- already belongs to. Repeats until no new rows are produced.
SELECT m.group_id
FROM auth.subject_group_members m
JOIN user_groups ug ON m.member_group_id = ug.group_id
)
SELECT group_id FROM user_groups;
$$;
-- Backing indexes used by the recursion. Already present from
-- 20260307000000_initial_schema.sql on
-- `auth.subject_group_members (member_user_id)` and
-- `auth.subject_group_members (member_group_id)` — no additional
-- indexes needed here.
@@ -0,0 +1,108 @@
-- ─────────────────────────────────────────────────────────────────────────
-- D7 step 5 — retire `user_id` as a write/uniqueness axis on
-- `storage.files` and `storage.folders`.
--
-- Every read that used to filter by `files.user_id = $caller` or
-- `folders.user_id = $caller` has already been migrated to a
-- drive-membership predicate (see D7-pass §6/§10 changes:
-- `file_blob_read_repository`, `folder_db_repository`,
-- `path_resolver_service`, `dedup_service`, plus `authz.require(Read, …)`
-- at every WebDAV consumer site). This migration removes the last
-- reason to keep binding `user_id` on writes:
--
-- 1. Files uniqueness indexes swap from `(folder_id, name, user_id)` /
-- `(name, user_id)` → `(drive_id, folder_id, name)` /
-- `(drive_id, name)`. Post-D0 `files.drive_id` is `NOT NULL`, so
-- the drive-scoped form is strictly stronger — a file is unique
-- by its position within its drive, not by "who used to own it".
-- The folder side already got this treatment in D0
-- (`20260802100002_drives_not_null.sql`).
--
-- 2. Dead user_id-leading indexes get dropped:
-- - `idx_files_user_id`, `idx_folders_user_id` — nothing scans by
-- `WHERE user_id = $1` any more.
-- - `idx_folders_trashed` — was `(user_id, is_trashed)`; the
-- trash listing moved to `(drive_id, is_trashed)` via the
-- same D7 rewrite.
-- - `idx_files_user_size_active` — was the per-user storage
-- usage summary; the reconciliation sweep now GROUPs by
-- `drive_id` (`storage_usage_service::update_all_drives_storage_usage`).
--
-- 3. `ALTER COLUMN user_id DROP NOT NULL` on both tables. The
-- column stays for compat with the follow-up column-drop
-- migration (D7 step 6) but new INSERTs will leave it NULL.
-- Existing rows keep their backfilled values until the drop.
--
-- Steps 4-6 (Rust INSERT binds dropped + PL/pgSQL copy_folder_tree
-- update) ship in the same commit so no in-flight INSERT ever
-- tries to bind a NOT NULL that just went away.
-- ── 1. Swap files uniqueness indexes ─────────────────────────────────────
--
-- Pre-D7: name unique within (folder, user). Post-D7: name unique within
-- (drive, folder). Since a drive has exactly one root folder tree and
-- a given file lives in exactly one drive, this is a strict tightening.
--
-- The `IF EXISTS` guards let this migration re-run cleanly against a DB
-- that's already been partially migrated (dev workflow).
DROP INDEX IF EXISTS storage.idx_files_unique_name_in_folder;
DROP INDEX IF EXISTS storage.idx_files_unique_name_at_root;
CREATE UNIQUE INDEX IF NOT EXISTS idx_files_unique_name_in_folder
ON storage.files (drive_id, folder_id, name)
WHERE NOT is_trashed AND folder_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_files_unique_name_at_root
ON storage.files (drive_id, name)
WHERE NOT is_trashed AND folder_id IS NULL;
-- ── 2. Drop dead user_id-leading indexes ─────────────────────────────────
DROP INDEX IF EXISTS storage.idx_files_user_id;
DROP INDEX IF EXISTS storage.idx_files_user_size_active;
DROP INDEX IF EXISTS storage.idx_folders_user_id;
DROP INDEX IF EXISTS storage.idx_folders_trashed;
-- ── 3. Allow NULL user_id on both tables ─────────────────────────────────
ALTER TABLE storage.files ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE storage.folders ALTER COLUMN user_id DROP NOT NULL;
-- ── 4. Post-flight sanity ────────────────────────────────────────────────
DO $BODY$
DECLARE
files_nullable BOOLEAN;
folders_nullable BOOLEAN;
new_files_uniq BOOLEAN;
BEGIN
SELECT is_nullable::boolean INTO files_nullable
FROM information_schema.columns
WHERE table_schema = 'storage'
AND table_name = 'files'
AND column_name = 'user_id';
SELECT is_nullable::boolean INTO folders_nullable
FROM information_schema.columns
WHERE table_schema = 'storage'
AND table_name = 'folders'
AND column_name = 'user_id';
SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = 'storage'
AND indexname = 'idx_files_unique_name_in_folder'
) INTO new_files_uniq;
IF NOT files_nullable THEN
RAISE EXCEPTION 'storage.files.user_id NOT NULL constraint did not drop';
END IF;
IF NOT folders_nullable THEN
RAISE EXCEPTION 'storage.folders.user_id NOT NULL constraint did not drop';
END IF;
IF NOT new_files_uniq THEN
RAISE EXCEPTION 'drive-scoped files uniqueness index did not land';
END IF;
END;
$BODY$;
@@ -0,0 +1,170 @@
-- ─────────────────────────────────────────────────────────────────────────
-- D7 step 5 — drop `user_id` from `storage.copy_folder_tree` INSERTs.
--
-- Companion to `20260902000000_files_folders_user_id_nullable.sql`. That
-- migration made both `storage.files.user_id` and `storage.folders.user_id`
-- nullable; this one stops writing to them from the copy-tree flow so
-- copied rows leave the column NULL — provenance moves entirely to the
-- `created_by` / `updated_by` §14 columns, which the PL/pgSQL already
-- preserved from source.
--
-- No behavioural change apart from the write-time projection: reads no
-- longer key on `files.user_id` (all migrated to drive-membership
-- predicates), the uniqueness constraints don't include `user_id`
-- (companion migration swapped them to drive-scoped), and provenance
-- was already flowing through `created_by` / `updated_by`.
--
-- Identical function signature and return shape — no caller update
-- needed.
CREATE OR REPLACE FUNCTION storage.copy_folder_tree(
p_source_id UUID,
p_target_parent_id UUID, -- NULL = copy to root (keeps source drive)
p_dest_name TEXT DEFAULT NULL -- NULL = keep source folder name
) RETURNS TABLE(new_root_id TEXT, folders_copied BIGINT, files_copied BIGINT) AS $$
DECLARE
v_root_lpath ltree;
v_root_depth INT;
v_max_depth INT;
v_level INT;
v_folders BIGINT := 0;
v_files BIGINT := 0;
v_inserted BIGINT;
v_new_root UUID;
v_dest_drive_id UUID;
BEGIN
-- Validate source exists.
SELECT fo.lpath, nlevel(fo.lpath)
INTO v_root_lpath, v_root_depth
FROM storage.folders fo
WHERE fo.id = p_source_id AND NOT fo.is_trashed;
IF v_root_lpath IS NULL THEN
RAISE EXCEPTION 'Source folder not found: %', p_source_id
USING ERRCODE = 'P0002'; -- no_data_found
END IF;
-- Resolve destination drive_id once up front (cross-drive copy path).
IF p_target_parent_id IS NULL THEN
SELECT fo.drive_id INTO v_dest_drive_id
FROM storage.folders fo
WHERE fo.id = p_source_id;
ELSE
SELECT fo.drive_id INTO v_dest_drive_id
FROM storage.folders fo
WHERE fo.id = p_target_parent_id AND NOT fo.is_trashed;
IF v_dest_drive_id IS NULL THEN
RAISE EXCEPTION 'Target parent folder not found: %', p_target_parent_id
USING ERRCODE = 'P0002';
END IF;
END IF;
-- Temp mapping: every folder in the subtree → new UUID.
CREATE TEMP TABLE IF NOT EXISTS _copy_map(
old_id UUID PRIMARY KEY,
new_id UUID NOT NULL DEFAULT gen_random_uuid()
) ON COMMIT DROP;
TRUNCATE _copy_map;
INSERT INTO _copy_map(old_id)
SELECT fo.id
FROM storage.folders fo
WHERE NOT fo.is_trashed
AND fo.lpath <@ v_root_lpath;
SELECT cm.new_id INTO v_new_root
FROM _copy_map cm WHERE cm.old_id = p_source_id;
SELECT MAX(nlevel(fo.lpath))
INTO v_max_depth
FROM storage.folders fo
JOIN _copy_map cm ON fo.id = cm.old_id;
-- ── Insert folders level by level ──
-- Post-D7: `user_id` intentionally omitted from the column list so
-- copied rows leave the (now-nullable) column NULL. Provenance is
-- carried by `created_by` / `updated_by` (§14 columns) — preserved
-- from source so authorship survives the copy.
FOR v_level IN v_root_depth .. v_max_depth LOOP
INSERT INTO storage.folders(
id, name, parent_id,
drive_id, created_by, updated_by
)
SELECT cm.new_id,
CASE WHEN fo.id = p_source_id AND p_dest_name IS NOT NULL
THEN p_dest_name ELSE fo.name END,
CASE WHEN fo.id = p_source_id THEN p_target_parent_id
ELSE pm.new_id END,
v_dest_drive_id,
fo.created_by,
fo.updated_by
FROM storage.folders fo
JOIN _copy_map cm ON fo.id = cm.old_id
LEFT JOIN _copy_map pm ON fo.parent_id = pm.old_id
WHERE NOT fo.is_trashed
AND nlevel(fo.lpath) = v_level;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
v_folders := v_folders + v_inserted;
END LOOP;
-- Temp mapping for files src→dst (dst ids pre-allocated so we can
-- reference them in the dead-property duplication below).
CREATE TEMP TABLE IF NOT EXISTS _copy_file_map(
old_id UUID PRIMARY KEY,
new_id UUID NOT NULL DEFAULT gen_random_uuid()
) ON COMMIT DROP;
TRUNCATE _copy_file_map;
INSERT INTO _copy_file_map(old_id)
SELECT f.id
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.old_id
WHERE NOT f.is_trashed;
-- ── Batch copy all files (zero-copy: same blob_hash) ──
-- Post-D7: `user_id` omitted. Provenance via `created_by`/`updated_by`.
INSERT INTO storage.files(
id, name, folder_id, blob_hash, size, mime_type,
media_sort_date, drive_id, created_by, updated_by
)
SELECT fm.new_id, f.name, cm.new_id, f.blob_hash, f.size,
f.mime_type, f.media_sort_date, v_dest_drive_id, f.created_by,
f.updated_by
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.old_id
JOIN _copy_file_map fm ON fm.old_id = f.id
WHERE NOT f.is_trashed;
GET DIAGNOSTICS v_files = ROW_COUNT;
-- Batch increment blob ref_counts.
IF v_files > 0 THEN
UPDATE storage.blobs b
SET ref_count = ref_count + hc.cnt
FROM (
SELECT f.blob_hash, COUNT(*)::int AS cnt
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.new_id
WHERE NOT f.is_trashed
GROUP BY f.blob_hash
) hc
WHERE b.hash = hc.blob_hash;
END IF;
-- Duplicate dead properties per RFC 4918 §8.8 — id-keyed store.
INSERT INTO storage.webdav_dead_properties
(folder_id, namespace, local_name, value)
SELECT cm.new_id, dp.namespace, dp.local_name, dp.value
FROM storage.webdav_dead_properties dp
JOIN _copy_map cm ON dp.folder_id = cm.old_id;
INSERT INTO storage.webdav_dead_properties
(file_id, namespace, local_name, value)
SELECT fm.new_id, dp.namespace, dp.local_name, dp.value
FROM storage.webdav_dead_properties dp
JOIN _copy_file_map fm ON dp.file_id = fm.old_id;
RETURN QUERY SELECT v_new_root::text, v_folders, v_files;
END;
$$ LANGUAGE plpgsql;
@@ -0,0 +1,108 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- D0-step-8 companion — cascade-delete guard for the orphan-root check.
--
-- Fixes a latent bug in `storage.check_no_orphan_root_folder` that
-- surfaced during user-delete tests. Repro (verified against a fresh
-- test DB with no other data):
--
-- INSERT INTO auth.users … one user
-- Run the atomic personal-drive create (drive + root folder +
-- drives.root_folder_id wire-up + owner role_grant)
-- DELETE FROM auth.users WHERE id = <that user>
-- → ERROR: Orphan root folder rejected …
--
-- Root cause — the FK columns `storage.folders.created_by` and
-- `storage.folders.updated_by` are declared
-- `REFERENCES auth.users(id) ON DELETE SET NULL` (D0/M1 migration
-- `20260802100000_drives_schema_additive.sql`, lines 117-129). So when
-- `DELETE FROM auth.users` runs, PostgreSQL cascades a SET NULL
-- update onto every folder row referencing that user — including that
-- user's own personal-drive root folder. That UPDATE fires the
-- DEFERRED `trg_no_orphan_root_folder` constraint trigger, which queues
-- a check on the row's `NEW` state.
--
-- Cascade order (all inside the same transaction) is: SET NULL on the
-- folder → cascade DELETE storage.drives (default_for_user FK) →
-- cascade DELETE storage.folders (drive_id FK). By COMMIT, the drive
-- and the folder are both gone. When the deferred trigger fires, its
-- query `EXISTS (drive d WHERE d.id = NEW.drive_id AND d.root_folder_id
-- = NEW.id)` finds no drive, so it raises. The check is correct in
-- isolation — but the row it's checking no longer exists, so the
-- invariant it's protecting no longer applies.
--
-- Fix: add an existence guard before the drive lookup. If the row has
-- been deleted in the same transaction, skip the check — a deleted row
-- can't be an orphan by definition.
--
-- This preserves the original invariant on all live rows:
-- * The atomic four-write create transaction still gets checked at
-- COMMIT and still requires the drive→folder wire-up (the folder
-- row exists at COMMIT because we didn't delete it).
-- * Direct SQL that tries to insert an orphan root folder is still
-- rejected (the INSERT queues a check, the row exists at COMMIT,
-- the drive lookup fails, exception raised).
-- * The only new behaviour is "if this row was deleted before COMMIT,
-- silently skip" — which is what the caller wanted anyway.
--
-- No table changes, no data changes, no reverse migration needed —
-- `CREATE OR REPLACE FUNCTION` is idempotent, and every future call
-- of the trigger picks up the new body immediately.
CREATE OR REPLACE FUNCTION storage.check_no_orphan_root_folder()
RETURNS trigger AS $$
BEGIN
-- Non-root rows 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;
-- Cascade-delete guard (NEW in this migration).
--
-- The trigger is DEFERRABLE INITIALLY DEFERRED — it fires at COMMIT
-- with `NEW` captured at trigger-queue time. If the row was
-- subsequently deleted in the same transaction (e.g. the cascade
-- path from `DELETE FROM auth.users` → SET NULL on created_by /
-- updated_by → cascade DELETE storage.drives → cascade DELETE
-- storage.folders), the invariant no longer applies: there's no
-- orphan because the row itself is gone.
IF NOT EXISTS (SELECT 1 FROM storage.folders WHERE id = NEW.id) THEN
RETURN NULL;
END IF;
-- The core check: some drive must point 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. Skips the check on rows that were '
'deleted in the same tx (cascade path from user delete). See '
'docs/plan/drive.md §3.';
@@ -0,0 +1,113 @@
-- ─────────────────────────────────────────────────────────────────────────
-- D7 step 6 — drop `user_id` from `storage.files` and `storage.folders`.
--
-- Companion / final step to:
-- • `20260902000000_files_folders_user_id_nullable.sql` — dropped NOT NULL,
-- swapped uniqueness indexes to drive-scoped, retired the user_id-leading
-- indexes.
-- • `20260902000001_copy_folder_tree_drop_user_id.sql` — stopped writing
-- the column from `storage.copy_folder_tree`.
--
-- All Rust writers already omit `user_id` from INSERTs (step 4). Every read
-- has been rewritten to drive-membership predicates (step 5). This migration
-- removes the column entirely so no future accidental read/write can bind it.
--
-- Ownership continues to live in `storage.role_grants` (drive-Owner role);
-- provenance in `created_by` / `updated_by` (§14).
--
-- ── Dependencies to unpin before ALTER ───────────────────────────────────
--
-- `storage.trash_items` is a VIEW that projects both `f.user_id` and
-- `fo.user_id`. `CREATE OR REPLACE VIEW` can only APPEND columns, never
-- drop or reorder — see `bug_create_or_replace_view_column_order`. So we
-- DROP the view, then recreate it without user_id after the column drop.
--
-- All remaining pre-D7 indexes that referenced `user_id`
-- (`idx_files_trashed`, `idx_files_media_timeline`, and any legacy
-- uniqueness holdovers) are dropped implicitly by `ALTER TABLE DROP
-- COLUMN`. The D0/D7 drive-keyed successors already exist
-- (`idx_files_media_timeline_by_drive`,
-- `idx_files_unique_name_in_folder`, `idx_files_unique_name_at_root`,
-- etc.), so the hot paths retain their O(LIMIT) shape.
-- ── 1. Drop dependent view so the column drop can proceed ────────────────
DROP VIEW IF EXISTS storage.trash_items;
-- ── 2. Drop the column ───────────────────────────────────────────────────
ALTER TABLE storage.files DROP COLUMN IF EXISTS user_id;
ALTER TABLE storage.folders DROP COLUMN IF EXISTS user_id;
-- ── 3. Recreate the trash view without user_id ───────────────────────────
--
-- `drive_id` is still projected (D2b introduced it) and is the scope
-- column for per-drive trash listing; `caller_group_ids($1)` fans it
-- out to the caller's group memberships via role_grants.
CREATE VIEW storage.trash_items AS
SELECT f.id, f.name, 'file' AS item_type, f.trashed_at,
f.original_folder_id AS original_parent_id, f.created_at,
f.drive_id
FROM storage.files f
WHERE f.is_trashed = TRUE
AND (f.folder_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM storage.folders p
WHERE p.id = f.folder_id AND p.is_trashed = TRUE))
UNION ALL
SELECT fo.id, fo.name, 'folder' AS item_type, fo.trashed_at,
fo.original_parent_id, fo.created_at,
fo.drive_id
FROM storage.folders fo
WHERE fo.is_trashed = TRUE
AND (fo.parent_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM storage.folders p
WHERE p.id = fo.parent_id AND p.is_trashed = TRUE));
COMMENT ON VIEW storage.trash_items IS
'Unified view of all trashed files and folders. Post-D7: `user_id` '
'projection removed — the source column is gone. Scope is `drive_id` '
'via role_grants membership (see TrashDbRepository::get_trash_items).';
-- ── 4. Post-flight sanity ────────────────────────────────────────────────
DO $BODY$
DECLARE
files_has_col BOOLEAN;
folders_has_col BOOLEAN;
view_has_col BOOLEAN;
BEGIN
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'storage'
AND table_name = 'files'
AND column_name = 'user_id'
) INTO files_has_col;
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'storage'
AND table_name = 'folders'
AND column_name = 'user_id'
) INTO folders_has_col;
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'storage'
AND table_name = 'trash_items'
AND column_name = 'user_id'
) INTO view_has_col;
IF files_has_col THEN
RAISE EXCEPTION 'storage.files.user_id column did not drop';
END IF;
IF folders_has_col THEN
RAISE EXCEPTION 'storage.folders.user_id column did not drop';
END IF;
IF view_has_col THEN
RAISE EXCEPTION 'storage.trash_items still projects user_id — view recreate skipped';
END IF;
END;
$BODY$;
@@ -0,0 +1,68 @@
-- ─────────────────────────────────────────────────────────────────────────
-- Round 3 — admit 'calendar' and 'address_book' into
-- `storage.role_grants.resource_type`.
--
-- Companion to the domain unblock in
-- `src/domain/services/authorization.rs` (Round 3 Phase 1). The
-- `Resource::Calendar(Uuid)` and `Resource::AddressBook(Uuid)`
-- variants can't be inserted into `role_grants` until the CHECK
-- constraint on `resource_type` permits their string discriminators.
--
-- CalDAV and CardDAV surfaces have historically enforced access via
-- dedicated per-domain share tables (`caldav.calendar_shares`,
-- `carddav.address_book_shares`) and bespoke `check_calendar_access`
-- / `check_address_book_access` helpers. Round 3 folds both into the
-- unified ReBAC engine so:
--
-- * A single ACL source of truth (`storage.role_grants`) covers
-- every OxiCloud resource type — files, folders, drives,
-- calendars, address books.
-- * Group subjects become a free feature on calendar/book shares
-- (falls out of `role_grants.subject_type='group'`).
-- * The `authz.require` audit line ("👮🏻‍♂️ perms: ⛔ …") fires on
-- denial with no per-domain retrofit.
--
-- Migration of existing rows from `caldav.calendar_shares` and
-- `carddav.address_book_shares` into `role_grants` happens in the
-- next migration (Phase 2). The legacy tables stay in place through
-- this PR for rollback safety; they get dropped one release later.
-- `resource_type` is a TEXT column with a CHECK constraint (not a PG
-- enum), so extending it is a DROP / ADD pair — no `ALTER TYPE` /
-- non-transactional migration issues.
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', 'calendar', 'address_book'));
-- Post-flight: introspect the live constraint definition and prove
-- both new values appear. Cheap read-only check with no INSERT.
DO $BODY$
DECLARE
defn TEXT;
BEGIN
SELECT pg_get_constraintdef(c.oid) INTO defn
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'storage'
AND t.relname = 'role_grants'
AND c.conname = 'role_grants_resource_type_check';
IF defn IS NULL THEN
RAISE EXCEPTION
'role_grants_resource_type_check not found on storage.role_grants';
END IF;
IF position('calendar' IN defn) = 0 THEN
RAISE EXCEPTION
'CHECK constraint does not admit ''calendar'': %', defn;
END IF;
IF position('address_book' IN defn) = 0 THEN
RAISE EXCEPTION
'CHECK constraint does not admit ''address_book'': %', defn;
END IF;
END;
$BODY$;
@@ -0,0 +1,145 @@
-- ─────────────────────────────────────────────────────────────────────────
-- Round 3 Phase 2 — backfill role_grants from the legacy per-domain
-- share tables.
--
-- Companion to `20260906000000_role_grants_calendar_address_book.sql`
-- (Phase 1: CHECK constraint extension). This migration seeds the
-- unified `storage.role_grants` table with:
--
-- 1. Owner grants for every existing calendar and address book —
-- replaces the implicit "owner via `caldav.calendars.owner_id`"
-- short-circuit that the bespoke `check_calendar_access`
-- helper used.
-- 2. Non-owner grants translated from `caldav.calendar_shares` and
-- `carddav.address_book_shares` — the existing "shared with me"
-- relationships continue working after Phase 3's service
-- rewrite starts reading grants from `role_grants` only.
--
-- The legacy share tables stay in place through this PR for
-- rollback safety. They get dropped in a follow-up migration one
-- release later, once the new engine path bakes.
--
-- Idempotent: every INSERT uses `ON CONFLICT DO NOTHING` on the
-- `(subject_type, subject_id, resource_type, resource_id)` unique
-- key so a re-run (or a duplicate row in the legacy table where
-- someone shared with themselves) is a no-op.
-- ── 1. Owner grants for calendars ───────────────────────────────────────
--
-- One row per calendar in `caldav.calendars`. `granted_by = owner_id`
-- is the self-seeded creation event — the calendar's owner brought
-- themselves into existence as its owner, matching the pattern used
-- by the drive lifecycle hook for personal drives.
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
SELECT 'user', c.owner_id, 'calendar', c.id, 'owner'::storage.grant_role, c.owner_id
FROM caldav.calendars c
ON CONFLICT (subject_type, subject_id, resource_type, resource_id)
DO NOTHING;
-- ── 2. Owner grants for address books ───────────────────────────────────
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
SELECT 'user', a.owner_id, 'address_book', a.id, 'owner'::storage.grant_role, a.owner_id
FROM carddav.address_books a
ON CONFLICT (subject_type, subject_id, resource_type, resource_id)
DO NOTHING;
-- ── 3. Non-owner grants from calendar_shares ────────────────────────────
--
-- `caldav.calendar_shares.access_level` is a VARCHAR(10) with values
-- `'read'`, `'write'`, or `'owner'`. Map:
-- - `'read'` → `viewer` (bundle: Read only)
-- - `'write'` → `editor` (bundle: Read + Update)
-- - `'owner'` → `owner` (bundle: everything, including Share/Manage)
-- Anything else (defensive) falls through to `viewer` — losing
-- permission is safer than silently gaining permission if a stray
-- value slipped past the pre-D0 CHECK.
--
-- `granted_by` = calendar owner, since the legacy share table didn't
-- track the granter. Best available signal — the owner is the only
-- principal who could have created the share via the legacy code path.
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
SELECT
'user',
s.user_id,
'calendar',
s.calendar_id,
(CASE s.access_level
WHEN 'write' THEN 'editor'
WHEN 'owner' THEN 'owner'
ELSE 'viewer'
END)::storage.grant_role,
c.owner_id
FROM caldav.calendar_shares s
JOIN caldav.calendars c ON c.id = s.calendar_id
WHERE s.user_id <> c.owner_id -- skip self-shares (owner grant already covers them)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id)
DO NOTHING;
-- ── 4. Non-owner grants from address_book_shares ────────────────────────
--
-- `carddav.address_book_shares.can_write` is a BOOLEAN. Map:
-- - `false` → `viewer`
-- - `true` → `editor`
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
SELECT
'user',
s.user_id,
'address_book',
s.address_book_id,
(CASE WHEN s.can_write THEN 'editor' ELSE 'viewer' END)::storage.grant_role,
a.owner_id
FROM carddav.address_book_shares s
JOIN carddav.address_books a ON a.id = s.address_book_id
WHERE s.user_id <> a.owner_id
ON CONFLICT (subject_type, subject_id, resource_type, resource_id)
DO NOTHING;
-- ── 5. Post-flight sanity ───────────────────────────────────────────────
--
-- Every calendar / address book must now have an owner role_grant.
-- If any row is missing one, the Phase 3 service rewrite would
-- lock owners out of their own resources — refuse to leave the
-- migration in that state.
DO $BODY$
DECLARE
missing_cal_owners BIGINT;
missing_ab_owners BIGINT;
BEGIN
SELECT COUNT(*) INTO missing_cal_owners
FROM caldav.calendars c
WHERE NOT EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.subject_type = 'user'
AND g.subject_id = c.owner_id
AND g.resource_type = 'calendar'
AND g.resource_id = c.id
AND g.role = 'owner'::storage.grant_role
);
SELECT COUNT(*) INTO missing_ab_owners
FROM carddav.address_books a
WHERE NOT EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.subject_type = 'user'
AND g.subject_id = a.owner_id
AND g.resource_type = 'address_book'
AND g.resource_id = a.id
AND g.role = 'owner'::storage.grant_role
);
IF missing_cal_owners > 0 THEN
RAISE EXCEPTION
'Round 3 backfill left % calendars without an Owner role_grant',
missing_cal_owners;
END IF;
IF missing_ab_owners > 0 THEN
RAISE EXCEPTION
'Round 3 backfill left % address books without an Owner role_grant',
missing_ab_owners;
END IF;
END;
$BODY$;
@@ -0,0 +1,38 @@
-- Drop the pre-Round-3 per-domain share tables. Every reader/writer
-- was retired in the Rust cleanup landing alongside this migration:
--
-- * `CalendarUseCase::{list_shared_calendars, share_calendar,
-- remove_calendar_sharing, get_calendar_shares}` — gone
-- * `AddressBookUseCase::{share_address_book, unshare_address_book,
-- get_address_book_shares}` — gone
-- * `CalendarRepository` / `AddressBookRepository` share methods — gone
-- * SQL bodies in `calendar_pg_repository.rs` /
-- `address_book_pg_repository.rs` that touched these tables — gone
--
-- Data lives on in `storage.role_grants` (backfilled by
-- `20260906000001_backfill_calendar_address_book_role_grants.sql`).
-- The one-release rollback window between the backfill and this drop
-- was left implicit — no external process reads either table today.
DROP TABLE IF EXISTS caldav.calendar_shares;
DROP TABLE IF EXISTS carddav.address_book_shares;
-- Post-flight introspection: refuse to complete if either table is
-- still present. Guards against a name-collision resurrection by an
-- older seed file or hand-rolled restore step.
DO $$
DECLARE
stray_count INT;
BEGIN
SELECT COUNT(*) INTO stray_count
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE (n.nspname = 'caldav' AND c.relname = 'calendar_shares')
OR (n.nspname = 'carddav' AND c.relname = 'address_book_shares');
IF stray_count > 0 THEN
RAISE EXCEPTION
'Migration 20260906000002 finished with % legacy share table(s) still present',
stray_count;
END IF;
END $$;
@@ -0,0 +1,63 @@
-- ─────────────────────────────────────────────────────────────────────────
-- Round 3 (Music) — admit 'playlist' into
-- `storage.role_grants.resource_type`.
--
-- Companion to the domain unblock in
-- `src/domain/services/authorization.rs`: uncomments
-- `Resource::Playlist(Uuid)` and its `type_str` / `id` / `from_parts`
-- arms. Nothing can insert `('playlist', …)` into `role_grants` until
-- the CHECK constraint permits the discriminator.
--
-- The music surface historically enforced access via a dedicated
-- `audio.playlist_shares` table and bespoke
-- `MusicStorageAdapter::{user_has_access, user_can_write}` helpers.
-- Round 3 folds them into the unified ReBAC engine, giving playlists
-- the same treatment already applied to calendars and address books:
--
-- * A single ACL source of truth (`storage.role_grants`) covers
-- every OxiCloud resource type — files, folders, drives,
-- calendars, address books, playlists.
-- * Group subjects become a free feature on playlist shares.
-- * The `authz.require` audit line ("👮🏻‍♂️ perms: ⛔ …") fires on
-- denial with no per-domain retrofit.
--
-- Owner + share backfill from `audio.playlist_shares` happens in the
-- companion migration. The legacy table stays in place through this
-- PR for rollback safety; a follow-up migration one release later
-- drops it.
-- `resource_type` is a TEXT column with a CHECK constraint (not a PG
-- enum), so extending it is a DROP / ADD pair — no `ALTER TYPE` /
-- non-transactional migration issues.
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', 'calendar', 'address_book', 'playlist'));
-- Post-flight: introspect the live constraint definition and prove
-- 'playlist' appears. Cheap read-only check with no INSERT.
DO $BODY$
DECLARE
defn TEXT;
BEGIN
SELECT pg_get_constraintdef(c.oid) INTO defn
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'storage'
AND t.relname = 'role_grants'
AND c.conname = 'role_grants_resource_type_check';
IF defn IS NULL THEN
RAISE EXCEPTION
'role_grants_resource_type_check not found on storage.role_grants';
END IF;
IF position('playlist' IN defn) = 0 THEN
RAISE EXCEPTION
'CHECK constraint does not admit ''playlist'': %', defn;
END IF;
END;
$BODY$;
@@ -0,0 +1,90 @@
-- ─────────────────────────────────────────────────────────────────────────
-- Round 3 (Music) Phase 2 — backfill role_grants from the legacy
-- per-domain share table.
--
-- Companion to `20260910000000_role_grants_playlist.sql` (Phase 1:
-- CHECK constraint extension). This migration seeds
-- `storage.role_grants` with:
--
-- 1. Owner grants for every existing playlist — replaces the
-- implicit "owner via `audio.playlists.owner_id`" short-circuit
-- that the bespoke `user_has_access` / `user_can_write` helpers
-- used.
-- 2. Non-owner grants translated from `audio.playlist_shares` —
-- existing "shared with me" relationships keep working after the
-- Phase 3 service rewrite starts reading grants from
-- `role_grants` only.
--
-- The legacy `audio.playlist_shares` table stays in place through
-- this PR for rollback safety. It gets dropped in a follow-up
-- migration one release later, once the new engine path bakes.
--
-- Idempotent: every INSERT uses `ON CONFLICT DO NOTHING` on the
-- `(subject_type, subject_id, resource_type, resource_id)` unique
-- key so a re-run (or a duplicate row in the legacy table where
-- someone shared with themselves) is a no-op.
-- ── 1. Owner grants for playlists ───────────────────────────────────────
--
-- One row per playlist. `granted_by = owner_id` is the self-seeded
-- creation event — matches the pattern used by the calendar /
-- address-book backfill and by the drive lifecycle hook for personal
-- drives.
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
SELECT 'user', p.owner_id, 'playlist', p.id, 'owner'::storage.grant_role, p.owner_id
FROM audio.playlists p
ON CONFLICT (subject_type, subject_id, resource_type, resource_id)
DO NOTHING;
-- ── 2. Non-owner grants from playlist_shares ────────────────────────────
--
-- `audio.playlist_shares.can_write` is a BOOLEAN. Map:
-- - `false` → `viewer` (bundle: Read only)
-- - `true` → `editor` (bundle: Read + Update)
--
-- `granted_by` = playlist owner, since the legacy share table didn't
-- track the granter. Best available signal — the owner is the only
-- principal who could have created the share via the legacy code path.
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
SELECT
'user',
s.user_id,
'playlist',
s.playlist_id,
(CASE WHEN s.can_write THEN 'editor' ELSE 'viewer' END)::storage.grant_role,
p.owner_id
FROM audio.playlist_shares s
JOIN audio.playlists p ON p.id = s.playlist_id
WHERE s.user_id <> p.owner_id -- skip self-shares (owner grant already covers them)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id)
DO NOTHING;
-- ── 3. Post-flight sanity ───────────────────────────────────────────────
--
-- Every playlist must now have an owner role_grant. If any row is
-- missing one, the Phase 3 service rewrite would lock owners out of
-- their own resources — refuse to leave the migration in that state.
DO $BODY$
DECLARE
missing_owners BIGINT;
BEGIN
SELECT COUNT(*) INTO missing_owners
FROM audio.playlists p
WHERE NOT EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.subject_type = 'user'
AND g.subject_id = p.owner_id
AND g.resource_type = 'playlist'
AND g.resource_id = p.id
AND g.role = 'owner'::storage.grant_role
);
IF missing_owners > 0 THEN
RAISE EXCEPTION
'Round 3 (Music) backfill left % playlists without an Owner role_grant',
missing_owners;
END IF;
END;
$BODY$;
@@ -0,0 +1,41 @@
-- Add opaque UI preferences bag to auth.users.
--
-- Purpose. Cross-device persistence of pure UI toggles (hide dotfiles,
-- view mode, group-by choice, sidebar collapse, …). The server NEVER
-- inspects the contents — this column exists solely so that the SPA can
-- fetch its own settings from `GET /api/auth/me` on a fresh browser and
-- write them back via `PATCH /api/auth/me/profile`.
--
-- Design rule. Preferences that ONLY affect the UI live here.
-- Preferences the SERVER reads (locale for magic-link templates,
-- notify_on_share for the notification pipeline, role for authz) stay as
-- typed columns. When a UI-only preference graduates to server-relevant,
-- promote it to a column and drop the JSON key in a follow-up migration.
--
-- Merge semantics. `PATCH /api/auth/me/profile` performs a SHALLOW
-- merge via `ui_preferences || $1::jsonb` in `pg_user_repository.rs`,
-- optionally stripping nulls (frontend convention: sending `{key: null}`
-- clears the key). Full replacement isn't offered — every operation is
-- additive so a partial write from Device A doesn't wipe prefs set on
-- Device B.
--
-- Size cap. Enforced via CHECK constraint: 16 KiB compressed JSONB is
-- generous for realistic UI prefs and prevents the endpoint from being
-- used as a scratch key-value store. `pg_column_size(ui_preferences)`
-- returns the on-disk byte size which is what actually consumes rows.
ALTER TABLE auth.users
ADD COLUMN ui_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;
-- Object shape only — arrays / scalars / null are rejected. The merge
-- semantics assume an object; a scalar in this column would break the
-- shallow-merge SQL. Cheap check (single jsonb_typeof call).
ALTER TABLE auth.users
ADD CONSTRAINT users_ui_preferences_is_object
CHECK (jsonb_typeof(ui_preferences) = 'object');
-- Size guard — 16 KiB is 16384 bytes. Realistic UI-toggle payloads are
-- well under 1 KiB; the cap exists to fence off misuse, not to be
-- tight.
ALTER TABLE auth.users
ADD CONSTRAINT users_ui_preferences_size_cap
CHECK (pg_column_size(ui_preferences) <= 16384);
@@ -0,0 +1,70 @@
-- ════════════════════════════════════════════════════════════════════════════
-- caldav.calendar_events — add RECURRENCE-ID column for exception instances
-- ════════════════════════════════════════════════════════════════════════════
-- Motivation: AtalayaLabs/OxiCloud#528 — CalDAV clients (Thunderbird, Apple
-- Calendar, Gnome Calendar, DAVx⁵) modify a single occurrence of a recurring
-- event by PUTting a separate VEVENT that shares the master's UID and adds
-- a RECURRENCE-ID identifying which occurrence is overridden (RFC 5545
-- §3.8.4.4).
--
-- Pre-#528 behaviour: modifications either hit a UID collision (silent
-- 500 or corrupt state) or overwrote the master. Post-#528 the exception
-- override lives as its own row keyed by
-- (calendar_id, ical_uid, recurrence_id), with the master identified by
-- `recurrence_id IS NULL`.
--
-- Related but distinct from parser Phase 1 (rewrite of extract_ical_property
-- on top of the `ical` crate) — that landed in the same branch to enable
-- parsing RECURRENCE-ID at all. This migration is the storage half.
--
-- No backfill needed — pre-migration events all become masters (NULL). No
-- existing exception rows existed because the parser couldn't read them.
-- ════════════════════════════════════════════════════════════════════════════
BEGIN;
-- Column: nullable. NULL = master, non-NULL = exception instance whose
-- value pinpoints which occurrence of the recurring master is being
-- overridden. TIMESTAMPTZ so both timed (DATE-TIME) and all-day (DATE)
-- RECURRENCE-IDs fit — the domain-side `parse_ical_datetime` normalises
-- both into `DateTime<Utc>` (all-day → midnight UTC of the target date).
ALTER TABLE caldav.calendar_events
ADD COLUMN recurrence_id TIMESTAMP WITH TIME ZONE NULL;
COMMENT ON COLUMN caldav.calendar_events.recurrence_id IS
'RFC 5545 §3.8.4.4 RECURRENCE-ID. NULL on the master, non-NULL on '
'per-instance exception overrides. Keyed with (calendar_id, ical_uid) '
'via the two partial unique indexes below.';
-- Partial unique index: at most one master row per (calendar_id, ical_uid).
--
-- Without this a client that re-uses a UID across calendar events (e.g. a
-- pre-2026-08 import that didn't dedupe) could produce two masters — the
-- lookup by (calendar_id, ical_uid) WHERE recurrence_id IS NULL would then
-- be ambiguous and the exception-routing logic would either overwrite the
-- wrong master or refuse to insert. Pre-migration duplicates would fail
-- this index creation; if that happens, the reconciliation is out of scope
-- for this migration (dedup script would go here — but the existing
-- codebase generates fresh UIDs on ambiguity so it shouldn't fire in
-- practice).
CREATE UNIQUE INDEX idx_calendar_events_master_unique
ON caldav.calendar_events (calendar_id, ical_uid)
WHERE recurrence_id IS NULL;
-- Partial unique index: at most one exception override per
-- (calendar_id, ical_uid, recurrence_id). Prevents two rows both claiming
-- to override the same instance of the same master — which would confuse
-- the client on next PROPFIND.
CREATE UNIQUE INDEX idx_calendar_events_exception_unique
ON caldav.calendar_events (calendar_id, ical_uid, recurrence_id)
WHERE recurrence_id IS NOT NULL;
-- Read-path index for the "give me the master + all its exceptions"
-- query the PROPFIND handler will run. Covered by the two unique indexes
-- above only partially — this covering index reads the full
-- (calendar_id, ical_uid) pair in one seek regardless of which side of
-- the master/exception split.
CREATE INDEX idx_calendar_events_uid_lookup
ON caldav.calendar_events (calendar_id, ical_uid);
COMMIT;
@@ -0,0 +1,81 @@
-- ─────────────────────────────────────────────────────────────────────────
-- Heal + pin the "personal drives always have NULL quota_bytes"
-- invariant from docs/plan/drive.md §7.
--
-- Bug (#595): `folder_service.rs::PersonalDriveLifecycleHook` was
-- calling `create_personal_drive_atomic(user_id, Some(user.storage_quota_bytes()))`,
-- baking the user's envelope quota into `storage.drives.quota_bytes`
-- for every personal drive. Two conventions then collided at upload
-- time:
--
-- * User-envelope check (`check_storage_quota`) treats `0` as
-- unlimited (`quota <= 0 → Ok`).
-- * Drive-quota check (`check_drive_quota`) treats `NULL` as
-- unlimited but `Some(0)` as a literal zero-byte cap.
--
-- Setting user quota to 0 in the Admin UI ("unlimited" per the UI
-- convention) therefore stamped `drives.quota_bytes = 0` on the
-- personal drive at creation, and every subsequent upload was
-- rejected with 507 Insufficient Storage.
--
-- Rust-side fix: `folder_service.rs` now passes `None`. This
-- migration:
--
-- 1. NULLs every existing personal drive's `quota_bytes` so already-
-- created users can upload immediately after deploy (Fix 2).
-- 2. Adds a CHECK constraint so any future code path that tries to
-- write a non-NULL quota on a personal drive fails at the DB
-- layer instead of silently corrupting state (Fix 3).
--
-- Shared drives are untouched — their quota model is orthogonal and
-- the "NULL = unlimited, positive = numeric cap, 0 = literal zero"
-- semantics are the design (an admin can legitimately lock a shared
-- drive at 0 bytes, e.g. archive-only).
-- ── 1. Heal existing personal-drive rows ────────────────────────────────
--
-- Every row today with `kind = 'personal'` should carry NULL. Set them
-- to NULL unconditionally (a personal drive already at NULL is a no-op
-- under IS DISTINCT FROM). Idempotent on re-run.
UPDATE storage.drives
SET quota_bytes = NULL
WHERE kind = 'personal'
AND quota_bytes IS DISTINCT FROM NULL;
-- ── 2. Pin the invariant at the schema layer ────────────────────────────
--
-- Uses `NOT VALID` + `VALIDATE CONSTRAINT` so the ALTER TABLE grabs
-- only the fast metadata lock instead of scanning the whole table
-- under an ACCESS EXCLUSIVE lock. The row heal above already satisfies
-- every existing row, so the subsequent VALIDATE completes without
-- error.
ALTER TABLE storage.drives
ADD CONSTRAINT drives_personal_quota_null
CHECK (kind <> 'personal' OR quota_bytes IS NULL)
NOT VALID;
ALTER TABLE storage.drives
VALIDATE CONSTRAINT drives_personal_quota_null;
-- ── 3. Post-flight sanity ───────────────────────────────────────────────
--
-- Refuse to finish if any personal drive still carries a non-NULL
-- quota (defense against a race where a concurrent transaction
-- inserted a bad row between the UPDATE and the VALIDATE — the
-- VALIDATE would already have failed in that case, but the explicit
-- check makes the failure mode obvious in logs).
DO $BODY$
DECLARE
bad BIGINT;
BEGIN
SELECT COUNT(*) INTO bad
FROM storage.drives
WHERE kind = 'personal'
AND quota_bytes IS NOT NULL;
IF bad > 0 THEN
RAISE EXCEPTION
'Migration 20260916000000 left % personal drive(s) with a non-NULL quota_bytes',
bad;
END IF;
END;
$BODY$;
@@ -0,0 +1,22 @@
-- ════════════════════════════════════════════════════════════════════════════
-- Name-ordered folder listing index for streaming WebDAV PROPFIND
-- ════════════════════════════════════════════════════════════════════════════
-- `list_files_batch` walks a folder's children in `ORDER BY name` pages of
-- 500 (native + NextCloud PROPFIND). The only index on the filter column
-- was `idx_files_folder_id (folder_id)`, so EVERY page did a bitmap scan of
-- all N children plus a top-(offset+limit) sort — a quadratic full-folder
-- walk (the initial schema's `(folder_id, name, user_id)` index that served
-- this was dropped by 20260902000000 when user_id went nullable).
--
-- This composite index restores the ordered access path: combined with the
-- keyset cursor (`name > $last` — see `file_blob_read_repository.rs`
-- `list_files_batch`), each page is one O(page) index-range read with no
-- sort, regardless of folder size or scroll depth. Benchmarked in
-- benches/DEAD-PROPS.md's companion doc benches/PROPFIND-PAGING.md.
--
-- Partial (`NOT is_trashed`) to match the listing predicate and keep the
-- index compact; trashed rows are never listed by PROPFIND.
CREATE INDEX IF NOT EXISTS idx_files_folder_name
ON storage.files (folder_id, name)
WHERE NOT is_trashed;
@@ -0,0 +1,24 @@
-- ════════════════════════════════════════════════════════════════════════════
-- Web-UI listing keyset — expression indexes for the default "name" sort
-- ════════════════════════════════════════════════════════════════════════════
-- `list_resources_paged` (SPA files view) sorts case-insensitively on
-- `LOWER(name)` with an id tie-breaker. The old query applied its keyset
-- cursor OUTSIDE the folders/files UNION-ALL on computed columns, so every
-- page rescanned and top-N-sorted the whole folder (28 ms/page on a
-- 20k-entry folder). The query now pushes the cursor into each branch as a
-- sargable row-value comparison `(LOWER(name), id) > ($str, $id)` — these
-- two partial expression indexes let each branch answer that with one
-- bounded, pre-ordered index-range read (1.3 ms/page, 19.5x;
-- benches/LISTING-KEYSET.md).
--
-- Sibling of `idx_files_folder_name (folder_id, name)` (migration
-- 20260917000000), which serves the byte-wise DAV ordering; the SPA orders
-- by LOWER(name), which that index cannot provide.
CREATE INDEX IF NOT EXISTS idx_files_folder_lname
ON storage.files (folder_id, LOWER(name), id)
WHERE NOT is_trashed;
CREATE INDEX IF NOT EXISTS idx_folders_parent_lname
ON storage.folders (parent_id, LOWER(name), id)
WHERE NOT is_trashed;
@@ -0,0 +1,30 @@
-- ════════════════════════════════════════════════════════════════════════════
-- Trash listing — partial (drive_id, trashed_at) indexes on trashed rows
-- ════════════════════════════════════════════════════════════════════════════
-- The trash surface (`TrashDbRepository::list_resources_paged`, `clear_trash`,
-- `get_all_trashed_file_ids`) filters `drive_id = ANY($drives) AND
-- is_trashed = TRUE` and keysets on `trashed_at` / `deletion_date`
-- (`deletion_date` = `trashed_at` + a constant retention interval, so it is
-- strictly monotonic in `trashed_at`).
--
-- The historical `idx_{files,folders}_trashed (user_id, is_trashed)` indexes
-- were dropped with the `user_id` columns (migration 20260904000000), leaving
-- only:
-- • `idx_{files,folders}_drive_id (drive_id)` — seeks the drive but then
-- filter-scans every LIVE row of the drive to find the trashed few;
-- • `idx_{files,folders}_trash_expiry (trashed_at) WHERE is_trashed` —
-- trashed-only but keyed for the GLOBAL retention sweeper; a per-drive
-- listing scans every tenant's trash and filters.
--
-- These partial indexes bound the read to exactly the caller's drives'
-- trashed rows, pre-ordered for the trashed_at/deletion_date keysets.
-- The retention sweeper keeps `idx_*_trash_expiry` (global, no drive
-- predicate). Benchmark: benches/ROUND10.md (trash-listing section).
CREATE INDEX IF NOT EXISTS idx_files_drive_trashed
ON storage.files (drive_id, trashed_at)
WHERE is_trashed;
CREATE INDEX IF NOT EXISTS idx_folders_drive_trashed
ON storage.folders (drive_id, trashed_at)
WHERE is_trashed;