0.134 → 0.135 — the engine mints, lowers and guards: placeholder ids, structural-edit verbs, one spelling per value, and a single writer¶
Core is single-writer: a stale base refuses¶
The break no type checker reports: a write under a base the value has moved on from throws edit::stale_base where it rebased. In 0.134 a stale base on apply, revise or replace rebased the write over the change since, kept in an in-memory log, and a markdown base merged a revise onto it. Now a base is the hash the writer read and nothing else, and a value that hashes otherwise refuses the write, carrying current, the hash now, and writes nothing. Core keeps no history of its writes. Writers that answer late and must merge with what landed since, an LLM beside a person typing, share a @quillmark/collab session, whose Yjs document merges them.
// 0.134
const receipt = doc.apply({}, change, { base }) // lands over the change since
if (receipt.rebased) refresh()
writer.reviseBody(reply, { baseMarkdown: read }) // merges onto the change since
// 0.135
try {
doc.apply({}, change, { base })
} catch (e) {
if (e.diagnostics[0].code === 'edit::stale_base') readAgain(e.diagnostics[0].args.current)
}
writer.revise({}, reply, { baseHash: writer.hashAt({}) })
| Break | Surface | Action |
|---|---|---|
A stale base on apply, revise or replace throws edit::stale_base, where it rebased |
Rust, WASM, Python | Read again and write from the read; route late writers that must merge through @quillmark/collab |
edit::rebase_conflict is retired |
Rust, WASM, Python | Drop its handling; edit::stale_base is the one base refusal |
The receipt's rebased is gone |
Rust, WASM, Python | Drop the read |
baseMarkdown / base_markdown is gone, and the writer's revise and reviseDocument take { baseHash } alone; another key throws |
WASM, Python | Pass the hash writer.hashAt(addr) or writer.hash() answered at the read |
quillmark_content::rebase, Conflict, RebaseError, ReviseError::Conflict, diff::revise_keeping, Base, DocumentBase, BaseError, Receipt::rebased, LOG_STEPS, read_at, lines::delta::merge_lines and rebase_marks_pinned are gone; EditError::Base(BaseError::Stale { .. }) is EditError::StaleBase(StaleBase { .. }) and BoundParseError::Base is BoundParseError::StaleBase; WriteOptions::base, TypedWriter::revise and revise_document_against take the hash as &str |
Rust | Pass Some(&hash) where you passed Some(Base::Hash(&hash)) |
A revise diffs the writer's markdown against the value, as in 0.134, so what the value holds and markdown does not spell, an empty paragraph, two hard breaks in a row in a cell, an x- mark, is gone from the result. An x- island still refuses to drop (edit::drops_ext_island).
A write takes a Content only in its canonical form¶
The break no type checker reports: a Content a write takes is refused where normalize would rewrite it. In 0.134 every input path decoded, normalized, then validated, so JSON with a reversed or empty mark, a mark over only a line break, marks or anchors out of order, an unused container, an untrimmed equation tex or a table head: 1 loaded, silently rewritten. Now replace, diff, invert, the structural edits, exportMarkdown and every other verb taking a Content throw, as do Rust's Content::from_value (Invariant::NotCanonical, naming the part, or the invariant the decoded value breaks) and Card::replace. normalize is the one input path that still rewrites: a producer that builds a value by hand normalizes it first. A value the engine answered, from importMarkdown, a read, a receipt or repair, is already canonical.
// 0.134: lands, normalized
// 0.135: throws where `normalize` would rewrite it
doc.replace({}, handBuilt)
doc.replace({}, normalize(handBuilt))
A stored row reads leniently. Document.fromStored (Rust Document::from_stored, Python Document.from_stored) reads a body or field value a write would refuse as its canonical form, and adds a content::noncanonical_read warning to doc.warnings at each one, so the host stores the document again and the row's hash moves once. The lenient read stays: a row never re-stored still opens.
| Break | Surface | Action |
|---|---|---|
A Content that normalize would change is refused by every verb taking one but normalize |
Rust, WASM, Python | Pass the value through normalize where a producer builds it by hand |
Content::from_value refuses what it normalized; Content::from_value_lenient is the stored read |
Rust | Call from_value_lenient where you read a stored value, from_value where you take one to write |
normalize no longer throws where replace would; replace throws where normalize would rewrite |
WASM | Normalize, then replace |
Document.fromStored carries content::noncanonical_read warnings, where it carried none |
Rust, WASM, Python | Re-store a document whose warnings hold one; serde_json deserialization of a Document reads the same without the warnings |
Invariant gains NotCanonical and BreakInCell; StoredContent is {content, rewritten} |
Rust | Add the match arms; read .content where you read .0 |
A table cell is one paragraph¶
The break no type checker reports: a table cell holds one paragraph, its lines parted by hard breaks (U+2028). In 0.134 a cell held paragraphs, and | one<br><br>two | or <td><p>one</p><p>two</p></td> imported as two; the editor holds one per cell and lost every write to such a table. Now both import as one, a hard break, two, and a <p> boundary in an HTML cell is a hard break where it dropped as parse::dropped_construct. A value with a paragraph break in a table cell is refused at every write as Invariant::BreakInCell, a step leaving one refuses, and repair joins one, reported with inserts: true. A stored cell of several paragraphs reads joined under content::noncanonical_read. A footnote's note is one paragraph the same way: the import joins a note's paragraphs with hard breaks, and a run of breaks as a cell's, so [^1]: one<br><br>two and a <br> ending a paragraph a continuation follows each read as one hard break where they imported as a note of two paragraphs that could not be stored; now a value holding a \n in a note is refused as a cell's is, joined on a stored read and by repair. An x- island's slots keep their paragraphs.
// 0.134: { text: "one\ntwo", blocks: [b0, b1] }
// 0.135: { text: "one\u2028two", blocks: [b0] }
importMarkdown('| h |\n| - |\n| one<br><br>two |').content.islands[0].slots.s1
| Break | Surface | Action |
|---|---|---|
<br><br> in a cell imports as one hard break; a <p> boundary in an HTML cell as one too |
Rust, WASM, Python | Nothing to change; a cell of paragraphs no longer exists to edit |
A Content whose table cell holds a \n is refused |
Rust, WASM, Python | Join a cell's paragraphs with U+2028, or pass it through normalize |
| Export never writes a table as HTML for a cell of several paragraphs | Rust, WASM, Python | Nothing to change |
A Content whose footnote note holds a \n is refused, as Invariant::BreakInCell, whose message names the slot and island |
Rust, WASM, Python | Join a note's paragraphs with U+2028, or pass it through normalize |
A step holding a key it does not define is refused¶
A change step decodes only the keys its kind defines. In 0.134 an unknown key was ignored, so a misspelled delete turned a guarded replace into a pure insert and a misspelled quote dropped the guard. Now the change is refused at the decoder and nothing is written: ParseError::UnknownKey { at, key } in Rust, a thrown Error naming the key in JS, a ValueError naming it in Python.
// 0.134: lands as an insert of "hi" before "hello"
// 0.135: throws, naming `delet`
doc.apply({}, { steps: [{ splice: { at: 0, delet: 'hello', insert: 'hi' } }] })
| Break | Surface | Action |
|---|---|---|
| A step object holding a key its kind does not define is refused | Rust, WASM, Python | Spell each step's keys as CONTENT.md § Changes lists them; the attrs and props bags stay open |
quillmark_content::wire::ParseError gains UnknownKey |
Rust | A match over it adds the arm |
A write mints from the value it lands on¶
A revise or replace mints from the value it lands on and the write alone, never the card's history of recent writes, so one write over equal values stores equal bytes on every card.
| Break | Surface | Action |
|---|---|---|
| A revise or replace no longer mints past ids only the card's history held | Rust, WASM, Python | Nothing to change; a card with history stores what a fresh one does |
Formatting inside a link's text survives a revise¶
Formatting inside a link's text, which 0.134's export dropped and every revise with it, is now written inside the link's brackets, so a revise keeps it. to_markdown_checked also names an x- block or mark among its losses (Loss::ExtBlock, Loss::ExtMark) where it reported none.
| Break | Surface | Action |
|---|---|---|
diff::revise answers ReviseError (Import or Refused) where it answered ImportError |
Rust | Match ReviseError::Import where you matched the import error |
Anchor text is spelled as stored¶
anchorContexts, anchorContext, and a content write's droppedAnchors spell a quote, prefix and suffix as the value stores them: a hard break is U+2028 and a cell's block break is one \n, as movedAnchors.before already did. In 0.134 they spelled a hard break as \n and a cell's block break as \n\n, so one receipt spelled an anchor two ways. reattach places a selector in either spelling for this release: one stored under 0.134 still finds its text.
| Break | Surface | Action |
|---|---|---|
A selector's quote, prefix, suffix hold U+2028 for a hard break |
Rust, WASM, Python | Compare selectors through reattach, or normalize both spellings before comparing them yourself |
Content values, changes and markdown are bounded¶
A value, change or markdown input past a bound now refuses where it was read in full. No document a person writes nears these; a host that generates content should check it stays inside them.
| Bound | Value | Refused as | Where |
|---|---|---|---|
| Marks on one text, a field's or a slot's | 64 + 4 per character, each mark counted once per block and line it spans before it is cut at each | Invariant::TooManyMarks, edit::too_many_marks |
decoding a value (fromStored, a field read, replace, diff, invert); a change, at the step that would pass it; repair keeps those in order up to it |
| Anchors on a value | 100 000 | Invariant::TooManyAnchors, edit::too_many_anchors |
decoding; a change; repair keeps those in reading order up to it |
| Markdown one import reads | 10 MiB (MAX_MARKDOWN_SIZE) |
ImportError::TooLarge, edit::input_too_large |
revise, reviseBody, reviseField, importMarkdown and every other import, as a parse already was |
| A content value's text and its slots' texts, at a binding | 10 MiB | edit::input_too_large |
every WASM and Python input path taking a Content or RawContent |
| Steps in one change, nested ones counted | 16 384 (MAX_CHANGE_STEPS) |
edit::too_many_steps |
apply in every binding and Card::write, and every WASM and Python input path taking a Change |
| Characters footnote notes copy past each note's first reference | 1 000 000 (MAX_NOTE_COPIES) |
a reference past it stays as its [^label] text, warning footnote:size |
every markdown import |
Before, a few kilobytes of x- marks of distinct attributes over many blocks decoded into gigabytes, and a note cited thousands of times into tens of megabytes; on wasm32 either trapped. A Python Content past a bound raises the diagnostics error under these codes, where an invalid value raises ValueError.
| Break | Surface | Action |
|---|---|---|
Invariant gains TooManyMarks and TooManyAnchors; ImportError gains TooLarge; DroppedConstruct gains FootnoteSize; EditError gains InputTooLarge, TooManySteps, TooManyMarks and TooManyAnchors |
Rust | An exhaustive match adds the arms |
A change refused for its marks or anchors throws edit::too_many_marks / edit::too_many_anchors where it threw edit::content_apply |
Rust, WASM, Python | Route the new codes |
New, with nothing to migrate: MAX_MARKDOWN_SIZE, MAX_NOTE_COPIES, MARKS_PER_CHAR, MARKS_BASE, max_marks, MAX_ANCHORS and Content::check_bounds in quillmark-content; MAX_CHANGE_STEPS, check_change and check_content_value in quillmark_core::document.
A write mints the ids its change leaves open¶
The break no type checker reports: an id may no longer open with ?. A record a change puts may carry a placeholder, ? and an id, which the write that lands the change fills with an id it mints and names in the receipt's new minted; a value holding an id that opens with ? is no longer valid.
// 0.134: the client minted, and could collide with another client's mint
doc.apply(addr, { steps: [{ splice: { at, insert: '\n', blocks: [{ id: myMint(), kind: 'para' }] } }] }, { base })
// 0.135
const { minted } = doc.apply(addr, { steps: [{ splice: { at, insert: '\n', blocks: [{ id: '?new', kind: 'para' }] } }] }, { base })
minted['?new'] // 'b1'
| Break | Surface | Action |
|---|---|---|
An id opening with ? is invalid: a value holding one refuses, and a change putting one is a placeholder the write fills |
Rust, WASM, Python | Rename any stored id that opens with ? before upgrading |
structural::split_block takes Option<&str>; indent, outdent, wrap and an id-less split_block put placeholders (?0, ?1, …) that apply fills |
Rust | Pass Some(id) for a chosen id, and read the ids from Applied::minted |
Card::apply, revise, replace, write and write_in take impl Into<WriteOptions> where they took Option<&str> |
Rust | A None or Some(hash.as_str()) still compiles; an Option<&String> needs .map(String::as_str) |
Edit gains Structural; Receipt and Applied gain minted |
Rust | An exhaustive match adds the arms; a struct literal adds the field |
New, with nothing to migrate: Receipt.minted in every binding ("minted" in Python), ids::is_placeholder and the mint module in quillmark-content.
Structural edits are document verbs, and a range lowers exactly¶
The WASM structural-edit functions that answered a Change over a whole Content are gone; the structural edits are Document verbs that write and answer a Receipt, as apply does, refusing a stale base. diffRange(before, after, { from, to }) lowers the range ProseMirror's ReplaceStep names as one exact splice, so typing a letter into a word is a one-letter splice rather than a rewrite of the word; doc.blockAt(addr, offset) answers the block at an offset.
// 0.134
doc.apply(addr, splitBlock(doc.read(addr), at, 'b9'), { base })
// 0.135
const { minted } = doc.splitBlock(addr, at, { base })
| Break | Surface | Action |
|---|---|---|
splitBlock, joinBlocks, deleteRange, indent, outdent and wrap are no longer module functions |
WASM | Call doc.splitBlock(addr, at, opts), doc.joinBlocks(addr, id, opts), doc.deleteRange(addr, start, end, opts), doc.indent(addr, first, last, opts), doc.outdent(addr, first, last, opts) or doc.wrap(addr, first, last, kind, opts); splitBlock takes the new id as opts.id |
New, with nothing to migrate: diffRange and doc.blockAt in WASM; diff::diff_range, Card::block_at and the Card structural-edit verbs in Rust.
A text field is stored as a content object spelling codec: "plain"¶
The break no type checker reports: getStored on a conformed text field answers a content object, not a string. A text field is stored as Content like a richtext one, its codec: "plain" key marking the literal codec, so it holds comment anchors and ids, and toMarkdown still writes it as its literal text. A host that read getStored as the string reads it with reader.get (the literal text) or reader.getContent.
// 0.134
const title = doc.getStored({ field: 'title' }) // "a *literal* line"
// 0.135
doc.getStored({ field: 'title' }) // { text: "a *literal* line", blocks: [...], codec: "plain" }
quill.reader(doc).get('title') // "a *literal* line"
| Break | Surface | Action |
|---|---|---|
A conformed text field's stored value is a Content spelling codec: "plain" |
Rust, WASM, Python | Read it through reader.get or reader.getContent; a stored string still reads, and rewrites to the object at its next bound load |
field_hash of a text field hashes its content, ids and anchors included, not its literal text |
Rust, WASM, Python | A hash read before the upgrade is stale once the field conforms; read it again |
Content gains codec; Invariant::NotPlain refuses a plain value holding a mark, an island, a container or a block other than a paragraph |
Rust, WASM, Python | A Rust struct literal of Content or Raw names codec (Codec::Rich for what it built before) |
The render plate's text value carries codec: "plain", and a text blank is the empty plain content |
Rust, WASM, Python | A plate reads it through qm.str or as content, unchanged |
A text value's literal text spells a paragraph break as a blank line and a hard break as a lone \n, both ways: a\n\nb imports as two paragraphs, where it imported an empty one between them, and a value of two paragraphs reads as a\n\nb, where it read as a\nb and revised as one paragraph |
Rust, WASM, Python | Spell a paragraph break in a text value as a blank line; a value stored with empty paragraphs between its lines reads back as itself |
A stored row needs no migration: a text field holding a string is the authored form, and the bound input path conforms it once. reattach answers a whole-field selector, an empty quote, prefix and suffix at a path naming a value that holds no content, as reattached, so a comment can sit on an enum, a number, a date or a boolean (COMMENTS.md § "Text and scalar fields").
An anchor step places or moves, never both¶
The break no type checker reports: an anchor step naming an id the value already holds now throws edit::content_apply. In 0.134 {id, start, end} placed a new anchor or silently moved the held one, so two hosts minting one id merged two comments. Now {id, start, end} places, and refuses an id the value or any slot holds; {id, start, end, move: true} moves the anchor its text holds, and refuses one it does not. diff, revise, invert and the receipts' applied write moves as move: true.
// 0.134: moves c1
doc.apply({}, { steps: [{ anchor: { id: 'c1', start: 6, end: 11 } }] })
// 0.135
doc.apply({}, { steps: [{ anchor: { id: 'c1', start: 6, end: 11, move: true } }] })
| Break | Surface | Action |
|---|---|---|
A placement of a held anchor id throws edit::content_apply |
Rust, WASM, Python | Add move: true to a step that moves an anchor |
A move of an id the text does not hold throws edit::content_apply |
Rust, WASM, Python | Place it instead; a move across slots is a removal and a placement |
AnchorStep::Set is AnchorStep::Place and AnchorStep::Move; ApplyError gains AnchorHeld |
Rust | Match the new arms |
repair answers the change from the merged state¶
repair answers {content, repairs, change}: change takes the merged state's text to the repaired value's, so a peer maps a caret, or a change made against the repaired value, through it with mapPos. Whether and by whom a repair is written back is @quillmark/collab's; inserts: true marks a mend that lands twice when it is written back twice.
The break no type checker reports: a range anchor that starts or ends on a heading's hard break shrinks off it when repair turns the break into a space, as apply moves an anchor over a rewritten character, so the anchors repair answers agree with change.
| Break | Surface | Action |
|---|---|---|
| A range anchor on a heading's repaired hard break shrinks off it | Rust, WASM, Python | Read the anchor from content, not from the merged state |
repair::repair answers (Content, Vec<Repair>, Change) |
Rust | Destructure the third field, or ignore it with _ |
Repaired gains change |
WASM | A strict equality over the whole result adds it |
New, with nothing to migrate: repair in Python, and repair::change_in in Rust, which counts a repair's change in a host's units.
movedAnchors reports drift by position, a point included¶
The break no type checker reports: movedAnchors names anchors it left out in 0.134. It named only a range anchor whose quote changed, so a point never reported, and a quote relocated into another sentence, or a tag the writer moved, reported nothing. Now it names every surviving anchor the write moved, a point included, and each entry carries reason, the first of these that holds:
position: ananchorstep put it other than where the write's text edits carry it, or in another slot;block: its start sits in another block;context: its quote, or the eight characters either side of it, read otherwise. Typing beside an anchor reports it.
for (const { id, reason } of receipt.movedAnchors) {
if (reason !== 'context') reviewComment(id)
refreshSelector(id)
}
| Break | Surface | Action |
|---|---|---|
movedAnchors names points, block moves, step moves and edits within eight characters of an anchor |
Rust, WASM, Python | Re-read the selector of each anchor named; filter by reason where a host only wants some |
Moved gains reason: MoveReason; each entry on the wire gains reason |
Rust, WASM, Python | A Rust struct literal adds the field; a strict equality over an entry adds reason |
The writer's revise answers the content Receipt¶
The break no type checker reports in Python, and in JS where the result is untyped: writer.reviseBody / reviseField (and revise_body / revise_field) answer the receipt doc.revise answers, { applied, hash, droppedAnchors, movedAnchors, warnings, minted }, where 0.134 answered { delta, warnings, droppedAnchors }. applied is the change from the value before, which mapPos maps a position through in place of the line delta; a text field's is one splice over its literal text. droppedAnchors entries are the selectors reattach takes, not { id, line }. reattach takes each path's applied where it took a delta.
// 0.134
const { delta, droppedAnchors } = writer.reviseBody(md)
const caret = mapThroughDelta(delta, oldCaret)
doc.reattach(selectors, { 'main.body': delta })
// 0.135
const { applied, droppedAnchors } = writer.revise({}, md)
const caret = mapPos(applied, oldCaret).pos
doc.reattach(selectors, { 'main.body': applied })
| Break | Surface | Action |
|---|---|---|
The writer's revise receipt is { applied, hash, droppedAnchors, movedAnchors, warnings, minted } |
WASM, Python | Read applied for delta; read each dropped anchor as a selector |
reattach's second argument maps a path to a Change |
Rust, WASM, Python | Pass the receipt's applied |
Revised is gone: every revise_* verb on Card, CardMut, TypedWriter and CardWriter returns Receipt, its warnings ImportWarnings |
Rust | Read applied for delta; anchor warnings with receipt.diagnostics(Some(&path)); count applied in a host's units with in_units |
Every write on Document validates, and the schema-free writes are unchecked¶
The break no type checker reports: a content value in a field is held as a body's is. storeField and insertCard stored a field's value exactly as written, so a content object whose keys came in another order compared equal and stored other bytes, exported as a YAML tree with its anchors, and an invalid one stored and loaded where the same value as a body was refused. Now each content value in a field, at any depth (an object holding a text string and a blocks array), decodes as a body write's does and stores in its canonical form: an invalid one throws edit::field_decode, and a load refuses a stored row holding one. A field's string, number or other object is stored as before.
The writes no quill schema checks move under one namespace in every surface, so a call site says which lane it writes through: doc.unchecked.storeField(addr, value) and doc.unchecked.insertCard(card, at?) (WASM), doc.unchecked.store_field(name, value, card=None) and doc.unchecked.insert_card(card, at=None) (Python), doc.unchecked().store_field(card, name, value) and doc.unchecked().insert_card(at, card) (Rust). insertCard answers the placed card's $id.
// 0.134
doc.storeField('title', 'x')
doc.insertCard(quill.seedCard('note'), 0)
// 0.135
doc.unchecked.storeField('title', 'x')
const id = doc.unchecked.insertCard(quill.seedCard('note'), 0)
| Break | Surface | Action |
|---|---|---|
A content value in a field stores canonical; an invalid one throws edit::field_decode, at the write and through a CardInput's payload items |
Rust, WASM, Python | Pass a hand-built value through normalize first |
Document.fromStored refuses a row holding a field content value that does not read |
Rust, WASM, Python | Repair the row's field, or drop it, before loading |
doc.storeField and doc.insertCard are doc.unchecked.storeField and doc.unchecked.insertCard; insertCard returns the card's $id |
WASM | Insert .unchecked |
doc.insert_card is doc.unchecked.insert_card, returning the $id; doc.unchecked.store_field is new |
Python | Insert .unchecked |
Document::push_card and insert_card are doc.unchecked().insert_card(at, card), at: None appending, answering the id; CardMut::store_field / store_fields are doc.unchecked().store_field(card, name, value); Card::store_field on a card not yet placed stays |
Rust | Call through unchecked() |
addCard answers the placed card's $id¶
| Break | Surface | Action |
|---|---|---|
TypedWriter::add_card answers CardAdded { id, warnings }, where it answered the warnings alone |
Rust | Read .warnings; .id is the placed card's $id |
writer.addCard returns { id, warnings } (CardAdded), where it returned Diagnostic[] |
WASM | Read .warnings; .id is the placed card's $id |
writer.add_card returns {"id": ..., "warnings": [...]}, where it returned the list |
Python | Read ["warnings"]; ["id"] is the placed card's $id |
One markdown import, one export, and one revise¶
The break no type checker reports: every revise verb is the content revise at an address, so a typed reviseField or reviseDocument now relocates a moved quote of four characters or more. An anchor whose paragraph a rewrite moves now follows it rather than dropping, so a host that read droppedAnchors to orphan a thread sees it in movedAnchors instead. A text field's revise diffs by word, as a body does, where it diffed by character. A value whose markdown imports to one that does not validate is refused by the address revise with edit::content_apply, and reviseDocument keeps its stored value with an edit::content_apply warning at its path, rather than storing a value the document cannot save.
Markdown crosses through one import and one export, and an annotated option, never a second name, says whether anchors and islands survive:
// 0.134
const doc = Document.fromMarkdown(md)
const whole = doc.toAnnotatedMarkdown()
const read = doc.annotatedAt({ card: 0 })
const body = doc.bodyMarkdown()
// 0.135
const doc = Document.parse(md)
const whole = doc.toMarkdown(undefined, { annotated: true })
const read = doc.toMarkdown({ card: 0 }, { annotated: true })
const body = doc.toMarkdown({})
| Break | Surface | Action |
|---|---|---|
Document.fromMarkdown is Document.parse |
WASM, Python | Rename the call |
doc.toAnnotatedMarkdown(), doc.annotatedAt(addr) and doc.annotated(addr) are doc.toMarkdown(addr?, { annotated: true }) |
WASM | Pass the address, or undefined for the whole document |
doc.bodyMarkdown(cardAddr?) and reader.bodyMarkdown() are gone |
WASM | Call doc.toMarkdown(cardAddr ?? {}); a field address reads that value's markdown |
exportMarkdown(content) takes { annotated } |
WASM | Nothing to change for the plain export; pass { annotated: true } for the tags and tokens |
doc.annotated(field=, card=) and reader.body_markdown() are gone |
Python | Call doc.to_markdown_at(field=, card=, annotated=True), or without annotated for the plain read; doc.to_markdown(annotated=True) reads the whole document |
quillmark_core::Content is the content model, where it was the line form; quillmark::Lines is gone |
Rust | Read a body with card.body() and write one with Card::replace |
Normalized is Lines, into_normalized is into_lines, Dropped is DroppedConstruct; quillmark_content::lines is hidden |
Rust | Rename; a renderer or codec reaching the line form names quillmark_content::lines |
body_lines, overwrite_body, overwrite_field, revise_body_content, revise_field_content, revise_import and revise_import_against are gone |
Rust | Write a Content with Card::replace; revise markdown with Card::revise, or the typed TypedWriter::revise |
AnchorRead, Annotated, ImportError, ImportWarning and DroppedConstruct live in quillmark_content::markdown |
Rust | Import them from there, or from quillmark_core::document as before |
The parity corpus holds no lines key; reimports is a Content |
Corpus readers | Read content |
quillmark_content::gestures is structural, its Gesture is StructuralEdit, and Edit::Gesture is Edit::Structural |
Rust | Rename |
Usv is Scalar, Content::len_usv is len_scalars, ANCHOR_CONTEXT_USV is ANCHOR_CONTEXT_SCALARS, and usv::char_to_byte is units::char_to_byte |
Rust | Rename |
markdown::Loss::Line is Loss::Mark, Loss::Island and Loss::Text, over the model's MarkKind and island ids |
Rust | Match the three arms |
quillmark::Delta and quillmark_core::session::{Delta, Assoc, Op} are gone, Content::lines_from_value is quillmark_content::migrate::lines_from_value, and BlockConstruct::tally is crate-private |
Rust | A content change is quillmark_content::change::Change; a renderer reaching the line form calls migrate::lines_from_value |
One dropped-anchor shape, one annotated export¶
| Break | Surface | Action |
|---|---|---|
DocumentRevised.dropped_anchors holds AnchorSelectors (droppedAnchors: AnchorContext[] in WASM, selector dicts in Python), where it held { id, path, cardId, line } |
Rust, WASM, Python | Pass them to reattach as a value write's droppedAnchors are; read quote where you read line |
exportMarkdown(c, { annotated: true }) answers { markdown, anchors }, each anchor { id, line } |
WASM | Read .markdown |
doc.to_markdown(annotated=True) answers { "markdown", "anchors" }, each anchor { id, path, card_id, line }, and doc.to_markdown_at(..., annotated=True) answers { "markdown", "anchors" }, each { id, line } |
Python | Read ["markdown"] |
New, with nothing to migrate: Python's import_markdown, export_markdown(content, annotated=), normalize, is_inline and is_plain, the module functions WASM already had.
Storage writes quillmark/document@0.135.0¶
The break no type checker reports: a 0.134 build refuses a row 0.135 saves. 0.135 adds the codec key to a content value, which a 0.134 reader does not decode, so the tag moves and an older build reads the row as a version it does not know rather than a text field as rich text. A row under 0.134.0 loads unchanged and saves under 0.135.0, with no bytes but the tag moving. The tree is the same.
| Break | Surface | Action |
|---|---|---|
Every save writes quillmark/document@0.135.0 |
Rust, WASM, Python | Upgrade every reader of a store before any writer; currentStorageVersion() answers the new tag |
Document::hash moves with the tag |
Rust, WASM, Python | A whole-document hash read before the upgrade is stale; read it again |
STORAGE_V0_135_0 and StoredDocument::V0_135_0 are new |
Rust | A match over StoredDocument adds the arm |
A line-form value loads migrated, and warns¶
A row holding the v1 line form, in its body, a field at any depth or a $seed overlay value, under any tag, loads with each such value in the content model and a content::noncanonical_read warning at its path, a $seed value's at its card's. In 0.134 a $seed value kept its line-form spelling and a migrated body or field warned nothing. Storing the document writes each value back in the model, so a resave over every row reports the rows still holding the line form and rewrites them. A $ext value stays as stored. The line form is readable in this release so that resave can run; a later release stops reading it.
| Break | Surface | Action |
|---|---|---|
A line-form body, field or $seed value warns content::noncanonical_read on load |
Rust, WASM, Python | Store each document whose warnings hold one; resave every row before upgrading past the release that stops reading the line form |
A $seed value reads in the content model where it read as stored |
Rust, WASM, Python | Read a $seed content value as a Content |
A content value or change holding a key its shape does not define is refused¶
The break no type checker reports: a Content or Change carrying an extra key throws where it decoded. In 0.134 the decoder read the keys it named and dropped the rest, so a key a newer build adds would have vanished on an older read, and a misspelled pth in a splice's block record landed the block at the root. Now the value, each block, container, mark, anchor and island in it, each slot, a change's top level and every record a step carries hold only the keys their shape defines, and a built-in member's attrs only the entries it names (level, lang, ordered, start, checked, url, name). Island props and an x- member's attrs stay open. A stored row's value reads leniently: the extra key drops and the read warns content::noncanonical_read.
| Break | Surface | Action |
|---|---|---|
An unknown key on a value, record, slot or change, or a foreign attrs entry on a built-in, is ParseError::UnknownKey |
Rust, WASM, Python | Drop the key, or carry the data in island props or an x- member |
A revise of a short paragraph keeps its neighbours' records¶
A diff now pairs lines by the words its word diff reads, so punctuation beside a word no longer makes it another word, and a rewrite of the first block keeps the \n that opens the next. In 0.134 Alpha. → Alpha, edited. replaced the whole line and re-minted the record of the paragraph after it. Now its record stays.
| Break | Surface | Action |
|---|---|---|
| A revise or replace keeps more block ids | Rust, WASM, Python | Nothing to change; read ids from the receipt |
Error enums are #[non_exhaustive]¶
EditError, StorageError, WireError, core's ParseError, and the content crate's ApplyError, MoveReason, Repair, Reason, Invariant and ParseError may gain variants in a minor release.
| Break | Surface | Action |
|---|---|---|
An exhaustive match over one of them needs a wildcard arm |
Rust | Add _ => |
New, with nothing to migrate: quillmark re-exports CardMut, Edit, Located, Target, Unchecked, WriteOptions, Change, Dropped, Moved and MoveReason, so a host writing content names the facade alone.
The body is an address: one revise, one get, one hash¶
The writer revises the body and a field through one verb, and the reader and the hashes read the body through the field door, the body being the address with no field. reviseBody and reviseField fold into revise(addr, text, opts), which takes the Addr reader.get and hashAt already take. Nothing else changes: a field revise is still typed and conformed, a body revise still the content revise, and the receipt is the same Receipt.
// 0.134
writer.reviseBody(md, { baseHash })
writer.reviseField('summary', text)
writer.card(0).reviseBody(md)
// 0.135
writer.revise({}, md, { baseHash })
writer.revise('summary', text)
writer.card(0).revise({}, md)
# 0.134
writer.revise_body(md, card=0, base_hash=h)
writer.revise_field("summary", text)
reader.get("summary"); doc.body
# 0.135
writer.revise(md, card=0, base_hash=h)
writer.revise(text, "summary")
reader.get("summary"); doc.read()
| Break | Surface | Action |
|---|---|---|
writer.reviseBody(md, opts) and writer.reviseField(name, text, opts) are writer.revise(addr, text, opts), the body {}; the card writer's are card(i).revise(addr, text, opts), addr a field name or { field? } |
WASM | Rename; pass {} for a body |
writer.revise_body(md, card=) and writer.revise_field(name, text, card=) are writer.revise(text, field=None, card=None, ...) |
Python | Rename; the text is first, the field second |
reader.get(name, card=) and reader.get_content(name, card=) take field=None, which reads the body |
Python | Nothing to change for a field; read a body with no field |
Document.body is gone |
Python | Call doc.read(), or read doc.main["body"] |
TypedWriter / CardWriter revise_body, revise_body_against, revise_field, revise_field_against and field_hash are revise(target, text, base) and hash_of(target), target a Target or a field name |
Rust | revise(Target::Body, md, None); revise("summary", text, Some(base)) |
TypedReader / CardReader body_markdown is gone; get and get_content take a Target or a field name |
Rust | get(Target::Body) reads the body's markdown, get_content(Target::Body) its Content |
Card and CardMut revise_body, revise_field, revise_body_against, revise_field_against, body_hash, field_hash, body_annotated and field_annotated are gone |
Rust | Card::revise(target, md, opts), hash_of(target) and annotated(target); a typed field revise goes through TypedWriter::revise |
Delta leaves @quillmark/wasm¶
No verb of @quillmark/wasm takes or answers a Delta: a content write speaks Change, and its receipt's applied is what mapPos maps a position through. The type is gone from the runtime's declarations, so code that imported it as the shape of a text change it builds itself declares it there, or takes it from the package that hands it one (@quillmark/collab).
| Break | Surface | Action |
|---|---|---|
The Delta type is gone |
WASM | Declare { ops: ({ retain: number } \| { insert: string } \| { delete: number })[] } where you build one, or import it from @quillmark/collab |
lines::delta::Delta, Op and Assoc no longer derive Serialize / Deserialize, and rebase_onto, rebase_pinned and rebase_marks are carry_onto, carry_pinned and carry_anchors |
Rust | Rename; the line form stays hidden from the documented surface |