docs(chord): delta README, growing strings through drafts and appendText

This commit is contained in:
Mario Zechner 2026-10-24 12:17:37 +00:00
parent 1de2d7adea
commit f321e5bdd2

View file

@ -210,9 +210,47 @@ and restores `Op` tuples. Use one encoder/decoder pair per ordered state stream.
Path IDs span batches and an `r` resets both dictionaries. After a decode or
apply error, discard the decoder and replica and recover from a later `r`.
## Growing strings: use `appendText`
The footgun: appending to a string through a draft costs the string's whole length on every change, not the length of
what you append.
```ts
const change = tracker.beginChange();
change.state.message.text += chunk; // looks like O(chunk)
const prepared = change.prepare(); // emits ["a", ["message", "text"], chunk], but costs O(text.length)
```
The draft's proxy only sees the finished new string, never the chunk, so `prepare()` recovers the append by checking
that the new string starts with the old one. That check reads the whole string, and V8 first copies a string built by
`+=` (a rope) into one flat piece. There is no fast path: measured on Node 24 and 26, 20,000 changes appending 40
characters each, up to 800 KB, take about 2.4 s with the check and about 1 ms without it. A stream of N chunks into one
string therefore costs O(N x length) in total: quadratic.
The fix: hand Delta the chunk.
```ts
import { appendText } from "@earendil-works/chord/delta";
appendText(change.state.message, "text", chunk); // records ["a", path, chunk]: O(chunk), no compare, no copy
```
- `appendText(parent, key, text)` takes the parent draft and a key whose type is exactly `string` (not optional, not a
string literal type), so the compiler refuses `appendText(block, "type", …)`.
- Several appends to one key in one change become one op. A plain assignment or deletion of the same key in that change
falls back to the compare, as does an append after a plain write.
- The appended text is copied into a string of its own, so neither the op nor the new value keeps a larger string
alive.
- Use it wherever a string grows by appends across many changes (streamed answers, tool output, logs). A one-off
assignment is fine with `=`.
- Derives cannot use it: `diffRevisions()` compares two revisions it did not write, so a derive producing a growing
string pays O(length) per delivery.
## Limits and footguns
- No freezing. Mutating any immutable value corrupts state silently.
- Appending to a string through a draft (`draft.text += chunk`) costs O(string length) per change; use `appendText`
(section "Growing strings").
- In-process sharing: local handles, cell subscribers, and `replicatedState` subscribers receive the provider's containers. A consumer mutation corrupts authority and makes remote replicas diverge.
- A change inside a large flat array copies that array's pointer storage for the new revision.
- Placement validation costs time and memory on bulk inserts. In a benchmark unshifting 100k three-field objects, it added about 19 ms and 20 MiB of transient heap.