From f321e5bdd26b3624bfb2ab182a334d0a5edb698a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 24 Oct 2026 12:17:37 +0000 Subject: [PATCH] docs(chord): delta README, growing strings through drafts and appendText --- packages/chord/src/delta/README.md | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/chord/src/delta/README.md b/packages/chord/src/delta/README.md index ecc6cefd6..0db4a8103 100644 --- a/packages/chord/src/delta/README.md +++ b/packages/chord/src/delta/README.md @@ -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.