First v1.1.1 commit. Adds the KV store the design notes commit to: `(app_id, collection, key)` identity with JSONB value and a per-app index. Trait lives in `picloud-shared` so the executor-core Rhai bridge (next commit), the Postgres impl, and tests all depend on the same surface without coupling crates. The `Services` bundle grows from empty to three fields: `kv`, `dead_letters` (NoopDeadLetterService stub — replaced by the Postgres impl in commit 8), and `events` (NoopEventEmitter until the outbox emitter lands with the dispatcher). Tests use `Services::default()` for an all-noop bundle. New capabilities `AppKvRead` / `AppKvWrite` join the Capability enum. They map onto the existing seven-value `Scope` (script:read / script:write) — the scope vocabulary stays locked per the `docs/versioning.md` commitment. Script-as-gate semantics in `KvServiceImpl`: capability check runs when `cx.principal.is_some()`, skipped when None (public HTTP). Cross-app isolation is enforced independently by deriving every row's `app_id` from `cx.app_id` rather than a script-passed argument. In-memory `KvRepo` impl + unit tests cover the round-trips, the cross-app isolation property, empty-collection rejection, script-as-gate behaviour for both anonymous and authed contexts, and cursor-style pagination. Postgres impl exists; integration testing waits for a real DB harness (see HANDBACK). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
224 lines
6.7 KiB
Rust
224 lines
6.7 KiB
Rust
//! Low-level Postgres CRUD over `kv_entries`. Stays storage-only;
|
|
//! authorization, event emission, and empty-collection validation live
|
|
//! one layer up in `KvServiceImpl`.
|
|
|
|
use async_trait::async_trait;
|
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
use base64::Engine as _;
|
|
use picloud_shared::{AppId, KvListPage};
|
|
use sqlx::PgPool;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum KvRepoError {
|
|
#[error("database error: {0}")]
|
|
Db(#[from] sqlx::Error),
|
|
|
|
#[error("invalid pagination cursor")]
|
|
InvalidCursor,
|
|
}
|
|
|
|
/// Repo surface. The trait is exposed so tests can substitute an
|
|
/// in-memory backing without spinning up Postgres.
|
|
#[async_trait]
|
|
pub trait KvRepo: Send + Sync {
|
|
async fn get(
|
|
&self,
|
|
app_id: AppId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, KvRepoError>;
|
|
|
|
/// Upserts the row. Returns the previous value (if any) so callers
|
|
/// can determine whether this was an `insert` or an `update` for
|
|
/// the emitted `ServiceEvent`.
|
|
async fn set(
|
|
&self,
|
|
app_id: AppId,
|
|
collection: &str,
|
|
key: &str,
|
|
value: serde_json::Value,
|
|
) -> Result<Option<serde_json::Value>, KvRepoError>;
|
|
|
|
/// Returns the deleted value if present, `None` if the row didn't
|
|
/// exist. The caller turns the `bool was-present` part into the
|
|
/// SDK's return value; the `Option<value>` part feeds the
|
|
/// `old_payload` field of the emitted delete event.
|
|
async fn delete(
|
|
&self,
|
|
app_id: AppId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, KvRepoError>;
|
|
|
|
async fn has(&self, app_id: AppId, collection: &str, key: &str) -> Result<bool, KvRepoError>;
|
|
|
|
async fn list(
|
|
&self,
|
|
app_id: AppId,
|
|
collection: &str,
|
|
cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<KvListPage, KvRepoError>;
|
|
}
|
|
|
|
pub struct PostgresKvRepo {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PostgresKvRepo {
|
|
#[must_use]
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
/// Hard ceiling on `list` page size — scripts that pass anything larger
|
|
/// silently get clamped to this. Cursor-style pagination keeps a single
|
|
/// request bounded; clients fetch the next page via the returned cursor.
|
|
const KV_LIST_MAX_LIMIT: u32 = 1_000;
|
|
const KV_LIST_DEFAULT_LIMIT: u32 = 100;
|
|
|
|
#[async_trait]
|
|
impl KvRepo for PostgresKvRepo {
|
|
async fn get(
|
|
&self,
|
|
app_id: AppId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, KvRepoError> {
|
|
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
|
"SELECT value FROM kv_entries \
|
|
WHERE app_id = $1 AND collection = $2 AND key = $3",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(collection)
|
|
.bind(key)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.map(|(v,)| v))
|
|
}
|
|
|
|
async fn set(
|
|
&self,
|
|
app_id: AppId,
|
|
collection: &str,
|
|
key: &str,
|
|
value: serde_json::Value,
|
|
) -> Result<Option<serde_json::Value>, KvRepoError> {
|
|
// `RETURNING` after `ON CONFLICT DO UPDATE` exposes the old
|
|
// value via the `xmax`/old-row trick: capture the prior value
|
|
// with a CTE so callers know whether this was insert vs update.
|
|
let row: Option<(Option<serde_json::Value>,)> = sqlx::query_as(
|
|
"WITH prev AS (\
|
|
SELECT value FROM kv_entries \
|
|
WHERE app_id = $1 AND collection = $2 AND key = $3\
|
|
), \
|
|
upserted AS (\
|
|
INSERT INTO kv_entries (app_id, collection, key, value) \
|
|
VALUES ($1, $2, $3, $4) \
|
|
ON CONFLICT (app_id, collection, key) DO UPDATE \
|
|
SET value = EXCLUDED.value, updated_at = NOW() \
|
|
RETURNING 1\
|
|
) \
|
|
SELECT (SELECT value FROM prev) FROM upserted",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(collection)
|
|
.bind(key)
|
|
.bind(value)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.and_then(|(v,)| v))
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
app_id: AppId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, KvRepoError> {
|
|
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
|
"DELETE FROM kv_entries \
|
|
WHERE app_id = $1 AND collection = $2 AND key = $3 \
|
|
RETURNING value",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(collection)
|
|
.bind(key)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.map(|(v,)| v))
|
|
}
|
|
|
|
async fn has(&self, app_id: AppId, collection: &str, key: &str) -> Result<bool, KvRepoError> {
|
|
let row: Option<(i64,)> = sqlx::query_as(
|
|
"SELECT 1 FROM kv_entries \
|
|
WHERE app_id = $1 AND collection = $2 AND key = $3",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(collection)
|
|
.bind(key)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.is_some())
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
app_id: AppId,
|
|
collection: &str,
|
|
cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<KvListPage, KvRepoError> {
|
|
let limit = if limit == 0 {
|
|
KV_LIST_DEFAULT_LIMIT
|
|
} else {
|
|
limit.min(KV_LIST_MAX_LIMIT)
|
|
};
|
|
|
|
let last_key = match cursor {
|
|
Some(c) => Some(decode_cursor(c)?),
|
|
None => None,
|
|
};
|
|
|
|
// Keyset pagination: rows beyond `last_key` ordered by key.
|
|
// `+1` to detect a "more pages" condition without a separate
|
|
// COUNT query.
|
|
let take = i64::from(limit) + 1;
|
|
let rows: Vec<(String,)> = sqlx::query_as(
|
|
"SELECT key FROM kv_entries \
|
|
WHERE app_id = $1 AND collection = $2 \
|
|
AND ($3::text IS NULL OR key > $3) \
|
|
ORDER BY key ASC \
|
|
LIMIT $4",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(collection)
|
|
.bind(last_key.as_deref())
|
|
.bind(take)
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
let mut keys: Vec<String> = rows.into_iter().map(|(k,)| k).collect();
|
|
let next_cursor = if keys.len() > limit as usize {
|
|
keys.truncate(limit as usize);
|
|
keys.last().map(|k| encode_cursor(k))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(KvListPage { keys, next_cursor })
|
|
}
|
|
}
|
|
|
|
fn encode_cursor(last_key: &str) -> String {
|
|
URL_SAFE_NO_PAD.encode(last_key.as_bytes())
|
|
}
|
|
|
|
fn decode_cursor(cursor: &str) -> Result<String, KvRepoError> {
|
|
let bytes = URL_SAFE_NO_PAD
|
|
.decode(cursor)
|
|
.map_err(|_| KvRepoError::InvalidCursor)?;
|
|
String::from_utf8(bytes).map_err(|_| KvRepoError::InvalidCursor)
|
|
}
|