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}