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