`cljfmt check src/ test/` is a step of the Backend workflow and these two
files did not pass it: an import block sorted the way a human reads it
rather than the way the formatter sorts it, and a `cond` in
`format-typed-value` indented one column short.
Formatter output only. No semantic change.
AI-assisted-by: mixed models
The graph namespaces explained themselves by citing a separate project
whose Python pipeline reads the graphs this backend writes. A reader of
this repository does not have that project and should not need it, and a
docstring that justifies a choice by pointing elsewhere cannot be checked
here.
Every claim survives; only the framing changes. Column names and types
are Penpot's own decision, recorded with the reason for each divergence
from the snake_case default. The transform registry describes the edges
it materializes. The denormalizations in `app.graph.project.document`
are justified by the walk already holding both answers.
Three corrections fall out of the rewrite:
- `app.graph.schema.contract` claimed a test, `graph_contract_test`,
that walks a checked-in schema manifest and fails on any divergence.
No such test exists. The paragraph is gone.
- `app.graph.project.document` pointed at
`app.graph.meta/projection-transforms`, which does not exist.
- `app.graph.project.transforms/registry` claimed its entries were "in
application order" while `apply-transforms!` reduced over the literal
vector. The three registered transforms read disjoint columns, so the
order is not load-bearing. The docstring now says so, and the one real
ordering constraint is stated where it applies: `link-swap-slots!`
strips `swap-slot-*` entries from `touched`, so anything reading
`touched` has to run before it.
`contract/pending-beadpot-columns` becomes `contract/unprojected-keys`.
It is referenced nowhere else.
AI-assisted-by: mixed models
`node-batch` named every top-level Arrow field with backticks, so that a
column whose name is a reserved word (`Page.index`, `Document.options`)
survived the DDL Ladybug generates for a staged table. The engine now
quotes those identifiers itself, and it does not collapse a doubled
backtick, so a pre-quoted name reaches the parser as ``index`` and
`createArrowTable` fails outright:
Parser exception: mismatched input '``' expecting PRIMARY
Name the fields with `column-name`. The `COPY` projection is Cypher
rather than DDL and keeps its own backticks through
`cypher-property-key`, and STRUCT member names keep theirs too: those
come out of `LogicalType::toString()`, which the DDL builder does not
touch, so an unquoted member called `column` still fails to parse.
Measured with `probes/arrow/probe25.clj` against lbug 0.19.1: a plain
top-level reserved word loads and reads back, a pre-quoted one fails to
parse, a plain STRUCT member fails to parse, and a pre-quoted one loads
and reads back.
Also re-dates the engine facts in the `app.graph.arrow` docstring to the
version they were checked against, drops the SIGSEGV note from
`->param-value` now that `Connection.execute` rejects an unwrapped
parameter, and removes two references to the CSV loader.
AI-assisted-by: mixed models
`app.graph.ladybug` could only run Cypher as text. Every value the sync
path writes is therefore concatenated into the statement, and nothing can
ask the engine whether a statement is even valid without running it.
Add the four functions that close both gaps. `prepare-on-connection!`
parses and binds without executing. `execute-prepared!` binds a parameter
map and runs it. `exec-prepared-on-connection!` prepares every statement
in a batch before executing any of them, so a parse or bind failure
aborts before the first mutation. `validate-on-connection!` returns
`{:ok? :error :read-only?}` instead of raising, which is what a gate
wants.
`->param-value` is the only `Value` constructor on the write path. It is
unconditional: on lbug 0.18.2 an unwrapped parameter does not raise, it
SIGSEGVs the JVM inside `lbug_value_clone`. Parameters are scalars only,
because the JNI `Value` constructor takes no list or map, so `MAP`,
`STRUCT` and `T[]` columns stay literal-rendered and the `:else` branch
raises rather than crashing.
Two departures from the design, both closing a JNI-handle leak on the
error path: `prepare-on-connection!` closes the failed
`PreparedStatement` before raising, and `execute-prepared!` closes every
`Value` it built, including the ones built before a later parameter was
rejected.
`as-statement` accepts a bare string, so the sync builders can convert to
bound parameters one family at a time rather than in one commit.
AI-assisted-by: mixed models
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.
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>