//! `KvService` — the v1.1.1 key-value store contract. //! //! Lives in `picloud-shared` (not `executor-core`) so the Rhai bridge, //! the manager-core Postgres impl, and any future in-memory test impl //! can all depend on the same trait without dragging //! `executor-core` into `manager-core`'s dep graph. //! //! Implementations MUST derive every storage `app_id` from `cx.app_id` //! — never from a script-passed argument. That is the cross-app //! isolation boundary; see `docs/sdk-shape.md`. use async_trait::async_trait; use thiserror::Error; use crate::SdkCallCx; /// `KvService` is collection-scoped. Scripts get a handle via /// `kv::collection(name)` and call `get`/`set`/`has`/`delete`/`list` /// on it. The trait surface accepts the collection by name so the /// Postgres impl can avoid an extra round-trip to materialize the /// collection (collections are namespaces, not first-class rows). #[async_trait] pub trait KvService: Send + Sync { async fn get( &self, cx: &SdkCallCx, collection: &str, key: &str, ) -> Result, KvError>; async fn set( &self, cx: &SdkCallCx, collection: &str, key: &str, value: serde_json::Value, ) -> Result<(), KvError>; /// Compare-and-swap. Atomically writes `new` only if the current value /// equals `expected` — or, when `expected` is `None`, only if the key is /// ABSENT (insert-if-absent). Returns `true` if the swap happened, `false` /// if the precondition failed (value differed / key already present). /// Default: unsupported, so only storage-backed impls opt in. async fn set_if( &self, cx: &SdkCallCx, collection: &str, key: &str, expected: Option, new: serde_json::Value, ) -> Result { let _ = (cx, collection, key, expected, new); Err(KvError::Backend( "set_if is not supported by this kv implementation".into(), )) } async fn delete(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result; async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result; /// Cursor-style pagination. `cursor` is opaque to the caller; /// implementations encode the resume key inside. `None` cursor /// starts from the beginning. Implementations cap `limit` at a /// reasonable ceiling internally (script can't request an unbounded /// page). async fn list( &self, cx: &SdkCallCx, collection: &str, cursor: Option<&str>, limit: u32, ) -> Result; } /// One page of keys from `KvService::list`. `next_cursor` is `Some` /// when more pages exist, `None` when exhausted. The cursor encoding /// is implementation-defined (the Postgres impl base64-encodes the /// last key). #[derive(Debug, Clone)] pub struct KvListPage { pub keys: Vec, pub next_cursor: Option, } /// Stub used by the test harness so executor-core integration tests /// (which don't touch KV) can construct a `Services` bundle without /// spinning up Postgres. Every call returns /// `KvError::Backend("...")` so accidental KV use surfaces clearly. #[derive(Debug, Default, Clone, Copy)] pub struct NoopKvService; #[async_trait] impl KvService for NoopKvService { async fn get( &self, _cx: &SdkCallCx, _collection: &str, _key: &str, ) -> Result, KvError> { Err(KvError::Backend("kv is not wired in".into())) } async fn set( &self, _cx: &SdkCallCx, _collection: &str, _key: &str, _value: serde_json::Value, ) -> Result<(), KvError> { Err(KvError::Backend("kv is not wired in".into())) } async fn delete( &self, _cx: &SdkCallCx, _collection: &str, _key: &str, ) -> Result { Err(KvError::Backend("kv is not wired in".into())) } async fn has(&self, _cx: &SdkCallCx, _collection: &str, _key: &str) -> Result { Err(KvError::Backend("kv is not wired in".into())) } async fn list( &self, _cx: &SdkCallCx, _collection: &str, _cursor: Option<&str>, _limit: u32, ) -> Result { Err(KvError::Backend("kv is not wired in".into())) } } /// Failure modes surfaced to the Rhai bridge. The bridge converts each /// to a Rhai runtime error string; the discriminants exist so internal /// callers (admin endpoints, tests, GC) can react more precisely. #[derive(Debug, Error)] pub enum KvError { /// Empty collection name; rejected at the SDK boundary per /// `docs/sdk-shape.md`. #[error("collection name must not be empty")] InvalidCollection, /// Caller principal lacked the required capability. Only raised /// when `cx.principal.is_some()` — scripts running with /// `principal: None` (public HTTP) operate under script-as-gate /// semantics and skip the capability check. #[error("forbidden")] Forbidden, /// The app is at its per-app row ceiling (`PICLOUD_APP_KV_MAX_ROWS`) and /// this write would add a key. An update to an existing key is exempt. #[error("kv: app key quota exceeded ({actual} keys; limit {limit})")] QuotaExceeded { limit: u64, actual: u64 }, /// JSON-encoded value exceeded the per-app `max_value_bytes` cap. /// Defends Postgres JSONB columns from anonymous-public-script DoS; /// the limit is configurable via `PICLOUD_KV_MAX_VALUE_BYTES`. #[error("kv: value too large ({actual} bytes; limit {limit})")] ValueTooLarge { limit: usize, actual: usize }, /// Anything else — Postgres unavailable, serialization failure, /// etc. The string is safe to surface to a script. #[error("kv backend error: {0}")] Backend(String), }