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