Skip to main content

max / makenotwork

server: gallery N+1 + ImageId, de-truncate aggregates, keyset paginator Performance + type-safety pass (audit Run 17): - Collapse the gallery reorder N+1 (per-id UPDATE loop) to one set-based UPDATE ... FROM UNNEST($ids) WITH ORDINALITY, for item and project galleries. - Add an ImageId newtype and thread it through the gallery db layer + routes (GalleryImage.id, delete/insert/reorder params, response shapes), replacing raw Uuid. - Drop the silent LIMIT 500 on the per-owner/per-project aggregate fetchers (items/projects lists + get_user_s3_keys) — the arbitrary cap silently truncated exports / feeds / dashboard / storage-key cleanup for a creator with a large catalog. These sets are naturally bounded by one creator/project. - Add db::pagination: a reusable keyset (cursor) paginator (opaque cursor, Page<T>, next-page detection via LIMIT n+1) as the primitive for future forward "load more" lists, with unit tests. Offset stays correct for the numbered-page admin lists (documented in the module); nothing converted. - try_join the two remaining serial preflight round-trips in check_upload_allowed / check_presign_allowed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 17:31 UTC
Commit: 404a4c47f94454a437f43f406b8cf7cae60229b9
Parent: 74739f1
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, &params, |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, &params, |(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, &params, |(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.