Skip to main content

max / makenotwork

Surface server error messages on HTMX failures; inline profile errors - HTMX responseError handler (static/mnw.js): parse the {"error": ...} body (json_error_layer) and show the server's specific message instead of a hardcoded "An error occurred." toast; fall back to the generic string for non-JSON bodies. - update_profile: on validation failure an HTMX request now returns the inline SaveStatusTemplate fragment (mirroring update_password) instead of a full-page ErrorTemplate swapped into the inline status span (UX-W1). - get_items_by_user: a flat LIMIT 500 silently truncated exports (a creator with >500 items exported only their newest 500). Page through all items up to a 100k cap, releasing the connection between pages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-25 04:29 UTC
Commit: 9206c0d4f07ecfe9698475834af329cf03880264
Parent: bacacb7
3 files changed, +71 insertions, -21 deletions
@@ -202,20 +202,41 @@ pub async fn get_items_by_project(pool: &PgPool, project_id: ProjectId) -> Resul
202 202 /// Capped at 500 as a safety limit.
203 203 #[tracing::instrument(skip_all)]
204 204 pub async fn get_items_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbItem>> {
205 - let items = sqlx::query_as::<_, DbItem>(
206 - r#"
207 - SELECT i.* FROM items i
208 - JOIN projects p ON i.project_id = p.id
209 - WHERE p.user_id = $1 AND i.deleted_at IS NULL
210 - ORDER BY i.created_at DESC
211 - LIMIT 500
212 - "#,
213 - )
214 - .bind(user_id)
215 - .fetch_all(pool)
216 - .await?;
205 + /// Page size for the accumulating fetch.
206 + const PAGE: i64 = 1_000;
207 + /// Hard cap so even a huge catalog can't load an unbounded result set.
208 + const MAX_ITEMS: usize = 100_000;
209 +
210 + // Previously a flat `LIMIT 500` silently truncated the result — a creator with
211 + // more than 500 items exported only their newest 500 with no indication
212 + // (data loss in the export). Page through instead, releasing the connection
213 + // between pages, up to a sane cap (Perf, Run 9).
214 + let mut all = Vec::new();
215 + let mut offset = 0i64;
216 + loop {
217 + let page = sqlx::query_as::<_, DbItem>(
218 + r#"
219 + SELECT i.* FROM items i
220 + JOIN projects p ON i.project_id = p.id
221 + WHERE p.user_id = $1 AND i.deleted_at IS NULL
222 + ORDER BY i.created_at DESC
223 + LIMIT $2 OFFSET $3
224 + "#,
225 + )
226 + .bind(user_id)
227 + .bind(PAGE)
228 + .bind(offset)
229 + .fetch_all(pool)
230 + .await?;
231 + let n = page.len();
232 + all.extend(page);
233 + offset += n as i64;
234 + if (n as i64) < PAGE || all.len() >= MAX_ITEMS {
235 + break;
236 + }
237 + }
217 238
218 - Ok(items)
239 + Ok(all)
219 240 }
220 241
221 242 /// Count a user's (non-deleted) items without materializing the rows. For
@@ -48,12 +48,28 @@ pub(in crate::routes::api) async fn update_profile(
48 48 ValidatedForm(req): ValidatedForm<UpdateProfileRequest>,
49 49 ) -> Result<Response> {
50 50 user.check_not_suspended()?;
51 - // Validate input
52 - if let Some(ref name) = req.display_name {
53 - validation::validate_display_name(name)?;
54 - }
55 - if let Some(ref bio) = req.bio {
56 - validation::validate_bio(bio)?;
51 + let is_htmx = is_htmx_request(&headers);
52 +
53 + // Validate input. On failure an HTMX request gets the inline status fragment
54 + // (mirroring update_password) instead of a full-page ErrorTemplate swapped
55 + // into the inline status span (UX-W1, Run 9).
56 + let validated = (|| -> Result<()> {
57 + if let Some(ref name) = req.display_name {
58 + validation::validate_display_name(name)?;
59 + }
60 + if let Some(ref bio) = req.bio {
61 + validation::validate_bio(bio)?;
62 + }
63 + Ok(())
64 + })();
65 + if let Err(e) = validated {
66 + if is_htmx {
67 + return Ok(Html(SaveStatusTemplate {
68 + success: false,
69 + message: e.user_message(),
70 + }.render_string()).into_response());
71 + }
72 + return Err(e);
57 73 }
58 74
59 75 let updated = db::users::update_user_profile(
@@ -64,7 +80,7 @@ pub(in crate::routes::api) async fn update_profile(
64 80 )
65 81 .await?;
66 82
67 - if is_htmx_request(&headers) {
83 + if is_htmx {
68 84 return Ok(Html(SaveStatusTemplate {
69 85 success: true,
70 86 message: "Profile saved".to_string(),
@@ -272,7 +272,20 @@ document.body.addEventListener('htmx:responseError', function(evt) {
272 272 var toast = document.createElement('div');
273 273 toast.className = 'toast toast-error';
274 274 var msg = document.createElement('span');
275 - msg.textContent = 'An error occurred.';
275 + // Surface the server's specific message. API errors come back as
276 + // {"error": "..."} (json_error_layer); fall back to a generic string when the
277 + // body isn't that shape so we never render raw JSON or "[object Object]".
278 + var text = 'An error occurred.';
279 + var body = evt.detail && evt.detail.xhr && evt.detail.xhr.responseText;
280 + if (body) {
281 + try {
282 + var parsed = JSON.parse(body);
283 + if (parsed && typeof parsed.error === 'string' && parsed.error) {
284 + text = parsed.error;
285 + }
286 + } catch (e) { /* non-JSON body (e.g. an HTML error page); keep the fallback */ }
287 + }
288 + msg.textContent = text;
276 289 toast.appendChild(msg);
277 290 var retryBtn = document.createElement('button');
278 291 retryBtn.textContent = 'Retry';