penpot/backend/src/app/graph/debug.clj
Álvaro Tejero Cantero 7185fcee64
✨ Retrofit the overlay: rules, resolved refs, intervals, tokens
Four architectural results of the 2026-08-20 study become structure
(docs.local/graph/20260820-report-graph-overlay-datascript.md):

The relation vocabulary is a rule set, not raw clauses: child-of,
descendant-of, instance-of, refers-to, fills-swap-slot and uses-token
are named once in app.graph.overlay.queries, and every consumer query
and backported beadpot assertion traverses relations through them, so
an encoding can change under a rule without a consumer moving.

The subtle helpers run in the builder, never in a query:
ctf/find-ref-shape is called once per copy shape at build time and its
answer is the plain :shape/refers-to reference. The sync path
re-resolves the affected set when resolution inputs change: the shape
on :shape-ref, the subtree on head attributes and reparents, every
instance subtree of a component whose record or copy moved.

Containment carries a second form: one global Euler-tour numbering,
one DFS per container from a single global counter, so "inside subtree
X" is two interval comparisons. Per-container counters are the bug the
suite pins against: ranges would overlap numerically across pages.
The sync path renumbers a container from beyond the global maximum;
interval containment, not the numbers, is the invariant.

Token applications fold the property into the attribute name:
{:token/fill "layerTwo.background"} over the closed vocabulary of
app.common.types.token/all-keys, behind the uses-token rule so the
choice stays reversible. The relation-entity encoding and its two
extra datoms per application are gone.

31 tests, 650 assertions, 0 failures across the three suites.

AI-assisted-by: claude-fable-5
2026-10-06 10:32:57 +02:00

345 lines
13 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.debug
"In-memory overlay sessions for the debug graph console.
A session is a datascript overlay of one file
(`app.graph.overlay`), maintained from the msgbus file-change feed by
`app.graph.overlay.sync` and queried in Datalog through
`app.graph.overlay.console`. The Ladybug engine no longer appears on
this path: it keeps the batch-export tier (`app.graph.ingest`), where
columnar output is the point. The overlay is immutable data in an atom,
so readers never lock and the sync loop is the only writer."
(:require
[app.binfile.common :as bfc]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.time :as ct]
[app.db :as db]
[app.graph.overlay :as overlay]
[app.graph.overlay.console :as console]
[app.graph.overlay.queries :as queries]
[app.graph.overlay.sync :as overlay.sync]
[app.msgbus :as mbus]
[app.srepl.helpers :as h]
[clojure.string :as str]
[datascript.core :as d]
[promesa.exec.csp :as sp]))
(set! *warn-on-reflection* true)
(def default-query
"Default console query, written to be self-explanatory in the textarea.
The `filter_*` columns carry node ids for the graph-view result filter;
the results table hides them (see `hide-filter-columns` and the
template's `renderQueryOutput`). `$` is bound to the overlay and `%` to
the shared rule set of `app.graph.overlay.queries`."
(str "[:find ?component ?instance ?page\n"
" ?filter_src_id ?filter_tgt_id\n"
" :in $ %\n"
" :where\n"
" (instance-of ?s ?c)\n"
" [?c :component/name ?component]\n"
" [?s :shape/name ?instance]\n"
" [?s :shape/container ?pc]\n"
" [?pc :container/name ?page]\n"
" ;; filter_* columns omitted from the table; they drive the graph view\n"
" [(identity ?s) ?filter_src_id]\n"
" [(identity ?c) ?filter_tgt_id]]"))
(defonce ^:private sessions
(atom {}))
(defn- session-key
[profile-id]
(str profile-id))
(defn- destroy-session!
[{:keys [sync-ch msgbus]}]
(when sync-ch
(sp/close! sync-ch)
(when msgbus
(mbus/purge! msgbus [sync-ch]))))
(defn- format-cell
[value]
(cond
(nil? value) "NULL"
(string? value) value
:else (str value)))
(defn- format-query-result
[{:keys [columns rows truncated?]}]
{:columns (mapv str columns)
:rows (mapv (fn [row]
(mapv format-cell row))
rows)
:truncated? truncated?
:row-count (count rows)})
(defn- apply-file-change!
[profile-id {:keys [changes revn file-id]}]
(try
(when-let [current (get @sessions (session-key profile-id))]
(when (= file-id (:file-id current))
(let [result (overlay.sync/apply-changes @(:db-atom current) changes)
sync-at (ct/now)]
(reset! (:db-atom current) (:db result))
(swap! sessions update-in [(session-key profile-id) :meta]
(fn [meta]
(cond-> (-> meta
(update :sync dissoc :error)
(assoc-in [:sync :last-at] sync-at)
(assoc-in [:sync :last-applied] (:applied result))
(assoc-in [:sync :last-skipped] (:skipped result)))
(seq (:applied result))
(assoc :graph-revn (long revn)))))
(when (seq (:skipped result))
(l/dbg :hint "graph sync skipped changes"
:file-id (str file-id)
:revn revn
:skipped (:skipped result))))))
(catch Throwable cause
(l/wrn :hint "graph sync failed"
:file-id (str file-id)
:cause cause)
(swap! sessions assoc-in [(session-key profile-id) :meta :sync :error]
(ex-message cause)))))
(defn- start-sync-loop!
[{:keys [profile-id file-id] :as session}]
(if-let [msgbus (:msgbus session)]
(let [sync-ch (sp/chan :buf (sp/dropping-buffer 64))]
(mbus/sub! msgbus :topic file-id :chan sync-ch)
;; Recur ONLY while the channel is open. A bare `(recur)` after
;; `take!` returns nil would spin forever across every Load.
(sp/go-loop []
(when-let [message (sp/take! sync-ch)]
(when (= :file-change (:type message))
(apply-file-change! profile-id message))
(recur)))
(assoc session :sync-ch sync-ch))
session))
(defn session-info
"Return a public view of the current session for `profile-id`, if any."
[profile-id]
(when-let [{:keys [file-id meta loaded-at]} (get @sessions (session-key profile-id))]
{:file-id file-id
:name (:name meta)
:revn (:revn meta)
:graph-revn (:graph-revn meta)
:schema-version (:schema-version meta)
:projection (:projection meta)
:sync (:sync meta)
:loaded-at (ct/format-inst loaded-at :iso)}))
(defn sync-status
"Return incremental sync status for the active session."
[profile-id]
(when-let [session (get @sessions (session-key profile-id))]
(let [{:keys [file-id meta loaded-at]} session]
{:file-id file-id
:revn (:revn meta)
:graph-revn (:graph-revn meta)
:sync (:sync meta)
:loaded-at (ct/format-inst loaded-at :iso)})))
(defn unload-session!
"Discard the in-memory overlay for `profile-id`."
[profile-id]
(when-let [session (get @sessions (session-key profile-id))]
(destroy-session! session))
(swap! sessions dissoc (session-key profile-id)))
(defn- fetch-file!
[system file-id]
(let [file-id (h/parse-uuid file-id)
file (db/run! system #(bfc/get-file % file-id :realize? true))]
(when-not file
(ex/raise :type :not-found
:code :file-not-found
:hint "file not found"
:file-id (str file-id)))
(when-not (:data file)
(ex/raise :type :internal
:code :file-without-data
:hint "file has no data blob"
:file-id (str file-id)))
[file-id file]))
(defn load-session!
"Build the overlay of `file-id` for `profile-id`."
[cfg profile-id file-id]
(unload-session! profile-id)
(let [msgbus (::mbus/msgbus cfg)
[file-id file] (fetch-file! cfg file-id)
started (System/nanoTime)
db (overlay/build (:data file) file)
build-ms (/ (- (System/nanoTime) started) 1e6)
stats (assoc (queries/stats db)
:edges (queries/edge-counts db)
:build-ms (long build-ms))
meta {:name (:name file)
:revn (:revn file)
:graph-revn (:revn file)
:schema-version overlay/schema-version
:projection {:stats stats}}
session (-> {:db-atom (atom db)
:file-id file-id
:meta meta
:msgbus msgbus
:profile-id profile-id
:loaded-at (ct/now)}
start-sync-loop!)]
(swap! sessions assoc (session-key profile-id) session)
meta))
(defn session-db
"The current overlay value for `profile-id`, or nil."
[profile-id]
(some-> (get @sessions (session-key profile-id)) :db-atom deref))
(defn query-session!
"Run a Datalog `statement` against the overlay for `profile-id`.
Read-only by construction — `d/q` cannot transact — and gated against
function smuggling by `app.graph.overlay.console/check-query!`."
[profile-id statement]
(when (or (nil? statement) (= "" statement))
(ex/raise :type :validation
:code :missing-query
:hint "datalog query is required"))
(if-let [db (session-db profile-id)]
(-> (console/run-query db statement)
format-query-result)
(ex/raise :type :not-found
:code :graph-session-not-loaded
:hint "load a file graph before running queries")))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; graph-view export
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- entity-node
"Node payload for the G6 view. Node ids are datascript eids, which keeps
two shapes sharing one uuid in different containers distinct — the
Cypher export merged them."
[db eid]
(let [e (d/entity db eid)]
(cond
(:document/id e)
{:id (str eid) :label (or (:document/name e) (str (:document/id e))) :table "Document"}
(:component/id e)
{:id (str eid) :label (or (:component/name e) (str (:component/id e))) :table "Component"}
(:container/id e)
{:id (str eid) :label (or (:container/name e) (str (:container/id e))) :table "Page"}
(:shape/id e)
{:id (str eid)
:label (or (:shape/name e) (str (:shape/id e)))
:table (get overlay/shape-type->table (:shape/type e) "Shape")}
(:color/id e)
{:id (str eid) :label (or (:color/name e) (str (:color/id e))) :table "Color"}
(:typography/id e)
{:id (str eid) :label (or (:typography/name e) (str (:typography/id e))) :table "Typography"}
(:token-set/id e)
{:id (str eid) :label (or (:token-set/name e) (str (:token-set/id e))) :table "TokenSet"}
(:token/id e)
{:id (str eid) :label (:token/name e) :table "Token"})))
(defn- ref-edges
[db attr rel]
(map (fn [dtm] {:source (str (:e dtm)) :target (str (:v dtm)) :rel rel})
(d/datoms db :avet attr)))
(defn- rule-edges
[db rule rel]
(map (fn [[s t]] {:source (str s) :target (str t) :rel rel})
(d/q (into [] (concat '[:find ?s ?t :in $ % :where] [(list rule '?s '?t)]))
db queries/rules)))
(defn- token-use-edges
"UsesToken edges from the folded token attributes, joined to token
entities by name (one edge per matching token when names repeat across
sets, the same join the `uses-token` rule performs)."
[db]
(for [attr overlay/token-attrs
dtm (d/datoms db :aevt attr)
token (map :e (d/datoms db :avet :token/name (:v dtm)))]
{:source (str (:e dtm))
:target (str token)
:rel "UsesToken"}))
(defn export-graph-data!
"Export the node/edge inventory of the overlay for `profile-id` as plain
data for the debug graph view. Returns nil when no session is loaded."
[profile-id]
(when-let [{:keys [db-atom file-id meta]} (get @sessions (session-key profile-id))]
(let [db @db-atom
eids (into (sorted-set)
(mapcat #(map :e (d/datoms db :avet %)))
[:document/id :container/id :component/id :shape/id
:color/id :typography/id :token-set/id :token/id])
nodes (into [] (keep #(entity-node db %)) eids)
edges (-> []
(into (ref-edges db :shape/parent "IsChildOf"))
(into (ref-edges db :container/document "IsChildOf"))
(into (ref-edges db :component/document "IsChildOf"))
(into (ref-edges db :color/document "IsChildOf"))
(into (ref-edges db :typography/document "IsChildOf"))
(into (ref-edges db :token-set/document "IsChildOf"))
(into (ref-edges db :token/set "IsChildOf"))
(into (rule-edges db 'instance-of "IsInstanceOf"))
(into (rule-edges db 'refers-to "RefersTo"))
(into (rule-edges db 'fills-swap-slot "FillsSwapSlot"))
(into (ref-edges db :shape/fill-color "UsesColor"))
(into (ref-edges db :shape/stroke-color "UsesColor"))
(into (ref-edges db :shape/text-color "UsesColor"))
(into (ref-edges db :shape/uses-typography "UsesTypography"))
(into (token-use-edges db)))
;; component containers appear in both the component and the
;; container eid sweeps; the sorted set already deduplicates,
;; and `entity-node` classifies them as Component.
edges (into [] (distinct) edges)]
{:file-id (str file-id)
:revn (:graph-revn meta)
:truncated false
:datoms (count db)
:nodes nodes
:edges edges})))
(defn- hide-filter-columns
"Drop `filter_*` columns from a query result before HTML table render;
they exist to feed node ids to the graph-view filter, not for reading.
The JSON response path keeps the full result."
[{:keys [columns rows] :as result}]
(let [idxs (vec (keep-indexed
(fn [i c] (when-not (str/starts-with? (str c) "filter_") i))
columns))]
(if (or (empty? idxs) (= (count idxs) (count columns)))
result
(assoc result
:columns (mapv (vec columns) idxs)
:rows (mapv (fn [row] (mapv (vec row) idxs)) rows)))))
(defn console-context
"Build template data for the graph debug console page."
[profile-id & {:keys [query query-result error message]}]
{:session (session-info profile-id)
:query (or query default-query)
:query-result (some-> query-result hide-filter-columns)
:error error
:message message
:default-query default-query})