Skip to main content

laser_wire/
kv.rs

1use crate::error::InvalidError;
2use crate::limits::MAX_NAMESPACE_BYTES;
3use serde::{Deserialize, Serialize};
4
5/// The memory scope a read-view row carries, stamped by the fold from the
6/// record's headers so recall reconstructs a memory item and narrows by scope.
7/// Absent on a generic key-value entry, which carries no scope.
8#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
9pub struct MemoryRowScope {
10    /// The item kind word, from the record body.
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub kind: Option<String>,
13    /// The producing agent, from `gen_ai.agent.id`.
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub agent: Option<String>,
16    /// The user scope layer, from `agdx.mem.user`.
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub user: Option<String>,
19    /// The app scope layer, from `agdx.mem.app`.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub app: Option<String>,
22    /// The conversation that wrote the record, from `gen_ai.conversation.id`.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub conversation: Option<String>,
25    /// The origin log record this memory item was folded from (stream, topic,
26    /// partition, offset), so a reader can navigate back to the source message
27    /// while it is still on the log. Absent when unknown.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub source: Option<crate::graph::SourceRef>,
30}
31
32/// One stored entry: an arbitrary-bytes key and value plus optional expiry. The
33/// key and value are owned `Vec<u8>`, so the public API never leaks the `bytes`
34/// crate, and they ride the wire as CBOR byte strings, byte-exact.
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36pub struct KvEntry {
37    #[serde(with = "crate::encoding::bin_bytes")]
38    pub key: Vec<u8>,
39    #[serde(with = "crate::encoding::bin_bytes")]
40    pub value: Vec<u8>,
41    /// Absolute expiry in epoch microseconds, or `None` for no expiry. Expired
42    /// entries are hidden on read.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub expires_at_micros: Option<u64>,
45    /// Optimistic-concurrency version, assigned by the store and bumped on every
46    /// successful mutation of this key. The token a [`KvCas`] precondition
47    /// matches against. A versioned managed backend reports `>= 1` for a live
48    /// entry and reserves `0` solely for "unversioned": a store that does not
49    /// track versions, or an entry written before versioning. A caller must
50    /// therefore never treat `0` as a valid compare token. The field is skipped
51    /// on the wire when `0` so those entries stay byte-identical.
52    #[serde(default, skip_serializing_if = "is_zero_u64")]
53    pub version: u64,
54    /// The memory scope, present only on a memory read-view row. Recall reads it
55    /// to rebuild the item and filter by scope. A generic entry leaves it `None`.
56    /// Boxed so a generic entry stays small (the scope is five optional strings).
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub scope: Option<Box<MemoryRowScope>>,
59    /// The origin log record this entry was folded from (stream, topic,
60    /// partition, offset). Every managed write goes log-first through a
61    /// mutation topic, so a stored entry points back to the record that wrote
62    /// it, the same provenance a memory row carries. Absent on an entry
63    /// written before provenance stamping, or read from a store that does not
64    /// track it. Boxed, like `scope`, so a generic entry stays small.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub source: Option<Box<crate::graph::SourceRef>>,
67}
68
69fn is_zero_u64(value: &u64) -> bool {
70    *value == 0
71}
72
73impl KvEntry {
74    /// The key decoded as UTF-8, or `None` when it is not valid UTF-8. Keys are
75    /// arbitrary bytes, so a binary key has no string form.
76    pub fn key_str(&self) -> Option<&str> {
77        std::str::from_utf8(&self.key).ok()
78    }
79}
80
81/// A page of scanned entries plus the cursor to resume after the last one.
82/// `cursor` is `None` when the scan reached the end.
83#[derive(Clone, Debug, Default, Serialize, Deserialize)]
84pub struct KvPage {
85    pub entries: Vec<KvEntry>,
86    #[serde(
87        default,
88        skip_serializing_if = "Option::is_none",
89        with = "crate::encoding::opt_bin_bytes"
90    )]
91    pub cursor: Option<Vec<u8>>,
92}
93
94/// Request to read the value at `key` in `namespace`. With `if_none_match` set to
95/// a version, the read returns [`KvOutcome::NotModified`] instead of the value
96/// when the live version matches (a conditional GET), so an up-to-date cache
97/// skips the body transfer.
98#[derive(Clone, Debug, Serialize, Deserialize)]
99pub struct KvGet {
100    pub v: u32,
101    pub namespace: String,
102    #[serde(with = "crate::encoding::bin_bytes")]
103    pub key: Vec<u8>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub if_none_match: Option<u64>,
106}
107
108/// Request to write `value` at `key` in `namespace`, with an optional expiry.
109#[derive(Clone, Debug, Serialize, Deserialize)]
110pub struct KvSet {
111    pub v: u32,
112    pub namespace: String,
113    #[serde(with = "crate::encoding::bin_bytes")]
114    pub key: Vec<u8>,
115    #[serde(with = "crate::encoding::bin_bytes")]
116    pub value: Vec<u8>,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub expires_at_micros: Option<u64>,
119}
120
121/// The precondition a [`KvCas`] write must satisfy to apply. The compare half
122/// of compare-and-swap: lock-free optimistic concurrency for callers contending
123/// on one key.
124#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
125pub enum CasExpect {
126    /// Apply only if the key currently holds this exact [`KvEntry::version`].
127    Match(u64),
128    /// Apply only if the key does not currently exist (a create-if-absent).
129    Absent,
130}
131
132/// Compare-and-swap write at `key` in `namespace`: apply `value` (with optional
133/// expiry) only if `expect` holds, else fail with
134/// [`KvError::VersionConflict`]. The swap half of optimistic concurrency, paired
135/// with [`KvEntry::version`] as the compare token.
136#[derive(Clone, Debug, Serialize, Deserialize)]
137pub struct KvCas {
138    pub v: u32,
139    pub namespace: String,
140    #[serde(with = "crate::encoding::bin_bytes")]
141    pub key: Vec<u8>,
142    #[serde(with = "crate::encoding::bin_bytes")]
143    pub value: Vec<u8>,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub expires_at_micros: Option<u64>,
146    pub expect: CasExpect,
147}
148
149/// Fenced compare-and-swap: the [`KvCas`] write and precondition, applied only
150/// while the task's fence sequence still equals `fence_token` (the at-most-one
151/// effective-writer gate). A failed fence maps to [`KvError::LeaseLost`], a failed
152/// precondition to [`KvError::VersionConflict`]. Additive over
153/// [`crate::codes::KV_OP_VERSION`] 1.
154#[derive(Clone, Debug, Serialize, Deserialize)]
155pub struct KvCasFenced {
156    pub v: u32,
157    pub namespace: String,
158    #[serde(with = "crate::encoding::bin_bytes")]
159    pub key: Vec<u8>,
160    #[serde(with = "crate::encoding::bin_bytes")]
161    pub value: Vec<u8>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub expires_at_micros: Option<u64>,
164    pub expect: CasExpect,
165    /// The task's fence-sequence key, in the reserved fence namespace the plane
166    /// owns.
167    #[serde(with = "crate::encoding::bin_bytes")]
168    pub fence_key: Vec<u8>,
169    /// The fencing token: the fence-sequence value the holder was granted.
170    /// Strictly monotonic per task, never the lease version.
171    pub fence_token: u64,
172}
173
174/// Request to remove `key` from `namespace`. With `if_match` set to a version,
175/// the delete applies only when the live version matches (a conditional delete),
176/// returning [`KvError::VersionConflict`] otherwise.
177#[derive(Clone, Debug, Serialize, Deserialize)]
178pub struct KvDelete {
179    pub v: u32,
180    pub namespace: String,
181    #[serde(with = "crate::encoding::bin_bytes")]
182    pub key: Vec<u8>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub if_match: Option<u64>,
185}
186
187/// Request to test presence and read metadata without the value. The cheap way
188/// to check a precondition before transferring a large value (the formal
189/// `EXISTS` primitive).
190#[derive(Clone, Debug, Serialize, Deserialize)]
191pub struct KvExists {
192    pub v: u32,
193    pub namespace: String,
194    #[serde(with = "crate::encoding::bin_bytes")]
195    pub key: Vec<u8>,
196}
197
198/// Request to set, refresh, or clear a key's expiry in place without rewriting
199/// its value. `expires_at_micros` of `None` clears the expiry (the formal
200/// `EXPIRE` primitive).
201#[derive(Clone, Debug, Serialize, Deserialize)]
202pub struct KvExpire {
203    pub v: u32,
204    pub namespace: String,
205    #[serde(with = "crate::encoding::bin_bytes")]
206    pub key: Vec<u8>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub expires_at_micros: Option<u64>,
209}
210
211/// Request to apply a merge patch to a structured value without transferring the
212/// whole object. `patch` is a codec-specific patch document (the `content_type`
213/// names the format). With `if_match` set, the patch applies only at that version
214/// (the formal `PATCH` primitive).
215#[derive(Clone, Debug, Serialize, Deserialize)]
216pub struct KvPatch {
217    pub v: u32,
218    pub namespace: String,
219    #[serde(with = "crate::encoding::bin_bytes")]
220    pub key: Vec<u8>,
221    #[serde(with = "crate::encoding::bin_bytes")]
222    pub patch: Vec<u8>,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub if_match: Option<u64>,
225}
226
227/// Request to copy the value at `key` to `to_key` (optionally into another
228/// namespace via `to_namespace`) in a single backend transaction. `Committed`
229/// on success, `NotFound` when the source is absent. The destination is
230/// overwritten (a guarded copy composes `exists` + `cas` instead).
231#[derive(Clone, Debug, Serialize, Deserialize)]
232pub struct KvCopy {
233    pub v: u32,
234    pub namespace: String,
235    #[serde(with = "crate::encoding::bin_bytes")]
236    pub key: Vec<u8>,
237    /// The destination namespace. Absent means the source `namespace`.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub to_namespace: Option<String>,
240    #[serde(with = "crate::encoding::bin_bytes")]
241    pub to_key: Vec<u8>,
242}
243
244/// Request to move the value at `key` to `to_key`: copy plus delete of the
245/// source, one backend transaction, the same outcomes as [`KvCopy`].
246#[derive(Clone, Debug, Serialize, Deserialize)]
247pub struct KvMove {
248    pub v: u32,
249    pub namespace: String,
250    #[serde(with = "crate::encoding::bin_bytes")]
251    pub key: Vec<u8>,
252    /// The destination namespace. Absent means the source `namespace`.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub to_namespace: Option<String>,
255    #[serde(with = "crate::encoding::bin_bytes")]
256    pub to_key: Vec<u8>,
257}
258
259/// Request to acquire an advisory lease (a bounded-TTL distributed lock) on
260/// `key`. On success the holder gets a `lease_token` to present on protected
261/// mutations (the formal `LEASE` primitive). Built on compare-and-swap.
262#[derive(Clone, Debug, Serialize, Deserialize)]
263pub struct KvLease {
264    pub v: u32,
265    pub namespace: String,
266    #[serde(with = "crate::encoding::bin_bytes")]
267    pub key: Vec<u8>,
268    pub lease_ttl_micros: u64,
269}
270
271/// Request to release an advisory lease early, presenting the `lease_token` the
272/// grant returned (the formal `RELEASE` primitive).
273#[derive(Clone, Debug, Serialize, Deserialize)]
274pub struct KvRelease {
275    pub v: u32,
276    pub namespace: String,
277    #[serde(with = "crate::encoding::bin_bytes")]
278    pub key: Vec<u8>,
279    pub lease_token: u64,
280}
281
282/// One key's metadata without its value: version, expiry, and value size. The
283/// reply to [`KvExists`].
284#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
285pub struct KvMetadata {
286    pub version: u64,
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub expires_at_micros: Option<u64>,
289    pub size_bytes: usize,
290}
291
292/// Request to list every namespace that holds at least one entry for the caller.
293#[derive(Clone, Debug, Serialize, Deserialize)]
294pub struct KvNamespaces {
295    pub v: u32,
296}
297
298/// One namespace summary in a `Namespaces` reply.
299#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
300pub struct KvNamespaceInfo {
301    pub namespace: String,
302    pub entries: usize,
303}
304
305/// Request to list entries in `namespace`. With no bounds it lists the whole
306/// namespace. `prefix` matches keys that start with it in byte order. `start`
307/// and `end` bound an inclusive-start, exclusive-end key range. `key_contains`
308/// keeps only keys that are valid UTF-8 and contain the substring (binary keys
309/// are skipped). All bounds compose.
310#[derive(Clone, Debug, Serialize, Deserialize)]
311pub struct KvScan {
312    pub v: u32,
313    pub namespace: String,
314    #[serde(
315        default,
316        skip_serializing_if = "Option::is_none",
317        with = "crate::encoding::opt_bin_bytes"
318    )]
319    pub prefix: Option<Vec<u8>>,
320    #[serde(
321        default,
322        skip_serializing_if = "Option::is_none",
323        with = "crate::encoding::opt_bin_bytes"
324    )]
325    pub start: Option<Vec<u8>>,
326    #[serde(
327        default,
328        skip_serializing_if = "Option::is_none",
329        with = "crate::encoding::opt_bin_bytes"
330    )]
331    pub end: Option<Vec<u8>>,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub key_contains: Option<String>,
334    /// The conversation lens: keep only rows the given conversation wrote (the
335    /// text form of its `gen_ai.conversation.id`). The memory read view stamps
336    /// this on each record it materializes, generic key-value rows leave it
337    /// unset, so a scan narrows to one conversation's memory. Absent on the wire
338    /// when unfiltered, so a pre-lens scan stays byte-identical.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub conversation: Option<String>,
341    pub limit: usize,
342    #[serde(
343        default,
344        skip_serializing_if = "Option::is_none",
345        with = "crate::encoding::opt_bin_bytes"
346    )]
347    pub cursor: Option<Vec<u8>>,
348}
349
350/// Request to bulk-delete entries in `namespace` matching the same composed
351/// bounds as a scan (`prefix`/`start`/`end`/`key_contains`). With no bounds it
352/// clears the whole namespace. No `limit`/`cursor`, and expiry is ignored (a
353/// matching expired entry is removed too).
354#[derive(Clone, Debug, Serialize, Deserialize)]
355pub struct KvDeleteMany {
356    pub v: u32,
357    pub namespace: String,
358    #[serde(
359        default,
360        skip_serializing_if = "Option::is_none",
361        with = "crate::encoding::opt_bin_bytes"
362    )]
363    pub prefix: Option<Vec<u8>>,
364    #[serde(
365        default,
366        skip_serializing_if = "Option::is_none",
367        with = "crate::encoding::opt_bin_bytes"
368    )]
369    pub start: Option<Vec<u8>>,
370    #[serde(
371        default,
372        skip_serializing_if = "Option::is_none",
373        with = "crate::encoding::opt_bin_bytes"
374    )]
375    pub end: Option<Vec<u8>>,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub key_contains: Option<String>,
378    /// The conversation lens over a bulk delete: clear only the rows a given
379    /// conversation wrote. See [`KvScan::conversation`]. Absent when unfiltered.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub conversation: Option<String>,
382}
383
384/// The result of a key-value operation: `Ok` with the operation's outcome, or
385/// `Err` with a structured failure.
386#[derive(Clone, Debug, Serialize, Deserialize)]
387#[non_exhaustive]
388pub enum KvReply {
389    Ok(KvOutcome),
390    Err(KvError),
391}
392
393/// The successful outcome of a KV command, shaped per op.
394#[derive(Clone, Debug, Serialize, Deserialize)]
395#[non_exhaustive]
396pub enum KvOutcome {
397    /// `get`: the live entry, or `None` when absent or expired.
398    Value(Option<KvEntry>),
399    /// `set`: the write was applied.
400    Written,
401    /// `cas`: the compare-and-swap applied. Carries the entry's new version, so
402    /// the caller can chain a further conditional write without a re-read.
403    Committed { version: u64 },
404    /// `delete`: `true` when a live entry was removed, `false` when none existed.
405    Deleted(bool),
406    /// `delete_many`: the number of entries removed by a filtered bulk delete.
407    DeletedMany(usize),
408    /// `scan`: one page of entries.
409    Page(KvPage),
410    /// `namespaces`: every namespace holding at least one entry for the
411    /// caller with its entry count, sorted by name.
412    Namespaces(Vec<KvNamespaceInfo>),
413    /// `get` with `if_none_match`: the live version matched, so the body is
414    /// unchanged and not transferred (a conditional-GET hit).
415    NotModified,
416    /// `exists`: the key's metadata, or `None` when absent or expired.
417    Metadata(Option<KvMetadata>),
418    /// `expire` / `patch`: applied, carrying the entry's version. `expire` leaves
419    /// the version unchanged, `patch` bumps it.
420    Versioned { version: u64 },
421    /// `lease`: the lease was granted, carrying the fencing token and the granted
422    /// TTL (which the store may shorten from the request).
423    Leased {
424        lease_token: u64,
425        granted_ttl_micros: u64,
426    },
427    /// `release`: `true` when a held lease was released, `false` when none was
428    /// held (idempotent release).
429    Released(bool),
430}
431
432/// Why a key-value operation failed.
433#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
434#[non_exhaustive]
435pub enum KvError {
436    #[error("kv not supported: {0}")]
437    Unsupported(String),
438    #[error("invalid key: {0}")]
439    InvalidKey(String),
440    /// The request named a namespace that fails [`validate_namespace`].
441    #[error("invalid namespace: {0}")]
442    InvalidNamespace(String),
443    #[error("{what} is {size}B, exceeds cap {cap}B")]
444    TooLarge {
445        what: String,
446        size: usize,
447        cap: usize,
448    },
449    #[error("kv backend error: {0}")]
450    Backend(String),
451    #[error("unsupported kv op version (expected {expected}, got {got})")]
452    Version { expected: u32, got: u32 },
453    /// A [`KvCas`] precondition was not met. `current` is the key's present
454    /// version (`Some`) or `None` when the key does not exist, so the caller can
455    /// re-read and retry, or learn that an `Absent` precondition lost a race.
456    #[error("kv version conflict (current: {current:?})")]
457    VersionConflict { current: Option<u64> },
458    /// A held [`KvLease`] expired or was released, so its token no longer
459    /// protects mutations. The caller must re-acquire the lease.
460    #[error("kv lease lost")]
461    LeaseLost,
462    /// An in-place op (`expire`, `patch`) targeted a key that is absent or
463    /// expired (the formal `NOT_FOUND`).
464    #[error("kv key not found")]
465    NotFound,
466    /// This plane does not own the mutation partition for the key. The caller
467    /// may retry after the streaming client refreshes partition leadership.
468    #[error("not the partition leader for this key")]
469    NotLeader,
470}
471
472/// The canonical namespace rule, shared by the SDK client edge and every
473/// serving tier: non-empty, at most [`MAX_NAMESPACE_BYTES`] bytes, no ASCII
474/// control characters. The charset is otherwise open, so `/`-style hierarchy
475/// stays legal. The bound is a size and sanity cap, not a safelist.
476pub fn validate_namespace(namespace: &str) -> Result<(), InvalidError> {
477    if namespace.is_empty() {
478        return Err(InvalidError::new("namespace must not be empty"));
479    }
480    if namespace.len() > MAX_NAMESPACE_BYTES {
481        return Err(InvalidError::new(format!(
482            "namespace is {}B, exceeds cap {MAX_NAMESPACE_BYTES}B",
483            namespace.len()
484        )));
485    }
486    if namespace.bytes().any(|byte| byte.is_ascii_control()) {
487        return Err(InvalidError::new(
488            "namespace must not contain ASCII control characters",
489        ));
490    }
491    Ok(())
492}
493
494#[cfg(all(test, feature = "cbor"))]
495mod tests {
496    use super::*;
497    use crate::codes::KV_OP_VERSION;
498    use crate::framing::{decode_named, encode_named};
499
500    #[test]
501    fn given_kv_delete_many_when_round_tripped_then_should_preserve_bounds() {
502        let request = KvDeleteMany {
503            v: KV_OP_VERSION,
504            namespace: "sessions".to_owned(),
505            prefix: Some(b"user:".to_vec()),
506            start: None,
507            end: None,
508            key_contains: Some("stale".to_owned()),
509            conversation: None,
510        };
511        let bytes = encode_named(&request).expect("serializes");
512        let back: KvDeleteMany = decode_named(&bytes).expect("deserializes");
513        assert_eq!(back.prefix, Some(b"user:".to_vec()));
514        assert_eq!(back.key_contains.as_deref(), Some("stale"));
515    }
516
517    #[test]
518    fn given_kv_deleted_many_reply_when_round_tripped_then_should_preserve_count() {
519        let reply = KvReply::Ok(KvOutcome::DeletedMany(7));
520        let bytes = encode_named(&reply).expect("serializes");
521        let back: KvReply = decode_named(&bytes).expect("deserializes");
522        match back {
523            KvReply::Ok(KvOutcome::DeletedMany(n)) => assert_eq!(n, 7),
524            other => panic!("expected DeletedMany, got {other:?}"),
525        }
526    }
527
528    #[test]
529    fn given_a_set_request_when_round_tripped_then_should_preserve_value_and_expiry() {
530        let request = KvSet {
531            v: KV_OP_VERSION,
532            namespace: "sessions".to_owned(),
533            key: b"user:42".to_vec(),
534            value: b"online".to_vec(),
535            expires_at_micros: Some(1_700_000_000_000_000),
536        };
537        let bytes = encode_named(&request).expect("the request serializes");
538        let back: KvSet = decode_named(&bytes).expect("the request deserializes");
539        assert_eq!(back.key, b"user:42");
540        assert_eq!(back.value, b"online");
541        assert_eq!(back.expires_at_micros, Some(1_700_000_000_000_000));
542    }
543
544    #[test]
545    fn given_a_binary_key_entry_when_round_tripped_then_should_preserve_raw_bytes() {
546        let reply = KvReply::Ok(KvOutcome::Value(Some(KvEntry {
547            key: vec![0xff, 0x00, 0xfe],
548            value: vec![0x00, 0x01, 0x02],
549            expires_at_micros: None,
550            version: 0,
551            scope: None,
552            source: None,
553        })));
554        let bytes = encode_named(&reply).expect("the reply serializes");
555        let back: KvReply = decode_named(&bytes).expect("the reply deserializes");
556        let KvReply::Ok(KvOutcome::Value(Some(entry))) = back else {
557            panic!("expected an Ok(Value(Some)) reply");
558        };
559        assert_eq!(entry.key, vec![0xff, 0x00, 0xfe]);
560        assert_eq!(entry.key_str(), None, "non-UTF-8 key has no string form");
561        assert_eq!(entry.value, vec![0x00, 0x01, 0x02]);
562    }
563
564    #[test]
565    fn given_a_scan_page_when_round_tripped_then_should_preserve_cursor() {
566        let reply = KvReply::Ok(KvOutcome::Page(KvPage {
567            entries: vec![KvEntry {
568                key: b"a".to_vec(),
569                value: b"1".to_vec(),
570                expires_at_micros: None,
571                version: 0,
572                scope: None,
573                source: None,
574            }],
575            cursor: Some(b"a".to_vec()),
576        }));
577        let bytes = encode_named(&reply).expect("serializes");
578        let back: KvReply = decode_named(&bytes).expect("deserializes");
579        let KvReply::Ok(KvOutcome::Page(page)) = back else {
580            panic!("expected an Ok(Page) reply");
581        };
582        assert_eq!(page.entries.len(), 1);
583        assert_eq!(page.entries[0].key_str(), Some("a"));
584        assert_eq!(page.cursor.as_deref(), Some(b"a".as_ref()));
585    }
586
587    #[test]
588    fn given_a_cas_request_when_round_tripped_then_should_preserve_the_precondition() {
589        for expect in [CasExpect::Match(7), CasExpect::Absent] {
590            let request = KvCas {
591                v: KV_OP_VERSION,
592                namespace: "counters".to_owned(),
593                key: b"hits".to_vec(),
594                value: b"42".to_vec(),
595                expires_at_micros: None,
596                expect,
597            };
598            let bytes = encode_named(&request).expect("serializes");
599            let back: KvCas = decode_named(&bytes).expect("deserializes");
600            assert_eq!(back.expect, expect);
601            assert_eq!(back.key, b"hits");
602        }
603    }
604
605    #[test]
606    fn given_a_committed_reply_when_round_tripped_then_should_preserve_the_version() {
607        let reply = KvReply::Ok(KvOutcome::Committed { version: 9 });
608        let bytes = encode_named(&reply).expect("serializes");
609        let back: KvReply = decode_named(&bytes).expect("deserializes");
610        match back {
611            KvReply::Ok(KvOutcome::Committed { version }) => assert_eq!(version, 9),
612            other => panic!("expected Committed, got {other:?}"),
613        }
614    }
615
616    #[test]
617    fn given_an_exists_metadata_reply_when_round_tripped_then_should_preserve_metadata() {
618        let reply = KvReply::Ok(KvOutcome::Metadata(Some(KvMetadata {
619            version: 4,
620            expires_at_micros: Some(1_700_000_000_000_000),
621            size_bytes: 128,
622        })));
623        let bytes = encode_named(&reply).expect("serializes");
624        let back: KvReply = decode_named(&bytes).expect("deserializes");
625        let KvReply::Ok(KvOutcome::Metadata(Some(meta))) = back else {
626            panic!("expected Ok(Metadata(Some))");
627        };
628        assert_eq!(meta.version, 4);
629        assert_eq!(meta.size_bytes, 128);
630    }
631
632    #[test]
633    fn given_a_patch_request_when_round_tripped_then_should_preserve_patch_and_precondition() {
634        let request = KvPatch {
635            v: KV_OP_VERSION,
636            namespace: "docs".to_owned(),
637            key: b"doc:1".to_vec(),
638            patch: br#"{"status":"closed"}"#.to_vec(),
639            if_match: Some(3),
640        };
641        let bytes = encode_named(&request).expect("serializes");
642        let back: KvPatch = decode_named(&bytes).expect("deserializes");
643        assert_eq!(back.patch, br#"{"status":"closed"}"#);
644        assert_eq!(back.if_match, Some(3));
645    }
646
647    #[test]
648    fn given_a_lease_reply_when_round_tripped_then_should_preserve_token_and_ttl() {
649        let reply = KvReply::Ok(KvOutcome::Leased {
650            lease_token: 77,
651            granted_ttl_micros: 30_000_000,
652        });
653        let bytes = encode_named(&reply).expect("serializes");
654        let back: KvReply = decode_named(&bytes).expect("deserializes");
655        match back {
656            KvReply::Ok(KvOutcome::Leased {
657                lease_token,
658                granted_ttl_micros,
659            }) => {
660                assert_eq!(lease_token, 77);
661                assert_eq!(granted_ttl_micros, 30_000_000);
662            }
663            other => panic!("expected Leased, got {other:?}"),
664        }
665    }
666
667    #[test]
668    fn given_a_conditional_get_when_round_tripped_then_should_preserve_if_none_match() {
669        let request = KvGet {
670            v: KV_OP_VERSION,
671            namespace: "sessions".to_owned(),
672            key: b"user:1".to_vec(),
673            if_none_match: Some(5),
674        };
675        let bytes = encode_named(&request).expect("serializes");
676        let back: KvGet = decode_named(&bytes).expect("deserializes");
677        assert_eq!(back.if_none_match, Some(5));
678        // A plain get omits the precondition on the wire, so the pre-conditional
679        // contract stays byte-identical.
680        let plain = KvGet {
681            if_none_match: None,
682            ..request
683        };
684        let json = serde_json::to_string(&plain).expect("json");
685        assert!(
686            !json.contains("if_none_match"),
687            "absent precondition omitted"
688        );
689    }
690
691    #[test]
692    fn given_a_version_conflict_when_round_tripped_then_should_preserve_the_current_version() {
693        for current in [Some(3u64), None] {
694            let reply = KvReply::Err(KvError::VersionConflict { current });
695            let bytes = encode_named(&reply).expect("serializes");
696            let back: KvReply = decode_named(&bytes).expect("deserializes");
697            match back {
698                KvReply::Err(KvError::VersionConflict { current: got }) => assert_eq!(got, current),
699                other => panic!("expected VersionConflict, got {other:?}"),
700            }
701        }
702    }
703
704    #[test]
705    fn given_a_versioned_entry_when_round_tripped_then_should_preserve_version_and_skip_zero() {
706        let entry = KvEntry {
707            key: b"k".to_vec(),
708            value: b"v".to_vec(),
709            expires_at_micros: None,
710            version: 5,
711            scope: None,
712            source: None,
713        };
714        let bytes = encode_named(&entry).expect("serializes");
715        let back: KvEntry = decode_named(&bytes).expect("deserializes");
716        assert_eq!(back.version, 5);
717        // An unversioned entry (version 0) omits the field on the wire, so a
718        // pre-versioning store stays byte-identical.
719        let unversioned = KvEntry {
720            version: 0,
721            ..entry
722        };
723        let json = serde_json::to_string(&unversioned).expect("json");
724        assert!(
725            !json.contains("version"),
726            "version 0 must be omitted: {json}"
727        );
728    }
729
730    #[test]
731    fn given_a_scan_with_bounds_when_round_tripped_then_should_preserve_filters() {
732        let scan = KvScan {
733            v: KV_OP_VERSION,
734            namespace: "sessions".to_owned(),
735            prefix: Some(b"user:".to_vec()),
736            start: None,
737            end: None,
738            key_contains: Some("admin".to_owned()),
739            conversation: None,
740            limit: 50,
741            cursor: Some(b"user:9".to_vec()),
742        };
743        let bytes = encode_named(&scan).expect("serializes");
744        let back: KvScan = decode_named(&bytes).expect("deserializes");
745        assert_eq!(back.prefix.as_deref(), Some(b"user:".as_ref()));
746        assert_eq!(back.key_contains.as_deref(), Some("admin"));
747        assert_eq!(back.cursor.as_deref(), Some(b"user:9".as_ref()));
748    }
749}