aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-02 11:04:15 +0100
committertv <tv@krebsco.de>2026-11-02 11:04:15 +0100
commit2ad5d7c6ba0962e7ba2a8c919cddc1c2fa136f63 (patch)
treeddf869ba92e3d60f020d05a7a7f315267bc902d0
parent11dabc03d8624557c146e0796dc4bc9c9c1050ee (diff)
api: add type casts
-rw-r--r--docs/openapi.yaml29
-rw-r--r--src/api/mod.rs3
-rw-r--r--src/api/store.rs50
-rw-r--r--src/document/store.rs31
-rw-r--r--src/document/typed.rs165
-rw-r--r--src/remake/bench.rs1
6 files changed, 269 insertions, 10 deletions
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 6e106b9..f188d69 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -177,7 +177,7 @@ paths:
description: 'Node is not ready (and isn''t rejoining: a rejoining node takes writes into its document before the rejoin, carried over when it ends).'
post:
tags: [Store]
- summary: Insert into a list, or increment a counter
+ summary: Insert into a list, increment a counter, or cast a value
description: |
Inserts the value (as `PUT` takes it) into a list: the path ends in the index
it goes to (`queue/2`), the items from there on moving up one; the list's
@@ -189,6 +189,14 @@ paths:
incremented, `-1` naming the last). Increments of a counter on several nodes
add up. Answers `200` with the counter's value then. Reads show a counter as
a number; a `PUT` there makes it a plain number again.
+
+ With `cast`, casts what is at the path to that type instead (no body; `-1`
+ names the last item): numbers (integers, floats, counters, timestamps as
+ milliseconds since 1970) to numbers where they are exactly that, any scalar
+ but bytes to a string or a text as its media type shows it (timestamps in
+ `format`) and back, bytes to a string or a text and back as UTF-8. Answers
+ `200` with what is there then, as `PUT` does (`x-continuum-retyped` saying
+ the change).
parameters:
- name: increment
in: query
@@ -196,6 +204,19 @@ paths:
schema:
type: integer
format: int64
+ - name: cast
+ in: query
+ description: The type to cast to.
+ schema:
+ type: string
+ enum: [text, string, bytes, int, uint, float, boolean, counter, timestamp]
+ - name: format
+ in: query
+ description: With `cast`, how timestamps are read from and written as strings.
+ schema:
+ type: string
+ enum: [rfc3339, unix, unix-ms, http-date]
+ default: rfc3339
requestBody:
required: false
content:
@@ -206,7 +227,7 @@ paths:
type: string
responses:
'200':
- description: With `increment`, the counter's value after it.
+ description: With `increment`, the counter's value after it; with `cast`, what is there after it (as `GET` shows it, with `x-continuum-type` and `x-continuum-retyped`).
headers:
x-continuum-heads:
$ref: '#/components/headers/WrittenHeads'
@@ -228,7 +249,7 @@ paths:
index:
type: integer
'400':
- description: Invalid path, index, malformed JSON or invalid UTF-8.
+ description: Invalid path, index, malformed JSON or invalid UTF-8; an unknown `cast` type or `format`, or both `increment` and `cast`.
'401':
$ref: '#/components/responses/Unauthorized'
'403':
@@ -238,7 +259,7 @@ paths:
'307':
description: A negative index, made positive (`Location`).
'409':
- description: Not a list there, or the index is past its end (the message says which, and the length); with `increment`, not a counter there.
+ description: Not a list there, or the index is past its end (the message says which, and the length); with `increment`, not a counter there; with `cast`, no cast possible (the message says why).
'415':
description: Content-Type is none the API takes (`application/json`, the default; `text/plain`; a type's own, `api.md`, Types).
'503':
diff --git a/src/api/mod.rs b/src/api/mod.rs
index ede1cfa..9bb2bed 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -212,7 +212,8 @@ pub(crate) fn store_status(error: StoreError, context: &str) -> ApiError {
| StoreError::NotAList
| StoreError::PastTheEnd { .. }
| StoreError::NotACounter
- | StoreError::Retyping(_) => StatusCode::CONFLICT,
+ | StoreError::Retyping(_)
+ | StoreError::NotCastable { .. } => StatusCode::CONFLICT,
StoreError::NotFound | StoreError::UnknownHeads => StatusCode::NOT_FOUND,
StoreError::Rejoining => StatusCode::SERVICE_UNAVAILABLE,
other => {
diff --git a/src/api/store.rs b/src/api/store.rs
index e6921cd..48bdafc 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -83,6 +83,12 @@ pub(crate) struct PostQuery {
/// Adds this to the counter at the path instead of inserting the body
/// (`store::increment_path`).
increment: Option<i64>,
+ /// Casts what is at the path to this type instead of inserting the body
+ /// (`store::cast_path`; `typed::Kind::name` names it).
+ cast: Option<String>,
+ /// With `cast`, how timestamps are read from and written as strings (`rfc3339` by
+ /// default; `typed::TimestampFormat`).
+ format: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -342,7 +348,11 @@ pub(crate) async fn put_store_entry(
.await?;
let read = read.ok_or(ApiError(StatusCode::INTERNAL_SERVER_ERROR, None))?;
- let mut response = with_heads(read.respond()?, &heads);
+ Ok(with_retyped(with_heads(read.respond()?, &heads), &retyped))
+}
+
+/// `response` saying where a write changed types (`RETYPED_HEADER`), if it did.
+fn with_retyped(mut response: Response, retyped: &[store::Retyped]) -> Response {
if !retyped.is_empty() {
let mut listed: Vec<String> = retyped
.iter()
@@ -356,7 +366,7 @@ pub(crate) async fn put_store_entry(
response.headers_mut().insert(RETYPED_HEADER, value);
}
}
- Ok(response)
+ response
}
fn etag_for(bytes: &[u8]) -> String {
@@ -619,7 +629,9 @@ fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<WriteBody, ApiError>
/// appends); `201` with where it went (`{"index": n}`). Not a list there, or an index
/// past its end: `409`, saying which. With `?increment=n`, adds `n` to the counter at
/// `key` instead (`store::increment_path`), answering with its value then; not a
-/// counter there: `409`.
+/// counter there: `409`. With `?cast=<type>`, casts what is at `key` to that type
+/// instead (`store::cast_path`), answering with it then, as `PUT` does; no cast
+/// possible: `409`.
pub(crate) async fn post_store_entry(
State(state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
@@ -633,8 +645,15 @@ pub(crate) async fn post_store_entry(
return Err(StatusCode::SERVICE_UNAVAILABLE.into());
}
let path = parse_write_path(&state, &user, &key, "POST")?;
- // An increment names an item (`-1`: the last); an insert where it goes (`-1`: the end).
- let insertion = query.increment.is_none();
+ if query.increment.is_some() && query.cast.is_some() {
+ return Err(ApiError(
+ StatusCode::BAD_REQUEST,
+ Some("increment or cast, not both".to_owned()),
+ ));
+ }
+ // An increment or a cast names an item (`-1`: the last); an insert where it goes
+ // (`-1`: the end).
+ let insertion = query.increment.is_none() && query.cast.is_none();
if let Some(redirect) =
to_positive(&state, &path, insertion, None, raw_query.as_deref()).await?
{
@@ -663,6 +682,27 @@ pub(crate) async fn post_store_entry(
&heads,
));
}
+ if let Some(name) = &query.cast {
+ let bad = |message: String| ApiError(StatusCode::BAD_REQUEST, Some(message));
+ let to = Kind::named(name).ok_or_else(|| bad(format!("no type {name:?}")))?;
+ let timestamps = match &query.format {
+ None => TimestampFormat::default(),
+ Some(format) => TimestampFormat::named(format)
+ .ok_or_else(|| bad(format!("no timestamp format {format:?}")))?,
+ };
+ let accept = headers.get(ACCEPT).cloned();
+ // A value that can't be shown as asked is refused, and the cast with it.
+ let ((retyped, read), heads) = state
+ .mutate_doc_for(Some(user.0.clone()), move |doc| {
+ let retyped = store::cast_path(doc, &path, to, timestamps)
+ .map_err(|e| store_status(e, "Failed to cast a store value"))?;
+ let read = read_shown(&*doc, &path, accept.as_ref(), false)?;
+ Ok::<_, ApiError>((retyped, read))
+ })
+ .await?;
+ let read = read.ok_or(ApiError(StatusCode::INTERNAL_SERVER_ERROR, None))?;
+ return Ok(with_retyped(with_heads(read.respond()?, &heads), &retyped));
+ }
let index = path.last().and_then(|leaf| store::list_index(leaf));
let value = body_value(&headers, &body)?;
let inserted = state
diff --git a/src/document/store.rs b/src/document/store.rs
index 07f2e1a..9075032 100644
--- a/src/document/store.rs
+++ b/src/document/store.rs
@@ -107,6 +107,9 @@ pub(crate) enum StoreError {
/// wanted (`Writing::retype`).
#[error("would change {0}")]
Retyping(Retyped),
+ /// A cast that isn't possible (`cast_path`, `typed::cast`), and why.
+ #[error("can't cast {} to {}: {why}", .from.name(), .to.name())]
+ NotCastable { from: Kind, to: Kind, why: String },
/// A read at heads the document doesn't have (`at::At`).
#[error("this node doesn't have these heads (yet)")]
UnknownHeads,
@@ -1055,6 +1058,34 @@ fn counter_at(doc: &impl ReadDoc, parent: &ObjId, prop: Prop) -> Result<i64, Sto
}
}
+/// Casts what is at `path` (the newest of concurrent values, as a read resolves them) to
+/// `to` (`typed::cast`, timestamps to and from strings in `timestamps`), written as a
+/// write of the cast value would be (`write_val_path`); returns where that changed the
+/// type. Already `to`: nothing changes. Nothing there: `NotFound`; no cast possible:
+/// `NotCastable`.
+pub(crate) fn cast_path(
+ doc: &mut impl Transactable,
+ path: &[String],
+ to: Kind,
+ timestamps: TimestampFormat,
+) -> Result<Vec<Retyped>, StoreError> {
+ let held = read_held(doc, path)?.ok_or(StoreError::NotFound)?;
+ if held.kind == to {
+ return Ok(Vec::new());
+ }
+ let not_castable = |why: String| StoreError::NotCastable {
+ from: held.kind,
+ to,
+ why,
+ };
+ let value = held
+ .value
+ .as_ref()
+ .ok_or_else(|| not_castable(format!("{} is cast to nothing", held.kind.name())))?;
+ let cast = typed::cast(held.kind, value, to, timestamps).map_err(not_castable)?;
+ write_val_path(doc, path, &cast, Writing::new(false, timestamps, true))
+}
+
/// Inserts `value` into a list: `path` ends in the index it goes to (the items from
/// 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
diff --git a/src/document/typed.rs b/src/document/typed.rs
index cf7f49b..7f6e0cc 100644
--- a/src/document/typed.rs
+++ b/src/document/typed.rs
@@ -73,6 +73,26 @@ impl Kind {
}
}
+ /// The kind `name` names (`name`).
+ pub(crate) fn named(name: &str) -> Option<Self> {
+ [
+ Kind::Map,
+ Kind::List,
+ Kind::Text,
+ Kind::String,
+ Kind::Bytes,
+ Kind::Int,
+ Kind::Uint,
+ Kind::Float,
+ Kind::Boolean,
+ Kind::Null,
+ Kind::Counter,
+ Kind::Timestamp,
+ ]
+ .into_iter()
+ .find(|kind| kind.name() == name)
+ }
+
/// The media type a value of this kind is shown as natively, if any.
pub(crate) fn media_type(self) -> Option<&'static str> {
Some(match self {
@@ -364,6 +384,93 @@ pub(crate) fn from_native(
})
}
+/// `value` (a scalar, or a text's characters as a string), held as `from`, cast to
+/// `to` (`store::cast_path`): numbers (integers, floats, counters, timestamps as
+/// milliseconds since 1970) to numbers where they are exactly that; anything but bytes
+/// to a string or a text as its type's own media type shows it (`native`, timestamps
+/// in `timestamps`), and back (`from_native`); bytes to a string or a text and back as
+/// UTF-8. An error says why not (maps, lists and nulls are cast to nothing, and nothing
+/// to them).
+pub(crate) fn cast(
+ from: Kind,
+ value: &ScalarValue,
+ to: Kind,
+ timestamps: TimestampFormat,
+) -> Result<Val, String> {
+ let string = |value: &ScalarValue| -> Result<String, String> {
+ match value {
+ ScalarValue::Str(text) => Ok(text.to_string()),
+ ScalarValue::Bytes(bytes) => {
+ String::from_utf8(bytes.clone()).map_err(|_| "not UTF-8".to_owned())
+ }
+ other => native(other, timestamps)
+ .and_then(|(_, body)| String::from_utf8(body).ok())
+ .ok_or_else(|| "it has no text form".to_owned()),
+ }
+ };
+ match to {
+ Kind::String => {
+ return string(value).map(|text| Val::Scalar(ScalarValue::Str(text.into())));
+ }
+ Kind::Text => return string(value).map(Val::Text),
+ Kind::Map | Kind::List | Kind::Null | Kind::Unknown => {
+ return Err(format!("nothing is cast to {}", to.name()));
+ }
+ _ => {}
+ }
+ if let ScalarValue::Str(text) = value {
+ if to == Kind::Bytes {
+ return Ok(Val::Scalar(ScalarValue::Bytes(text.as_bytes().to_vec())));
+ }
+ let media = to.media_type().ok_or("it has no text form")?;
+ let params = format!("; format={}", timestamps.name());
+ return from_native(media, &params, text.as_bytes())
+ .unwrap_or_else(|| Err("it has no text form".to_owned()))
+ .map(Val::Scalar);
+ }
+ // Numbers: exactly, as an integer if they are one, else as a float.
+ let number = match value {
+ ScalarValue::Int(i) => Ok(i128::from(*i)),
+ ScalarValue::Uint(u) => Ok(i128::from(*u)),
+ ScalarValue::Counter(c) => Ok(i128::from(i64::from(c))),
+ ScalarValue::Timestamp(t) => Ok(i128::from(*t)),
+ ScalarValue::F64(f) => Err(*f),
+ _ => return Err(format!("{} isn't a number", from.name())),
+ };
+ let integer = || -> Result<i128, String> {
+ match number {
+ Ok(integer) => Ok(integer),
+ // Integral floats up to 2^64 convert exactly (`as` saturates beyond).
+ Err(float) if float.fract() == 0.0 && float.abs() < 2f64.powi(64) => Ok(float as i128),
+ Err(float) => Err(format!("{float} isn't an integer that fits")),
+ }
+ };
+ let signed = || -> Result<i64, String> {
+ let integer = integer()?;
+ i64::try_from(integer).map_err(|_| format!("{integer} doesn't fit a 64-bit integer"))
+ };
+ let scalar = match to {
+ Kind::Int => ScalarValue::Int(signed()?),
+ Kind::Counter => ScalarValue::counter(signed()?),
+ Kind::Timestamp => ScalarValue::Timestamp(signed()?),
+ Kind::Uint => {
+ let integer = integer()?;
+ ScalarValue::Uint(
+ u64::try_from(integer)
+ .map_err(|_| format!("{integer} doesn't fit an unsigned 64-bit integer"))?,
+ )
+ }
+ Kind::Float => match number {
+ Err(float) => ScalarValue::F64(float),
+ // Exactly: up to 2^53 every integer is a float.
+ Ok(integer) if integer.unsigned_abs() <= 1 << 53 => ScalarValue::F64(integer as f64),
+ Ok(integer) => return Err(format!("{integer} isn't exactly a float")),
+ },
+ _ => return Err(format!("numbers aren't cast to {}", to.name())),
+ };
+ Ok(Val::Scalar(scalar))
+}
+
/// The value of parameter `name` among a media type's `params` (`; a=b; c="d"`).
pub(crate) fn param(params: &str, name: &str) -> Option<String> {
params.split(';').find_map(|param| {
@@ -520,3 +627,61 @@ impl Val {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn cast_to(from: ScalarValue, to: Kind) -> Result<Val, String> {
+ cast(Kind::of(&from), &from, to, TimestampFormat::default())
+ }
+
+ #[test]
+ fn numbers_cast_where_exact() {
+ assert_eq!(
+ cast_to(ScalarValue::F64(2.0), Kind::Int),
+ Ok(Val::Scalar(ScalarValue::Int(2)))
+ );
+ assert!(cast_to(ScalarValue::F64(2.5), Kind::Int).is_err());
+ assert!(cast_to(ScalarValue::Int(-1), Kind::Uint).is_err());
+ assert_eq!(
+ cast_to(ScalarValue::Int(5), Kind::Counter),
+ Ok(Val::Scalar(ScalarValue::counter(5)))
+ );
+ assert!(cast_to(ScalarValue::Int(i64::MAX), Kind::Float).is_err());
+ assert!(cast_to(ScalarValue::Boolean(true), Kind::Int).is_err());
+ }
+
+ #[test]
+ fn strings_cast_as_media_types_show_them() {
+ assert_eq!(
+ cast_to(
+ ScalarValue::Str("2026-11-01T21:50:20.345Z".into()),
+ Kind::Timestamp
+ ),
+ Ok(Val::Scalar(ScalarValue::Timestamp(1_793_569_820_345)))
+ );
+ assert!(cast_to(ScalarValue::Str("soon".into()), Kind::Timestamp).is_err());
+ assert_eq!(
+ cast_to(ScalarValue::Timestamp(1_793_569_820_345), Kind::String),
+ Ok(Val::Scalar(ScalarValue::Str(
+ "2026-11-01T21:50:20.345Z".into()
+ )))
+ );
+ assert_eq!(
+ cast_to(ScalarValue::Str("true".into()), Kind::Boolean),
+ Ok(Val::Scalar(ScalarValue::Boolean(true)))
+ );
+ assert_eq!(
+ cast_to(ScalarValue::Str("hé".into()), Kind::Text),
+ Ok(Val::Text("hé".to_owned()))
+ );
+ assert_eq!(
+ cast_to(ScalarValue::Bytes(b"hi".to_vec()), Kind::String),
+ Ok(Val::Scalar(ScalarValue::Str("hi".into())))
+ );
+ assert!(cast_to(ScalarValue::Bytes(vec![0xff]), Kind::String).is_err());
+ assert!(cast_to(ScalarValue::Null, Kind::String).is_err());
+ assert!(cast_to(ScalarValue::Int(1), Kind::Map).is_err());
+ }
+}
diff --git a/src/remake/bench.rs b/src/remake/bench.rs
index 213ed4b..3c3f513 100644
--- a/src/remake/bench.rs
+++ b/src/remake/bench.rs
@@ -83,6 +83,7 @@ fn replay(keys: usize, late: usize, newer: usize) {
#[test]
#[ignore = "a benchmark: cargo benchmarks"]
fn bench_replay() {
+ println!(""); // put reports on new line
replay(1_000, 100, 100);
replay(100_000, 100, 100);
replay(100_000, 1_000, 1_000);