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}