cloudillo_search/crdt.rs
1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Reading a Yjs/CRDT document as plain JSON, so the same index manifest works
5//! for CRDT apps (prezillo, ideallo, quillo, calcillo) as for RTDB ones.
6//!
7//! # Why this lives here and not in the CRDT adapter
8//!
9//! [`cloudillo_types::crdt_adapter::CrdtAdapter`] stores opaque binary updates
10//! and must stay that way — teaching a storage adapter to parse content would
11//! put document semantics in the persistence layer. This module reads the same
12//! updates back through the adapter's public API and materialises them, which
13//! keeps the knowledge of *what a document means* in the search crate where the
14//! rest of it already lives.
15//!
16//! # The collection model
17//!
18//! An RTDB document is a set of collections, each holding documents keyed by id
19//! — which is exactly what a manifest's `parts[].kind` names. A Yjs document has
20//! named **root types** instead, so this module maps them onto the same shape:
21//!
22//! - a root **map** is a collection; its keys are document ids
23//! - a root **sequence** is a collection, if its entries are structured; its
24//! indices are document ids
25//! - a root **`Y.Text`** is a collection holding exactly one document, keyed
26//! `_`, carrying the whole text stream
27//! - anything else — a list of loose scalars — is **skipped**
28//!
29//! The text case is whole-document granularity on purpose. Prose has no
30//! per-entry identity to point a hit at, so there is no interior anchor to
31//! offer; collapsing the stream into one entry makes the document findable by
32//! its text while keeping the id stable under every edit. Skipping the last
33//! case is likewise deliberate rather than a gap: a bare number or bool list is
34//! neither prose nor addressable, and the file's own `'F'` row already makes
35//! such a document findable by name and tags.
36//!
37//! # Undeclared root types
38//!
39//! A document replayed purely from its update log has root types the local
40//! store has never seen declared, so `root_refs()` reports them as
41//! [`Out::UndefinedRef`] rather than as a map or an array. That is the normal
42//! case here — nothing in this crate ever calls `get_or_insert_map` — so the
43//! shape is recovered from the branch's contents instead: a branch with keys is
44//! a map, a branch with a sequence is a sequence. `Y.Text` is indistinguishable
45//! from an array at that level, so it is separated by reading the branch as text
46//! first: only `Y.Text` stores its content as string items, so a genuine array
47//! reads back as empty text and falls through to the sequence rules.
48
49use cloudillo_types::crdt_adapter::CrdtUpdate;
50use serde_json::Value;
51use yrs::{
52 Any, ArrayRef, Doc, GetString, Map, MapRef, OffsetKind, Options, Out, ReadTxn, TextRef,
53 Transact, Update, branch::BranchPtr, types::ToJson, updates::decoder::Decode,
54};
55
56use crate::prelude::*;
57
58/// Materialise a CRDT document as `(path, value)` pairs in the same
59/// `"{collection}/{doc_id}"` shape [`cloudillo_types::rtdb_adapter::RtdbAdapter::export_all`]
60/// returns, so the indexer treats both stores identically.
61pub async fn export_all(app: &App, tn_id: TnId, doc_id: &str) -> ClResult<Vec<(Box<str>, Value)>> {
62 let updates = app.crdt_adapter.get_updates(tn_id, doc_id).await?;
63 if updates.is_empty() {
64 return Ok(Vec::new());
65 }
66
67 // Decoding and replaying an update log is CPU-bound and unbounded in size, so
68 // it goes to the worker pool — and to the *low-priority* queue, because unlike
69 // a live connection's document load nobody is waiting on an index run.
70 //
71 // The read above is off the runtime too (`get_updates` scans redb on the
72 // blocking pool), so neither half occupies a tokio worker.
73 let owned_id = doc_id.to_owned();
74 app.worker
75 .run_slow(move || materialize(&updates, &owned_id))
76 .await
77 .map_err(|e| Error::Internal(format!("Worker pool failed reading CRDT doc: {e}")))
78}
79
80/// Replay `updates` into a fresh document and flatten its roots.
81///
82/// A corrupt update is logged and skipped rather than failing the run: a
83/// partially replayed document still indexes usefully, and refusing to index
84/// would leave the search results stale forever with no way to recover.
85fn materialize(updates: &[CrdtUpdate], doc_id: &str) -> Vec<(Box<str>, Value)> {
86 // Yjs encodes item lengths in UTF-16 units, and `block_len` is summed straight
87 // off the wire from those. yrs defaults to `OffsetKind::Bytes`, so the
88 // document's own two length accountings disagree for any non-ASCII content.
89 // Matching the producer keeps them consistent.
90 let doc = Doc::with_options(Options { offset_kind: OffsetKind::Utf16, ..Default::default() });
91 {
92 let mut txn = doc.transact_mut();
93 for (idx, stored) in updates.iter().enumerate() {
94 match Update::decode_v1(&stored.data) {
95 Ok(update) => {
96 if let Err(e) = txn.apply_update(update) {
97 warn!(doc_id, idx, error = %e, "CRDT update failed to apply while indexing");
98 }
99 }
100 Err(e) => {
101 warn!(doc_id, idx, error = %e, "CRDT update failed to decode while indexing");
102 }
103 }
104 }
105 }
106
107 let txn = doc.transact();
108 let mut out = Vec::new();
109 // Sorted by root name: `root_refs` walks yrs' own `HashMap`, so its order is
110 // randomised per process. See [`collect_root`] for what that costs.
111 let mut roots: Vec<(&str, Out)> = txn.root_refs().collect();
112 roots.sort_unstable_by(|a, b| a.0.cmp(b.0));
113 for (root, value) in roots {
114 // Anything else — XML, a subdocument — has no addressable entries for a
115 // search hit to deep-link to. See the module docs. A replayed `Y.Text`
116 // arrives as `UndefinedRef`; `YText` is listed for the case where it
117 // does not.
118 if matches!(value, Out::YMap(_) | Out::YArray(_) | Out::YText(_) | Out::UndefinedRef(_)) {
119 collect_root(&txn, doc_id, root, &value, &mut out);
120 }
121 }
122 out
123}
124
125/// Document id a text root's single entry gets, in `"{root}/{TEXT_ENTRY}"`.
126const TEXT_ENTRY: &str = "_";
127
128/// How much of the first line is kept as a heading.
129const TEXT_HEADING_MAX_CHARS: usize = 120;
130
131/// The first non-empty line of `text`, as a stand-in title.
132///
133/// A text root carries no title field — quillo keeps none anywhere in its
134/// document — and a hit with no title renders as "Untitled", so the opening
135/// line is the only heading available.
136fn first_line(text: &str) -> String {
137 let line = text.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or_default();
138 line.chars().take(TEXT_HEADING_MAX_CHARS).collect()
139}
140
141/// Flatten one root into `(path, value)` entries.
142///
143/// `doc_id` is carried only for diagnostics — it names the document in the
144/// nesting-limit warning, which is otherwise impossible to attribute.
145fn collect_root<T: ReadTxn>(
146 txn: &T,
147 doc_id: &str,
148 root: &str,
149 value: &Out,
150 out: &mut Vec<(Box<str>, Value)>,
151) {
152 let Some(ptr) = value.try_branch().map(BranchPtr::from) else { return };
153 // Reported once per root rather than per node: a document that trips the
154 // limit trips it in every sibling, and the warning is about the document.
155 let mut truncated = false;
156
157 // Keys win over the sequence: the two are mutually exclusive in practice,
158 // and a keyed entry carries an id worth deep-linking to.
159 let map = MapRef::from(ptr);
160 if map.len(txn) > 0 {
161 // Sorted by key: `MapRef::iter` walks yrs' own `HashMap`. Each entry is its
162 // own part, so order never changes a part's text — but it decides which parts
163 // `indexer::build_parts` keeps once a document reaches `max_parts` or
164 // `max_total_chars`, and that subset must not differ between reindexes. yrs
165 // kept no source order to restore, so key order is the only one available;
166 // the array branch below is positional and stays so.
167 let mut entries: Vec<(&str, Out)> = map.iter(txn).collect();
168 entries.sort_unstable_by(|a, b| a.0.cmp(b.0));
169 for (key, entry) in entries {
170 let json = any_to_json(&entry.to_json(txn), MAX_ANY_DEPTH, &mut truncated);
171 out.push((format!("{root}/{key}").into(), json));
172 }
173 if truncated {
174 warn!(doc_id, root, MAX_ANY_DEPTH, "CRDT root nested past the indexing depth limit");
175 }
176 return;
177 }
178
179 // Text before the sequence, and never via `ArrayRef`. `ItemContent::String`
180 // reports its length in three different units depending on who asks — chars
181 // from `read()`, bytes from `content_len()` under the default
182 // `OffsetKind::Bytes`, UTF-16 units in the `block_len` that sizes the buffer —
183 // and `BlockIter::slice` compares two of them to decide whether to advance. On
184 // a multi-chunk non-ASCII text they never agree, so `ArrayRef::to_json`
185 // re-reads the same item forever. `TextRef::get_string` walks the item list
186 // directly and touches none of that arithmetic. It is also an exact
187 // discriminator: only `Y.Text`/`Y.XmlText` produce `ItemContent::String`, so a
188 // genuine array — even one of strings — yields `""` here and falls through.
189 //
190 // Guarded on `!text.is_empty()`, **not** `!text.trim().is_empty()`: the hazard
191 // is "this branch stores string items", not "printable string items". A
192 // `Y.Text` of only non-ASCII whitespace (U+00A0, U+3000 — ordinary in CJK
193 // prose) split across two items would otherwise fall through into the spin,
194 // and `catch_unwind` catches panics, not hangs, so it would burn a `run_slow`
195 // pool slot permanently. Any peer that can write the document can author one.
196 // Blankness decides only whether an entry is *emitted*.
197 let text = TextRef::from(ptr).get_string(txn);
198 if !text.is_empty() {
199 if !text.trim().is_empty() {
200 let heading = first_line(&text);
201 out.push((
202 format!("{root}/{TEXT_ENTRY}").into(),
203 serde_json::json!({ "t": text, "h": heading }),
204 ));
205 }
206 return;
207 }
208
209 // `ArrayRef::to_json` panics on a branch it cannot read to the end. Containing
210 // it here costs one root rather than the whole document, and through it the
211 // whole reindex step.
212 let read = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
213 any_to_json(&ArrayRef::from(ptr).to_json(txn), MAX_ANY_DEPTH, &mut truncated)
214 }));
215 let Ok(json) = read else {
216 warn!(root, "CRDT root could not be read as a sequence; skipping");
217 return;
218 };
219 if truncated {
220 warn!(doc_id, root, MAX_ANY_DEPTH, "CRDT root nested past the indexing depth limit");
221 }
222 let Value::Array(items) = json else { return };
223
224 // A string anywhere in the sequence means `Y.Text` replayed: text arrives as
225 // chunks, and an embed (an image, a mention) splits the run into chunks
226 // interleaved with maps. Testing for *any* string rather than all of them
227 // keeps such a document out of the positional loop below, where an embed
228 // moving would reshuffle every id. The whole stream becomes one entry
229 // instead — `filter_map` drops the embeds, which are not prose.
230 if items.iter().any(Value::is_string) {
231 let text: String = items.iter().filter_map(Value::as_str).collect();
232 if !text.trim().is_empty() {
233 let heading = first_line(&text);
234 out.push((
235 format!("{root}/{TEXT_ENTRY}").into(),
236 serde_json::json!({ "t": text, "h": heading }),
237 ));
238 }
239 return;
240 }
241 // A list of loose scalars is neither prose nor addressable.
242 if items.iter().all(|i| !i.is_object() && !i.is_array()) {
243 return;
244 }
245
246 // Positional ids are only as stable as the sequence itself, which is why an
247 // app wanting durable deep links should key its parts in a map. Indexing
248 // them anyway beats not indexing them: a shifted anchor still lands on the
249 // right document.
250 //
251 // Two root arrays both start at `0`, so their entries would collide on
252 // `search_docs`' `(obj_id, part_id)` key. `indexer::build_parts` namespaces
253 // `part_id` with the rule kind, which resolves the *collision* — it does
254 // nothing for the instability above.
255 for (i, item) in items.into_iter().enumerate() {
256 out.push((format!("{root}/{i}").into(), item));
257 }
258}
259
260/// How many levels of container nesting [`any_to_json`] will reproduce.
261///
262/// A safety limit, not a cost limit. The conversion recurses one stack frame per
263/// level and the `serde_json::Value` it builds is *dropped* recursively too, so a
264/// deeply nested value overflows the `run_slow` worker's stack — which aborts the
265/// process rather than unwinding, so [`collect_root`]'s `catch_unwind` cannot
266/// contain it. The content is peer-authored: one writer on a shared CRDT file
267/// could otherwise take down every tenant on the node.
268///
269/// Set to `rules::MAX_EXTRACT_DEPTH`, the ceiling `extract::walk` clamps its own
270/// descent to. Anything past it would be discarded downstream anyway, so the
271/// bound costs no indexable text.
272const MAX_ANY_DEPTH: usize = 32;
273
274/// Convert yrs' JSON representation into `serde_json`'s.
275///
276/// `Buffer` becomes null rather than base64: binary blobs are not prose, and
277/// indexing their encoding would flood the index with meaningless tokens. A
278/// non-finite `Number` also becomes null, since JSON cannot represent one.
279///
280/// `depth` counts down, matching `extract::walk`'s convention. A container found
281/// at zero becomes `Value::Null` rather than being descended into, and sets
282/// `truncated` so the caller can say so once for the whole document — see
283/// [`MAX_ANY_DEPTH`] for why the bound exists at all.
284fn any_to_json(any: &Any, depth: usize, truncated: &mut bool) -> Value {
285 match any {
286 Any::Null | Any::Undefined | Any::Buffer(_) => Value::Null,
287 Any::Bool(b) => Value::Bool(*b),
288 Any::Number(n) => serde_json::Number::from_f64(*n).map_or(Value::Null, Value::Number),
289 Any::BigInt(i) => Value::Number((*i).into()),
290 Any::String(s) => Value::String(s.to_string()),
291 Any::Array(_) | Any::Map(_) if depth == 0 => {
292 *truncated = true;
293 Value::Null
294 }
295 Any::Array(items) => {
296 Value::Array(items.iter().map(|i| any_to_json(i, depth - 1, truncated)).collect())
297 }
298 // `yrs::Any::Map` is an `Arc<HashMap<..>>`, so its order is randomised per
299 // process and `preserve_order` faithfully keeps it — an embed's indexed text
300 // would differ run to run. yrs kept no source order to restore, so sorting is
301 // the only determinism available; RTDB JSON, which has one, keeps it.
302 Any::Map(map) => {
303 let mut entries: Vec<(&String, &Any)> = map.iter().collect();
304 entries.sort_unstable_by(|a, b| a.0.cmp(b.0));
305 Value::Object(
306 entries
307 .into_iter()
308 .map(|(k, v)| (k.clone(), any_to_json(v, depth - 1, truncated)))
309 .collect(),
310 )
311 }
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use std::sync::Arc;
318
319 use yrs::{Array, ArrayPrelim, MapPrelim, Text};
320
321 use super::*;
322
323 /// Encode a document the way the adapter stores it, so the tests exercise
324 /// the real decode path rather than an in-memory shortcut.
325 fn updates_of(doc: &Doc) -> Vec<CrdtUpdate> {
326 let data = doc.transact().encode_state_as_update_v1(&yrs::StateVector::default());
327 vec![CrdtUpdate::with_client(data, "test".to_owned())]
328 }
329
330 #[test]
331 fn a_root_map_becomes_one_document_per_key() {
332 let doc = Doc::new();
333 let pages = doc.get_or_insert_map("p");
334 {
335 let mut txn = doc.transact_mut();
336 pages.insert(&mut txn, "page1", MapPrelim::from([("ti", "Bevezetés")]));
337 pages.insert(&mut txn, "page2", MapPrelim::from([("ti", "Részletek")]));
338 }
339
340 // Deliberately not sorted: `materialize` owes the caller key order, and a
341 // sort here would hide its loss behind yrs' randomised `HashMap` order.
342 let out = materialize(&updates_of(&doc), "f1~doc");
343
344 assert_eq!(out.len(), 2);
345 assert_eq!(&*out[0].0, "p/page1");
346 assert_eq!(out[0].1["ti"], serde_json::json!("Bevezetés"));
347 assert_eq!(&*out[1].0, "p/page2");
348 }
349
350 #[test]
351 fn a_root_array_becomes_one_document_per_index() {
352 let doc = Doc::new();
353 let slides = doc.get_or_insert_array("s");
354 {
355 let mut txn = doc.transact_mut();
356 slides.push_back(&mut txn, MapPrelim::from([("ti", "First")]));
357 slides.push_back(&mut txn, MapPrelim::from([("ti", "Second")]));
358 }
359
360 // Positional: this order is the array's own, not the key sort's.
361 let out = materialize(&updates_of(&doc), "f1~doc");
362
363 assert_eq!(out.len(), 2);
364 assert_eq!(&*out[0].0, "s/0");
365 assert_eq!(out[0].1["ti"], serde_json::json!("First"));
366 assert_eq!(out[1].1["ti"], serde_json::json!("Second"));
367 }
368
369 /// Roots are the second `HashMap` in the path. Inserted in reverse, so
370 /// insertion order cannot pass for sorted order.
371 #[test]
372 fn roots_come_out_in_name_order() {
373 let doc = Doc::new();
374 let second = doc.get_or_insert_map("z");
375 let first = doc.get_or_insert_map("a");
376 {
377 let mut txn = doc.transact_mut();
378 second.insert(&mut txn, "k", MapPrelim::from([("ti", "Utolsó")]));
379 first.insert(&mut txn, "k", MapPrelim::from([("ti", "Első")]));
380 }
381
382 let out = materialize(&updates_of(&doc), "f1~doc");
383
384 assert_eq!(out.len(), 2);
385 assert_eq!(&*out[0].0, "a/k");
386 assert_eq!(&*out[1].0, "z/k");
387 }
388
389 #[test]
390 fn nested_structures_survive_the_conversion() {
391 let doc = Doc::new();
392 let blocks = doc.get_or_insert_map("b");
393 {
394 let mut txn = doc.transact_mut();
395 blocks.insert(
396 &mut txn,
397 "blk",
398 MapPrelim::from([("c", ArrayPrelim::from(["hello", "world"]))]),
399 );
400 }
401
402 let out = materialize(&updates_of(&doc), "f1~doc");
403 assert_eq!(out.len(), 1);
404 assert_eq!(out[0].1["c"], serde_json::json!(["hello", "world"]));
405 }
406
407 #[test]
408 fn a_text_root_becomes_one_entry_holding_the_whole_stream() {
409 let doc = Doc::new();
410 let text = doc.get_or_insert_text("body");
411 {
412 let mut txn = doc.transact_mut();
413 text.push(&mut txn, "A címsor\nés a törzsszöveg.");
414 }
415
416 let out = materialize(&updates_of(&doc), "f1~doc");
417 assert_eq!(out.len(), 1);
418 assert_eq!(&*out[0].0, "body/_");
419 assert_eq!(out[0].1["t"], serde_json::json!("A címsor\nés a törzsszöveg."));
420 assert_eq!(out[0].1["h"], serde_json::json!("A címsor"), "the heading is the first line");
421 }
422
423 #[test]
424 fn a_blank_text_root_yields_nothing() {
425 let doc = Doc::new();
426 let text = doc.get_or_insert_text("body");
427 {
428 let mut txn = doc.transact_mut();
429 text.push(&mut txn, " \n\n");
430 }
431
432 assert!(materialize(&updates_of(&doc), "f1~doc").is_empty());
433 }
434
435 /// An embed splits the chunk run, which under a stricter "all items are
436 /// strings" rule would drop the document into the positional loop and index
437 /// one row per chunk.
438 #[test]
439 fn a_text_root_with_an_embed_is_still_one_entry() {
440 let doc = Doc::new();
441 let text = doc.get_or_insert_text("body");
442 {
443 let mut txn = doc.transact_mut();
444 text.push(&mut txn, "before ");
445 text.insert_embed(&mut txn, 7, MapPrelim::from([("image", "pic.png")]));
446 text.insert(&mut txn, 8, " after");
447 }
448
449 let out = materialize(&updates_of(&doc), "f1~doc");
450 assert_eq!(out.len(), 1, "an embed must not split the document into positional rows");
451 assert_eq!(&*out[0].0, "body/_");
452 assert_eq!(out[0].1["t"], serde_json::json!("before after"));
453 }
454
455 /// The shape that spins forever through `ArrayRef`: two or more countable
456 /// `ItemContent::String` items, both non-ASCII. A single `push` squashes into
457 /// one item and reads back on the first pass, so this deletes a range to split
458 /// the run.
459 #[test]
460 fn a_multi_chunk_non_ascii_text_root_does_not_hang() {
461 // The construction doc must use the same offset kind as `materialize`, or
462 // the indices below are byte offsets and land mid-character.
463 let doc =
464 Doc::with_options(Options { offset_kind: OffsetKind::Utf16, ..Default::default() });
465 let text = doc.get_or_insert_text("body");
466 {
467 let mut txn = doc.transact_mut();
468 text.push(&mut txn, "árvíztűrő tükörfúrógép");
469 text.remove_range(&mut txn, 9, 1); // the space, splitting the run
470 }
471
472 let out = materialize(&updates_of(&doc), "f1~doc");
473 assert_eq!(out.len(), 1);
474 assert_eq!(&*out[0].0, "body/_");
475 assert_eq!(out[0].1["t"], serde_json::json!("árvíztűrőtükörfúrógép"));
476 }
477
478 /// The same spin as the test above, reached through the guard rather than
479 /// around it: a `Y.Text` holding **only non-ASCII whitespace**, split across
480 /// two items. `str::trim` eats U+3000 and U+00A0, so the older
481 /// `!text.trim().is_empty()` guard let this fall through to
482 /// `ArrayRef::to_json` and hang — a shape a CJK writer produces by accident,
483 /// and a peer with write access produces on purpose. Blankness must decide
484 /// only whether an entry is emitted, never whether `ArrayRef` is reached.
485 ///
486 /// Run on its own thread with a deadline: a regression here hangs forever, and
487 /// a hung test would wedge the whole suite instead of failing it.
488 #[test]
489 fn a_blank_multi_chunk_non_ascii_text_root_does_not_hang() {
490 let doc =
491 Doc::with_options(Options { offset_kind: OffsetKind::Utf16, ..Default::default() });
492 let text = doc.get_or_insert_text("body");
493 {
494 let mut txn = doc.transact_mut();
495 // Ideographic spaces and a no-break space — whitespace to `trim`,
496 // non-ASCII to the length accounting.
497 text.push(&mut txn, "\u{3000}\u{3000}\u{00a0}\u{3000}");
498 text.remove_range(&mut txn, 1, 1); // splits the run in two
499 }
500
501 let updates = updates_of(&doc);
502 let (tx, rx) = std::sync::mpsc::channel();
503 let worker = std::thread::spawn(move || {
504 let _ = tx.send(materialize(&updates, "f1~doc"));
505 });
506 let out = rx
507 .recv_timeout(std::time::Duration::from_secs(10))
508 .expect("materialize spun on a blank multi-chunk non-ASCII text root");
509 worker.join().expect("materialize thread panicked");
510
511 assert!(out.is_empty(), "a blank text root carries nothing worth indexing");
512 }
513
514 /// The retained `any(Value::is_string)` branch: a genuine array of loose
515 /// strings reads back as empty text, falls through, and is still collapsed
516 /// into one text entry rather than indexed positionally.
517 #[test]
518 fn a_root_array_of_plain_strings_is_still_one_text_entry() {
519 let doc = Doc::new();
520 let lines = doc.get_or_insert_array("l");
521 {
522 let mut txn = doc.transact_mut();
523 lines.push_back(&mut txn, "Első sor");
524 lines.push_back(&mut txn, " és a többi");
525 }
526
527 let out = materialize(&updates_of(&doc), "f1~doc");
528 assert_eq!(out.len(), 1);
529 assert_eq!(&*out[0].0, "l/_");
530 assert_eq!(out[0].1["t"], serde_json::json!("Első sor és a többi"));
531 }
532
533 #[test]
534 fn a_root_array_of_loose_scalars_is_still_skipped() {
535 let doc = Doc::new();
536 let nums = doc.get_or_insert_array("n");
537 {
538 let mut txn = doc.transact_mut();
539 nums.push_back(&mut txn, Any::Number(1.0));
540 nums.push_back(&mut txn, Any::Bool(true));
541 }
542
543 assert!(materialize(&updates_of(&doc), "f1~doc").is_empty());
544 }
545
546 #[test]
547 fn a_corrupt_update_does_not_lose_the_rest_of_the_log() {
548 let doc = Doc::new();
549 let pages = doc.get_or_insert_map("p");
550 {
551 let mut txn = doc.transact_mut();
552 pages.insert(&mut txn, "page1", MapPrelim::from([("ti", "Kept")]));
553 }
554
555 let mut updates = updates_of(&doc);
556 updates.insert(0, CrdtUpdate::with_client(vec![0xff, 0xff, 0xff], "test".to_owned()));
557
558 let out = materialize(&updates, "f1~doc");
559 assert_eq!(out.len(), 1, "the readable update must still be indexed");
560 assert_eq!(out[0].1["ti"], serde_json::json!("Kept"));
561 }
562
563 #[test]
564 fn an_empty_log_yields_nothing() {
565 assert!(materialize(&[], "f1~doc").is_empty());
566 }
567
568 /// `any_to_json` with a full depth budget and the truncation flag discarded.
569 fn to_json(any: &Any) -> Value {
570 let mut truncated = false;
571 any_to_json(any, MAX_ANY_DEPTH, &mut truncated)
572 }
573
574 #[test]
575 fn binary_and_non_finite_values_become_null_rather_than_noise() {
576 assert_eq!(to_json(&Any::Buffer(Arc::from([1u8, 2, 3]))), Value::Null);
577 assert_eq!(to_json(&Any::Number(f64::NAN)), Value::Null);
578 assert_eq!(to_json(&Any::BigInt(-7)), serde_json::json!(-7));
579 }
580
581 /// The bound that keeps peer-authored nesting from overflowing the worker
582 /// stack — see [`MAX_ANY_DEPTH`]. Built iteratively, because building the
583 /// input recursively would be the very overflow under test.
584 #[test]
585 fn nesting_past_the_depth_limit_is_clipped_rather_than_followed() {
586 let mut any = Any::String("deep".into());
587 for _ in 0..(MAX_ANY_DEPTH + 8) {
588 any = Any::Array(Arc::from([any]));
589 }
590
591 let mut truncated = false;
592 let mut value = any_to_json(&any, MAX_ANY_DEPTH, &mut truncated);
593 assert!(truncated, "clipping must be observable to the caller");
594
595 // Exactly `MAX_ANY_DEPTH` arrays survive, and the level below the last one
596 // is the null the limit substituted for the rest.
597 for _ in 0..MAX_ANY_DEPTH {
598 let Value::Array(items) = value else { panic!("expected an array level") };
599 value = items.into_iter().next().expect("each level holds one child");
600 }
601 assert_eq!(value, Value::Null);
602 }
603}
604
605// vim: ts=4