offline_protocol_data/doc.rs
1//! The replicated document: collections, commits, and encoding.
2//!
3//! This module is the only place the CRDT engine is named. Nothing it
4//! exports mentions an engine type, so the engine can be replaced without a
5//! breaking change to any caller, the FFI surface, or any binding. That is
6//! the property the whole data layer's no-lock-in promise rests on, and it
7//! is why `loro` appears in no `pub` signature below.
8
9use std::panic::{catch_unwind, AssertUnwindSafe};
10
11use loro::{EncodedBlobMode, ExportMode, LoroDoc, LoroValue, VersionVector};
12
13use crate::error::{DataError, DataResult};
14use crate::policy;
15use crate::value::DataValue;
16
17/// An opaque marker for "how much of this document I have already seen".
18///
19/// Callers treat it as bytes: store it, hand it back, compare it for
20/// equality. Its interior is the engine's version vector, which is exactly
21/// the kind of detail that must not leak into a wire format or an FFI
22/// signature, because pinning it would pin the engine.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct VersionToken(Vec<u8>);
25
26impl VersionToken {
27 /// The token as bytes, for storage or transmission.
28 pub fn as_bytes(&self) -> &[u8] {
29 &self.0
30 }
31
32 /// Rebuild a token from bytes produced by [`VersionToken::as_bytes`].
33 pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Self {
34 Self(bytes.into())
35 }
36}
37
38/// A committed change, ready to persist and (from F3) to send.
39#[derive(Debug, Clone)]
40pub struct Delta {
41 /// The encoded change.
42 pub bytes: Vec<u8>,
43 /// The document version after applying it.
44 pub version: VersionToken,
45}
46
47/// Sizes and counters an owner needs to decide about compaction and caps.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct DocStats {
50 /// Size of the compacted (shallow snapshot) encoding.
51 pub compacted_bytes: usize,
52 /// Number of commits recorded since the document was last compacted.
53 pub commits_since_compaction: u32,
54}
55
56/// What happened to the changes in an imported blob.
57///
58/// The distinction is load-bearing and is the reason [`DataDoc::import`]
59/// does not return `()`. The engine accepts a change whose causal
60/// predecessor it has never seen, and answers `Ok`: the change is *parked*,
61/// invisible to every read and absent from [`DataDoc::export_compacted`],
62/// until the predecessor arrives. An owner that treats that `Ok` as "applied"
63/// will happily compact the document and delete the very records the parked
64/// change is still waiting for, which turns a recoverable gap into permanent
65/// silent loss.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ImportOutcome {
68 /// Every change in the blob applied and is readable now.
69 Applied,
70 /// Some or all of the blob is parked, waiting on a predecessor that has
71 /// not arrived. The document is missing those changes until it does, so
72 /// nothing that could destroy the missing predecessor may run.
73 Parked,
74}
75
76impl ImportOutcome {
77 /// Whether anything in the blob is still waiting on a predecessor.
78 pub fn is_parked(self) -> bool {
79 matches!(self, Self::Parked)
80 }
81}
82
83/// What a blob claims about itself, judged against one document.
84///
85/// Every field is a verdict rather than a measurement, because the engine's
86/// own metadata types must not cross this crate's boundary: pinning them in
87/// a caller would pin the engine. The verdicts exist so a caller can decide
88/// *not* to import, which is the only decision that helps when the bytes
89/// came off a network rather than out of a sealed record.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct BlobMeta {
92 already_applied: bool,
93 spans_trimmed_history: bool,
94 is_snapshot: bool,
95 change_count: u32,
96}
97
98impl BlobMeta {
99 /// Whether this document already holds every change the blob carries.
100 ///
101 /// True for the ordinary redelivery a ladder promising at-least-once
102 /// produces. Importing anyway would be merely wasteful for a fresh
103 /// document and actively dangerous for a compacted one, which is why
104 /// this is checked before anything reaches the engine.
105 pub fn already_applied(self) -> bool {
106 self.already_applied
107 }
108
109 /// Whether the blob needs history this document has compacted away.
110 ///
111 /// A shallow snapshot trims history below a point, and the engine has an
112 /// open defect where changes referring below that point panic instead of
113 /// erroring, poisoning the document's lock as they go. Under the
114 /// `minisize` profile the SDK ships with `panic = "abort"`, so there is
115 /// no unwinding to catch: the only defense is never handing such a blob
116 /// over.
117 ///
118 /// It is asked of a whole document as well as of a run of changes, and
119 /// the two are asked differently. A snapshot carries its own base, so
120 /// the question for one is not where it starts but whether it still
121 /// covers what this document kept: a replica that trimmed history can
122 /// merge a snapshot that contains the ops it retained, and aborts on one
123 /// that does not. Exempting snapshots because they are self contained
124 /// reads well and is wrong, because the missing ancestors are missing
125 /// from the *receiver*.
126 ///
127 /// A caller seeing this on a run of changes asks for a snapshot. A
128 /// caller seeing it on a snapshot has reached the end of what
129 /// replication can do: the peer forked below a point this replica
130 /// deleted, and nothing either side can send carries the ancestors back.
131 pub fn spans_trimmed_history(self) -> bool {
132 self.spans_trimmed_history
133 }
134
135 /// Whether the blob is a snapshot rather than a run of updates.
136 pub fn is_snapshot(self) -> bool {
137 self.is_snapshot
138 }
139
140 /// How many changes the blob claims to carry.
141 pub fn change_count(self) -> u32 {
142 self.change_count
143 }
144}
145
146/// The outcome of importing a blob that arrived from a peer.
147///
148/// Distinct from [`ImportOutcome`] because two of the four outcomes never
149/// reach the engine at all, and a caller has to tell "we already had this"
150/// apart from "this applied" to keep its own bookkeeping honest.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum RemoteImport {
153 /// Applied and readable now.
154 Applied,
155 /// Accepted but parked behind a predecessor that has not arrived.
156 Parked,
157 /// Every change in it was already present; the engine was not touched.
158 AlreadyHave,
159 /// Refused: it refers to history this document has compacted away.
160 /// The caller should ask the sender for a snapshot instead.
161 RefusedTrimmedHistory,
162}
163
164/// What a peer needs in order to catch up with this document.
165///
166/// The ladder is deliberately explicit rather than "here are some bytes":
167/// only the caller knows the frame budget, and only this crate knows whether
168/// the updates it would have to send still exist.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum CatchUp {
171 /// The peer already has everything this document holds.
172 UpToDate,
173 /// The changes the peer is missing, encoded.
174 Updates(Vec<u8>),
175 /// The peer is missing changes this document has compacted away, so no
176 /// run of updates can express the gap. Send a snapshot.
177 NeedsSnapshot,
178}
179
180/// A replicated document.
181///
182/// Holds named collections (`map`, `list`, `text`, `counter`). Concurrent
183/// edits from any replica merge deterministically, and applying the same
184/// change twice is a no-op, which is what lets this layer ride a transport
185/// ladder that only promises at-least-once, unordered delivery.
186pub struct DataDoc {
187 inner: LoroDoc,
188 /// The version whose changes have already been handed out as deltas.
189 /// Everything after it is what the next commit exports.
190 exported: VersionVector,
191 /// What `exported` was before the most recent commit.
192 ///
193 /// Kept so a caller whose persistence failed can put the marker back and
194 /// have the same change handed out again. Without it, a commit whose
195 /// delta never reached storage is lost: the document keeps the change in
196 /// memory, the next commit exports only what came after it, and the
197 /// change is simply missing after a restart.
198 previous_exported: Option<VersionVector>,
199 /// Sticky corruption flag. Set when a decode panics; every later call
200 /// refuses rather than touching an engine left in an unknown state.
201 poisoned: bool,
202 commits_since_compaction: u32,
203 last_measured_bytes: usize,
204 delta_bytes_since_measure: usize,
205}
206
207impl Default for DataDoc {
208 fn default() -> Self {
209 Self::new()
210 }
211}
212
213impl DataDoc {
214 /// Create an empty document.
215 pub fn new() -> Self {
216 Self {
217 inner: LoroDoc::new(),
218 exported: VersionVector::new(),
219 previous_exported: None,
220 poisoned: false,
221 commits_since_compaction: 0,
222 last_measured_bytes: 0,
223 delta_bytes_since_measure: 0,
224 }
225 }
226
227 /// Whether this handle has been poisoned by a decode failure.
228 ///
229 /// A poisoned handle never recovers. The owner drops it and re-opens the
230 /// document from storage, which is a decision only the owner can make.
231 pub fn is_poisoned(&self) -> bool {
232 self.poisoned
233 }
234
235 fn check_live(&self) -> DataResult<()> {
236 if self.poisoned {
237 return Err(DataError::Poisoned);
238 }
239 Ok(())
240 }
241
242 /// Apply an encoded snapshot or delta.
243 ///
244 /// Every blob reaching this method comes back out of a sealed
245 /// protocol-state record, so an AEAD tag has already established that
246 /// these are bytes this SDK wrote and nobody altered. That is the real
247 /// containment for the engine's known import-panic classes, and the
248 /// reason no code path may ever feed unverified bytes here: the
249 /// `catch_unwind` below is a second line, and under the `minisize`
250 /// profile (`panic = "abort"`) it does not exist at all.
251 ///
252 /// Returns whether the blob applied or is parked behind a missing
253 /// predecessor. Callers that can destroy history (compaction, log
254 /// truncation) must branch on it: see [`ImportOutcome`].
255 pub fn import(&mut self, bytes: &[u8]) -> DataResult<ImportOutcome> {
256 self.check_live()?;
257
258 // Reject a blob the engine cannot even describe before asking it to
259 // apply the contents. Checksum verification is on: it is cheap
260 // relative to import and catches truncation that got past framing.
261 if let Err(err) = LoroDoc::decode_import_blob_meta(bytes, true) {
262 return Err(DataError::Corrupt(err.to_string()));
263 }
264
265 let outcome = catch_unwind(AssertUnwindSafe(|| self.inner.import(bytes)));
266 match outcome {
267 // `pending` names the changes the engine took but cannot apply
268 // yet. Discarding it is how a gap in the delta log turns into
269 // silent loss, so it is reported rather than dropped.
270 Ok(Ok(status)) => Ok(match status.pending {
271 Some(range) if !range.is_empty() => ImportOutcome::Parked,
272 _ => ImportOutcome::Applied,
273 }),
274 Ok(Err(err)) => Err(DataError::Corrupt(err.to_string())),
275 Err(_) => {
276 // The engine may hold a poisoned lock now; the handle is done.
277 self.poisoned = true;
278 Err(DataError::Corrupt(
279 "engine panicked while importing".to_string(),
280 ))
281 }
282 }
283 }
284
285 /// Judge a blob against this document without letting the engine touch
286 /// it.
287 ///
288 /// The whole point is that this runs *first*. Header decoding with the
289 /// checksum on is the cheap part; the verdicts it produces are what let
290 /// a caller refuse the two blob shapes that are known to end a process
291 /// rather than return an error.
292 pub fn inspect(&self, bytes: &[u8]) -> DataResult<BlobMeta> {
293 self.check_live()?;
294 let meta = LoroDoc::decode_import_blob_meta(bytes, true)
295 .map_err(|err| DataError::Corrupt(err.to_string()))?;
296
297 let is_snapshot = matches!(
298 meta.mode,
299 EncodedBlobMode::Snapshot
300 | EncodedBlobMode::OutdatedSnapshot
301 | EncodedBlobMode::ShallowSnapshot
302 );
303 let already_applied = self.inner.oplog_vv().includes_vv(&meta.partial_end_vv);
304
305 // A document that has never been trimmed can take anything.
306 let shallow_since = self.inner.shallow_since_vv();
307 let spans_trimmed_history = if shallow_since.is_empty() {
308 false
309 } else if is_snapshot {
310 // A trimmed document cannot merge a branch at all: the ops the
311 // branch depends on were deleted here, and supplying them in
312 // the snapshot does not help, because they sit below this
313 // document's own base. The only snapshot it can take is one
314 // that supersedes it outright, so that is the test: everything
315 // held here has to be in there too. Anything less is a fork,
316 // and handing one over aborts the process exactly as a run of
317 // changes of the same shape does.
318 !meta.partial_end_vv.includes_vv(&self.inner.oplog_vv())
319 } else {
320 // If this document's history starts at or after where the blob's
321 // does, the blob describes changes on top of ops that are no
322 // longer here.
323 shallow_since.to_vv().includes_vv(&meta.partial_start_vv)
324 };
325
326 Ok(BlobMeta {
327 already_applied,
328 spans_trimmed_history,
329 is_snapshot,
330 change_count: meta.change_num,
331 })
332 }
333
334 /// Apply a blob that arrived from a peer.
335 ///
336 /// [`DataDoc::import`] exists for bytes that came back out of a sealed
337 /// record, where an AEAD tag has already established that this SDK wrote
338 /// them. Nothing of the sort is true here: a peer is authenticated, which
339 /// says who sent the bytes and nothing at all about their shape. So this
340 /// path re-judges every blob with [`DataDoc::inspect`] and declines the
341 /// two shapes the engine is known to abort on, rather than relying on an
342 /// unwinding catch that the shipped mobile profile does not have.
343 ///
344 /// Refusing is not a failure: a duplicate is the ladder keeping its
345 /// at-least-once promise, and a trimmed-history gap in a run of changes
346 /// is answered by asking for a snapshot. Both are recorded outcomes, not
347 /// errors. The same refusal on a snapshot is the exception: it means the
348 /// replicas have diverged past anything a frame can carry, and the
349 /// caller has nothing left to ask for.
350 pub fn import_remote(&mut self, bytes: &[u8]) -> DataResult<RemoteImport> {
351 let meta = self.inspect(bytes)?;
352 if meta.already_applied() {
353 return Ok(RemoteImport::AlreadyHave);
354 }
355 if meta.spans_trimmed_history() {
356 return Ok(RemoteImport::RefusedTrimmedHistory);
357 }
358 Ok(match self.import(bytes)? {
359 ImportOutcome::Applied => RemoteImport::Applied,
360 ImportOutcome::Parked => RemoteImport::Parked,
361 })
362 }
363
364 /// Encode what a replica at `from` is missing.
365 ///
366 /// `from` is a token that replica produced with [`DataDoc::version`].
367 /// A token this document cannot decode is treated as corrupt rather than
368 /// as "send everything": on the sync path the token is remote input, and
369 /// answering a malformed one with the whole document would make a garbled
370 /// byte an amplification lever.
371 pub fn export_since(&self, from: &VersionToken) -> DataResult<CatchUp> {
372 self.check_live()?;
373 let from_vv = VersionVector::decode(from.as_bytes())
374 .map_err(|err| DataError::Corrupt(err.to_string()))?;
375
376 if from_vv.includes_vv(&self.inner.oplog_vv()) {
377 return Ok(CatchUp::UpToDate);
378 }
379
380 // The mirror of the refusal in `import_remote`: if this document's
381 // history no longer reaches back to where the peer is, no run of
382 // updates can express the gap, and asking the engine for one is the
383 // same defect from the sending side.
384 let shallow_since = self.inner.shallow_since_vv();
385 if !shallow_since.is_empty() && !from_vv.includes_vv(&shallow_since.to_vv()) {
386 return Ok(CatchUp::NeedsSnapshot);
387 }
388
389 self.inner
390 .export(ExportMode::updates(&from_vv))
391 .map(CatchUp::Updates)
392 .map_err(|err| DataError::Engine(err.to_string()))
393 }
394
395 /// Declare everything currently in the document as already persisted.
396 ///
397 /// Called after loading a snapshot and its delta log, so the next commit
398 /// exports only genuinely new changes instead of re-emitting the history
399 /// that was just read off disk.
400 pub fn mark_persisted(&mut self) {
401 self.exported = self.inner.oplog_vv();
402 }
403
404 /// Commit pending edits and return the change they produced.
405 ///
406 /// `Ok(None)` means nothing had changed. The returned delta is exactly
407 /// the bytes needed to bring a replica at the previous version up to the
408 /// current one, which is what the delta log stores and what F3 will send.
409 pub fn commit(&mut self) -> DataResult<Option<Delta>> {
410 self.check_live()?;
411 self.inner.commit();
412
413 let current = self.inner.oplog_vv();
414 if current == self.exported {
415 return Ok(None);
416 }
417
418 let bytes = self
419 .inner
420 .export(ExportMode::updates(&self.exported))
421 .map_err(|err| DataError::Engine(err.to_string()))?;
422
423 self.previous_exported = Some(std::mem::replace(&mut self.exported, current));
424 self.commits_since_compaction = self.commits_since_compaction.saturating_add(1);
425 self.delta_bytes_since_measure = self.delta_bytes_since_measure.saturating_add(bytes.len());
426
427 Ok(Some(Delta {
428 bytes,
429 version: self.version(),
430 }))
431 }
432
433 /// Undo the bookkeeping of the last commit, so its change is handed out
434 /// again by the next one.
435 ///
436 /// For the caller whose write failed. The document keeps the edits (they
437 /// happened, and the application is looking at them); what is undone is
438 /// only the claim that someone has taken responsibility for persisting
439 /// them. Without this a failed write loses the change at the next
440 /// restart, silently, which is the worst shape a storage error can take.
441 ///
442 /// Safe to call spuriously: re-exporting a delta that did reach storage
443 /// costs one duplicate record, and duplicates are free by construction.
444 pub fn rewind_last_commit(&mut self) {
445 if let Some(previous) = self.previous_exported.take() {
446 self.exported = previous;
447 self.commits_since_compaction = self.commits_since_compaction.saturating_sub(1);
448 }
449 }
450
451 /// The current version of the document.
452 pub fn version(&self) -> VersionToken {
453 VersionToken(self.inner.oplog_vv().encode())
454 }
455
456 /// Encode the document compactly, discarding history.
457 ///
458 /// This is what a document record stores: a 10,000 operation document
459 /// compacts to under a kilobyte here, against tens of kilobytes of full
460 /// history, which is what keeps a document inside one sealed record and
461 /// a catch-up inside one message.
462 pub fn export_compacted(&self) -> DataResult<Vec<u8>> {
463 self.check_live()?;
464 let frontiers = self.inner.oplog_frontiers();
465 self.inner
466 .export(ExportMode::shallow_snapshot(&frontiers))
467 .map_err(|err| DataError::Engine(err.to_string()))
468 }
469
470 /// Encode the document with its full history.
471 ///
472 /// Part of the escape hatch: an application can always take this and
473 /// leave. Prefer [`DataDoc::export_compacted`] for storage and transfer.
474 pub fn export_raw(&self) -> DataResult<Vec<u8>> {
475 self.check_live()?;
476 self.inner
477 .export(ExportMode::Snapshot)
478 .map_err(|err| DataError::Engine(err.to_string()))
479 }
480
481 /// The document's current state as plain JSON.
482 ///
483 /// The second half of the escape hatch, and the one that does not
484 /// require the reader to know anything about this SDK at all.
485 pub fn export_json(&self) -> DataResult<String> {
486 self.check_live()?;
487 let value = self.inner.get_deep_value();
488 serde_json::to_string(&value).map_err(|err| DataError::Engine(err.to_string()))
489 }
490
491 /// Measure the compacted encoding and refresh the size bookkeeping.
492 pub fn measure(&mut self) -> DataResult<usize> {
493 let bytes = self.export_compacted()?.len();
494 self.last_measured_bytes = bytes;
495 self.delta_bytes_since_measure = 0;
496 Ok(bytes)
497 }
498
499 /// Check the document against the size cap, measuring only when the
500 /// running estimate says a breach is possible.
501 ///
502 /// Returns the verdict and, when a measurement happened, the true size.
503 pub fn check_size(&mut self) -> DataResult<(policy::SizeVerdict, Option<usize>)> {
504 if !policy::needs_size_check(self.last_measured_bytes, self.delta_bytes_since_measure) {
505 return Ok((policy::SizeVerdict::Ok, None));
506 }
507 let bytes = self.measure()?;
508 Ok((policy::size_verdict(bytes), Some(bytes)))
509 }
510
511 /// Statistics for compaction and cap decisions.
512 pub fn stats(&mut self) -> DataResult<DocStats> {
513 let compacted_bytes = self.measure()?;
514 Ok(DocStats {
515 compacted_bytes,
516 commits_since_compaction: self.commits_since_compaction,
517 })
518 }
519
520 /// Commits recorded since the last compaction.
521 pub fn commits_since_compaction(&self) -> u32 {
522 self.commits_since_compaction
523 }
524
525 /// Reset the compaction counter, after the owner has written a fresh
526 /// compacted record and dropped the folded delta records.
527 pub fn mark_compacted(&mut self, compacted_bytes: usize) {
528 self.commits_since_compaction = 0;
529 self.last_measured_bytes = compacted_bytes;
530 self.delta_bytes_since_measure = 0;
531 }
532
533 /// Adopt the bookkeeping of a document just loaded from storage.
534 ///
535 /// Without this a reopened document starts as though it were empty, and
536 /// the cheap size estimate then under-reports by the entire size of what
537 /// was loaded: a document already near the cap would look far from it
538 /// and sail past without ever triggering a real measurement. Passing the
539 /// record sizes in keeps the estimate an over-estimate, which is the
540 /// property the whole check depends on.
541 ///
542 /// `compacted_bytes` is the size of the stored snapshot and `log_bytes`
543 /// the total size of the delta records replayed on top of it.
544 pub fn restore_bookkeeping(&mut self, compacted_bytes: usize, log_bytes: usize, commits: u32) {
545 self.last_measured_bytes = compacted_bytes;
546 self.delta_bytes_since_measure = log_bytes;
547 self.commits_since_compaction = commits;
548 }
549
550 // ---- collections -------------------------------------------------
551
552 /// Set a key in a map collection.
553 pub fn map_set(&mut self, collection: &str, key: &str, value: DataValue) -> DataResult<()> {
554 self.check_live()?;
555 crate::validate_name(collection)?;
556 crate::validate_key(key)?;
557 crate::validate_value(&value)?;
558 let map = self.inner.get_map(collection);
559 map.insert(key, to_engine_value(value))
560 .map_err(|err| DataError::Engine(err.to_string()))
561 }
562
563 /// Remove a key from a map collection.
564 pub fn map_delete(&mut self, collection: &str, key: &str) -> DataResult<()> {
565 self.check_live()?;
566 crate::validate_name(collection)?;
567 let map = self.inner.get_map(collection);
568 map.delete(key)
569 .map_err(|err| DataError::Engine(err.to_string()))
570 }
571
572 /// Read a key from a map collection.
573 pub fn map_get(&self, collection: &str, key: &str) -> DataResult<Option<DataValue>> {
574 self.check_live()?;
575 crate::validate_name(collection)?;
576 let map = self.inner.get_map(collection);
577 Ok(map.get(key).and_then(|entry| match entry {
578 loro::ValueOrContainer::Value(value) => from_engine_value(&value),
579 loro::ValueOrContainer::Container(_) => None,
580 }))
581 }
582
583 /// Append to a list collection.
584 pub fn list_push(&mut self, collection: &str, value: DataValue) -> DataResult<()> {
585 self.check_live()?;
586 crate::validate_name(collection)?;
587 crate::validate_value(&value)?;
588 let list = self.inner.get_list(collection);
589 list.push(to_engine_value(value))
590 .map_err(|err| DataError::Engine(err.to_string()))
591 }
592
593 /// Insert into a list collection at `index`.
594 pub fn list_insert(
595 &mut self,
596 collection: &str,
597 index: usize,
598 value: DataValue,
599 ) -> DataResult<()> {
600 self.check_live()?;
601 crate::validate_name(collection)?;
602 crate::validate_value(&value)?;
603 let list = self.inner.get_list(collection);
604 let length = list.len();
605 if index > length {
606 return Err(DataError::OutOfRange {
607 position: index,
608 length,
609 });
610 }
611 list.insert(index, to_engine_value(value))
612 .map_err(|err| DataError::Engine(err.to_string()))
613 }
614
615 /// Delete `count` entries from a list collection at `index`.
616 pub fn list_delete(&mut self, collection: &str, index: usize, count: usize) -> DataResult<()> {
617 self.check_live()?;
618 crate::validate_name(collection)?;
619 let list = self.inner.get_list(collection);
620 let length = list.len();
621 if index.saturating_add(count) > length {
622 return Err(DataError::OutOfRange {
623 position: index.saturating_add(count),
624 length,
625 });
626 }
627 list.delete(index, count)
628 .map_err(|err| DataError::Engine(err.to_string()))
629 }
630
631 /// Number of entries in a list collection.
632 pub fn list_len(&self, collection: &str) -> DataResult<usize> {
633 self.check_live()?;
634 crate::validate_name(collection)?;
635 Ok(self.inner.get_list(collection).len())
636 }
637
638 /// Insert text into a text collection at a character position.
639 pub fn text_insert(&mut self, collection: &str, position: usize, text: &str) -> DataResult<()> {
640 self.check_live()?;
641 crate::validate_name(collection)?;
642 crate::validate_value_len(text.len())?;
643 let handle = self.inner.get_text(collection);
644 let length = handle.len_unicode();
645 if position > length {
646 return Err(DataError::OutOfRange { position, length });
647 }
648 handle
649 .insert(position, text)
650 .map_err(|err| DataError::Engine(err.to_string()))
651 }
652
653 /// Delete `count` characters from a text collection at `position`.
654 pub fn text_delete(
655 &mut self,
656 collection: &str,
657 position: usize,
658 count: usize,
659 ) -> DataResult<()> {
660 self.check_live()?;
661 crate::validate_name(collection)?;
662 let handle = self.inner.get_text(collection);
663 let length = handle.len_unicode();
664 if position.saturating_add(count) > length {
665 return Err(DataError::OutOfRange {
666 position: position.saturating_add(count),
667 length,
668 });
669 }
670 handle
671 .delete(position, count)
672 .map_err(|err| DataError::Engine(err.to_string()))
673 }
674
675 /// The current contents of a text collection.
676 pub fn text_value(&self, collection: &str) -> DataResult<String> {
677 self.check_live()?;
678 crate::validate_name(collection)?;
679 Ok(self.inner.get_text(collection).to_string())
680 }
681
682 /// Add to a counter collection. Negative values subtract.
683 pub fn counter_increment(&mut self, collection: &str, amount: f64) -> DataResult<()> {
684 self.check_live()?;
685 crate::validate_name(collection)?;
686 self.inner
687 .get_counter(collection)
688 .increment(amount)
689 .map_err(|err| DataError::Engine(err.to_string()))
690 }
691
692 /// The current value of a counter collection.
693 pub fn counter_value(&self, collection: &str) -> DataResult<f64> {
694 self.check_live()?;
695 crate::validate_name(collection)?;
696 Ok(self.inner.get_counter(collection).get_value())
697 }
698}
699
700impl std::fmt::Debug for DataDoc {
701 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
702 f.debug_struct("DataDoc")
703 .field("poisoned", &self.poisoned)
704 .field("commits_since_compaction", &self.commits_since_compaction)
705 .finish_non_exhaustive()
706 }
707}
708
709/// Field names of an attachment reference inside the engine.
710///
711/// Deliberately the same spelling as the serde representation and the JSON
712/// that crosses the FFI, so a document exported with [`DataDoc::export_json`]
713/// describes its own attachments in the same words the API uses. The export
714/// is the escape hatch for an application leaving this SDK, and an escape
715/// hatch that needs a decoder ring is not one.
716const ATTACHMENT_TAG_KEY: &str = "kind";
717const ATTACHMENT_TAG: &str = "attachment";
718const ATTACHMENT_HASH_KEY: &str = "hash";
719const ATTACHMENT_SIZE_KEY: &str = "size";
720const ATTACHMENT_NAME_KEY: &str = "name";
721const ATTACHMENT_MIME_KEY: &str = "mime";
722
723fn to_engine_value(value: DataValue) -> LoroValue {
724 match value {
725 DataValue::Null => LoroValue::Null,
726 DataValue::Bool { value } => LoroValue::from(value),
727 DataValue::Int { value } => LoroValue::from(value),
728 DataValue::Float { value } => LoroValue::from(value),
729 DataValue::Text { value } => LoroValue::from(value),
730 DataValue::Bytes { value } => LoroValue::from(value),
731 // A reference is one engine value, not a container, and that is the
732 // whole of the "replaced, never edited" property: a container would
733 // give two writers a way to merge half of one reference with half of
734 // another and produce a hash that addresses nothing.
735 DataValue::Attachment {
736 hash,
737 size,
738 name,
739 mime,
740 } => {
741 let mut fields: Vec<(String, LoroValue)> = vec![
742 (
743 ATTACHMENT_TAG_KEY.to_string(),
744 LoroValue::from(ATTACHMENT_TAG),
745 ),
746 (ATTACHMENT_HASH_KEY.to_string(), LoroValue::from(hash)),
747 (
748 ATTACHMENT_SIZE_KEY.to_string(),
749 // The engine has one integer type and it is signed.
750 // `validate_attachment` refuses anything past
751 // `MAX_ATTACHMENT_SIZE` at the operation that writes it,
752 // and every write path runs it, so the saturation here is
753 // a backstop rather than the bound: it exists because a
754 // value this deep has no way to report a refusal, not
755 // because the case is expected.
756 LoroValue::from(i64::try_from(size).unwrap_or(i64::MAX)),
757 ),
758 ];
759 if let Some(name) = name {
760 fields.push((ATTACHMENT_NAME_KEY.to_string(), LoroValue::from(name)));
761 }
762 if let Some(mime) = mime {
763 fields.push((ATTACHMENT_MIME_KEY.to_string(), LoroValue::from(mime)));
764 }
765 LoroValue::Map(fields.into())
766 }
767 }
768}
769
770fn from_engine_value(value: &LoroValue) -> Option<DataValue> {
771 match value {
772 LoroValue::Null => Some(DataValue::Null),
773 LoroValue::Bool(inner) => Some(DataValue::bool(*inner)),
774 LoroValue::I64(inner) => Some(DataValue::int(*inner)),
775 LoroValue::Double(inner) => Some(DataValue::float(*inner)),
776 LoroValue::String(inner) => Some(DataValue::text(inner.to_string())),
777 LoroValue::Binary(inner) => Some(DataValue::bytes(inner.to_vec())),
778 LoroValue::Map(fields) => {
779 // Only a map carrying the attachment tag is a value this layer
780 // knows. Anything else is read as absent, which is exactly what a
781 // build predating attachments does with this same map: it has no
782 // arm for it and falls through to the wildcard below. That is the
783 // compatibility story, and it is worth stating because it is the
784 // reason a reference is a map rather than an encoded string. A
785 // string would replicate to an older build as text and be shown
786 // to a person as a hash.
787 match fields.get(ATTACHMENT_TAG_KEY) {
788 Some(LoroValue::String(tag)) if tag.as_str() == ATTACHMENT_TAG => {}
789 _ => return None,
790 }
791 let hash = match fields.get(ATTACHMENT_HASH_KEY) {
792 Some(LoroValue::String(hash)) => hash.to_string(),
793 _ => return None,
794 };
795 let size = match fields.get(ATTACHMENT_SIZE_KEY) {
796 Some(LoroValue::I64(size)) if *size >= 0 => *size as u64,
797 _ => return None,
798 };
799 // Present-but-wrong is refused, absent is fine. These two are
800 // optional, so a missing key is an ordinary reference with no
801 // display text; a key holding something that is not text is a
802 // reference that disagrees with this version's shape, and
803 // reading it as though the field were simply missing would have
804 // two members render one delta differently. Absent is the one
805 // answer every reader already agrees on.
806 let text_field = |key: &str| match fields.get(key) {
807 Some(LoroValue::String(value)) => Ok(Some(value.to_string())),
808 None => Ok(None),
809 Some(_) => Err(()),
810 };
811 let (Ok(name), Ok(mime)) = (
812 text_field(ATTACHMENT_NAME_KEY),
813 text_field(ATTACHMENT_MIME_KEY),
814 ) else {
815 return None;
816 };
817 // Checked on the way out, not only on the way in. A local write
818 // is validated at the operation, but a reference can also arrive
819 // inside a peer's delta, and nothing on that path has looked at
820 // it: the engine merges bytes, it does not know what an
821 // attachment is. A malformed reference reads as absent, which is
822 // what an implementation without attachments does with it
823 // anyway.
824 if crate::validate_attachment(&hash, size, name.as_deref(), mime.as_deref()).is_err() {
825 return None;
826 }
827 Some(DataValue::Attachment {
828 hash,
829 size,
830 name,
831 mime,
832 })
833 }
834 // Nested containers are not part of the v1 value model: a value that
835 // is neither a scalar nor a reference reads as absent rather than as
836 // a lossy conversion.
837 _ => None,
838 }
839}
840
841#[cfg(test)]
842mod engine_value_tests {
843 use super::*;
844
845 /// Stand in for a build that predates attachments.
846 ///
847 /// Such a build has no `LoroValue::Map` arm at all, so every map falls
848 /// through to the wildcard. This asserts the same outcome from the other
849 /// direction: a map this build does not recognise reads as absent. The
850 /// two are the same path, and it is the path that makes an attachment
851 /// safe to write into a space whose other members are on an older
852 /// release. Had a reference been an encoded string instead of a map,
853 /// those members would read a hash as text and show it to a person.
854 const TEST_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
855
856 /// A map that is a complete attachment apart from its tag.
857 ///
858 /// Every field the parse needs is present and well formed, so the only
859 /// thing standing between this value and being read as an attachment is
860 /// the tag gate. That is deliberate: cases missing a hash are refused by
861 /// the hash check no matter what the gate does, and a test built from
862 /// those passes with the gate deleted.
863 fn tagged(tag: Option<&str>) -> LoroValue {
864 let mut fields = vec![
865 (ATTACHMENT_HASH_KEY.to_string(), LoroValue::from(TEST_HASH)),
866 (ATTACHMENT_SIZE_KEY.to_string(), LoroValue::from(4096i64)),
867 ];
868 if let Some(tag) = tag {
869 fields.push((ATTACHMENT_TAG_KEY.to_string(), LoroValue::from(tag)));
870 }
871 LoroValue::Map(fields.into())
872 }
873
874 #[test]
875 fn a_map_without_the_attachment_tag_reads_as_absent() {
876 let cases = [
877 // No tag at all, which is what a nested container's deep value
878 // looks like.
879 tagged(None),
880 // A tag naming something this build does not know, which is what
881 // a future reference kind looks like arriving here.
882 tagged(Some("something-later")),
883 // Close enough to be a typo, far enough to be a different value.
884 tagged(Some("Attachment")),
885 ];
886 for case in cases {
887 assert_eq!(
888 from_engine_value(&case),
889 None,
890 "{case:?} must read as absent"
891 );
892 }
893 // The control: the same map, correctly tagged, IS read. Without this
894 // the test above passes for a parser that reads nothing at all.
895 assert!(matches!(
896 from_engine_value(&tagged(Some(ATTACHMENT_TAG))),
897 Some(DataValue::Attachment { .. })
898 ));
899 }
900
901 #[test]
902 fn an_attachment_without_an_address_reads_as_absent() {
903 // Separate from the tag gate on purpose, so each refusal is pinned
904 // by a case that isolates it. Everything but the hash is present
905 // and well formed, for the same reason `tagged` carries a hash:
906 // a fixture missing two things is refused by whichever check runs
907 // first and says nothing about the other.
908 let no_hash = LoroValue::Map(
909 vec![
910 (
911 ATTACHMENT_TAG_KEY.to_string(),
912 LoroValue::from(ATTACHMENT_TAG),
913 ),
914 (ATTACHMENT_SIZE_KEY.to_string(), LoroValue::from(4096i64)),
915 ]
916 .into(),
917 );
918 assert_eq!(from_engine_value(&no_hash), None);
919 }
920
921 #[test]
922 fn a_display_field_of_the_wrong_type_makes_the_reference_absent() {
923 // Present-but-wrong is not absent. A key holding something that is
924 // not text is a reference disagreeing with this version's shape,
925 // and reading it as though the key were merely missing would leave
926 // two members rendering one delta differently: one an attachment
927 // with no name, the other whatever its own reader made of it.
928 // Absent is the one answer every reader already agrees on, and it
929 // is what an unknown value kind gets.
930 for wrong in [ATTACHMENT_NAME_KEY, ATTACHMENT_MIME_KEY] {
931 let value = LoroValue::Map(
932 vec![
933 (
934 ATTACHMENT_TAG_KEY.to_string(),
935 LoroValue::from(ATTACHMENT_TAG),
936 ),
937 (ATTACHMENT_HASH_KEY.to_string(), LoroValue::from(TEST_HASH)),
938 (ATTACHMENT_SIZE_KEY.to_string(), LoroValue::from(4096i64)),
939 (wrong.to_string(), LoroValue::from(5i64)),
940 ]
941 .into(),
942 );
943 assert_eq!(
944 from_engine_value(&value),
945 None,
946 "a {wrong} field of the wrong type must make the whole reference absent"
947 );
948 }
949
950 // The control: the same map with that field spelled as text IS
951 // read, so what the cases above pin is the type and not the key.
952 let good = LoroValue::Map(
953 vec![
954 (
955 ATTACHMENT_TAG_KEY.to_string(),
956 LoroValue::from(ATTACHMENT_TAG),
957 ),
958 (ATTACHMENT_HASH_KEY.to_string(), LoroValue::from(TEST_HASH)),
959 (ATTACHMENT_SIZE_KEY.to_string(), LoroValue::from(4096i64)),
960 (ATTACHMENT_NAME_KEY.to_string(), LoroValue::from("plan.pdf")),
961 ]
962 .into(),
963 );
964 assert!(matches!(
965 from_engine_value(&good),
966 Some(DataValue::Attachment { .. })
967 ));
968 }
969
970 #[test]
971 fn a_field_this_version_does_not_know_is_ignored() {
972 // The other half of the same rule, and the one that keeps a later
973 // version able to add an optional field without a flag day. A
974 // change that alters what a reference MEANS cannot be a new field
975 // for exactly this reason: it would be silently ignored here.
976 let mut fields = vec![
977 (
978 ATTACHMENT_TAG_KEY.to_string(),
979 LoroValue::from(ATTACHMENT_TAG),
980 ),
981 (ATTACHMENT_HASH_KEY.to_string(), LoroValue::from(TEST_HASH)),
982 (ATTACHMENT_SIZE_KEY.to_string(), LoroValue::from(4096i64)),
983 ];
984 fields.push(("something_later".to_string(), LoroValue::from("v2 field")));
985 assert!(matches!(
986 from_engine_value(&LoroValue::Map(fields.into())),
987 Some(DataValue::Attachment { .. })
988 ));
989 }
990
991 #[test]
992 fn an_attachment_survives_the_engine_conversion_both_ways() {
993 let reference = DataValue::Attachment {
994 hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
995 size: 4096,
996 name: Some("plan.pdf".to_string()),
997 mime: Some("application/pdf".to_string()),
998 };
999 let engine = to_engine_value(reference.clone());
1000 assert_eq!(from_engine_value(&engine), Some(reference));
1001 }
1002
1003 #[test]
1004 fn a_negative_size_is_refused_rather_than_wrapped() {
1005 // The engine's integer is signed and this crate's is not. A value
1006 // that could only arrive from a corrupted or hostile encoding reads
1007 // as absent rather than as a size near u64::MAX, which is what a
1008 // cast would produce and what a fetch would then try to allocate.
1009 let engine = LoroValue::Map(
1010 vec![
1011 (
1012 ATTACHMENT_TAG_KEY.to_string(),
1013 LoroValue::from(ATTACHMENT_TAG),
1014 ),
1015 (
1016 ATTACHMENT_HASH_KEY.to_string(),
1017 LoroValue::from(
1018 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1019 ),
1020 ),
1021 (ATTACHMENT_SIZE_KEY.to_string(), LoroValue::from(-1i64)),
1022 ]
1023 .into(),
1024 );
1025 assert_eq!(from_engine_value(&engine), None);
1026 }
1027}