use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
fn present_value<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
where
D: Deserializer<'de>,
{
Value::deserialize(deserializer).map(Some)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AppStateRecord {
pub context_id: String,
pub namespace: String,
pub key: String,
pub version: u64,
pub deleted: bool,
#[serde(
default,
deserialize_with = "present_value",
skip_serializing_if = "Option::is_none"
)]
pub value: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AppStateGetBody {
pub context_id: String,
pub namespace: String,
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_deleted: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ext: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppStateGetResponse {
pub record: AppStateRecord,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AppStatePutBody {
pub context_id: String,
pub namespace: String,
pub key: String,
#[serde(
default,
deserialize_with = "present_value",
skip_serializing_if = "Option::is_none"
)]
pub value: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merge_patch: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_version: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ext: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppStatePutResponse {
pub context_id: String,
pub namespace: String,
pub key: String,
pub version: u64,
pub created: bool,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_bytes: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AppStateListBody {
pub context_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since_version: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_values: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_deleted: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_size: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ext: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppStateListResponse {
pub records: Vec<AppStateRecord>,
pub truncated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub high_watermark: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tombstone_retention_seconds: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AppStateDeleteBody {
pub context_id: String,
pub namespace: String,
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_version: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ext: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppStateDeleteResponse {
pub context_id: String,
pub namespace: String,
pub key: String,
pub existed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AppStateGetManyBody {
pub context_id: String,
pub namespace: String,
pub keys: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_deleted: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ext: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppStateGetManyResponse {
pub records: Vec<AppStateRecord>,
pub missing: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deferred: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PutManyMode {
#[default]
Independent,
Atomic,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[non_exhaustive]
pub struct AppStateWrite {
pub key: String,
#[serde(
default,
deserialize_with = "present_value",
skip_serializing_if = "Option::is_none"
)]
pub value: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merge_patch: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_version: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ext: Option<Value>,
}
impl AppStateWrite {
pub fn new(key: String) -> Self {
Self {
key,
value: None,
merge_patch: None,
expected_version: None,
ext: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AppStatePutManyBody {
pub context_id: String,
pub namespace: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<PutManyMode>,
pub writes: Vec<AppStateWrite>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ext: Option<Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WriteOutcome {
Written,
Conflict,
TooLarge,
NotFound,
Skipped,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteResult {
pub key: String,
pub outcome: WriteOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_version: Option<u64>,
#[serde(
default,
deserialize_with = "present_value",
skip_serializing_if = "Option::is_none"
)]
pub current_value: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_deleted: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actual_bytes: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppStatePutManyResponse {
pub mode: PutManyMode,
pub results: Vec<WriteResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub high_watermark: Option<u64>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_stored_json_null_is_not_an_absent_value() {
let with_null = json!({
"contextId": "ctx", "namespace": "openvtc", "key": "k",
"version": 1, "deleted": false, "value": null,
"updatedAt": "2026-08-22T00:00:00Z"
});
let rec: AppStateRecord = serde_json::from_value(with_null).expect("parse");
assert_eq!(
rec.value,
Some(Value::Null),
"a present null is a stored value, not an absent one"
);
let without = json!({
"contextId": "ctx", "namespace": "openvtc", "key": "k",
"version": 1, "deleted": false,
"updatedAt": "2026-08-22T00:00:00Z"
});
let rec: AppStateRecord = serde_json::from_value(without).expect("parse");
assert_eq!(rec.value, None, "an absent member stays absent");
}
#[test]
fn put_with_a_null_value_still_counts_as_supplying_a_value() {
let body: AppStatePutBody = serde_json::from_value(json!({
"contextId": "ctx", "namespace": "openvtc", "key": "k", "value": null
}))
.expect("parse");
assert_eq!(body.value, Some(Value::Null));
assert!(body.merge_patch.is_none());
}
#[test]
fn a_tombstone_is_distinguished_by_deleted_not_by_an_absent_value() {
let tomb: AppStateRecord = serde_json::from_value(json!({
"contextId": "ctx", "namespace": "openvtc", "key": "k",
"version": 9, "deleted": true,
"updatedAt": "2026-08-22T00:00:00Z",
"deletedAt": "2026-08-22T00:00:00Z"
}))
.expect("parse");
assert!(tomb.deleted);
assert!(tomb.value.is_none());
}
#[test]
fn put_many_mode_defaults_to_independent() {
assert_eq!(PutManyMode::default(), PutManyMode::Independent);
let body: AppStatePutManyBody = serde_json::from_value(json!({
"contextId": "ctx", "namespace": "openvtc",
"writes": [{ "key": "k", "value": 1 }]
}))
.expect("parse");
assert!(
body.mode.is_none(),
"an omitted mode stays absent on the wire rather than being materialised"
);
assert_eq!(body.mode.unwrap_or_default(), PutManyMode::Independent);
}
#[test]
fn ext_is_accepted_but_a_typo_is_not() {
let with_ext: AppStateGetBody = serde_json::from_value(json!({
"contextId": "ctx", "namespace": "openvtc", "key": "k",
"ext": { "org.example.tracing": { "traceId": "abc" } }
}))
.expect("a conforming producer's ext must be accepted");
assert!(with_ext.ext.is_some());
let typo = serde_json::from_value::<AppStateGetBody>(json!({
"contextId": "ctx", "namespace": "openvtc", "key": "k",
"includeDelted": true
}));
assert!(
typo.is_err(),
"a misspelled member must still be refused rather than silently ignored"
);
}
#[test]
fn wire_enums_are_lower_camel_case() {
assert_eq!(
serde_json::to_value(PutManyMode::Atomic).unwrap(),
json!("atomic")
);
assert_eq!(
serde_json::to_value(WriteOutcome::TooLarge).unwrap(),
json!("tooLarge")
);
assert_eq!(
serde_json::to_value(WriteOutcome::NotFound).unwrap(),
json!("notFound")
);
}
}