diff options
| author | tv <tv@krebsco.de> | 2026-11-02 10:35:38 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-02 10:35:38 +0100 |
| commit | 55d33b0f0bc3aa4497842355684da2efa51a6011 (patch) | |
| tree | 8ec904b40f95f44bed71a60641b497b41adbb6a1 /src/document/store.rs | |
| parent | 58d574166fef1246f367009546358703e16d7f7e (diff) | |
api: types are sticky
Diffstat (limited to 'src/document/store.rs')
| -rw-r--r-- | src/document/store.rs | 249 |
1 files changed, 216 insertions, 33 deletions
diff --git a/src/document/store.rs b/src/document/store.rs index 4dd9d02..07f2e1a 100644 --- a/src/document/store.rs +++ b/src/document/store.rs @@ -9,7 +9,7 @@ use serde::Serialize; use serde_json::Value as JsonValue; use std::collections::BTreeSet; -use super::typed::{self, JsonForm, Kind, Val}; +use super::typed::{self, JsonForm, Kind, TimestampFormat, Val}; /// Store paths touched by one batch of document changes (`AppState::change_events`). #[derive(Debug)] @@ -103,6 +103,10 @@ pub(crate) enum StoreError { /// An increment of something that isn't a counter (`increment_path`). #[error("not a counter")] NotACounter, + /// A write that would change the type of what it writes over, where that isn't + /// wanted (`Writing::retype`). + #[error("would change {0}")] + Retyping(Retyped), /// A read at heads the document doesn't have (`at::At`). #[error("this node doesn't have these heads (yet)")] UnknownHeads, @@ -632,19 +636,27 @@ pub(crate) fn write_path( path: &[String], value: &JsonValue, ) -> Result<(), StoreError> { - write_val_path(doc, path, &from_json(value)?) + write_val_path( + doc, + path, + &from_json(value)?, + Writing::sticky(TimestampFormat::default()), + ) + .map(|_| ()) } /// [`write_path`] for a value of any type (`typed::Val`): from typed JSON, or a scalar -/// of a type JSON doesn't have. +/// of a type JSON doesn't have. Returns the places whose type the write changed. pub(crate) fn write_val_path( doc: &mut impl Transactable, path: &[String], value: &Val, -) -> Result<(), StoreError> { + mut writing: Writing, +) -> Result<Vec<Retyped>, StoreError> { let (leaf, parents) = path.split_last().ok_or(StoreError::NotFound)?; + writing.path = parents.to_vec(); match parent_for_write(doc, parents)? { - Parent::Maps(maps) => update_key(doc, &maps, leaf, value)?, + Parent::Maps(maps) => update_key(doc, &maps, leaf, value, &mut writing)?, Parent::List(list) => { let index = list_index(leaf).ok_or(StoreError::NotFound)?; let length = doc.length(&list); @@ -652,13 +664,139 @@ pub(crate) fn write_val_path( return Err(StoreError::NotFound); } if index < length { - update_item(doc, &list, index, value)?; + update_item(doc, &list, index, value, &mut writing)?; } else { write_value(doc, &list, Slot::Index(index), value)?; } } } - Ok(()) + Ok(writing.retyped) +} + +/// How a write treats the types of what it writes over. +pub(crate) struct Writing { + /// Types are sticky: the value comes as plain JSON, which has fewer types than + /// Automerge, and where it is the JSON form of a value of the type there (a string + /// that reads as a timestamp, as base64, a number without a fraction over a float, + /// an unsigned integer or a counter, a string over a text), it is written as that + /// type (`stuck`). Typed JSON and a type's own media type say the type themselves. + pub(crate) sticky: bool, + /// How sticky timestamps are written in JSON (`typed::TimestampFormat::json`). + pub(crate) timestamps: TimestampFormat, + /// Whether the write may change the type of what it writes over; if not, one that + /// would is refused (`Retyping`). + pub(crate) retype: bool, + path: Vec<String>, + retyped: Vec<Retyped>, +} + +impl Writing { + pub(crate) fn sticky(timestamps: TimestampFormat) -> Self { + Self::new(true, timestamps, true) + } + + pub(crate) fn new(sticky: bool, timestamps: TimestampFormat, retype: bool) -> Self { + Self { + sticky, + timestamps, + retype, + path: Vec::new(), + retyped: Vec::new(), + } + } + + /// `kind` is written over `held` at the place at `segment`, if `held` is another + /// kind: noted, or with `retype` off, refused. + fn retyping(&mut self, segment: &str, held: Kind, kind: Kind) -> Result<(), StoreError> { + if held == kind { + return Ok(()); + } + let mut path = self.path.clone(); + path.push(segment.to_owned()); + let retyped = Retyped { + path, + from: held, + to: kind, + }; + if !self.retype { + return Err(StoreError::Retyping(retyped)); + } + self.retyped.push(retyped); + Ok(()) + } +} + +/// A place whose type a write changed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Retyped { + pub(crate) path: Vec<String>, + pub(crate) from: Kind, + pub(crate) to: Kind, +} + +impl std::fmt::Display for Retyped { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}: {} -> {}", + self.path.join("/"), + self.from.name(), + self.to.name() + ) + } +} + +/// What a value held is stored as. +fn kind_of(held: &Value<'_>) -> Kind { + match held { + Value::Object(ObjType::Map | ObjType::Table) => Kind::Map, + Value::Object(ObjType::List) => Kind::List, + Value::Object(ObjType::Text) => Kind::Text, + Value::Scalar(scalar) => Kind::of(scalar), + } +} + +/// `value` written as the type of `held`, where it is a JSON form of a value of it +/// (`Writing::sticky`); None where it isn't (it is written as it is). +fn stuck(held: &Value<'_>, value: &Val, timestamps: TimestampFormat) -> Option<Val> { + let Val::Scalar(scalar) = value else { + return None; + }; + let held = match held { + Value::Object(ObjType::Text) => { + return match scalar { + ScalarValue::Str(text) => Some(Val::Text(text.to_string())), + _ => None, + }; + } + Value::Object(_) => return None, + Value::Scalar(held) => held.as_ref(), + }; + let stuck = match (held, scalar) { + (ScalarValue::Timestamp(_), ScalarValue::Str(text)) => { + ScalarValue::Timestamp(timestamps.read(text)?) + } + (ScalarValue::Timestamp(_), ScalarValue::Int(_) | ScalarValue::F64(_)) + if matches!(timestamps, TimestampFormat::Unix | TimestampFormat::UnixMs) => + { + let number = match scalar { + ScalarValue::Int(i) => i.to_string(), + ScalarValue::F64(f) => f.to_string(), + _ => return None, + }; + ScalarValue::Timestamp(timestamps.read(&number)?) + } + (ScalarValue::Bytes(_), ScalarValue::Str(text)) => { + use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; + ScalarValue::Bytes(BASE64.decode(text.as_bytes()).ok()?) + } + (ScalarValue::Uint(_), ScalarValue::Int(i)) => ScalarValue::Uint(u64::try_from(*i).ok()?), + (ScalarValue::F64(_), ScalarValue::Int(i)) => ScalarValue::F64(*i as f64), + (ScalarValue::F64(_), ScalarValue::Uint(u)) => ScalarValue::F64(*u as f64), + (ScalarValue::Counter(_), ScalarValue::Int(i)) => ScalarValue::counter(*i), + _ => return None, + }; + Some(Val::Scalar(stuck)) } fn from_json(value: &JsonValue) -> Result<Val, StoreError> { @@ -689,17 +827,40 @@ enum Update { Anew, } -fn update_of(holds: &[(Value<'static>, ObjId)], value: &Val) -> Update { - let [(held, id)] = holds else { - return Update::Anew; +/// What updating the place at `segment`, holding `holds`, to `value` comes to, with the +/// value to write there: `value`, or as the type there (`stuck`). A change of type is +/// noted (`Writing::retyping`). +fn update_of<'v>( + holds: &[(Value<'static>, ObjId)], + value: &'v Val, + segment: &str, + writing: &mut Writing, +) -> Result<(Update, std::borrow::Cow<'v, Val>), StoreError> { + use std::borrow::Cow; + let Some((held, _)) = holds.first() else { + return Ok((Update::Anew, Cow::Borrowed(value))); }; - match (held, value) { - (Value::Object(ObjType::Map), Val::Map(_)) => Update::Map(id.clone()), - (Value::Object(ObjType::List), Val::List(_)) => Update::List(id.clone()), - (Value::Object(ObjType::Text), Val::Text(_)) => Update::Text(id.clone()), - (Value::Scalar(held), Val::Scalar(scalar)) if held.as_ref() == scalar => Update::Same, - _ => Update::Anew, - } + let value = match writing + .sticky + .then(|| stuck(held, value, writing.timestamps)) + .flatten() + { + Some(stuck) => Cow::Owned(stuck), + None => Cow::Borrowed(value), + }; + let update = match (holds, &*value) { + ([(Value::Object(ObjType::Map), id)], Val::Map(_)) => Update::Map(id.clone()), + ([(Value::Object(ObjType::List), id)], Val::List(_)) => Update::List(id.clone()), + ([(Value::Object(ObjType::Text), id)], Val::Text(_)) => Update::Text(id.clone()), + ([(Value::Scalar(held), _)], Val::Scalar(scalar)) if held.as_ref() == scalar => { + Update::Same + } + _ => { + writing.retyping(segment, kind_of(held), value.kind())?; + Update::Anew + } + }; + Ok((update, value)) } /// Updates `key` of the merged maps `maps` to `value`. @@ -708,18 +869,23 @@ fn update_key( maps: &[ObjId], key: &str, value: &Val, + writing: &mut Writing, ) -> Result<(), StoreError> { - match update_of(&candidates(doc, maps, key)?, value) { + let (update, value) = update_of(&candidates(doc, maps, key)?, value, key, writing)?; + writing.path.push(key.to_owned()); + let done = match update { Update::Same => Ok(()), - Update::Map(map) => update_map(doc, &map, value), - Update::List(list) => update_list(doc, &list, value), - Update::Text(text) => update_text(doc, &text, value), + Update::Map(map) => update_map(doc, &map, &value, writing), + Update::List(list) => update_list(doc, &list, &value, writing), + Update::Text(text) => update_text(doc, &text, &value), Update::Anew => { // Cleared in every merged map, or an old map would merge into the new value. delete_in_all(doc, maps, key)?; - write_value(doc, &maps[0], Slot::Key(key), value) + write_value(doc, &maps[0], Slot::Key(key), &value) } - } + }; + writing.path.pop(); + done } /// Updates the item at `index` of `list` (which has it) to `value`. @@ -728,17 +894,29 @@ fn update_item( list: &ObjId, index: usize, value: &Val, + writing: &mut Writing, ) -> Result<(), StoreError> { - match update_of(&candidates(doc, std::slice::from_ref(list), index)?, value) { + let segment = index.to_string(); + let holds = candidates(doc, std::slice::from_ref(list), index)?; + let (update, value) = update_of(&holds, value, &segment, writing)?; + writing.path.push(segment); + let done = match update { Update::Same => Ok(()), - Update::Map(map) => update_map(doc, &map, value), - Update::List(inner) => update_list(doc, &inner, value), - Update::Text(text) => update_text(doc, &text, value), - Update::Anew => write_value(doc, list, Slot::At(index), value), - } + Update::Map(map) => update_map(doc, &map, &value, writing), + Update::List(inner) => update_list(doc, &inner, &value, writing), + Update::Text(text) => update_text(doc, &text, &value), + Update::Anew => write_value(doc, list, Slot::At(index), &value), + }; + writing.path.pop(); + done } -fn update_map(doc: &mut impl Transactable, map: &ObjId, value: &Val) -> Result<(), StoreError> { +fn update_map( + doc: &mut impl Transactable, + map: &ObjId, + value: &Val, + writing: &mut Writing, +) -> Result<(), StoreError> { let Val::Map(entries) = value else { unreachable!("a map is updated to a map"); }; @@ -750,19 +928,24 @@ fn update_map(doc: &mut impl Transactable, map: &ObjId, value: &Val) -> Result<( } } for (key, value) in entries { - update_key(doc, maps, key, value)?; + update_key(doc, maps, key, value, writing)?; } Ok(()) } -fn update_list(doc: &mut impl Transactable, list: &ObjId, value: &Val) -> Result<(), StoreError> { +fn update_list( + doc: &mut impl Transactable, + list: &ObjId, + value: &Val, + writing: &mut Writing, +) -> Result<(), StoreError> { let Val::List(items) = value else { unreachable!("a list is updated to a list"); }; let length = doc.length(list); for (index, item) in items.iter().enumerate() { if index < length { - update_item(doc, list, index, item)?; + update_item(doc, list, index, item, writing)?; } else { write_value(doc, list, Slot::Index(index), item)?; } |
