From d54a6ae8f847a4b7980437cde084bb39c8315a9e Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 7 Jul 2026 13:16:20 -0400 Subject: [PATCH] fix: rescue orphaned annotations to root in the pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by loading the real Challengers project through re-frame-test: 3 of 15 stored annotations were filed under a since-deleted parent. Under the :in model their sole host was a ghost, so ::all-annotations never surfaced them — invisible and unrefileable. ::all-annotations now drops :in hosts that no longer exist and rescues an annotation left with none to root (full-scene context, so peer-delta safe). Tests (re-frame-test, high-level events + pane sub): - orphaned-annotation-is-rescued-to-root - legacy-parent-data-migrates-through-peer-delta (the real DB shape) Co-Authored-By: Claude Opus 4.8 --- tl/src/tl/subs.cljs | 9 +++++++-- tl/test/tl/flow_test.cljs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/tl/src/tl/subs.cljs b/tl/src/tl/subs.cljs index 00861c6..388c1fb 100644 --- a/tl/src/tl/subs.cljs +++ b/tl/src/tl/subs.cljs @@ -135,11 +135,16 @@ :<- [::scene] :<- [::context] :<- [::segments] :<- [::revealed] (fn [[scene ctx segs revealed] _] (let [ann? (fn [gid] (= :annotation (:type (get-in scene [:groups gid])))) + exists? (fn [x] (contains? (:groups scene) x)) ;; each annotation → the mark-groups it's FILED UNDER (:in membership). A ;; normal annotation has one; a linked one has several, so it lists under - ;; each. Asserted, not derived from where marks resolve. + ;; each. Asserted, not derived from where marks resolve. Any :in host that + ;; no longer exists (its group was deleted) is dropped; an annotation left + ;; with NO surviving host is rescued to root so it stays visible/refileable + ;; instead of vanishing into a ghost parent. parents (into {} (for [[gid g] (:groups scene) :when (= :annotation (:type g))] - [gid (scene/membership scene gid)])) + (let [ms (filter exists? (scene/membership scene gid))] + [gid (if (seq ms) (set ms) #{:root})]))) ;; child count per timeline (drives the "Show N" nested badge) nested (reduce (fn [acc ps] (reduce #(update %1 %2 (fnil inc 0)) acc ps)) {} (vals parents)) ;; membership-reveal hierarchy: shows when ctx is a host it's filed under, diff --git a/tl/test/tl/flow_test.cljs b/tl/test/tl/flow_test.cljs index 8412ce5..6ba878d 100644 --- a/tl/test/tl/flow_test.cljs +++ b/tl/test/tl/flow_test.cljs @@ -190,10 +190,48 @@ (rf/dispatch [::ev/toggle-children :annA]) (is (contains? (pane-ids) :annC) "revealed → annC shows as annA's child at root")))) +;; ========================================================================= +;; real-data hazard: an annotation whose only host was deleted (an orphan) +;; must NOT vanish — the pane rescues it to root so it stays refileable. +;; (found by loading the actual Challengers project: 3/15 annotations were +;; filed under a since-deleted parent.) +;; ========================================================================= + +(deftest orphaned-annotation-is-rescued-to-root + (rf-test/run-test-sync + ;; annC's sole host (annA) is deleted out from under it, as if by restore of + ;; stale data or a peer deleting the parent + (setup! (update (seed) :groups dissoc :annA) [:root]) + (testing "annC's :in still points at the now-missing annA" + (is (= [:annA] (:in (get-in (scene*) [:groups :annC])))) + (is (nil? (get-in (scene*) [:groups :annA])))) + (testing "but the pane rescues it to root rather than dropping it" + (is (contains? (pane-ids) :annC) "orphan is visible at root, not lost") + (is (= #{:root} (:parents (card :annC))) "listed under root until re-filed")) + (testing "and it can be re-filed normally from there" + (rf/dispatch [::ev/file-into :annC :annB]) + (is (contains? (s/membership (scene*) :annC) :annB))))) + ;; ========================================================================= ;; JSON wire round-trip of :in (vector, keyworded) via restore-annotations ;; ========================================================================= +(deftest legacy-parent-data-migrates-through-peer-delta + (rf-test/run-test-sync + (setup! clips-scene [:root]) + ;; a reload/peer delivers a legacy annotation — string :parent, no :in, exactly + ;; the shape stored in the real DB before the membership migration + (rf/dispatch [::ev/peer-delta + {:changed {:ann-legacy {:type "annotation" :parent "root" :name "L" + :marks [{:id "lm" :start {:ref "clip-a" :at 0} + :end {:ref "clip-a" :at 50}}]}} + :deleted []}]) + (testing "it migrates :parent -> :in [:root] on ingest and drops the old field" + (is (= [:root] (:in (get-in (scene*) [:groups :ann-legacy])))) + (is (nil? (:parent (get-in (scene*) [:groups :ann-legacy]))))) + (testing "and shows up in the root pane like any other" + (is (contains? (pane-ids) :ann-legacy))))) + (deftest membership-survives-the-json-wire (rf-test/run-test-sync (setup! (seed) [:root])