text_document/streaming.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Append-only streaming: the log/console path.
5//!
6//! A view tailing output does two things in a loop — append a line at the end,
7//! and drop the oldest once it is over its scrollback cap. Both are expressible
8//! with the ordinary editing API, and both are O(N) that way: appending one line
9//! to a 10 000-line document costs ~15.9 ms, and evicting 20 lines ~55 ms
10//! (`docs/streaming-baseline.md`). Neither cost is inherent — it comes from
11//! general-purpose machinery that a tail-append does not need:
12//!
13//! * `character_count()` / `cursor_at()` each run `get_document_stats`, which
14//! materializes every block's text to compute a word count the caller never
15//! asked for — just to locate the end of the document.
16//! * `insert_block` fetches *every* block entity (via
17//! `collect_block_ids_recursive` and `get_block_multi`) before it can find
18//! the insertion point. `insert_text` has an O(log n) rope fast path;
19//! `insert_block` never received one.
20//! * `delete_text` walks the whole `child_order` refreshing every block's stored
21//! position, with a rope lookup per block, regardless of how little was
22//! deleted.
23//!
24//! None of that is needed when the insertion point is, by construction, the end:
25//! the rope already knows where the end is, nothing after it needs shifting, and
26//! no block above it is touched. This module takes that shortcut directly, via
27//! the `rope_helpers` primitives (`rope_append_block` is a rope insert at the
28//! tail plus an O(1) amortized `push_block` — nothing shifts, because appending
29//! at the end shifts nothing).
30//!
31//! # Not undoable — and not an oversight
32//!
33//! **Nothing in this module participates in undo/redo.** An appended or evicted
34//! line cannot be brought back with [`TextDocument::undo`], does not appear on
35//! the undo stack, and does not affect [`TextDocument::can_undo`].
36//!
37//! That is deliberate on both counts. A million streamed lines must not become a
38//! million undo entries — the stack is unbounded, so that is a leak, not a
39//! feature. And the alternative escape hatch the rest of the crate uses for
40//! non-undoable work (perform, then `clear_stack`, as `set_plain_text` does)
41//! would throw away the *user's* history every time a line arrived from a
42//! background thread.
43//!
44//! So the entity writes are routed through a private stack of their own, cleared
45//! as they go. Note that passing `None` as the stack id would not have achieved
46//! this: `add_command_to_stack` resolves `None` to stack 0 rather than skipping,
47//! so it is not an opt-out.
48//!
49//! Consequences worth being deliberate about:
50//!
51//! * **The document's own undo stack is untouched.** A user's edits stay
52//! undoable across any amount of streaming, and undo will never surface or
53//! swallow a streamed line. This is covered by a test.
54//! * **Streaming is not reversible.** Content that arrived from elsewhere is not
55//! the user's to undo; if a caller needs to drop it, that is
56//! [`truncate_front`](TextDocument::truncate_front), not undo.
57//! * **`modified` is still set**, and change events are still emitted, so views
58//! and dirty-tracking behave normally.
59//!
60//! Mixing streaming with ordinary editing on one document works and is tested
61//! (the rope and the block index stay coherent), but it is not the intended
62//! shape: these are for buffers whose content arrives from elsewhere.
63//!
64//! # All-or-nothing
65//!
66//! Every entry point here is atomic: it either lands completely or leaves the
67//! document exactly as it was. That needs saying because it does not come for
68//! free. These paths hand-maintain four things that must agree — the rope, the
69//! block offset index, the frame's `child_order`, and the document's cached
70//! counts — across several fallible calls, and the unit-of-work layer that
71//! normally guarantees that is precisely what they bypass for speed. Without
72//! care, any early `?` would leave text in the rope that no block owns, or a
73//! block the frame never references, or counts describing a document that no
74//! longer exists — none of which surfaces as an error, only as a document that
75//! quietly disagrees with itself.
76//!
77//! So each operation takes a savepoint first and rolls back on any failure,
78//! including a panic. A savepoint is a whole-store snapshot, but a cheap one:
79//! the rope is persistent and the entity tables are `im::HashMap`s, so it is
80//! pointer copies, not a deep clone.
81//!
82//! Note that a rolled-back append still consumes the entity ids it allocated —
83//! the store's counters are deliberately not rewound — so ids may show gaps.
84//! Nothing depends on them being contiguous.
85//!
86//! # This is still O(N) — know the ceiling before using it
87//!
88//! Measured, appending one line (`docs/streaming-baseline.md`):
89//!
90//! | lines in document | ordinary editing path | this |
91//! |---|---|---|
92//! | 1 000 | 986 µs | 174 µs |
93//! | 10 000 | 28.1 ms | 2.90 ms |
94//!
95//! Roughly 10× cheaper — but 174 µs → 2.90 ms is 16.7× for 10× the document, so
96//! **this is still O(N), not O(1)**. It moves the ceiling; it does not remove it.
97//! Around 10 000 lines an append costs ~2.9 ms (~345 lines/second), which suits
98//! an application's own event log. It does not suit `tail -f` of a large file:
99//! at 100 000 lines the cost extrapolates to ~29 ms per line. Cap the buffer
100//! accordingly, and evict with [`truncate_front`](TextDocument::truncate_front).
101//!
102//! `truncate_front` is barely better than the ordinary path (1.19× at 10 000)
103//! and is *slower* below a few thousand lines, because the ordinary path makes a
104//! single bulk `delete_text` call where this makes one entity removal per evicted
105//! block. It exists for the coherence of the streaming API — appending without
106//! being able to evict is useless — not because it is fast.
107//!
108//! The rope is not the reason. `rope_append_block` is O(log n) as intended; the
109//! residual is the generic entity-update path. `UndoableUpdateUseCase::
110//! execute_multi` fetches the old entity for undo and stashes it on every write,
111//! so each `update_frame` clones the `Frame` — its whole `child_order: Vec<i64>`
112//! included — several times over. Every appended line therefore touches the
113//! entire `child_order`, whatever the rope does.
114//!
115//! Reaching O(1) means `child_order` no longer being an N-element `Vec` cloned
116//! per write. That reshapes a core entity the whole engine and its property tests
117//! depend on, and is deliberately out of scope here.
118
119use std::sync::Arc;
120
121use frontend::block::dtos::CreateBlockDto;
122use frontend::commands::{block_commands, document_commands, frame_commands, undo_redo_commands};
123use frontend::common::types::EntityId;
124use frontend::document::dtos::UpdateDocumentDto;
125use frontend::frame::dtos::UpdateFrameDto;
126
127use crate::document::get_main_frame_id;
128use crate::events::DocumentEvent;
129use crate::inner::TextDocumentInner;
130use crate::{DocumentError, Result, TextDocument};
131
132impl TextDocument {
133 /// Append `text` as a new block at the end, returning the document's new
134 /// block count.
135 ///
136 /// The append half of a streaming buffer. Costs the rope insert and one
137 /// entity write, whatever the buffer already holds — against ~15.9 ms per
138 /// line at 10 000 lines through the ordinary editing path
139 /// (`docs/streaming-baseline.md`).
140 ///
141 /// The returned count is what a scrollback cap is checked against, so a
142 /// caller never needs a separate count call — which matters, because
143 /// [`block_count`](Self::block_count) walks the whole document:
144 ///
145 /// ```ignore
146 /// let count = doc.append_line(line)?;
147 /// if count > CAP {
148 /// doc.truncate_front(count - CAP)?;
149 /// }
150 /// ```
151 ///
152 /// `text` is taken as a single line: it must not contain `\n`, since a block
153 /// is one line by construction here (embedded newlines would desynchronize
154 /// the rope from the block index). Returns [`DocumentError::InvalidArgument`]
155 /// if it does.
156 ///
157 /// **Not undoable**, by design — see the module docs.
158 pub fn append_line(&self, text: &str) -> Result<usize> {
159 if text.contains('\n') {
160 return Err(DocumentError::InvalidArgument(
161 "append_line takes a single line; text must not contain '\\n'".into(),
162 ));
163 }
164
165 let mut inner = self.inner.lock();
166 let frame_id = get_main_frame_id(&inner);
167 if frame_id == 0 {
168 return Err(DocumentError::InvalidArgument(
169 "document has no main frame".into(),
170 ));
171 }
172
173 // Everything below either all lands or none of it does.
174 let atomic = Atomic::begin(&inner);
175 let appended = append_one(&mut inner, frame_id, text)?;
176 let new_count = commit_counts(&mut inner, 1, text.chars().count() as i64)?;
177 atomic.commit();
178
179 finish(&mut inner, appended.edit_pos, appended.chars_added, 1);
180
181 inner.queue_event(DocumentEvent::BlockCountChanged(new_count));
182 // A pure tail append: the new element lands at the end of the flow, so
183 // the index is the previous count. Emitted directly rather than through
184 // the generic `check_flow_changed`, which diffs the whole `child_order`
185 // on every edit — O(N) again, to rediscover what is known here.
186 inner.queue_event(DocumentEvent::FlowElementsInserted {
187 flow_index: new_count - 1,
188 count: 1,
189 });
190
191 // Notify `on_change` subscribers, like every other mutation — the poll
192 // path alone would leave callback-based observers (a reactive view) blind
193 // to appends. Dispatch outside the lock: a callback may re-enter.
194 let queued = inner.take_queued_events();
195 drop(inner);
196 crate::inner::dispatch_queued_events(queued);
197 Ok(new_count)
198 }
199
200 /// Append several lines in one go, returning the document's new block count.
201 ///
202 /// Equivalent to [`append_line`](Self::append_line) per line, but pays the
203 /// document-count write and the throwaway-stack clear once for the batch
204 /// rather than per line. A view draining a channel once per frame should
205 /// prefer this.
206 ///
207 /// No line may contain `\n`.
208 ///
209 /// **Not undoable**, by design — see the module docs.
210 pub fn append_lines<I, S>(&self, lines: I) -> Result<usize>
211 where
212 I: IntoIterator<Item = S>,
213 S: AsRef<str>,
214 {
215 let lines: Vec<String> = lines.into_iter().map(|s| s.as_ref().to_owned()).collect();
216 if lines.iter().any(|l| l.contains('\n')) {
217 return Err(DocumentError::InvalidArgument(
218 "append_lines takes single lines; none may contain '\\n'".into(),
219 ));
220 }
221 if lines.is_empty() {
222 let inner = self.inner.lock();
223 return Ok(current_block_count(&inner)? as usize);
224 }
225
226 let mut inner = self.inner.lock();
227 let frame_id = get_main_frame_id(&inner);
228 if frame_id == 0 {
229 return Err(DocumentError::InvalidArgument(
230 "document has no main frame".into(),
231 ));
232 }
233
234 // The batch is one unit: a line failing halfway would otherwise leave
235 // its predecessors appended with the document's counts never updated,
236 // since those are written once at the end.
237 let atomic = Atomic::begin(&inner);
238 let mut edit_pos = None;
239 let mut chars_added = 0usize;
240 for line in &lines {
241 let appended = append_one(&mut inner, frame_id, line)?;
242 // The batch's edit starts where its first line did.
243 edit_pos.get_or_insert(appended.edit_pos);
244 chars_added += appended.chars_added;
245 }
246 let edit_pos = edit_pos.unwrap_or(0);
247
248 let chars: i64 = lines.iter().map(|l| l.chars().count() as i64).sum();
249 let new_count = commit_counts(&mut inner, lines.len() as i64, chars)?;
250 atomic.commit();
251
252 finish(&mut inner, edit_pos, chars_added, lines.len());
253
254 inner.queue_event(DocumentEvent::BlockCountChanged(new_count));
255 inner.queue_event(DocumentEvent::FlowElementsInserted {
256 flow_index: new_count - lines.len(),
257 count: lines.len(),
258 });
259
260 // Dispatch to `on_change` subscribers (outside the lock — a callback may
261 // re-enter), the same as an ordinary edit.
262 let queued = inner.take_queued_events();
263 drop(inner);
264 crate::inner::dispatch_queued_events(queued);
265 Ok(new_count)
266 }
267
268 /// Drop the first `n` blocks, returning how many were actually removed.
269 ///
270 /// The eviction half of a streaming buffer. Returns less than `n` when the
271 /// document holds fewer blocks; a document is never emptied completely —
272 /// one block always remains, since an empty document is not a valid state
273 /// here (it is created with one block, and the rest of the API assumes at
274 /// least one exists).
275 ///
276 /// **Not undoable**, by design — see the module docs.
277 pub fn truncate_front(&self, n: usize) -> Result<usize> {
278 if n == 0 {
279 return Ok(0);
280 }
281
282 let mut inner = self.inner.lock();
283 let frame_id = get_main_frame_id(&inner);
284 if frame_id == 0 {
285 return Err(DocumentError::InvalidArgument(
286 "document has no main frame".into(),
287 ));
288 }
289
290 let stack = streaming_stack(&mut inner);
291
292 // Which blocks to drop, capped so that at least one always survives.
293 let (victims, chars_removed) = {
294 let frame = frame_commands::get_frame(&inner.ctx, &frame_id)?
295 .ok_or_else(|| DocumentError::InvalidArgument("main frame missing".into()))?;
296 let block_ids: Vec<EntityId> = frame
297 .child_order
298 .iter()
299 .filter(|e| **e > 0)
300 .map(|e| *e as EntityId)
301 .collect();
302 let take = n.min(block_ids.len().saturating_sub(1));
303 let victims: Vec<EntityId> = block_ids.into_iter().take(take).collect();
304
305 // Every victim's length must be counted, so a lookup that fails is
306 // an error rather than a silent zero: swallowing it would subtract
307 // too little from `character_count`, and since all later deltas are
308 // relative, the document's count would stay wrong forever with
309 // nothing reporting it.
310 let mut chars: i64 = 0;
311 for id in &victims {
312 let block = block_commands::get_block(&inner.ctx, id)?.ok_or_else(|| {
313 DocumentError::InvalidArgument(format!(
314 "block {id} is referenced by the frame but does not exist"
315 ))
316 })?;
317 let entity: common::entities::Block = block.into();
318 let store = inner.ctx.db_context.get_store();
319 chars += common::database::rope_helpers::block_char_length(&entity, store);
320 }
321 (victims, chars)
322 };
323
324 if victims.is_empty() {
325 return Ok(0);
326 }
327
328 // Everything below either all lands or none of it does. Without this,
329 // a failure partway through the entity loop would leave the rope
330 // already stripped of victims whose blocks still exist and are still
331 // referenced by the frame.
332 let atomic = Atomic::begin(&inner);
333
334 // Unmirror from the rope first: `rope_remove_block` resolves each block
335 // through the offset index, which the entity must still exist for.
336 {
337 let store = inner.ctx.db_context.get_store();
338 for id in &victims {
339 common::database::rope_helpers::rope_remove_block(store, *id);
340 }
341 }
342
343 // Drop the entities, then the frame's references to them in one write.
344 for id in &victims {
345 block_commands::remove_block(&inner.ctx, Some(stack), id)?;
346 }
347 {
348 let frame = frame_commands::get_frame(&inner.ctx, &frame_id)?
349 .ok_or_else(|| DocumentError::InvalidArgument("main frame missing".into()))?;
350 let mut update: UpdateFrameDto = frame.into();
351 // Drop exactly the evicted ids, rather than the first N entries:
352 // `remove_block` may already have pruned them, and `child_order`
353 // can hold negative entries (sub-frames) that are not blocks, so
354 // positional removal would take out survivors.
355 let evicted: std::collections::HashSet<i64> =
356 victims.iter().map(|id| *id as i64).collect();
357 let before = update.child_order.len();
358 update.child_order.retain(|e| !evicted.contains(e));
359 if update.child_order.len() != before {
360 frame_commands::update_frame(&inner.ctx, Some(stack), &update)?;
361 }
362 }
363
364 let removed = victims.len();
365 let new_count = commit_counts(&mut inner, -(removed as i64), -chars_removed)?;
366 atomic.commit();
367
368 undo_redo_commands::clear_stack(&inner.ctx, stack);
369
370 // Everything shifts down by what was cut off the front.
371 inner.invalidate_text_cache();
372 inner.adjust_cursors(0, chars_removed as usize + removed, 0);
373 inner.modified = true;
374 inner.queue_event(DocumentEvent::ContentsChanged {
375 position: 0,
376 chars_removed: chars_removed as usize + removed,
377 chars_added: 0,
378 blocks_affected: removed,
379 });
380 inner.queue_event(DocumentEvent::BlockCountChanged(new_count));
381 inner.queue_event(DocumentEvent::FlowElementsRemoved {
382 flow_index: 0,
383 count: removed,
384 });
385
386 // Dispatch to `on_change` subscribers (outside the lock — a callback may
387 // re-enter), the same as an ordinary edit.
388 let queued = inner.take_queued_events();
389 drop(inner);
390 crate::inner::dispatch_queued_events(queued);
391 Ok(removed)
392 }
393}
394
395/// The document's block count, read straight off the entity.
396///
397/// [`TextDocument::block_count`] answers the same question via
398/// `get_document_stats`, which also computes a word count by materializing every
399/// block's text — 3.18 ms at 10 000 lines. This is the cached value the entity
400/// already carries, which is what `inner.rs`'s own `check_block_count_changed`
401/// reads for exactly the same reason.
402fn current_block_count(inner: &TextDocumentInner) -> Result<i64> {
403 let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
404 .ok_or_else(|| DocumentError::InvalidArgument("document missing".into()))?;
405 Ok(doc.block_count)
406}
407
408/// A private undo stack for streaming writes, created on first use.
409///
410/// The entity commands always push an undo command — passing `None` does not
411/// opt out, it resolves to stack 0 (`add_command_to_stack`). Routing these
412/// writes to a stack of their own, cleared as they go, keeps them off the
413/// document's real history without bounding-problems or clearing what the user
414/// did.
415fn streaming_stack(inner: &mut TextDocumentInner) -> u64 {
416 if let Some(id) = inner.streaming_stack_id {
417 return id;
418 }
419 let id = undo_redo_commands::create_new_stack(&inner.ctx);
420 inner.streaming_stack_id = Some(id);
421 id
422}
423
424/// All-or-nothing around a streaming mutation.
425///
426/// These paths hand-maintain four things that must agree — the rope, the block
427/// offset index, the frame's `child_order`, and the document's cached counts —
428/// across several fallible calls. The ordinary editing path gets that
429/// consistency from the unit-of-work layer; taking the shortcut past it takes
430/// the shortcut past its atomicity too, so every early `?` would otherwise be a
431/// chance to leave the four disagreeing: text in the rope that no block owns, a
432/// block absent from `child_order`, counts describing a document that no longer
433/// exists.
434///
435/// A savepoint is a whole-store snapshot, but a cheap one — the rope is a
436/// persistent structure and the entity tables are `im::HashMap`s, so it is
437/// pointer-copies rather than a deep clone. Restoring it undoes everything
438/// since, the inner commands' own writes included, because each savepoint is an
439/// independent snapshot rather than a stack position.
440///
441/// Deliberately not `Transaction`, which is the same savepoint plus an
442/// O(N) `recompute_all_frame_byte_ranges` on commit — a cost this path exists
443/// to avoid, and one the inner commands' own transactions already pay.
444///
445/// Rolls back on drop unless [`commit`](Self::commit) ran, so an early `?` — or
446/// a panic — cannot leave the document half-written.
447struct Atomic {
448 /// Owned rather than borrowed: holding a reference would borrow the
449 /// document's interior for the guard's whole lifetime, which is exactly the
450 /// span in which the work needs `&mut` access to it.
451 store: Arc<common::database::Store>,
452 savepoint: Option<u64>,
453}
454
455impl Atomic {
456 fn begin(inner: &TextDocumentInner) -> Self {
457 let store = Arc::clone(inner.ctx.db_context.get_store());
458 let savepoint = Some(store.create_savepoint());
459 Self { store, savepoint }
460 }
461
462 /// Keep the mutations and drop the snapshot.
463 fn commit(mut self) {
464 if let Some(sp) = self.savepoint.take() {
465 self.store.discard_savepoint(sp);
466 }
467 }
468}
469
470impl Drop for Atomic {
471 fn drop(&mut self) {
472 // Still holding the savepoint means `commit` never ran: the operation
473 // left early, so put the document back exactly as it was.
474 if let Some(sp) = self.savepoint.take() {
475 self.store.restore_savepoint(sp);
476 self.store.discard_savepoint(sp);
477 }
478 }
479}
480
481/// What one appended line did to the document, as the caller's bookkeeping
482/// needs to describe it.
483struct Appended {
484 /// Document position the insertion began at.
485 edit_pos: usize,
486 /// Characters inserted there — the text, plus the separator when one was
487 /// needed. Not derivable from the text alone, which is the whole reason
488 /// this is reported rather than recomputed.
489 chars_added: usize,
490}
491
492/// Create one block, mirror it into the rope at the tail, and reference it from
493/// the frame.
494fn append_one(inner: &mut TextDocumentInner, frame_id: EntityId, text: &str) -> Result<Appended> {
495 let stack = streaming_stack(inner);
496
497 // Read the frame *before* creating the block: the generic create path adds
498 // the block to the junction table but not to `child_order`, so this sees
499 // exactly the blocks that already existed.
500 let frame = frame_commands::get_frame(&inner.ctx, &frame_id)?
501 .ok_or_else(|| DocumentError::InvalidArgument("main frame missing".into()))?;
502
503 // Blocks after the first in a frame are separated by a `\n` sentinel, and
504 // `rope_append_block` does not add one itself.
505 //
506 // The condition is "does the frame already hold a block", NOT "is the rope
507 // non-empty". A freshly created document already holds one *empty* block,
508 // registered at byte 0, with an empty rope — so keying off the rope would
509 // skip the separator and register the new block at byte 0 as well, leaving
510 // two blocks at the same offset with no `\n` between them. That is not a
511 // hypothetical: `TextDocument::new()` followed by `append_line` is the most
512 // direct way to use this API.
513 let needs_boundary = frame.child_order.iter().any(|entry| *entry > 0);
514
515 let block = block_commands::create_block(
516 &inner.ctx,
517 Some(stack),
518 &CreateBlockDto::default(),
519 frame_id,
520 -1,
521 )?;
522
523 let appended = {
524 let store = inner.ctx.db_context.get_store();
525 // Where the insertion begins — captured before it, so it covers the
526 // separator too when one is written.
527 let edit_pos = store.rope.read().len_chars();
528 if needs_boundary {
529 common::database::rope_helpers::rope_insert_block_boundary(store);
530 }
531 common::database::rope_helpers::rope_append_block(store, block.id, text);
532 Appended {
533 edit_pos,
534 chars_added: text.chars().count() + usize::from(needs_boundary),
535 }
536 };
537
538 // The generic create path adds the block to the junction table but not to
539 // `child_order`, which is what `flow()` reads — `initialize` notes the same.
540 let mut update: UpdateFrameDto = frame.into();
541 update.child_order.push(block.id as i64);
542 frame_commands::update_frame(&inner.ctx, Some(stack), &update)?;
543
544 Ok(appended)
545}
546
547/// Apply deltas to the document's cached counts, returning the new block count.
548fn commit_counts(
549 inner: &mut TextDocumentInner,
550 block_delta: i64,
551 char_delta: i64,
552) -> Result<usize> {
553 let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
554 .ok_or_else(|| DocumentError::InvalidArgument("document missing".into()))?;
555 let stack = streaming_stack(inner);
556 let mut update: UpdateDocumentDto = doc.into();
557 update.block_count = (update.block_count + block_delta).max(0);
558 update.character_count = (update.character_count + char_delta).max(0);
559 let new_count = update.block_count as usize;
560 document_commands::update_document(&inner.ctx, Some(stack), &update)?;
561 Ok(new_count)
562}
563
564/// Post-append bookkeeping shared by the append entry points.
565///
566/// `blocks_affected` is passed rather than assumed: a batch adds as many blocks
567/// as it has lines, and consumers size work off that number.
568fn finish(inner: &mut TextDocumentInner, edit_pos: usize, added: usize, blocks_affected: usize) {
569 let stack = streaming_stack(inner);
570 // Discard the throwaway history: these writes are not undoable, and letting
571 // it grow would leak a command per appended line.
572 undo_redo_commands::clear_stack(&inner.ctx, stack);
573
574 // The document's text just changed, so the lazily-built plain-text cache no
575 // longer describes it. Every ordinary edit clears this; bypassing the
576 // editing path means bypassing that too, and a stale cache is silent —
577 // `blocks()` reads the live document while `to_plain_text()` keeps serving
578 // whatever the text was when it was last asked.
579 inner.invalidate_text_cache();
580 inner.adjust_cursors(edit_pos, 0, added);
581 inner.modified = true;
582 inner.queue_event(DocumentEvent::ContentsChanged {
583 position: edit_pos,
584 chars_removed: 0,
585 chars_added: added,
586 blocks_affected,
587 });
588}