feat: per-annotation schema version + migration framework

Each stored annotation carries :v (schema-version, currently 1). tl.scene gains
a transformer registry and migrate fn that chains version upgrades on load;
save-group and open-draft stamp the current version. Empty transformer set at
v1 — bump schema-version and add a transformer when the model changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-30 01:06:14 -04:00
parent 7e47ec6a2d
commit 9c4b56dbc6
3 changed files with 40 additions and 8 deletions

View file

@ -368,7 +368,8 @@
;; fresh annotation would reuse a prior gid and clobber it on merge. ;; fresh annotation would reuse a prior gid and clobber it on merge.
(assoc-in [:scene :groups (keyword (str "ann-" (random-uuid)))] (assoc-in [:scene :groups (keyword (str "ann-" (random-uuid)))]
{:type :annotation :parent (peek (get-in db [:view :stack])) {: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)))) (assoc-in [:view :pt] :new))))
(rf/reg-event-db ::edit-draft (fn [db [_ gid]] (-> db (assoc-in [:scene :groups gid :draft] :edit) (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 (rf/reg-event-fx
::save-group ::save-group
(fn [{:keys [db]} [_ gid g orig]] (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) patch (group-patch orig g)
root? (nil? (:parent g)) ; the root timeline persists whole root? (nil? (:parent g)) ; the root timeline persists whole
id (get-in db [:project :id])] ; (no diff: it'd lose :type/:marks) id (get-in db [:project :id])] ; (no diff: it'd lose :type/:marks)

View file

@ -257,16 +257,37 @@
(get-in m [:end :ref]) (update-in [:end :ref] keyword) (get-in m [:end :ref]) (update-in [:end :ref] keyword)
(:track m) (update :track 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 (defn restore-annotations
"Re-keywordize the fields that lose their keyword-ness through JSON: a saved "Re-keywordize the fields that lose their keyword-ness through JSON (string
annotation round-trips with string :type/:parent and string mark :refs, so :type/:parent and mark :refs), then migrate each annotation to the current
put them back before merging into the (keyword-keyed) scene." schema version, before merging into the (keyword-keyed) scene."
[anns] [anns]
(into {} (map (fn [[gid g]] (into {} (map (fn [[gid g]]
[gid (-> g [gid (-> g
(update :type keyword) (update :type keyword)
(update :parent keyword) (update :parent keyword)
(update :marks #(mapv restore-mark (or % []))))])) (update :marks #(mapv restore-mark (or % [])))
migrate)]))
anns)) anns))
(defn marks->rows (defn marks->rows

View file

@ -256,9 +256,9 @@
(deftest annotations-survive-json-roundtrip (deftest annotations-survive-json-roundtrip
(testing "restore-annotations is the exact inverse of the JSON wire trip — guards (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" 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}]} :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}}]}}] :marks [{:id :m-2 :start {:ref :m-1 :at 0} :end {:ref :m-1 :at 5}}]}}]
(is (= anns (s/restore-annotations (json-roundtrip anns))))))) (is (= anns (s/restore-annotations (json-roundtrip anns)))))))
@ -363,6 +363,15 @@
(is (contains? gids :b)) (is (contains? gids :b))
(is (not (contains? gids :orphan))))))) (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 (deftest seg-point-and-link-local-round-trip
(testing "a clip-scoped point resolves back to the same ctx-local frame" (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)) (let [seg (some #(when (= :clip-b (:mark %)) %) (s/content-segments base :root))