Skip to main content

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}