Files
PiCloud/crates/manager-core/src/group_docs_repo.rs
MechaCat02 2bc3fb677b feat(group-quota): per-group shared-collection quotas (M3)
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>
2026-07-01 21:17:12 +02:00

300 lines
8.4 KiB
Rust

//! Low-level Postgres CRUD over `group_docs` (§11.6). A near-clone of
//! [`crate::docs_repo`] keyed by the owning `group_id` instead of `app_id`.
//! The `find` query is built by the shared [`crate::docs_repo::build_find_query`]
//! (parameterized on table + owner column), so the security-sensitive filter
//! SQL has a single source. Authorization, group resolution, value validation,
//! and event policy live one layer up in `GroupDocsServiceImpl`.
use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use picloud_shared::{DocId, DocRow, DocsListPage, GroupId};
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::docs_filter::DocsFilter;
use crate::docs_repo::{build_find_query, row_to_doc, DocsRepoError};
#[derive(Debug, thiserror::Error)]
pub enum GroupDocsRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("invalid pagination cursor")]
InvalidCursor,
}
impl From<DocsRepoError> for GroupDocsRepoError {
fn from(e: DocsRepoError) -> Self {
match e {
DocsRepoError::Db(e) => Self::Db(e),
DocsRepoError::InvalidCursor => Self::InvalidCursor,
}
}
}
#[async_trait]
pub trait GroupDocsRepo: Send + Sync {
async fn create(
&self,
group_id: GroupId,
collection: &str,
data: Value,
) -> Result<DocRow, GroupDocsRepoError>;
async fn get(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
) -> Result<Option<DocRow>, GroupDocsRepoError>;
async fn find(
&self,
group_id: GroupId,
collection: &str,
filter: &DocsFilter,
) -> Result<Vec<DocRow>, GroupDocsRepoError>;
/// Returns the previous data (for the would-be event), `None` if missing.
async fn update(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
data: Value,
) -> Result<Option<Value>, GroupDocsRepoError>;
async fn delete(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
) -> Result<Option<Value>, GroupDocsRepoError>;
async fn list(
&self,
group_id: GroupId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<DocsListPage, GroupDocsRepoError>;
/// §11.6 quota: total doc count across the group's shared-docs collections.
/// Default `Ok(0)` so non-Postgres impls skip the quota check.
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
let _ = group_id;
Ok(0)
}
}
pub struct PostgresGroupDocsRepo {
pool: PgPool,
}
impl PostgresGroupDocsRepo {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
const DOCS_LIST_MAX_LIMIT: u32 = 1_000;
const DOCS_LIST_DEFAULT_LIMIT: u32 = 100;
#[async_trait]
impl GroupDocsRepo for PostgresGroupDocsRepo {
async fn create(
&self,
group_id: GroupId,
collection: &str,
data: Value,
) -> Result<DocRow, GroupDocsRepoError> {
let id = Uuid::new_v4();
let row: (DateTime<Utc>, DateTime<Utc>) = sqlx::query_as(
"INSERT INTO group_docs (group_id, collection, id, data) \
VALUES ($1, $2, $3, $4) \
RETURNING created_at, updated_at",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.bind(&data)
.fetch_one(&self.pool)
.await?;
Ok(DocRow {
id,
data,
created_at: row.0,
updated_at: row.1,
})
}
async fn get(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
) -> Result<Option<DocRow>, GroupDocsRepoError> {
let row: Option<(Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
"SELECT data, created_at, updated_at FROM group_docs \
WHERE group_id = $1 AND collection = $2 AND id = $3",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(data, created_at, updated_at)| DocRow {
id,
data,
created_at,
updated_at,
}))
}
async fn find(
&self,
group_id: GroupId,
collection: &str,
filter: &DocsFilter,
) -> Result<Vec<DocRow>, GroupDocsRepoError> {
let mut qb = build_find_query(
"group_docs",
"group_id",
group_id.into_inner(),
collection,
filter,
);
let rows = qb.build().fetch_all(&self.pool).await?;
rows.into_iter()
.map(row_to_doc)
.collect::<Result<Vec<_>, _>>()
.map_err(Into::into)
}
async fn update(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
data: Value,
) -> Result<Option<Value>, GroupDocsRepoError> {
let row: Option<(Option<Value>,)> = sqlx::query_as(
"WITH prev AS ( \
SELECT data FROM group_docs \
WHERE group_id = $1 AND collection = $2 AND id = $3 \
), \
updated AS ( \
UPDATE group_docs SET data = $4, updated_at = NOW() \
WHERE group_id = $1 AND collection = $2 AND id = $3 \
RETURNING 1 \
) \
SELECT (SELECT data FROM prev) FROM updated",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.bind(&data)
.fetch_optional(&self.pool)
.await?;
Ok(row.and_then(|(v,)| v))
}
async fn delete(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
) -> Result<Option<Value>, GroupDocsRepoError> {
let row: Option<(Value,)> = sqlx::query_as(
"DELETE FROM group_docs \
WHERE group_id = $1 AND collection = $2 AND id = $3 \
RETURNING data",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(v,)| v))
}
async fn list(
&self,
group_id: GroupId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<DocsListPage, GroupDocsRepoError> {
let limit = if limit == 0 {
DOCS_LIST_DEFAULT_LIMIT
} else {
limit.min(DOCS_LIST_MAX_LIMIT)
};
let last_id = match cursor {
Some(c) => Some(decode_cursor(c)?),
None => None,
};
let take = i64::from(limit) + 1;
let rows: Vec<(Uuid, Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
"SELECT id, data, created_at, updated_at FROM group_docs \
WHERE group_id = $1 AND collection = $2 \
AND ($3::uuid IS NULL OR id > $3) \
ORDER BY id ASC \
LIMIT $4",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(last_id)
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut docs: Vec<DocRow> = rows
.into_iter()
.map(|(id, data, created_at, updated_at)| DocRow {
id,
data,
created_at,
updated_at,
})
.collect();
let next_cursor = if docs.len() > limit as usize {
docs.truncate(limit as usize);
docs.last().map(|d| encode_cursor(&d.id))
} else {
None
};
Ok(DocsListPage { docs, next_cursor })
}
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM group_docs 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_id: &Uuid) -> String {
URL_SAFE_NO_PAD.encode(last_id.as_bytes())
}
fn decode_cursor(cursor: &str) -> Result<Uuid, GroupDocsRepoError> {
let bytes = URL_SAFE_NO_PAD
.decode(cursor)
.map_err(|_| GroupDocsRepoError::InvalidCursor)?;
let arr: [u8; 16] = bytes
.as_slice()
.try_into()
.map_err(|_| GroupDocsRepoError::InvalidCursor)?;
Ok(Uuid::from_bytes(arr))
}