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.
22pub const ID_KEYS: &[&str] = &["fragment_id", "content_hash", "memory_id", "fragment_ids"];
23
24/// Recursively rewrite every [`ID_KEYS`] field of a serialized `context`
25/// wire value into its decimal-string form.
26pub fn stringify_id_fields(value: &mut Value) {
27 match value {
28 Value::Object(map) => {
29 for (key, entry) in map.iter_mut() {
30 if ID_KEYS.contains(&key.as_str()) {
31 stringify_ids_in(entry);
32 } else {
33 stringify_id_fields(entry);
34 }
35 }
36 }
37 Value::Array(items) => items.iter_mut().for_each(stringify_id_fields),
38 _ => {}
39 }
40}
41
42/// Rewrite one id value (or an array of them) into decimal strings.
43fn stringify_ids_in(value: &mut Value) {
44 match value {
45 Value::Number(number) => {
46 if let Some(id) = number.as_u64() {
47 *value = Value::String(id.to_string());
48 }
49 }
50 Value::Array(items) => items.iter_mut().for_each(stringify_ids_in),
51 _ => {}
52 }
53}
54
55/// The inverse of [`stringify_id_fields`]: recursively rewrite every
56/// [`ID_KEYS`] field given in decimal-string form back into the numeric form
57/// the domain types deserialize. Non-string id values pass through
58/// untouched (serde reports them with its own error).
59///
60/// Deliberately stricter than serde: an [`ID_KEYS`]-named field with a
61/// non-numeric string is rejected here even where serde would have dropped
62/// it as an unknown field — a rejected typo beats a silently ignored one,
63/// and an id key can never be user data on these wire shapes.
64///
65/// # Errors
66/// Returns the offending text if an [`ID_KEYS`] field holds a string that
67/// does not parse as a decimal `u64`.
68pub fn parse_id_fields(value: &mut Value) -> Result<(), String> {
69 match value {
70 Value::Object(map) => {
71 for (key, entry) in map.iter_mut() {
72 if ID_KEYS.contains(&key.as_str()) {
73 parse_ids_in(entry)?;
74 } else {
75 parse_id_fields(entry)?;
76 }
77 }
78 }
79 Value::Array(items) => {
80 for item in items {
81 parse_id_fields(item)?;
82 }
83 }
84 _ => {}
85 }
86 Ok(())
87}
88
89/// Rewrite one id value (or an array of them) from decimal string to number.
90fn parse_ids_in(value: &mut Value) -> Result<(), String> {
91 match value {
92 Value::String(text) => {
93 *value = Value::Number(parse_u64(text)?.into());
94 }
95 Value::Array(items) => {
96 for item in items {
97 parse_ids_in(item)?;
98 }
99 }
100 _ => {}
101 }
102 Ok(())
103}
104
105/// Accept `fragments[].id` in decimal-string form by rewriting it to the
106/// numeric wire form. The other [`ID_KEYS`] never appear in a compile
107/// *request*, only in the output — a blanket rule over every `id` key would
108/// corrupt caller metadata that happens to use that name.
109///
110/// # Errors
111/// Returns the offending text if a fragment's `id` does not parse as a
112/// decimal `u64`.
113pub fn parse_fragment_id_strings(request: &mut Value) -> Result<(), String> {
114 let Some(fragments) = request.get_mut("fragments").and_then(Value::as_array_mut) else {
115 return Ok(());
116 };
117 for fragment in fragments {
118 let Some(id) = fragment.get_mut("id") else {
119 continue;
120 };
121 if let Value::String(text) = id {
122 *id = Value::Number(parse_u64(text)?.into());
123 }
124 }
125 Ok(())
126}
127
128pub(crate) fn parse_u64(text: &str) -> Result<u64, String> {
129 text.parse()
130 .map_err(|_| format!("invalid id '{text}' (expected a decimal u64 string)"))
131}
132
133/// Serde `deserialize_with` for [`super::model::ContextFragment::id`]:
134/// accepts either a JSON number or a decimal string, so a caller who
135/// received an id as a string (e.g. echoing back a `fragment_id` handed out
136/// under [`super::model::CompilePolicy::ids_as_strings`], or any client that
137/// serializes `u64` as a JS-safe string by convention) can resubmit it
138/// as-is. Reuses [`parse_u64`] — the same decimal-parsing rule
139/// [`parse_fragment_id_strings`] and [`parse_ids_in`] already apply,
140/// centralized once. Matched by hand over a [`Value`] (not an untagged
141/// enum) so a rejected value gets a message naming it and the accepted
142/// forms, instead of serde's opaque "did not match any variant".
143///
144/// # Errors
145/// Returns a deserialize error naming the offending value if it is neither
146/// a `u64` JSON number nor a decimal-`u64` string.
147pub(crate) fn deserialize_optional_id<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
148where
149 D: serde::Deserializer<'de>,
150{
151 use serde::de::Error;
152 use serde::Deserialize;
153
154 let expected = "expected a u64 number or a decimal u64 string";
155 Option::<Value>::deserialize(deserializer)?
156 .map(|value| match value {
157 Value::Number(number) => number
158 .as_u64()
159 .ok_or_else(|| Error::custom(format!("invalid id {number} ({expected})"))),
160 Value::String(text) => parse_u64(&text).map_err(Error::custom),
161 other => Err(Error::custom(format!("invalid id {other} ({expected})"))),
162 })
163 .transpose()
164}
165
166#[cfg(test)]
167#[path = "wire_tests.rs"]
168mod tests;