diff options
| author | tv <tv@krebsco.de> | 2026-10-30 19:13:40 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-30 19:13:40 +0100 |
| commit | 289b84b09a791b88f039664d71bb644c94de6000 (patch) | |
| tree | 727ee46dc62409f2b5ca2c31def506034e4f92be | |
| parent | 7d3d8031d2301f24b32ee08215a8d004fb81493b (diff) | |
api: add support for shallow store reads
| -rw-r--r-- | docs/openapi.yaml | 16 | ||||
| -rw-r--r-- | src/api/store.rs | 37 | ||||
| -rw-r--r-- | src/document/store.rs | 72 |
3 files changed, 125 insertions, 0 deletions
diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b85481c..ba66f08 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -54,6 +54,7 @@ paths: parameters: - $ref: '#/components/parameters/conflicts' - $ref: '#/components/parameters/heads' + - $ref: '#/components/parameters/shallow' responses: '200': $ref: '#/components/responses/Value' @@ -77,6 +78,7 @@ paths: parameters: - $ref: '#/components/parameters/conflicts' - $ref: '#/components/parameters/heads' + - $ref: '#/components/parameters/shallow' responses: '200': $ref: '#/components/responses/Value' @@ -1001,6 +1003,20 @@ components: schema: type: boolean default: false + shallow: + name: shallow + in: query + description: | + Instead of the value, what is there one level deep, as JSON, to browse a large + store a level at a time: `type` (`map`, `list`, `text`, `string`, `bytes`, + `number`, `boolean`, `null`, `unknown`), `size` (a map's keys, a list's items, + a text's characters, a string's or bytes' bytes), `value` for a scalar up to + 256 bytes, and for a map `entries`: each key's summary (without entries of its + own). Concurrent values resolved as a read resolves them. Not with `conflicts` + (`400`). + schema: + type: boolean + default: false wait: name: wait in: query 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<ReadQuery>, ) -> Result<Response, StatusCode> { + 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<ReadQuery>, ) -> Result<Response, StatusCode> { 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<String>, + unready_live: bool, + query: &ReadQuery, +) -> Result<Response, StatusCode> { + 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<Option<JsonValue>, 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<JsonValue, StoreError> { + 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<Vec<String>, StoreError> { let mut maps = vec![automerge::ROOT]; |
