Skip to main content

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