Skip to main content

max / makenotwork

Cut dashboard/discover/feed query cost and stop orphaning builds (ultra-fuzz Run 11 Performance) Drive the Performance axis to A+: - SER-1: scoped db::ota::get_release(app_id, release_id) replaces list-then-scan in both OTA handlers — an indexed lookup, not a per-request full release scan. - SER-2: the Stripe balance is lazy-loaded into the Payments tab via an HTMX partial (mirrors the S6 Forums fix), so the tab render never blocks on the Stripe round-trip. - SER-3: projects.item_count is denormalized and maintained by a trigger on items (active = public AND listed AND not deleted); discover_projects reads the column instead of a LEFT JOIN + GROUP BY COUNT over the whole catalog. The trigger keeps the count correct on every item insert/update/delete with no app-code maintenance to forget. Regression test pins the lifecycle. - SER-4: the follows feed's OR'd IN-subqueries become a UNION CTE driven from follows (sargable via idx_follows_unique) across all three queries. - SER-5: ssh/scp build spawns get kill_on_drop(true) so a build timeout doesn't leave an orphaned remote build running. - MOD tail: composite (seller_id|buyer_id, created_at DESC) tx indexes for the ordered dashboard reads (drops the redundant single-column ones); the four independent dashboard reads run via try_join!; the free-cart claim loop commits once instead of per item. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-30 18:36 UTC
Commit: f801710909e12900c2d9034cfd485db89b72280f
Parent: fb4ac6e
18 files changed, +340 insertions, -159 deletions
@@ -0,0 +1,61 @@
1 + -- Denormalized active-item count per project (ultra-fuzz Run 11 Perf SER-3).
2 + --
3 + -- discover_projects computed item_count with a LEFT JOIN items + GROUP BY +
4 + -- COUNT(...) FILTER over the whole public catalog on every discover page load —
5 + -- O(items) per project. This caches the count on the project row.
6 + --
7 + -- "Active" matches the discover filter exactly: is_public AND listed AND
8 + -- deleted_at IS NULL. The count is maintained by a trigger on `items`, not by
9 + -- application code, so no write path can forget to adjust it and the value can't
10 + -- drift (the maintenance lives in one place, atomic with the item write).
11 +
12 + ALTER TABLE projects ADD COLUMN item_count INTEGER NOT NULL DEFAULT 0;
13 +
14 + -- Backfill from the current truth.
15 + UPDATE projects p SET item_count = (
16 + SELECT COUNT(*) FROM items i
17 + WHERE i.project_id = p.id
18 + AND i.is_public = true
19 + AND i.listed = true
20 + AND i.deleted_at IS NULL
21 + );
22 +
23 + CREATE OR REPLACE FUNCTION sync_project_item_count() RETURNS TRIGGER AS $$
24 + DECLARE
25 + old_active BOOLEAN;
26 + new_active BOOLEAN;
27 + BEGIN
28 + IF TG_OP = 'INSERT' THEN
29 + IF NEW.is_public AND NEW.listed AND NEW.deleted_at IS NULL THEN
30 + UPDATE projects SET item_count = item_count + 1 WHERE id = NEW.project_id;
31 + END IF;
32 + RETURN NEW;
33 + ELSIF TG_OP = 'DELETE' THEN
34 + IF OLD.is_public AND OLD.listed AND OLD.deleted_at IS NULL THEN
35 + UPDATE projects SET item_count = item_count - 1 WHERE id = OLD.project_id;
36 + END IF;
37 + RETURN OLD;
38 + ELSE -- UPDATE
39 + old_active := OLD.is_public AND OLD.listed AND OLD.deleted_at IS NULL;
40 + new_active := NEW.is_public AND NEW.listed AND NEW.deleted_at IS NULL;
41 + IF OLD.project_id <> NEW.project_id THEN
42 + -- Item moved between projects: settle the count on both sides.
43 + IF old_active THEN
44 + UPDATE projects SET item_count = item_count - 1 WHERE id = OLD.project_id;
45 + END IF;
46 + IF new_active THEN
47 + UPDATE projects SET item_count = item_count + 1 WHERE id = NEW.project_id;
48 + END IF;
49 + ELSIF old_active AND NOT new_active THEN
50 + UPDATE projects SET item_count = item_count - 1 WHERE id = NEW.project_id;
51 + ELSIF new_active AND NOT old_active THEN
52 + UPDATE projects SET item_count = item_count + 1 WHERE id = NEW.project_id;
53 + END IF;
54 + RETURN NEW;
55 + END IF;
56 + END;
57 + $$ LANGUAGE plpgsql;
58 +
59 + CREATE TRIGGER trg_sync_project_item_count
60 + AFTER INSERT OR UPDATE OR DELETE ON items
61 + FOR EACH ROW EXECUTE FUNCTION sync_project_item_count();
@@ -0,0 +1,15 @@
1 + -- Ordered dashboard-transaction indexes (ultra-fuzz Run 11 Perf MOD tail).
2 + --
3 + -- The buyer/seller dashboard tabs run
4 + -- SELECT ... FROM transactions WHERE {seller,buyer}_id = $1 ORDER BY created_at DESC LIMIT $2
5 + -- The plain single-column indexes serve the equality but force a sort of every
6 + -- one of the user's transactions to satisfy the ORDER BY + LIMIT. A composite
7 + -- (id, created_at DESC) lets the planner walk the index in order and stop at the
8 + -- limit. The composite covers the single-column equality lookups as a prefix, so
9 + -- the originals are redundant and dropped.
10 +
11 + DROP INDEX IF EXISTS idx_transactions_seller_id;
12 + DROP INDEX IF EXISTS idx_transactions_buyer_id;
13 +
14 + CREATE INDEX idx_transactions_seller_created ON transactions(seller_id, created_at DESC);
15 + CREATE INDEX idx_transactions_buyer_created ON transactions(buyer_id, created_at DESC);
@@ -551,6 +551,10 @@ async fn run_ssh_command(host: &str, command: &str) -> std::result::Result<Strin
551 551 .stdin(std::process::Stdio::null())
552 552 .stdout(std::process::Stdio::piped())
553 553 .stderr(std::process::Stdio::piped())
554 + // Kill the ssh process if this future is dropped (e.g. the 30-min build
555 + // timeout fires): otherwise the dropped future leaves ssh — and the
556 + // remote build it drives — running orphaned (ultra-fuzz Run 11 Perf).
557 + .kill_on_drop(true)
554 558 .spawn()
555 559 .map_err(|e| format!("failed to spawn ssh: {e}"))?;
556 560
@@ -625,6 +629,9 @@ async fn run_scp_download(
625 629 args.push(local_path);
626 630 let output = tokio::process::Command::new("scp")
627 631 .args(&args)
632 + // Kill scp if this future is dropped (build timeout) rather than leaving
633 + // an orphaned transfer running (ultra-fuzz Run 11 Perf).
634 + .kill_on_drop(true)
628 635 .output()
629 636 .await
630 637 .map_err(|e| format!("failed to spawn scp: {e}"))?;
@@ -341,7 +341,7 @@ pub async fn discover_projects(
341 341 p.project_type,
342 342 p.created_at,
343 343 u.username,
344 - COUNT(i.id) FILTER (WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL) as item_count,
344 + p.item_count::bigint as item_count,
345 345 GREATEST(
346 346 similarity(p.title, $1),
347 347 similarity(COALESCE(p.description, ''), $1) * 0.5
@@ -350,7 +350,6 @@ pub async fn discover_projects(
350 350 pc.slug as category_slug
351 351 FROM projects p
352 352 JOIN users u ON p.user_id = u.id
353 - LEFT JOIN items i ON i.project_id = p.id
354 353 LEFT JOIN project_categories pc ON pc.id = p.category_id
355 354 WHERE p.is_public = true AND u.is_sandbox = FALSE
356 355 "#,
@@ -366,13 +365,12 @@ pub async fn discover_projects(
366 365 p.project_type,
367 366 p.created_at,
368 367 u.username,
369 - COUNT(i.id) FILTER (WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL) as item_count,
368 + p.item_count::bigint as item_count,
370 369 1.0::real as match_score,
371 370 pc.name as category_name,
372 371 pc.slug as category_slug
373 372 FROM projects p
374 373 JOIN users u ON p.user_id = u.id
375 - LEFT JOIN items i ON i.project_id = p.id
376 374 LEFT JOIN project_categories pc ON pc.id = p.category_id
377 375 WHERE p.is_public = true AND u.is_sandbox = FALSE
378 376 "#,
@@ -387,13 +385,12 @@ pub async fn discover_projects(
387 385 p.project_type,
388 386 p.created_at,
389 387 u.username,
390 - COUNT(i.id) FILTER (WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL) as item_count,
388 + p.item_count::bigint as item_count,
391 389 NULL::real as match_score,
392 390 pc.name as category_name,
393 391 pc.slug as category_slug
394 392 FROM projects p
395 393 JOIN users u ON p.user_id = u.id
396 - LEFT JOIN items i ON i.project_id = p.id
397 394 LEFT JOIN project_categories pc ON pc.id = p.category_id
398 395 WHERE p.is_public = true AND u.is_sandbox = FALSE
399 396 "#,
@@ -416,7 +413,8 @@ pub async fn discover_projects(
416 413 query.push_str(" AND EXISTS (SELECT 1 FROM git_repos gr WHERE gr.project_id = p.id)");
417 414 }
418 415
419 - query.push_str(" GROUP BY p.id, u.username, pc.name, pc.slug");
416 + // No GROUP BY: item_count is now a denormalized column on projects (maintained
417 + // by a trigger), so the query has no aggregate to group (Run 11 Perf SER-3).
420 418
421 419 let order = if has_search && (sort_by.is_none() || sort_by == Some(DiscoverSort::Newest)) {
422 420 "match_score DESC NULLS LAST, p.created_at DESC"
@@ -83,30 +83,25 @@ pub async fn get_followed_items(
83 83 ) -> Result<Vec<super::models::DbItem>> {
84 84 let items = sqlx::query_as::<_, super::models::DbItem>(
85 85 r#"
86 - SELECT DISTINCT i.* FROM items i
87 - JOIN projects p ON i.project_id = p.id
88 - WHERE i.is_public = true AND p.is_public = true
89 - AND (
90 - -- Items from followed users
91 - p.user_id IN (
92 - SELECT target_id FROM follows
93 - WHERE follower_id = $1 AND target_type = 'user'
94 - )
95 - OR
96 - -- Items from followed projects
97 - p.id IN (
98 - SELECT target_id FROM follows
99 - WHERE follower_id = $1 AND target_type = 'project'
100 - )
101 - OR
102 - -- Items with followed tags
103 - i.id IN (
104 - SELECT it.item_id FROM item_tags it
105 - WHERE it.tag_id IN (
106 - SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag'
107 - )
108 - )
86 + WITH followed_item_ids AS (
87 + SELECT i.id FROM items i
88 + JOIN projects p ON i.project_id = p.id
89 + JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id
90 + WHERE i.is_public = true AND p.is_public = true
91 + UNION
92 + SELECT i.id FROM items i
93 + JOIN projects p ON i.project_id = p.id
94 + JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id
95 + WHERE i.is_public = true AND p.is_public = true
96 + UNION
97 + SELECT i.id FROM items i
98 + JOIN projects p ON i.project_id = p.id
99 + JOIN item_tags it ON it.item_id = i.id
100 + JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id
101 + WHERE i.is_public = true AND p.is_public = true
109 102 )
103 + SELECT i.* FROM items i
104 + JOIN followed_item_ids fi ON fi.id = i.id
110 105 ORDER BY i.created_at DESC
111 106 LIMIT 50
112 107 "#,
@@ -129,7 +124,24 @@ pub async fn get_followed_feed_items(
129 124 ) -> Result<Vec<super::models::DbDiscoverItemRow>> {
130 125 let items = sqlx::query_as::<_, super::models::DbDiscoverItemRow>(
131 126 r#"
132 - SELECT DISTINCT
127 + WITH followed_item_ids AS (
128 + SELECT i.id FROM items i
129 + JOIN projects p ON i.project_id = p.id
130 + JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id
131 + WHERE i.is_public = true AND p.is_public = true
132 + UNION
133 + SELECT i.id FROM items i
134 + JOIN projects p ON i.project_id = p.id
135 + JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id
136 + WHERE i.is_public = true AND p.is_public = true
137 + UNION
138 + SELECT i.id FROM items i
139 + JOIN projects p ON i.project_id = p.id
140 + JOIN item_tags it ON it.item_id = i.id
141 + JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id
142 + WHERE i.is_public = true AND p.is_public = true
143 + )
144 + SELECT
133 145 i.id,
134 146 i.title,
135 147 i.description,
@@ -145,25 +157,11 @@ pub async fn get_followed_feed_items(
145 157 NULL::real as match_score,
146 158 i.ai_tier
147 159 FROM items i
160 + JOIN followed_item_ids fi ON fi.id = i.id
148 161 JOIN projects p ON i.project_id = p.id
149 162 JOIN users u ON p.user_id = u.id
150 163 LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
151 164 LEFT JOIN tags pt ON pt.id = pit.tag_id
152 - WHERE i.is_public = true AND p.is_public = true
153 - AND (
154 - p.user_id IN (
155 - SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'user'
156 - )
157 - OR p.id IN (
158 - SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'project'
159 - )
160 - OR i.id IN (
161 - SELECT it.item_id FROM item_tags it
162 - WHERE it.tag_id IN (
163 - SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag'
164 - )
165 - )
166 - )
167 165 ORDER BY i.created_at DESC
168 166 LIMIT $2 OFFSET $3
169 167 "#,
@@ -185,24 +183,24 @@ pub async fn count_followed_feed_items(
185 183 ) -> Result<i64> {
186 184 let count: i64 = sqlx::query_scalar(
187 185 r#"
188 - SELECT COUNT(DISTINCT i.id)
189 - FROM items i
190 - JOIN projects p ON i.project_id = p.id
191 - WHERE i.is_public = true AND p.is_public = true
192 - AND (
193 - p.user_id IN (
194 - SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'user'
195 - )
196 - OR p.id IN (
197 - SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'project'
198 - )
199 - OR i.id IN (
200 - SELECT it.item_id FROM item_tags it
201 - WHERE it.tag_id IN (
202 - SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag'
203 - )
204 - )
186 + WITH followed_item_ids AS (
187 + SELECT i.id FROM items i
188 + JOIN projects p ON i.project_id = p.id
189 + JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id
190 + WHERE i.is_public = true AND p.is_public = true
191 + UNION
192 + SELECT i.id FROM items i
193 + JOIN projects p ON i.project_id = p.id
194 + JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id
195 + WHERE i.is_public = true AND p.is_public = true
196 + UNION
197 + SELECT i.id FROM items i
198 + JOIN projects p ON i.project_id = p.id
199 + JOIN item_tags it ON it.item_id = i.id
200 + JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id
201 + WHERE i.is_public = true AND p.is_public = true
205 202 )
203 + SELECT COUNT(*) FROM followed_item_ids
206 204 "#,
207 205 )
208 206 .bind(follower_id)
@@ -110,6 +110,27 @@ pub async fn get_latest_release(
110 110 Ok(release)
111 111 }
112 112
113 + /// Fetch a single release scoped to its app — a direct indexed `(id, app_id)`
114 + /// lookup instead of listing the app's whole release set and scanning it in Rust
115 + /// (ultra-fuzz Run 11 Perf SER-1). Returns None if the release doesn't exist or
116 + /// doesn't belong to the app.
117 + #[tracing::instrument(skip_all)]
118 + pub async fn get_release(
119 + pool: &PgPool,
120 + app_id: SyncAppId,
121 + release_id: OtaReleaseId,
122 + ) -> Result<Option<DbOtaRelease>> {
123 + let release = sqlx::query_as::<_, DbOtaRelease>(
124 + "SELECT * FROM ota_releases WHERE id = $1 AND app_id = $2",
125 + )
126 + .bind(release_id)
127 + .bind(app_id)
128 + .fetch_optional(pool)
129 + .await?;
130 +
131 + Ok(release)
132 + }
133 +
113 134 /// Delete a release (cascades to artifacts).
114 135 #[tracing::instrument(skip_all)]
115 136 pub async fn delete_release(pool: &PgPool, release_id: OtaReleaseId) -> Result<bool> {
@@ -280,11 +280,9 @@ async fn upload_artifact(
280 280 return Err(AppError::BadRequest("file_size must be positive".to_string()));
281 281 }
282 282
283 - // Verify the release belongs to this app
284 - let releases = db::ota::list_releases(&state.db, app_id).await?;
285 - let release = releases
286 - .iter()
287 - .find(|r| r.id == release_id)
283 + // Verify the release belongs to this app (scoped lookup, not a full list scan)
284 + let release = db::ota::get_release(&state.db, app_id, release_id)
285 + .await?
288 286 .ok_or(AppError::NotFound)?;
289 287
290 288 let s3_key = crate::storage::S3Client::generate_ota_artifact_key(
@@ -390,9 +388,11 @@ async fn artifact_download(
390 388 .await?
391 389 .ok_or(AppError::NotFound)?;
392 390
393 - // Verify release belongs to this app
394 - let releases = db::ota::list_releases(&state.db, app.id).await?;
395 - if !releases.iter().any(|r| r.id == release_id) {
391 + // Verify release belongs to this app (scoped lookup, not a full list scan)
392 + if db::ota::get_release(&state.db, app.id, release_id)
393 + .await?
394 + .is_none()
395 + {
396 396 return Err(AppError::NotFound);
397 397 }
398 398
@@ -72,15 +72,16 @@ pub(super) async fn dashboard(
72 72 ) -> Result<impl IntoResponse> {
73 73 let csrf_token = get_csrf_token(&session).await;
74 74
75 - let db_user = db::users::get_user_by_id(&state.db, session_user.id)
76 - .await?
77 - .ok_or(AppError::NotFound)?;
78 -
79 - let db_projects = db::projects::get_projects_by_user(&state.db, session_user.id).await?;
80 -
81 - // Get transactions where user is buyer or seller
82 - let incoming_txs = db::transactions::get_transactions_by_seller(&state.db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)).await?;
83 - let outgoing_txs = db::transactions::get_transactions_by_buyer(&state.db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)).await?;
75 + // These four reads are independent — run them concurrently rather than in
76 + // series so the dashboard pays one round-trip's latency, not four (Run 11
77 + // Perf MOD tail; revisits Run 10 M6 now that the heavy per-render work is gone).
78 + let (db_user, db_projects, incoming_txs, outgoing_txs) = tokio::try_join!(
79 + db::users::get_user_by_id(&state.db, session_user.id),
80 + db::projects::get_projects_by_user(&state.db, session_user.id),
81 + db::transactions::get_transactions_by_seller(&state.db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)),
82 + db::transactions::get_transactions_by_buyer(&state.db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)),
83 + )?;
84 + let db_user = db_user.ok_or(AppError::NotFound)?;
84 85
85 86 let user = User::from(&db_user);
86 87 let transactions = super::collect_transactions(incoming_txs, outgoing_txs);
@@ -61,6 +61,7 @@ pub fn dashboard_routes() -> CsrfRouter<AppState> {
61 61 .route_get("/dashboard/tabs/ssh-keys", get(tabs::dashboard_tab_ssh_keys))
62 62 .route_get("/dashboard/tabs/support", get(tabs::dashboard_tab_support))
63 63 .route_get("/dashboard/tabs/contacts", get(tabs::dashboard_tab_contacts))
64 + .route_get("/dashboard/tabs/payout-summary", get(tabs::dashboard_tab_payout_summary))
64 65 .route_get("/dashboard/transactions", get(tabs::dashboard_transactions))
65 66 .route_get("/dashboard/project/{slug}/tabs/overview", get(project_tabs::project_tab_overview))
66 67 .route_get("/dashboard/project/{slug}/tabs/content", get(project_tabs::project_tab_content))
@@ -10,7 +10,8 @@ pub(super) use item::{
10 10 pub(super) use user::{
11 11 dashboard_tab_account, dashboard_tab_analytics, dashboard_tab_contacts,
12 12 dashboard_tab_creator, dashboard_tab_details, dashboard_tab_forums,
13 - dashboard_tab_media, dashboard_tab_payments, dashboard_tab_profile,
13 + dashboard_tab_media, dashboard_tab_payments, dashboard_tab_payout_summary,
14 + dashboard_tab_profile,
14 15 dashboard_tab_projects, dashboard_tab_settings, dashboard_tab_ssh_keys,
15 16 dashboard_tab_support, dashboard_tab_synckit, dashboard_transactions,
16 17 regenerate_feed_url,
@@ -11,7 +11,8 @@ pub(in crate::routes::pages::dashboard) use integrations::{
11 11 dashboard_tab_forums, dashboard_tab_media, dashboard_tab_synckit,
12 12 };
13 13 pub(in crate::routes::pages::dashboard) use payments::{
14 - dashboard_tab_contacts, dashboard_tab_payments, dashboard_transactions,
14 + dashboard_tab_contacts, dashboard_tab_payments, dashboard_tab_payout_summary,
15 + dashboard_transactions,
15 16 };
16 17
17 18 use axum::extract::State;
@@ -26,25 +26,6 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments(
26 26
27 27 let user = User::from(&db_user);
28 28
29 - // Fetch Stripe balance for payout summary
30 - let payout_summary = match (&state.stripe, &user.stripe_account_id) {
31 - (Some(stripe), Some(account_id)) => {
32 - match stripe.get_balance(account_id).await {
33 - Ok(balance) => {
34 - Some(PayoutSummary {
35 - available: helpers::format_revenue(balance.available_cents),
36 - pending: helpers::format_revenue(balance.pending_cents),
37 - })
38 - }
39 - Err(e) => {
40 - tracing::warn!(error = ?e, "failed to fetch Stripe balance for payout summary");
41 - None
42 - }
43 - }
44 - }
45 - _ => None,
46 - };
47 -
48 29 let incoming_txs = db::transactions::get_transactions_by_seller(
49 30 &state.db,
50 31 session_user.id,
@@ -81,7 +62,6 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments(
81 62
82 63 Ok(UserPaymentsTabTemplate {
83 64 user,
84 - payout_summary,
85 65 transactions,
86 66 tips_received,
87 67 tips_total: helpers::format_revenue(tips_total_cents),
@@ -93,6 +73,39 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments(
93 73 })
94 74 }
95 75
76 + /// Render the HTMX partial for the Stripe payout summary (lazy-loaded in the
77 + /// Payments tab). Split out of `dashboard_tab_payments` so the tab renders
78 + /// instantly instead of blocking on the Stripe balance round-trip (Run 11 Perf SER-2).
79 + #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_payout_summary")]
80 + pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payout_summary(
81 + State(state): State<AppState>,
82 + AuthUser(session_user): AuthUser,
83 + ) -> Result<impl IntoResponse> {
84 + let db_user = db::users::get_user_by_id(&state.db, session_user.id)
85 + .await?
86 + .ok_or(crate::error::AppError::NotFound)?;
87 + let user = User::from(&db_user);
88 +
89 + let payout_summary = match (&state.stripe, &user.stripe_account_id) {
90 + (Some(stripe), Some(account_id)) => match stripe.get_balance(account_id).await {
91 + Ok(balance) => Some(PayoutSummary {
92 + available: helpers::format_revenue(balance.available_cents),
93 + pending: helpers::format_revenue(balance.pending_cents),
94 + }),
95 + Err(e) => {
96 + tracing::warn!(error = ?e, "failed to fetch Stripe balance for payout summary");
97 + None
98 + }
99 + },
100 + _ => None,
101 + };
102 +
103 + Ok(PayoutSummaryPartialTemplate {
104 + payout_summary,
105 + stripe_payouts_enabled: user.stripe_payouts_enabled,
106 + })
107 + }
108 +
96 109 /// Render the HTMX partial for the buyer contacts section (lazy-loaded in Payments tab).
97 110 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_contacts")]
98 111 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_contacts(
@@ -147,7 +147,14 @@ async fn claim_free_cart_items(
147 147 if items.is_empty() {
148 148 return Ok(());
149 149 }
150 + // Claim every free item in ONE transaction instead of a begin/commit per item
151 + // (Run 11 Perf MOD tail) — collapses N round-trips to one and makes the cart
152 + // claim atomic (all free items or none). Post-commit side effects (bundle
153 + // grants, license-key minting) run after, only for the newly-claimed items.
150 154 let mut to_remove: Vec<db::ItemId> = Vec::with_capacity(items.len());
155 + let mut claimed_items: Vec<&db::cart::CartItem> = Vec::with_capacity(items.len());
156 +
157 + let mut tx = state.db.begin().await.context("begin free-claim transaction")?;
151 158 for item in items {
152 159 let claim = db::transactions::ClaimParams {
153 160 buyer_id: user_id,
@@ -159,7 +166,6 @@ async fn claim_free_cart_items(
159 166 parent_transaction_id: None,
160 167 };
161 168
162 - let mut tx = state.db.begin().await.context("begin free-claim transaction")?;
163 169 let claimed = db::transactions::claim_free_item(&mut *tx, &claim)
164 170 .await
165 171 .context("claim free item")?;
@@ -167,23 +173,23 @@ async fn claim_free_cart_items(
167 173 db::items::increment_sales_count(&mut *tx, item.item_id)
168 174 .await
169 175 .context("increment sales count")?;
176 + claimed_items.push(*item);
170 177 }
171 - tx.commit().await.context("commit free-claim transaction")?;
178 + to_remove.push(item.item_id);
179 + }
180 + tx.commit().await.context("commit free-claim transaction")?;
172 181
173 - if claimed {
174 - if item.item_type == "bundle" {
175 - grant_bundle_items(state, item.item_id, user_id, seller_id, None).await;
176 - }
177 - if item.enable_license_keys {
178 - let key_code = helpers::generate_key_code();
179 - db::license_keys::create_license_key(
180 - &state.db, item.item_id, user_id, None, &key_code,
181 - item.default_max_activations,
182 - ).await.ok();
183 - }
182 + for item in claimed_items {
183 + if item.item_type == "bundle" {
184 + grant_bundle_items(state, item.item_id, user_id, seller_id, None).await;
185 + }
186 + if item.enable_license_keys {
187 + let key_code = helpers::generate_key_code();
188 + db::license_keys::create_license_key(
189 + &state.db, item.item_id, user_id, None, &key_code,
190 + item.default_max_activations,
191 + ).await.ok();
184 192 }
185 -
186 - to_remove.push(item.item_id);
187 193 }
188 194 db::cart::remove_from_cart_bulk(&state.db, user_id, &to_remove).await.ok();
189 195 Ok(())
@@ -196,6 +196,7 @@ impl_into_response!(
196 196 ProjectAnalyticsTabTemplate,
197 197 UserAnalyticsTabTemplate,
198 198 BuyerContactsPartialTemplate,
199 + PayoutSummaryPartialTemplate,
199 200 ProjectSettingsTabTemplate,
200 201 ProjectCodeTabTemplate,
201 202 ProjectBlogTabTemplate,
@@ -262,7 +262,6 @@ pub struct CustomLinkWithId {
262 262 #[template(path = "partials/tabs/user_payments.html")]
263 263 pub struct UserPaymentsTabTemplate {
264 264 pub user: User,
265 - pub payout_summary: Option<PayoutSummary>,
266 265 pub transactions: Vec<Transaction>,
267 266 pub tips_received: Vec<TipReceived>,
268 267 pub tips_total: String,
@@ -620,6 +619,15 @@ pub struct BuyerContactsPartialTemplate {
620 619 pub contacts: Vec<BuyerContact>,
621 620 }
622 621
622 + /// Stripe payout-summary card, HTMX-loaded into the Payments tab so the tab
623 + /// render never blocks on the Stripe balance round-trip (ultra-fuzz Run 11 Perf SER-2).
624 + #[derive(Template)]
625 + #[template(path = "partials/tabs/payout_summary.html")]
626 + pub struct PayoutSummaryPartialTemplate {
627 + pub payout_summary: Option<PayoutSummary>,
628 + pub stripe_payouts_enabled: bool,
629 + }
630 +
623 631 /// SSH keys list partial for HTMX updates.
624 632 #[derive(Template)]
625 633 #[template(path = "partials/ssh_keys_list.html")]
@@ -0,0 +1,44 @@
1 + {% if let Some(summary) = payout_summary %}
2 + <div class="stripe-payout-summary card-muted mb-5">
3 + <div class="stack-row stack-row--top">
4 + <div>
5 + <h3 class="card-h">Payout Summary</h3>
6 + <p class="m-0 text-sm muted">
7 + Payouts are processed automatically via Stripe Connect.
8 + </p>
9 + </div>
10 + <a href="https://dashboard.stripe.com/payouts" target="_blank" rel="noopener">
11 + <button class="btn-secondary text-sm">View in Stripe</button>
12 + </a>
13 + </div>
14 +
15 + <div class="payout-stats-grid">
16 + <div>
17 + <div class="text-xs dimmed">Available Balance</div>
18 + <div class="amount-big">{{ summary.available }}</div>
19 + </div>
20 + <div>
21 + <div class="text-xs dimmed">Pending</div>
22 + <div class="amount-big">{{ summary.pending }}</div>
23 + </div>
24 + </div>
25 +
26 + {% if !stripe_payouts_enabled %}
27 + <div class="payouts-disabled-notice">
28 + Payouts are not yet enabled. Complete your Stripe account setup above.
29 + </div>
30 + {% endif %}
31 + </div>
32 + {% else %}
33 + <div class="card-muted mb-5">
34 + <div class="stack-row">
35 + <div>
36 + <h3 class="card-h">Payout Summary</h3>
37 + <p class="m-0 text-sm dimmed">Unable to load balance. Check your Stripe dashboard for details.</p>
38 + </div>
39 + <a href="https://dashboard.stripe.com/payouts" target="_blank" rel="noopener">
40 + <button class="btn-secondary text-sm">View in Stripe</button>
41 + </a>
42 + </div>
43 + </div>
44 + {% endif %}
@@ -68,47 +68,13 @@
68 68
69 69 <div class="section-group-label">Payouts</div>
70 70
71 - {% if let Some(summary) = payout_summary %}
72 - <div class="stripe-payout-summary card-muted mb-5">
73 - <div class="stack-row stack-row--top">
74 - <div>
75 - <h3 class="card-h">Payout Summary</h3>
76 - <p class="m-0 text-sm muted">
77 - Payouts are processed automatically via Stripe Connect.
78 - </p>
79 - </div>
80 - <a href="https://dashboard.stripe.com/payouts" target="_blank" rel="noopener">
81 - <button class="btn-secondary text-sm">View in Stripe</button>
82 - </a>
83 - </div>
84 -
85 - <div class="payout-stats-grid">
86 - <div>
87 - <div class="text-xs dimmed">Available Balance</div>
88 - <div class="amount-big">{{ summary.available }}</div>
89 - </div>
90 - <div>
91 - <div class="text-xs dimmed">Pending</div>
92 - <div class="amount-big">{{ summary.pending }}</div>
93 - </div>
94 - </div>
95 -
96 - {% if !user.stripe_payouts_enabled %}
97 - <div class="payouts-disabled-notice">
98 - Payouts are not yet enabled. Complete your Stripe account setup above.
99 - </div>
100 - {% endif %}
101 - </div>
102 - {% else if user.stripe_account_id.is_some() %}
103 - <div class="card-muted mb-5">
104 - <div class="stack-row">
105 - <div>
106 - <h3 class="card-h">Payout Summary</h3>
107 - <p class="m-0 text-sm dimmed">Unable to load balance. Check your Stripe dashboard for details.</p>
108 - </div>
109 - <a href="https://dashboard.stripe.com/payouts" target="_blank" rel="noopener">
110 - <button class="btn-secondary text-sm">View in Stripe</button>
111 - </a>
71 + {% if user.stripe_account_id.is_some() %}
72 + <!-- Stripe balance is lazy-loaded so the tab render never blocks on the
73 + Stripe round-trip (ultra-fuzz Run 11 Perf SER-2). -->
74 + <div hx-get="/dashboard/tabs/payout-summary" hx-trigger="revealed" hx-swap="innerHTML" id="payout-summary-section">
75 + <div class="card-muted mb-5">
76 + <h3 class="card-h">Payout Summary</h3>
77 + <p class="m-0 text-sm muted">Loading balance.</p>
112 78 </div>
113 79 </div>
114 80 {% endif %}
@@ -44,6 +44,45 @@ async fn make_discoverable_item(
44 44 (setup.user_id.to_string(), setup.item_id)
45 45 }
46 46
47 + /// The denormalized `projects.item_count` (read by discover instead of a
48 + /// COUNT-over-the-catalog) is maintained by a trigger on `items`. Pin that it
49 + /// tracks the active-item lifecycle exactly: list/unlist/soft-delete each move
50 + /// the cached count (ultra-fuzz Run 11 Perf SER-3).
51 + async fn project_item_count(h: &TestHarness, project_id: &str) -> i32 {
52 + sqlx::query_scalar::<_, i32>("SELECT item_count FROM projects WHERE id = $1::uuid")
53 + .bind(project_id)
54 + .fetch_one(&h.db)
55 + .await
56 + .expect("read item_count")
57 + }
58 +
59 + #[tokio::test]
60 + async fn project_item_count_tracks_active_item_lifecycle() {
61 + let mut h = TestHarness::new().await;
62 + let setup = h.create_creator_with_item("counttrack", "audio", 1000).await;
63 + let pid = setup.project_id.to_string();
64 +
65 + // Active: public + listed + not deleted.
66 + sqlx::query("UPDATE items SET is_public = true, listed = true, deleted_at = NULL WHERE id = $1::uuid")
67 + .bind(&setup.item_id).execute(&h.db).await.expect("activate item");
68 + assert_eq!(project_item_count(&h, &pid).await, 1, "active item counts");
69 +
70 + // Unlist -> drops out of the active set.
71 + sqlx::query("UPDATE items SET listed = false WHERE id = $1::uuid")
72 + .bind(&setup.item_id).execute(&h.db).await.expect("unlist");
73 + assert_eq!(project_item_count(&h, &pid).await, 0, "unlisted item not counted");
74 +
75 + // Relist -> back in.
76 + sqlx::query("UPDATE items SET listed = true WHERE id = $1::uuid")
77 + .bind(&setup.item_id).execute(&h.db).await.expect("relist");
78 + assert_eq!(project_item_count(&h, &pid).await, 1, "relisted item counts again");
79 +
80 + // Soft-delete -> drops out.
81 + sqlx::query("UPDATE items SET deleted_at = NOW() WHERE id = $1::uuid")
82 + .bind(&setup.item_id).execute(&h.db).await.expect("soft delete");
83 + assert_eq!(project_item_count(&h, &pid).await, 0, "soft-deleted item not counted");
84 + }
85 +
47 86 #[tokio::test]
48 87 async fn discover_page_renders_for_anonymous_visitor() {
49 88 let mut h = TestHarness::new().await;