aboutsummaryrefslogtreecommitdiffstats
path: root/src/document/store.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-02 10:35:38 +0100
committertv <tv@krebsco.de>2026-11-02 10:35:38 +0100
commit55d33b0f0bc3aa4497842355684da2efa51a6011 (patch)
tree8ec904b40f95f44bed71a60641b497b41adbb6a1 /src/document/store.rs
parent58d574166fef1246f367009546358703e16d7f7e (diff)
api: types are sticky
Diffstat (limited to 'src/document/store.rs')
-rw-r--r--src/document/store.rs249
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)?;
}