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>
3 files changed,
+71 insertions,
-21 deletions
| 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 |
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 |
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 |
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';
|