Global env-var ceilings enforced in the group write path (mirrors the per-value caps): PICLOUD_GROUP_KV_MAX_ROWS / _DOCS_MAX_ROWS (per-group row count, checked only on a NEW key/doc — updates exempt) and _FILES_MAX_TOTAL_BYTES (per-group total blob bytes). New group_quota env helpers; count_rows/total_bytes repo methods (trait-default 0 for non-Postgres); QuotaExceeded error variants on the three group error enums; a with_max_rows test builder. Pinned by a group_kv_service unit test (3rd key rejected, update of an existing key at the cap allowed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
239 lines
7.0 KiB
Rust
239 lines
7.0 KiB
Rust
//! Low-level Postgres CRUD over `group_kv_entries` (§11.6). A near-clone of
|
|
//! [`crate::kv_repo`] keyed by the owning `group_id` instead of `app_id` —
|
|
//! authorization, group resolution, event policy, and empty-collection
|
|
//! validation live one layer up in `GroupKvServiceImpl`.
|
|
|
|
use async_trait::async_trait;
|
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
use base64::Engine as _;
|
|
use picloud_shared::{GroupId, KvListPage};
|
|
use sqlx::PgPool;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum GroupKvRepoError {
|
|
#[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 GroupKvRepo: Send + Sync {
|
|
async fn get(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, GroupKvRepoError>;
|
|
|
|
/// Upserts the row. Returns the previous value (if any) so callers can
|
|
/// determine whether this was an `insert` or an `update`.
|
|
async fn set(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
value: serde_json::Value,
|
|
) -> Result<Option<serde_json::Value>, GroupKvRepoError>;
|
|
|
|
/// Returns the deleted value if present, `None` if the row didn't exist.
|
|
async fn delete(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, GroupKvRepoError>;
|
|
|
|
async fn has(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<bool, GroupKvRepoError>;
|
|
|
|
async fn list(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<KvListPage, GroupKvRepoError>;
|
|
|
|
/// §11.6 quota: total key count across all of the group's shared-KV
|
|
/// collections. Default `Ok(0)` so non-Postgres impls skip the quota check.
|
|
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
|
|
let _ = group_id;
|
|
Ok(0)
|
|
}
|
|
}
|
|
|
|
pub struct PostgresGroupKvRepo {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PostgresGroupKvRepo {
|
|
#[must_use]
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
/// Hard ceiling on `list` page size (mirrors `kv_repo`).
|
|
const KV_LIST_MAX_LIMIT: u32 = 1_000;
|
|
const KV_LIST_DEFAULT_LIMIT: u32 = 100;
|
|
|
|
#[async_trait]
|
|
impl GroupKvRepo for PostgresGroupKvRepo {
|
|
async fn get(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
|
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
|
"SELECT value FROM group_kv_entries \
|
|
WHERE group_id = $1 AND collection = $2 AND key = $3",
|
|
)
|
|
.bind(group_id.into_inner())
|
|
.bind(collection)
|
|
.bind(key)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.map(|(v,)| v))
|
|
}
|
|
|
|
async fn set(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
value: serde_json::Value,
|
|
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
|
let row: Option<(Option<serde_json::Value>,)> = sqlx::query_as(
|
|
"WITH prev AS (\
|
|
SELECT value FROM group_kv_entries \
|
|
WHERE group_id = $1 AND collection = $2 AND key = $3\
|
|
), \
|
|
upserted AS (\
|
|
INSERT INTO group_kv_entries (group_id, collection, key, value) \
|
|
VALUES ($1, $2, $3, $4) \
|
|
ON CONFLICT (group_id, collection, key) DO UPDATE \
|
|
SET value = EXCLUDED.value, updated_at = NOW() \
|
|
RETURNING 1\
|
|
) \
|
|
SELECT (SELECT value FROM prev) FROM upserted",
|
|
)
|
|
.bind(group_id.into_inner())
|
|
.bind(collection)
|
|
.bind(key)
|
|
.bind(value)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.and_then(|(v,)| v))
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
|
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
|
"DELETE FROM group_kv_entries \
|
|
WHERE group_id = $1 AND collection = $2 AND key = $3 \
|
|
RETURNING value",
|
|
)
|
|
.bind(group_id.into_inner())
|
|
.bind(collection)
|
|
.bind(key)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.map(|(v,)| v))
|
|
}
|
|
|
|
async fn has(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<bool, GroupKvRepoError> {
|
|
let row: Option<(i64,)> = sqlx::query_as(
|
|
"SELECT 1 FROM group_kv_entries \
|
|
WHERE group_id = $1 AND collection = $2 AND key = $3",
|
|
)
|
|
.bind(group_id.into_inner())
|
|
.bind(collection)
|
|
.bind(key)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.is_some())
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<KvListPage, GroupKvRepoError> {
|
|
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,
|
|
};
|
|
|
|
let take = i64::from(limit) + 1;
|
|
let rows: Vec<(String,)> = sqlx::query_as(
|
|
"SELECT key FROM group_kv_entries \
|
|
WHERE group_id = $1 AND collection = $2 \
|
|
AND ($3::text IS NULL OR key > $3) \
|
|
ORDER BY key ASC \
|
|
LIMIT $4",
|
|
)
|
|
.bind(group_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 })
|
|
}
|
|
|
|
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
|
|
let (n,): (i64,) =
|
|
sqlx::query_as("SELECT COUNT(*) FROM group_kv_entries WHERE group_id = $1")
|
|
.bind(group_id.into_inner())
|
|
.fetch_one(&self.pool)
|
|
.await?;
|
|
Ok(u64::try_from(n).unwrap_or(0))
|
|
}
|
|
}
|
|
|
|
fn encode_cursor(last_key: &str) -> String {
|
|
URL_SAFE_NO_PAD.encode(last_key.as_bytes())
|
|
}
|
|
|
|
fn decode_cursor(cursor: &str) -> Result<String, GroupKvRepoError> {
|
|
let bytes = URL_SAFE_NO_PAD
|
|
.decode(cursor)
|
|
.map_err(|_| GroupKvRepoError::InvalidCursor)?;
|
|
String::from_utf8(bytes).map_err(|_| GroupKvRepoError::InvalidCursor)
|
|
}
|