Skip to main content

laser_wire/
kv.rs

1use crate::error::InvalidError;
2use crate::limits::MAX_NAMESPACE_BYTES;
3use crate::mutation::MutationPosition;
4use serde::{Deserialize, Serialize};
5
6/// The memory scope a read-view row carries, stamped by the fold from the
7/// record's headers so recall reconstructs a memory item and narrows by scope.
8/// Absent on a generic key-value entry, which carries no scope.
9#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
10pub struct MemoryRowScope {
11    /// The item kind word, from the record body.
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub kind: Option<String>,
14    /// The producing agent, from `gen_ai.agent.id`.
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub agent: Option<String>,
17    /// The user scope layer, from `agdx.mem.user`.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub user: Option<String>,
20    /// The app scope layer, from `agdx.mem.app`.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub app: Option<String>,
23    /// The conversation that wrote the record, from `gen_ai.conversation.id`.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub conversation: Option<String>,
26    /// The origin log record this memory item was folded from (stream, topic,
27    /// partition, offset), so a reader can navigate back to the source message
28    /// while it is still on the log. Absent when unknown.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub source: Option<crate::graph::SourceRef>,
31    /// The server append time of the source record in microseconds. With the
32    /// source address, it orders items remembered on different partitions.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub timestamp_micros: Option<u64>,
35    /// The feedback weight folded for this item, the sum of every feedback
36    /// record that targets it. Recall ranks every strategy except recent by it.
37    /// Absent when no feedback was recorded.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub weight: Option<f32>,
40}
41
42/// One stored entry: an arbitrary-bytes key and value plus optional expiry. The
43/// key and value are owned `Vec<u8>`, so the public API never leaks the `bytes`
44/// crate, and they ride the wire as CBOR byte strings, byte-exact.
45#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46pub struct KvEntry {
47    #[serde(with = "crate::encoding::bin_bytes")]
48    pub key: Vec<u8>,
49    #[serde(with = "crate::encoding::bin_bytes")]
50    pub value: Vec<u8>,
51    /// Absolute expiry in epoch microseconds, or `None` for no expiry. Expired
52    /// entries are hidden on read.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub expires_at_micros: Option<u64>,
55    /// Optimistic-concurrency version, assigned by the store and bumped on every
56    /// successful mutation of this key. The token a [`KvCas`] precondition
57    /// matches against. A versioned managed backend reports `>= 1` for a live
58    /// entry and reserves `0` solely for "unversioned": a store that does not
59    /// track versions, or an entry written before versioning. A caller must
60    /// therefore never treat `0` as a valid compare token. The field is skipped
61    /// on the wire when `0` so those entries stay byte-identical.
62    #[serde(default, skip_serializing_if = "is_zero_u64")]
63    pub version: u64,
64    /// The memory scope, present only on a memory read-view row. Recall reads it
65    /// to rebuild the item and filter by scope. A generic entry leaves it `None`.
66    /// Boxed so a generic entry stays small (the scope is five optional strings).
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub scope: Option<Box<MemoryRowScope>>,
69    /// The origin log record this entry was folded from (stream, topic,
70    /// partition, offset). Every managed write goes log-first through a
71    /// mutation topic, so a stored entry points back to the record that wrote
72    /// it, the same provenance a memory row carries. Absent on an entry
73    /// written before provenance stamping, or read from a store that does not
74    /// track it. Boxed, like `scope`, so a generic entry stays small.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub source: Option<Box<crate::graph::SourceRef>>,
77}
78
79fn is_zero_u64(value: &u64) -> bool {
80    *value == 0
81}
82
83impl KvEntry {
84    /// The key decoded as UTF-8, or `None` when it is not valid UTF-8. Keys are
85    /// arbitrary bytes, so a binary key has no string form.
86    pub fn key_str(&self) -> Option<&str> {
87        std::str::from_utf8(&self.key).ok()
88    }
89}
90
91/// One page of scanned entries in key order. `page` counts from 1, and
92/// `total_results` counts every live match of the scan, so a caller reads any
93/// page directly.
94#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
95pub struct KvPage {
96    pub items: Vec<KvEntry>,
97    pub page: u32,
98    pub total_pages: u32,
99    pub total_results: u32,
100}
101
102/// Request to read the value at `key` in `namespace`. With `if_none_match` set to
103/// a version, the read returns [`KvOutcome::NotModified`] instead of the value
104/// when the live version matches (a conditional GET), so an up-to-date cache
105/// skips the body transfer.
106///
107/// With `min_position` set, the read is barriered: the answering fold must have
108/// applied at least that [`MutationPosition`] before reading, so a takeover
109/// reader cannot observe a fold that predates its own lease acquisition. The
110/// server waits a short bound and answers [`KvError::Stale`] when the fold does
111/// not catch up, never an absent value. Only a deployment advertising the
112/// `KV_FENCED_LEASES` capability honors the barrier: a pre-fencing backend
113/// ignores the field, so a client must not send it unless the bit is
114/// advertised. Absent keeps today's immediate local read, byte-identical.
115#[derive(Clone, Debug, Serialize, Deserialize)]
116pub struct KvGet {
117    pub v: u32,
118    pub namespace: String,
119    #[serde(with = "crate::encoding::bin_bytes")]
120    pub key: Vec<u8>,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub if_none_match: Option<u64>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub min_position: Option<MutationPosition>,
125}
126
127/// Request to write `value` at `key` in `namespace`, with an optional expiry.
128#[derive(Clone, Debug, Serialize, Deserialize)]
129pub struct KvSet {
130    pub v: u32,
131    pub namespace: String,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub session: Option<crate::agent::SessionRef>,
134    #[serde(with = "crate::encoding::bin_bytes")]
135    pub key: Vec<u8>,
136    #[serde(with = "crate::encoding::bin_bytes")]
137    pub value: Vec<u8>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub expires_at_micros: Option<u64>,
140    /// Expire the entry this long after the mutation's server timestamp. The
141    /// relative form a client clock cannot skew, resolved once at fold time
142    /// so every replica stores the same expiry. Mutually exclusive with
143    /// `expires_at_micros`, a request carrying both is refused.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub ttl_micros: Option<u64>,
146}
147
148/// The precondition a [`KvCas`] write must satisfy to apply. The compare half
149/// of compare-and-swap: lock-free optimistic concurrency for callers contending
150/// on one key.
151#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
152pub enum CasExpect {
153    /// Apply only if the key currently holds this exact [`KvEntry::version`].
154    Match(u64),
155    /// Apply only if the key does not currently exist (a create-if-absent).
156    Absent,
157}
158
159/// Compare-and-swap write at `key` in `namespace`: apply `value` (with optional
160/// expiry) only if `expect` holds, else fail with
161/// [`KvError::VersionConflict`]. The swap half of optimistic concurrency, paired
162/// with [`KvEntry::version`] as the compare token.
163#[derive(Clone, Debug, Serialize, Deserialize)]
164pub struct KvCas {
165    pub v: u32,
166    pub namespace: String,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub session: Option<crate::agent::SessionRef>,
169    #[serde(with = "crate::encoding::bin_bytes")]
170    pub key: Vec<u8>,
171    #[serde(with = "crate::encoding::bin_bytes")]
172    pub value: Vec<u8>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub expires_at_micros: Option<u64>,
175    /// Expire the entry this long after the mutation's server timestamp. The
176    /// relative form a client clock cannot skew, resolved once at fold time
177    /// so every replica stores the same expiry. Mutually exclusive with
178    /// `expires_at_micros`, a request carrying both is refused.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub ttl_micros: Option<u64>,
181    pub expect: CasExpect,
182}
183
184/// Fenced compare-and-swap: the [`KvCas`] write and precondition, applied in
185/// one backend transaction that also requires a **live** lease at
186/// (`fence_namespace`, `fence_key`) and a fence sequence still equal to
187/// `fence_token`. A released or expired lease, or a stale token, maps to
188/// [`KvError::LeaseLost`] even before another holder acquires, and a failed
189/// precondition to [`KvError::VersionConflict`]. The request uses
190/// [`crate::codes::KV_LEASE_OP_VERSION`]. Required fence fields have no decode
191/// defaults and any other `v` fails [`crate::validate::Validate`] fail-closed.
192#[derive(Clone, Debug, Serialize, Deserialize)]
193pub struct KvCasFenced {
194    pub v: u32,
195    pub namespace: String,
196    /// The session this write is linked to, as on [`KvCas`].
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub session: Option<crate::agent::SessionRef>,
199    #[serde(with = "crate::encoding::bin_bytes")]
200    pub key: Vec<u8>,
201    #[serde(with = "crate::encoding::bin_bytes")]
202    pub value: Vec<u8>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub expires_at_micros: Option<u64>,
205    /// Expire the entry this long after the mutation's server timestamp. The
206    /// relative form a client clock cannot skew, resolved once at fold time
207    /// so every replica stores the same expiry. Mutually exclusive with
208    /// `expires_at_micros`, a request carrying both is refused.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub ttl_micros: Option<u64>,
211    pub expect: CasExpect,
212    /// The coordination namespace holding the lease and fence rows. Authorized
213    /// separately from the target `namespace` (`kv_fence:read`), so a state
214    /// writer can present a fence without being able to mutate it.
215    pub fence_namespace: String,
216    /// The lease key whose live row and fence sequence gate this write.
217    #[serde(with = "crate::encoding::bin_bytes")]
218    pub fence_key: Vec<u8>,
219    /// The fencing token: the fence-sequence value the holder was granted.
220    /// Strictly monotonic per key, never the lease-row version.
221    pub fence_token: u64,
222}
223
224/// Request to remove `key` from `namespace`. With `if_match` set to a version,
225/// the delete applies only when the live version matches (a conditional delete),
226/// returning [`KvError::VersionConflict`] otherwise.
227#[derive(Clone, Debug, Serialize, Deserialize)]
228pub struct KvDelete {
229    pub v: u32,
230    pub namespace: String,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub session: Option<crate::agent::SessionRef>,
233    #[serde(with = "crate::encoding::bin_bytes")]
234    pub key: Vec<u8>,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub if_match: Option<u64>,
237}
238
239/// Request to test presence and read metadata without the value. The cheap way
240/// to check a precondition before transferring a large value (the formal
241/// `EXISTS` primitive).
242#[derive(Clone, Debug, Serialize, Deserialize)]
243pub struct KvExists {
244    pub v: u32,
245    pub namespace: String,
246    #[serde(with = "crate::encoding::bin_bytes")]
247    pub key: Vec<u8>,
248}
249
250/// Request to set, refresh, or clear a key's expiry in place without rewriting
251/// its value. `expires_at_micros` of `None` clears the expiry (the formal
252/// `EXPIRE` primitive).
253#[derive(Clone, Debug, Serialize, Deserialize)]
254pub struct KvExpire {
255    pub v: u32,
256    pub namespace: String,
257    #[serde(with = "crate::encoding::bin_bytes")]
258    pub key: Vec<u8>,
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub expires_at_micros: Option<u64>,
261    /// Expire the entry this long after the mutation's server timestamp. The
262    /// relative form a client clock cannot skew, resolved once at fold time
263    /// so every replica stores the same expiry. Mutually exclusive with
264    /// `expires_at_micros`, a request carrying both is refused.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub ttl_micros: Option<u64>,
267}
268
269/// Request to apply a merge patch to a structured value without transferring the
270/// whole object. `patch` is a codec-specific patch document (the `content_type`
271/// names the format). With `if_match` set, the patch applies only at that version
272/// (the formal `PATCH` primitive).
273#[derive(Clone, Debug, Serialize, Deserialize)]
274pub struct KvPatch {
275    pub v: u32,
276    pub namespace: String,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub session: Option<crate::agent::SessionRef>,
279    #[serde(with = "crate::encoding::bin_bytes")]
280    pub key: Vec<u8>,
281    #[serde(with = "crate::encoding::bin_bytes")]
282    pub patch: Vec<u8>,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub if_match: Option<u64>,
285}
286
287/// Request to copy the value at `key` to `to_key` (optionally into another
288/// namespace via `to_namespace`) in a single backend transaction. `Committed`
289/// on success, `NotFound` when the source is absent. The destination is
290/// overwritten (a guarded copy composes `exists` + `cas` instead).
291#[derive(Clone, Debug, Serialize, Deserialize)]
292pub struct KvCopy {
293    pub v: u32,
294    pub namespace: String,
295    #[serde(with = "crate::encoding::bin_bytes")]
296    pub key: Vec<u8>,
297    /// The destination namespace. Absent means the source `namespace`.
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub to_namespace: Option<String>,
300    #[serde(with = "crate::encoding::bin_bytes")]
301    pub to_key: Vec<u8>,
302}
303
304/// Request to move the value at `key` to `to_key`: copy plus delete of the
305/// source, one backend transaction, the same outcomes as [`KvCopy`].
306#[derive(Clone, Debug, Serialize, Deserialize)]
307pub struct KvMove {
308    pub v: u32,
309    pub namespace: String,
310    #[serde(with = "crate::encoding::bin_bytes")]
311    pub key: Vec<u8>,
312    /// The destination namespace. Absent means the source `namespace`.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub to_namespace: Option<String>,
315    #[serde(with = "crate::encoding::bin_bytes")]
316    pub to_key: Vec<u8>,
317}
318
319/// Request to acquire a revocable lease (a bounded-TTL distributed lock) on
320/// `key`. On success the store atomically creates the live lease row and bumps
321/// the never-expiring fence sequence. The holder gets the new `lease_token`
322/// and the granted TTL (the formal `LEASE` primitive). A live lease always
323/// conflicts: renewal is [`KvLeaseRenew`], never re-acquisition. The request
324/// uses [`crate::codes::KV_LEASE_OP_VERSION`] and requires holder identity.
325#[derive(Clone, Debug, Serialize, Deserialize)]
326pub struct KvLease {
327    pub v: u32,
328    pub namespace: String,
329    #[serde(with = "crate::encoding::bin_bytes")]
330    pub key: Vec<u8>,
331    /// Requested maximum lifetime, within
332    /// [`MIN_LEASE_TTL_MICROS`](crate::limits::MIN_LEASE_TTL_MICROS)`..=`[`MAX_LEASE_TTL_MICROS`](crate::limits::MAX_LEASE_TTL_MICROS).
333    /// A successful grant is nonzero and never exceeds this duration, so this
334    /// remains a conservative expiry bound when the grant reply is lost. The
335    /// store may grant less but never more, which is why the range is
336    /// validated on the request rather than clamped.
337    pub lease_ttl_micros: u64,
338    /// Stable identity of the acquiring holder (a node or worker id, never a
339    /// connection-scoped value). Renewal and release must present the same
340    /// holder, so a different process cannot extend or drop a lease it does
341    /// not hold.
342    pub holder_id: String,
343    /// The user the lease protects mutations for. `None` leases for the
344    /// authenticated caller. `Some` is a delegated acquisition (an operator
345    /// leasing on behalf of a runtime user) and requires the `kv_lease:admin`
346    /// grant. The fence then validates against the subject's rows.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub subject_user_id: Option<u32>,
349}
350
351/// Request to extend a held lease without changing its token (the formal
352/// `RENEW` primitive). The store requires the same holder (and subject), a
353/// live lease row, and the current fence token, then moves the expiry forward
354/// and returns [`KvOutcome::Renewed`] with the same token: renewal never bumps
355/// the fence. An expired or re-acquired lease answers
356/// [`KvError::LeaseLost`], so a deposed holder learns immediately.
357#[derive(Clone, Debug, Serialize, Deserialize)]
358pub struct KvLeaseRenew {
359    pub v: u32,
360    pub namespace: String,
361    #[serde(with = "crate::encoding::bin_bytes")]
362    pub key: Vec<u8>,
363    pub holder_id: String,
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub subject_user_id: Option<u32>,
366    pub lease_token: u64,
367    /// Requested maximum extended lifetime, within the same
368    /// [`MIN_LEASE_TTL_MICROS`](crate::limits::MIN_LEASE_TTL_MICROS)`..=`[`MAX_LEASE_TTL_MICROS`](crate::limits::MAX_LEASE_TTL_MICROS)
369    /// range as acquisition. A successful renewal is nonzero and never exceeds
370    /// this duration.
371    pub lease_ttl_micros: u64,
372}
373
374/// Request to release a held lease early, presenting the same `holder_id` and
375/// the `lease_token` the grant returned (the formal `RELEASE` primitive). The
376/// token is validated against the fence sequence, never the lease-row version
377/// (which resets on re-acquire), and the live row is physically removed in the
378/// same transaction. A missing or expired row under a still-current fence is
379/// the idempotent [`KvOutcome::Released`]`(false)`. A stale fence is
380/// [`KvError::LeaseLost`].
381#[derive(Clone, Debug, Serialize, Deserialize)]
382pub struct KvRelease {
383    pub v: u32,
384    pub namespace: String,
385    #[serde(with = "crate::encoding::bin_bytes")]
386    pub key: Vec<u8>,
387    pub lease_token: u64,
388    pub holder_id: String,
389}
390
391/// One key's metadata without its value: version, expiry, and value size. The
392/// reply to [`KvExists`].
393#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
394pub struct KvMetadata {
395    pub version: u64,
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub expires_at_micros: Option<u64>,
398    pub size_bytes: usize,
399}
400
401/// Request to list every namespace that holds at least one entry for the caller.
402#[derive(Clone, Debug, Serialize, Deserialize)]
403pub struct KvNamespaces {
404    pub v: u32,
405    /// The stream whose resources the listing covers. Required in stream
406    /// tenancy, where the server authorizes the stream and lists only its
407    /// resources.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub stream: Option<String>,
410}
411
412/// One namespace summary in a `Namespaces` reply.
413#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
414pub struct KvNamespaceInfo {
415    pub namespace: String,
416    pub entries: usize,
417}
418
419/// Request to list entries in `namespace`. With no bounds it lists the whole
420/// namespace. `prefix` matches keys that start with it in byte order. `start`
421/// and `end` bound an inclusive-start, exclusive-end key range. `key_contains`
422/// keeps only keys that are valid UTF-8 and contain the substring (binary keys
423/// are skipped). All bounds compose.
424#[derive(Clone, Debug, Serialize, Deserialize)]
425pub struct KvScan {
426    pub v: u32,
427    pub namespace: String,
428    #[serde(
429        default,
430        skip_serializing_if = "Option::is_none",
431        with = "crate::encoding::opt_bin_bytes"
432    )]
433    pub prefix: Option<Vec<u8>>,
434    #[serde(
435        default,
436        skip_serializing_if = "Option::is_none",
437        with = "crate::encoding::opt_bin_bytes"
438    )]
439    pub start: Option<Vec<u8>>,
440    #[serde(
441        default,
442        skip_serializing_if = "Option::is_none",
443        with = "crate::encoding::opt_bin_bytes"
444    )]
445    pub end: Option<Vec<u8>>,
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub key_contains: Option<String>,
448    /// The conversation lens: keep only rows the given conversation wrote (the
449    /// text form of its `gen_ai.conversation.id`). The memory read view stamps
450    /// this on each record it materializes, generic key-value rows leave it
451    /// unset, so a scan narrows to one conversation's memory. Absent on the wire
452    /// when unfiltered, so a pre-lens scan stays byte-identical.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub conversation: Option<String>,
455    /// The stream the lens conversation belongs to. Set alongside
456    /// `conversation` by a client that scopes its resources to a stream.
457    /// Absent on the wire when unset.
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub stream: Option<String>,
460    /// The memory scope lenses: keep only memory-view rows stamped with this
461    /// user (`agdx.mem.user`), agent (`gen_ai.agent.id`), or app
462    /// (`agdx.mem.app`). Generic key-value rows carry none, so a lens filters
463    /// them out. Absent on the wire when unfiltered.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub user: Option<String>,
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub agent: Option<String>,
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub app: Option<String>,
470    /// The 1-based page, over the matches in key order. Zero reads the first
471    /// page.
472    #[serde(default)]
473    pub page: u32,
474    /// The entries per page, at most [`MAX_SCAN_LIMIT`](crate::limits::MAX_SCAN_LIMIT).
475    /// Zero reads [`DEFAULT_SCAN_LIMIT`](crate::limits::DEFAULT_SCAN_LIMIT).
476    pub results: u32,
477}
478
479/// Request to bulk-delete entries in `namespace` matching the same composed
480/// bounds as a scan (`prefix`/`start`/`end`/`key_contains`). With no bounds it
481/// clears the whole namespace. No `page`/`results`, and expiry is ignored (a
482/// matching expired entry is removed too).
483#[derive(Clone, Debug, Serialize, Deserialize)]
484pub struct KvDeleteMany {
485    pub v: u32,
486    pub namespace: String,
487    #[serde(
488        default,
489        skip_serializing_if = "Option::is_none",
490        with = "crate::encoding::opt_bin_bytes"
491    )]
492    pub prefix: Option<Vec<u8>>,
493    #[serde(
494        default,
495        skip_serializing_if = "Option::is_none",
496        with = "crate::encoding::opt_bin_bytes"
497    )]
498    pub start: Option<Vec<u8>>,
499    #[serde(
500        default,
501        skip_serializing_if = "Option::is_none",
502        with = "crate::encoding::opt_bin_bytes"
503    )]
504    pub end: Option<Vec<u8>>,
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub key_contains: Option<String>,
507    /// The conversation lens over a bulk delete: clear only the rows a given
508    /// conversation wrote. See [`KvScan::conversation`]. Absent when unfiltered.
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub conversation: Option<String>,
511    /// The stream the lens conversation belongs to. See [`KvScan::stream`].
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub stream: Option<String>,
514}
515
516/// The result of a key-value operation: `Ok` with the operation's outcome, or
517/// `Err` with a structured failure.
518#[derive(Clone, Debug, Serialize, Deserialize)]
519#[non_exhaustive]
520pub enum KvReply {
521    Ok(KvOutcome),
522    Err(KvError),
523}
524
525/// The successful outcome of a KV command, shaped per op.
526#[derive(Clone, Debug, Serialize, Deserialize)]
527#[non_exhaustive]
528pub enum KvOutcome {
529    /// `get`: the live entry, or `None` when absent or expired.
530    Value(Option<KvEntry>),
531    /// `set`: the write was applied.
532    Written,
533    /// `cas`: the compare-and-swap applied. Carries the entry's new version, so
534    /// the caller can chain a further conditional write without a re-read.
535    Committed { version: u64 },
536    /// `delete`: `true` when a live entry was removed, `false` when none existed.
537    Deleted(bool),
538    /// `delete_many`: the number of entries removed by a filtered bulk delete.
539    DeletedMany(usize),
540    /// `scan`: one page of entries.
541    Page(KvPage),
542    /// `namespaces`: every namespace holding at least one entry for the
543    /// caller with its entry count, sorted by name.
544    Namespaces(Vec<KvNamespaceInfo>),
545    /// `get` with `if_none_match`: the live version matched, so the body is
546    /// unchanged and not transferred (a conditional-GET hit).
547    NotModified,
548    /// `exists`: the key's metadata, or `None` when absent or expired.
549    Metadata(Option<KvMetadata>),
550    /// `expire` / `patch`: applied, carrying the entry's version. `expire` leaves
551    /// the version unchanged, `patch` bumps it.
552    Versioned { version: u64 },
553    /// `lease`: the lease was granted, carrying the fencing token, the granted
554    /// nonzero TTL (which the store may clamp down from, but never above, the
555    /// request), and the mutation
556    /// position at which the answering fold applied the grant, the barrier a
557    /// takeover read passes back as [`KvGet::min_position`].
558    Leased {
559        lease_token: u64,
560        granted_ttl_micros: u64,
561        position: MutationPosition,
562    },
563    /// `lease_renew`: the lease was extended, carrying the unchanged token,
564    /// the granted TTL, and the renewal's applied mutation position. A caller
565    /// treating an unexpected variant here as success would trust an
566    /// unextended deadline, so decode fail-closed.
567    Renewed {
568        lease_token: u64,
569        granted_ttl_micros: u64,
570        position: MutationPosition,
571    },
572    /// `release`: `true` when a held lease was released, `false` when none was
573    /// held (idempotent release).
574    Released(bool),
575}
576
577/// Why a key-value operation failed.
578#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
579#[non_exhaustive]
580pub enum KvError {
581    #[error("kv not supported: {0}")]
582    Unsupported(String),
583    #[error("invalid key: {0}")]
584    InvalidKey(String),
585    /// The request named a namespace that fails [`validate_namespace`].
586    #[error("invalid namespace: {0}")]
587    InvalidNamespace(String),
588    #[error("{what} is {size}B, exceeds cap {cap}B")]
589    TooLarge {
590        what: String,
591        size: usize,
592        cap: usize,
593    },
594    #[error("kv backend error: {0}")]
595    Backend(String),
596    /// The request could not be served right now and the identical request may
597    /// succeed later: the store was momentarily out of reach, a connection was
598    /// refused or dropped, or a concurrency conflict was aborted. Retry with
599    /// backoff rather than treating it as a fault.
600    #[error("temporarily unavailable: {0}")]
601    Unavailable(String),
602    #[error("unsupported kv op version (expected {expected}, got {got})")]
603    Version { expected: u32, got: u32 },
604    /// A [`KvCas`] precondition was not met. `current` is the key's present
605    /// version (`Some`) or `None` when the key does not exist, so the caller can
606    /// re-read and retry, or learn that an `Absent` precondition lost a race.
607    #[error("kv version conflict (current: {current:?})")]
608    VersionConflict { current: Option<u64> },
609    /// A held [`KvLease`] expired, was released, or was re-acquired by another
610    /// holder, so its token no longer protects mutations. The caller must stop
611    /// its protected work. A new epoch needs a fresh acquisition.
612    #[error("kv lease lost")]
613    LeaseLost,
614    /// A [`KvLease`] found the key held by a live lease. `expires_at_micros`
615    /// is the holder's expiry in log time, the earliest a fresh acquisition can
616    /// succeed unless the holder renews.
617    #[error("kv lease held (expires at: {expires_at_micros:?})")]
618    LeaseHeld { expires_at_micros: Option<u64> },
619    /// A barriered [`KvGet`] could not be answered: the local fold did not
620    /// reach `required` within the server's wait bound. Explicitly never mapped
621    /// to an absent value. Retry against a caught-up fold.
622    #[error("kv read barrier not reached (required: {required:?})")]
623    Stale { required: MutationPosition },
624    /// A barriered [`KvGet`] named a position no wait can reach: an older
625    /// topic generation of the mutation log, or a partition it does not use.
626    /// Never retryable. The caller acquires again for a position on the
627    /// current log.
628    #[error("kv read barrier unreachable (required: {required:?})")]
629    BarrierUnreachable { required: MutationPosition },
630    /// An in-place op (`expire`, `patch`) targeted a key that is absent or
631    /// expired (the formal `NOT_FOUND`).
632    #[error("kv key not found")]
633    NotFound,
634    /// This managed backend does not own the mutation partition for the key. The caller
635    /// may retry after the streaming client refreshes partition leadership.
636    #[error("not the partition leader for this key")]
637    NotLeader,
638    /// The caller authenticated but lacks the grant the operation needs on
639    /// the namespace (`kv:write`, `kv_lease:admin`, `kv_fence:read`).
640    #[error("kv operation not permitted: {0}")]
641    Forbidden(String),
642    /// A write named both an absolute expiry and a relative TTL.
643    #[error("invalid kv expiry: {0}")]
644    InvalidExpiry(String),
645}
646
647/// The absolute expiry a write stores: `expires_at_micros` as given, or
648/// `ttl_micros` after `now_micros`, the mutation's server timestamp, so every
649/// replica folding the same record stores the same expiry. A write naming
650/// both is refused.
651pub fn resolve_expiry(
652    expires_at_micros: Option<u64>,
653    ttl_micros: Option<u64>,
654    now_micros: u64,
655) -> Result<Option<u64>, InvalidError> {
656    match (expires_at_micros, ttl_micros) {
657        (Some(_), Some(_)) => Err(InvalidError::new(
658            "a write names either expires_at_micros or ttl_micros, not both",
659        )),
660        (Some(expires_at_micros), None) => Ok(Some(expires_at_micros)),
661        (None, ttl_micros) => Ok(ttl_micros.map(|ttl| now_micros.saturating_add(ttl))),
662    }
663}
664
665/// The canonical namespace rule, shared by the SDK client edge and every
666/// serving tier: non-empty, at most [`MAX_NAMESPACE_BYTES`] bytes, no ASCII
667/// control characters. The charset is otherwise open, so `/`-style hierarchy
668/// stays legal. The bound is a size and sanity cap, not a safelist.
669pub fn validate_namespace(namespace: &str) -> Result<(), InvalidError> {
670    if namespace.is_empty() {
671        return Err(InvalidError::new("namespace must not be empty"));
672    }
673    if namespace.len() > MAX_NAMESPACE_BYTES {
674        return Err(InvalidError::new(format!(
675            "namespace is {}B, exceeds cap {MAX_NAMESPACE_BYTES}B",
676            namespace.len()
677        )));
678    }
679    if namespace.bytes().any(|byte| byte.is_ascii_control()) {
680        return Err(InvalidError::new(
681            "namespace must not contain ASCII control characters",
682        ));
683    }
684    Ok(())
685}
686
687#[cfg(all(test, feature = "cbor"))]
688mod tests {
689    use super::*;
690    use crate::codes::KV_OP_VERSION;
691    use crate::framing::{decode_named, encode_named};
692    use crate::result::ResultCode;
693
694    #[test]
695    fn given_a_held_lease_or_an_unreachable_barrier_when_round_tripped_then_should_keep_fields_and_codes()
696     {
697        let required = MutationPosition {
698            topic_generation: 7,
699            partition: 0,
700            offset: 3,
701        };
702        for (error, code) in [
703            (
704                KvError::LeaseHeld {
705                    expires_at_micros: Some(1_700_000_000_000_000),
706                },
707                ResultCode::Conflict,
708            ),
709            (
710                KvError::BarrierUnreachable { required },
711                ResultCode::StaleGeneration,
712            ),
713        ] {
714            let bytes = encode_named(&KvReply::Err(error.clone())).expect("serializes");
715            let back: KvReply = decode_named(&bytes).expect("deserializes");
716            assert!(matches!(back, KvReply::Err(ref decoded) if *decoded == error));
717            assert_eq!(ResultCode::from(&error), code);
718        }
719        assert!(
720            !ResultCode::StaleGeneration.is_retryable(),
721            "an unreachable barrier is never retried"
722        );
723    }
724
725    #[test]
726    fn given_an_absolute_expiry_or_a_ttl_when_resolved_then_should_store_one_expiry_and_refuse_both()
727     {
728        assert_eq!(resolve_expiry(Some(9), None, 100), Ok(Some(9)));
729        assert_eq!(
730            resolve_expiry(None, Some(30), 100),
731            Ok(Some(130)),
732            "a TTL counts from the server timestamp of the write"
733        );
734        assert_eq!(resolve_expiry(None, None, 100), Ok(None));
735        assert_eq!(
736            resolve_expiry(None, Some(u64::MAX), 100),
737            Ok(Some(u64::MAX))
738        );
739        assert!(resolve_expiry(Some(9), Some(30), 100).is_err());
740    }
741
742    #[test]
743    fn given_kv_delete_many_when_round_tripped_then_should_preserve_bounds() {
744        let request = KvDeleteMany {
745            v: KV_OP_VERSION,
746            namespace: "sessions".to_owned(),
747            prefix: Some(b"user:".to_vec()),
748            start: None,
749            end: None,
750            key_contains: Some("stale".to_owned()),
751            conversation: None,
752            stream: None,
753        };
754        let bytes = encode_named(&request).expect("serializes");
755        let back: KvDeleteMany = decode_named(&bytes).expect("deserializes");
756        assert_eq!(back.prefix, Some(b"user:".to_vec()));
757        assert_eq!(back.key_contains.as_deref(), Some("stale"));
758    }
759
760    #[test]
761    fn given_kv_deleted_many_reply_when_round_tripped_then_should_preserve_count() {
762        let reply = KvReply::Ok(KvOutcome::DeletedMany(7));
763        let bytes = encode_named(&reply).expect("serializes");
764        let back: KvReply = decode_named(&bytes).expect("deserializes");
765        match back {
766            KvReply::Ok(KvOutcome::DeletedMany(n)) => assert_eq!(n, 7),
767            other => panic!("expected DeletedMany, got {other:?}"),
768        }
769    }
770
771    #[test]
772    fn given_a_set_request_when_round_tripped_then_should_preserve_value_and_expiry() {
773        let request = KvSet {
774            v: KV_OP_VERSION,
775            namespace: "sessions".to_owned(),
776            session: None,
777            key: b"user:42".to_vec(),
778            value: b"online".to_vec(),
779            expires_at_micros: Some(1_700_000_000_000_000),
780            ttl_micros: None,
781        };
782        let bytes = encode_named(&request).expect("the request serializes");
783        let back: KvSet = decode_named(&bytes).expect("the request deserializes");
784        assert_eq!(back.key, b"user:42");
785        assert_eq!(back.value, b"online");
786        assert_eq!(back.expires_at_micros, Some(1_700_000_000_000_000));
787    }
788
789    #[test]
790    fn given_a_binary_key_entry_when_round_tripped_then_should_preserve_raw_bytes() {
791        let reply = KvReply::Ok(KvOutcome::Value(Some(KvEntry {
792            key: vec![0xff, 0x00, 0xfe],
793            value: vec![0x00, 0x01, 0x02],
794            expires_at_micros: None,
795            version: 0,
796            scope: None,
797            source: None,
798        })));
799        let bytes = encode_named(&reply).expect("the reply serializes");
800        let back: KvReply = decode_named(&bytes).expect("the reply deserializes");
801        let KvReply::Ok(KvOutcome::Value(Some(entry))) = back else {
802            panic!("expected an Ok(Value(Some)) reply");
803        };
804        assert_eq!(entry.key, vec![0xff, 0x00, 0xfe]);
805        assert_eq!(entry.key_str(), None, "non-UTF-8 key has no string form");
806        assert_eq!(entry.value, vec![0x00, 0x01, 0x02]);
807    }
808
809    #[test]
810    fn given_a_scan_page_when_round_tripped_then_should_preserve_the_page_and_totals() {
811        let page = KvPage {
812            items: vec![KvEntry {
813                key: b"a".to_vec(),
814                value: b"1".to_vec(),
815                expires_at_micros: None,
816                version: 0,
817                scope: None,
818                source: None,
819            }],
820            page: 2,
821            total_pages: 3,
822            total_results: 21,
823        };
824        let bytes = encode_named(&KvReply::Ok(KvOutcome::Page(page.clone()))).expect("serializes");
825        let back: KvReply = decode_named(&bytes).expect("deserializes");
826        let KvReply::Ok(KvOutcome::Page(decoded)) = back else {
827            panic!("expected an Ok(Page) reply");
828        };
829        assert_eq!(decoded, page);
830    }
831
832    #[test]
833    fn given_a_cas_request_when_round_tripped_then_should_preserve_the_precondition() {
834        for expect in [CasExpect::Match(7), CasExpect::Absent] {
835            let request = KvCas {
836                v: KV_OP_VERSION,
837                namespace: "counters".to_owned(),
838                session: None,
839                key: b"hits".to_vec(),
840                value: b"42".to_vec(),
841                expires_at_micros: None,
842                ttl_micros: None,
843                expect,
844            };
845            let bytes = encode_named(&request).expect("serializes");
846            let back: KvCas = decode_named(&bytes).expect("deserializes");
847            assert_eq!(back.expect, expect);
848            assert_eq!(back.key, b"hits");
849        }
850    }
851
852    #[test]
853    fn given_a_committed_reply_when_round_tripped_then_should_preserve_the_version() {
854        let reply = KvReply::Ok(KvOutcome::Committed { version: 9 });
855        let bytes = encode_named(&reply).expect("serializes");
856        let back: KvReply = decode_named(&bytes).expect("deserializes");
857        match back {
858            KvReply::Ok(KvOutcome::Committed { version }) => assert_eq!(version, 9),
859            other => panic!("expected Committed, got {other:?}"),
860        }
861    }
862
863    #[test]
864    fn given_an_exists_metadata_reply_when_round_tripped_then_should_preserve_metadata() {
865        let reply = KvReply::Ok(KvOutcome::Metadata(Some(KvMetadata {
866            version: 4,
867            expires_at_micros: Some(1_700_000_000_000_000),
868            size_bytes: 128,
869        })));
870        let bytes = encode_named(&reply).expect("serializes");
871        let back: KvReply = decode_named(&bytes).expect("deserializes");
872        let KvReply::Ok(KvOutcome::Metadata(Some(meta))) = back else {
873            panic!("expected Ok(Metadata(Some))");
874        };
875        assert_eq!(meta.version, 4);
876        assert_eq!(meta.size_bytes, 128);
877    }
878
879    #[test]
880    fn given_a_patch_request_when_round_tripped_then_should_preserve_patch_and_precondition() {
881        let request = KvPatch {
882            v: KV_OP_VERSION,
883            namespace: "docs".to_owned(),
884            session: None,
885            key: b"doc:1".to_vec(),
886            patch: br#"{"status":"closed"}"#.to_vec(),
887            if_match: Some(3),
888        };
889        let bytes = encode_named(&request).expect("serializes");
890        let back: KvPatch = decode_named(&bytes).expect("deserializes");
891        assert_eq!(back.patch, br#"{"status":"closed"}"#);
892        assert_eq!(back.if_match, Some(3));
893    }
894
895    #[test]
896    fn given_a_lease_reply_when_round_tripped_then_should_preserve_token_ttl_and_position() {
897        let position = MutationPosition {
898            topic_generation: 3,
899            partition: 1,
900            offset: 4_200,
901        };
902        for reply in [
903            KvReply::Ok(KvOutcome::Leased {
904                lease_token: 77,
905                granted_ttl_micros: 30_000_000,
906                position,
907            }),
908            KvReply::Ok(KvOutcome::Renewed {
909                lease_token: 77,
910                granted_ttl_micros: 30_000_000,
911                position,
912            }),
913        ] {
914            let bytes = encode_named(&reply).expect("serializes");
915            let back: KvReply = decode_named(&bytes).expect("deserializes");
916            match back {
917                KvReply::Ok(
918                    KvOutcome::Leased {
919                        lease_token,
920                        granted_ttl_micros,
921                        position: got,
922                    }
923                    | KvOutcome::Renewed {
924                        lease_token,
925                        granted_ttl_micros,
926                        position: got,
927                    },
928                ) => {
929                    assert_eq!(lease_token, 77);
930                    assert_eq!(granted_ttl_micros, 30_000_000);
931                    assert_eq!(got, position);
932                }
933                other => panic!("expected Leased or Renewed, got {other:?}"),
934            }
935        }
936    }
937
938    #[test]
939    fn given_a_reshaped_lease_request_when_round_tripped_then_should_preserve_holder_and_subject() {
940        let request = KvLease {
941            v: crate::codes::KV_LEASE_OP_VERSION,
942            namespace: "connectors.coordination".to_owned(),
943            key: b"source-owner".to_vec(),
944            lease_ttl_micros: 30_000_000,
945            holder_id: "node-1".to_owned(),
946            subject_user_id: Some(42),
947        };
948        let bytes = encode_named(&request).expect("serializes");
949        let back: KvLease = decode_named(&bytes).expect("deserializes");
950        assert_eq!(back.holder_id, "node-1");
951        assert_eq!(back.subject_user_id, Some(42));
952        // A self-lease omits the subject on the wire.
953        let own = KvLease {
954            subject_user_id: None,
955            ..request
956        };
957        let json = serde_json::to_string(&own).expect("json");
958        assert!(!json.contains("subject_user_id"), "absent subject omitted");
959    }
960
961    #[test]
962    fn given_a_renew_request_when_round_tripped_then_should_preserve_token_and_holder() {
963        let request = KvLeaseRenew {
964            v: crate::codes::KV_LEASE_OP_VERSION,
965            namespace: "connectors.coordination".to_owned(),
966            key: b"source-owner".to_vec(),
967            holder_id: "node-1".to_owned(),
968            subject_user_id: None,
969            lease_token: 7,
970            lease_ttl_micros: 30_000_000,
971        };
972        let bytes = encode_named(&request).expect("serializes");
973        let back: KvLeaseRenew = decode_named(&bytes).expect("deserializes");
974        assert_eq!(back.lease_token, 7);
975        assert_eq!(back.holder_id, "node-1");
976    }
977
978    #[test]
979    fn given_a_pre_fencing_lease_payload_when_decoded_then_should_fail_closed() {
980        // The v1 shapes, re-declared locally: no holder identity on lease and
981        // release, no coordination namespace on the fenced CAS. The reshape
982        // made those fields required, so an old payload must fail decode
983        // rather than fill in defaults.
984        #[derive(Serialize)]
985        struct LegacyLease<'a> {
986            v: u32,
987            namespace: &'a str,
988            #[serde(with = "crate::encoding::bin_bytes")]
989            key: &'a [u8],
990            lease_ttl_micros: u64,
991        }
992        let legacy = encode_named(&LegacyLease {
993            v: KV_OP_VERSION,
994            namespace: "sessions",
995            key: b"job",
996            lease_ttl_micros: 30_000_000,
997        })
998        .expect("legacy shape serializes");
999        assert!(
1000            decode_named::<KvLease>(&legacy).is_err(),
1001            "a holder-less lease payload must not decode"
1002        );
1003
1004        #[derive(Serialize)]
1005        struct LegacyRelease<'a> {
1006            v: u32,
1007            namespace: &'a str,
1008            #[serde(with = "crate::encoding::bin_bytes")]
1009            key: &'a [u8],
1010            lease_token: u64,
1011        }
1012        let legacy = encode_named(&LegacyRelease {
1013            v: KV_OP_VERSION,
1014            namespace: "sessions",
1015            key: b"job",
1016            lease_token: 7,
1017        })
1018        .expect("legacy shape serializes");
1019        assert!(
1020            decode_named::<KvRelease>(&legacy).is_err(),
1021            "a holder-less release payload must not decode"
1022        );
1023
1024        #[derive(Serialize)]
1025        struct LegacyCasFenced<'a> {
1026            v: u32,
1027            namespace: &'a str,
1028            #[serde(with = "crate::encoding::bin_bytes")]
1029            key: &'a [u8],
1030            #[serde(with = "crate::encoding::bin_bytes")]
1031            value: &'a [u8],
1032            expect: CasExpect,
1033            #[serde(with = "crate::encoding::bin_bytes")]
1034            fence_key: &'a [u8],
1035            fence_token: u64,
1036        }
1037        let legacy = encode_named(&LegacyCasFenced {
1038            v: KV_OP_VERSION,
1039            namespace: "sessions",
1040            key: b"cursor",
1041            value: b"42",
1042            expect: CasExpect::Absent,
1043            fence_key: b"job",
1044            fence_token: 7,
1045        })
1046        .expect("legacy shape serializes");
1047        assert!(
1048            decode_named::<KvCasFenced>(&legacy).is_err(),
1049            "a fence-only CAS payload must not decode"
1050        );
1051    }
1052
1053    #[test]
1054    fn given_a_stale_reply_when_round_tripped_then_should_preserve_the_required_position() {
1055        let required = MutationPosition {
1056            topic_generation: 2,
1057            partition: 0,
1058            offset: 99,
1059        };
1060        let reply = KvReply::Err(KvError::Stale { required });
1061        let bytes = encode_named(&reply).expect("serializes");
1062        let back: KvReply = decode_named(&bytes).expect("deserializes");
1063        match back {
1064            KvReply::Err(KvError::Stale { required: got }) => assert_eq!(got, required),
1065            other => panic!("expected Stale, got {other:?}"),
1066        }
1067    }
1068
1069    #[test]
1070    fn given_a_conditional_get_when_round_tripped_then_should_preserve_if_none_match() {
1071        let request = KvGet {
1072            v: KV_OP_VERSION,
1073            namespace: "sessions".to_owned(),
1074            key: b"user:1".to_vec(),
1075            if_none_match: Some(5),
1076            min_position: None,
1077        };
1078        let bytes = encode_named(&request).expect("serializes");
1079        let back: KvGet = decode_named(&bytes).expect("deserializes");
1080        assert_eq!(back.if_none_match, Some(5));
1081        // A plain get omits the precondition and the barrier on the wire, so
1082        // the pre-conditional contract stays byte-identical.
1083        let plain = KvGet {
1084            if_none_match: None,
1085            ..request
1086        };
1087        let json = serde_json::to_string(&plain).expect("json");
1088        assert!(
1089            !json.contains("if_none_match"),
1090            "absent precondition omitted"
1091        );
1092        assert!(!json.contains("min_position"), "absent barrier omitted");
1093    }
1094
1095    #[test]
1096    fn given_a_barriered_get_when_round_tripped_then_should_preserve_the_minimum_position() {
1097        let request = KvGet {
1098            v: KV_OP_VERSION,
1099            namespace: "connectors.state".to_owned(),
1100            key: b"source_state/pg".to_vec(),
1101            if_none_match: None,
1102            min_position: Some(MutationPosition {
1103                topic_generation: 1,
1104                partition: 0,
1105                offset: 512,
1106            }),
1107        };
1108        let bytes = encode_named(&request).expect("serializes");
1109        let back: KvGet = decode_named(&bytes).expect("deserializes");
1110        assert_eq!(
1111            back.min_position,
1112            Some(MutationPosition {
1113                topic_generation: 1,
1114                partition: 0,
1115                offset: 512,
1116            })
1117        );
1118    }
1119
1120    #[test]
1121    fn given_a_version_conflict_when_round_tripped_then_should_preserve_the_current_version() {
1122        for current in [Some(3u64), None] {
1123            let reply = KvReply::Err(KvError::VersionConflict { current });
1124            let bytes = encode_named(&reply).expect("serializes");
1125            let back: KvReply = decode_named(&bytes).expect("deserializes");
1126            match back {
1127                KvReply::Err(KvError::VersionConflict { current: got }) => assert_eq!(got, current),
1128                other => panic!("expected VersionConflict, got {other:?}"),
1129            }
1130        }
1131    }
1132
1133    #[test]
1134    fn given_a_versioned_entry_when_round_tripped_then_should_preserve_version_and_skip_zero() {
1135        let entry = KvEntry {
1136            key: b"k".to_vec(),
1137            value: b"v".to_vec(),
1138            expires_at_micros: None,
1139            version: 5,
1140            scope: None,
1141            source: None,
1142        };
1143        let bytes = encode_named(&entry).expect("serializes");
1144        let back: KvEntry = decode_named(&bytes).expect("deserializes");
1145        assert_eq!(back.version, 5);
1146        // An unversioned entry (version 0) omits the field on the wire, so a
1147        // pre-versioning store stays byte-identical.
1148        let unversioned = KvEntry {
1149            version: 0,
1150            ..entry
1151        };
1152        let json = serde_json::to_string(&unversioned).expect("json");
1153        assert!(
1154            !json.contains("version"),
1155            "version 0 must be omitted: {json}"
1156        );
1157    }
1158
1159    #[test]
1160    fn given_a_scan_with_bounds_when_round_tripped_then_should_preserve_filters() {
1161        let scan = KvScan {
1162            v: KV_OP_VERSION,
1163            namespace: "sessions".to_owned(),
1164            prefix: Some(b"user:".to_vec()),
1165            start: None,
1166            end: None,
1167            key_contains: Some("admin".to_owned()),
1168            conversation: None,
1169            stream: None,
1170            user: None,
1171            agent: None,
1172            app: None,
1173            page: 3,
1174            results: 50,
1175        };
1176        let bytes = encode_named(&scan).expect("serializes");
1177        let back: KvScan = decode_named(&bytes).expect("deserializes");
1178        assert_eq!(back.prefix.as_deref(), Some(b"user:".as_ref()));
1179        assert_eq!(back.key_contains.as_deref(), Some("admin"));
1180        assert_eq!((back.page, back.results), (3, 50));
1181        assert_eq!(back.stream, None);
1182    }
1183
1184    #[test]
1185    fn given_a_lens_with_a_stream_when_round_tripped_then_should_preserve_it_and_omit_it_when_unset()
1186     {
1187        let scan = KvScan {
1188            v: KV_OP_VERSION,
1189            namespace: "stream:acme/memory".to_owned(),
1190            prefix: None,
1191            start: None,
1192            end: None,
1193            key_contains: None,
1194            conversation: Some("01KWM3K3XEP3NP5TN850J17YBP".to_owned()),
1195            stream: Some("acme".to_owned()),
1196            user: Some("reader".to_owned()),
1197            agent: Some("planner".to_owned()),
1198            app: Some("desk".to_owned()),
1199            page: 0,
1200            results: 10,
1201        };
1202        let back: KvScan =
1203            decode_named(&encode_named(&scan).expect("serializes")).expect("deserializes");
1204        assert_eq!(back.stream.as_deref(), Some("acme"));
1205        assert_eq!(
1206            (
1207                back.user.as_deref(),
1208                back.agent.as_deref(),
1209                back.app.as_deref()
1210            ),
1211            (Some("reader"), Some("planner"), Some("desk"))
1212        );
1213        let delete = KvDeleteMany {
1214            v: KV_OP_VERSION,
1215            namespace: "stream:acme/memory".to_owned(),
1216            prefix: None,
1217            start: None,
1218            end: None,
1219            key_contains: None,
1220            conversation: Some("01KWM3K3XEP3NP5TN850J17YBP".to_owned()),
1221            stream: Some("acme".to_owned()),
1222        };
1223        let back: KvDeleteMany =
1224            decode_named(&encode_named(&delete).expect("serializes")).expect("deserializes");
1225        assert_eq!(back.stream.as_deref(), Some("acme"));
1226        let unset = KvDeleteMany {
1227            stream: None,
1228            ..delete
1229        };
1230        assert!(
1231            !serde_json::to_string(&unset)
1232                .expect("serializes")
1233                .contains("\"stream\":"),
1234            "an unset stream is omitted"
1235        );
1236    }
1237}