aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-02 10:26:51 +0100
committertv <tv@krebsco.de>2026-11-02 10:26:51 +0100
commit58d574166fef1246f367009546358703e16d7f7e (patch)
treea307cd78560058c45e32f18631191e0fd15c9ae1
parent74ced20c6013b7ced9ae14a41033f60239790f6a (diff)
api: add application/vnd.continuum.typed+json
-rw-r--r--docs/openapi.yaml10
-rw-r--r--src/api/store.rs126
-rw-r--r--src/document/store.rs212
-rw-r--r--src/document/typed.rs227
4 files changed, 383 insertions, 192 deletions
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 5402389..36c3ddc 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -79,8 +79,9 @@ paths:
description: |
The value at the path, as JSON, or as `Accept` asks (`api.md`, Types): its
type's own media type for a scalar (`application/octet-stream` for bytes,
- `application/vnd.continuum.<type>`), or `text/plain` (strings unquoted,
- anything else as JSON). `Accept` is taken in its order; `406` if it names
+ `application/vnd.continuum.<type>`), typed JSON
+ (`application/vnd.continuum.typed+json`, which tags what plain JSON would show
+ as another type), or `text/plain` (strings unquoted, anything else as JSON). `Accept` is taken in its order; `406` if it names
nothing the value can be shown as. `application/json; timestamp-format=…` shows
timestamps as `rfc3339` (the default), `unix`, `unix-ms` or `http-date`.
Concurrent writes resolve as follows: concurrent maps merge, anything else
@@ -117,7 +118,10 @@ paths:
JSON maps and arrays become Automerge maps and lists; numbers become integers
when they fit, floats otherwise; strings are plain strings (not text objects).
- A scalar of another type is written as its media type (`api.md`, Types):
+ Typed JSON (`application/vnd.continuum.typed+json`) writes values of every
+ type, tagged (`{"$timestamp": …}`, `{"$bytes": …}`, `{"$uint": …}`,
+ `{"$counter": …}`, `{"$float": …}`, `{"$text": …}`, `{"$map": …}`).
+ A scalar of another type is also written as its media type (`api.md`, Types):
`application/octet-stream` (bytes), `application/vnd.continuum.int`, `.uint`,
`.float`, `.boolean`, `.counter` (decimal or `true`/`false`), or
`application/vnd.continuum.timestamp; format=rfc3339|unix|unix-ms|http-date`;
diff --git a/src/api/store.rs b/src/api/store.rs
index 13f5012..47aade1 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -23,7 +23,7 @@ use super::{ApiError, store_status};
use crate::document::ChangeHash;
use crate::document::meta;
use crate::document::store::{self, StoreError, paths_related};
-use crate::document::typed::{self, JsonForm, Kind, TimestampFormat};
+use crate::document::typed::{self, JsonForm, Kind, TimestampFormat, Val};
use crate::state::{AppState, ReadSource};
use crate::transport::Hex;
@@ -310,13 +310,12 @@ pub(crate) async fn put_store_entry(
}
let value = body_value(&headers, &body)?;
// Answered with the value written, shown as asked.
- let (held, json) = value.held();
- let shown = shown(headers.get(ACCEPT), held.kind)?;
+ let shown = shown(headers.get(ACCEPT), value.kind())?;
+ let (held, json) = written(&value, shown);
let ((), heads) = state
- .mutate_doc_for(Some(user.0.clone()), move |doc| match &value {
- Written::Json(value) => store::write_path(doc, &path, value),
- Written::Scalar(scalar) => store::write_scalar_path(doc, &path, scalar),
+ .mutate_doc_for(Some(user.0.clone()), move |doc| {
+ store::write_val_path(doc, &path, &value)
})
.await
.map_err(|e| store_status(e, "Failed to write store value"))?;
@@ -378,8 +377,15 @@ enum Shown {
/// a timestamp format there is none of.
fn json_form(accept: Option<&HeaderValue>) -> Result<JsonForm, ApiError> {
for (media, params) in accepted(accept) {
- if media == "application/json" {
- return json_form_of(params);
+ match media.as_str() {
+ "application/json" => return json_form_of(params),
+ typed::TYPED_JSON => {
+ return Ok(JsonForm {
+ typed: true,
+ ..json_form_of(params)?
+ });
+ }
+ _ => {}
}
}
Ok(JsonForm::default())
@@ -395,7 +401,10 @@ fn json_form_of(params: &str) -> Result<JsonForm, ApiError> {
)
})?,
};
- Ok(JsonForm { timestamps })
+ Ok(JsonForm {
+ timestamps,
+ typed: false,
+ })
}
/// The media types of `accept`, in its order, each with its parameters (q-values are not
@@ -425,6 +434,12 @@ fn shown(accept: Option<&HeaderValue>, kind: Kind) -> Result<Shown, ApiError> {
for (media, params) in &items {
match media.as_str() {
"application/json" => return Ok(Shown::Json(json_form_of(params)?)),
+ typed::TYPED_JSON => {
+ return Ok(Shown::Json(JsonForm {
+ typed: true,
+ ..json_form_of(params)?
+ }));
+ }
typed::TEXT => return Ok(Shown::Text),
"*/*" | "application/*" => return Ok(Shown::Json(JsonForm::default())),
media if Some(media) == native => {
@@ -442,7 +457,7 @@ fn shown(accept: Option<&HeaderValue>, kind: Kind) -> Result<Shown, ApiError> {
_ => {}
}
}
- let mut offered = vec!["application/json", typed::TEXT];
+ let mut offered = vec!["application/json", typed::TYPED_JSON, typed::TEXT];
if let Some(native) = native.filter(|native| *native != typed::TEXT) {
offered.push(native);
}
@@ -461,7 +476,14 @@ fn shown(accept: Option<&HeaderValue>, kind: Kind) -> Result<Shown, ApiError> {
fn show(held: &store::Held, shown: Shown, json: Option<&JsonValue>) -> Result<Response, ApiError> {
let json = || json.ok_or(ApiError(StatusCode::INTERNAL_SERVER_ERROR, None));
match shown {
- Shown::Json(_) => respond(Some(held.kind), "application/json", json_bytes(json()?)?),
+ Shown::Json(form) => {
+ let media = if form.typed {
+ typed::TYPED_JSON
+ } else {
+ "application/json"
+ };
+ respond(Some(held.kind), media, json_bytes(json()?)?)
+ }
Shown::Text => {
let body = match json()? {
JsonValue::String(value) => value.clone().into_bytes(),
@@ -508,64 +530,53 @@ fn respond(kind: Option<Kind>, content_type: &str, body: Vec<u8>) -> Result<Resp
/// Header naming what a value read or written is stored as (`typed::Kind::name`).
const TYPE_HEADER: &str = "x-continuum-type";
-/// A write's body: JSON (the default), text as a string, or a value of a type of its
-/// own (`typed::from_native`).
-enum Written {
- Json(JsonValue),
- Scalar(automerge::ScalarValue),
-}
-
-impl Written {
- /// What it is stored as, and its JSON, to answer the write with.
- fn held(&self) -> (store::Held, Option<JsonValue>) {
- match self {
- Written::Json(json) => {
- let kind = match json {
- JsonValue::Object(_) => Kind::Map,
- JsonValue::Array(_) => Kind::List,
- JsonValue::String(_) => Kind::String,
- JsonValue::Bool(_) => Kind::Boolean,
- JsonValue::Null => Kind::Null,
- JsonValue::Number(number) if number.is_i64() => Kind::Int,
- JsonValue::Number(number) if number.is_u64() => Kind::Uint,
- JsonValue::Number(_) => Kind::Float,
- };
- let value = store::json_scalar(json).ok().flatten();
- (store::Held { kind, value }, Some(json.clone()))
- }
- Written::Scalar(scalar) => (
- store::Held {
- kind: Kind::of(scalar),
- value: Some(scalar.clone()),
- },
- typed::scalar_to_json(scalar, JsonForm::default()),
- ),
- }
- }
-}
-
-fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<Written, ApiError> {
+/// A write's body as a value to write (`typed::Val`): JSON (the default), typed JSON,
+/// text as a string, or a scalar of a type of its own (`typed::from_native`).
+fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<Val, ApiError> {
let content_type = headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/json");
let (media, params) = content_type.split_once(';').unwrap_or((content_type, ""));
let media = media.trim().to_ascii_lowercase();
+ let bad = |error: String| ApiError(StatusCode::BAD_REQUEST, Some(error));
+ let json = || {
+ serde_json::from_slice::<JsonValue>(body).map_err(|error| bad(format!("not JSON: {error}")))
+ };
match media.as_str() {
- "application/json" => serde_json::from_slice::<JsonValue>(body)
- .map(Written::Json)
- .map_err(|_| StatusCode::BAD_REQUEST.into()),
+ "application/json" => Val::from_json(&json()?).map_err(bad),
+ typed::TYPED_JSON => {
+ let form = json_form_of(params)?;
+ Val::from_typed_json(&json()?, form.timestamps).map_err(bad)
+ }
typed::TEXT => String::from_utf8(body.to_vec())
- .map(|text| Written::Json(JsonValue::String(text)))
- .map_err(|_| StatusCode::BAD_REQUEST.into()),
+ .map(|text| Val::Scalar(automerge::ScalarValue::Str(text.into())))
+ .map_err(|_| bad("not UTF-8".to_owned())),
media => match typed::from_native(media, params, body) {
- Some(Ok(scalar)) => Ok(Written::Scalar(scalar)),
- Some(Err(error)) => Err(ApiError(StatusCode::BAD_REQUEST, Some(error))),
+ Some(Ok(scalar)) => Ok(Val::Scalar(scalar)),
+ Some(Err(error)) => Err(bad(error)),
None => Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into()),
},
}
}
+/// What `value` is stored as, and its JSON of `shown`'s form, to answer its write with.
+fn written(value: &Val, shown: Shown) -> (store::Held, Option<JsonValue>) {
+ let held = store::Held {
+ kind: value.kind(),
+ value: match value {
+ Val::Scalar(scalar) => Some(scalar.clone()),
+ Val::Text(text) => Some(automerge::ScalarValue::Str(text.as_str().into())),
+ Val::Map(_) | Val::List(_) => None,
+ },
+ };
+ let form = match shown {
+ Shown::Json(form) => form,
+ Shown::Text | Shown::Native(_) => JsonForm::default(),
+ };
+ (held, value.to_json(form))
+}
+
/// `POST /store/{key}`: inserts the body into a list, `key` ending in the index it
/// goes to (`store::insert_path`: the items from there on move up; the length, or `-1`,
/// appends); `201` with where it went (`{"index": n}`). Not a list there, or an index
@@ -618,9 +629,8 @@ pub(crate) async fn post_store_entry(
let index = path.last().and_then(|leaf| store::list_index(leaf));
let value = body_value(&headers, &body)?;
let inserted = state
- .mutate_doc_for(Some(user.0.clone()), move |doc| match &value {
- Written::Json(value) => store::insert_path(doc, &path, value),
- Written::Scalar(scalar) => store::insert_scalar_path(doc, &path, scalar),
+ .mutate_doc_for(Some(user.0.clone()), move |doc| {
+ store::insert_val_path(doc, &path, &value)
})
.await;
match inserted {
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)?,
+ })
+ }
+}