From 289b84b09a791b88f039664d71bb644c94de6000 Mon Sep 17 00:00:00 2001 From: tv Date: Fri, 30 Oct 2026 19:13:40 +0100 Subject: api: add support for shallow store reads --- src/api/store.rs | 37 ++++++++++++++++++++++++++ src/document/store.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) (limited to 'src') diff --git a/src/api/store.rs b/src/api/store.rs index d0fc807..7e8caed 100644 --- a/src/api/store.rs +++ b/src/api/store.rs @@ -32,6 +32,10 @@ pub(crate) struct ReadQuery { /// to base a replacing remake on exactly what was read. #[serde(default)] heads: bool, + /// What's there, one level deep (`store::read_shallow`), to browse a large store a + /// level at a time. + #[serde(default)] + shallow: bool, } #[derive(Debug, Deserialize)] @@ -54,6 +58,9 @@ pub(crate) async fn get_store_root( headers: HeaderMap, Query(query): Query, ) -> Result { + if query.shallow { + return shallow(&state, Vec::new(), true, &query).await; + } let accept = headers.get(ACCEPT).cloned(); let want_conflicts = query.conflicts; @@ -91,6 +98,9 @@ pub(crate) async fn get_store_entry( Query(query): Query, ) -> Result { let path = parse_key_path(&key)?; + if query.shallow { + return shallow(&state, path, false, &query).await; + } let accept = headers.get(ACCEPT).cloned(); let want_conflicts = query.conflicts; @@ -121,6 +131,33 @@ pub(crate) async fn get_store_entry( mark_source(response, source, heads) } +/// `?shallow=true`: what's at `path`, one level deep, as JSON (`store::read_shallow`); +/// with conflicts asked for too, `400`. `unready_live` as `render_read` has it. +async fn shallow( + state: &AppState, + path: Vec, + unready_live: bool, + query: &ReadQuery, +) -> Result { + if query.conflicts { + return Err(StatusCode::BAD_REQUEST); + } + let (response, source, heads) = state + .render_read( + unready_live, + query.heads, + move |doc| store::read_shallow(doc, &path), + move |result| { + let value = result + .map_err(|e| store_status(e, "Failed to read store path"))? + .ok_or(StatusCode::NOT_FOUND)?; + response_for_value(&value, None) + }, + ) + .await?; + mark_source(response, source, heads) +} + /// Header on reads served from the pre-rejoin document; its value is that epoch. const STALE_HEADER: &str = "x-continuum-stale"; diff --git a/src/document/store.rs b/src/document/store.rs index e742edb..89058fb 100644 --- a/src/document/store.rs +++ b/src/document/store.rs @@ -234,6 +234,78 @@ pub(crate) fn read_path_traced( Ok((value, !log.reports.is_empty())) } +/// Strings and bytes up to this long are in a shallow read's summaries. +const SHALLOW_INLINE: usize = 256; + +/// What's at `path` (the empty path is the whole store), one level deep: a summary of +/// it, `type` (`map`, `list`, `text`, `string`, `bytes`, `number`, `boolean`, `null`, +/// or `unknown`, an Automerge scalar of a later version) +/// and `size` (keys, items, characters or bytes), and its `value` where it's a short +/// scalar; a map's has its keys' summaries too, under `entries`. Concurrent values +/// resolved as a read resolves them; conflicts not reported. +pub(crate) fn read_shallow( + doc: &AutoCommit, + path: &[String], +) -> Result, StoreError> { + let Some(resolved) = resolve_at(doc, path)? else { + return Ok(None); + }; + summary(doc, &resolved, true).map(Some) +} + +/// A summary of `resolved` (`read_shallow`); with `entries`, a map's keys' too. +fn summary(doc: &AutoCommit, resolved: &Resolved, entries: bool) -> Result { + let mut out = serde_json::Map::new(); + match resolved { + Resolved::Maps(maps) => { + let keys = merged_keys(doc, maps); + out.insert("type".into(), "map".into()); + out.insert("size".into(), keys.len().into()); + if entries { + let mut listed = serde_json::Map::new(); + for key in keys { + if let Some(child) = resolve(doc, maps, &key, None)? { + listed.insert(key, summary(doc, &child, false)?); + } + } + out.insert("entries".into(), JsonValue::Object(listed)); + } + } + Resolved::Other(Value::Object(ObjType::Map), id) => { + return summary(doc, &Resolved::Maps(vec![id.clone()]), entries); + } + Resolved::Other(Value::Object(ObjType::List), id) => { + out.insert("type".into(), "list".into()); + out.insert("size".into(), doc.length(id).into()); + } + Resolved::Other(Value::Object(ObjType::Text), id) => { + out.insert("type".into(), "text".into()); + out.insert("size".into(), doc.length(id).into()); + } + Resolved::Other(Value::Object(ObjType::Table), _) => { + return Err(anyhow!("Automerge tables are not supported").into()); + } + Resolved::Other(Value::Scalar(scalar), _) => { + let (kind, size) = match &**scalar { + ScalarValue::Str(text) => ("string", Some(text.len())), + ScalarValue::Bytes(bytes) => ("bytes", Some(bytes.len())), + ScalarValue::Null => ("null", None), + ScalarValue::Boolean(_) => ("boolean", None), + ScalarValue::Unknown { .. } => ("unknown", None), + _ => ("number", None), + }; + out.insert("type".into(), kind.into()); + if let Some(size) = size { + out.insert("size".into(), size.into()); + } + if kind != "unknown" && size.is_none_or(|size| size <= SHALLOW_INLINE) { + out.insert("value".into(), scalar_to_json(scalar.clone().into_owned())?); + } + } + } + Ok(JsonValue::Object(out)) +} + /// Keys of the merged map at `path`; empty if it doesn't exist or isn't a map. pub(crate) fn keys_at(doc: &AutoCommit, path: &[String]) -> Result, StoreError> { let mut maps = vec![automerge::ROOT]; -- cgit v1.3.1