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>
300 lines
8.4 KiB
Rust
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))
|
|
}
|