diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cdca2f2..1efb8ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Version history +## Unreleased + +- Add exact `Text`/`Array` owner validation with `StickyIndex.resolve()` and reject a mismatched + owner in sequence-backed `get_index()` calls. +- Return Python `ValueError`/`None` outcomes for malformed or unresolvable sticky indices instead + of propagating Rust panics. + ## 0.14.4 - Bump `yrs` to v0.27.4. diff --git a/docs/usage.md b/docs/usage.md index 261d4a79..f2ae24a5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -84,6 +84,33 @@ After receiving the other user's update, if no special care is taken, machine A In other words, their document states will diverge, and thus users won't collaborate on the same document anymore. CRDTs ensure that documents don't diverge, their shared documents will eventually have the same state. It will arbitrary be "ab" or "ba", but it will be the same on both machines. +## Sticky indices + +A sticky index tracks a position in a `Text` or `Array` while edits move its current numeric +index. It can be serialized for another process and resolved later: + +```py +from pycrdt import Assoc, Doc, StickyIndex, Text + +doc = Doc() +text = doc.get("text", type=Text) +text += "abc" + +encoded = text.sticky_index(1, Assoc.AFTER).encode() +position = StickyIndex.decode(encoded) +assert position.resolve(text) == 1 +``` + +`resolve(sequence)` validates the exact shared type returned in the resolved Yrs offset. It returns +`None` when the position belongs to a sibling, its owner was deleted, or the position is not yet +available in the sequence's document. Resolution does not change the document. + +Passing a sequence to `decode()` or `from_json()` associates that validation target with the sticky +index. In that form, `get_index()` remains convenient for existing callers but raises `ValueError` +if the position cannot be resolved against that sequence; it never returns an offset belonging to a +sibling. A sticky index deserialized without a sequence can still use the existing +`get_index(transaction)` form, which resolves without owner validation. + ## Transactions Every change to a shared data happens in a document transaction, and there can only be one transaction at a time. Pycrdt offers two methods for creating transactions: diff --git a/python/pycrdt/_pycrdt.pyi b/python/pycrdt/_pycrdt.pyi index b3f5938a..6150d3c5 100644 --- a/python/pycrdt/_pycrdt.pyi +++ b/python/pycrdt/_pycrdt.pyi @@ -158,6 +158,9 @@ class Text: def diff(self, txn: Transaction) -> list[tuple[Any, dict[str, Any] | None]]: """Returns a sequence of formatted chunks.""" + def sticky_index(self, txn: Transaction, index: int, assoc: int) -> "StickyIndex": + """Creates a sticky index at the given position.""" + def observe(self, callback: Callable[[TextEvent], None]) -> Subscription: """Subscribes a callback to be called with the shared text change event. Returns a subscription that can be used to unsubscribe.""" @@ -184,6 +187,9 @@ class Array: def to_json(self, txn: Transaction) -> str: """Returns a JSON representation of the current array.""" + def sticky_index(self, txn: Transaction, index: int, assoc: int) -> "StickyIndex": + """Creates a sticky index at the given position.""" + def observe(self, callback: Callable[[TextEvent], None]) -> Subscription: """Subscribes a callback to be called with the array change event. Returns a subscription that can be used to unsubscribe.""" @@ -600,7 +606,8 @@ class StackItem(Generic[MetaT]): """ class StickyIndex: - def get_offset(self, txn: Transaction) -> int: ... + def get_offset(self, txn: Transaction) -> int | None: ... + def resolve(self, txn: Transaction, sequence: Text | Array) -> int | None: ... def encode(self) -> bytes: ... def to_json_string(self) -> str: ... def get_assoc(self) -> int: ... diff --git a/python/pycrdt/_sticky_index.py b/python/pycrdt/_sticky_index.py index 2566c63c..69cf471d 100644 --- a/python/pycrdt/_sticky_index.py +++ b/python/pycrdt/_sticky_index.py @@ -53,19 +53,48 @@ def get_index(self, transaction: Transaction | None = None) -> int: Raises: RuntimeError: No transaction was provided and no shared type was associated with the deserialized sticky index. + ValueError: The sticky index cannot be resolved, or its resolved owner is not + the associated shared type. """ if transaction is not None: _txn = transaction._txn assert _txn is not None - return self._sticky_index.get_offset(_txn) + if self._sequence is None: + index = self._sticky_index.get_offset(_txn) + elif self._sequence.is_integrated: + index = self._sticky_index.resolve(_txn, self._sequence.integrated) + else: + index = None + elif self._sequence is not None: + index = self.resolve(self._sequence) + else: + raise RuntimeError("No transaction available") + + if index is None: + raise ValueError("Sticky index cannot be resolved") + return index + + def resolve(self, sequence: Sequence) -> int | None: + """ + Resolve the current index only if it belongs to an exact shared type. + + Resolution does not mutate the document. A position owned by a sibling shared type, + a deleted shared type, or data that is not yet available in the document is not resolved. - if self._sequence is not None: - with self._sequence.doc.transaction() as txn: - _txn = txn._txn - assert _txn is not None - return self._sticky_index.get_offset(_txn) + Args: + sequence: The [Array][pycrdt.Array] or [Text][pycrdt.Text] against which to validate + the resolved owner. - raise RuntimeError("No transaction available") + Returns: + The current index, or `None` if the position cannot be resolved against `sequence`. + """ + if not sequence.is_integrated: + return None + + with sequence.doc.transaction() as txn: + _txn = txn._txn + assert _txn is not None + return self._sticky_index.resolve(_txn, sequence.integrated) @property def assoc(self) -> Assoc: @@ -105,6 +134,9 @@ def new(cls, sequence: Sequence, index: int, assoc: Assoc) -> Self: Returns: The sticky index. + + Raises: + ValueError: The index is outside the sequence. """ with sequence.doc.transaction() as txn: self = cls(sequence.integrated.sticky_index(txn._txn, index, assoc), sequence) @@ -117,12 +149,15 @@ def decode(cls, data: bytes, sequence: Sequence | None = None) -> Self: Args: data: The binary data to get the sticky index from. - sequence: The [Array][pycrdt.Array] or [Text][pycrdt.Text] the sticky index belongs to. - If not provided, a [Transaction][pycrdt.Transaction] will be needed when getting - the index. + sequence: The [Array][pycrdt.Array] or [Text][pycrdt.Text] against which the resolved + owner will be validated. If not provided, a [Transaction][pycrdt.Transaction] + will be needed when getting the index. Returns: The decoded sticky index. + + Raises: + ValueError: The binary data is malformed. """ self = cls(decode_sticky_index(data), sequence) return self @@ -134,12 +169,15 @@ def from_json(cls, data: dict, sequence: Sequence | None = None) -> Self: Args: data: The JSON dictionary to get the sticky index from. - sequence: The [Array][pycrdt.Array] or [Text][pycrdt.Text] the sticky index belongs to. - If not provided, a [Transaction][pycrdt.Transaction] will be needed when getting - the index. + sequence: The [Array][pycrdt.Array] or [Text][pycrdt.Text] against which the resolved + owner will be validated. If not provided, a [Transaction][pycrdt.Transaction] + will be needed when getting the index. Returns: The deserialized sticky index. + + Raises: + ValueError: The JSON data is malformed. """ self = cls(get_sticky_index_from_json_string(json.dumps(data)), sequence) return self diff --git a/src/array.rs b/src/array.rs index 3db80614..708176ce 100644 --- a/src/array.rs +++ b/src/array.rs @@ -9,7 +9,6 @@ use yrs::{ Assoc, DeepObservable, Doc as _Doc, - IndexedSequence, Observable, TransactionMut, XmlFragmentPrelim, @@ -147,8 +146,10 @@ impl Array { 0 => _assoc = Assoc::After, _ => _assoc = Assoc::Before, } - let sticky_index = self.array.sticky_index(t, index, _assoc); - let s: Py = Py::new(py, StickyIndex::from(sticky_index))?; + let sticky_index = + StickyIndex::from_sequence(t, &self.array, index, self.array.len(t), _assoc) + .ok_or_else(|| PyValueError::new_err("Index out of range"))?; + let s: Py = Py::new(py, sticky_index)?; Ok(s) } diff --git a/src/sticky_index.rs b/src/sticky_index.rs index 7d2617be..59fd9044 100644 --- a/src/sticky_index.rs +++ b/src/sticky_index.rs @@ -1,77 +1,282 @@ +use crate::Transaction; +use crate::array::Array; +use crate::text::Text; +use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError}; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyString}; -use std::cell::RefCell; -use yrs::{StickyIndex as _StickyIndex, Assoc}; use yrs::updates::decoder::Decode; use yrs::updates::encoder::Encode; -use crate::Transaction; +use yrs::{ + ArrayRef, Assoc, IndexedSequence, Offset, ReadTxn, StickyIndex as _StickyIndex, TextRef, +}; + +trait SequenceOwner { + fn owns(&self, offset: &Offset) -> bool; +} + +impl SequenceOwner for TextRef { + fn owns(&self, offset: &Offset) -> bool { + std::ptr::eq(offset.branch.as_ref(), self.as_ref()) + } +} + +impl SequenceOwner for ArrayRef { + fn owns(&self, offset: &Offset) -> bool { + std::ptr::eq(offset.branch.as_ref(), self.as_ref()) + } +} + +fn resolve_offset(sticky_index: &_StickyIndex, txn: &T, sequence: &S) -> Option +where + T: ReadTxn, + S: SequenceOwner, +{ + let offset = sticky_index.get_offset(txn)?; + if offset.branch.is_deleted() || !sequence.owns(&offset) { + None + } else { + Some(offset.index) + } +} #[pyclass(unsendable)] pub struct StickyIndex { - sticky_index: RefCell>, - assoc: Assoc, + sticky_index: _StickyIndex, } -impl From> for StickyIndex { - fn from(sticky_index: Option<_StickyIndex>) -> Self { - let s: _StickyIndex = unsafe {std::mem::transmute(sticky_index.clone())}; - StickyIndex { sticky_index: RefCell::from(Some(s)), assoc: sticky_index.unwrap().assoc } +impl From<_StickyIndex> for StickyIndex { + fn from(sticky_index: _StickyIndex) -> Self { + StickyIndex { sticky_index } } } -impl From<&[u8]> for StickyIndex { - fn from(data: &[u8]) -> Self { - let sticky_index = _StickyIndex::decode_v1(data).unwrap(); - let s: _StickyIndex = unsafe {std::mem::transmute(sticky_index.clone())}; - StickyIndex { sticky_index: RefCell::from(Some(s)), assoc: sticky_index.assoc } +impl StickyIndex { + pub(crate) fn from_sequence( + txn: &T, + sequence: &S, + index: u32, + len: u32, + assoc: Assoc, + ) -> Option + where + T: ReadTxn, + S: IndexedSequence, + { + if index > len { + return None; + } + sequence + .sticky_index(txn, index, assoc) + .or_else(|| { + (index == len && assoc == Assoc::After) + .then(|| _StickyIndex::from_type(txn, sequence, assoc)) + }) + .map(StickyIndex::from) } -} -impl From<&str> for StickyIndex { - fn from(data: &str) -> Self { - let sticky_index = serde_json::from_str::<_StickyIndex>(data).unwrap(); - let s: _StickyIndex = unsafe {std::mem::transmute(sticky_index.clone())}; - StickyIndex { sticky_index: RefCell::from(Some(s)), assoc: sticky_index.assoc } + fn decode(data: &[u8]) -> Result { + _StickyIndex::decode_v1(data) + .map(StickyIndex::from) + .map_err(|error| format!("Cannot decode sticky index: {error}")) + } + + fn from_json_string(data: &str) -> Result { + serde_json::from_str::<_StickyIndex>(data) + .map(StickyIndex::from) + .map_err(|error| format!("Cannot decode sticky index JSON: {error}")) } } #[pymethods] impl StickyIndex { - pub fn get_offset(&self, txn: &mut Transaction) -> u32 { - let mut t0 = txn.transaction(); - let t1 = t0.as_mut().unwrap(); - let t = t1.as_ref(); - self.sticky_index.borrow_mut().as_mut().unwrap().get_offset(t).unwrap().index + pub fn get_offset(&self, txn: &mut Transaction) -> PyResult> { + let mut transaction = txn.transaction(); + let transaction = transaction + .as_mut() + .ok_or_else(|| PyRuntimeError::new_err("No current transaction"))?; + Ok(self + .sticky_index + .get_offset(transaction.as_ref()) + .map(|offset| offset.index)) + } + + pub fn resolve( + &self, + txn: &mut Transaction, + sequence: &Bound<'_, PyAny>, + ) -> PyResult> { + let mut transaction = txn.transaction(); + let transaction = transaction + .as_mut() + .ok_or_else(|| PyRuntimeError::new_err("No current transaction"))?; + let transaction = transaction.as_ref(); + + if let Ok(text) = sequence.cast::() { + let text = text.try_borrow()?; + Ok(resolve_offset(&self.sticky_index, transaction, &text.text)) + } else if let Ok(array) = sequence.cast::() { + let array = array.try_borrow()?; + Ok(resolve_offset( + &self.sticky_index, + transaction, + &array.array, + )) + } else { + Err(PyTypeError::new_err("sequence must be an Array or Text")) + } } pub fn encode(&self) -> Py { - let encoded = self.sticky_index.borrow_mut().as_mut().unwrap().encode_v1(); + let encoded = self.sticky_index.encode_v1(); Python::attach(|py| PyBytes::new(py, &encoded).into()) } - pub fn to_json_string(&self) -> Py { - let encoded = serde_json::to_string(self.sticky_index.borrow_mut().as_mut().unwrap()).unwrap(); - Python::attach(|py| PyString::new(py, &encoded).into()) + pub fn to_json_string(&self) -> PyResult> { + let encoded = serde_json::to_string(&self.sticky_index).map_err(|error| { + PyValueError::new_err(format!("Cannot encode sticky index JSON: {error}")) + })?; + Ok(Python::attach(|py| PyString::new(py, &encoded).into())) } pub fn get_assoc(&self) -> i8 { - let _assoc: i8; - match self.assoc { - Assoc::After => _assoc = 0, - _ => _assoc = -1, + match self.sticky_index.assoc { + Assoc::After => 0, + Assoc::Before => -1, } - _assoc } } #[pyfunction] -pub fn decode_sticky_index<'py>(data: &Bound<'_, PyBytes>) -> StickyIndex { - let data: &[u8] = data.as_bytes(); - StickyIndex::from(data) +pub fn decode_sticky_index(data: &Bound<'_, PyBytes>) -> PyResult { + StickyIndex::decode(data.as_bytes()).map_err(PyValueError::new_err) } #[pyfunction] -pub fn get_sticky_index_from_json_string<'py>(data: &Bound<'_, PyString>) -> StickyIndex { - let data: &str = data.to_str().unwrap(); - StickyIndex::from(data) +pub fn get_sticky_index_from_json_string(data: &Bound<'_, PyString>) -> PyResult { + let data = data.to_str()?; + StickyIndex::from_json_string(data).map_err(PyValueError::new_err) +} + +#[cfg(test)] +mod tests { + use super::{StickyIndex, resolve_offset}; + use yrs::types::text::TextPrelim; + use yrs::updates::encoder::Encode; + use yrs::{ + Array, Assoc, ClientID, Doc, ID, IndexedSequence, Map, ReadTxn, + StickyIndex as _StickyIndex, Text, Transact, + }; + + fn check_positions(txn: &T, sequence: &S, len: u32) + where + T: ReadTxn, + S: yrs::IndexedSequence + super::SequenceOwner, + { + let indexes = if len == 0 { vec![0] } else { vec![0, 1, len] }; + for assoc in [Assoc::After, Assoc::Before] { + for index in indexes.iter().copied() { + let sticky_index = StickyIndex::from_sequence(txn, sequence, index, len, assoc) + .expect("position should be valid"); + assert_eq!( + resolve_offset(&sticky_index.sticky_index, txn, sequence), + Some(index) + ); + } + } + } + + #[test] + fn resolves_text_owner_and_rejects_sibling() { + let doc = Doc::with_client_id(1); + let owner = doc.get_or_insert_text("owner"); + let sibling = doc.get_or_insert_text("sibling"); + let mut txn = doc.transact_mut(); + owner.insert(&mut txn, 0, "abc"); + sibling.insert(&mut txn, 0, "abc"); + + for assoc in [Assoc::After, Assoc::Before] { + let sticky_index = owner.sticky_index(&txn, 1, assoc).unwrap(); + assert_eq!(resolve_offset(&sticky_index, &txn, &owner), Some(1)); + assert_eq!(resolve_offset(&sticky_index, &txn, &sibling), None); + } + } + + #[test] + fn resolves_array_owner_and_rejects_sibling() { + let doc = Doc::with_client_id(1); + let owner = doc.get_or_insert_array("owner"); + let sibling = doc.get_or_insert_array("sibling"); + let mut txn = doc.transact_mut(); + owner.insert_range(&mut txn, 0, [1, 2, 3]); + sibling.insert_range(&mut txn, 0, [1, 2, 3]); + + for assoc in [Assoc::After, Assoc::Before] { + let sticky_index = owner.sticky_index(&txn, 1, assoc).unwrap(); + assert_eq!(resolve_offset(&sticky_index, &txn, &owner), Some(1)); + assert_eq!(resolve_offset(&sticky_index, &txn, &sibling), None); + } + } + + #[test] + fn resolves_empty_start_interior_and_end_positions() { + let doc = Doc::with_client_id(1); + let empty_text = doc.get_or_insert_text("empty_text"); + let empty_array = doc.get_or_insert_array("empty_array"); + let text = doc.get_or_insert_text("text"); + let array = doc.get_or_insert_array("array"); + let mut txn = doc.transact_mut(); + text.insert(&mut txn, 0, "abc"); + array.insert_range(&mut txn, 0, [1, 2, 3]); + + check_positions(&txn, &empty_text, 0); + check_positions(&txn, &empty_array, 0); + check_positions(&txn, &text, 3); + check_positions(&txn, &array, 3); + for assoc in [Assoc::After, Assoc::Before] { + assert!(StickyIndex::from_sequence(&txn, &text, 4, 3, assoc).is_none()); + assert!(StickyIndex::from_sequence(&txn, &array, 4, 3, assoc).is_none()); + } + } + + #[test] + fn rejects_deleted_and_unresolvable_positions() { + let doc = Doc::with_client_id(1); + let root = doc.get_or_insert_map("root"); + let mut txn = doc.transact_mut(); + let text = root.insert(&mut txn, "text", TextPrelim::new("abc")); + let sticky_index = text.sticky_index(&txn, 1, Assoc::After).unwrap(); + root.remove(&mut txn, "text"); + + assert_eq!(resolve_offset(&sticky_index, &txn, &text), None); + + let unknown = _StickyIndex::from_id(ID::new(ClientID::new(999), 0), Assoc::After); + assert_eq!(unknown.get_offset(&txn), None); + assert_eq!(resolve_offset(&unknown, &txn, &text), None); + } + + #[test] + fn malformed_encodings_return_errors() { + assert!(StickyIndex::decode(&[]).is_err()); + assert!(StickyIndex::decode(&[u8::MAX]).is_err()); + assert!(StickyIndex::from_json_string("{}").is_err()); + } + + #[test] + fn serialization_and_raw_offset_behavior_remain_compatible() { + let doc = Doc::with_client_id(1); + let text = doc.get_or_insert_text("text"); + let mut txn = doc.transact_mut(); + text.insert(&mut txn, 0, "abc"); + let sticky_index = text.sticky_index(&txn, 1, Assoc::Before).unwrap(); + + let binary = sticky_index.encode_v1(); + let decoded = StickyIndex::decode(&binary).expect("binary should decode"); + assert_eq!(decoded.sticky_index.get_offset(&txn).unwrap().index, 1); + assert_eq!(decoded.sticky_index.assoc, Assoc::Before); + + let json = serde_json::to_string(&sticky_index).unwrap(); + let decoded = StickyIndex::from_json_string(&json).expect("JSON should decode"); + assert_eq!(decoded.sticky_index.get_offset(&txn).unwrap().index, 1); + assert_eq!(decoded.sticky_index.assoc, Assoc::Before); + } } diff --git a/src/text.rs b/src/text.rs index e7bd2546..4479d14e 100644 --- a/src/text.rs +++ b/src/text.rs @@ -4,7 +4,6 @@ use pyo3::types::{PyDict, PyIterator, PyList, PyString, PyTuple}; use yrs::{ Assoc, GetString, - IndexedSequence, Observable, TextRef, Text as _Text, @@ -175,8 +174,10 @@ impl Text { 0 => _assoc = Assoc::After, _ => _assoc = Assoc::Before, } - let sticky_index = self.text.sticky_index(t, index, _assoc); - let s: Py = Py::new(py, StickyIndex::from(sticky_index))?; + let sticky_index = + StickyIndex::from_sequence(t, &self.text, index, self.text.len(t), _assoc) + .ok_or_else(|| pyo3::exceptions::PyValueError::new_err("Index out of range"))?; + let s: Py = Py::new(py, sticky_index)?; Ok(s) } diff --git a/tests/test_sticky_index.py b/tests/test_sticky_index.py new file mode 100644 index 00000000..dd747634 --- /dev/null +++ b/tests/test_sticky_index.py @@ -0,0 +1,227 @@ +import pytest +from pycrdt import Array, Assoc, Doc, Map, StickyIndex, Text + +SEQUENCE_CASES = [ + pytest.param(Text, "abc", id="text"), + pytest.param(Array, ["a", "b", "c"], id="array"), +] +EMPTY_SEQUENCE_CASES = [ + pytest.param(Text, "", id="text"), + pytest.param(Array, [], id="array"), +] + + +@pytest.mark.parametrize(("sequence_type", "value"), SEQUENCE_CASES) +@pytest.mark.parametrize("assoc", [Assoc.AFTER, Assoc.BEFORE]) +@pytest.mark.parametrize("index", [0, 1, 3], ids=["start", "interior", "end"]) +def test_resolve_owner_at_each_position(sequence_type, value, assoc: Assoc, index: int): + doc = Doc(client_id=1) + sequence = sequence_type(value) + doc["owner"] = sequence + state = doc.get_state() + + sticky_index = sequence.sticky_index(index, assoc) + assert sticky_index.resolve(sequence) == index + assert sticky_index.get_index() == index + + decoded = StickyIndex.decode(sticky_index.encode(), sequence) + assert decoded.resolve(sequence) == index + assert decoded.get_index() == index + assert doc.get_state() == state + + +@pytest.mark.parametrize(("sequence_type", "value"), SEQUENCE_CASES) +@pytest.mark.parametrize("assoc", [Assoc.AFTER, Assoc.BEFORE]) +def test_resolve_owner_across_replica_and_fresh_wrapper(sequence_type, value, assoc: Assoc): + source = Doc(client_id=1) + source_root = source.get("root", type=Map) + owner = sequence_type(value) + source_root["owner"] = owner + encoded = owner.sticky_index(2, assoc).encode() + + replica = Doc(client_id=2) + replica.apply_update(source.get_update()) + replica_root = replica.get("root", type=Map) + replica_owner = replica_root["owner"] + decoded = StickyIndex.decode(encoded, replica_owner) + + assert decoded.resolve(replica_owner) == 2 + assert decoded.resolve(replica_root["owner"]) == 2 + assert decoded.get_index() == 2 + + +@pytest.mark.parametrize(("sequence_type", "value"), SEQUENCE_CASES) +def test_nested_end_anchor_resolves_after_decode_and_across_replica(sequence_type, value): + source = Doc(client_id=1) + source_root = source.get("root", type=Map) + owner = sequence_type(value) + source_root["owner"] = owner + start_encoded = owner.sticky_index(0, Assoc.AFTER).encode() + end_encoded = owner.sticky_index(len(value), Assoc.AFTER).encode() + + fresh_owner = source_root["owner"] + assert StickyIndex.decode(start_encoded, fresh_owner).resolve(fresh_owner) == 0 + decoded = StickyIndex.decode(end_encoded, fresh_owner) + assert decoded.resolve(fresh_owner) == len(value) + assert decoded.get_index() == len(value) + with source.transaction() as txn: + assert StickyIndex.decode(end_encoded).get_index(txn) == len(value) + + sibling = sequence_type(value) + source_root["sibling"] = sibling + decoded = StickyIndex.decode(end_encoded, sibling) + assert decoded.resolve(sibling) is None + with pytest.raises(ValueError, match="Sticky index cannot be resolved"): + decoded.get_index() + + replica = Doc(client_id=2) + replica.apply_update(source.get_update()) + replica_owner = replica.get("root", type=Map)["owner"] + assert StickyIndex.decode(start_encoded, replica_owner).resolve(replica_owner) == 0 + decoded = StickyIndex.decode(end_encoded, replica_owner) + assert decoded.resolve(replica_owner) == len(value) + assert decoded.get_index() == len(value) + + +@pytest.mark.parametrize(("sequence_type", "value"), EMPTY_SEQUENCE_CASES) +@pytest.mark.parametrize("assoc", [Assoc.AFTER, Assoc.BEFORE]) +def test_resolve_empty_sequence(sequence_type, value, assoc: Assoc): + doc = Doc(client_id=1) + sequence = sequence_type(value) + doc["owner"] = sequence + + sticky_index = sequence.sticky_index(0, assoc) + decoded = StickyIndex.from_json(sticky_index.to_json(), sequence) + + assert decoded.resolve(sequence) == 0 + assert decoded.get_index() == 0 + + +@pytest.mark.parametrize(("sequence_type", "value"), SEQUENCE_CASES) +@pytest.mark.parametrize("assoc", [Assoc.AFTER, Assoc.BEFORE]) +@pytest.mark.parametrize("serialization", ["binary", "json"]) +def test_reject_sibling_owner(sequence_type, value, assoc: Assoc, serialization: str): + doc = Doc(client_id=1) + root = doc.get("root", type=Map) + sibling = sequence_type(value) + owner = sequence_type(value) + root["sibling"] = sibling + root["owner"] = owner + sticky_index = owner.sticky_index(2, assoc) + + if serialization == "binary": + decoded = StickyIndex.decode(sticky_index.encode(), sibling) + else: + decoded = StickyIndex.from_json(sticky_index.to_json(), sibling) + + state = doc.get_state() + assert decoded.resolve(sibling) is None + assert decoded.resolve(owner) == 2 + with pytest.raises(ValueError, match="Sticky index cannot be resolved"): + decoded.get_index() + with doc.transaction() as txn: + with pytest.raises(ValueError, match="Sticky index cannot be resolved"): + decoded.get_index(txn) + assert doc.get_state() == state + + +@pytest.mark.parametrize("assoc", [Assoc.AFTER, Assoc.BEFORE]) +def test_reject_cross_kind_owner(assoc: Assoc): + doc = Doc(client_id=1) + root = doc.get("root", type=Map) + text = Text("abc") + array = Array(["a", "b", "c"]) + root["text"] = text + root["array"] = array + + for owner, sibling in ((text, array), (array, text)): + decoded = StickyIndex.decode(owner.sticky_index(2, assoc).encode(), sibling) + assert decoded.resolve(sibling) is None + with pytest.raises(ValueError, match="Sticky index cannot be resolved"): + decoded.get_index() + + +@pytest.mark.parametrize(("sequence_type", "value"), SEQUENCE_CASES) +def test_reject_deleted_owner(sequence_type, value): + doc = Doc(client_id=1) + root = doc.get("root", type=Map) + owner = sequence_type(value) + root["owner"] = owner + sticky_index = owner.sticky_index(1, Assoc.AFTER) + + del root["owner"] + + assert sticky_index.resolve(owner) is None + with pytest.raises(ValueError, match="Sticky index cannot be resolved"): + sticky_index.get_index() + + +@pytest.mark.parametrize(("sequence_type", "value"), EMPTY_SEQUENCE_CASES) +def test_reject_detached_sequence(sequence_type, value): + doc = Doc(client_id=1) + owner = doc.get("owner", type=sequence_type) + sticky_index = owner.sticky_index(0, Assoc.BEFORE) + detached = sequence_type(value) + decoded = StickyIndex.decode(sticky_index.encode(), detached) + + assert sticky_index.resolve(detached) is None + with doc.transaction() as txn: + with pytest.raises(ValueError, match="Sticky index cannot be resolved"): + decoded.get_index(txn) + + +def test_unresolvable_position_returns_python_error_or_none(): + doc = Doc(client_id=1) + text = doc.get("text", type=Text) + data = {"item": {"client": 999, "clock": 0}, "assoc": 0} + sticky_index = StickyIndex.from_json(data, text) + + assert sticky_index.resolve(text) is None + with pytest.raises(ValueError, match="Sticky index cannot be resolved"): + sticky_index.get_index() + + sticky_index = StickyIndex.from_json(data) + with doc.transaction() as txn: + with pytest.raises(ValueError, match="Sticky index cannot be resolved"): + sticky_index.get_index(txn) + + +@pytest.mark.parametrize("data", [b"", b"\xff"]) +def test_malformed_binary_raises_value_error(data: bytes): + with pytest.raises(ValueError, match="Cannot decode sticky index"): + StickyIndex.decode(data) + + +@pytest.mark.parametrize("data", [{}, {"item": "invalid", "assoc": 0}]) +def test_malformed_json_raises_value_error(data: dict): + with pytest.raises(ValueError, match="Cannot decode sticky index JSON"): + StickyIndex.from_json(data) + + +@pytest.mark.parametrize(("sequence_type", "value"), SEQUENCE_CASES) +@pytest.mark.parametrize("assoc", [Assoc.AFTER, Assoc.BEFORE]) +def test_out_of_range_position_raises_value_error(sequence_type, value, assoc: Assoc): + doc = Doc(client_id=1) + sequence = sequence_type(value) + doc["owner"] = sequence + + with pytest.raises(ValueError, match="Index out of range"): + sequence.sticky_index(4, assoc) + + +@pytest.mark.parametrize(("sequence_type", "value"), SEQUENCE_CASES) +@pytest.mark.parametrize("serialization", ["binary", "json"]) +def test_get_index_with_transaction_remains_compatible(sequence_type, value, serialization: str): + doc = Doc(client_id=1) + sequence = sequence_type(value) + doc["owner"] = sequence + sticky_index = sequence.sticky_index(1, Assoc.BEFORE) + + if serialization == "binary": + decoded = StickyIndex.decode(sticky_index.encode()) + else: + decoded = StickyIndex.from_json(sticky_index.to_json()) + + assert decoded.assoc == Assoc.BEFORE + with doc.transaction() as txn: + assert decoded.get_index(txn) == 1