aboutsummaryrefslogtreecommitdiffstats
path: root/src/document
diff options
context:
space:
mode:
Diffstat (limited to 'src/document')
-rw-r--r--src/document/store.rs212
-rw-r--r--src/document/typed.rs227
2 files changed, 308 insertions, 131 deletions
diff --git a/src/document/store.rs b/src/document/store.rs
index 0f1fd58..4dd9d02 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};
+use super::typed::{self, JsonForm, Kind, Val};
/// Store paths touched by one batch of document changes (`AppState::change_events`).
#[derive(Debug)]
@@ -333,7 +333,7 @@ fn resolved_to_json(
}
}
- Ok(JsonValue::Object(out))
+ Ok(typed::map_to_json(out, form))
}
fn object_to_json(
@@ -357,7 +357,7 @@ fn object_to_json(
}
Ok(JsonValue::Array(out))
}
- ObjType::Text => Ok(JsonValue::String(doc.text(id)?)),
+ ObjType::Text => Ok(typed::text_to_json(doc.text(id)?, form)),
ObjType::Table => Err(anyhow!("Automerge tables are not supported").into()),
}
}
@@ -632,6 +632,16 @@ pub(crate) fn write_path(
path: &[String],
value: &JsonValue,
) -> Result<(), StoreError> {
+ write_val_path(doc, path, &from_json(value)?)
+}
+
+/// [`write_path`] for a value of any type (`typed::Val`): from typed JSON, or a scalar
+/// of a type JSON doesn't have.
+pub(crate) fn write_val_path(
+ doc: &mut impl Transactable,
+ path: &[String],
+ value: &Val,
+) -> Result<(), StoreError> {
let (leaf, parents) = path.split_last().ok_or(StoreError::NotFound)?;
match parent_for_write(doc, parents)? {
Parent::Maps(maps) => update_key(doc, &maps, leaf, value)?,
@@ -651,67 +661,18 @@ pub(crate) fn write_path(
Ok(())
}
-/// [`write_path`] for a scalar (`typed::from_native`): one of a type JSON doesn't have,
-/// or doesn't tell apart (bytes, a timestamp, an unsigned integer, a counter).
-pub(crate) fn write_scalar_path(
- doc: &mut impl Transactable,
- path: &[String],
- scalar: &ScalarValue,
-) -> Result<(), StoreError> {
- let (leaf, parents) = path.split_last().ok_or(StoreError::NotFound)?;
- let same = |holds: &[(Value<'static>, ObjId)]| matches!(holds, [(Value::Scalar(held), _)] if held.as_ref() == scalar);
- match parent_for_write(doc, parents)? {
- Parent::Maps(maps) => {
- if !same(&candidates(doc, &maps, leaf.as_str())?) {
- delete_in_all(doc, &maps, leaf)?;
- put_scalar(doc, &maps[0], Slot::Key(leaf), scalar.clone())?;
- }
- }
- Parent::List(list) => {
- let index = list_index(leaf).ok_or(StoreError::NotFound)?;
- let length = doc.length(&list);
- if index > length {
- return Err(StoreError::NotFound);
- }
- if index == length {
- put_scalar(doc, &list, Slot::Index(index), scalar.clone())?;
- } else if !same(&candidates(doc, std::slice::from_ref(&list), index)?) {
- put_scalar(doc, &list, Slot::At(index), scalar.clone())?;
- }
- }
- }
- Ok(())
+fn from_json(value: &JsonValue) -> Result<Val, StoreError> {
+ Val::from_json(value).map_err(|error| anyhow!(error).into())
}
// A write makes its path read as the value written, changing only what differs: a map
// written over a map keeps its keys that stay (and what is inside them), and gets
// those it lacks and loses those the value doesn't have; a list written over a list
-// does the same item by item, by position; a scalar equal to the one there isn't
-// written again. So a write of a whole map, concurrent with one inside it elsewhere,
-// keeps the other unless it changes the same key. Where the path holds concurrent
-// values (a conflict), or one of another kind, it is written anew: the write ends the
-// conflict.
-
-/// What `value` would be stored as, if it is a scalar.
-pub(crate) fn json_scalar(value: &JsonValue) -> Result<Option<ScalarValue>, StoreError> {
- Ok(Some(match value {
- JsonValue::String(value) => ScalarValue::Str(value.as_str().into()),
- JsonValue::Bool(value) => ScalarValue::Boolean(*value),
- JsonValue::Number(value) => {
- if let Some(value) = value.as_i64() {
- ScalarValue::Int(value)
- } else if let Some(value) = value.as_u64() {
- ScalarValue::Uint(value)
- } else if let Some(value) = value.as_f64() {
- ScalarValue::F64(value)
- } else {
- return Err(anyhow!("Unsupported JSON number").into());
- }
- }
- JsonValue::Null => ScalarValue::Null,
- JsonValue::Object(_) | JsonValue::Array(_) => return Ok(None),
- }))
-}
+// does the same item by item, by position; a text written over a text is changed
+// where it differs (a diff, as splices); a scalar equal to the one there isn't written
+// again. So a write of a whole map, concurrent with one inside it elsewhere, keeps the
+// other unless it changes the same key. Where the path holds concurrent values (a
+// conflict), or one of another kind, it is written anew: the write ends the conflict.
/// What updating a place to a value comes to, given what it holds (all its concurrent
/// values).
@@ -722,22 +683,23 @@ enum Update {
Map(ObjId),
/// A list there, the only value: its items updated.
List(ObjId),
+ /// A text there, the only value: changed where it differs.
+ Text(ObjId),
/// Written anew.
Anew,
}
-fn update_of(holds: &[(Value<'static>, ObjId)], value: &JsonValue) -> Result<Update, StoreError> {
+fn update_of(holds: &[(Value<'static>, ObjId)], value: &Val) -> Update {
let [(held, id)] = holds else {
- return Ok(Update::Anew);
+ return Update::Anew;
};
- Ok(match (held, value) {
- (Value::Object(ObjType::Map), JsonValue::Object(_)) => Update::Map(id.clone()),
- (Value::Object(ObjType::List), JsonValue::Array(_)) => Update::List(id.clone()),
- (Value::Scalar(held), _) if json_scalar(value)?.as_ref() == Some(held.as_ref()) => {
- Update::Same
- }
+ 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,
- })
+ }
}
/// Updates `key` of the merged maps `maps` to `value`.
@@ -745,16 +707,17 @@ fn update_key(
doc: &mut impl Transactable,
maps: &[ObjId],
key: &str,
- value: &JsonValue,
+ value: &Val,
) -> Result<(), StoreError> {
- match update_of(&candidates(doc, maps, key)?, value)? {
+ match update_of(&candidates(doc, maps, key)?, value) {
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::Anew => {
// Cleared in every merged map, or an old map would merge into the new value.
delete_in_all(doc, maps, key)?;
- json_to_automerge(doc, &maps[0], key, value)
+ write_value(doc, &maps[0], Slot::Key(key), value)
}
}
}
@@ -764,44 +727,37 @@ fn update_item(
doc: &mut impl Transactable,
list: &ObjId,
index: usize,
- value: &JsonValue,
+ value: &Val,
) -> Result<(), StoreError> {
- match update_of(&candidates(doc, std::slice::from_ref(list), index)?, value)? {
+ match update_of(&candidates(doc, std::slice::from_ref(list), index)?, value) {
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),
}
}
-fn update_map(
- doc: &mut impl Transactable,
- map: &ObjId,
- value: &JsonValue,
-) -> Result<(), StoreError> {
- let JsonValue::Object(object) = value else {
- unreachable!("a map is updated to an object");
+fn update_map(doc: &mut impl Transactable, map: &ObjId, value: &Val) -> Result<(), StoreError> {
+ let Val::Map(entries) = value else {
+ unreachable!("a map is updated to a map");
};
let maps = std::slice::from_ref(map);
// The keys it loses first, then those it keeps or gets, each in key order.
for key in doc.keys(map).collect::<Vec<_>>() {
- if !object.contains_key(&key) {
+ if !entries.iter().any(|(kept, _)| *kept == key) {
doc.delete(map, key.as_str())?;
}
}
- for (key, value) in object {
+ for (key, value) in entries {
update_key(doc, maps, key, value)?;
}
Ok(())
}
-fn update_list(
- doc: &mut impl Transactable,
- list: &ObjId,
- value: &JsonValue,
-) -> Result<(), StoreError> {
- let JsonValue::Array(items) = value else {
- unreachable!("a list is updated to an array");
+fn update_list(doc: &mut impl Transactable, list: &ObjId, value: &Val) -> 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() {
@@ -817,6 +773,14 @@ fn update_list(
Ok(())
}
+fn update_text(doc: &mut impl Transactable, text: &ObjId, value: &Val) -> Result<(), StoreError> {
+ let Val::Text(new) = value else {
+ unreachable!("a text is updated to a text");
+ };
+ doc.update_text(text, new)?;
+ Ok(())
+}
+
/// What a write's last segment goes into.
enum Parent {
/// Merged maps (`[0]` takes new keys).
@@ -912,23 +876,23 @@ fn counter_at(doc: &impl ReadDoc, parent: &ObjId, prop: Prop) -> Result<i64, Sto
/// there on move up one; the list's length appends). An index past the end is
/// `PastTheEnd`; a parent that isn't a list `NotAList`; anything but an index there, or
/// no parent, `NotFound`.
+#[cfg(test)]
pub(crate) fn insert_path(
doc: &mut impl Transactable,
path: &[String],
value: &JsonValue,
) -> Result<(), StoreError> {
- let (list, index) = insertion(doc, path)?;
- write_value(doc, &list, Slot::Index(index), value)
+ insert_val_path(doc, path, &from_json(value)?)
}
-/// [`insert_path`] for a scalar (`typed::from_native`).
-pub(crate) fn insert_scalar_path(
+/// [`insert_path`] for a value of any type (`typed::Val`).
+pub(crate) fn insert_val_path(
doc: &mut impl Transactable,
path: &[String],
- scalar: &ScalarValue,
+ value: &Val,
) -> Result<(), StoreError> {
let (list, index) = insertion(doc, path)?;
- put_scalar(doc, &list, Slot::Index(index), scalar.clone())
+ write_value(doc, &list, Slot::Index(index), value)
}
/// The list an insert at `path` goes into, and where (`insert_path`).
@@ -1216,55 +1180,43 @@ fn write_value(
doc: &mut impl Transactable,
parent: &ObjId,
slot: Slot,
- value: &JsonValue,
+ value: &Val,
) -> Result<(), StoreError> {
match value {
- JsonValue::Object(_) | JsonValue::Array(_) => {
+ Val::Scalar(scalar) => put_scalar(doc, parent, slot, scalar.clone()),
+ tree => {
// A new tree in one go, not an operation at a time.
- let tree = hydrated(value)?;
+ let tree = hydrated(tree, doc.text_encoding());
let (prop, insert): (Prop, bool) = match slot {
Slot::Key(key) => (key.into(), false),
Slot::Index(index) => (index.into(), true),
Slot::At(index) => (index.into(), false),
};
doc.batch_create_object(parent, prop, &tree, insert)?;
- }
- scalar => {
- let scalar = json_scalar(scalar)?.expect("neither an object nor an array");
- put_scalar(doc, parent, slot, scalar)?;
+ Ok(())
}
}
-
- Ok(())
}
-/// `value` as Automerge holds it: maps, lists and scalars as `write_value` stores them.
-fn hydrated(value: &JsonValue) -> Result<automerge::hydrate::Value, StoreError> {
+/// `value` as Automerge holds it.
+fn hydrated(value: &Val, encoding: automerge::TextEncoding) -> automerge::hydrate::Value {
use automerge::hydrate;
- Ok(match value {
- JsonValue::Object(object) => {
- let mut map = std::collections::HashMap::new();
- for (key, value) in object {
- map.insert(key.clone(), hydrated(value)?);
- }
- hydrate::Value::Map(hydrate::Map::from(map))
- }
- JsonValue::Array(items) => {
- hydrate::Value::from(items.iter().map(hydrated).collect::<Result<Vec<_>, _>>()?)
- }
- scalar => {
- hydrate::Value::from(json_scalar(scalar)?.expect("neither an object nor an array"))
- }
- })
-}
-
-fn json_to_automerge(
- doc: &mut impl Transactable,
- parent: &ObjId,
- key: &str,
- value: &JsonValue,
-) -> Result<(), StoreError> {
- write_value(doc, parent, Slot::Key(key), value)
+ match value {
+ Val::Map(entries) => hydrate::Value::Map(hydrate::Map::from(
+ entries
+ .iter()
+ .map(|(key, value)| (key.clone(), hydrated(value, encoding)))
+ .collect::<std::collections::HashMap<_, _>>(),
+ )),
+ Val::List(items) => hydrate::Value::from(
+ items
+ .iter()
+ .map(|item| hydrated(item, encoding))
+ .collect::<Vec<_>>(),
+ ),
+ Val::Text(text) => hydrate::Value::text(encoding, text),
+ Val::Scalar(scalar) => hydrate::Value::Scalar(scalar.clone()),
+ }
}
fn scalar_to_json(scalar: ScalarValue, form: JsonForm) -> Result<JsonValue, StoreError> {
diff --git a/src/document/typed.rs b/src/document/typed.rs
index bca79b3..a542578 100644
--- a/src/document/typed.rs
+++ b/src/document/typed.rs
@@ -35,6 +35,8 @@ pub(crate) const FLOAT: &str = "application/vnd.continuum.float";
pub(crate) const BOOLEAN: &str = "application/vnd.continuum.boolean";
pub(crate) const COUNTER: &str = "application/vnd.continuum.counter";
pub(crate) const TIMESTAMP: &str = "application/vnd.continuum.timestamp";
+/// JSON whose scalars keep their types (`Val::from_typed_json`, `scalar_to_json`).
+pub(crate) const TYPED_JSON: &str = "application/vnd.continuum.typed+json";
impl Kind {
pub(crate) fn of(scalar: &ScalarValue) -> Self {
@@ -194,11 +196,66 @@ impl TimestampFormat {
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct JsonForm {
pub(crate) timestamps: TimestampFormat,
+ /// Typed JSON (`TYPED_JSON`): what plain JSON doesn't tell apart is tagged, so it
+ /// reads back as it is stored.
+ pub(crate) typed: bool,
+}
+
+// Typed JSON is plain JSON but for what plain JSON shows as another type: a single-key
+// object whose key is a tag names it. `{"$uint": 5}`, `{"$counter": 5}`,
+// `{"$timestamp": "2026-11-01T21:50:20.345Z"}` (in the timestamp format asked),
+// `{"$bytes": "<base64>"}`, `{"$float": 2}` (a float without a fraction, which JSON
+// numbers don't keep apart from integers in every language), `{"$text": "…"}` (an
+// Automerge text). A map whose only key starts with `$` is `{"$map": {…}}`, so that
+// nothing else reads as a tag.
+
+/// A map's single key that is a tag, or would be taken for one.
+pub(crate) fn tag_like(map: &serde_json::Map<String, JsonValue>) -> bool {
+ map.len() == 1 && map.keys().all(|key| key.starts_with('$'))
+}
+
+fn tagged(tag: &str, value: JsonValue) -> JsonValue {
+ let mut map = serde_json::Map::new();
+ map.insert(tag.to_owned(), value);
+ JsonValue::Object(map)
+}
+
+/// A map in `form` (`$map` around one that would read as a tag in typed JSON).
+pub(crate) fn map_to_json(map: serde_json::Map<String, JsonValue>, form: JsonForm) -> JsonValue {
+ if form.typed && tag_like(&map) {
+ tagged("$map", JsonValue::Object(map))
+ } else {
+ JsonValue::Object(map)
+ }
+}
+
+/// A text in `form`.
+pub(crate) fn text_to_json(text: String, form: JsonForm) -> JsonValue {
+ if form.typed {
+ tagged("$text", JsonValue::String(text))
+ } else {
+ JsonValue::String(text)
+ }
}
/// `scalar` in JSON: integers, floats and counters as numbers, bytes as base64,
-/// timestamps as `form` says.
+/// timestamps as `form` says; in typed JSON, tagged where plain JSON would show it as
+/// another type.
pub(crate) fn scalar_to_json(scalar: &ScalarValue, form: JsonForm) -> Option<JsonValue> {
+ if form.typed {
+ match scalar {
+ ScalarValue::Uint(u) => return Some(tagged("$uint", (*u).into())),
+ ScalarValue::Counter(c) => return Some(tagged("$counter", i64::from(c).into())),
+ ScalarValue::Timestamp(t) => {
+ return Some(tagged("$timestamp", form.timestamps.json(*t)));
+ }
+ ScalarValue::Bytes(b) => return Some(tagged("$bytes", BASE64.encode(b).into())),
+ ScalarValue::F64(f) if f.fract() == 0.0 => {
+ return Some(tagged("$float", serde_json::Number::from_f64(*f)?.into()));
+ }
+ _ => {}
+ }
+ }
Some(match scalar {
ScalarValue::Null => JsonValue::Null,
ScalarValue::Boolean(b) => JsonValue::Bool(*b),
@@ -316,3 +373,171 @@ pub(crate) fn param(params: &str, name: &str) -> Option<String> {
.then(|| value.trim().trim_matches('"').to_owned())
})
}
+
+/// A value to write (`store::write_val_path`), as Automerge stores it.
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) enum Val {
+ /// Its keys in order.
+ Map(Vec<(String, Val)>),
+ List(Vec<Val>),
+ /// An Automerge text.
+ Text(String),
+ Scalar(ScalarValue),
+}
+
+/// What a plain JSON `value` is stored as, if it is a scalar: an integer where it fits
+/// (signed, else unsigned), a float otherwise.
+pub(crate) fn json_scalar(value: &JsonValue) -> Result<Option<ScalarValue>, String> {
+ Ok(Some(match value {
+ JsonValue::String(value) => ScalarValue::Str(value.as_str().into()),
+ JsonValue::Bool(value) => ScalarValue::Boolean(*value),
+ JsonValue::Number(value) => {
+ if let Some(value) = value.as_i64() {
+ ScalarValue::Int(value)
+ } else if let Some(value) = value.as_u64() {
+ ScalarValue::Uint(value)
+ } else if let Some(value) = value.as_f64() {
+ ScalarValue::F64(value)
+ } else {
+ return Err(format!("unsupported JSON number {value}"));
+ }
+ }
+ JsonValue::Null => ScalarValue::Null,
+ JsonValue::Object(_) | JsonValue::Array(_) => return Ok(None),
+ }))
+}
+
+impl Val {
+ /// Plain JSON, as it is stored: maps, lists and scalars (`json_scalar`).
+ pub(crate) fn from_json(value: &JsonValue) -> Result<Val, String> {
+ Ok(match value {
+ JsonValue::Object(map) => Val::Map(
+ map.iter()
+ .map(|(key, value)| Ok((key.clone(), Val::from_json(value)?)))
+ .collect::<Result<_, String>>()?,
+ ),
+ JsonValue::Array(items) => {
+ Val::List(items.iter().map(Val::from_json).collect::<Result<_, _>>()?)
+ }
+ scalar => Val::Scalar(json_scalar(scalar)?.expect("a scalar")),
+ })
+ }
+
+ /// Typed JSON (`TYPED_JSON`): plain JSON, but tagged values as their tags say,
+ /// timestamps in `timestamps`. An object of one key that starts with `$` and is no
+ /// tag is an error.
+ pub(crate) fn from_typed_json(
+ value: &JsonValue,
+ timestamps: TimestampFormat,
+ ) -> Result<Val, String> {
+ let JsonValue::Object(map) = value else {
+ return match value {
+ JsonValue::Array(items) => Ok(Val::List(
+ items
+ .iter()
+ .map(|item| Val::from_typed_json(item, timestamps))
+ .collect::<Result<_, _>>()?,
+ )),
+ scalar => Val::from_json(scalar),
+ };
+ };
+ if !tag_like(map) {
+ return Ok(Val::Map(
+ map.iter()
+ .map(|(key, value)| Ok((key.clone(), Val::from_typed_json(value, timestamps)?)))
+ .collect::<Result<_, String>>()?,
+ ));
+ }
+ let (tag, value) = map.iter().next().expect("one key");
+ let integer = |value: &JsonValue| {
+ value
+ .as_i64()
+ .ok_or_else(|| format!("{tag}: not an integer: {value}"))
+ };
+ Ok(match tag.as_str() {
+ "$map" => match value {
+ JsonValue::Object(map) => Val::Map(
+ map.iter()
+ .map(|(key, value)| {
+ Ok((key.clone(), Val::from_typed_json(value, timestamps)?))
+ })
+ .collect::<Result<_, String>>()?,
+ ),
+ other => return Err(format!("$map: not an object: {other}")),
+ },
+ "$text" => Val::Text(
+ value
+ .as_str()
+ .ok_or_else(|| format!("$text: not a string: {value}"))?
+ .to_owned(),
+ ),
+ "$bytes" => Val::Scalar(ScalarValue::Bytes(
+ value
+ .as_str()
+ .and_then(|text| BASE64.decode(text).ok())
+ .ok_or_else(|| format!("$bytes: not base64: {value}"))?,
+ )),
+ "$uint" => {
+ Val::Scalar(ScalarValue::Uint(value.as_u64().ok_or_else(|| {
+ format!("$uint: not an unsigned integer: {value}")
+ })?))
+ }
+ "$counter" => Val::Scalar(ScalarValue::counter(integer(value)?)),
+ "$float" => Val::Scalar(ScalarValue::F64(
+ value
+ .as_f64()
+ .ok_or_else(|| format!("$float: not a number: {value}"))?,
+ )),
+ "$timestamp" => {
+ let text = match value {
+ JsonValue::String(text) => text.clone(),
+ JsonValue::Number(number) => number.to_string(),
+ other => {
+ return Err(format!(
+ "$timestamp: neither a string nor a number: {other}"
+ ));
+ }
+ };
+ Val::Scalar(ScalarValue::Timestamp(timestamps.read(&text).ok_or_else(
+ || format!("$timestamp: not a {} timestamp: {text}", timestamps.name()),
+ )?))
+ }
+ other => {
+ return Err(format!(
+ "no tag {other} (a map whose only key starts with $ is written as {{\"$map\": …}})"
+ ));
+ }
+ })
+ }
+
+ /// What it is stored as.
+ pub(crate) fn kind(&self) -> Kind {
+ match self {
+ Val::Map(_) => Kind::Map,
+ Val::List(_) => Kind::List,
+ Val::Text(_) => Kind::Text,
+ Val::Scalar(scalar) => Kind::of(scalar),
+ }
+ }
+
+ /// In JSON of `form`.
+ pub(crate) fn to_json(&self, form: JsonForm) -> Option<JsonValue> {
+ Some(match self {
+ Val::Map(entries) => map_to_json(
+ entries
+ .iter()
+ .map(|(key, value)| Some((key.clone(), value.to_json(form)?)))
+ .collect::<Option<_>>()?,
+ form,
+ ),
+ Val::List(items) => JsonValue::Array(
+ items
+ .iter()
+ .map(|item| item.to_json(form))
+ .collect::<Option<_>>()?,
+ ),
+ Val::Text(text) => text_to_json(text.clone(), form),
+ Val::Scalar(scalar) => scalar_to_json(scalar, form)?,
+ })
+ }
+}