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;