0.111 → 0.112 — one spelling per name: every payload rides attrs¶
The canonical content had two ways to spell a construct's payload. A built-in
put it in named siblings ({"kind":"heading","level":1}); an unknown put it in
one opaque attrs bag ({"kind":"callout","attrs":{…}}). Which one a document
used depended on whether the build that wrote it happened to know the name — so
promoting a name from unknown to built-in changed the encoding, and five
mechanisms existed to bridge the two forms.
Now there is one:
Heading{level} ⇄ attrs.level, Code{lang} ⇄ attrs.lang, Link{url} ⇄
attrs.url, Anchor{id} ⇄ attrs.id, and ListItem's ordered/start/
ordinal ⇄ the same three under attrs. Envelope keys stay siblings —
kind/type/container, containers, continues, a mark's start/end, a
container's instance — because they belong to the object, not to the member it
names. An empty bag is omitted. The Rust types do not change: still typed in
memory, now uniform on the wire.
Stored documents keep loading, and you do not have to migrate them. The decoder reads the old spelling wherever it meets it. That is not a courtesy: it is the only thing that can work, for reasons in Why the decoder, not a migration below.
What breaks¶
Reading content JSON at the seam. Every host that reads line.level,
line.lang, mark.url, mark.id, or a list item's ordered/start/ordinal
off a Content gets undefined. The values are one level down, under attrs:
// 0.111 0.112
line.level line.attrs.level
line.lang line.attrs?.lang
mark.url mark.attrs.url
mark.id mark.attrs.id
container.ordered container.attrs.ordered
container.instance container.instance // unchanged: envelope
The TypeScript narrowing types move with them, so npm run typecheck reports
this break rather than leaving it to run time. isHeadingLine(line) now narrows
to { kind: 'heading'; attrs: { level: number } }, and its four siblings the
same way.
Writing content JSON, including the op wire. The authored lane rejects the
old spelling instead of quietly reading it: {"type":"link","url":"u"} in an
overwrite, or in a markOps / setKind / setContainers entry, is a shape
error. This is deliberate. The storage decoder would read that sibling, so
accepting it at the write door would let a host keep writing a spelling nobody
else emits, and where a bag sits beside it the sibling is not the one that wins.
A CardInput.body is not among those doors: the card wire decodes storage-lane,
so a card read from one document pushes into another whatever spelling it
carries. Respell your writes:
// 0.111
doc.applyChange({}, { markOps: [{ op: 'add', start: 0, end: 5, type: 'anchor', id: 'c1' }] })
// 0.112
doc.applyChange({}, { markOps: [{ op: 'add', start: 0, end: 5, type: 'anchor', attrs: { id: 'c1' } }] })
A typed field write — a content object handed to a field through the
metadata patch, or a richtext literal in a Quill.yaml — is deliberately not
one of these doors: it decodes storage-lane and re-canonicalizes, so the old
spelling lands correctly there instead of throwing. Respell it anyway; nothing
else emits it.
A foreign bag on a built-in is now legal, where 0.111 refused it. attrs
beside a known name is no longer an ambiguous shape, so {"kind":"para","attrs":
{"tone":"warn"}} is accepted and the bag drops unread — the same bound as
before ("the carrier preserves unknown tags, not unknown payloads on known
tags"), restated per attrs key.
Bare content JSON has no dispatcher. A consumer holding {text, lines,
marks, islands} outside a stored document — a cache, a queue payload, a fixture
— carries no schema tag, so nothing tells it which spelling it holds. Reading it
back through this library is fine (the decoder takes either). Anything that
picks the JSON apart itself needs the change above.
Rust: MarkKind::ord is gone, replaced by MarkKind::sort_key. tag() and
attrs() are new on LineKind, Container, and MarkKind.
What moves without breaking¶
Canonical bytes. attrs appears on built-ins, and coincident marks reorder
within equal (start, end) because the tie-break is now the (type, attrs)
pair the wire carries rather than a hand-assigned ordinal. Mark order carries no
meaning, but it is hash input: content hashes change, so cache keys and
template-divergence checks keyed on stored bytes recompute once.
Exported markdown. exportMarkdown / to_markdown nests coincident marks
in that same order, so its bytes move too: **~~x~~** becomes ~~**x**~~. The
two parse back to the same content, and re-importing either gives the same
canonical form — but a host that diffs or hashes the projection sees a
change, on the same one-time basis as a content hash.
Generated Typst. Coincident wraps nest in the stored mark order —
#emph[#strong[x]] where 0.111 wrote #strong[#emph[x]]. Every wrap is a
content-wrapping call, so the two set identical glyphs under default styling;
only the generated string moves. Golden expectations over emitted markup need
regenerating. The one place the nesting is visible is a quill whose show
strong: / show emph: rule decorates its body — a box, a fill, a stroke —
where the two orders paint the decoration inside out from each other.
The storage tag is now quillmark/document@0.112.0
(Document.currentStorageVersion). Rows tagged @0.93.0 migrate forward on
read; nothing else about the envelope changed.
Why the decoder, not a migration¶
A migration can only reach what it can find, and it can only find the card
body. Every other stored content sits inside an opaque payload value with no
schema tag over it:
- a
richtextfield rests as the canonical content object — that is its documented resting form, and it is where most stored content lives; - a
plaintextfield, anarray<richtext>element, a content property on anobjectfield, the same; - a
$seedoverlay carries field values by card kind, the same; $extis arbitrary host data by contract, so a walk that rewrote something in it that merely looked like content would be corrupting a consumer's payload.
So there is no walk that finds all the stored content and nothing else. Had the
decoder been made new-spelling-only, every one of those values would have
mis-read: a hard error for heading and anchor, and a silent one for code's
lang, link's url, and a list item's numbering, which have defaults.
The fallback is therefore in the decoder, and it is small: when the attrs bag
is absent, read the named sibling. It is unambiguous, because a built-in
carrying a payload always writes a bag, so an absent bag means either the old
spelling or an empty payload — and those two agree on every key. Unlike the fold
it replaces, it is frozen: it does not grow when a name is promoted, because
promotion no longer changes the encoding.
It is also not retirable on the usual evidence. A legacy DTO retires when no row carries its tag; these values carry no tag, so "no rows remain in that shape" is not checkable. Read-repair converges the population — a row read and written back rests in the current spelling — and cold rows keep the fallback alive.
Checklist¶
- Respell every seam read of
level/lang/url/id/ordered/start/ordinalto go throughattrs.npm run typecheckfinds these. - Respell every seam write of the same, op wire included. These throw — except a typed field write, which reads the old spelling and repairs it.
- Recompute anything keyed on a content hash, on stored document bytes, or on
the bytes of an
exportMarkdownprojection. - Regenerate goldens over emitted Typst markup, and over exported markdown.
- Leave your stored documents alone.