📚 Update Tech Guide about abstraction levels

This commit is contained in:
Andrés Moya 2025-04-16 17:35:39 +02:00
parent ca2891d441
commit 2ddcd0ce15
11 changed files with 380 additions and 220 deletions

View file

@ -1,5 +1,5 @@
---
title: 3.7. Data Guide
title: 3.08. Data Guide
desc: Learn about data structures, code organization, file operations, migrations, shape editing, and component syncing. See Penpot's technical guide. Try it free!
---
@ -30,217 +30,6 @@ all of this is important in general.
Clojure (for example ending it with ? for boolean values), because this may
cause problems when exporting.
## Code organization in abstraction levels
Initially, Penpot data model implementation was organized in a different way.
We are currently in a process of reorganization. The objective is to have data
manipulation code structured in abstraction layers, with well-defined
boundaries.
At this moment the namespace structure is already organized as described here,
but there is much code that does not comply with these rules, and needs to be
moved or refactored. We expect to be refactoring existing modules incrementally,
each time we do an important functionality change.
### Abstract data types
```text
▾ common/
▾ src/app/common/
▾ types/
file.cljc
page.cljc
shape.cljc
color.cljc
component.cljc
...
```
Namespaces here represent a single data structure, or a fragment of one, as an
abstract data type. Each structure has:
* A **schema spec** that defines the structure of the type and its values:
```clojure
(sm/define! ::fill
[:map {:title "Fill"}
[:fill-color {:optional true} ::ctc/rgb-color]
[:fill-opacity {:optional true} ::sm/safe-number]
...)
(sm/define! ::shape-attrs
[:map {:title "ShapeAttrs"}
[:name {:optional true} :string]
[:selrect {:optional true} ::grc/rect]
[:points {:optional true} ::points]
[:blocked {:optional true} :boolean]
[:fills {:optional true}
[:vector {:gen/max 2} ::fill]]
...)
```
* **Helper functions** to create, query and manipulate the structure. Helpers
at this level only are allowed to see the internal attributes of a type.
Updaters receive an object of the type, and return a new object modified,
also ensuring the internal integrity of the data after the change.
```clojure
(defn setup-shape
"A function that initializes the geometric data of the shape. The props must
contain at least :x :y :width :height."
[{:keys [type] :as props}]
...)
(defn has-direction?
[interaction]
(#{:slide :push} (-> interaction :animation :animation-type)))
(defn set-direction
[interaction direction]
(dm/assert!
"expected valid interaction map"
(check-interaction! interaction))
(dm/assert!
"expected valid direction"
(contains? direction-types direction))
(dm/assert!
"expected compatible interaction map"
(has-direction? interaction))
(update interaction :animation assoc :direction direction))
```
> IMPORTANT: we should always use helper functions to access and modify these data
> structures. Avoid direct attribute read or using functions like <code class="language-clojure">assoc</code> or
> <code class="language-clojure">update</code>, even if the information is contained in a single attribute. This way
> it will be much simpler to add validation checks or to modify the internal
> representation of a type, and will be easier to search for places in the code
> where this data item is used.
>
> Currently much of Penpot code does not follow this requirement, but it
should do in new code or in any refactor.
### File operations
```text
▾ common/
▾ src/app/common/
▾ files/
helpers.cljc
shapes_helpers.cljc
...
```
Functions that modify a file object (or a part of it) in place, returning the
file object changed. They ensure the referential integrity within the file, or
between a file and its libraries.
```clojure
(defn resolve-component
"Retrieve the referenced component, from the local file or from a library"
[shape file libraries & {:keys [include-deleted?] :or {include-deleted? False}}]
(if (= (:component-file shape) (:id file))
(ctkl/get-component (:data file) (:component-id shape) include-deleted?)
(get-component libraries
(:component-file shape)
(:component-id shape)
:include-deleted? include-deleted?)))
(defn delete-component
"Mark a component as deleted and store the main instance shapes inside it, to
be able to be recovered later."
[file-data component-id skip-undelete? Main-instance]
(let [components-v2 (dm/get-in file-data [:options :components-v2])]
(if (or (not components-v2) skip-undelete?)
(ctkl/delete-component file-data component-id)
(let [set-main-instance ;; If there is a saved main-instance, restore it.
#(if main-instance
(assoc-in % [:objects (:main-instance-id %)] main-instance)
%)]
(-> file-data
(ctkl/update-component component-id load-component-objects)
(ctkl/update-component component-id set-main-instance)
(ctkl/mark-component-deleted component-id))))))
```
> This module is still needing an important refactor. Mainly to take functions
> from common.types and move them here.
#### File validation and repair
There is a function in <code class="language-clojure">app.common.files.validate</code> that checks a file for
referential and semantic integrity. It's called automatically when file changes
are sent to backend, but may be invoked manually whenever it's needed.
### File changes objects
```text
▾ common/
▾ src/app/common/
▾ files/
changes_builder.cljc
changes.cljc
...
```
Wrap the update functions in file operations module into <code class="language-clojure">changes</code> objects, that
may be serialized, stored, sent to backend and executed to actually modify a file
object. They should not contain business logic or algorithms. Only adapt the
interface to the file operations or types.
```clojure
(sm/define! ::changes
[:map {:title "changes"}
[:redo-changes vector?]
[:undo-changes seq?]
[:origin {:optional true} any?]
[:save-undo? {:optional true} boolean?]
[:stack-undo? {:optional true} boolean?]
[:undo-group {:optional true} any?]])
(defmethod process-change :add-component
[file-data params]
(ctkl/add-component file-data params))
```
### Business logic
```text
▾ common/
▾ src/app/common/
▾ logic/
shapes.cljc
libraries.cljc
```
Functions that implement semantic user actions, in an abstract way (independent
of UI). They don't directly modify files, but generate changes objects, that
may be executed in frontend or sent to backend.
```clojure
(defn generate-instantiate-component
"Generate changes to create a new instance from a component."
[changes objects file-id component-id position page libraries old-id parent-id
frame-id {:keys [force-frame?] :or {force-frame? False}}]
(let [component (ctf/get-component libraries file-id component-id)
parent (when parent-id (get objects parent-id))
library (get libraries file-id)
components-v2 (dm/get-in library [:data :options :components-v2])
[new-shape new-shapes]º
(ctn/make-component-instance page
Component
(:data library)
Position
Components-v2
(cond-> {}
force-frame? (assoc :force-frame-id frame-id)))
changes (cond-> (pcb/add-object changes first-shape {:ignore-touched true})
(some? old-id) (pcb/amend-last-change #(assoc % :old-id old-id)))
changes (reduce #(pcb/add-object %1 %2 {:ignore-touched true})
changes
(rest new-shapes))]
[new-shape changes]))
```
## Data migrations
```text