main
tag: launch-2026-06-01
tag: magicmirror-v0.1.1
tag: magicmirror-v0.3.0
tag: mnw-cli-v0.1.2
tag: mnw-cli-v0.1.3
tag: mnw-cli-v0.1.4
tag: pom-v0.4.1
tag: pom-v0.4.2
tag: pom-v0.4.3
tag: pom-v0.4.4
tag: pom-v0.4.5
tag: wam-v0.3.0
tag: wam-v0.3.1
Files
Commits
Tags
Notes
Issues
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
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';
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;
217
209
218
-
Ok(items)
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
+
}
238
+
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(),