Skip to main content

laser_sdk/kv/
client.rs

1use crate::error::LaserError;
2use crate::kv::{
3    AGDX_KV_CAS_CODE, AGDX_KV_CAS_FENCED_CODE, AGDX_KV_COPY_CODE, AGDX_KV_DELETE_CODE,
4    AGDX_KV_DELETE_MANY_CODE, AGDX_KV_EXISTS_CODE, AGDX_KV_EXPIRE_CODE, AGDX_KV_GET_CODE,
5    AGDX_KV_LEASE_RENEW_CODE, AGDX_KV_MOVE_CODE, AGDX_KV_NAMESPACES_CODE, AGDX_KV_PATCH_CODE,
6    AGDX_KV_RELEASE_CODE, AGDX_KV_SCAN_CODE, AGDX_KV_SET_CODE, CasExpect, DEFAULT_SCAN_LIMIT,
7    KV_LEASE_OP_VERSION, KV_OP_VERSION, KvCasFenced, KvEntry, KvError, KvGet, KvLease,
8    KvLeaseRenew, KvMetadata, KvNamespaceInfo, KvPage, KvRelease, MAX_KEY_BYTES, MAX_VALUE_BYTES,
9};
10use crate::laser::Laser;
11use crate::stream::{Codec, Decoder};
12use crate::types::ConversationId;
13use laser_wire::framing::encode_named;
14use laser_wire::kv::{
15    KvCas, KvCopy, KvDelete, KvDeleteMany, KvExists, KvExpire, KvNamespaces, KvOutcome, KvPatch,
16    KvReply, KvScan, KvSet,
17};
18use laser_wire::mutation::MutationPosition;
19use laser_wire::validate::Validate;
20use serde::Serialize;
21use serde::de::DeserializeOwned;
22use std::time::{Duration, SystemTime, UNIX_EPOCH};
23
24/// A granted revocable lease: the fencing token to present on protected
25/// mutations, the TTL the store granted (which may be shorter than requested),
26/// and the mutation position at which the answering fold applied the grant or
27/// renewal (the barrier a takeover read passes to
28/// [`Kv::get_entry_at_least`]).
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct Lease {
31    pub token: u64,
32    pub granted_ttl: Duration,
33    pub position: MutationPosition,
34}
35
36impl Laser {
37    /// A handle to the managed key-value store, scoped to `namespace`. Cheap to
38    /// create, it borrows the connection. Keys are unique within a namespace and
39    /// scans are scoped to it. The namespace is sent as
40    /// [`resource_name`](Self::resource_name) names it.
41    ///
42    /// ```no_run
43    /// # use laser_sdk::prelude::*;
44    /// # use std::time::Duration;
45    /// # async fn run(laser: &Laser) -> Result<(), LaserError> {
46    /// let kv = laser.kv("sessions");
47    /// kv.set("user:42").json(&"online")?.ttl(Duration::from_secs(300)).send().await?;
48    /// let state: Option<String> = kv.get_typed("user:42").await?;
49    /// kv.delete("user:42").await?;
50    /// # Ok(()) }
51    /// ```
52    pub fn kv(&self, namespace: impl Into<String>) -> Kv {
53        let namespace = self.resource_name(&namespace.into());
54        Kv {
55            laser: self.clone(),
56            namespace,
57            session: None,
58        }
59    }
60
61    /// Every KV namespace that holds at least one entry for this caller,
62    /// sorted. Namespace discovery for tooling and UIs: browse the store
63    /// without knowing names upfront. Read-only, user-scoped. A handle that
64    /// scopes its resources to a stream lists only that stream's namespaces,
65    /// under the names the caller gave them.
66    pub async fn kv_namespaces(&self) -> Result<Vec<KvNamespaceInfo>, LaserError> {
67        let request = KvNamespaces { v: KV_OP_VERSION };
68        match self
69            .execute_kv(None, AGDX_KV_NAMESPACES_CODE, &request)
70            .await?
71        {
72            KvOutcome::Namespaces(namespaces) => Ok(self.local_namespaces(namespaces)),
73            _ => Err(LaserError::Protocol(
74                "kv namespaces: unexpected outcome".to_owned(),
75            )),
76        }
77    }
78
79    fn local_namespaces(&self, namespaces: Vec<KvNamespaceInfo>) -> Vec<KvNamespaceInfo> {
80        namespaces
81            .into_iter()
82            .filter_map(|mut info| {
83                let local = self.local_resource_name(&info.namespace)?.to_owned();
84                info.namespace = local;
85                Some(info)
86            })
87            .collect()
88    }
89
90    // Send one KV command over the binary connection and decode the reply. Gated
91    // on `managed_kv` (set by the connect-time probe). Without it, `Unsupported`.
92    pub(crate) async fn execute_kv(
93        &self,
94        namespace: Option<&str>,
95        code: u32,
96        request: &impl Serialize,
97    ) -> Result<KvOutcome, LaserError> {
98        if let Some(namespace) = namespace {
99            laser_wire::kv::validate_namespace(namespace)?;
100        }
101        let capabilities = self.capabilities().await;
102        if !capabilities.kv.available {
103            return Err(LaserError::unsupported(
104                "kv",
105                "the key-value surface is not served by this deployment",
106            ));
107        }
108        // Fail fast on advertised version skew (see `execute_query`): a server
109        // that advertised its accepted KV op version spares the round-trip.
110        if let Some(versions) = capabilities.versions
111            && versions.kv != KV_OP_VERSION
112        {
113            return Err(KvError::Version {
114                expected: versions.kv,
115                got: KV_OP_VERSION,
116            }
117            .into());
118        }
119        let payload = encode_named(request)
120            .map_err(|error| LaserError::Codec(format!("encode request: {error}")))?;
121        let payload = self.send_raw_with_response(code, payload).await?;
122        match crate::error::decode_managed_reply::<KvReply>(&payload)? {
123            KvReply::Ok(outcome) => Ok(outcome),
124            KvReply::Err(error) => Err(error.into()),
125            _ => Err(LaserError::Protocol("kv: unknown reply variant".to_owned())),
126        }
127    }
128}
129
130/// A namespace-scoped view of the key-value store. Build it with
131/// [`Laser::kv`](crate::laser::Laser::kv).
132pub struct Kv {
133    laser: Laser,
134    namespace: String,
135    session: Option<laser_wire::agent::SessionRef>,
136}
137
138impl Kv {
139    /// This handle with every set, compare-and-swap, delete, and patch linked
140    /// to `session`, so the deployment records which session wrote each key.
141    #[must_use]
142    pub fn in_session(mut self, session: laser_wire::agent::SessionRef) -> Self {
143        self.session = Some(session);
144        self
145    }
146
147    /// The session this handle links its writes to.
148    pub fn session(&self) -> Option<&laser_wire::agent::SessionRef> {
149        self.session.as_ref()
150    }
151
152    /// The namespace this handle is bound to, as the caller named it.
153    pub fn namespace(&self) -> &str {
154        self.laser
155            .local_resource_name(&self.namespace)
156            .unwrap_or(&self.namespace)
157    }
158
159    /// The namespace this handle sends, scoped by the connection's
160    /// [`ResourceNaming`](crate::laser::ResourceNaming).
161    pub fn resource_namespace(&self) -> &str {
162        &self.namespace
163    }
164
165    /// Fetch the raw value bytes stored at `key`, or `None` if absent or
166    /// expired. The key is anything byte-like (`&str`, `&[u8]`, `Vec<u8>`, ...).
167    pub async fn get(&self, key: impl AsRef<[u8]>) -> Result<Option<Vec<u8>>, LaserError> {
168        Ok(self.get_entry(key).await?.map(|entry| entry.value))
169    }
170
171    /// Fetch the full [`KvEntry`] at `key` (key, value, expiry), or `None`.
172    pub async fn get_entry(&self, key: impl AsRef<[u8]>) -> Result<Option<KvEntry>, LaserError> {
173        let key = validated_key(key.as_ref())?;
174        let request = KvGet {
175            v: KV_OP_VERSION,
176            namespace: self.namespace.clone(),
177            key,
178            if_none_match: None,
179            min_position: None,
180        };
181        match self
182            .laser
183            .execute_kv(Some(&self.namespace), AGDX_KV_GET_CODE, &request)
184            .await?
185        {
186            KvOutcome::Value(entry) => Ok(entry),
187            other => Err(unexpected("get", &other)),
188        }
189    }
190
191    /// Barriered read: fetch the [`KvEntry`] at `key` only once the answering
192    /// fold has applied at least `min_position` (the position a [`Lease`]
193    /// grant or renewal returned), so a takeover reader cannot observe state
194    /// older than its own acquisition. A fold that does not catch up within
195    /// the server's wait surfaces as
196    /// [`KvError::Stale`](laser_wire::kv::KvError::Stale), a retryable
197    /// orchestration failure, never `None`. Requires the `kv_fenced_leases`
198    /// capability: a pre-fencing server would silently ignore the barrier.
199    pub async fn get_entry_at_least(
200        &self,
201        key: impl AsRef<[u8]>,
202        min_position: MutationPosition,
203    ) -> Result<Option<KvEntry>, LaserError> {
204        let key = validated_key(key.as_ref())?;
205        self.require_fenced_leases("get_entry_at_least").await?;
206        let request = KvGet {
207            v: KV_OP_VERSION,
208            namespace: self.namespace.clone(),
209            key,
210            if_none_match: None,
211            min_position: Some(min_position),
212        };
213        match self
214            .laser
215            .execute_kv(Some(&self.namespace), AGDX_KV_GET_CODE, &request)
216            .await?
217        {
218            KvOutcome::Value(entry) => Ok(entry),
219            other => Err(unexpected("get", &other)),
220        }
221    }
222
223    /// Fetch and JSON-decode the value at `key` into `T`, or `None` if absent.
224    /// Sugar for [`get_as`](Self::get_as) with the `Json` codec.
225    pub async fn get_typed<T: DeserializeOwned>(
226        &self,
227        key: impl AsRef<[u8]>,
228    ) -> Result<Option<T>, LaserError> {
229        self.get_as::<crate::stream::Json, T>(key).await
230    }
231
232    /// Fetch and decode the value at `key` with any [`Decoder`] (`Json`,
233    /// `Msgpack`, or your own codec), or `None` if absent. The store keeps
234    /// values as opaque bytes, so the codec is the caller's choice, and reads
235    /// are no more JSON-locked than writes.
236    ///
237    /// ```no_run
238    /// # use laser_sdk::prelude::*;
239    /// # use laser_sdk::stream::Msgpack;
240    /// # use serde::Deserialize;
241    /// # #[derive(Deserialize)] struct Session { user: String }
242    /// # async fn run(kv: &laser_sdk::kv::Kv) -> Result<(), LaserError> {
243    /// let session: Option<Session> = kv.get_as::<Msgpack, _>("user:42").await?;
244    /// # Ok(()) }
245    /// ```
246    pub async fn get_as<C, T>(&self, key: impl AsRef<[u8]>) -> Result<Option<T>, LaserError>
247    where
248        C: Decoder<T>,
249    {
250        match self.get(key).await? {
251            Some(payload) => C::decode(&payload).map(Some).map_err(LaserError::from),
252            None => Ok(None),
253        }
254    }
255
256    /// Start a `set`. Finish with `.send().await` after supplying a value
257    /// (`.bytes` / `.json` / `.msgpack` / `.encode_with`) and an optional
258    /// `.ttl` / `.expires_at`. The key is anything byte-like.
259    pub fn set(&self, key: impl AsRef<[u8]>) -> KvSetRequest {
260        KvSetRequest {
261            laser: self.laser.clone(),
262            namespace: self.namespace.clone(),
263            session: self.session.clone(),
264            key: key.as_ref().to_vec(),
265            value: Vec::new(),
266            expires_at_micros: None,
267            expect: None,
268        }
269    }
270
271    /// Start a fenced compare-and-swap on `key`: the write applies in one
272    /// backend transaction that requires a live lease at (`fence_namespace`,
273    /// `fence_key`) and a fence sequence still equal to `fence_token` (the
274    /// values a [`lease`](Self::lease) returned). Supply a value and a
275    /// precondition (`.expect_absent()` or `.expect_version(..)`), then
276    /// `.commit().await`. The at-most-one-effective-writer gate for an
277    /// exclusive effect: a zombie holder at an older token, or one whose lease
278    /// expired or was released, is rejected before another holder acquires.
279    pub fn cas_fenced(
280        &self,
281        key: impl AsRef<[u8]>,
282        fence_namespace: impl Into<String>,
283        fence_key: impl AsRef<[u8]>,
284        fence_token: u64,
285    ) -> KvCasFencedRequest {
286        KvCasFencedRequest {
287            laser: self.laser.clone(),
288            namespace: self.namespace.clone(),
289            key: key.as_ref().to_vec(),
290            value: Vec::new(),
291            expires_at_micros: None,
292            expect: None,
293            fence_namespace: self.laser.resource_name(&fence_namespace.into()),
294            fence_key: fence_key.as_ref().to_vec(),
295            fence_token,
296        }
297    }
298
299    /// Delete `key`. Returns `true` when a live entry was removed, `false` when
300    /// none existed.
301    pub async fn delete(&self, key: impl AsRef<[u8]>) -> Result<bool, LaserError> {
302        let key = validated_key(key.as_ref())?;
303        let request = KvDelete {
304            v: KV_OP_VERSION,
305            namespace: self.namespace.clone(),
306            session: self.session.clone(),
307            key,
308            if_match: None,
309        };
310        match self
311            .laser
312            .execute_kv(Some(&self.namespace), AGDX_KV_DELETE_CODE, &request)
313            .await?
314        {
315            KvOutcome::Deleted(existed) => Ok(existed),
316            other => Err(unexpected("delete", &other)),
317        }
318    }
319
320    /// Test presence and read metadata (version, expiry, value size) without
321    /// transferring the value. The cheap precondition check before a large fetch.
322    pub async fn exists(&self, key: impl AsRef<[u8]>) -> Result<Option<KvMetadata>, LaserError> {
323        let key = validated_key(key.as_ref())?;
324        let request = KvExists {
325            v: KV_OP_VERSION,
326            namespace: self.namespace.clone(),
327            key,
328        };
329        match self
330            .laser
331            .execute_kv(Some(&self.namespace), AGDX_KV_EXISTS_CODE, &request)
332            .await?
333        {
334            KvOutcome::Metadata(metadata) => Ok(metadata),
335            other => Err(unexpected("exists", &other)),
336        }
337    }
338
339    /// Set or refresh the entry's expiry in place without rewriting its value.
340    /// `ttl` of `None` clears the expiry. Returns the entry's (unchanged) version.
341    pub async fn expire(
342        &self,
343        key: impl AsRef<[u8]>,
344        ttl: Option<Duration>,
345    ) -> Result<u64, LaserError> {
346        let expires_at_micros = ttl.map(|ttl| now_micros().saturating_add(duration_micros(ttl)));
347        self.expire_at(key, expires_at_micros).await
348    }
349
350    /// Set the entry's absolute expiry (epoch microseconds) in place without
351    /// rewriting its value. `None` clears the expiry. Returns the entry's
352    /// (unchanged) version.
353    pub async fn expire_at(
354        &self,
355        key: impl AsRef<[u8]>,
356        expires_at_micros: Option<u64>,
357    ) -> Result<u64, LaserError> {
358        let key = validated_key(key.as_ref())?;
359        let request = KvExpire {
360            v: KV_OP_VERSION,
361            namespace: self.namespace.clone(),
362            key,
363            expires_at_micros,
364        };
365        match self
366            .laser
367            .execute_kv(Some(&self.namespace), AGDX_KV_EXPIRE_CODE, &request)
368            .await?
369        {
370            KvOutcome::Versioned { version } => Ok(version),
371            other => Err(unexpected("expire", &other)),
372        }
373    }
374
375    /// Apply a merge `patch` to a structured value without transferring the whole
376    /// object, returning the new version. The patch bytes are codec-specific (a
377    /// JSON merge patch over a JSON value, for instance).
378    pub async fn patch(
379        &self,
380        key: impl AsRef<[u8]>,
381        patch: impl Into<Vec<u8>>,
382    ) -> Result<u64, LaserError> {
383        let key = validated_key(key.as_ref())?;
384        let request = KvPatch {
385            v: KV_OP_VERSION,
386            namespace: self.namespace.clone(),
387            session: self.session.clone(),
388            key,
389            patch: patch.into(),
390            if_match: None,
391        };
392        match self
393            .laser
394            .execute_kv(Some(&self.namespace), AGDX_KV_PATCH_CODE, &request)
395            .await?
396        {
397            KvOutcome::Versioned { version } => Ok(version),
398            other => Err(unexpected("patch", &other)),
399        }
400    }
401
402    /// Acquire a revocable lease (a bounded-TTL distributed lock) on `key` as
403    /// `holder` (a stable node or worker id, never a connection-scoped value).
404    /// The returned [`Lease`] carries the fencing token to present on protected
405    /// mutations, the TTL the store granted, and the grant's applied mutation
406    /// position. `ttl` is a requested maximum within
407    /// [`MIN_LEASE_TTL_MICROS`](laser_wire::limits::MIN_LEASE_TTL_MICROS)`..=`[`MAX_LEASE_TTL_MICROS`](laser_wire::limits::MAX_LEASE_TTL_MICROS);
408    /// the store may grant less and never more, so anything outside that range
409    /// is `LaserError::Invalid` before the round trip. A holder needing longer
410    /// renews. A live lease always conflicts: extend with
411    /// [`renew_lease`](Self::renew_lease), never by re-acquiring. Requires the
412    /// `kv_fenced_leases` capability, else `LaserError::Unsupported`. This path
413    /// uses the connection-backed client's dedicated coordination transport. If
414    /// the outcome is ambiguous, it actively retires that transport and waits the
415    /// requested TTL before returning `LaserError::AmbiguousMutation`, so a caller
416    /// cannot immediately retry under a fresh operation identity. Acquisitions
417    /// are serialized per client, and recovery continues if the calling future
418    /// is dropped.
419    pub async fn lease(
420        &self,
421        key: impl AsRef<[u8]>,
422        holder: impl Into<String>,
423        ttl: Duration,
424    ) -> Result<Lease, LaserError> {
425        let key = validated_key(key.as_ref())?;
426        self.require_fenced_leases("lease").await?;
427        let request = KvLease {
428            v: KV_LEASE_OP_VERSION,
429            namespace: self.namespace.clone(),
430            key,
431            lease_ttl_micros: duration_micros(ttl),
432            holder_id: holder.into(),
433            subject_user_id: None,
434        };
435        request.validate()?;
436        self.laser.acquire_lease(request).await
437    }
438
439    /// Extend a held lease without changing its token, presenting the same
440    /// `holder` and the `token` the grant returned. The returned [`Lease`]
441    /// carries the unchanged token, the granted TTL, and the renewal's applied
442    /// mutation position. `ttl` obeys the same range as
443    /// [`lease`](Self::lease). An expired, released, or re-acquired lease surfaces
444    /// as [`KvError::LeaseLost`](laser_wire::kv::KvError::LeaseLost): stop the
445    /// protected work, a new epoch needs a fresh [`lease`](Self::lease).
446    pub async fn renew_lease(
447        &self,
448        key: impl AsRef<[u8]>,
449        holder: impl Into<String>,
450        token: u64,
451        ttl: Duration,
452    ) -> Result<Lease, LaserError> {
453        let key = validated_key(key.as_ref())?;
454        self.require_fenced_leases("renew_lease").await?;
455        let request = KvLeaseRenew {
456            v: KV_LEASE_OP_VERSION,
457            namespace: self.namespace.clone(),
458            key,
459            holder_id: holder.into(),
460            subject_user_id: None,
461            lease_token: token,
462            lease_ttl_micros: duration_micros(ttl),
463        };
464        request.validate()?;
465        match self
466            .laser
467            .execute_kv(Some(&self.namespace), AGDX_KV_LEASE_RENEW_CODE, &request)
468            .await?
469        {
470            KvOutcome::Renewed {
471                lease_token,
472                granted_ttl_micros,
473                position,
474            } => Ok(Lease {
475                token: lease_token,
476                granted_ttl: Duration::from_micros(granted_ttl_micros),
477                position,
478            }),
479            other => Err(unexpected("renew_lease", &other)),
480        }
481    }
482
483    /// Release a held lease early, presenting the same `holder` and the `token`
484    /// the grant returned. Returns `true` when a held lease was released,
485    /// `false` when none was held (idempotent); a stale token surfaces as
486    /// [`KvError::LeaseLost`](laser_wire::kv::KvError::LeaseLost).
487    pub async fn release(
488        &self,
489        key: impl AsRef<[u8]>,
490        holder: impl Into<String>,
491        token: u64,
492    ) -> Result<bool, LaserError> {
493        let key = validated_key(key.as_ref())?;
494        self.require_fenced_leases("release").await?;
495        let request = KvRelease {
496            v: KV_LEASE_OP_VERSION,
497            namespace: self.namespace.clone(),
498            key,
499            lease_token: token,
500            holder_id: holder.into(),
501        };
502        request.validate()?;
503        match self
504            .laser
505            .execute_kv(Some(&request.namespace), AGDX_KV_RELEASE_CODE, &request)
506            .await?
507        {
508            KvOutcome::Released(released) => Ok(released),
509            other => Err(unexpected("release", &other)),
510        }
511    }
512
513    // A server without the fenced-lease bit would decode the reshaped lease
514    // ops under the old contract instead of rejecting them, so every lease
515    // path fails fast client-side before the round-trip.
516    async fn require_fenced_leases(&self, what: &'static str) -> Result<(), LaserError> {
517        if self.laser.capabilities().await.kv.fenced_leases {
518            Ok(())
519        } else {
520            Err(LaserError::unsupported_feature(
521                "kv",
522                what,
523                "the fenced-lease contract is not advertised by this deployment",
524            ))
525        }
526    }
527
528    /// Copy the value at `key` to `to_key` in one backend transaction: chain
529    /// [`into_namespace`](KvCopyRequest::into_namespace) for a cross-namespace
530    /// copy, then `.send().await` for the destination's new version. The
531    /// destination is overwritten (compose `exists` + `cas` for a guarded
532    /// copy), and the value moves with its remaining expiry.
533    pub fn copy_to(&self, key: impl AsRef<[u8]>, to_key: impl AsRef<[u8]>) -> KvCopyRequest {
534        KvCopyRequest {
535            laser: self.laser.clone(),
536            namespace: self.namespace.clone(),
537            key: key.as_ref().to_vec(),
538            to_namespace: None,
539            to_key: to_key.as_ref().to_vec(),
540            delete_source: false,
541        }
542    }
543
544    /// Move the value at `key` to `to_key`: [`copy_to`](Self::copy_to) plus
545    /// the source delete, one backend transaction.
546    pub fn move_to(&self, key: impl AsRef<[u8]>, to_key: impl AsRef<[u8]>) -> KvCopyRequest {
547        KvCopyRequest {
548            delete_source: true,
549            ..self.copy_to(key, to_key)
550        }
551    }
552
553    /// Point-read several keys in ONE round trip, riding the mixed-operation
554    /// batch ([`Laser::execute_batch`]): one `Option<value>` per key, in key
555    /// order. The common multi-get amortization for agent hot loops.
556    pub async fn get_many(
557        &self,
558        keys: impl IntoIterator<Item = impl AsRef<[u8]>>,
559    ) -> Result<Vec<Option<Vec<u8>>>, LaserError> {
560        let mut ops = Vec::new();
561        for key in keys {
562            let request = KvGet {
563                v: KV_OP_VERSION,
564                namespace: self.namespace.clone(),
565                key: validated_key(key.as_ref())?,
566                if_none_match: None,
567                min_position: None,
568            };
569            ops.push(laser_wire::batch::BatchItem {
570                code: AGDX_KV_GET_CODE,
571                payload: encode_named(&request)
572                    .map_err(|error| LaserError::Codec(format!("encode get: {error}")))?,
573            });
574        }
575        let results = self.laser.execute_batch(ops).await?;
576        results
577            .iter()
578            .map(
579                |slot| match crate::error::decode_managed_reply::<KvReply>(slot)? {
580                    KvReply::Ok(KvOutcome::Value(entry)) => Ok(entry.map(|entry| entry.value)),
581                    KvReply::Ok(ref other) => Err(unexpected("get", other)),
582                    KvReply::Err(error) => Err(error.into()),
583                    // The reply enums are non_exhaustive: a newer server's
584                    // unknown outcome is a protocol surprise, not a value.
585                    _ => Err(LaserError::Protocol(
586                        "unrecognized kv reply in a batch slot".to_owned(),
587                    )),
588                },
589            )
590            .collect()
591    }
592
593    /// Start a filtered bulk delete over this namespace. Narrow with `.prefix` /
594    /// `.range` / `.key_contains` (same bounds as `scan`), then `.send().await`
595    /// for the count removed. With no bounds it clears the whole namespace.
596    ///
597    /// ```no_run
598    /// # use laser_sdk::prelude::*;
599    /// # async fn run(kv: &laser_sdk::kv::Kv) -> Result<(), LaserError> {
600    /// let removed = kv.delete_many().prefix("session:").send().await?;
601    /// # let _ = removed; Ok(()) }
602    /// ```
603    pub fn delete_many(&self) -> KvDeleteManyRequest {
604        KvDeleteManyRequest {
605            laser: self.laser.clone(),
606            namespace: self.namespace.clone(),
607            prefix: None,
608            start: None,
609            end: None,
610            key_contains: None,
611            conversation: None,
612            stream: None,
613        }
614    }
615
616    /// Start a `scan` over this namespace. Finish with `.fetch().await` for one
617    /// page, or `.entries().await` to walk every match across pages.
618    pub fn scan(&self) -> KvScanRequest {
619        KvScanRequest {
620            laser: self.laser.clone(),
621            namespace: self.namespace.clone(),
622            prefix: None,
623            start: None,
624            end: None,
625            key_contains: None,
626            conversation: None,
627            stream: None,
628            limit: DEFAULT_SCAN_LIMIT,
629            cursor: None,
630        }
631    }
632}
633
634// The managed KV store is the durable, managed `StateStore`: hand a `Kv` to any
635// seam that wants a point store (a durable `Deduplicator`, a `Cursor` checkpoint,
636// per-agent state) and it persists off the log via the managed backend. Richer ops (`scan`,
637// `ttl`, `delete_many`) stay on the inherent `Kv` API. The trait is the narrow
638// `get`/`set`/`delete` shape shared with `InMemoryStore` / `FileStore`, so a
639// `StateStore`-routed `set` never expires (reach for the inherent `set(..).ttl(..)`
640// when you want a TTL). The explicit `Kv::` paths avoid resolving back into these
641// same trait methods.
642#[cfg(feature = "agent")]
643impl crate::state_store::StateStore for Kv {
644    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, LaserError> {
645        Kv::get(self, key).await
646    }
647
648    async fn set(&self, key: &str, value: Vec<u8>) -> Result<(), LaserError> {
649        Kv::set(self, key).bytes(value).send().await
650    }
651
652    async fn delete(&self, key: &str) -> Result<(), LaserError> {
653        Kv::delete(self, key).await.map(|_existed| ())
654    }
655}
656
657/// Fluent builder for `Kv::set`. Supply a value with one of `.bytes` (raw),
658/// `.json` / `.msgpack` (built-in codecs), or `.encode_with::<C>` (any codec -
659/// Avro, Protobuf, CBOR, Arrow, your own), optionally an expiry, then
660/// `.send().await`. Values are stored as opaque bytes, and the codec is the
661/// caller's choice.
662#[must_use = "call .send().await to write, or .commit().await for a compare-and-swap"]
663pub struct KvSetRequest {
664    laser: Laser,
665    namespace: String,
666    session: Option<laser_wire::agent::SessionRef>,
667    key: Vec<u8>,
668    value: Vec<u8>,
669    expires_at_micros: Option<u64>,
670    expect: Option<CasExpect>,
671}
672
673impl KvSetRequest {
674    /// Store raw `bytes` as the value (anything byte-like: `Vec<u8>`, `&[u8]`,
675    /// `&str`, ...). Use this for an already-encoded body (Avro / Protobuf /
676    /// Arrow / your own framing).
677    pub fn bytes(mut self, payload: impl AsRef<[u8]>) -> Self {
678        self.value = payload.as_ref().to_vec();
679        self
680    }
681
682    /// Encode `value` with any [`Codec`] and store the bytes. The generic path:
683    /// `.json` and `.msgpack` are one-line sugar over it.
684    pub fn encode_with<C, T>(mut self, value: &T) -> Result<Self, LaserError>
685    where
686        C: Codec<T>,
687        T: ?Sized,
688    {
689        self.value = C::encode(value)?;
690        Ok(self)
691    }
692
693    /// JSON-encode `value` and store the bytes. Sugar for
694    /// `.encode_with::<Json, _>`.
695    pub fn json<T: Serialize + ?Sized>(self, value: &T) -> Result<Self, LaserError> {
696        self.encode_with::<crate::stream::Json, T>(value)
697    }
698
699    /// MessagePack-encode `value` and store the bytes. Sugar for
700    /// `.encode_with::<Msgpack, _>`, more compact than JSON for binary state.
701    pub fn msgpack<T: Serialize + ?Sized>(self, value: &T) -> Result<Self, LaserError> {
702        self.encode_with::<crate::stream::Msgpack, T>(value)
703    }
704
705    /// Expire the entry `ttl` from now. Overrides any prior `expires_at`.
706    pub fn ttl(mut self, ttl: Duration) -> Self {
707        self.expires_at_micros = Some(now_micros().saturating_add(duration_micros(ttl)));
708        self
709    }
710
711    /// Expire the entry at an absolute epoch-microseconds timestamp.
712    pub fn expires_at(mut self, epoch_micros: u64) -> Self {
713        self.expires_at_micros = Some(epoch_micros);
714        self
715    }
716
717    /// Make this a compare-and-swap that applies only if the key currently holds
718    /// `version` (the [`KvEntry::version`] from a prior read). Finish with
719    /// [`commit`](Self::commit). Lock-free optimistic concurrency: read the
720    /// version, compute the new value, `commit`, and retry on a conflict.
721    ///
722    /// ```no_run
723    /// # use laser_sdk::prelude::*;
724    /// # async fn run(kv: &laser_sdk::kv::Kv) -> Result<(), LaserError> {
725    /// let entry = kv.get_entry("counter").await?.expect("seeded");
726    /// let prev: u64 = String::from_utf8_lossy(&entry.value).parse().unwrap_or(0);
727    /// let next = kv.set("counter").json(&(prev + 1))?.expect_version(entry.version).commit().await?;
728    /// # let _ = next; Ok(()) }
729    /// ```
730    pub fn expect_version(mut self, version: u64) -> Self {
731        self.expect = Some(CasExpect::Match(version));
732        self
733    }
734
735    /// Make this a compare-and-swap that applies only if the key does not yet
736    /// exist (a create-if-absent). Finish with [`commit`](Self::commit). A racing
737    /// writer that got there first surfaces as a conflict.
738    pub fn expect_absent(mut self) -> Self {
739        self.expect = Some(CasExpect::Absent);
740        self
741    }
742
743    /// Apply a compare-and-swap write set up by [`expect_version`](Self::expect_version)
744    /// or [`expect_absent`](Self::expect_absent), returning the entry's new
745    /// version. The precondition miss is a typed
746    /// [`KvError::VersionConflict`](laser_wire::kv::KvError::VersionConflict)
747    /// carrying the current version, so the caller can re-read and retry.
748    /// Returns `LaserError::Invalid` if no precondition was set (use `send` for
749    /// an unconditional write), and `LaserError::Unsupported` when the
750    /// deployment does not advertise the `kv_cas` capability.
751    pub async fn commit(self) -> Result<u64, LaserError> {
752        let Some(expect) = self.expect else {
753            return Err(LaserError::Invalid(
754                "commit() needs a precondition: call expect_version(..) or expect_absent()"
755                    .to_owned(),
756            ));
757        };
758        // Fail fast when the deployment does not advertise compare-and-swap.
759        // Unlike a plain set, CAS rides its own command code an unaware server
760        // rejects, but checking the advertised capability locally turns that
761        // into the documented `Unsupported` before the round-trip.
762        if !self.laser.capabilities().await.kv.cas {
763            return Err(LaserError::unsupported_feature(
764                "kv",
765                "cas",
766                "compare-and-swap is not advertised by this deployment",
767            ));
768        }
769        let key = validated_key(&self.key)?;
770        validated_value(&self.value)?;
771        let request = KvCas {
772            v: KV_OP_VERSION,
773            namespace: self.namespace,
774            session: self.session,
775            key,
776            value: self.value,
777            expires_at_micros: self.expires_at_micros,
778            expect,
779        };
780        match self
781            .laser
782            .execute_kv(Some(&request.namespace), AGDX_KV_CAS_CODE, &request)
783            .await?
784        {
785            KvOutcome::Committed { version } => Ok(version),
786            other => Err(unexpected("cas", &other)),
787        }
788    }
789
790    /// Apply the unconditional write. Returns `LaserError::Invalid` when a
791    /// precondition was set with [`expect_version`](Self::expect_version) or
792    /// [`expect_absent`](Self::expect_absent): finish those with
793    /// [`commit`](Self::commit), so a precondition is never dropped.
794    pub async fn send(self) -> Result<(), LaserError> {
795        if self.expect.is_some() {
796            return Err(LaserError::Invalid(
797                "a precondition was set: call commit() instead of send()".to_owned(),
798            ));
799        }
800        let key = validated_key(&self.key)?;
801        validated_value(&self.value)?;
802        let request = KvSet {
803            v: KV_OP_VERSION,
804            namespace: self.namespace,
805            session: self.session,
806            key,
807            value: self.value,
808            expires_at_micros: self.expires_at_micros,
809        };
810        match self
811            .laser
812            .execute_kv(Some(&request.namespace), AGDX_KV_SET_CODE, &request)
813            .await?
814        {
815            KvOutcome::Written => Ok(()),
816            other => Err(unexpected("set", &other)),
817        }
818    }
819}
820
821/// Fluent builder for `Kv::cas_fenced`. Supply a value (`.bytes` / `.json` /
822/// `.msgpack` / `.encode_with`), a precondition (`.expect_absent` or
823/// `.expect_version`), an optional expiry, then `.commit().await`. The write
824/// lands only while the task fence sequence still equals the held token.
825#[must_use = "call .commit().await to apply the fenced compare-and-swap"]
826pub struct KvCasFencedRequest {
827    laser: Laser,
828    namespace: String,
829    key: Vec<u8>,
830    value: Vec<u8>,
831    expires_at_micros: Option<u64>,
832    expect: Option<CasExpect>,
833    fence_namespace: String,
834    fence_key: Vec<u8>,
835    fence_token: u64,
836}
837
838impl KvCasFencedRequest {
839    /// Store raw `bytes` as the value (anything byte-like).
840    pub fn bytes(mut self, payload: impl AsRef<[u8]>) -> Self {
841        self.value = payload.as_ref().to_vec();
842        self
843    }
844
845    /// Encode `value` with any [`Codec`] and store the bytes.
846    pub fn encode_with<C, T>(mut self, value: &T) -> Result<Self, LaserError>
847    where
848        C: Codec<T>,
849        T: ?Sized,
850    {
851        self.value = C::encode(value)?;
852        Ok(self)
853    }
854
855    /// JSON-encode `value` and store the bytes.
856    pub fn json<T: Serialize + ?Sized>(self, value: &T) -> Result<Self, LaserError> {
857        self.encode_with::<crate::stream::Json, T>(value)
858    }
859
860    /// MessagePack-encode `value` and store the bytes.
861    pub fn msgpack<T: Serialize + ?Sized>(self, value: &T) -> Result<Self, LaserError> {
862        self.encode_with::<crate::stream::Msgpack, T>(value)
863    }
864
865    /// Expire the entry `ttl` from now.
866    pub fn ttl(mut self, ttl: Duration) -> Self {
867        self.expires_at_micros = Some(now_micros().saturating_add(duration_micros(ttl)));
868        self
869    }
870
871    /// Expire the entry at an absolute epoch-microseconds timestamp.
872    pub fn expires_at(mut self, epoch_micros: u64) -> Self {
873        self.expires_at_micros = Some(epoch_micros);
874        self
875    }
876
877    /// Apply only if the key currently holds `version`.
878    pub fn expect_version(mut self, version: u64) -> Self {
879        self.expect = Some(CasExpect::Match(version));
880        self
881    }
882
883    /// Apply only if the key does not yet exist (a create-if-absent).
884    pub fn expect_absent(mut self) -> Self {
885        self.expect = Some(CasExpect::Absent);
886        self
887    }
888
889    /// Apply the fenced compare-and-swap, returning the entry's new version. A
890    /// stale fence, or a lease that expired or was released, surfaces as
891    /// [`KvError::LeaseLost`](laser_wire::kv::KvError::LeaseLost), a
892    /// precondition miss as
893    /// [`KvError::VersionConflict`](laser_wire::kv::KvError::VersionConflict).
894    /// Returns `LaserError::Invalid` if no precondition was set, and
895    /// `LaserError::Unsupported` when the deployment does not advertise the
896    /// `kv_fenced_leases` capability (a pre-fencing server would apply the
897    /// old fence-only contract instead of rejecting the reshaped payload).
898    pub async fn commit(self) -> Result<u64, LaserError> {
899        let Some(expect) = self.expect else {
900            return Err(LaserError::Invalid(
901                "commit() needs a precondition: call expect_version(..) or expect_absent()"
902                    .to_owned(),
903            ));
904        };
905        // Fail fast when the deployment does not advertise the fenced-lease
906        // contract, turning the unaware-server misdecode into the documented
907        // `Unsupported` before the round-trip.
908        if !self.laser.capabilities().await.kv.fenced_leases {
909            return Err(LaserError::unsupported_feature(
910                "kv",
911                "cas_fenced",
912                "the fenced-lease contract is not advertised by this deployment",
913            ));
914        }
915        let key = validated_key(&self.key)?;
916        let fence_key = validated_key(&self.fence_key)?;
917        validated_value(&self.value)?;
918        let request = KvCasFenced {
919            v: KV_LEASE_OP_VERSION,
920            namespace: self.namespace,
921            key,
922            value: self.value,
923            expires_at_micros: self.expires_at_micros,
924            expect,
925            fence_namespace: self.fence_namespace,
926            fence_key,
927            fence_token: self.fence_token,
928        };
929        request.validate()?;
930        match self
931            .laser
932            .execute_kv(Some(&request.namespace), AGDX_KV_CAS_FENCED_CODE, &request)
933            .await?
934        {
935            KvOutcome::Committed { version } => Ok(version),
936            other => Err(unexpected("cas_fenced", &other)),
937        }
938    }
939}
940
941/// Fluent builder for `Kv::scan`. Narrow with `.prefix` or `.range`, cap with
942/// `.limit`, then `.fetch()` for a page or `.entries()` for everything.
943#[must_use = "call .fetch().await or .entries().await to read the page"]
944pub struct KvScanRequest {
945    laser: Laser,
946    namespace: String,
947    prefix: Option<Vec<u8>>,
948    start: Option<Vec<u8>>,
949    end: Option<Vec<u8>>,
950    key_contains: Option<String>,
951    conversation: Option<String>,
952    stream: Option<String>,
953    limit: usize,
954    cursor: Option<Vec<u8>>,
955}
956
957impl KvScanRequest {
958    /// The conversation lens: keep only entries the given conversation wrote.
959    /// The memory read view stamps this on every record it materializes, so a
960    /// scan of a memory namespace narrows to one conversation's memory. Generic
961    /// key-value entries carry no conversation, so this filters them out.
962    pub fn conversation(mut self, conversation: ConversationId) -> Self {
963        self.conversation = Some(conversation.to_string());
964        self.stream = self.laser.resource_stream().map(str::to_owned);
965        self
966    }
967
968    /// Only keys starting with `prefix` (byte order). The prefix is any
969    /// byte-like value.
970    pub fn prefix(mut self, prefix: impl AsRef<[u8]>) -> Self {
971        self.prefix = Some(prefix.as_ref().to_vec());
972        self
973    }
974
975    /// Only keys in `[start, end)` (inclusive start, exclusive end, byte order).
976    pub fn range(mut self, start: impl AsRef<[u8]>, end: impl AsRef<[u8]>) -> Self {
977        self.start = Some(start.as_ref().to_vec());
978        self.end = Some(end.as_ref().to_vec());
979        self
980    }
981
982    /// Keep only keys that are valid UTF-8 and contain `substring`. Binary keys
983    /// are skipped. Composes with `prefix` / `range`.
984    pub fn key_contains(mut self, substring: impl Into<String>) -> Self {
985        self.key_contains = Some(substring.into());
986        self
987    }
988
989    /// Cap the page at `n` entries (clamped to `MAX_SCAN_LIMIT`).
990    pub fn limit(mut self, n: usize) -> Self {
991        self.limit = n.min(laser_wire::limits::MAX_SCAN_LIMIT);
992        self
993    }
994
995    /// Resume after a previous page's cursor.
996    pub fn cursor(mut self, cursor: impl AsRef<[u8]>) -> Self {
997        self.cursor = Some(cursor.as_ref().to_vec());
998        self
999    }
1000
1001    /// Fetch one page (entries plus the cursor to continue).
1002    pub async fn fetch(self) -> Result<KvPage, LaserError> {
1003        let request = self.request();
1004        match self
1005            .laser
1006            .execute_kv(Some(&self.namespace), AGDX_KV_SCAN_CODE, &request)
1007            .await?
1008        {
1009            KvOutcome::Page(page) => Ok(page),
1010            other => Err(unexpected("scan", &other)),
1011        }
1012    }
1013
1014    /// Walk every matching entry across pages, following the cursor until the
1015    /// scan is exhausted. Convenient when the working set fits in memory.
1016    pub async fn entries(self) -> Result<Vec<KvEntry>, LaserError> {
1017        let laser = self.laser.clone();
1018        let mut request = self.request();
1019        let mut out = Vec::new();
1020        loop {
1021            let page = match laser
1022                .execute_kv(Some(&request.namespace), AGDX_KV_SCAN_CODE, &request)
1023                .await?
1024            {
1025                KvOutcome::Page(page) => page,
1026                other => return Err(unexpected("scan", &other)),
1027            };
1028            out.extend(page.entries);
1029            match page.cursor {
1030                Some(cursor) => request.cursor = Some(cursor),
1031                None => return Ok(out),
1032            }
1033        }
1034    }
1035
1036    // The stream the conversation lens belongs to when it is not the default
1037    // stream, as for memory riding an explicitly named stream.
1038    #[cfg(feature = "agent")]
1039    pub(crate) fn lens_stream(mut self, stream: Option<String>) -> Self {
1040        self.stream = stream;
1041        self
1042    }
1043
1044    fn request(&self) -> KvScan {
1045        KvScan {
1046            v: KV_OP_VERSION,
1047            namespace: self.namespace.clone(),
1048            prefix: self.prefix.clone(),
1049            start: self.start.clone(),
1050            end: self.end.clone(),
1051            key_contains: self.key_contains.clone(),
1052            conversation: self.conversation.clone(),
1053            stream: self.stream.clone(),
1054            limit: self.limit,
1055            cursor: self.cursor.clone(),
1056        }
1057    }
1058}
1059
1060/// Fluent builder for `Kv::delete_many`. Narrow with `.prefix` / `.range` /
1061/// `.key_contains`, then `.send()` for the count removed.
1062#[must_use = "call .send().await to delete the matched entries"]
1063pub struct KvDeleteManyRequest {
1064    laser: Laser,
1065    namespace: String,
1066    prefix: Option<Vec<u8>>,
1067    start: Option<Vec<u8>>,
1068    end: Option<Vec<u8>>,
1069    key_contains: Option<String>,
1070    conversation: Option<String>,
1071    stream: Option<String>,
1072}
1073
1074impl KvDeleteManyRequest {
1075    /// The conversation lens over a bulk delete: clear only the entries the
1076    /// given conversation wrote (a memory namespace's rows for one conversation).
1077    pub fn conversation(mut self, conversation: ConversationId) -> Self {
1078        self.conversation = Some(conversation.to_string());
1079        self.stream = self.laser.resource_stream().map(str::to_owned);
1080        self
1081    }
1082
1083    /// Only keys starting with `prefix` (byte order).
1084    pub fn prefix(mut self, prefix: impl AsRef<[u8]>) -> Self {
1085        self.prefix = Some(prefix.as_ref().to_vec());
1086        self
1087    }
1088
1089    /// Only keys in `[start, end)` (inclusive start, exclusive end, byte order).
1090    pub fn range(mut self, start: impl AsRef<[u8]>, end: impl AsRef<[u8]>) -> Self {
1091        self.start = Some(start.as_ref().to_vec());
1092        self.end = Some(end.as_ref().to_vec());
1093        self
1094    }
1095
1096    /// Keep only keys that are valid UTF-8 and contain `substring`. Composes with
1097    /// `prefix` / `range`.
1098    pub fn key_contains(mut self, substring: impl Into<String>) -> Self {
1099        self.key_contains = Some(substring.into());
1100        self
1101    }
1102
1103    /// Apply the bulk delete. Returns the number of entries removed.
1104    pub async fn send(self) -> Result<usize, LaserError> {
1105        let request = KvDeleteMany {
1106            v: KV_OP_VERSION,
1107            namespace: self.namespace,
1108            prefix: self.prefix,
1109            start: self.start,
1110            end: self.end,
1111            key_contains: self.key_contains,
1112            conversation: self.conversation,
1113            stream: self.stream,
1114        };
1115        match self
1116            .laser
1117            .execute_kv(Some(&request.namespace), AGDX_KV_DELETE_MANY_CODE, &request)
1118            .await?
1119        {
1120            KvOutcome::DeletedMany(removed) => Ok(removed),
1121            other => Err(unexpected("delete_many", &other)),
1122        }
1123    }
1124}
1125
1126/// One copy or move, built by [`Kv::copy_to`] / [`Kv::move_to`], finished with
1127/// `.send().await`. `Committed`'s new destination version comes back.
1128/// An absent or expired source is the typed `KvError::NotFound`.
1129#[must_use = "call .send().await to copy or move the value"]
1130pub struct KvCopyRequest {
1131    laser: Laser,
1132    namespace: String,
1133    key: Vec<u8>,
1134    to_namespace: Option<String>,
1135    to_key: Vec<u8>,
1136    delete_source: bool,
1137}
1138
1139impl KvCopyRequest {
1140    /// Send the copy into another namespace instead of the source's.
1141    pub fn into_namespace(mut self, namespace: impl Into<String>) -> Self {
1142        self.to_namespace = Some(self.laser.resource_name(&namespace.into()));
1143        self
1144    }
1145
1146    /// Apply the copy (or move) and return the destination's new version.
1147    pub async fn send(self) -> Result<u64, LaserError> {
1148        let key = validated_key(&self.key)?;
1149        let to_key = validated_key(&self.to_key)?;
1150        let code = if self.delete_source {
1151            AGDX_KV_MOVE_CODE
1152        } else {
1153            AGDX_KV_COPY_CODE
1154        };
1155        if let Some(to_namespace) = &self.to_namespace {
1156            laser_wire::kv::validate_namespace(to_namespace)?;
1157        }
1158        // KvMove mirrors KvCopy field-for-field, so the copy shape encodes both.
1159        let request = KvCopy {
1160            v: KV_OP_VERSION,
1161            namespace: self.namespace,
1162            key,
1163            to_namespace: self.to_namespace,
1164            to_key,
1165        };
1166        match self
1167            .laser
1168            .execute_kv(Some(&request.namespace), code, &request)
1169            .await?
1170        {
1171            KvOutcome::Committed { version } => Ok(version),
1172            other => Err(unexpected("copy", &other)),
1173        }
1174    }
1175}
1176
1177// Validate a key and return it as an owned `Vec<u8>`. Keys are arbitrary bytes,
1178// non-empty, at most `MAX_KEY_BYTES`.
1179fn validated_key(key: &[u8]) -> Result<Vec<u8>, LaserError> {
1180    if key.is_empty() {
1181        return Err(LaserError::Invalid("key must not be empty".to_owned()));
1182    }
1183    if key.len() > MAX_KEY_BYTES {
1184        return Err(LaserError::Invalid(format!(
1185            "key is {}B, exceeds cap {MAX_KEY_BYTES}B",
1186            key.len()
1187        )));
1188    }
1189    Ok(key.to_vec())
1190}
1191
1192// Reject an over-cap value before the round-trip, the single check shared by
1193// `set` and the compare-and-swap `commit` so the cap and message cannot drift
1194// between the two terminals.
1195fn validated_value(value: &[u8]) -> Result<(), LaserError> {
1196    if value.len() > MAX_VALUE_BYTES {
1197        return Err(LaserError::Invalid(format!(
1198            "value is {}B, exceeds cap {MAX_VALUE_BYTES}B",
1199            value.len()
1200        )));
1201    }
1202    Ok(())
1203}
1204
1205fn unexpected(op: &str, outcome: &KvOutcome) -> LaserError {
1206    LaserError::Protocol(format!("kv {op}: unexpected reply outcome {outcome:?}"))
1207}
1208
1209fn now_micros() -> u64 {
1210    SystemTime::now()
1211        .duration_since(UNIX_EPOCH)
1212        .map(duration_micros)
1213        .unwrap_or(0)
1214}
1215
1216fn duration_micros(duration: Duration) -> u64 {
1217    u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use super::*;
1223    use crate::capabilities::Capabilities;
1224
1225    fn fenced_laser() -> Laser {
1226        let mut capabilities = Capabilities::OPEN;
1227        capabilities.kv.available = true;
1228        capabilities.kv.fenced_leases = true;
1229        Laser::from_client(crate::iggy::prelude::IggyClient::default())
1230            .with_capabilities(capabilities)
1231    }
1232
1233    fn scoped(naming: crate::laser::ResourceNaming) -> Laser {
1234        fenced_laser()
1235            .with_default_stream("acme")
1236            .with_resource_naming(naming)
1237    }
1238
1239    #[test]
1240    fn given_a_default_stream_when_opening_a_namespace_then_should_send_it_scoped() {
1241        let kv = scoped(crate::laser::ResourceNaming::Stream).kv("sessions");
1242        assert_eq!(kv.resource_namespace(), "stream:acme/sessions");
1243        assert_eq!(kv.namespace(), "sessions");
1244    }
1245
1246    #[test]
1247    fn given_bare_naming_when_opening_a_namespace_then_should_send_it_unchanged() {
1248        let kv = scoped(crate::laser::ResourceNaming::Bare).kv("sessions");
1249        assert_eq!(kv.resource_namespace(), "sessions");
1250        assert_eq!(kv.namespace(), "sessions");
1251    }
1252
1253    #[test]
1254    fn given_a_conversation_lens_when_scanning_then_should_name_the_stream() {
1255        let conversation = ConversationId::derive("lens");
1256        let laser = scoped(crate::laser::ResourceNaming::Stream);
1257        let request = laser
1258            .kv("memory")
1259            .scan()
1260            .conversation(conversation)
1261            .request();
1262        assert_eq!(request.namespace, "stream:acme/memory");
1263        assert_eq!(request.stream.as_deref(), Some("acme"));
1264        assert_eq!(request.conversation, Some(conversation.to_string()));
1265        let unfiltered = laser.kv("memory").scan().request();
1266        assert_eq!(unfiltered.stream, None);
1267        let bare = scoped(crate::laser::ResourceNaming::Bare)
1268            .kv("memory")
1269            .scan()
1270            .conversation(conversation)
1271            .request();
1272        assert_eq!(bare.stream, None);
1273    }
1274
1275    #[test]
1276    fn given_a_conversation_lens_when_deleting_many_then_should_name_the_stream() {
1277        let request = scoped(crate::laser::ResourceNaming::Stream)
1278            .kv("memory")
1279            .delete_many()
1280            .conversation(ConversationId::derive("lens"));
1281        assert_eq!(request.namespace, "stream:acme/memory");
1282        assert_eq!(request.stream.as_deref(), Some("acme"));
1283    }
1284
1285    #[test]
1286    fn given_fence_and_copy_namespaces_when_built_then_should_scope_both() {
1287        let kv = scoped(crate::laser::ResourceNaming::Stream).kv("state");
1288        let fenced = kv.cas_fenced("key", "coordination", "lease", 1);
1289        assert_eq!(fenced.namespace, "stream:acme/state");
1290        assert_eq!(fenced.fence_namespace, "stream:acme/coordination");
1291        let copy = kv.copy_to("a", "b").into_namespace("archive");
1292        assert_eq!(copy.to_namespace.as_deref(), Some("stream:acme/archive"));
1293    }
1294
1295    #[test]
1296    fn given_listed_namespaces_when_localized_then_should_keep_only_the_own_stream() {
1297        let info = |namespace: &str| KvNamespaceInfo {
1298            namespace: namespace.to_owned(),
1299            entries: 1,
1300        };
1301        let listed = vec![
1302            info("stream:acme/sessions"),
1303            info("stream:other/sessions"),
1304            info("legacy"),
1305        ];
1306        let local = scoped(crate::laser::ResourceNaming::Stream).local_namespaces(listed.clone());
1307        assert_eq!(
1308            local
1309                .iter()
1310                .map(|info| info.namespace.as_str())
1311                .collect::<Vec<_>>(),
1312            ["sessions"]
1313        );
1314        let bare = scoped(crate::laser::ResourceNaming::Bare).local_namespaces(listed);
1315        assert_eq!(bare.len(), 3);
1316    }
1317
1318    #[test]
1319    fn given_an_empty_key_when_validated_then_should_error() {
1320        assert!(matches!(validated_key(b""), Err(LaserError::Invalid(_))));
1321    }
1322
1323    #[test]
1324    fn given_an_oversized_key_when_validated_then_should_error() {
1325        let key = vec![b'x'; MAX_KEY_BYTES + 1];
1326        assert!(matches!(validated_key(&key), Err(LaserError::Invalid(_))));
1327    }
1328
1329    #[test]
1330    fn given_a_binary_key_when_validated_then_should_accept_it() {
1331        assert!(validated_key(&[0xff, 0x00, 0xfe]).is_ok());
1332    }
1333
1334    #[test]
1335    fn given_an_extreme_duration_when_converted_then_should_saturate() {
1336        assert_eq!(duration_micros(Duration::MAX), u64::MAX);
1337    }
1338
1339    #[tokio::test]
1340    async fn given_invalid_lease_inputs_when_called_then_should_reject_before_transport() {
1341        let kv = fenced_laser().kv("coordination");
1342        assert!(matches!(
1343            kv.lease("key", "", Duration::from_secs(1)).await,
1344            Err(LaserError::Invalid(_))
1345        ));
1346        assert!(matches!(
1347            kv.lease("key", "holder", Duration::ZERO).await,
1348            Err(LaserError::Invalid(_))
1349        ));
1350        assert!(matches!(
1351            kv.lease("key", "x".repeat(129), Duration::from_secs(1))
1352                .await,
1353            Err(LaserError::Invalid(_))
1354        ));
1355    }
1356
1357    #[tokio::test]
1358    async fn given_invalid_renew_release_when_called_then_should_reject_before_transport() {
1359        let kv = fenced_laser().kv("coordination");
1360        assert!(matches!(
1361            kv.renew_lease("key", "holder", 1, Duration::ZERO).await,
1362            Err(LaserError::Invalid(_))
1363        ));
1364        assert!(matches!(
1365            kv.renew_lease("key", "holder", 0, Duration::from_secs(1))
1366                .await,
1367            Err(LaserError::Invalid(_))
1368        ));
1369        assert!(matches!(
1370            kv.release("key", "holder", 0).await,
1371            Err(LaserError::Invalid(_))
1372        ));
1373    }
1374
1375    #[tokio::test]
1376    async fn given_an_empty_key_when_expiring_at_then_should_reject_before_transport() {
1377        let kv = fenced_laser().kv("state");
1378        assert!(matches!(
1379            kv.expire_at("", Some(1)).await,
1380            Err(LaserError::Invalid(_))
1381        ));
1382        assert!(matches!(
1383            kv.expire_at("", None).await,
1384            Err(LaserError::Invalid(_))
1385        ));
1386    }
1387
1388    #[tokio::test]
1389    async fn given_a_precondition_when_sending_then_should_reject_before_transport() {
1390        for request in [
1391            fenced_laser()
1392                .kv("state")
1393                .set("key")
1394                .bytes(b"value")
1395                .expect_absent(),
1396            fenced_laser()
1397                .kv("state")
1398                .set("key")
1399                .bytes(b"value")
1400                .expect_version(3),
1401        ] {
1402            assert!(matches!(request.send().await, Err(LaserError::Invalid(_))));
1403        }
1404    }
1405
1406    #[tokio::test]
1407    async fn given_an_invalid_fence_namespace_when_committing_then_should_reject_before_transport()
1408    {
1409        let result = fenced_laser()
1410            .kv("state")
1411            .cas_fenced("key", "", "lease", 1)
1412            .bytes(b"value")
1413            .expect_absent()
1414            .commit()
1415            .await;
1416        assert!(matches!(result, Err(LaserError::Invalid(_))));
1417    }
1418
1419    #[test]
1420    fn given_a_kv_error_when_converted_then_should_nest_the_typed_error() {
1421        let unsupported = LaserError::from(KvError::Unsupported("no managed backend".to_owned()));
1422        assert!(matches!(
1423            unsupported,
1424            LaserError::Kv(KvError::Unsupported(_))
1425        ));
1426        assert!(unsupported.is_unsupported());
1427
1428        let backend = LaserError::from(KvError::Backend("boom".to_owned()));
1429        assert!(matches!(backend, LaserError::Kv(KvError::Backend(_))));
1430        assert!(!backend.is_retryable());
1431
1432        let skew = LaserError::from(KvError::Version {
1433            expected: 1,
1434            got: 2,
1435        });
1436        assert!(matches!(skew, LaserError::Kv(KvError::Version { .. })));
1437        assert!(skew.is_version_skew());
1438        assert!(!skew.is_retryable());
1439    }
1440}