Ladybug is schema-first and strongly typed: a property key gets its type
at table-creation time and there is no widening later. That makes the
Malli to Ladybug mapping the whole of the graph's typing, and it was
leaving a lot on the table: a transform stored as `STRING`, a rect as
`JSON`, a set of feature flags as a single `STRING`. A column typed
`DOUBLE[4]` is four numbers a consumer reads as a tensor row; the same
value as JSON is text somebody has to parse and trust.
`app.graph.schema.types` now maps, in order: scalars; Penpot value types
whose layout is fixed even though Malli only sees a map or a string
(`::gmt/matrix` to `DOUBLE[6]`, `::gpt/point` to `DOUBLE[2]`,
`::grc/rect` to `DOUBLE[4]`, `::clr/hex-color` to `UINT32`); then
structure, with collections to `T[]`, `:map-of` to `MAP(k, v)`, and a
closed map of scalars to a `STRUCT`. JSON is the fallback of last
resort, for schemas that genuinely admit more than one shape.
Two defects fell out. `::sm/set` was unmapped, so `features` and
`migrations` were single strings rather than `STRING[]`, and
`::sm/one-of`, how Penpot spells a closed set of keywords, was unmapped
too, so `blend-mode`, `grow-type`, the constraints and every `layout-*`
were mistyped.
A tight column is only worth having if the writer fills it in that
shape, so `app.graph.schema.values` shapes a value for its type: a
matrix record into six doubles, a hex colour into a packed integer, a
map into a struct's fields. Both writers go through it, so the bulk load
and the incremental sync cannot disagree. What that required:
- STRUCT field names must be backticked in the DDL *and* in every
literal, because a grid cell has a field named `column`. The catalog
reports them bare.
- A struct literal's type is its field list, so every declared field
must appear, and an absent one needs `cast(NULL, '<type>')`. A bare
NULL is typed STRING and changes the struct's type.
- `STRUCT(…)[]` starts with `STRUCT(` but is a list, so the list check
comes first.
- Nested lists cannot be rendered with `str`: Clojure's `[1 2]` is
space-separated and Ladybug reads it as a one-element array.
Three more corrections in the same area:
- `project-attrs` used truthiness where it meant `some?`, so `opacity 0`
and `blocked false` projected as absent.
- Set-valued columns are written sorted. A set has no order, so the
column varied between builds of the same file, which is precisely what
stops two builds being diffable.
- An empty collection is written as `[]` rather than skipped. A shape
with no fills has none; NULL would say "unknown".
Renamed the `kuzu-*` helpers to `ladybug-*`: Kùzu is deprecated and
Ladybug substitutes it, so a name bearing the engine should bear this
one. The one remaining mention cites the upstream issue Ladybug
inherits.
AI-assisted-by: mixed models
Three parity failures against beadpot's suite, all one cause: the bulk loader
put compound and multi-line values into CSV, where Ladybug parses a field's
*contents* as a literal with no escape mechanism at all. Verified against
0.18: a comma inside a list element ends the element, quotes are kept as part
of the value rather than delimiting it, and the parallel reader rejects
quoted newlines outright.
So a value now goes through CSV only if it cannot be misread there — UUIDs,
numbers, booleans, single-line strings, and lists of those. Everything else
(MAP, STRUCT, STRING[]/JSON[], any string containing a newline) is written
after the COPY by one Cypher statement per row, where `app.graph.ladybug`
escapes properly. Parquet removes the distinction entirely and is still the
right destination (masterplan P0 T1); this is what CSV can honestly do.
Consequences beyond the encoding:
- `touched` entries reached the graph as `:swap-slot-…`, keywords stringified
with their colon, so `LinkSwapSlots` matched nothing. Keywords now render
through `name`.
- Shape names lost their newlines to a flattening step that existed only to
keep the CSV writer happy. They are preserved.
- `applied_tokens` keys are rendered camelCase, the form Penpot's own JSON
encoder produces and the one beadpot's `AppliedTokenKey` holds — a MAP
column's keys are values, not schema, so they are not snake_cased.
- `link-component-instances!` keys on `component-file`, not `component-id`
alone. The projection denormalizes `component-id` down the shape tree, after
which it no longer tells an instance head from a shape inside one, and the
transform linked every descendant frame; `ctk/instance-of?` requires both
keys anyway. IsInstanceOf on the variants fixture: 78 -> 60, matching
beadpot exactly.
`app.graph.schema.nodes/format-column-value` is now the single place that
knows a column's type and its contract details, used by the bulk loader and
the incremental sync alike so the two cannot disagree about a value's shape.
A projected graph is a cache of one file at one revision, built by one
schema, and nothing in it said so. `GraphMeta` records the file, the
revision, the schema version and the producer, and is written last, so
its presence also marks the build complete and its contents say whether
a cached database is still worth opening.
- `graph/meta.clj`: the `GraphMeta` table and its writer.
- `graph/schema/contract.clj`: one place that maps a Penpot key to its
graph column. The rule is snake_case of the key; every exception, be
it a rename, a drop or a type override, is recorded there with its
reason, so a divergence is a diff to review rather than a silent
rename.
- `graph/project/document.clj`: `page-id` and the inherited
`component-id` are written during the tree walk, which already knows
both, rather than by a post-ingest statement. `graph/sync.clj` does
the same on the incremental path, so a live-synced graph matches a
rebuild.
- `graph/project/transforms.clj`: a registry, so adding a derived-link
pass is one entry. Adds `RefersTo` (from `shape-ref`) and
`FillsSwapSlot` (from `swap-slot-*` entries in `touched`, then
stripped as `ctk/normal-touched-groups` does).
- `graph/debug.clj`, `graph/stats.clj`: enumerate relationship tables
from the catalog instead of naming them, so the console's graph view
and the ingest counts pick up new edge types without being told.
- `graph/debug.clj`, `http/debug.clj`: `graph-export` gains
`source=session`, which snapshots the live in-memory console graph
through EXPORT/IMPORT DATABASE. Live sync moves that graph away from a
fresh projection, and taking it away to query elsewhere is the point
of asking for it.
AI-assisted-by: mixed models
graph-data gains bm-bytes (CALL bm_info() -> [mem_limit mem_usage], nil-safe, under the session lock); the session panel shows it as MiB behind the node/edge counts — real resident memory replacing the removed estimate.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
The default query is now multi-line with // comments that explain the filter_* column convention in place (Kuzu accepts comments and blank lines mid-statement; verified against an in-memory database through the console query path). The query fieldset is retitled 'LadybugDB Cypher' with the Cypher word linking to https://docs.ladybugdb.com/cypher/.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Graph view gains an on-canvas G6 toolbar (auto-fit, expand, restore - the fullscreen icons drive the existing in-page expand), an 'animate' checkbox that disables animation unconditionally when off (persisted, adaptive <=100-node rule applies only when on), and a ResizeObserver on the canvas so the panel follows window/flex resizes without touching the user's viewport. Preset tree positions are now only injected for the built-in tree layout, removing the tree-then-layout flash on animated re-renders under G6 layouts. Refetches skip the repaint when the display projection (nodes, edges, truncated) is byte-identical, so attribute-only change bursts no longer repaint.
Console: default query returns s/t name+label over all edges plus filter_src_id/filter_tgt_id columns; filter_* columns are hidden from the results table (client and server render) but still feed the 'Show result in graph view' id harvest, keeping the table legible while the graph filter stays available. The query text persists in localStorage across page reloads (restored only over the default, never over a server-rendered query). Legend shows colored Unicode glyphs matching node shapes instead of squares with textual annotations. Load/Unload buttons share one row (HTML5 form attribute), and the loaded file name links to the Penpot workspace via the legacy /#/workspace/<project-id>/<file-id> route resolved client-side from the files-tree payload.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
COPY failed on any file with container shapes: list-typed DDL columns (shapes UUID[], points STRING[], strokes JSON[], ...) were JSON-encoded in staging CSVs, which Ladybug's list parser rejects. Write Kuzu list literals instead, typed per column. Also: value->clj no longer crashes on LIST/STRUCT values (binding lacks value_get_value support; fall back to string), and the debug session Connection is now guarded by a per-session lock — it was shared unsynchronized between the msgbus sync loop and HTTP query/export handlers, and one lost DETACH DELETE was observed under concurrent refetch load.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
POC per work/g6/plan.md. New /dbg/actions/graph-data exports the in-memory Ladybug session as plain JSON (per-table node queries + multi-table IsChildOf match, row cap 100k with truncation flag). Console page renders it with AntV G6 v5 (jsDelivr CDN, antv-dagre BT layout, color+glyph per node table, validated palette) and refetches debounced on live :file-change messages.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>