fix: rescue orphaned annotations to root in the pane

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 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-07-07 13:16:20 -04:00
parent 65a80857be
commit d54a6ae8f8
2 changed files with 45 additions and 2 deletions

View file

@ -135,11 +135,16 @@
:<- [::scene] :<- [::context] :<- [::segments] :<- [::revealed] :<- [::scene] :<- [::context] :<- [::segments] :<- [::revealed]
(fn [[scene ctx segs revealed] _] (fn [[scene ctx segs revealed] _]
(let [ann? (fn [gid] (= :annotation (:type (get-in scene [:groups gid])))) (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 ;; 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 ;; 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))] 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) ;; 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)) 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, ;; membership-reveal hierarchy: shows when ctx is a host it's filed under,

View file

@ -190,10 +190,48 @@
(rf/dispatch [::ev/toggle-children :annA]) (rf/dispatch [::ev/toggle-children :annA])
(is (contains? (pane-ids) :annC) "revealed → annC shows as annA's child at root")))) (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 ;; 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 (deftest membership-survives-the-json-wire
(rf-test/run-test-sync (rf-test/run-test-sync
(setup! (seed) [:root]) (setup! (seed) [:root])