0.101 → 0.102 — the pre-1.0 vocabulary reset¶
Three independent changes this step. The largest is a rename sweep: verbs,
diagnostic codes, and two words that meant different things at different
altitudes. Documents and stored blobs are untouched — no document reparses, no
blob remigrates, and the storage wire format is byte-identical — but nearly
every consumer touches at least one renamed verb, and diagnostic codes are the
part that cannot wait: consumers route on them, so they are settled before 1.0
freezes them. The second is a producer contract for island-id restore. The
third is WASM-only: @quillmark/wasm ships --target web, and await init()
becomes mandatory — in exchange, the bundler plugins it required are gone.
Nothing here changes behavior except two cases, both called out below: a splice
against an absent field, and the receipt reviseBody now returns.
| Break | Surface | Action |
|---|---|---|
install* → overwrite* |
Rust, WASM | Rename; the ladder is now spelled by anchor fate |
writer setBody → reviseBody, now returning Delta |
Rust, WASM, Python | Rename; discard the receipt if unused |
applyFieldRichtextChange → applyFieldChange |
Rust | Rename |
getMarkdown, reader getBody → bodyMarkdown |
Rust, WASM, Python | Rename |
LiveSession.apply → update, and backend::apply_unsupported → backend::update_unsupported |
Rust, WASM | Rename both; re-key any routing on the code |
store/removeSeedNamespace → store/removeSeedOverlay |
Rust, WASM, Python | Rename |
schemaVersionOf / currentSchemaVersion → storageVersionOf / currentStorageVersion |
Rust, WASM, Python | Rename; the wire key stays "schema" |
edit::field_richtext_decode → edit::field_decode, +codec arg |
every surface | Re-key routing; absence no longer raises it |
edit::field_conform → edit::field_coercion_failed |
every surface | Re-key routing |
edit::field_richtext_not_inline → edit::field_not_inline, +codec arg |
every surface | Re-key routing; now covers plaintext(inline) too |
richtext::not_inline → validation::not_inline, plaintext::not_plain → validation::not_plain |
every surface | Re-key routing |
Card::commit_field / Card::revise_field_checked become #[doc(hidden)] |
Rust | Write through quill.writer(&mut doc) |
EditError::variant_name is removed |
Rust | Use EditError::code(), or match the enum |
| Island-id minting splits by case | WASM, Rust (islandOps producers) |
Re-insert the held Island verbatim on restore |
await init() is required before first use |
WASM | Add one line at startup; drop vite-plugin-wasm and vite-plugin-top-level-await |
Verbs that asserted the opposite of what they did¶
install destroyed identity; every other verb in its group did not. The
content lane is a ladder, and the rung a verb sits on is decided by what happens
to the identity anchors already on the value. Grouping the three as
"identity-aware" hid the only distinction that matters, and install named the
one that keeps nothing. It was also a homograph with package installation, a
noun the same WASM runtime uses in its own error strings.
// 0.101
doc.install(addr, importMarkdown(md))
// 0.102 — same call, and the name now says the anchors are gone
doc.overwrite(addr, importMarkdown(md))
| Rung | Anchors | Rust | WASM |
|---|---|---|---|
overwrite |
destroyed | overwrite_body / overwrite_field |
doc.overwrite(addr, rt) |
revise |
rebased | revise_body / revise_field |
doc.revise(addr, md) |
apply |
preserved | apply_body_change / apply_field_change |
doc.applyChange(addr, bundle) |
The writer's setBody was the content lane's reviseBody with the receipt
thrown away. A body carries no field schema, so a typed-lane verb sat on an
operation with nothing to type. It keeps its place on the writer — that is where
a caller already is — but it now spells what it does and hands back the Delta
it was already computing.
// 0.101
writer.setBody(md)
// 0.102 — same write; the receipt was always there, now it is returned
const delta = writer.reviseBody(md) // JS
let _ = writer.revise_body(md)?; // Rust, discarding
w.revise_body(md) # Python, receipt discarded as on revise_field
applyFieldRichtextChange promised a codec it never had. It is schema-blind
and assumes markdown, exactly like its neighbour revise_field, which never
carried richtext in its name. Now applyFieldChange.
The markdown body projection had three names. getMarkdown (WASM),
reader.getBody, and core's Card::body_markdown. One name now, on every
surface: bodyMarkdown / body_markdown. WASM loses its get* read convention
here, which is the deliberate cost of the parity rule that a binding verb be
identical to its core twin.
LiveSession.apply was an op splice and a whole-document recompile at once.
The session verb is update; the content lane keeps applyChange. The
diagnostic code moved with it — a public code must not name an operation the
surface no longer has:
// 0.101
session.apply(doc) // and: err.diagnostics[0].code === 'backend::apply_unsupported'
// 0.102
session.update(doc) // and: 'backend::update_unsupported'
The Rust trait seam (SessionHandle::apply) and the feature-gated raw-plate
seam (LiveSession::apply_data) renamed alongside. An out-of-workspace backend
implementing SessionHandle renames one method.
Two words that meant two things¶
namespace was a free-form consumer key and a card kind. $ext is keyed by
a namespace its consumer owns and the engine never inspects. $seed is keyed by
a validated composable card-kind, and its value is an overlay — which is already
what the read (seedOverlay) and the type (SeedOverlay) called it. Only the
$seed pair renames; $ext's namespace verbs are correct as they stand.
# 0.101
doc.store_seed_namespace("indorsement", {...})
# 0.102
doc.store_seed_overlay("indorsement", {...})
schema was a quill's field declarations and the storage DTO's wire
version. The accessors now say storage:
Document.storageVersionOf(blob) // was schemaVersionOf
Document.currentStorageVersion() // was currentSchemaVersion
Rust renames with them (peek_storage_version, STORAGE_V0_93_0), because the
parity rule that keeps binding verbs identical to their core twins is what makes
the table reviewable.
The wire key stays spelled "schema". It is the serde tag the storage DTO
dispatches on, so retagging it would break the versioning mechanism the tag
exists to serve — and it is not where the confusion originated. Stored blobs are
byte-identical across this step. The two senses part at the API, where a quill's
field declarations are the only schema.
The diagnostic taxonomy¶
This is the part with a deadline. Codes are a public contract consumers route on, so they are corrected now or carried past 1.0.
| 0.101 | 0.102 | Why |
|---|---|---|
edit::field_richtext_decode |
edit::field_decode + codec arg |
Raised for plaintext failures under a richtext name |
edit::field_conform |
edit::field_coercion_failed |
conform::field_conform stuttered, and "conform" named both the repair pass and a failure class |
edit::field_richtext_not_inline |
edit::field_not_inline + codec arg |
plaintext(inline) fell through to the generic code |
richtext::not_inline |
validation::not_inline |
Raised for plaintext violations too |
plaintext::not_plain |
validation::not_plain |
Moved with its sibling; stage-namespaced like every other code |
The conform::* twins rename in lockstep (conform::field_decode,
conform::field_coercion_failed, conform::field_not_inline).
Route on the codec, not on the code. field_decode and field_not_inline
each carry a codec arg ("richtext" or "plaintext"), so one code covers
both lanes and the arg says which ran:
// 0.101 — two codes for one condition, and one of them lied
if (code === 'edit::field_richtext_not_inline') { ... } // plaintext never got here
// 0.102 — one code, both lanes
if (code === 'edit::field_not_inline') {
const codec = err.diagnostics[0].args.codec // 'richtext' | 'plaintext'
}
field_coercion_failed, not field_type_mismatch. The variant is minted
from the same CoercionError as validation::coercion_failed and carries the
same target key; validation::type_mismatch is a stricter, differently-shaped
condition that never attempts coercion. The name points at the twin it has.
EditError::variant_name is removed. It returned the bare variant name
("FieldConform"), a second discriminator that had to be kept in lockstep with
code() and that nothing routed on: both binding error mappers stamp code()
onto the Diagnostic they raise, and ERROR.md has said identity is the code
since the edit::* family landed. Assertions and matches move one of two ways:
// 0.101
assert_eq!(err.variant_name(), "FieldConform");
// 0.102 — the stable contract, and the thing a consumer sees
assert_eq!(err.code(), "edit::field_coercion_failed");
// or, in Rust, match the enum (`#[non_exhaustive]`, so keep a `_` arm)
matches!(err, EditError::FieldCoercionFailed { .. })
Absence stops being a decode error. apply_field_change on an absent field
reported FieldRichtextDecode with the message "field is absent", the one place
in the API where a missing field was not simply None. It now splices against
the empty content, exactly as revise_field diffs against it:
// 0.101: Err(FieldRichtextDecode { message: "field is absent" })
// 0.102: Ok(()) — the field is created, as revise_field would create it
card.apply_field_change("intro", &bundle)?;
This is the one behavior change in the sweep, and it does not lose you an
error. A bundle that expected content still fails, and now reports what it
actually hit: the text delta declares the base length it was computed against,
so a stale splice against a vanished field lands as edit::content_apply rather
than as a decode error standing in for absence. Only a bundle whose base length
is zero succeeds — and a zero-base bundle is one the producer computed against
an empty field, which is what an absent field is. A consumer that was routing on
edit::field_richtext_decode to detect a vanished field should route on
edit::content_apply, or check presence before splicing.
The typed primitives leave the documented surface¶
Card::commit_field and Card::revise_field_checked are #[doc(hidden)]. They
were disambiguated from their opaque and schema-blind neighbours neither by
receiver nor by verb but by taking a schema argument — an undocumented third
mechanism, and the reason one verb appeared to name two lanes. The writer is the
typed door on every other surface; core no longer differs.
// 0.101
card.commit_field("qty", 3, &schema)?;
// 0.102 — the quill resolves the schema, which it always could
quill.writer(&mut doc).set("qty", 3)?;
They remain callable (the in-workspace fuzz harness drives the coercion seam directly), on the same terms as the other hidden items: no stability promise.
Restoring a deleted island re-lands its original id¶
The rest of this guide is the second, unrelated change. It is a producer contract the 0.101 guide stated for one case and left readable as covering two. An editor that built its undo path from that line renames the island on restore, which moves the content hash of a document the user believes they restored.
The minting rule covers new islands only¶
0.101 closed with the never-ambient rule for editors that mint island ids:
continue the positional isl-{n} sequence past the field's highest, never a
UUID or a clock reading. That is correct for a new island and wrong for a
restored one.
Deleting a slot drops the backing Island from the store, so undo re-inserts
it through IslandOp::Insert, which carries a whole island and therefore an id.
Minting a fresh one there is a rename: ids are hash input, so the restored
document does not carry the bytes it had before the delete, and divergence
detection and cache keys see a change the user did not make.
// The delete dropped the payload from the store, so the restore re-inserts the
// copy the producer kept: { id, type, props, loss }.
const held = undoRecord.island
// 0.101 — the mint rule read as covering every insert
doc.applyChange(path, {
islandOps: [{ op: 'insert', at: 6, ...held, id: nextIslandId(field) }],
})
// 0.102 — a restore re-lands the id the drop freed
doc.applyChange(path, {
islandOps: [{ op: 'insert', at: 6, ...held }],
})
A pasted copy of an island that is still live is a new island and mints fresh,
since the original holds the id. Swapping the two cases is IslandIdCollision
on the paste, or the silent rename on the restore.
The two rules compose because minting reads the live ids. Deleting the highest
island frees its number for the next mint, so a later restore of that delete
would collide, but linear undo never reaches the state: a mint made after a
delete is undone before it. A producer with non-linear history (selective undo,
a merge) owns the case, and the apply refuses it rather than aliasing two
islands. DOCUMENT_STORAGE.md § Island-id determinism carries the rule.
Two contracts the island channel now states¶
Neither is a behavior change; both were reachable only by reading the implementation, and a producer that guessed wrong got a wrong document rather than an error.
at is sequenced. An island op's at counts the text the delta and this
bundle's earlier island ops left, not the shared post-delta frame. Each insert
splices its slot before the next op reads the text, so slots after a and b
of abc go in at 1 and 3, and of two inserts at one position the later one
lands first. A bundle carrying a single island op is unaffected, which is why
this surfaces first on a paste of two tables.
A slot-bearing splice splits. A delta insert string may not carry an
island slot, which would orphan. A producer that computes its edit as one diff
over the whole field text carries slots in that string on any paste of an island
or undo of a deletion; the splice splits into the slot-free delta plus one
insert per slot. A block island's line demotes to para when its slot goes,
so re-landing one re-tags the line as a third channel.
@quillmark/wasm requires await init()¶
This is the one break in the step that bites code which never called the
renamed thing. init existed before as a no-op — the panic hook installed
itself at instantiation, so calling it changed nothing and most consumers never
did. It now performs instantiation, and nothing works until it resolves.
import { init, Quill, Document, Engine } from '@quillmark/wasm';
await init(); // ← add this
const quill = Quill.fromTree(tree); // unchanged, and still synchronous
Once, anywhere before first use. Extra calls are free — init memoizes its
promise, so several entry points may each await it for one instantiation. Only
the core build is yours to initialize; Engine still loads and instantiates a
backend itself, on the first render against it.
Forgetting it is not subtle: reaching the surface early throws a
QuillmarkError coded runtime::not_initialized naming the fix.
What you get back¶
Delete these from your bundler config:
import wasm from 'vite-plugin-wasm'; // ← no longer needed
import topLevelAwait from 'vite-plugin-top-level-await'; // ← no longer needed
And delete the discipline that came with them. The package was built
--target bundler, which emits import * as wasm from "./wasm_bg.wasm" — a
form no browser and no bundler resolves natively. The plugin that fixed it
rewrote the import into a top-level await, and because the runtime statically
re-exports the core build, that await landed on the static module graph of
everything importing @quillmark/wasm. Consumers had to keep the package off
every route's static graph, transitively and forever, or a SvelteKit route would
render blank in Safari's dev server while Chrome and vite build stayed green.
--target web emits neither form. A static import of @quillmark/wasm is now
safe anywhere, SSR included, and scripts/build-wasm.sh asserts the built
artifacts carry no .wasm import and no top-level await, so the constraint
cannot come back unnoticed.
One line stays, for Vite's dev server only: dependency pre-bundling relocates the package away from its binary.
A load failure surfaces as runtime::init_failed, whose hint names that line.
Node¶
@quillmark/wasm now imports under plain Node, with no bundler and no flags —
the ESM .wasm import needed --experimental-wasm-modules, so this never
worked before. The call is the same argless await init(); the package resolves
the byte source per environment through a subpath import, streaming from a URL
in a browser and reading off disk under Node.
initSync is not exported¶
An embedded-bytes host that wants a fully synchronous startup loses it. The
capability remains through init(source), which takes bytes, a Response, a
WebAssembly.Module, or a URL — asynchronously. This is deliberate: a second
way to instantiate is the ambiguity the explicit gate exists to remove. Pass the
source on the first call; a later call with a different source throws
runtime::init_conflict rather than silently ignoring it, and passing the same
value again is fine, so several entry points may each await init(BYTES)
against one constant.