diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index a89b177..283b8b0 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -368,7 +368,8 @@ ;; fresh annotation would reuse a prior gid and clobber it on merge. (assoc-in [:scene :groups (keyword (str "ann-" (random-uuid)))] {:type :annotation :parent (peek (get-in db [:view :stack])) - :draft :new :name "" :color "#4e8fc2" :marks []}) + :draft :new :name "" :color "#4e8fc2" :marks [] + :v scene/schema-version}) (assoc-in [:view :pt] :new)))) (rf/reg-event-db ::edit-draft (fn [db [_ gid]] (-> db (assoc-in [:scene :groups gid :draft] :edit) @@ -399,7 +400,8 @@ (rf/reg-event-fx ::save-group (fn [{:keys [db]} [_ gid g orig]] - (let [g (editable-group g) + (let [g (cond-> (editable-group g) + (= :annotation (:type g)) (assoc :v scene/schema-version)) patch (group-patch orig g) root? (nil? (:parent g)) ; the root timeline persists whole id (get-in db [:project :id])] ; (no diff: it'd lose :type/:marks) diff --git a/tl/src/tl/scene.cljs b/tl/src/tl/scene.cljs index e4fb4b8..a5107f5 100644 --- a/tl/src/tl/scene.cljs +++ b/tl/src/tl/scene.cljs @@ -257,16 +257,37 @@ (get-in m [:end :ref]) (update-in [:end :ref] keyword) (:track m) (update :track keyword))) +;; --- schema versioning ---------------------------------------------------- +;; Every stored annotation carries :v, its schema version. When the data model +;; changes, bump schema-version and add a transformer that upgrades the previous +;; version's shape to the new one; old annotations migrate forward on load. + +(def schema-version 1) ; current annotation schema — bump on any model change + +;; transformers[n] upgrades a schema-v(n) annotation to v(n+1) (and must set +;; :v (n+1)). Empty at v1; e.g. {1 (fn [g] (-> g (rename-key …) (assoc :v 2)))}. +(def ^:private transformers {}) + +(defn migrate + "Upgrade annotation `g` to the current schema-version by chaining transformers. + Pre-versioned data (no :v) is treated as the original v1." + [g] + (loop [g (update g :v #(or % 1))] + (if (>= (:v g) schema-version) + (assoc g :v schema-version) + (recur ((transformers (:v g)) g))))) + (defn restore-annotations - "Re-keywordize the fields that lose their keyword-ness through JSON: a saved - annotation round-trips with string :type/:parent and string mark :refs, so - put them back before merging into the (keyword-keyed) scene." + "Re-keywordize the fields that lose their keyword-ness through JSON (string + :type/:parent and mark :refs), then migrate each annotation to the current + schema version, before merging into the (keyword-keyed) scene." [anns] (into {} (map (fn [[gid g]] [gid (-> g (update :type keyword) (update :parent keyword) - (update :marks #(mapv restore-mark (or % []))))])) + (update :marks #(mapv restore-mark (or % []))) + migrate)])) anns)) (defn marks->rows diff --git a/tl/test/tl/scene_test.cljs b/tl/test/tl/scene_test.cljs index d16c843..e9dd323 100644 --- a/tl/test/tl/scene_test.cljs +++ b/tl/test/tl/scene_test.cljs @@ -256,9 +256,9 @@ (deftest annotations-survive-json-roundtrip (testing "restore-annotations is the exact inverse of the JSON wire trip — guards against any keyword-valued field (id/ref/type/parent/track) being missed" - (let [anns {:ann-p {:type :annotation :parent :root :name "p" :color "#abc" :content "hi" + (let [anns {:ann-p {:v 1 :type :annotation :parent :root :name "p" :color "#abc" :content "hi" :marks [{:id :m-1 :start {:ref :clip-a :at 0} :end {:ref :clip-a :at -1} :track :t0}]} - :ann-c {:type :annotation :parent :ann-p :name "c" + :ann-c {:v 1 :type :annotation :parent :ann-p :name "c" :marks [{:id :m-2 :start {:ref :m-1 :at 0} :end {:ref :m-1 :at 5}}]}}] (is (= anns (s/restore-annotations (json-roundtrip anns))))))) @@ -363,6 +363,15 @@ (is (contains? gids :b)) (is (not (contains? gids :orphan))))))) +(deftest schema-migration + (testing "pre-versioned annotations are treated as v1 (the current schema)" + (is (= s/schema-version (:v (s/migrate {:type :annotation :name "x"}))))) + (testing "migrate is idempotent at the current version" + (is (= s/schema-version (:v (s/migrate {:v s/schema-version :name "x"}))))) + (testing "restore-annotations stamps :v on every annotation" + (is (= s/schema-version + (:v (get (s/restore-annotations {"a" {:type "annotation" :name "x"}}) "a")))))) + (deftest seg-point-and-link-local-round-trip (testing "a clip-scoped point resolves back to the same ctx-local frame" (let [seg (some #(when (= :clip-b (:mark %)) %) (s/content-segments base :root))