Skip to main content

velesdb_memory/context/
wire.rs

1//! JSON-tree helpers for the id wire contract shared by every JS-facing
2//! binding (Node, WASM) of the `context` types: a `u64` id crosses as a
3//! decimal string, because JS `number` loses precision above 2^53.
4//!
5//! Node and WASM independently need the exact same tree walk over a
6//! serialized [`CompiledContext`](super::CompiledContext) — one to turn
7//! outgoing ids into strings, the other to turn incoming strings back into
8//! numbers before deserializing. Living here once (instead of copy-pasted
9//! per binding) means [`ID_KEYS`] has a single source of truth: a future id
10//! field added to a `context` type only needs updating in one place, not
11//! silently missed in whichever binding a copy-paste forgot.
12//!
13//! Deliberately `String`-erred, not binding-specific: this crate depends on
14//! neither `napi` nor `wasm-bindgen`, so each binding maps the `String`
15//! error to its own error type at the call site.
16
17use serde_json::Value;
18
19/// Object keys whose `u64` values (or arrays of them) must cross to JS as
20/// decimal strings. Token counts stay numbers: they are bounded far below
21/// 2^53 by the budget caps.
22///
23/// Re-exported from [`crate::schema::WIRE_ID_KEYS`] rather than redeclared
24/// (#1685): the two used to be independent constants with identical
25/// contents, so a future id field added to one could silently miss the
26/// other. `schema.rs` holds the single declaration because it compiles
27/// unconditionally, while this module only compiles under the `context`
28/// feature.
29pub use crate::schema::WIRE_ID_KEYS as ID_KEYS;
30
31/// Recursively rewrite every [`ID_KEYS`] field of a serialized `context`
32/// wire value into its decimal-string form.
33pub fn stringify_id_fields(value: &mut Value) {
34    match value {
35        Value::Object(map) => {
36            for (key, entry) in map.iter_mut() {
37                if ID_KEYS.contains(&key.as_str()) {
38                    stringify_ids_in(entry);
39                } else {
40                    stringify_id_fields(entry);
41                }
42            }
43        }
44        Value::Array(items) => items.iter_mut().for_each(stringify_id_fields),
45        _ => {}
46    }
47}
48
49/// Rewrite one id value (or an array of them) into decimal strings.
50fn stringify_ids_in(value: &mut Value) {
51    match value {
52        Value::Number(number) => {
53            if let Some(id) = number.as_u64() {
54                *value = Value::String(id.to_string());
55            }
56        }
57        Value::Array(items) => items.iter_mut().for_each(stringify_ids_in),
58        _ => {}
59    }
60}
61
62/// The inverse of [`stringify_id_fields`]: recursively rewrite every
63/// [`ID_KEYS`] field given in decimal-string form back into the numeric form
64/// the domain types deserialize. Non-string id values pass through
65/// untouched (serde reports them with its own error).
66///
67/// Deliberately stricter than serde: an [`ID_KEYS`]-named field with a
68/// non-numeric string is rejected here even where serde would have dropped
69/// it as an unknown field — a rejected typo beats a silently ignored one,
70/// and an id key can never be user data on these wire shapes.
71///
72/// # Errors
73/// Returns the offending text if an [`ID_KEYS`] field holds a string that
74/// does not parse as a decimal `u64`.
75pub fn parse_id_fields(value: &mut Value) -> Result<(), String> {
76    match value {
77        Value::Object(map) => {
78            for (key, entry) in map.iter_mut() {
79                if ID_KEYS.contains(&key.as_str()) {
80                    parse_ids_in(entry)?;
81                } else {
82                    parse_id_fields(entry)?;
83                }
84            }
85        }
86        Value::Array(items) => {
87            for item in items {
88                parse_id_fields(item)?;
89            }
90        }
91        _ => {}
92    }
93    Ok(())
94}
95
96/// Rewrite one id value (or an array of them) from decimal string to number.
97fn parse_ids_in(value: &mut Value) -> Result<(), String> {
98    match value {
99        Value::String(text) => {
100            *value = Value::Number(parse_u64(text)?.into());
101        }
102        Value::Array(items) => {
103            for item in items {
104                parse_ids_in(item)?;
105            }
106        }
107        _ => {}
108    }
109    Ok(())
110}
111
112/// Accept `fragments[].id` in decimal-string form by rewriting it to the
113/// numeric wire form. The other [`ID_KEYS`] never appear in a compile
114/// *request*, only in the output — a blanket rule over every `id` key would
115/// corrupt caller metadata that happens to use that name.
116///
117/// # Errors
118/// Returns the offending text if a fragment's `id` does not parse as a
119/// decimal `u64`.
120pub fn parse_fragment_id_strings(request: &mut Value) -> Result<(), String> {
121    let Some(fragments) = request.get_mut("fragments").and_then(Value::as_array_mut) else {
122        return Ok(());
123    };
124    for fragment in fragments {
125        let Some(id) = fragment.get_mut("id") else {
126            continue;
127        };
128        if let Value::String(text) = id {
129            *id = Value::Number(parse_u64(text)?.into());
130        }
131    }
132    Ok(())
133}
134
135pub(crate) fn parse_u64(text: &str) -> Result<u64, String> {
136    text.parse()
137        .map_err(|_| format!("invalid id '{text}' (expected a decimal u64 string)"))
138}
139
140/// Serde `deserialize_with` for [`super::model::ContextFragment::id`]:
141/// accepts either a JSON number or a decimal string, so a caller who
142/// received an id as a string (e.g. echoing back a `fragment_id` handed out
143/// under [`super::model::CompilePolicy::ids_as_strings`], or any client that
144/// serializes `u64` as a JS-safe string by convention) can resubmit it
145/// as-is. Reuses [`parse_u64`] — the same decimal-parsing rule
146/// [`parse_fragment_id_strings`] and [`parse_ids_in`] already apply,
147/// centralized once. Matched by hand over a [`Value`] (not an untagged
148/// enum) so a rejected value gets a message naming it and the accepted
149/// forms, instead of serde's opaque "did not match any variant".
150///
151/// # Errors
152/// Returns a deserialize error naming the offending value if it is neither
153/// a `u64` JSON number nor a decimal-`u64` string.
154pub(crate) fn deserialize_optional_id<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
155where
156    D: serde::Deserializer<'de>,
157{
158    use serde::de::Error;
159    use serde::Deserialize;
160
161    let expected = "expected a u64 number or a decimal u64 string";
162    Option::<Value>::deserialize(deserializer)?
163        .map(|value| match value {
164            Value::Number(number) => number
165                .as_u64()
166                .ok_or_else(|| Error::custom(format!("invalid id {number} ({expected})"))),
167            Value::String(text) => parse_u64(&text).map_err(Error::custom),
168            other => Err(Error::custom(format!("invalid id {other} ({expected})"))),
169        })
170        .transpose()
171}
172
173#[cfg(test)]
174#[path = "wire_tests.rs"]
175mod tests;