graphddb_runtime 0.7.5

Rust runtime for GraphDDB — interprets the language-neutral IR (manifest.json + operations.json) and executes the validated access patterns against DynamoDB.
Documentation
//! Per-key cursor envelope for batched `range` contract methods — a byte-for-byte
//! port of `python/graphddb_runtime/per_key_cursor.py` and the TS
//! `src/runtime/per-key-cursor.ts`.
//!
//! A `range` contract method paginates per key: each key owns its own pagination
//! position, so the cursor envelope must carry the key it belongs to. This wraps
//! the inner page cursor (the base64url `LastEvaluatedKey` from [`encode_cursor`])
//! together with a stable identity of the owning key into a single opaque
//! base64url-JSON envelope, so no runtime can resume one key's pagination against
//! another.

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde_json::{Map, Value as Json};

use crate::errors::GraphDDBError;
use crate::value::write_json_string;

/// Canonical, cross-runtime-stable string identity of a contract key.
///
/// Object fields are sorted by name so `{a, b}` and `{b, a}` serialize
/// identically; values are emitted via compact `json.dumps` separators, matching
/// the TS `serializeContractKey` (`JSON.stringify` over a field-sorted object),
/// e.g. `{"categoryId":"tech"}`.
pub fn serialize_contract_key(key: &Json) -> String {
    let obj = key.as_object().cloned().unwrap_or_default();
    let mut keys: Vec<&String> = obj.keys().collect();
    keys.sort();
    let mut out = String::from("{");
    for (i, k) in keys.iter().enumerate() {
        if i > 0 {
            out.push(',');
        }
        write_json_string(k, &mut out);
        out.push(':');
        write_json_value_compact(&obj[*k], &mut out);
    }
    out.push('}');
    out
}

/// Compact JSON of a `serde_json::Value` matching Python `json.dumps(v,
/// separators=(",", ":"), ensure_ascii=False)`. Numbers are emitted as
/// `serde_json` renders them (contract key values are strings / small ints).
fn write_json_value_compact(v: &Json, out: &mut String) {
    match v {
        Json::String(s) => write_json_string(s, out),
        Json::Null => out.push_str("null"),
        Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
        Json::Number(n) => out.push_str(&n.to_string()),
        Json::Array(items) => {
            out.push('[');
            for (i, item) in items.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                write_json_value_compact(item, out);
            }
            out.push(']');
        }
        Json::Object(map) => {
            out.push('{');
            for (i, (k, val)) in map.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                write_json_string(k, out);
                out.push(':');
                write_json_value_compact(val, out);
            }
            out.push('}');
        }
    }
}

/// Build a per-key cursor envelope from the owning key and an inner page cursor,
/// encoded as a single opaque base64url string. Returns `None` when `inner` is
/// `None` (the key has no further pages — a terminal connection has a `None`
/// cursor, never an envelope wrapping nothing).
pub fn encode_per_key_cursor(key: &Json, inner: Option<&str>) -> Option<String> {
    let inner = inner?;
    // {"key": <identity>, "inner": <inner>} — insertion order key, inner.
    let mut payload = String::from("{");
    write_json_string("key", &mut payload);
    payload.push(':');
    write_json_string(&serialize_contract_key(key), &mut payload);
    payload.push(',');
    write_json_string("inner", &mut payload);
    payload.push(':');
    write_json_string(inner, &mut payload);
    payload.push('}');
    Some(URL_SAFE_NO_PAD.encode(payload.as_bytes()))
}

/// Decode a per-key cursor envelope and VERIFY it belongs to `expected_key`.
/// Returns the inner page cursor to hand to the underlying Query.
pub fn decode_per_key_cursor(cursor: &str, expected_key: &Json) -> Result<String, GraphDDBError> {
    let bytes = URL_SAFE_NO_PAD.decode(cursor.as_bytes()).map_err(|_| {
        GraphDDBError::new(
            "Invalid per-key cursor: the value passed as `after` is not a cursor \
             minted by this runtime (it failed to decode).",
        )
    })?;
    let envelope: Json = serde_json::from_slice(&bytes).map_err(|_| {
        GraphDDBError::new(
            "Invalid per-key cursor: the value passed as `after` is not a cursor \
             minted by this runtime (it failed to decode).",
        )
    })?;
    let obj: &Map<String, Json> = envelope.as_object().ok_or_else(|| {
        GraphDDBError::new(
            "Invalid per-key cursor: the decoded envelope is missing its key / inner \
             fields. A range cursor must be a per-key envelope minted by this runtime.",
        )
    })?;
    let key = obj.get("key").and_then(Json::as_str);
    let inner = obj.get("inner").and_then(Json::as_str);
    let (key, inner) = match (key, inner) {
        (Some(k), Some(i)) => (k, i),
        _ => {
            return Err(GraphDDBError::new(
                "Invalid per-key cursor: the decoded envelope is missing its key / inner \
                 fields. A range cursor must be a per-key envelope minted by this runtime.",
            ))
        }
    };
    let expected = serialize_contract_key(expected_key);
    if key != expected {
        return Err(GraphDDBError::new(format!(
            "Per-key cursor mismatch: the supplied `after` cursor belongs to key {key}, \
             but the method is being called for key {expected}. A range cursor may only \
             resume pagination of the same key it was issued for."
        )));
    }
    Ok(inner.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn serialize_sorts_keys() {
        let key = json!({"b": "2", "a": "1"});
        assert_eq!(serialize_contract_key(&key), "{\"a\":\"1\",\"b\":\"2\"}");
    }

    #[test]
    fn round_trip_and_mismatch() {
        let key = json!({"categoryId": "tech"});
        let env = encode_per_key_cursor(&key, Some("INNER")).unwrap();
        assert_eq!(decode_per_key_cursor(&env, &key).unwrap(), "INNER");
        let other = json!({"categoryId": "sports"});
        assert!(decode_per_key_cursor(&env, &other).is_err());
    }

    #[test]
    fn none_inner_yields_none() {
        let key = json!({"categoryId": "tech"});
        assert_eq!(encode_per_key_cursor(&key, None), None);
    }
}