Text anchors
A comment is pinned to a sentence, not to character 412. An anchor is that pin. It moves as other people type above it, and it says so when the sentence is gone.
Store a raw offset and the next insertion above it makes you wrong. The field
can track the position for you instead. This works on text fields only.
Create one, resolve it later
const anchor = article.fields.content.anchors.create({
kind: "range",
start: 12,
end: 20,
});
const current = article.fields.content.anchors.resolve(anchor);
if (current.status === "resolved" && current.kind === "range") {
highlight(current.start, current.end);
}create takes a point or a range and hands back a token. resolve takes the
token and hands back where it points now.
| You create | resolve gives back |
|---|---|
{ kind: "point", offset } | { status: "resolved", kind: "point", offset } |
{ kind: "range", start, end } | { status: "resolved", kind: "range", start, end } |
| either, once it cannot place it | { status: "detached" } |
A range must be ordered and non-empty at creation, so start has to be less
than end. Later edits may collapse it onto a single offset. That still
resolves.
Affinity decides which side an insertion lands on
Someone types exactly at your anchor. Affinity says which side of the new text
the anchor ends up on. "following" moves the anchor past it. "preceding"
leaves the anchor in front of it.
| Anchor | Option | Default |
|---|---|---|
| point | affinity | "following" |
| range start | startAffinity | "following" |
| range end | endAffinity | "preceding" |
So a range defaults to holding exactly the characters it started with. Text typed at its start lands outside it. So does text typed at its end. Pass the options when you want the range to grow instead.
Create only from acknowledged state
create needs a settled field. It throws CrdtAnchorError with
UNACKNOWLEDGED_STATE in four cases:
- the field is still syncing
- the document is
synchronizing - the field has a local edit the server has not acknowledged
- the call is inside
transaction()
Bad offsets and bad ranges throw INVALID_INPUT instead. Wait for the edit to
land, then create the anchor from the resulting state.
resolve has no such rule. It works from any readable state, and inside a
transaction it sees that transaction's text.
The token is opaque
It is a branded string starting with qpa1_, at most 2,048 characters. Store
it as a value. Do not parse it, do not build one, and do not treat it as
permission to read anything.
Six things are baked into it. The namespace, the record's incarnation, the field
slot, the field epoch, the engine id and the format version. Resolution compares
all six, and any mismatch returns { status: "detached" }.
A malformed token detaches too. So does one from another field, and one the engine can no longer place.
`replace()` detaches every anchor in the field
A server-side replace raises the field epoch. Its reason is agent,
import, restore or resolve. Recreating the field or the record detaches
them too.
Resolving on the server
Routes and hooks get the same two calls on ctx.crdt. They are asynchronous
there. The server reloads read authority and the authoritative head on every
call.
import { route } from "questpie/services";
import { z } from "zod";
export default route()
.post()
.schema(z.object({ articleId: z.string(), anchor: z.string() }))
.handler(({ crdt, input }) =>
crdt.collections.articles
.document({ id: input.articleId })
.fields.content.anchors.resolve(input.anchor),
);Holding a token grants nothing. The caller still needs read access to the field, checked on that request.
The working result
A comment thread survives a morning of other people's typing. It follows the sentence it was attached to. It reports honestly when that sentence was replaced. The same token resolves the same way in the browser and on the server.
Delivery
What a channel promises about the events it carries. Ordering per resolved channel, replay after a reconnect, an explicit gap when replay cannot reach far enough, and hard caps on payload and buffer size.
Offline and recovery
Edits made with no network are kept in the browser and replayed on reconnect. When replaying them would be wrong, the document says so instead and hands you the bundle.