fix(drive): move correct cascade of lpath
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user