feat(modules): group-docs schema + kind-generalized registry (§11.6 docs C1)
Data layer for extending shared group collections from KV to docs. KV behavior
unchanged (callers pass kind="kv").
- 0054_group_docs.sql: widen the group_collections kind CHECK to ('kv','docs')
and add the group-keyed group_docs store (clone of docs/0013, keyed by
group_id, CASCADE on group delete) + its (group_id,collection) and data GIN
indexes.
- group_collection_repo: thread a `kind` parameter through list_for_owner,
resolve_owning_group, insert/delete_collection_tx; add list_all_for_owner ->
(name,kind) for the kind-aware diff (D4). Relocate the injectable
GroupCollectionResolver trait + Postgres impl here (now shared by kv+docs;
resolve takes kind) — was in group_kv_service.
- group_docs_repo: a near-clone of docs_repo keyed by group_id; find reuses the
generalized build_find_query.
- docs_repo: generalize build_find_query(table, owner_col, owner_id, …) — both
are compile-time literals (no injection); app docs passes ("docs","app_id"),
group docs ("group_docs","group_id"). row_to_doc/build_find_query are now
pub(crate) for reuse.
Schema snapshot re-blessed (55 migrations).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
284
crates/manager-core/src/group_docs_repo.rs
Normal file
284
crates/manager-core/src/group_docs_repo.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
//! 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>;
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user