max / makenotwork
8 files changed,
+272 insertions,
-49 deletions
| @@ -745,8 +745,14 @@ pub async fn check_upload_allowed( | |||
| 745 | 745 | )); | |
| 746 | 746 | } | |
| 747 | 747 | ||
| 748 | + | // The per-file-override and current-usage lookups are independent, so fetch | |
| 749 | + | // them concurrently rather than as two serial round-trips (audit Run 17 Perf). | |
| 750 | + | let (max_override, used) = tokio::try_join!( | |
| 751 | + | get_max_file_override(pool, user_id), | |
| 752 | + | get_storage_used(pool, user_id), | |
| 753 | + | )?; | |
| 754 | + | ||
| 748 | 755 | // Per-file size check | |
| 749 | - | let max_override = get_max_file_override(pool, user_id).await?; | |
| 750 | 756 | let max_file = max_override.unwrap_or(tier.max_file_bytes()); | |
| 751 | 757 | if file_size_bytes > max_file { | |
| 752 | 758 | return Err(AppError::FileTooLarge(format!( | |
| @@ -759,7 +765,6 @@ pub async fn check_upload_allowed( | |||
| 759 | 765 | ||
| 760 | 766 | // Storage cap pre-check (non-atomic fast-fail; the atomic enforcement | |
| 761 | 767 | // happens in try_increment_storage after scanning completes) | |
| 762 | - | let used = get_storage_used(pool, user_id).await?; | |
| 763 | 768 | let max_storage = tier.max_storage_bytes(); | |
| 764 | 769 | if used + file_size_bytes > max_storage { | |
| 765 | 770 | return Err(AppError::BadRequest(format!( | |
| @@ -786,8 +791,12 @@ pub async fn check_presign_allowed( | |||
| 786 | 791 | return Ok(()); | |
| 787 | 792 | } | |
| 788 | 793 | ||
| 789 | - | let active_tier = get_active_creator_tier(pool, user_id).await?; | |
| 790 | - | let grandfathered = get_grandfathered_until(pool, user_id).await?; | |
| 794 | + | // Independent lookups — run concurrently on the presign hot path (audit Run | |
| 795 | + | // 17 Perf: was two serial round-trips). | |
| 796 | + | let (active_tier, grandfathered) = tokio::try_join!( | |
| 797 | + | get_active_creator_tier(pool, user_id), | |
| 798 | + | get_grandfathered_until(pool, user_id), | |
| 799 | + | )?; | |
| 791 | 800 | ||
| 792 | 801 | let effective_tier = match active_tier { | |
| 793 | 802 | Some(tier) => Some(tier), |
| @@ -10,15 +10,14 @@ | |||
| 10 | 10 | //! live gallery objects via the S3_KEY_REFS registry. | |
| 11 | 11 | ||
| 12 | 12 | use sqlx::{PgExecutor, PgPool}; | |
| 13 | - | use uuid::Uuid; | |
| 14 | 13 | ||
| 15 | - | use super::{ItemId, ProjectId}; | |
| 14 | + | use super::{ImageId, ItemId, ProjectId}; | |
| 16 | 15 | use crate::error::Result; | |
| 17 | 16 | ||
| 18 | 17 | /// One gallery image row (shared shape for item and project galleries). | |
| 19 | 18 | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 20 | 19 | pub struct GalleryImage { | |
| 21 | - | pub id: Uuid, | |
| 20 | + | pub id: ImageId, | |
| 22 | 21 | pub s3_key: String, | |
| 23 | 22 | pub image_url: String, | |
| 24 | 23 | pub alt: String, | |
| @@ -71,8 +70,8 @@ pub async fn insert_for_item<'e>( | |||
| 71 | 70 | image_url: &str, | |
| 72 | 71 | alt: &str, | |
| 73 | 72 | file_size_bytes: i64, | |
| 74 | - | ) -> Result<Uuid> { | |
| 75 | - | let id: Uuid = sqlx::query_scalar( | |
| 73 | + | ) -> Result<ImageId> { | |
| 74 | + | let id: ImageId = sqlx::query_scalar( | |
| 76 | 75 | "INSERT INTO item_images (item_id, s3_key, image_url, alt, position, file_size_bytes) \ | |
| 77 | 76 | VALUES ($1, $2, $3, $4, \ | |
| 78 | 77 | COALESCE((SELECT MAX(position) + 1 FROM item_images WHERE item_id = $1), 0), \ | |
| @@ -116,7 +115,7 @@ pub async fn find_for_item_by_key<'e>( | |||
| 116 | 115 | #[tracing::instrument(skip_all)] | |
| 117 | 116 | pub async fn delete_for_item<'e>( | |
| 118 | 117 | executor: impl PgExecutor<'e>, | |
| 119 | - | image_id: Uuid, | |
| 118 | + | image_id: ImageId, | |
| 120 | 119 | user_id: super::UserId, | |
| 121 | 120 | ) -> Result<Option<GalleryImage>> { | |
| 122 | 121 | let row = sqlx::query_as::<_, GalleryImage>( | |
| @@ -133,18 +132,21 @@ pub async fn delete_for_item<'e>( | |||
| 133 | 132 | ||
| 134 | 133 | /// Reorder an item's gallery to match `ordered_ids` (ids not belonging to the | |
| 135 | 134 | /// item are ignored). Positions are assigned by list order. | |
| 135 | + | /// | |
| 136 | + | /// One set-based `UPDATE ... FROM UNNEST(...) WITH ORDINALITY` rather than a | |
| 137 | + | /// per-id UPDATE loop — `WITH ORDINALITY` is 1-based, so `position = ord - 1` | |
| 138 | + | /// keeps the prior 0-based ordering (audit Run 17 Performance N+1). | |
| 136 | 139 | #[tracing::instrument(skip_all)] | |
| 137 | - | pub async fn reorder_item(pool: &PgPool, item_id: ItemId, ordered_ids: &[Uuid]) -> Result<()> { | |
| 138 | - | let mut tx = pool.begin().await?; | |
| 139 | - | for (pos, id) in ordered_ids.iter().enumerate() { | |
| 140 | - | sqlx::query("UPDATE item_images SET position = $1 WHERE id = $2 AND item_id = $3") | |
| 141 | - | .bind(pos as i32) | |
| 142 | - | .bind(id) | |
| 143 | - | .bind(item_id) | |
| 144 | - | .execute(&mut *tx) | |
| 145 | - | .await?; | |
| 146 | - | } | |
| 147 | - | tx.commit().await?; | |
| 140 | + | pub async fn reorder_item(pool: &PgPool, item_id: ItemId, ordered_ids: &[ImageId]) -> Result<()> { | |
| 141 | + | sqlx::query( | |
| 142 | + | "UPDATE item_images AS t SET position = o.ord - 1 \ | |
| 143 | + | FROM UNNEST($1::uuid[]) WITH ORDINALITY AS o(id, ord) \ | |
| 144 | + | WHERE t.id = o.id AND t.item_id = $2", | |
| 145 | + | ) | |
| 146 | + | .bind(ordered_ids) | |
| 147 | + | .bind(item_id) | |
| 148 | + | .execute(pool) | |
| 149 | + | .await?; | |
| 148 | 150 | Ok(()) | |
| 149 | 151 | } | |
| 150 | 152 | ||
| @@ -187,8 +189,8 @@ pub async fn insert_for_project<'e>( | |||
| 187 | 189 | image_url: &str, | |
| 188 | 190 | alt: &str, | |
| 189 | 191 | file_size_bytes: i64, | |
| 190 | - | ) -> Result<Uuid> { | |
| 191 | - | let id: Uuid = sqlx::query_scalar( | |
| 192 | + | ) -> Result<ImageId> { | |
| 193 | + | let id: ImageId = sqlx::query_scalar( | |
| 192 | 194 | "INSERT INTO project_images (project_id, s3_key, image_url, alt, position, file_size_bytes) \ | |
| 193 | 195 | VALUES ($1, $2, $3, $4, \ | |
| 194 | 196 | COALESCE((SELECT MAX(position) + 1 FROM project_images WHERE project_id = $1), 0), \ | |
| @@ -229,7 +231,7 @@ pub async fn find_for_project_by_key<'e>( | |||
| 229 | 231 | #[tracing::instrument(skip_all)] | |
| 230 | 232 | pub async fn delete_for_project<'e>( | |
| 231 | 233 | executor: impl PgExecutor<'e>, | |
| 232 | - | image_id: Uuid, | |
| 234 | + | image_id: ImageId, | |
| 233 | 235 | user_id: super::UserId, | |
| 234 | 236 | ) -> Result<Option<GalleryImage>> { | |
| 235 | 237 | let row = sqlx::query_as::<_, GalleryImage>( | |
| @@ -244,19 +246,19 @@ pub async fn delete_for_project<'e>( | |||
| 244 | 246 | Ok(row) | |
| 245 | 247 | } | |
| 246 | 248 | ||
| 247 | - | /// Reorder a project's gallery to match `ordered_ids`. | |
| 249 | + | /// Reorder a project's gallery to match `ordered_ids`. Set-based, like | |
| 250 | + | /// [`reorder_item`]. | |
| 248 | 251 | #[tracing::instrument(skip_all)] | |
| 249 | - | pub async fn reorder_project(pool: &PgPool, project_id: ProjectId, ordered_ids: &[Uuid]) -> Result<()> { | |
| 250 | - | let mut tx = pool.begin().await?; | |
| 251 | - | for (pos, id) in ordered_ids.iter().enumerate() { | |
| 252 | - | sqlx::query("UPDATE project_images SET position = $1 WHERE id = $2 AND project_id = $3") | |
| 253 | - | .bind(pos as i32) | |
| 254 | - | .bind(id) | |
| 255 | - | .bind(project_id) | |
| 256 | - | .execute(&mut *tx) | |
| 257 | - | .await?; | |
| 258 | - | } | |
| 259 | - | tx.commit().await?; | |
| 252 | + | pub async fn reorder_project(pool: &PgPool, project_id: ProjectId, ordered_ids: &[ImageId]) -> Result<()> { | |
| 253 | + | sqlx::query( | |
| 254 | + | "UPDATE project_images AS t SET position = o.ord - 1 \ | |
| 255 | + | FROM UNNEST($1::uuid[]) WITH ORDINALITY AS o(id, ord) \ | |
| 256 | + | WHERE t.id = o.id AND t.project_id = $2", | |
| 257 | + | ) | |
| 258 | + | .bind(ordered_ids) | |
| 259 | + | .bind(project_id) | |
| 260 | + | .execute(pool) | |
| 261 | + | .await?; | |
| 260 | 262 | Ok(()) | |
| 261 | 263 | } | |
| 262 | 264 |
| @@ -179,6 +179,7 @@ define_pg_uuid_id!( | |||
| 179 | 179 | CustomDomainId, | |
| 180 | 180 | ItemSectionId, | |
| 181 | 181 | ProjectSectionId, | |
| 182 | + | ImageId, | |
| 182 | 183 | ImportJobId, | |
| 183 | 184 | MediaFileId, | |
| 184 | 185 | TipId, |
| @@ -171,7 +171,9 @@ pub async fn get_item_project_ids_batch(pool: &PgPool, ids: &[ItemId]) -> Result | |||
| 171 | 171 | #[tracing::instrument(skip_all)] | |
| 172 | 172 | pub async fn get_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> { | |
| 173 | 173 | let items = sqlx::query_as::<_, DbItem>( | |
| 174 | - | "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NULL ORDER BY sort_order, created_at DESC LIMIT 500", | |
| 174 | + | // No LIMIT: one project's live item set is naturally bounded; the old | |
| 175 | + | // flat cap silently truncated dashboard/exports (audit Run 17 Perf). | |
| 176 | + | "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NULL ORDER BY sort_order, created_at DESC", | |
| 175 | 177 | ) | |
| 176 | 178 | .bind(project_id) | |
| 177 | 179 | .fetch_all(pool) | |
| @@ -270,7 +272,7 @@ pub async fn count_items_by_user_projects( | |||
| 270 | 272 | #[tracing::instrument(skip_all)] | |
| 271 | 273 | pub async fn get_public_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> { | |
| 272 | 274 | let items = sqlx::query_as::<_, DbItem>( | |
| 273 | - | "SELECT * FROM items WHERE project_id = $1 AND is_public = true AND listed = true ORDER BY sort_order, created_at DESC LIMIT 500", | |
| 275 | + | "SELECT * FROM items WHERE project_id = $1 AND is_public = true AND listed = true ORDER BY sort_order, created_at DESC", | |
| 274 | 276 | ) | |
| 275 | 277 | .bind(project_id) | |
| 276 | 278 | .fetch_all(pool) | |
| @@ -414,7 +416,7 @@ pub async fn get_deleted_items_by_project( | |||
| 414 | 416 | project_id: ProjectId, | |
| 415 | 417 | ) -> Result<Vec<DbItem>> { | |
| 416 | 418 | let items = sqlx::query_as::<_, DbItem>( | |
| 417 | - | "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT 500", | |
| 419 | + | "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC", | |
| 418 | 420 | ) | |
| 419 | 421 | .bind(project_id) | |
| 420 | 422 | .fetch_all(pool) | |
| @@ -932,8 +934,10 @@ pub async fn get_user_s3_keys(pool: &PgPool, user_id: UserId) -> Result<Vec<Item | |||
| 932 | 934 | FROM items i JOIN projects p ON i.project_id = p.id | |
| 933 | 935 | WHERE p.user_id = $1 AND (i.audio_s3_key IS NOT NULL OR i.cover_s3_key IS NOT NULL OR i.video_s3_key IS NOT NULL) | |
| 934 | 936 | ORDER BY p.slug, i.sort_order | |
| 935 | - | LIMIT 500 | |
| 936 | 937 | "#, | |
| 938 | + | // No LIMIT: this drives per-user S3 storage accounting + cleanup; a cap | |
| 939 | + | // silently dropped keys for a >500-item user, orphaning their objects | |
| 940 | + | // (audit Run 17 Perf — same silent-truncation class). | |
| 937 | 941 | ) | |
| 938 | 942 | .bind(user_id) | |
| 939 | 943 | .fetch_all(pool) |
| @@ -73,6 +73,7 @@ pub(crate) mod moderation; | |||
| 73 | 73 | pub(crate) mod wishlists; | |
| 74 | 74 | pub(crate) mod cart; | |
| 75 | 75 | pub mod gallery_images; | |
| 76 | + | pub mod pagination; | |
| 76 | 77 | pub mod page_views; | |
| 77 | 78 | pub mod pending_s3_deletions; | |
| 78 | 79 | pub(crate) mod pending_uploads; |
| @@ -0,0 +1,204 @@ | |||
| 1 | + | //! Reusable keyset (cursor) pagination. | |
| 2 | + | //! | |
| 3 | + | //! Keyset, not offset: a paged query fetches only `limit + 1` rows via an | |
| 4 | + | //! indexed range scan on `(sort_col, id)`, and derives the next page's cursor | |
| 5 | + | //! from the last row — it never fetches the full set and slices in memory. The | |
| 6 | + | //! cursor is **opaque** (base64) so a consumer's client contract does not change | |
| 7 | + | //! if the encoded sort key later does. | |
| 8 | + | //! | |
| 9 | + | //! When to reach for this vs. offset: | |
| 10 | + | //! - **Keyset (this module)** — forward "load more" / infinite-scroll lists, | |
| 11 | + | //! large or hot tables, anywhere a full-scan-per-page would be wrong. | |
| 12 | + | //! - **Offset** — a numbered-page admin UI that needs "page 7 of 42" and a | |
| 13 | + | //! total count (e.g. the admin user list). Offset is correct there; keyset | |
| 14 | + | //! cannot express page numbers or totals. Don't convert those. | |
| 15 | + | //! | |
| 16 | + | //! Consumer contract: a keyset query MUST have an index on `(<sort_col>, id)` in | |
| 17 | + | //! its sort direction, or the `LIMIT n+1` degrades to sort-the-world. The | |
| 18 | + | //! generic machinery here (cursor encode/decode, page assembly, next-page | |
| 19 | + | //! detection) is written once; each consumer writes ~3 lines of SQL: | |
| 20 | + | //! | |
| 21 | + | //! ```ignore | |
| 22 | + | //! // WHERE <base predicates> | |
| 23 | + | //! // AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3)) | |
| 24 | + | //! // ORDER BY created_at DESC, id DESC | |
| 25 | + | //! // LIMIT $1 -- bind params.fetch_limit() | |
| 26 | + | //! let rows = sqlx::query_as::<_, Row>(SQL) | |
| 27 | + | //! .bind(params.fetch_limit()) | |
| 28 | + | //! .bind(params.after.as_ref().map(|c| c.ts)) | |
| 29 | + | //! .bind(params.after.as_ref().map(|c| c.id)) | |
| 30 | + | //! .fetch_all(pool).await?; | |
| 31 | + | //! Ok(Page::from_rows(rows, ¶ms, |r| Cursor::new(r.created_at, r.id))) | |
| 32 | + | //! ``` | |
| 33 | + | ||
| 34 | + | use base64::Engine; | |
| 35 | + | use chrono::{DateTime, Utc}; | |
| 36 | + | use serde::Serialize; | |
| 37 | + | use uuid::Uuid; | |
| 38 | + | ||
| 39 | + | /// Default page size when a consumer does not specify one. | |
| 40 | + | pub const DEFAULT_PAGE_SIZE: u32 = 50; | |
| 41 | + | /// Upper clamp on page size so a client can't request an unbounded page. | |
| 42 | + | pub const MAX_PAGE_SIZE: u32 = 200; | |
| 43 | + | ||
| 44 | + | /// An opaque forward cursor: the `(sort-timestamp, id)` of the last row on a | |
| 45 | + | /// page. Ordering is `(ts, id)` DESC, with `id` as the unique tiebreaker so the | |
| 46 | + | /// keyset is a total order (no row is skipped or repeated across pages when two | |
| 47 | + | /// rows share a timestamp). | |
| 48 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 49 | + | pub struct Cursor { | |
| 50 | + | pub ts: DateTime<Utc>, | |
| 51 | + | pub id: Uuid, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | impl Cursor { | |
| 55 | + | pub fn new(ts: DateTime<Utc>, id: Uuid) -> Self { | |
| 56 | + | Self { ts, id } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Encode as an opaque base64 token: `<unix_millis>:<uuid>`. The internal | |
| 60 | + | /// format is deliberately not part of any contract — decode tolerates only | |
| 61 | + | /// what encode produces, and both can change without touching callers. | |
| 62 | + | pub fn encode(&self) -> String { | |
| 63 | + | let raw = format!("{}:{}", self.ts.timestamp_millis(), self.id); | |
| 64 | + | base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // (see `Serialize` impl below: a Cursor serializes AS its opaque token, so a | |
| 68 | + | // `Page.next` field emits the JSON string a client passes straight back.) | |
| 69 | + | ||
| 70 | + | /// Decode a token produced by [`encode`](Self::encode). Returns `None` for | |
| 71 | + | /// any malformed / tampered token — a bad cursor is treated as "start from | |
| 72 | + | /// the beginning", never an error (a client should never be able to 500 the | |
| 73 | + | /// server by editing a cursor). | |
| 74 | + | pub fn decode(token: &str) -> Option<Self> { | |
| 75 | + | let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD | |
| 76 | + | .decode(token) | |
| 77 | + | .ok()?; | |
| 78 | + | let raw = String::from_utf8(bytes).ok()?; | |
| 79 | + | let (millis, id) = raw.split_once(':')?; | |
| 80 | + | let ts = DateTime::<Utc>::from_timestamp_millis(millis.parse().ok()?)?; | |
| 81 | + | Some(Self::new(ts, id.parse().ok()?)) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | impl Serialize for Cursor { | |
| 86 | + | /// A cursor serializes as its opaque token string, so `Page.next` renders as | |
| 87 | + | /// the exact value a client hands back as `?after=`. | |
| 88 | + | fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { | |
| 89 | + | serializer.serialize_str(&self.encode()) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// Parameters for a keyset page request: where to start, and how many rows. | |
| 94 | + | #[derive(Debug, Clone)] | |
| 95 | + | pub struct PageParams { | |
| 96 | + | pub after: Option<Cursor>, | |
| 97 | + | pub limit: u32, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | impl PageParams { | |
| 101 | + | /// Build from a raw client cursor token + optional page size. A malformed | |
| 102 | + | /// cursor silently starts from the beginning; the size is clamped to | |
| 103 | + | /// `[1, MAX_PAGE_SIZE]`. | |
| 104 | + | pub fn new(after_token: Option<&str>, limit: Option<u32>) -> Self { | |
| 105 | + | Self { | |
| 106 | + | after: after_token.and_then(Cursor::decode), | |
| 107 | + | limit: limit.unwrap_or(DEFAULT_PAGE_SIZE).clamp(1, MAX_PAGE_SIZE), | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// The SQL `LIMIT` to bind: one more than the page size, so a returned extra | |
| 112 | + | /// row signals "there is a next page" without a second COUNT query. | |
| 113 | + | pub fn fetch_limit(&self) -> i64 { | |
| 114 | + | i64::from(self.limit) + 1 | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// One page of results plus the cursor to fetch the next page (`None` = last). | |
| 119 | + | #[derive(Debug, Serialize)] | |
| 120 | + | pub struct Page<T> { | |
| 121 | + | pub items: Vec<T>, | |
| 122 | + | pub next: Option<Cursor>, | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | impl<T> Page<T> { | |
| 126 | + | /// Assemble a page from up to `limit + 1` fetched rows (ordered by the same | |
| 127 | + | /// key the query used). If the extra row is present there is a next page: | |
| 128 | + | /// drop it and take the next cursor from the last *kept* row. `cursor_of` | |
| 129 | + | /// extracts the `(ts, id)` sort key from a row. | |
| 130 | + | pub fn from_rows(mut rows: Vec<T>, params: &PageParams, cursor_of: impl Fn(&T) -> Cursor) -> Self { | |
| 131 | + | let has_more = rows.len() as u32 > params.limit; | |
| 132 | + | rows.truncate(params.limit as usize); | |
| 133 | + | let next = if has_more { rows.last().map(&cursor_of) } else { None }; | |
| 134 | + | Page { items: rows, next } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// The opaque next-page token for a client, if any. | |
| 138 | + | pub fn next_token(&self) -> Option<String> { | |
| 139 | + | self.next.map(|c| c.encode()) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[cfg(test)] | |
| 144 | + | mod tests { | |
| 145 | + | use super::*; | |
| 146 | + | ||
| 147 | + | fn ts(millis: i64) -> DateTime<Utc> { | |
| 148 | + | DateTime::<Utc>::from_timestamp_millis(millis).unwrap() | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | #[test] | |
| 152 | + | fn cursor_round_trips_through_opaque_token() { | |
| 153 | + | let c = Cursor::new(ts(1_700_000_000_123), Uuid::from_u128(42)); | |
| 154 | + | let token = c.encode(); | |
| 155 | + | // Opaque: not the raw value. | |
| 156 | + | assert_ne!(token, "1700000000123:00000000-0000-0000-0000-00000000002a"); | |
| 157 | + | assert_eq!(Cursor::decode(&token), Some(c)); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | #[test] | |
| 161 | + | fn malformed_cursor_decodes_to_none_not_error() { | |
| 162 | + | assert_eq!(Cursor::decode("not-base64!!!"), None); | |
| 163 | + | assert_eq!(Cursor::decode(""), None); | |
| 164 | + | // Valid base64 but wrong shape. | |
| 165 | + | let garbage = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode("no-colon"); | |
| 166 | + | assert_eq!(Cursor::decode(&garbage), None); | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | #[test] | |
| 170 | + | fn params_clamp_and_default_page_size() { | |
| 171 | + | assert_eq!(PageParams::new(None, None).limit, DEFAULT_PAGE_SIZE); | |
| 172 | + | assert_eq!(PageParams::new(None, Some(0)).limit, 1); | |
| 173 | + | assert_eq!(PageParams::new(None, Some(10_000)).limit, MAX_PAGE_SIZE); | |
| 174 | + | assert_eq!(PageParams::new(None, Some(25)).fetch_limit(), 26); | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | #[test] | |
| 178 | + | fn bad_cursor_token_starts_from_beginning() { | |
| 179 | + | let p = PageParams::new(Some("tampered"), Some(20)); | |
| 180 | + | assert!(p.after.is_none(), "a bad cursor must not error, just restart"); | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | #[test] | |
| 184 | + | fn from_rows_detects_next_page_and_trims_extra() { | |
| 185 | + | let params = PageParams::new(None, Some(3)); // fetch_limit = 4 | |
| 186 | + | // 4 rows returned for a page size of 3 => there IS a next page. | |
| 187 | + | let rows: Vec<(i64, Uuid)> = (0..4).map(|i| (i, Uuid::from_u128(i as u128))).collect(); | |
| 188 | + | let page = Page::from_rows(rows, ¶ms, |(m, id)| Cursor::new(ts(*m), *id)); | |
| 189 | + | assert_eq!(page.items.len(), 3, "the extra probe row is trimmed"); | |
| 190 | + | // Next cursor is the last KEPT row (index 2), not the dropped probe row. | |
| 191 | + | assert_eq!(page.next, Some(Cursor::new(ts(2), Uuid::from_u128(2)))); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[test] | |
| 195 | + | fn from_rows_last_page_has_no_next() { | |
| 196 | + | let params = PageParams::new(None, Some(5)); // fetch_limit = 6 | |
| 197 | + | // Only 3 rows for a page size of 5 => last page. | |
| 198 | + | let rows: Vec<(i64, Uuid)> = (0..3).map(|i| (i, Uuid::from_u128(i as u128))).collect(); | |
| 199 | + | let page = Page::from_rows(rows, ¶ms, |(m, id)| Cursor::new(ts(*m), *id)); | |
| 200 | + | assert_eq!(page.items.len(), 3); | |
| 201 | + | assert_eq!(page.next, None); | |
| 202 | + | assert_eq!(page.next_token(), None); | |
| 203 | + | } | |
| 204 | + | } |
| @@ -114,7 +114,9 @@ pub async fn get_project_ids_for_user(pool: &PgPool, user_id: UserId) -> Result< | |||
| 114 | 114 | #[tracing::instrument(skip_all)] | |
| 115 | 115 | pub async fn get_projects_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbProject>> { | |
| 116 | 116 | let projects = sqlx::query_as::<_, DbProject>( | |
| 117 | - | "SELECT * FROM projects WHERE user_id = $1 ORDER BY created_at DESC LIMIT 500", | |
| 117 | + | // No LIMIT: one creator's project set is naturally bounded; an arbitrary | |
| 118 | + | // cap silently truncated exports/feeds/dashboard (audit Run 17 Perf). | |
| 119 | + | "SELECT * FROM projects WHERE user_id = $1 ORDER BY created_at DESC", | |
| 118 | 120 | ) | |
| 119 | 121 | .bind(user_id) | |
| 120 | 122 | .fetch_all(pool) | |
| @@ -379,7 +381,7 @@ pub async fn set_mt_community_id( | |||
| 379 | 381 | #[tracing::instrument(skip_all)] | |
| 380 | 382 | pub async fn get_projects_without_mt_community(pool: &PgPool) -> Result<Vec<DbProject>> { | |
| 381 | 383 | let projects = sqlx::query_as::<_, DbProject>( | |
| 382 | - | "SELECT * FROM projects WHERE mt_community_id IS NULL ORDER BY created_at LIMIT 500", | |
| 384 | + | "SELECT * FROM projects WHERE mt_community_id IS NULL ORDER BY created_at", | |
| 383 | 385 | ) | |
| 384 | 386 | .fetch_all(pool) | |
| 385 | 387 | .await?; |
| @@ -17,7 +17,7 @@ use uuid::Uuid; | |||
| 17 | 17 | ||
| 18 | 18 | use crate::{ | |
| 19 | 19 | auth::AuthUser, | |
| 20 | - | db::{self, ItemId, ProjectId, UserId}, | |
| 20 | + | db::{self, ImageId, ItemId, ProjectId, UserId}, | |
| 21 | 21 | error::{AppError, Result, ResultExt}, | |
| 22 | 22 | storage::{self, FileType, S3Client, CACHE_CONTROL_IMMUTABLE}, | |
| 23 | 23 | AppState, | |
| @@ -69,7 +69,7 @@ async fn require_owned( | |||
| 69 | 69 | ||
| 70 | 70 | #[derive(Debug, Serialize)] | |
| 71 | 71 | struct GalleryListItem { | |
| 72 | - | id: Uuid, | |
| 72 | + | id: ImageId, | |
| 73 | 73 | image_url: String, | |
| 74 | 74 | alt: String, | |
| 75 | 75 | } | |
| @@ -190,7 +190,7 @@ pub struct GalleryConfirmRequest { | |||
| 190 | 190 | #[derive(Debug, Serialize)] | |
| 191 | 191 | pub struct GalleryConfirmResponse { | |
| 192 | 192 | pub success: bool, | |
| 193 | - | pub id: Uuid, | |
| 193 | + | pub id: ImageId, | |
| 194 | 194 | pub image_url: String, | |
| 195 | 195 | pub alt: String, | |
| 196 | 196 | } | |
| @@ -199,7 +199,7 @@ pub struct GalleryConfirmResponse { | |||
| 199 | 199 | /// found on a replayed confirm (STOR-S1 idempotency — no second insert, no | |
| 200 | 200 | /// second storage charge). | |
| 201 | 201 | enum GalleryOutcome { | |
| 202 | - | Inserted(Uuid), | |
| 202 | + | Inserted(ImageId), | |
| 203 | 203 | Existing(db::gallery_images::GalleryImage), | |
| 204 | 204 | } | |
| 205 | 205 | ||
| @@ -373,7 +373,7 @@ pub(super) async fn gallery_confirm( | |||
| 373 | 373 | // Scan enqueue AFTER the DB write commits (the chronic-ordering rule). | |
| 374 | 374 | commit_upload( | |
| 375 | 375 | &state, | |
| 376 | - | CommitTarget::GalleryImage(id), | |
| 376 | + | CommitTarget::GalleryImage(id.into()), | |
| 377 | 377 | &req.s3_key, | |
| 378 | 378 | FileType::Cover, | |
| 379 | 379 | user.id, | |
| @@ -395,7 +395,7 @@ pub(super) async fn gallery_confirm( | |||
| 395 | 395 | pub(super) async fn gallery_delete( | |
| 396 | 396 | State(state): State<AppState>, | |
| 397 | 397 | AuthUser(user): AuthUser, | |
| 398 | - | Path((target_type, image_id)): Path<(String, Uuid)>, | |
| 398 | + | Path((target_type, image_id)): Path<(String, ImageId)>, | |
| 399 | 399 | ) -> Result<impl IntoResponse> { | |
| 400 | 400 | user.check_not_suspended()?; | |
| 401 | 401 | let target = GalleryTarget::parse(&target_type)?; | |
| @@ -440,7 +440,7 @@ pub(super) async fn gallery_delete( | |||
| 440 | 440 | pub struct GalleryReorderRequest { | |
| 441 | 441 | pub target_type: String, | |
| 442 | 442 | pub target_id: Uuid, | |
| 443 | - | pub ordered_ids: Vec<Uuid>, | |
| 443 | + | pub ordered_ids: Vec<ImageId>, | |
| 444 | 444 | } | |
| 445 | 445 | ||
| 446 | 446 | /// POST /api/gallery/reorder — set gallery display order. |