use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use sqlx::PgPool; use uuid::Uuid; use secrets_domain::{VaultObjectEnvelope, VaultObjectKind, VaultTombstone}; #[derive(Debug, sqlx::FromRow)] struct VaultObjectRow { object_id: Uuid, _object_kind: String, revision: i64, cipher_version: i32, ciphertext: Vec, content_hash: String, deleted_at: Option>, updated_at: DateTime, } impl From for VaultObjectEnvelope { fn from(row: VaultObjectRow) -> Self { Self { object_id: row.object_id, object_kind: VaultObjectKind::Cipher, revision: row.revision, cipher_version: row.cipher_version, ciphertext: row.ciphertext, content_hash: row.content_hash, deleted_at: row.deleted_at, updated_at: row.updated_at, } } } pub async fn list_objects_since( pool: &PgPool, user_id: Uuid, cursor: i64, limit: i64, ) -> Result> { let rows = sqlx::query_as::<_, VaultObjectRow>( r#" SELECT object_id, object_kind AS _object_kind, revision, cipher_version, ciphertext, content_hash, deleted_at, updated_at FROM vault_objects WHERE user_id = $1 AND revision > $2 ORDER BY revision ASC LIMIT $3 "#, ) .bind(user_id) .bind(cursor) .bind(limit.max(1)) .fetch_all(pool) .await .context("failed to list vault objects")?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn get_object( pool: &PgPool, user_id: Uuid, object_id: Uuid, ) -> Result> { let row = sqlx::query_as::<_, VaultObjectRow>( r#" SELECT object_id, object_kind AS _object_kind, revision, cipher_version, ciphertext, content_hash, deleted_at, updated_at FROM vault_objects WHERE user_id = $1 AND object_id = $2 "#, ) .bind(user_id) .bind(object_id) .fetch_optional(pool) .await .context("failed to load vault object")?; Ok(row.map(Into::into)) } pub async fn list_tombstones_since( pool: &PgPool, user_id: Uuid, cursor: i64, limit: i64, ) -> Result> { let rows = sqlx::query_as::<_, (Uuid, i64, DateTime)>( r#" SELECT object_id, revision, deleted_at FROM vault_objects WHERE user_id = $1 AND revision > $2 AND deleted_at IS NOT NULL ORDER BY revision ASC LIMIT $3 "#, ) .bind(user_id) .bind(cursor) .bind(limit.max(1)) .fetch_all(pool) .await .context("failed to list tombstones")?; Ok(rows .into_iter() .map(|(object_id, revision, deleted_at)| VaultTombstone { object_id, revision, deleted_at, }) .collect()) } pub async fn max_server_revision(pool: &PgPool, user_id: Uuid) -> Result { let revision = sqlx::query_scalar::<_, Option>( r#" SELECT MAX(revision) FROM vault_objects WHERE user_id = $1 "#, ) .bind(user_id) .fetch_one(pool) .await .context("failed to load max server revision")?; Ok(revision.unwrap_or(0)) }