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. Caught, that
182    /// panic still leaves the document unusable for the process, and under
183    /// `panic = "abort"` there is nothing to catch: the only defense is
184    /// never handing such a blob 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, which poisons this handle
318    /// when it catches, and under `panic = "abort"` it does not exist at
319    /// all. It wraps the engine's apply step only. The header decode just
320    /// above it, like [`DataDoc::inspect`], runs outside it, so a panic
321    /// there unwinds to the caller and leaves this handle live.
322    ///
323    /// Returns whether the blob applied or is parked behind a missing
324    /// predecessor. Callers that can destroy history (compaction, log
325    /// truncation) must branch on it: see [`ImportOutcome`].
326    pub fn import(&mut self, bytes: &[u8]) -> DataResult<ImportOutcome> {
327        self.check_live()?;
328
329        // Reject a blob the engine cannot even describe before asking it to
330        // apply the contents. Checksum verification is on: it is cheap
331        // relative to import and catches truncation that got past framing.
332        if let Err(err) = LoroDoc::decode_import_blob_meta(bytes, true) {
333            return Err(DataError::Corrupt(err.to_string()));
334        }
335
336        let outcome = catch_unwind(AssertUnwindSafe(|| self.inner.import(bytes)));
337        match outcome {
338            // `pending` names the changes the engine took but cannot apply
339            // yet. Discarding it is how a gap in the delta log turns into
340            // silent loss, so it is reported rather than dropped.
341            Ok(Ok(status)) => Ok(match status.pending {
342                Some(range) if !range.is_empty() => ImportOutcome::Parked,
343                _ => ImportOutcome::Applied,
344            }),
345            Ok(Err(err)) => Err(DataError::Corrupt(err.to_string())),
346            Err(_) => {
347                // The engine may hold a poisoned lock now; the handle is done.
348                self.poisoned = true;
349                Err(DataError::Corrupt(
350                    "engine panicked while importing".to_string(),
351                ))
352            }
353        }
354    }
355
356    /// Judge a blob against this document without letting the engine touch
357    /// it.
358    ///
359    /// The whole point is that this runs *first*. Header decoding with the
360    /// checksum on is the cheap part; the verdicts it produces are what let
361    /// a caller refuse the two blob shapes that are known to end a process
362    /// rather than return an error.
363    pub fn inspect(&self, bytes: &[u8]) -> DataResult<BlobMeta> {
364        self.check_live()?;
365        let meta = LoroDoc::decode_import_blob_meta(bytes, true)
366            .map_err(|err| DataError::Corrupt(err.to_string()))?;
367
368        let is_snapshot = matches!(
369            meta.mode,
370            EncodedBlobMode::Snapshot
371                | EncodedBlobMode::OutdatedSnapshot
372                | EncodedBlobMode::ShallowSnapshot
373        );
374        let already_applied = self.inner.oplog_vv().includes_vv(&meta.partial_end_vv);
375
376        // A document that has never been trimmed can take anything.
377        let shallow_since = self.inner.shallow_since_vv();
378        let spans_trimmed_history = if shallow_since.is_empty() {
379            false
380        } else if is_snapshot {
381            // A trimmed document cannot merge a branch at all: the ops the
382            // branch depends on were deleted here, and supplying them in
383            // the snapshot does not help, because they sit below this
384            // document's own base. The only snapshot it can take is one
385            // that supersedes it outright, so that is the test: everything
386            // held here has to be in there too. Anything less is a fork,
387            // and handing one over aborts the process exactly as a run of
388            // changes of the same shape does.
389            !meta.partial_end_vv.includes_vv(&self.inner.oplog_vv())
390        } else {
391            // If this document's history starts at or after where the blob's
392            // does, the blob describes changes on top of ops that are no
393            // longer here.
394            shallow_since.to_vv().includes_vv(&meta.partial_start_vv)
395        };
396
397        Ok(BlobMeta {
398            already_applied,
399            spans_trimmed_history,
400            is_snapshot,
401            change_count: meta.change_num,
402        })
403    }
404
405    /// Apply a blob that arrived from a peer.
406    ///
407    /// [`DataDoc::import`] exists for bytes that came back out of a sealed
408    /// record, where an AEAD tag has already established that this SDK wrote
409    /// them. Nothing of the sort is true here: a peer is authenticated, which
410    /// says who sent the bytes and nothing at all about their shape. So this
411    /// path re-judges every blob with [`DataDoc::inspect`] and declines the
412    /// two shapes the engine is known to panic on, rather than relying on
413    /// the unwinding catch in [`DataDoc::import`]: it covers the apply step
414    /// only, poisons this handle when it fires, and does not exist in a host
415    /// built with `panic = "abort"`.
416    ///
417    /// Refusing is not a failure: a duplicate is the ladder keeping its
418    /// at-least-once promise, and a trimmed-history gap in a run of changes
419    /// is answered by asking for a snapshot. Both are recorded outcomes, not
420    /// errors. The same refusal on a snapshot is the exception: it means the
421    /// replicas have diverged past anything a frame can carry, and the
422    /// caller has nothing left to ask for.
423    pub fn import_remote(&mut self, bytes: &[u8]) -> DataResult<RemoteImport> {
424        let meta = self.inspect(bytes)?;
425        if meta.already_applied() {
426            return Ok(RemoteImport::AlreadyHave);
427        }
428        if meta.spans_trimmed_history() {
429            return Ok(RemoteImport::RefusedTrimmedHistory);
430        }
431        Ok(match self.import(bytes)? {
432            ImportOutcome::Applied => RemoteImport::Applied,
433            ImportOutcome::Parked => RemoteImport::Parked,
434        })
435    }
436
437    /// Encode what a replica at `from` is missing.
438    ///
439    /// `from` is a token that replica produced with [`DataDoc::version`].
440    /// A token this document cannot decode is treated as corrupt rather than
441    /// as "send everything": on the sync path the token is remote input, and
442    /// answering a malformed one with the whole document would make a garbled
443    /// byte an amplification lever.
444    pub fn export_since(&self, from: &VersionToken) -> DataResult<CatchUp> {
445        self.check_live()?;
446        let from_vv = VersionVector::decode(from.as_bytes())
447            .map_err(|err| DataError::Corrupt(err.to_string()))?;
448
449        if from_vv.includes_vv(&self.inner.oplog_vv()) {
450            return Ok(CatchUp::UpToDate);
451        }
452
453        // The mirror of the refusal in `import_remote`: if this document's
454        // history no longer reaches back to where the peer is, no run of
455        // updates can express the gap, and asking the engine for one is the
456        // same defect from the sending side.
457        let shallow_since = self.inner.shallow_since_vv();
458        if !shallow_since.is_empty() && !from_vv.includes_vv(&shallow_since.to_vv()) {
459            return Ok(CatchUp::NeedsSnapshot);
460        }
461
462        self.inner
463            .export(ExportMode::updates(&from_vv))
464            .map(CatchUp::Updates)
465            .map_err(|err| DataError::Engine(err.to_string()))
466    }
467
468    /// Declare everything currently in the document as already persisted.
469    ///
470    /// Called after loading a snapshot and its delta log, so the next commit
471    /// exports only genuinely new changes instead of re-emitting the history
472    /// that was just read off disk.
473    pub fn mark_persisted(&mut self) {
474        self.exported = self.inner.oplog_vv();
475    }
476
477    /// Commit pending edits and return the change they produced.
478    ///
479    /// `Ok(None)` means nothing had changed. The returned delta is exactly
480    /// the bytes needed to bring a replica at the previous version up to the
481    /// current one, which is what the delta log stores and what F3 will send.
482    pub fn commit(&mut self) -> DataResult<Option<Delta>> {
483        self.check_live()?;
484        self.inner.commit();
485
486        let current = self.inner.oplog_vv();
487        if current == self.exported {
488            return Ok(None);
489        }
490
491        let bytes = self
492            .inner
493            .export(ExportMode::updates(&self.exported))
494            .map_err(|err| DataError::Engine(err.to_string()))?;
495
496        self.previous_exported = Some(std::mem::replace(&mut self.exported, current));
497        self.commits_since_compaction = self.commits_since_compaction.saturating_add(1);
498        self.delta_bytes_since_measure = self.delta_bytes_since_measure.saturating_add(bytes.len());
499
500        Ok(Some(Delta {
501            bytes,
502            version: self.version(),
503        }))
504    }
505
506    /// Undo the bookkeeping of the last commit, so its change is handed out
507    /// again by the next one.
508    ///
509    /// For the caller whose write failed. The document keeps the edits (they
510    /// happened, and the application is looking at them); what is undone is
511    /// only the claim that someone has taken responsibility for persisting
512    /// them. Without this a failed write loses the change at the next
513    /// restart, silently, which is the worst shape a storage error can take.
514    ///
515    /// Safe to call spuriously: re-exporting a delta that did reach storage
516    /// costs one duplicate record, and duplicates are free by construction.
517    pub fn rewind_last_commit(&mut self) {
518        if let Some(previous) = self.previous_exported.take() {
519            self.exported = previous;
520            self.commits_since_compaction = self.commits_since_compaction.saturating_sub(1);
521        }
522    }
523
524    /// The current version of the document.
525    pub fn version(&self) -> VersionToken {
526        VersionToken(self.inner.oplog_vv().encode())
527    }
528
529    /// Encode the document compactly, discarding history.
530    ///
531    /// This is what a document record stores: a 10,000 operation document
532    /// compacts to under a kilobyte here, against tens of kilobytes of full
533    /// history, which is what keeps a document inside one sealed record and
534    /// a catch-up inside one message.
535    pub fn export_compacted(&self) -> DataResult<Vec<u8>> {
536        self.check_live()?;
537        let frontiers = self.inner.oplog_frontiers();
538        self.inner
539            .export(ExportMode::shallow_snapshot(&frontiers))
540            .map_err(|err| DataError::Engine(err.to_string()))
541    }
542
543    /// Encode the document with its full history.
544    ///
545    /// Part of the escape hatch: an application can always take this and
546    /// leave. Prefer [`DataDoc::export_compacted`] for storage and transfer.
547    pub fn export_raw(&self) -> DataResult<Vec<u8>> {
548        self.check_live()?;
549        self.inner
550            .export(ExportMode::Snapshot)
551            .map_err(|err| DataError::Engine(err.to_string()))
552    }
553
554    /// The document's current state as plain JSON.
555    ///
556    /// The second half of the escape hatch, and the one that does not
557    /// require the reader to know anything about this SDK at all.
558    pub fn export_json(&self) -> DataResult<String> {
559        self.check_live()?;
560        let value = self.inner.get_deep_value();
561        serde_json::to_string(&value).map_err(|err| DataError::Engine(err.to_string()))
562    }
563
564    /// Measure the compacted encoding and refresh the size bookkeeping.
565    pub fn measure(&mut self) -> DataResult<usize> {
566        let bytes = self.export_compacted()?.len();
567        self.last_measured_bytes = bytes;
568        self.delta_bytes_since_measure = 0;
569        Ok(bytes)
570    }
571
572    /// Check the document against the size cap, measuring only when the
573    /// running estimate says a breach is possible.
574    ///
575    /// Returns the verdict and, when a measurement happened, the true size.
576    pub fn check_size(&mut self) -> DataResult<(policy::SizeVerdict, Option<usize>)> {
577        if !policy::needs_size_check(self.last_measured_bytes, self.delta_bytes_since_measure) {
578            return Ok((policy::SizeVerdict::Ok, None));
579        }
580        let bytes = self.measure()?;
581        Ok((policy::size_verdict(bytes), Some(bytes)))
582    }
583
584    /// Statistics for compaction and cap decisions.
585    pub fn stats(&mut self) -> DataResult<DocStats> {
586        let compacted_bytes = self.measure()?;
587        Ok(DocStats {
588            compacted_bytes,
589            commits_since_compaction: self.commits_since_compaction,
590        })
591    }
592
593    /// Commits recorded since the last compaction.
594    pub fn commits_since_compaction(&self) -> u32 {
595        self.commits_since_compaction
596    }
597
598    /// Reset the compaction counter, after the owner has written a fresh
599    /// compacted record and dropped the folded delta records.
600    pub fn mark_compacted(&mut self, compacted_bytes: usize) {
601        self.commits_since_compaction = 0;
602        self.last_measured_bytes = compacted_bytes;
603        self.delta_bytes_since_measure = 0;
604    }
605
606    /// Adopt the bookkeeping of a document just loaded from storage.
607    ///
608    /// Without this a reopened document starts as though it were empty, and
609    /// the cheap size estimate then under-reports by the entire size of what
610    /// was loaded: a document already near the cap would look far from it
611    /// and sail past without ever triggering a real measurement. Passing the
612    /// record sizes in keeps the estimate an over-estimate, which is the
613    /// property the whole check depends on.
614    ///
615    /// `compacted_bytes` is the size of the stored snapshot and `log_bytes`
616    /// the total size of the delta records replayed on top of it.
617    pub fn restore_bookkeeping(&mut self, compacted_bytes: usize, log_bytes: usize, commits: u32) {
618        self.last_measured_bytes = compacted_bytes;
619        self.delta_bytes_since_measure = log_bytes;
620        self.commits_since_compaction = commits;
621    }
622
623    // ---- collections -------------------------------------------------
624
625    /// Set a key in a map collection.
626    pub fn map_set(&mut self, collection: &str, key: &str, value: DataValue) -> DataResult<()> {
627        self.check_live()?;
628        crate::validate_name(collection)?;
629        crate::validate_key(key)?;
630        crate::validate_value(&value)?;
631        let map = self.inner.get_map(collection);
632        map.insert(key, to_engine_value(value))
633            .map_err(|err| DataError::Engine(err.to_string()))
634    }
635
636    /// Remove a key from a map collection.
637    pub fn map_delete(&mut self, collection: &str, key: &str) -> DataResult<()> {
638        self.check_live()?;
639        crate::validate_name(collection)?;
640        let map = self.inner.get_map(collection);
641        map.delete(key)
642            .map_err(|err| DataError::Engine(err.to_string()))
643    }
644
645    /// Read a key from a map collection.
646    pub fn map_get(&self, collection: &str, key: &str) -> DataResult<Option<DataValue>> {
647        self.check_live()?;
648        crate::validate_name(collection)?;
649        let map = self.inner.get_map(collection);
650        Ok(map.get(key).and_then(|entry| match entry {
651            loro::ValueOrContainer::Value(value) => from_engine_value(&value),
652            loro::ValueOrContainer::Container(_) => None,
653        }))
654    }
655
656    /// Append to a list collection.
657    pub fn list_push(&mut self, collection: &str, value: DataValue) -> DataResult<()> {
658        self.check_live()?;
659        crate::validate_name(collection)?;
660        crate::validate_value(&value)?;
661        let list = self.inner.get_list(collection);
662        list.push(to_engine_value(value))
663            .map_err(|err| DataError::Engine(err.to_string()))
664    }
665
666    /// Insert into a list collection at `index`.
667    pub fn list_insert(
668        &mut self,
669        collection: &str,
670        index: usize,
671        value: DataValue,
672    ) -> DataResult<()> {
673        self.check_live()?;
674        crate::validate_name(collection)?;
675        crate::validate_value(&value)?;
676        let list = self.inner.get_list(collection);
677        let length = list.len();
678        if index > length {
679            return Err(DataError::OutOfRange {
680                position: index,
681                length,
682            });
683        }
684        list.insert(index, to_engine_value(value))
685            .map_err(|err| DataError::Engine(err.to_string()))
686    }
687
688    /// Delete `count` entries from a list collection at `index`.
689    pub fn list_delete(&mut self, collection: &str, index: usize, count: usize) -> DataResult<()> {
690        self.check_live()?;
691        crate::validate_name(collection)?;
692        let list = self.inner.get_list(collection);
693        let length = list.len();
694        if index.saturating_add(count) > length {
695            return Err(DataError::OutOfRange {
696                position: index.saturating_add(count),
697                length,
698            });
699        }
700        list.delete(index, count)
701            .map_err(|err| DataError::Engine(err.to_string()))
702    }
703
704    /// Number of entries in a list collection.
705    pub fn list_len(&self, collection: &str) -> DataResult<usize> {
706        self.check_live()?;
707        crate::validate_name(collection)?;
708        Ok(self.inner.get_list(collection).len())
709    }
710
711    /// Insert text into a text collection at a character position.
712    pub fn text_insert(&mut self, collection: &str, position: usize, text: &str) -> DataResult<()> {
713        self.check_live()?;
714        crate::validate_name(collection)?;
715        crate::validate_value_len(text.len())?;
716        let handle = self.inner.get_text(collection);
717        let length = handle.len_unicode();
718        if position > length {
719            return Err(DataError::OutOfRange { position, length });
720        }
721        handle
722            .insert(position, text)
723            .map_err(|err| DataError::Engine(err.to_string()))
724    }
725
726    /// Delete `count` characters from a text collection at `position`.
727    pub fn text_delete(
728        &mut self,
729        collection: &str,
730        position: usize,
731        count: usize,
732    ) -> DataResult<()> {
733        self.check_live()?;
734        crate::validate_name(collection)?;
735        let handle = self.inner.get_text(collection);
736        let length = handle.len_unicode();
737        if position.saturating_add(count) > length {
738            return Err(DataError::OutOfRange {
739                position: position.saturating_add(count),
740                length,
741            });
742        }
743        handle
744            .delete(position, count)
745            .map_err(|err| DataError::Engine(err.to_string()))
746    }
747
748    /// The current contents of a text collection.
749    pub fn text_value(&self, collection: &str) -> DataResult<String> {
750        self.check_live()?;
751        crate::validate_name(collection)?;
752        Ok(self.inner.get_text(collection).to_string())
753    }
754
755    /// Add to a counter collection. Negative values subtract.
756    pub fn counter_increment(&mut self, collection: &str, amount: f64) -> DataResult<()> {
757        self.check_live()?;
758        crate::validate_name(collection)?;
759        self.inner
760            .get_counter(collection)
761            .increment(amount)
762            .map_err(|err| DataError::Engine(err.to_string()))
763    }
764
765    /// The current value of a counter collection.
766    pub fn counter_value(&self, collection: &str) -> DataResult<f64> {
767        self.check_live()?;
768        crate::validate_name(collection)?;
769        Ok(self.inner.get_counter(collection).get_value())
770    }
771}
772
773impl std::fmt::Debug for DataDoc {
774    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
775        f.debug_struct("DataDoc")
776            .field("poisoned", &self.poisoned)
777            .field("commits_since_compaction", &self.commits_since_compaction)
778            .finish_non_exhaustive()
779    }
780}
781
782/// Field names of an attachment reference inside the engine.
783///
784/// Deliberately the same spelling as the serde representation and the JSON
785/// that crosses the FFI, so a document exported with [`DataDoc::export_json`]
786/// describes its own attachments in the same words the API uses. The export
787/// is the escape hatch for an application leaving this SDK, and an escape
788/// hatch that needs a decoder ring is not one.
789const ATTACHMENT_TAG_KEY: &str = "kind";
790const ATTACHMENT_TAG: &str = "attachment";
791const ATTACHMENT_HASH_KEY: &str = "hash";
792const ATTACHMENT_SIZE_KEY: &str = "size";
793const ATTACHMENT_NAME_KEY: &str = "name";
794const ATTACHMENT_MIME_KEY: &str = "mime";
795
796fn to_engine_value(value: DataValue) -> LoroValue {
797    match value {
798        DataValue::Null => LoroValue::Null,
799        DataValue::Bool { value } => LoroValue::from(value),
800        DataValue::Int { value } => LoroValue::from(value),
801        DataValue::Float { value } => LoroValue::from(value),
802        DataValue::Text { value } => LoroValue::from(value),
803        DataValue::Bytes { value } => LoroValue::from(value),
804        // A reference is one engine value, not a container, and that is the
805        // whole of the "replaced, never edited" property: a container would
806        // give two writers a way to merge half of one reference with half of
807        // another and produce a hash that addresses nothing.
808        DataValue::Attachment {
809            hash,
810            size,
811            name,
812            mime,
813        } => {
814            let mut fields: Vec<(String, LoroValue)> = vec![
815                (
816                    ATTACHMENT_TAG_KEY.to_string(),
817                    LoroValue::from(ATTACHMENT_TAG),
818                ),
819                (ATTACHMENT_HASH_KEY.to_string(), LoroValue::from(hash)),
820                (
821                    ATTACHMENT_SIZE_KEY.to_string(),
822                    // The engine has one integer type and it is signed.
823                    // `validate_attachment` refuses anything past
824                    // `MAX_ATTACHMENT_SIZE` at the operation that writes it,
825                    // and every write path runs it, so the saturation here is
826                    // a backstop rather than the bound: it exists because a
827                    // value this deep has no way to report a refusal, not
828                    // because the case is expected.
829                    LoroValue::from(i64::try_from(size).unwrap_or(i64::MAX)),
830                ),
831            ];
832            if let Some(name) = name {
833                fields.push((ATTACHMENT_NAME_KEY.to_string(), LoroValue::from(name)));
834            }
835            if let Some(mime) = mime {
836                fields.push((ATTACHMENT_MIME_KEY.to_string(), LoroValue::from(mime)));
837            }
838            LoroValue::Map(fields.into())
839        }
840    }
841}
842
843fn from_engine_value(value: &LoroValue) -> Option<DataValue> {
844    match value {
845        LoroValue::Null => Some(DataValue::Null),
846        LoroValue::Bool(inner) => Some(DataValue::bool(*inner)),
847        LoroValue::I64(inner) => Some(DataValue::int(*inner)),
848        LoroValue::Double(inner) => Some(DataValue::float(*inner)),
849        LoroValue::String(inner) => Some(DataValue::text(inner.to_string())),
850        LoroValue::Binary(inner) => Some(DataValue::bytes(inner.to_vec())),
851        LoroValue::Map(fields) => {
852            // Only a map carrying the attachment tag is a value this layer
853            // knows. Anything else is read as absent, which is exactly what a
854            // build predating attachments does with this same map: it has no
855            // arm for it and falls through to the wildcard below. That is the
856            // compatibility story, and it is worth stating because it is the
857            // reason a reference is a map rather than an encoded string. A
858            // string would replicate to an older build as text and be shown
859            // to a person as a hash.
860            match fields.get(ATTACHMENT_TAG_KEY) {
861                Some(LoroValue::String(tag)) if tag.as_str() == ATTACHMENT_TAG => {}
862                _ => return None,
863            }
864            let hash = match fields.get(ATTACHMENT_HASH_KEY) {
865                Some(LoroValue::String(hash)) => hash.to_string(),
866                _ => return None,
867            };
868            let size = match fields.get(ATTACHMENT_SIZE_KEY) {
869                Some(LoroValue::I64(size)) if *size >= 0 => *size as u64,
870                _ => return None,
871            };
872            // Present-but-wrong is refused, absent is fine. These two are
873            // optional, so a missing key is an ordinary reference with no
874            // display text; a key holding something that is not text is a
875            // reference that disagrees with this version's shape, and
876            // reading it as though the field were simply missing would have
877            // two members render one delta differently. Absent is the one
878            // answer every reader already agrees on.
879            let text_field = |key: &str| match fields.get(key) {
880                Some(LoroValue::String(value)) => Ok(Some(value.to_string())),
881                None => Ok(None),
882                Some(_) => Err(()),
883            };
884            let (Ok(name), Ok(mime)) = (
885                text_field(ATTACHMENT_NAME_KEY),
886                text_field(ATTACHMENT_MIME_KEY),
887            ) else {
888                return None;
889            };
890            // Checked on the way out, not only on the way in. A local write
891            // is validated at the operation, but a reference can also arrive
892            // inside a peer's delta, and nothing on that path has looked at
893            // it: the engine merges bytes, it does not know what an
894            // attachment is. A malformed reference reads as absent, which is
895            // what an implementation without attachments does with it
896            // anyway.
897            if crate::validate_attachment(&hash, size, name.as_deref(), mime.as_deref()).is_err() {
898                return None;
899            }
900            Some(DataValue::Attachment {
901                hash,
902                size,
903                name,
904                mime,
905            })
906        }
907        // Nested containers are not part of the v1 value model: a value that
908        // is neither a scalar nor a reference reads as absent rather than as
909        // a lossy conversion.
910        _ => None,
911    }
912}
913
914#[cfg(test)]
915mod engine_value_tests {
916    use super::*;
917
918    /// Stand in for a build that predates attachments.
919    ///
920    /// Such a build has no `LoroValue::Map` arm at all, so every map falls
921    /// through to the wildcard. This asserts the same outcome from the other
922    /// direction: a map this build does not recognise reads as absent. The
923    /// two are the same path, and it is the path that makes an attachment
924    /// safe to write into a space whose other members are on an older
925    /// release. Had a reference been an encoded string instead of a map,
926    /// those members would read a hash as text and show it to a person.
927    const TEST_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
928
929    /// A map that is a complete attachment apart from its tag.
930    ///
931    /// Every field the parse needs is present and well formed, so the only
932    /// thing standing between this value and being read as an attachment is
933    /// the tag gate. That is deliberate: cases missing a hash are refused by
934    /// the hash check no matter what the gate does, and a test built from
935    /// those passes with the gate deleted.
936    fn tagged(tag: Option<&str>) -> LoroValue {
937        let mut fields = vec![
938            (ATTACHMENT_HASH_KEY.to_string(), LoroValue::from(TEST_HASH)),
939            (ATTACHMENT_SIZE_KEY.to_string(), LoroValue::from(4096i64)),
940        ];
941        if let Some(tag) = tag {
942            fields.push((ATTACHMENT_TAG_KEY.to_string(), LoroValue::from(tag)));
943        }
944        LoroValue::Map(fields.into())
945    }
946
947    #[test]
948    fn a_map_without_the_attachment_tag_reads_as_absent() {
949        let cases = [
950            // No tag at all, which is what a nested container's deep value
951            // looks like.
952            tagged(None),
953            // A tag naming something this build does not know, which is what
954            // a future reference kind looks like arriving here.
955            tagged(Some("something-later")),
956            // Close enough to be a typo, far enough to be a different value.
957            tagged(Some("Attachment")),
958        ];
959        for case in cases {
960            assert_eq!(
961                from_engine_value(&case),
962                None,
963                "{case:?} must read as absent"
964            );
965        }
966        // The control: the same map, correctly tagged, IS read. Without this
967        // the test above passes for a parser that reads nothing at all.
968        assert!(matches!(
969            from_engine_value(&tagged(Some(ATTACHMENT_TAG))),
970            Some(DataValue::Attachment { .. })
971        ));
972    }
973
974    #[test]
975    fn an_attachment_without_an_address_reads_as_absent() {
976        // Separate from the tag gate on purpose, so each refusal is pinned
977        // by a case that isolates it. Everything but the hash is present
978        // and well formed, for the same reason `tagged` carries a hash:
979        // a fixture missing two things is refused by whichever check runs
980        // first and says nothing about the other.
981        let no_hash = LoroValue::Map(
982            vec![
983                (
984                    ATTACHMENT_TAG_KEY.to_string(),
985                    LoroValue::from(ATTACHMENT_TAG),
986                ),
987                (ATTACHMENT_SIZE_KEY.to_string(), LoroValue::from(4096i64)),
988            ]
989            .into(),
990        );
991        assert_eq!(from_engine_value(&no_hash), None);
992    }
993
994    #[test]
995    fn a_display_field_of_the_wrong_type_makes_the_reference_absent() {
996        // Present-but-wrong is not absent. A key holding something that is
997        // not text is a reference disagreeing with this version's shape,
998        // and reading it as though the key were merely missing would leave
999        // two members rendering one delta differently: one an attachment
1000        // with no name, the other whatever its own reader made of it.
1001        // Absent is the one answer every reader already agrees on, and it
1002        // is what an unknown value kind gets.
1003        for wrong in [ATTACHMENT_NAME_KEY, ATTACHMENT_MIME_KEY] {
1004            let value = LoroValue::Map(
1005                vec![
1006                    (
1007                        ATTACHMENT_TAG_KEY.to_string(),
1008                        LoroValue::from(ATTACHMENT_TAG),
1009                    ),
1010                    (ATTACHMENT_HASH_KEY.to_string(), LoroValue::from(TEST_HASH)),
1011                    (ATTACHMENT_SIZE_KEY.to_string(), LoroValue::from(4096i64)),
1012                    (wrong.to_string(), LoroValue::from(5i64)),
1013                ]
1014                .into(),
1015            );
1016            assert_eq!(
1017                from_engine_value(&value),
1018                None,
1019                "a {wrong} field of the wrong type must make the whole reference absent"
1020            );
1021        }
1022
1023        // The control: the same map with that field spelled as text IS
1024        // read, so what the cases above pin is the type and not the key.
1025        let good = LoroValue::Map(
1026            vec![
1027                (
1028                    ATTACHMENT_TAG_KEY.to_string(),
1029                    LoroValue::from(ATTACHMENT_TAG),
1030                ),
1031                (ATTACHMENT_HASH_KEY.to_string(), LoroValue::from(TEST_HASH)),
1032                (ATTACHMENT_SIZE_KEY.to_string(), LoroValue::from(4096i64)),
1033                (ATTACHMENT_NAME_KEY.to_string(), LoroValue::from("plan.pdf")),
1034            ]
1035            .into(),
1036        );
1037        assert!(matches!(
1038            from_engine_value(&good),
1039            Some(DataValue::Attachment { .. })
1040        ));
1041    }
1042
1043    #[test]
1044    fn a_field_this_version_does_not_know_is_ignored() {
1045        // The other half of the same rule, and the one that keeps a later
1046        // version able to add an optional field without a flag day. A
1047        // change that alters what a reference MEANS cannot be a new field
1048        // for exactly this reason: it would be silently ignored here.
1049        let mut fields = vec![
1050            (
1051                ATTACHMENT_TAG_KEY.to_string(),
1052                LoroValue::from(ATTACHMENT_TAG),
1053            ),
1054            (ATTACHMENT_HASH_KEY.to_string(), LoroValue::from(TEST_HASH)),
1055            (ATTACHMENT_SIZE_KEY.to_string(), LoroValue::from(4096i64)),
1056        ];
1057        fields.push(("something_later".to_string(), LoroValue::from("v2 field")));
1058        assert!(matches!(
1059            from_engine_value(&LoroValue::Map(fields.into())),
1060            Some(DataValue::Attachment { .. })
1061        ));
1062    }
1063
1064    #[test]
1065    fn an_attachment_survives_the_engine_conversion_both_ways() {
1066        let reference = DataValue::Attachment {
1067            hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
1068            size: 4096,
1069            name: Some("plan.pdf".to_string()),
1070            mime: Some("application/pdf".to_string()),
1071        };
1072        let engine = to_engine_value(reference.clone());
1073        assert_eq!(from_engine_value(&engine), Some(reference));
1074    }
1075
1076    #[test]
1077    fn a_negative_size_is_refused_rather_than_wrapped() {
1078        // The engine's integer is signed and this crate's is not. A value
1079        // that could only arrive from a corrupted or hostile encoding reads
1080        // as absent rather than as a size near u64::MAX, which is what a
1081        // cast would produce and what a fetch would then try to allocate.
1082        let engine = LoroValue::Map(
1083            vec![
1084                (
1085                    ATTACHMENT_TAG_KEY.to_string(),
1086                    LoroValue::from(ATTACHMENT_TAG),
1087                ),
1088                (
1089                    ATTACHMENT_HASH_KEY.to_string(),
1090                    LoroValue::from(
1091                        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1092                    ),
1093                ),
1094                (ATTACHMENT_SIZE_KEY.to_string(), LoroValue::from(-1i64)),
1095            ]
1096            .into(),
1097        );
1098        assert_eq!(from_engine_value(&engine), None);
1099    }
1100}