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