max / makenotwork
20 files changed,
+836 insertions,
-380 deletions
| @@ -0,0 +1,72 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n SELECT p.title AS project_title, t.name AS tier_name, t.price_cents,\n u.username, s.status AS \"status: super::SubscriptionStatus\",\n s.current_period_start AS \"current_period_start: chrono::DateTime<chrono::Utc>\",\n s.current_period_end AS \"current_period_end: chrono::DateTime<chrono::Utc>\",\n s.canceled_at AS \"canceled_at: chrono::DateTime<chrono::Utc>\",\n s.created_at AS \"created_at: chrono::DateTime<chrono::Utc>\"\n FROM subscriptions s\n JOIN users u ON u.id = s.subscriber_id\n JOIN subscription_tiers t ON t.id = s.tier_id\n JOIN projects p ON p.id = s.project_id\n WHERE s.project_id IN (SELECT id FROM projects WHERE user_id = $1)\n ORDER BY s.created_at DESC, s.id DESC\n LIMIT $2 OFFSET $3\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "project_title", | |
| 9 | + | "type_info": "Varchar" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "tier_name", | |
| 14 | + | "type_info": "Varchar" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "price_cents", | |
| 19 | + | "type_info": "Int4" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "username", | |
| 24 | + | "type_info": "Varchar" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "status: super::SubscriptionStatus", | |
| 29 | + | "type_info": "Varchar" | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "ordinal": 5, | |
| 33 | + | "name": "current_period_start: chrono::DateTime<chrono::Utc>", | |
| 34 | + | "type_info": "Timestamptz" | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | "ordinal": 6, | |
| 38 | + | "name": "current_period_end: chrono::DateTime<chrono::Utc>", | |
| 39 | + | "type_info": "Timestamptz" | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | "ordinal": 7, | |
| 43 | + | "name": "canceled_at: chrono::DateTime<chrono::Utc>", | |
| 44 | + | "type_info": "Timestamptz" | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | "ordinal": 8, | |
| 48 | + | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 49 | + | "type_info": "Timestamptz" | |
| 50 | + | } | |
| 51 | + | ], | |
| 52 | + | "parameters": { | |
| 53 | + | "Left": [ | |
| 54 | + | "Uuid", | |
| 55 | + | "Int8", | |
| 56 | + | "Int8" | |
| 57 | + | ] | |
| 58 | + | }, | |
| 59 | + | "nullable": [ | |
| 60 | + | false, | |
| 61 | + | false, | |
| 62 | + | false, | |
| 63 | + | false, | |
| 64 | + | false, | |
| 65 | + | true, | |
| 66 | + | true, | |
| 67 | + | true, | |
| 68 | + | false | |
| 69 | + | ] | |
| 70 | + | }, | |
| 71 | + | "hash": "169ed0d8f74cc69847d5d63b6a1d3b3f4d277cd3540036c7d5278ddc139a3340" | |
| 72 | + | } |
| @@ -0,0 +1,150 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n SELECT\n id AS \"id: TransactionId\", buyer_id AS \"buyer_id: UserId\", seller_id AS \"seller_id: UserId\",\n item_id AS \"item_id: ItemId\", amount_cents AS \"amount_cents: Cents\", platform_fee_cents AS \"platform_fee_cents: Cents\",\n currency, status AS \"status: super::TransactionStatus\", stripe_payment_intent_id, stripe_checkout_session_id,\n created_at AS \"created_at: chrono::DateTime<chrono::Utc>\", completed_at AS \"completed_at: chrono::DateTime<chrono::Utc>\",\n item_title, seller_username, share_contact, project_id AS \"project_id: ProjectId\",\n parent_transaction_id AS \"parent_transaction_id: TransactionId\", promo_code_id AS \"promo_code_id: PromoCodeId\",\n guest_email, claim_token AS \"claim_token: ClaimToken\", claimed_by AS \"claimed_by: UserId\",\n download_token AS \"download_token: DownloadToken\"\n FROM transactions WHERE buyer_id = $1\n ORDER BY created_at DESC, id DESC\n LIMIT $2 OFFSET $3\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "id: TransactionId", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "buyer_id: UserId", | |
| 14 | + | "type_info": "Uuid" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "seller_id: UserId", | |
| 19 | + | "type_info": "Uuid" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "item_id: ItemId", | |
| 24 | + | "type_info": "Uuid" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "amount_cents: Cents", | |
| 29 | + | "type_info": "Int4" | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "ordinal": 5, | |
| 33 | + | "name": "platform_fee_cents: Cents", | |
| 34 | + | "type_info": "Int4" | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | "ordinal": 6, | |
| 38 | + | "name": "currency", | |
| 39 | + | "type_info": "Varchar" | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | "ordinal": 7, | |
| 43 | + | "name": "status: super::TransactionStatus", | |
| 44 | + | "type_info": "Varchar" | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | "ordinal": 8, | |
| 48 | + | "name": "stripe_payment_intent_id", | |
| 49 | + | "type_info": "Varchar" | |
| 50 | + | }, | |
| 51 | + | { | |
| 52 | + | "ordinal": 9, | |
| 53 | + | "name": "stripe_checkout_session_id", | |
| 54 | + | "type_info": "Varchar" | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | "ordinal": 10, | |
| 58 | + | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 59 | + | "type_info": "Timestamptz" | |
| 60 | + | }, | |
| 61 | + | { | |
| 62 | + | "ordinal": 11, | |
| 63 | + | "name": "completed_at: chrono::DateTime<chrono::Utc>", | |
| 64 | + | "type_info": "Timestamptz" | |
| 65 | + | }, | |
| 66 | + | { | |
| 67 | + | "ordinal": 12, | |
| 68 | + | "name": "item_title", | |
| 69 | + | "type_info": "Varchar" | |
| 70 | + | }, | |
| 71 | + | { | |
| 72 | + | "ordinal": 13, | |
| 73 | + | "name": "seller_username", | |
| 74 | + | "type_info": "Varchar" | |
| 75 | + | }, | |
| 76 | + | { | |
| 77 | + | "ordinal": 14, | |
| 78 | + | "name": "share_contact", | |
| 79 | + | "type_info": "Bool" | |
| 80 | + | }, | |
| 81 | + | { | |
| 82 | + | "ordinal": 15, | |
| 83 | + | "name": "project_id: ProjectId", | |
| 84 | + | "type_info": "Uuid" | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | "ordinal": 16, | |
| 88 | + | "name": "parent_transaction_id: TransactionId", | |
| 89 | + | "type_info": "Uuid" | |
| 90 | + | }, | |
| 91 | + | { | |
| 92 | + | "ordinal": 17, | |
| 93 | + | "name": "promo_code_id: PromoCodeId", | |
| 94 | + | "type_info": "Uuid" | |
| 95 | + | }, | |
| 96 | + | { | |
| 97 | + | "ordinal": 18, | |
| 98 | + | "name": "guest_email", | |
| 99 | + | "type_info": "Varchar" | |
| 100 | + | }, | |
| 101 | + | { | |
| 102 | + | "ordinal": 19, | |
| 103 | + | "name": "claim_token: ClaimToken", | |
| 104 | + | "type_info": "Uuid" | |
| 105 | + | }, | |
| 106 | + | { | |
| 107 | + | "ordinal": 20, | |
| 108 | + | "name": "claimed_by: UserId", | |
| 109 | + | "type_info": "Uuid" | |
| 110 | + | }, | |
| 111 | + | { | |
| 112 | + | "ordinal": 21, | |
| 113 | + | "name": "download_token: DownloadToken", | |
| 114 | + | "type_info": "Uuid" | |
| 115 | + | } | |
| 116 | + | ], | |
| 117 | + | "parameters": { | |
| 118 | + | "Left": [ | |
| 119 | + | "Uuid", | |
| 120 | + | "Int8", | |
| 121 | + | "Int8" | |
| 122 | + | ] | |
| 123 | + | }, | |
| 124 | + | "nullable": [ | |
| 125 | + | false, | |
| 126 | + | true, | |
| 127 | + | true, | |
| 128 | + | true, | |
| 129 | + | false, | |
| 130 | + | false, | |
| 131 | + | false, | |
| 132 | + | false, | |
| 133 | + | true, | |
| 134 | + | true, | |
| 135 | + | false, | |
| 136 | + | true, | |
| 137 | + | true, | |
| 138 | + | true, | |
| 139 | + | false, | |
| 140 | + | true, | |
| 141 | + | true, | |
| 142 | + | true, | |
| 143 | + | true, | |
| 144 | + | true, | |
| 145 | + | true, | |
| 146 | + | true | |
| 147 | + | ] | |
| 148 | + | }, | |
| 149 | + | "hash": "17188cab9291709226c6f4586bccfbbd878a4c98b27bb844e8b48c56edd7981a" | |
| 150 | + | } |
| @@ -0,0 +1,48 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n SELECT u.username, u.display_name, t.name AS tier_name,\n s.status AS \"status: super::SubscriptionStatus\",\n s.created_at AS \"created_at: chrono::DateTime<chrono::Utc>\"\n FROM subscriptions s\n JOIN users u ON u.id = s.subscriber_id\n JOIN subscription_tiers t ON t.id = s.tier_id\n WHERE s.project_id IN (SELECT id FROM projects WHERE user_id = $1)\n ORDER BY s.created_at DESC, s.id DESC\n LIMIT $2 OFFSET $3\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "username", | |
| 9 | + | "type_info": "Varchar" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "display_name", | |
| 14 | + | "type_info": "Varchar" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "tier_name", | |
| 19 | + | "type_info": "Varchar" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "status: super::SubscriptionStatus", | |
| 24 | + | "type_info": "Varchar" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 29 | + | "type_info": "Timestamptz" | |
| 30 | + | } | |
| 31 | + | ], | |
| 32 | + | "parameters": { | |
| 33 | + | "Left": [ | |
| 34 | + | "Uuid", | |
| 35 | + | "Int8", | |
| 36 | + | "Int8" | |
| 37 | + | ] | |
| 38 | + | }, | |
| 39 | + | "nullable": [ | |
| 40 | + | false, | |
| 41 | + | true, | |
| 42 | + | false, | |
| 43 | + | false, | |
| 44 | + | false | |
| 45 | + | ] | |
| 46 | + | }, | |
| 47 | + | "hash": "5013a9ea2c85fe65193f873cfc5d32bdccbedb7de2af30694a52c37df3c592e5" | |
| 48 | + | } |
| @@ -1,70 +0,0 @@ | |||
| 1 | - | { | |
| 2 | - | "db_name": "PostgreSQL", | |
| 3 | - | "query": "\n SELECT p.title AS project_title, t.name AS tier_name, t.price_cents,\n u.username, s.status AS \"status: super::SubscriptionStatus\",\n s.current_period_start AS \"current_period_start: chrono::DateTime<chrono::Utc>\",\n s.current_period_end AS \"current_period_end: chrono::DateTime<chrono::Utc>\",\n s.canceled_at AS \"canceled_at: chrono::DateTime<chrono::Utc>\",\n s.created_at AS \"created_at: chrono::DateTime<chrono::Utc>\"\n FROM subscriptions s\n JOIN users u ON u.id = s.subscriber_id\n JOIN subscription_tiers t ON t.id = s.tier_id\n JOIN projects p ON p.id = s.project_id\n WHERE s.project_id IN (SELECT id FROM projects WHERE user_id = $1)\n ORDER BY s.created_at DESC\n ", | |
| 4 | - | "describe": { | |
| 5 | - | "columns": [ | |
| 6 | - | { | |
| 7 | - | "ordinal": 0, | |
| 8 | - | "name": "project_title", | |
| 9 | - | "type_info": "Varchar" | |
| 10 | - | }, | |
| 11 | - | { | |
| 12 | - | "ordinal": 1, | |
| 13 | - | "name": "tier_name", | |
| 14 | - | "type_info": "Varchar" | |
| 15 | - | }, | |
| 16 | - | { | |
| 17 | - | "ordinal": 2, | |
| 18 | - | "name": "price_cents", | |
| 19 | - | "type_info": "Int4" | |
| 20 | - | }, | |
| 21 | - | { | |
| 22 | - | "ordinal": 3, | |
| 23 | - | "name": "username", | |
| 24 | - | "type_info": "Varchar" | |
| 25 | - | }, | |
| 26 | - | { | |
| 27 | - | "ordinal": 4, | |
| 28 | - | "name": "status: super::SubscriptionStatus", | |
| 29 | - | "type_info": "Varchar" | |
| 30 | - | }, | |
| 31 | - | { | |
| 32 | - | "ordinal": 5, | |
| 33 | - | "name": "current_period_start: chrono::DateTime<chrono::Utc>", | |
| 34 | - | "type_info": "Timestamptz" | |
| 35 | - | }, | |
| 36 | - | { | |
| 37 | - | "ordinal": 6, | |
| 38 | - | "name": "current_period_end: chrono::DateTime<chrono::Utc>", | |
| 39 | - | "type_info": "Timestamptz" | |
| 40 | - | }, | |
| 41 | - | { | |
| 42 | - | "ordinal": 7, | |
| 43 | - | "name": "canceled_at: chrono::DateTime<chrono::Utc>", | |
| 44 | - | "type_info": "Timestamptz" | |
| 45 | - | }, | |
| 46 | - | { | |
| 47 | - | "ordinal": 8, | |
| 48 | - | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 49 | - | "type_info": "Timestamptz" | |
| 50 | - | } | |
| 51 | - | ], | |
| 52 | - | "parameters": { | |
| 53 | - | "Left": [ | |
| 54 | - | "Uuid" | |
| 55 | - | ] | |
| 56 | - | }, | |
| 57 | - | "nullable": [ | |
| 58 | - | false, | |
| 59 | - | false, | |
| 60 | - | false, | |
| 61 | - | false, | |
| 62 | - | false, | |
| 63 | - | true, | |
| 64 | - | true, | |
| 65 | - | true, | |
| 66 | - | false | |
| 67 | - | ] | |
| 68 | - | }, | |
| 69 | - | "hash": "92512050fa13a3ad75ae5e31b848ac1040a9f9c69eecdeef2e03adb90dcbfc69" | |
| 70 | - | } |
| @@ -1,52 +0,0 @@ | |||
| 1 | - | { | |
| 2 | - | "db_name": "PostgreSQL", | |
| 3 | - | "query": "\n SELECT\n t.created_at AS \"created_at: chrono::DateTime<chrono::Utc>\",\n t.item_id AS \"item_id: ItemId\",\n t.item_title,\n t.amount_cents AS \"amount_cents: Cents\",\n t.status AS \"status: super::TransactionStatus\",\n CASE WHEN t.share_contact AND NOT EXISTS (\n SELECT 1 FROM contact_revocations cr\n WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id\n ) THEN u.email ELSE NULL END as buyer_email\n FROM transactions t\n LEFT JOIN users u ON u.id = t.buyer_id\n WHERE t.seller_id = $1\n ORDER BY t.created_at DESC\n ", | |
| 4 | - | "describe": { | |
| 5 | - | "columns": [ | |
| 6 | - | { | |
| 7 | - | "ordinal": 0, | |
| 8 | - | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 9 | - | "type_info": "Timestamptz" | |
| 10 | - | }, | |
| 11 | - | { | |
| 12 | - | "ordinal": 1, | |
| 13 | - | "name": "item_id: ItemId", | |
| 14 | - | "type_info": "Uuid" | |
| 15 | - | }, | |
| 16 | - | { | |
| 17 | - | "ordinal": 2, | |
| 18 | - | "name": "item_title", | |
| 19 | - | "type_info": "Varchar" | |
| 20 | - | }, | |
| 21 | - | { | |
| 22 | - | "ordinal": 3, | |
| 23 | - | "name": "amount_cents: Cents", | |
| 24 | - | "type_info": "Int4" | |
| 25 | - | }, | |
| 26 | - | { | |
| 27 | - | "ordinal": 4, | |
| 28 | - | "name": "status: super::TransactionStatus", | |
| 29 | - | "type_info": "Varchar" | |
| 30 | - | }, | |
| 31 | - | { | |
| 32 | - | "ordinal": 5, | |
| 33 | - | "name": "buyer_email", | |
| 34 | - | "type_info": "Varchar" | |
| 35 | - | } | |
| 36 | - | ], | |
| 37 | - | "parameters": { | |
| 38 | - | "Left": [ | |
| 39 | - | "Uuid" | |
| 40 | - | ] | |
| 41 | - | }, | |
| 42 | - | "nullable": [ | |
| 43 | - | false, | |
| 44 | - | true, | |
| 45 | - | true, | |
| 46 | - | false, | |
| 47 | - | false, | |
| 48 | - | null | |
| 49 | - | ] | |
| 50 | - | }, | |
| 51 | - | "hash": "b0e71c186ef4f316174cd31b3903d03e801d8be57576766dbd3fa9a677979ca5" | |
| 52 | - | } |
| @@ -1,46 +0,0 @@ | |||
| 1 | - | { | |
| 2 | - | "db_name": "PostgreSQL", | |
| 3 | - | "query": "\n SELECT u.username, u.display_name, t.name AS tier_name,\n s.status AS \"status: super::SubscriptionStatus\",\n s.created_at AS \"created_at: chrono::DateTime<chrono::Utc>\"\n FROM subscriptions s\n JOIN users u ON u.id = s.subscriber_id\n JOIN subscription_tiers t ON t.id = s.tier_id\n WHERE s.project_id IN (SELECT id FROM projects WHERE user_id = $1)\n ORDER BY s.created_at DESC\n ", | |
| 4 | - | "describe": { | |
| 5 | - | "columns": [ | |
| 6 | - | { | |
| 7 | - | "ordinal": 0, | |
| 8 | - | "name": "username", | |
| 9 | - | "type_info": "Varchar" | |
| 10 | - | }, | |
| 11 | - | { | |
| 12 | - | "ordinal": 1, | |
| 13 | - | "name": "display_name", | |
| 14 | - | "type_info": "Varchar" | |
| 15 | - | }, | |
| 16 | - | { | |
| 17 | - | "ordinal": 2, | |
| 18 | - | "name": "tier_name", | |
| 19 | - | "type_info": "Varchar" | |
| 20 | - | }, | |
| 21 | - | { | |
| 22 | - | "ordinal": 3, | |
| 23 | - | "name": "status: super::SubscriptionStatus", | |
| 24 | - | "type_info": "Varchar" | |
| 25 | - | }, | |
| 26 | - | { | |
| 27 | - | "ordinal": 4, | |
| 28 | - | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 29 | - | "type_info": "Timestamptz" | |
| 30 | - | } | |
| 31 | - | ], | |
| 32 | - | "parameters": { | |
| 33 | - | "Left": [ | |
| 34 | - | "Uuid" | |
| 35 | - | ] | |
| 36 | - | }, | |
| 37 | - | "nullable": [ | |
| 38 | - | false, | |
| 39 | - | true, | |
| 40 | - | false, | |
| 41 | - | false, | |
| 42 | - | false | |
| 43 | - | ] | |
| 44 | - | }, | |
| 45 | - | "hash": "b5fa4c20ec1e09cb5a89fee1674d3ce34fb8016490fc5b286bb171a7d34f8b2c" | |
| 46 | - | } |
| @@ -0,0 +1,54 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n SELECT\n t.created_at AS \"created_at: chrono::DateTime<chrono::Utc>\",\n t.item_id AS \"item_id: ItemId\",\n t.item_title,\n t.amount_cents AS \"amount_cents: Cents\",\n t.status AS \"status: super::TransactionStatus\",\n CASE WHEN t.share_contact AND NOT EXISTS (\n SELECT 1 FROM contact_revocations cr\n WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id\n ) THEN u.email ELSE NULL END as buyer_email\n FROM transactions t\n LEFT JOIN users u ON u.id = t.buyer_id\n WHERE t.seller_id = $1\n ORDER BY t.created_at DESC, t.id DESC\n LIMIT $2 OFFSET $3\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 9 | + | "type_info": "Timestamptz" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "item_id: ItemId", | |
| 14 | + | "type_info": "Uuid" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "item_title", | |
| 19 | + | "type_info": "Varchar" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "amount_cents: Cents", | |
| 24 | + | "type_info": "Int4" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "status: super::TransactionStatus", | |
| 29 | + | "type_info": "Varchar" | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "ordinal": 5, | |
| 33 | + | "name": "buyer_email", | |
| 34 | + | "type_info": "Varchar" | |
| 35 | + | } | |
| 36 | + | ], | |
| 37 | + | "parameters": { | |
| 38 | + | "Left": [ | |
| 39 | + | "Uuid", | |
| 40 | + | "Int8", | |
| 41 | + | "Int8" | |
| 42 | + | ] | |
| 43 | + | }, | |
| 44 | + | "nullable": [ | |
| 45 | + | false, | |
| 46 | + | true, | |
| 47 | + | true, | |
| 48 | + | false, | |
| 49 | + | false, | |
| 50 | + | null | |
| 51 | + | ] | |
| 52 | + | }, | |
| 53 | + | "hash": "c9b1c6374241b92f67625311ba06f033367de4cf3822ffae9ba04b3dbe3cb9e0" | |
| 54 | + | } |
| @@ -229,9 +229,17 @@ pub async fn get_followed_tag_ids(pool: &PgPool, follower_id: UserId) -> Result< | |||
| 229 | 229 | /// | |
| 230 | 230 | /// Returns username, display_name, what they follow (user/project), and when. | |
| 231 | 231 | #[tracing::instrument(skip_all)] | |
| 232 | - | pub async fn get_followers_for_export( | |
| 232 | + | /// One page of a creator's followers for CSV export, newest first. | |
| 233 | + | /// | |
| 234 | + | /// Paginated (`LIMIT`/`OFFSET`) so the export streams in bounded batches rather | |
| 235 | + | /// than materializing every follower in one query, and so the per-row email | |
| 236 | + | /// `EXISTS` reveal runs only for a bounded page at a time (ultra-fuzz Run 4 S1). | |
| 237 | + | /// Stable `(created_at, follower_id)` ordering keeps OFFSET batches consistent. | |
| 238 | + | pub async fn get_followers_for_export_page( | |
| 233 | 239 | pool: &PgPool, | |
| 234 | 240 | user_id: UserId, | |
| 241 | + | limit: i64, | |
| 242 | + | offset: i64, | |
| 235 | 243 | ) -> Result<Vec<FollowerExportRow>> { | |
| 236 | 244 | let rows = sqlx::query_as::<_, FollowerExportRow>( | |
| 237 | 245 | r#" | |
| @@ -255,10 +263,13 @@ pub async fn get_followers_for_export( | |||
| 255 | 263 | JOIN users u ON u.id = f.follower_id | |
| 256 | 264 | WHERE (f.target_type = 'user' AND f.target_id = $1) | |
| 257 | 265 | OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1)) | |
| 258 | - | ORDER BY f.created_at DESC | |
| 266 | + | ORDER BY f.created_at DESC, f.follower_id DESC | |
| 267 | + | LIMIT $2 OFFSET $3 | |
| 259 | 268 | "#, | |
| 260 | 269 | ) | |
| 261 | 270 | .bind(user_id) | |
| 271 | + | .bind(limit) | |
| 272 | + | .bind(offset) | |
| 262 | 273 | .fetch_all(pool) | |
| 263 | 274 | .await?; | |
| 264 | 275 |
| @@ -323,9 +323,16 @@ pub async fn count_splits_for_recipient(pool: &PgPool, recipient_id: UserId) -> | |||
| 323 | 323 | /// - The recipient (collaborator receiving a share), or | |
| 324 | 324 | /// - The seller/tip recipient (owner who owes collaborators) | |
| 325 | 325 | #[tracing::instrument(skip(pool))] | |
| 326 | - | pub async fn get_splits_for_export( | |
| 326 | + | /// One page of a user's revenue splits for CSV export, newest first. | |
| 327 | + | /// | |
| 328 | + | /// Paginated so the splits export streams in bounded batches instead of loading | |
| 329 | + | /// every split in one query (ultra-fuzz Run 4 S1). Stable `(created_at, id)` | |
| 330 | + | /// ordering keeps OFFSET batches consistent. | |
| 331 | + | pub async fn get_splits_for_export_page( | |
| 327 | 332 | pool: &PgPool, | |
| 328 | 333 | user_id: UserId, | |
| 334 | + | limit: i64, | |
| 335 | + | offset: i64, | |
| 329 | 336 | ) -> Result<Vec<DbSplitExportRow>> { | |
| 330 | 337 | let rows = sqlx::query_as::<_, DbSplitExportRow>( | |
| 331 | 338 | r#" | |
| @@ -338,10 +345,13 @@ pub async fn get_splits_for_export( | |||
| 338 | 345 | LEFT JOIN tips tip ON tip.id = rs.tip_id | |
| 339 | 346 | WHERE rs.recipient_id = $1 | |
| 340 | 347 | OR COALESCE(t.seller_id, tip.recipient_id) = $1 | |
| 341 | - | ORDER BY rs.created_at DESC | |
| 348 | + | ORDER BY rs.created_at DESC, rs.id DESC | |
| 349 | + | LIMIT $2 OFFSET $3 | |
| 342 | 350 | "#, | |
| 343 | 351 | ) | |
| 344 | 352 | .bind(user_id) | |
| 353 | + | .bind(limit) | |
| 354 | + | .bind(offset) | |
| 345 | 355 | .fetch_all(pool) | |
| 346 | 356 | .await?; | |
| 347 | 357 |
| @@ -664,9 +664,14 @@ pub async fn get_project_subscriber_count(pool: &PgPool, project_id: ProjectId) | |||
| 664 | 664 | /// | |
| 665 | 665 | /// Returns username, display_name, tier name, subscription status, and when. | |
| 666 | 666 | #[tracing::instrument(skip_all)] | |
| 667 | - | pub async fn get_project_subscribers_for_export( | |
| 667 | + | /// One page of a creator's project subscribers for CSV export, newest first. | |
| 668 | + | /// Paginated for bounded-memory streaming (ultra-fuzz Run 4 S1); stable | |
| 669 | + | /// `(created_at, id)` ordering keeps OFFSET batches consistent. | |
| 670 | + | pub async fn get_project_subscribers_for_export_page( | |
| 668 | 671 | pool: &PgPool, | |
| 669 | 672 | user_id: UserId, | |
| 673 | + | limit: i64, | |
| 674 | + | offset: i64, | |
| 670 | 675 | ) -> Result<Vec<SubscriberExportRow>> { | |
| 671 | 676 | let rows = sqlx::query_as!( | |
| 672 | 677 | SubscriberExportRow, | |
| @@ -678,9 +683,12 @@ pub async fn get_project_subscribers_for_export( | |||
| 678 | 683 | JOIN users u ON u.id = s.subscriber_id | |
| 679 | 684 | JOIN subscription_tiers t ON t.id = s.tier_id | |
| 680 | 685 | WHERE s.project_id IN (SELECT id FROM projects WHERE user_id = $1) | |
| 681 | - | ORDER BY s.created_at DESC | |
| 686 | + | ORDER BY s.created_at DESC, s.id DESC | |
| 687 | + | LIMIT $2 OFFSET $3 | |
| 682 | 688 | "#, | |
| 683 | 689 | user_id as UserId, | |
| 690 | + | limit, | |
| 691 | + | offset, | |
| 684 | 692 | ) | |
| 685 | 693 | .fetch_all(pool) | |
| 686 | 694 | .await?; | |
| @@ -693,9 +701,14 @@ pub async fn get_project_subscribers_for_export( | |||
| 693 | 701 | /// Returns project name, tier name, price, subscriber username, status, | |
| 694 | 702 | /// billing period dates, and cancellation date. | |
| 695 | 703 | #[tracing::instrument(skip_all)] | |
| 696 | - | pub async fn get_subscriptions_for_export( | |
| 704 | + | /// One page of a creator's subscriptions for CSV export, newest first. | |
| 705 | + | /// Paginated for bounded-memory streaming (ultra-fuzz Run 4 S1); stable | |
| 706 | + | /// `(created_at, id)` ordering keeps OFFSET batches consistent. | |
| 707 | + | pub async fn get_subscriptions_for_export_page( | |
| 697 | 708 | pool: &PgPool, | |
| 698 | 709 | user_id: UserId, | |
| 710 | + | limit: i64, | |
| 711 | + | offset: i64, | |
| 699 | 712 | ) -> Result<Vec<SubscriptionExportRow>> { | |
| 700 | 713 | let rows = sqlx::query_as!( | |
| 701 | 714 | SubscriptionExportRow, | |
| @@ -711,9 +724,12 @@ pub async fn get_subscriptions_for_export( | |||
| 711 | 724 | JOIN subscription_tiers t ON t.id = s.tier_id | |
| 712 | 725 | JOIN projects p ON p.id = s.project_id | |
| 713 | 726 | WHERE s.project_id IN (SELECT id FROM projects WHERE user_id = $1) | |
| 714 | - | ORDER BY s.created_at DESC | |
| 727 | + | ORDER BY s.created_at DESC, s.id DESC | |
| 728 | + | LIMIT $2 OFFSET $3 | |
| 715 | 729 | "#, | |
| 716 | 730 | user_id as UserId, | |
| 731 | + | limit, | |
| 732 | + | offset, | |
| 717 | 733 | ) | |
| 718 | 734 | .fetch_all(pool) | |
| 719 | 735 | .await?; |
| @@ -359,6 +359,43 @@ pub async fn get_transactions_by_buyer( | |||
| 359 | 359 | Ok(txs) | |
| 360 | 360 | } | |
| 361 | 361 | ||
| 362 | + | /// One page of a buyer's purchases for CSV export, newest first. | |
| 363 | + | /// | |
| 364 | + | /// Paginated so the purchases export streams in bounded batches rather than | |
| 365 | + | /// loading the buyer's whole history with `limit: None` (ultra-fuzz Run 4 S1). | |
| 366 | + | /// Stable `(created_at, id)` ordering keeps OFFSET batches consistent. | |
| 367 | + | pub async fn get_buyer_transactions_for_export_page( | |
| 368 | + | pool: &PgPool, | |
| 369 | + | buyer_id: UserId, | |
| 370 | + | limit: i64, | |
| 371 | + | offset: i64, | |
| 372 | + | ) -> Result<Vec<DbTransaction>> { | |
| 373 | + | let txs = sqlx::query_as!( | |
| 374 | + | DbTransaction, | |
| 375 | + | r#" | |
| 376 | + | SELECT | |
| 377 | + | id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId", | |
| 378 | + | item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents", | |
| 379 | + | currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, | |
| 380 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>", | |
| 381 | + | item_title, seller_username, share_contact, project_id AS "project_id: ProjectId", | |
| 382 | + | parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId", | |
| 383 | + | guest_email, claim_token AS "claim_token: ClaimToken", claimed_by AS "claimed_by: UserId", | |
| 384 | + | download_token AS "download_token: DownloadToken" | |
| 385 | + | FROM transactions WHERE buyer_id = $1 | |
| 386 | + | ORDER BY created_at DESC, id DESC | |
| 387 | + | LIMIT $2 OFFSET $3 | |
| 388 | + | "#, | |
| 389 | + | buyer_id as UserId, | |
| 390 | + | limit, | |
| 391 | + | offset, | |
| 392 | + | ) | |
| 393 | + | .fetch_all(pool) | |
| 394 | + | .await?; | |
| 395 | + | ||
| 396 | + | Ok(txs) | |
| 397 | + | } | |
| 398 | + | ||
| 362 | 399 | /// List transactions where the user is the seller, newest first. | |
| 363 | 400 | /// | |
| 364 | 401 | /// Pass `limit: None` for all rows (exports), or `Some(n)` for dashboard display. | |
| @@ -1100,9 +1137,19 @@ pub async fn get_seller_contacts( | |||
| 1100 | 1137 | /// Respects contact revocations: if a buyer revoked sharing, their email | |
| 1101 | 1138 | /// is hidden even if `share_contact` was true on the transaction. | |
| 1102 | 1139 | #[tracing::instrument(skip_all)] | |
| 1103 | - | pub async fn get_seller_transactions_for_export( | |
| 1140 | + | /// One page of a seller's sales for CSV export, newest first. | |
| 1141 | + | /// | |
| 1142 | + | /// Paginated (`LIMIT`/`OFFSET`) so the export streams in bounded batches instead | |
| 1143 | + | /// of loading the seller's entire transaction history into memory in one query | |
| 1144 | + | /// (ultra-fuzz Run 4 S1). The `(created_at, id)` ordering is stable so OFFSET | |
| 1145 | + | /// batches don't reorder. (Keyset pagination would avoid OFFSET's deep-scan cost | |
| 1146 | + | /// and is the future optimization; OFFSET is sufficient at current scale and | |
| 1147 | + | /// keeps peak memory + per-query result bounded, which is the DoS fix.) | |
| 1148 | + | pub async fn get_seller_transactions_for_export_page( | |
| 1104 | 1149 | pool: &PgPool, | |
| 1105 | 1150 | seller_id: UserId, | |
| 1151 | + | limit: i64, | |
| 1152 | + | offset: i64, | |
| 1106 | 1153 | ) -> Result<Vec<DbTransactionExportRow>> { | |
| 1107 | 1154 | let rows = sqlx::query_as!( | |
| 1108 | 1155 | DbTransactionExportRow, | |
| @@ -1120,9 +1167,12 @@ pub async fn get_seller_transactions_for_export( | |||
| 1120 | 1167 | FROM transactions t | |
| 1121 | 1168 | LEFT JOIN users u ON u.id = t.buyer_id | |
| 1122 | 1169 | WHERE t.seller_id = $1 | |
| 1123 | - | ORDER BY t.created_at DESC | |
| 1170 | + | ORDER BY t.created_at DESC, t.id DESC | |
| 1171 | + | LIMIT $2 OFFSET $3 | |
| 1124 | 1172 | "#, | |
| 1125 | 1173 | seller_id as UserId, | |
| 1174 | + | limit, | |
| 1175 | + | offset, | |
| 1126 | 1176 | ) | |
| 1127 | 1177 | .fetch_all(pool) | |
| 1128 | 1178 | .await?; | |
| @@ -1130,6 +1180,33 @@ pub async fn get_seller_transactions_for_export( | |||
| 1130 | 1180 | Ok(rows) | |
| 1131 | 1181 | } | |
| 1132 | 1182 | ||
| 1183 | + | /// All of a seller's export rows accumulated into one Vec, bounded to | |
| 1184 | + | /// `EXPORT_ACCUMULATE_CAP` rows. For admin / internal-API callers that need the | |
| 1185 | + | /// full set in memory; the public creator-facing export streams page-by-page via | |
| 1186 | + | /// [`get_seller_transactions_for_export_page`] instead of materializing here. | |
| 1187 | + | pub async fn get_seller_transactions_for_export( | |
| 1188 | + | pool: &PgPool, | |
| 1189 | + | seller_id: UserId, | |
| 1190 | + | ) -> Result<Vec<DbTransactionExportRow>> { | |
| 1191 | + | /// Page size for the accumulating fetch. | |
| 1192 | + | const PAGE: i64 = 5_000; | |
| 1193 | + | /// Cap so even an admin/internal export can't load an unbounded result set. | |
| 1194 | + | const EXPORT_ACCUMULATE_CAP: usize = 1_000_000; | |
| 1195 | + | ||
| 1196 | + | let mut all = Vec::new(); | |
| 1197 | + | let mut offset = 0i64; | |
| 1198 | + | loop { | |
| 1199 | + | let page = get_seller_transactions_for_export_page(pool, seller_id, PAGE, offset).await?; | |
| 1200 | + | let n = page.len(); | |
| 1201 | + | all.extend(page); | |
| 1202 | + | offset += n as i64; | |
| 1203 | + | if (n as i64) < PAGE || all.len() >= EXPORT_ACCUMULATE_CAP { | |
| 1204 | + | break; | |
| 1205 | + | } | |
| 1206 | + | } | |
| 1207 | + | Ok(all) | |
| 1208 | + | } | |
| 1209 | + | ||
| 1133 | 1210 | /// Platform-wide revenue stats: total completed revenue, completed count, refunded count. | |
| 1134 | 1211 | #[tracing::instrument(skip_all)] | |
| 1135 | 1212 | pub async fn get_platform_revenue_stats(pool: &PgPool) -> Result<(i64, i64, i64)> { |
| @@ -5,10 +5,14 @@ mod content; | |||
| 5 | 5 | pub(super) use content::export_content; | |
| 6 | 6 | ||
| 7 | 7 | use axum::{ | |
| 8 | + | body::Body, | |
| 8 | 9 | extract::State, | |
| 9 | 10 | http::header::HeaderMap, | |
| 10 | 11 | response::{IntoResponse, Response}, | |
| 11 | 12 | }; | |
| 13 | + | use bytes::Bytes; | |
| 14 | + | use tokio::sync::mpsc; | |
| 15 | + | use tokio_stream::{wrappers::ReceiverStream, StreamExt}; | |
| 12 | 16 | use crate::{ | |
| 13 | 17 | auth::AuthUser, | |
| 14 | 18 | db, | |
| @@ -18,6 +22,90 @@ use crate::{ | |||
| 18 | 22 | AppState, | |
| 19 | 23 | }; | |
| 20 | 24 | ||
| 25 | + | /// Rows per page for streamed CSV exports. | |
| 26 | + | const EXPORT_BATCH: i64 = 2_000; | |
| 27 | + | /// Hard ceiling on rows in one export, bounding worst-case OFFSET scan cost. | |
| 28 | + | const EXPORT_MAX_ROWS: usize = 1_000_000; | |
| 29 | + | ||
| 30 | + | /// Spawn a producer that pages a single export query and streams CSV chunks | |
| 31 | + | /// (header first) into a bounded channel. Peak memory is one batch and the DB | |
| 32 | + | /// connection is released between batches, so a huge export no longer loads the | |
| 33 | + | /// whole result set into one `String` from one unbounded query (ultra-fuzz Run 4 | |
| 34 | + | /// S1). `page(limit, offset)` returns the formatted CSV for that page and its row | |
| 35 | + | /// count; a short page ends the stream. OFFSET pagination is sufficient at | |
| 36 | + | /// current scale; keyset is the future optimization (see the `_page` queries). | |
| 37 | + | fn spawn_paginated_csv<F, Fut>(header: &'static str, mut page: F) -> mpsc::Receiver<Bytes> | |
| 38 | + | where | |
| 39 | + | F: FnMut(i64, i64) -> Fut + Send + 'static, | |
| 40 | + | Fut: std::future::Future<Output = Result<(String, usize)>> + Send, | |
| 41 | + | { | |
| 42 | + | let (tx, rx) = mpsc::channel::<Bytes>(4); | |
| 43 | + | tokio::spawn(async move { | |
| 44 | + | if tx.send(Bytes::from_static(header.as_bytes())).await.is_err() { | |
| 45 | + | return; | |
| 46 | + | } | |
| 47 | + | let mut offset = 0i64; | |
| 48 | + | let mut total = 0usize; | |
| 49 | + | loop { | |
| 50 | + | let (chunk, n) = match page(EXPORT_BATCH, offset).await { | |
| 51 | + | Ok(p) => p, | |
| 52 | + | Err(e) => { | |
| 53 | + | tracing::error!(error = ?e, "csv export page failed mid-stream"); | |
| 54 | + | break; | |
| 55 | + | } | |
| 56 | + | }; | |
| 57 | + | if n > 0 && tx.send(Bytes::from(chunk)).await.is_err() { | |
| 58 | + | return; // client disconnected; stop producing | |
| 59 | + | } | |
| 60 | + | offset += n as i64; | |
| 61 | + | total += n; | |
| 62 | + | if (n as i64) < EXPORT_BATCH { | |
| 63 | + | break; | |
| 64 | + | } | |
| 65 | + | if total >= EXPORT_MAX_ROWS { | |
| 66 | + | let _ = tx | |
| 67 | + | .send(Bytes::from_static( | |
| 68 | + | b"# export truncated at row limit; contact support for a full export\n", | |
| 69 | + | )) | |
| 70 | + | .await; | |
| 71 | + | break; | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | }); | |
| 75 | + | rx | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Turn a CSV chunk stream into a response. The non-HTMX path (the JS | |
| 79 | + | /// `fetch().then(r => r.blob())` download) gets a true streamed attachment with | |
| 80 | + | /// flat memory. The no-JS HTMX fallback can't stream a `data:` URI, so it | |
| 81 | + | /// materializes a bounded prefix and notes the truncation. | |
| 82 | + | async fn finish_csv(is_htmx: bool, filename: &str, mut rx: mpsc::Receiver<Bytes>) -> Result<Response> { | |
| 83 | + | if is_htmx { | |
| 84 | + | const HTMX_BYTE_CAP: usize = 4 * 1024 * 1024; | |
| 85 | + | let mut body = String::new(); | |
| 86 | + | while let Some(chunk) = rx.recv().await { | |
| 87 | + | body.push_str(&String::from_utf8_lossy(&chunk)); | |
| 88 | + | if body.len() >= HTMX_BYTE_CAP { | |
| 89 | + | body.push_str("\n# Export truncated. Enable JavaScript to download the full file.\n"); | |
| 90 | + | break; | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | // Dropping `rx` here stops the producer (its next send errors out). | |
| 94 | + | let data_uri = format!("data:text/csv;charset=utf-8,{}", urlencoding::encode(&body)); | |
| 95 | + | return Ok(ExportDownloadTemplate { | |
| 96 | + | data_uri, | |
| 97 | + | filename: filename.to_string(), | |
| 98 | + | } | |
| 99 | + | .into_response()); | |
| 100 | + | } | |
| 101 | + | let stream = ReceiverStream::new(rx).map(Ok::<Bytes, std::convert::Infallible>); | |
| 102 | + | Response::builder() | |
| 103 | + | .header("Content-Type", "text/csv") | |
| 104 | + | .header("Content-Disposition", format!("attachment; filename=\"{filename}\"")) | |
| 105 | + | .body(Body::from_stream(stream)) | |
| 106 | + | .context("build streaming export response") | |
| 107 | + | } | |
| 108 | + | ||
| 21 | 109 | /// Return an inline error message for HTMX export requests instead of | |
| 22 | 110 | /// letting the error propagate to the JSON error layer (which would swap | |
| 23 | 111 | /// raw JSON text into the status div). | |
| @@ -330,41 +418,40 @@ pub(super) async fn export_sales( | |||
| 330 | 418 | AuthUser(user): AuthUser, | |
| 331 | 419 | ) -> Result<Response> { | |
| 332 | 420 | let is_htmx = is_htmx_request(&headers); | |
| 333 | - | ||
| 334 | - | // Get all sales with conditional buyer email | |
| 335 | - | let transactions = db::transactions::get_seller_transactions_for_export(&state.db, user.id).await?; | |
| 336 | - | ||
| 337 | - | let mut csv_content = String::from("Date,Item ID,Item Title,Amount,Status,Buyer Email\n"); | |
| 338 | - | for tx in &transactions { | |
| 339 | - | let item_title = tx.item_title.as_deref().unwrap_or("[Deleted]"); | |
| 340 | - | let item_id_str = tx.item_id | |
| 341 | - | .map(|id| id.to_string()) | |
| 342 | - | .unwrap_or_else(|| "[Deleted]".to_string()); | |
| 343 | - | let buyer_email = tx.buyer_email.as_deref().unwrap_or(""); | |
| 344 | - | ||
| 345 | - | csv_content.push_str(&format!( | |
| 346 | - | "{},{},{},{:.2},{},{}\n", | |
| 347 | - | tx.created_at.format("%Y-%m-%d %H:%M:%S"), | |
| 348 | - | item_id_str, | |
| 349 | - | sanitize_csv_cell(item_title), | |
| 350 | - | tx.amount_cents.as_f64() / 100.0, | |
| 351 | - | sanitize_csv_cell(&tx.status.to_string()), | |
| 352 | - | sanitize_csv_cell(buyer_email) | |
| 353 | - | )); | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | if is_htmx { | |
| 357 | - | let data_uri = format!( | |
| 358 | - | "data:text/csv;charset=utf-8,{}", | |
| 359 | - | urlencoding::encode(&csv_content) | |
| 360 | - | ); | |
| 361 | - | return Ok(ExportDownloadTemplate { | |
| 362 | - | data_uri, | |
| 363 | - | filename: "makenot-work-sales.csv".to_string(), | |
| 364 | - | }.into_response()); | |
| 365 | - | } | |
| 366 | - | ||
| 367 | - | download_response(csv_content.into_bytes(), "makenot-work-sales.csv", "text/csv") | |
| 421 | + | let pool = state.db.clone(); | |
| 422 | + | let uid = user.id; | |
| 423 | + | let rx = spawn_paginated_csv( | |
| 424 | + | "Date,Item ID,Item Title,Amount,Status,Buyer Email\n", | |
| 425 | + | move |limit, offset| { | |
| 426 | + | let pool = pool.clone(); | |
| 427 | + | async move { | |
| 428 | + | let rows = db::transactions::get_seller_transactions_for_export_page( | |
| 429 | + | &pool, uid, limit, offset, | |
| 430 | + | ) | |
| 431 | + | .await?; | |
| 432 | + | let mut buf = String::new(); | |
| 433 | + | for tx in &rows { | |
| 434 | + | let item_title = tx.item_title.as_deref().unwrap_or("[Deleted]"); | |
| 435 | + | let item_id_str = tx | |
| 436 | + | .item_id | |
| 437 | + | .map(|id| id.to_string()) | |
| 438 | + | .unwrap_or_else(|| "[Deleted]".to_string()); | |
| 439 | + | let buyer_email = tx.buyer_email.as_deref().unwrap_or(""); | |
| 440 | + | buf.push_str(&format!( | |
| 441 | + | "{},{},{},{:.2},{},{}\n", | |
| 442 | + | tx.created_at.format("%Y-%m-%d %H:%M:%S"), | |
| 443 | + | item_id_str, | |
| 444 | + | sanitize_csv_cell(item_title), | |
| 445 | + | tx.amount_cents.as_f64() / 100.0, | |
| 446 | + | sanitize_csv_cell(&tx.status.to_string()), | |
| 447 | + | sanitize_csv_cell(buyer_email) | |
| 448 | + | )); | |
| 449 | + | } | |
| 450 | + | Ok((buf, rows.len())) | |
| 451 | + | } | |
| 452 | + | }, | |
| 453 | + | ); | |
| 454 | + | finish_csv(is_htmx, "makenot-work-sales.csv", rx).await | |
| 368 | 455 | } | |
| 369 | 456 | ||
| 370 | 457 | /// Export revenue splits as a downloadable CSV file. | |
| @@ -375,35 +462,33 @@ pub(super) async fn export_splits( | |||
| 375 | 462 | AuthUser(user): AuthUser, | |
| 376 | 463 | ) -> Result<Response> { | |
| 377 | 464 | let is_htmx = is_htmx_request(&headers); | |
| 378 | - | ||
| 379 | - | let splits = db::project_members::get_splits_for_export(&state.db, user.id).await?; | |
| 380 | - | ||
| 381 | - | let mut csv_content = String::from("Date,Type,Direction,Recipient,Amount,Split %\n"); | |
| 382 | - | for split in &splits { | |
| 383 | - | let direction = if split.recipient_id == user.id { "incoming" } else { "outgoing" }; | |
| 384 | - | csv_content.push_str(&format!( | |
| 385 | - | "{},{},{},{},{:.2},{}\n", | |
| 386 | - | split.created_at.format("%Y-%m-%d %H:%M:%S"), | |
| 387 | - | sanitize_csv_cell(&split.source_type), | |
| 388 | - | direction, | |
| 389 | - | sanitize_csv_cell(&split.recipient_username), | |
| 390 | - | split.amount_cents.as_f64() / 100.0, | |
| 391 | - | split.split_percent, | |
| 392 | - | )); | |
| 393 | - | } | |
| 394 | - | ||
| 395 | - | if is_htmx { | |
| 396 | - | let data_uri = format!( | |
| 397 | - | "data:text/csv;charset=utf-8,{}", | |
| 398 | - | urlencoding::encode(&csv_content) | |
| 399 | - | ); | |
| 400 | - | return Ok(ExportDownloadTemplate { | |
| 401 | - | data_uri, | |
| 402 | - | filename: "makenot-work-splits.csv".to_string(), | |
| 403 | - | }.into_response()); | |
| 404 | - | } | |
| 405 | - | ||
| 406 | - | download_response(csv_content.into_bytes(), "makenot-work-splits.csv", "text/csv") | |
| 465 | + | let pool = state.db.clone(); | |
| 466 | + | let uid = user.id; | |
| 467 | + | let rx = spawn_paginated_csv( | |
| 468 | + | "Date,Type,Direction,Recipient,Amount,Split %\n", | |
| 469 | + | move |limit, offset| { | |
| 470 | + | let pool = pool.clone(); | |
| 471 | + | async move { | |
| 472 | + | let splits = | |
| 473 | + | db::project_members::get_splits_for_export_page(&pool, uid, limit, offset).await?; | |
| 474 | + | let mut buf = String::new(); | |
| 475 | + | for split in &splits { | |
| 476 | + | let direction = if split.recipient_id == uid { "incoming" } else { "outgoing" }; | |
| 477 | + | buf.push_str(&format!( | |
| 478 | + | "{},{},{},{},{:.2},{}\n", | |
| 479 | + | split.created_at.format("%Y-%m-%d %H:%M:%S"), | |
| 480 | + | sanitize_csv_cell(&split.source_type), | |
| 481 | + | direction, | |
| 482 | + | sanitize_csv_cell(&split.recipient_username), | |
| 483 | + | split.amount_cents.as_f64() / 100.0, | |
| 484 | + | split.split_percent, | |
| 485 | + | )); | |
| 486 | + | } | |
| 487 | + | Ok((buf, splits.len())) | |
| 488 | + | } | |
| 489 | + | }, | |
| 490 | + | ); | |
| 491 | + | finish_csv(is_htmx, "makenot-work-splits.csv", rx).await | |
| 407 | 492 | } | |
| 408 | 493 | ||
| 409 | 494 | /// Export all purchase transactions as a downloadable CSV file. | |
| @@ -414,57 +499,56 @@ pub(super) async fn export_purchases( | |||
| 414 | 499 | AuthUser(user): AuthUser, | |
| 415 | 500 | ) -> Result<Response> { | |
| 416 | 501 | let is_htmx = is_htmx_request(&headers); | |
| 417 | - | ||
| 418 | - | // Get all purchases (transactions where user is the buyer) | |
| 419 | - | let transactions = db::transactions::get_transactions_by_buyer(&state.db, user.id, None).await?; | |
| 420 | - | ||
| 421 | - | // Batch-fetch titles for transactions missing the denormalized item_title | |
| 422 | - | let missing_title_ids: Vec<db::ItemId> = transactions.iter() | |
| 423 | - | .filter(|tx| tx.item_title.is_none()) | |
| 424 | - | .filter_map(|tx| tx.item_id) | |
| 425 | - | .collect(); | |
| 426 | - | let title_lookup: std::collections::HashMap<db::ItemId, String> = | |
| 427 | - | db::items::get_item_titles_batch(&state.db, &missing_title_ids) | |
| 428 | - | .await? | |
| 429 | - | .into_iter() | |
| 430 | - | .collect(); | |
| 431 | - | ||
| 432 | - | let mut csv_content = String::from("Date,Item ID,Item Title,Amount,Status\n"); | |
| 433 | - | for tx in &transactions { | |
| 434 | - | let item_title = if let Some(title) = &tx.item_title { | |
| 435 | - | title.clone() | |
| 436 | - | } else if let Some(item_id) = tx.item_id { | |
| 437 | - | title_lookup.get(&item_id).cloned().unwrap_or_else(|| "[Deleted]".to_string()) | |
| 438 | - | } else { | |
| 439 | - | "[Deleted]".to_string() | |
| 440 | - | }; | |
| 441 | - | ||
| 442 | - | let item_id_str = tx.item_id | |
| 443 | - | .map(|id| id.to_string()) | |
| 444 | - | .unwrap_or_else(|| "[Deleted]".to_string()); | |
| 445 | - | ||
| 446 | - | csv_content.push_str(&format!( | |
| 447 | - | "{},{},{},{:.2},{}\n", | |
| 448 | - | tx.created_at.format("%Y-%m-%d %H:%M:%S"), | |
| 449 | - | item_id_str, | |
| 450 | - | sanitize_csv_cell(&item_title), | |
| 451 | - | tx.amount_cents.as_f64() / 100.0, | |
| 452 | - | sanitize_csv_cell(&tx.status.to_string()) | |
| 453 | - | )); | |
| 454 | - | } | |
| 455 | - | ||
| 456 | - | if is_htmx { | |
| 457 | - | let data_uri = format!( | |
| 458 | - | "data:text/csv;charset=utf-8,{}", | |
| 459 | - | urlencoding::encode(&csv_content) | |
| 460 | - | ); | |
| 461 | - | return Ok(ExportDownloadTemplate { | |
| 462 | - | data_uri, | |
| 463 | - | filename: "makenot-work-purchases.csv".to_string(), | |
| 464 | - | }.into_response()); | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | download_response(csv_content.into_bytes(), "makenot-work-purchases.csv", "text/csv") | |
| 502 | + | let pool = state.db.clone(); | |
| 503 | + | let uid = user.id; | |
| 504 | + | let rx = spawn_paginated_csv( | |
| 505 | + | "Date,Item ID,Item Title,Amount,Status\n", | |
| 506 | + | move |limit, offset| { | |
| 507 | + | let pool = pool.clone(); | |
| 508 | + | async move { | |
| 509 | + | let transactions = | |
| 510 | + | db::transactions::get_buyer_transactions_for_export_page(&pool, uid, limit, offset) | |
| 511 | + | .await?; | |
| 512 | + | // Batch-fetch titles only for this page's transactions missing | |
| 513 | + | // the denormalized item_title. | |
| 514 | + | let missing_title_ids: Vec<db::ItemId> = transactions | |
| 515 | + | .iter() | |
| 516 | + | .filter(|tx| tx.item_title.is_none()) | |
| 517 | + | .filter_map(|tx| tx.item_id) | |
| 518 | + | .collect(); | |
| 519 | + | let title_lookup: std::collections::HashMap<db::ItemId, String> = | |
| 520 | + | db::items::get_item_titles_batch(&pool, &missing_title_ids) | |
| 521 | + | .await? | |
| 522 | + | .into_iter() | |
| 523 | + | .collect(); | |
| 524 | + | ||
| 525 | + | let mut buf = String::new(); | |
| 526 | + | for tx in &transactions { | |
| 527 | + | let item_title = if let Some(title) = &tx.item_title { | |
| 528 | + | title.clone() | |
| 529 | + | } else if let Some(item_id) = tx.item_id { | |
| 530 | + | title_lookup.get(&item_id).cloned().unwrap_or_else(|| "[Deleted]".to_string()) | |
| 531 | + | } else { | |
| 532 | + | "[Deleted]".to_string() | |
| 533 | + | }; | |
| 534 | + | let item_id_str = tx | |
| 535 | + | .item_id | |
| 536 | + | .map(|id| id.to_string()) | |
| 537 | + | .unwrap_or_else(|| "[Deleted]".to_string()); | |
| 538 | + | buf.push_str(&format!( | |
| 539 | + | "{},{},{},{:.2},{}\n", | |
| 540 | + | tx.created_at.format("%Y-%m-%d %H:%M:%S"), | |
| 541 | + | item_id_str, | |
| 542 | + | sanitize_csv_cell(&item_title), | |
| 543 | + | tx.amount_cents.as_f64() / 100.0, | |
| 544 | + | sanitize_csv_cell(&tx.status.to_string()) | |
| 545 | + | )); | |
| 546 | + | } | |
| 547 | + | Ok((buf, transactions.len())) | |
| 548 | + | } | |
| 549 | + | }, | |
| 550 | + | ); | |
| 551 | + | finish_csv(is_htmx, "makenot-work-purchases.csv", rx).await | |
| 468 | 552 | } | |
| 469 | 553 | ||
| 470 | 554 | /// Export followers and subscribers as a downloadable CSV file. | |
| @@ -475,48 +559,76 @@ pub(super) async fn export_followers( | |||
| 475 | 559 | AuthUser(user): AuthUser, | |
| 476 | 560 | ) -> Result<Response> { | |
| 477 | 561 | let is_htmx = is_htmx_request(&headers); | |
| 562 | + | let pool = state.db.clone(); | |
| 563 | + | let uid = user.id; | |
| 564 | + | ||
| 565 | + | // Two-section CSV (followers, then subscribers); each section pages | |
| 566 | + | // independently so the whole thing streams in bounded batches (Run 4 S1). | |
| 567 | + | let (tx, rx) = mpsc::channel::<Bytes>(4); | |
| 568 | + | tokio::spawn(async move { | |
| 569 | + | if tx | |
| 570 | + | .send(Bytes::from_static(b"Section,Username,Display Name,Email,Type,Status,Since\n")) | |
| 571 | + | .await | |
| 572 | + | .is_err() | |
| 573 | + | { | |
| 574 | + | return; | |
| 575 | + | } | |
| 478 | 576 | ||
| 479 | - | let followers = db::follows::get_followers_for_export(&state.db, user.id).await?; | |
| 480 | - | let subscribers = db::subscriptions::get_project_subscribers_for_export(&state.db, user.id).await?; | |
| 481 | - | ||
| 482 | - | let mut csv_content = String::from("Section,Username,Display Name,Email,Type,Status,Since\n"); | |
| 483 | - | ||
| 484 | - | for f in &followers { | |
| 485 | - | csv_content.push_str(&format!( | |
| 486 | - | "Follower,{},{},{},{},{},{}\n", | |
| 487 | - | sanitize_csv_cell(&f.username), | |
| 488 | - | sanitize_csv_cell(f.display_name.as_deref().unwrap_or("")), | |
| 489 | - | sanitize_csv_cell(f.email.as_deref().unwrap_or("")), | |
| 490 | - | f.target_type, | |
| 491 | - | "", | |
| 492 | - | f.created_at.format("%Y-%m-%d %H:%M:%S"), | |
| 493 | - | )); | |
| 494 | - | } | |
| 495 | - | ||
| 496 | - | for s in &subscribers { | |
| 497 | - | csv_content.push_str(&format!( | |
| 498 | - | "Subscriber,{},{},{},{},{},{}\n", | |
| 499 | - | sanitize_csv_cell(&s.username), | |
| 500 | - | sanitize_csv_cell(s.display_name.as_deref().unwrap_or("")), | |
| 501 | - | "", | |
| 502 | - | sanitize_csv_cell(&s.tier_name), | |
| 503 | - | s.status, | |
| 504 | - | s.created_at.format("%Y-%m-%d %H:%M:%S"), | |
| 505 | - | )); | |
| 506 | - | } | |
| 507 | - | ||
| 508 | - | if is_htmx { | |
| 509 | - | let data_uri = format!( | |
| 510 | - | "data:text/csv;charset=utf-8,{}", | |
| 511 | - | urlencoding::encode(&csv_content) | |
| 512 | - | ); | |
| 513 | - | return Ok(ExportDownloadTemplate { | |
| 514 | - | data_uri, | |
| 515 | - | filename: "makenot-work-followers.csv".to_string(), | |
| 516 | - | }.into_response()); | |
| 517 | - | } | |
| 577 | + | let mut offset = 0i64; | |
| 578 | + | let mut total = 0usize; | |
| 579 | + | loop { | |
| 580 | + | let rows = match db::follows::get_followers_for_export_page(&pool, uid, EXPORT_BATCH, offset).await { | |
| 581 | + | Ok(r) => r, | |
| 582 | + | Err(e) => { tracing::error!(error = ?e, "followers export page failed"); break; } | |
| 583 | + | }; | |
| 584 | + | if !rows.is_empty() { | |
| 585 | + | let mut buf = String::new(); | |
| 586 | + | for f in &rows { | |
| 587 | + | buf.push_str(&format!( | |
| 588 | + | "Follower,{},{},{},{},{},{}\n", | |
| 589 | + | sanitize_csv_cell(&f.username), | |
| 590 | + | sanitize_csv_cell(f.display_name.as_deref().unwrap_or("")), | |
| 591 | + | sanitize_csv_cell(f.email.as_deref().unwrap_or("")), | |
| 592 | + | f.target_type, | |
| 593 | + | "", | |
| 594 | + | f.created_at.format("%Y-%m-%d %H:%M:%S"), | |
| 595 | + | )); | |
| 596 | + | } | |
| 597 | + | if tx.send(Bytes::from(buf)).await.is_err() { return; } | |
| 598 | + | } | |
| 599 | + | offset += rows.len() as i64; | |
| 600 | + | total += rows.len(); | |
| 601 | + | if (rows.len() as i64) < EXPORT_BATCH || total >= EXPORT_MAX_ROWS { break; } | |
| 602 | + | } | |
| 518 | 603 | ||
| 519 | - | download_response(csv_content.into_bytes(), "makenot-work-followers.csv", "text/csv") | |
| 604 | + | let mut offset = 0i64; | |
| 605 | + | let mut total = 0usize; | |
| 606 | + | loop { | |
| 607 | + | let rows = match db::subscriptions::get_project_subscribers_for_export_page(&pool, uid, EXPORT_BATCH, offset).await { | |
| 608 | + | Ok(r) => r, | |
| 609 | + | Err(e) => { tracing::error!(error = ?e, "subscribers export page failed"); break; } | |
| 610 | + | }; | |
| 611 | + | if !rows.is_empty() { | |
| 612 | + | let mut buf = String::new(); | |
| 613 | + | for s in &rows { | |
| 614 | + | buf.push_str(&format!( | |
| 615 | + | "Subscriber,{},{},{},{},{},{}\n", | |
| 616 | + | sanitize_csv_cell(&s.username), | |
| 617 | + | sanitize_csv_cell(s.display_name.as_deref().unwrap_or("")), | |
| 618 | + | "", | |
| 619 | + | sanitize_csv_cell(&s.tier_name), | |
| 620 | + | s.status, | |
| 621 | + | s.created_at.format("%Y-%m-%d %H:%M:%S"), | |
| 622 | + | )); | |
| 623 | + | } | |
| 624 | + | if tx.send(Bytes::from(buf)).await.is_err() { return; } | |
| 625 | + | } | |
| 626 | + | offset += rows.len() as i64; | |
| 627 | + | total += rows.len(); | |
| 628 | + | if (rows.len() as i64) < EXPORT_BATCH || total >= EXPORT_MAX_ROWS { break; } | |
| 629 | + | } | |
| 630 | + | }); | |
| 631 | + | finish_csv(is_htmx, "makenot-work-followers.csv", rx).await | |
| 520 | 632 | } | |
| 521 | 633 | ||
| 522 | 634 | /// Export subscriptions as a downloadable CSV file with full detail. | |
| @@ -527,47 +639,40 @@ pub(super) async fn export_subscriptions( | |||
| 527 | 639 | AuthUser(user): AuthUser, | |
| 528 | 640 | ) -> Result<Response> { | |
| 529 | 641 | let is_htmx = is_htmx_request(&headers); | |
| 530 | - | ||
| 531 | - | let subscriptions = | |
| 532 | - | db::subscriptions::get_subscriptions_for_export(&state.db, user.id).await?; | |
| 533 | - | ||
| 534 | - | let mut csv_content = String::from( | |
| 642 | + | let pool = state.db.clone(); | |
| 643 | + | let uid = user.id; | |
| 644 | + | let rx = spawn_paginated_csv( | |
| 535 | 645 | "Project,Tier,Price,Username,Status,Period Start,Period End,Canceled At,Created At\n", | |
| 646 | + | move |limit, offset| { | |
| 647 | + | let pool = pool.clone(); | |
| 648 | + | async move { | |
| 649 | + | let subscriptions = db::subscriptions::get_subscriptions_for_export_page( | |
| 650 | + | &pool, uid, limit, offset, | |
| 651 | + | ) | |
| 652 | + | .await?; | |
| 653 | + | let fmt_opt = |dt: Option<chrono::DateTime<chrono::Utc>>| -> String { | |
| 654 | + | dt.map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string()).unwrap_or_default() | |
| 655 | + | }; | |
| 656 | + | let mut buf = String::new(); | |
| 657 | + | for s in &subscriptions { | |
| 658 | + | buf.push_str(&format!( | |
| 659 | + | "{},{},{},{},{},{},{},{},{}\n", | |
| 660 | + | sanitize_csv_cell(&s.project_title), | |
| 661 | + | sanitize_csv_cell(&s.tier_name), | |
| 662 | + | crate::formatting::format_dollars_plain(s.price_cents), | |
| 663 | + | sanitize_csv_cell(&s.username), | |
| 664 | + | sanitize_csv_cell(&s.status.to_string()), | |
| 665 | + | fmt_opt(s.current_period_start), | |
| 666 | + | fmt_opt(s.current_period_end), | |
| 667 | + | fmt_opt(s.canceled_at), | |
| 668 | + | s.created_at.format("%Y-%m-%d %H:%M:%S"), | |
| 669 | + | )); |
Lines truncated
| @@ -575,10 +575,10 @@ pub(super) async fn update_repo_visibility( | |||
| 575 | 575 | ) -> Result<impl IntoResponse> { | |
| 576 | 576 | user.check_not_suspended()?; | |
| 577 | 577 | ||
| 578 | - | let repo = db::git_repos::get_repos_by_user(&state.db, user.id) | |
| 578 | + | // Indexed lookup by primary key, not fetch-all-then-find over the user's | |
| 579 | + | // whole repo set (ultra-fuzz Run 4 Perf). Ownership is still enforced below. | |
| 580 | + | let repo = db::git_repos::get_repo_by_id(&state.db, repo_id) | |
| 579 | 581 | .await? | |
| 580 | - | .into_iter() | |
| 581 | - | .find(|r| r.id == repo_id) | |
| 582 | 582 | .ok_or(AppError::NotFound)?; | |
| 583 | 583 | ||
| 584 | 584 | if repo.user_id != user.id { |
| @@ -237,34 +237,35 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 237 | 237 | } | |
| 238 | 238 | } | |
| 239 | 239 | ||
| 240 | - | // Database health checks | |
| 241 | - | let db_test_users = run_test("Count users", || async { | |
| 242 | - | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users") | |
| 243 | - | .fetch_one(&state.db) | |
| 244 | - | .await | |
| 245 | - | .is_ok() | |
| 246 | - | }).await; | |
| 247 | - | ||
| 248 | - | let db_test_projects = run_test("Count projects", || async { | |
| 249 | - | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM projects") | |
| 250 | - | .fetch_one(&state.db) | |
| 251 | - | .await | |
| 252 | - | .is_ok() | |
| 253 | - | }).await; | |
| 254 | - | ||
| 255 | - | let db_test_items = run_test("Count items", || async { | |
| 256 | - | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM items") | |
| 257 | - | .fetch_one(&state.db) | |
| 258 | - | .await | |
| 259 | - | .is_ok() | |
| 260 | - | }).await; | |
| 261 | - | ||
| 262 | - | let db_test_transactions = run_test("Count transactions", || async { | |
| 263 | - | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM transactions") | |
| 264 | - | .fetch_one(&state.db) | |
| 265 | - | .await | |
| 266 | - | .is_ok() | |
| 267 | - | }).await; | |
| 240 | + | // Database health probes. Run concurrently (each still records its own | |
| 241 | + | // latency) instead of serially, so the dashboard's worst-case wait is the | |
| 242 | + | // slowest single probe, not their sum (ultra-fuzz Run 4 Perf). | |
| 243 | + | let (db_test_users, db_test_projects, db_test_items, db_test_transactions) = tokio::join!( | |
| 244 | + | run_test("Count users", || async { | |
| 245 | + | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users") | |
| 246 | + | .fetch_one(&state.db) | |
| 247 | + | .await | |
| 248 | + | .is_ok() | |
| 249 | + | }), | |
| 250 | + | run_test("Count projects", || async { | |
| 251 | + | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM projects") | |
| 252 | + | .fetch_one(&state.db) | |
| 253 | + | .await | |
| 254 | + | .is_ok() | |
| 255 | + | }), | |
| 256 | + | run_test("Count items", || async { | |
| 257 | + | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM items") | |
| 258 | + | .fetch_one(&state.db) | |
| 259 | + | .await | |
| 260 | + | .is_ok() | |
| 261 | + | }), | |
| 262 | + | run_test("Count transactions", || async { | |
| 263 | + | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM transactions") | |
| 264 | + | .fetch_one(&state.db) | |
| 265 | + | .await | |
| 266 | + | .is_ok() | |
| 267 | + | }), | |
| 268 | + | ); | |
| 268 | 269 | ||
| 269 | 270 | // Get actual counts | |
| 270 | 271 | let stats = db::health::get_health_stats(&state.db).await.unwrap_or(db::health::DbHealthStats { |
| @@ -683,9 +683,9 @@ pub(super) async fn begin_rotation( | |||
| 683 | 683 | )); | |
| 684 | 684 | } | |
| 685 | 685 | ||
| 686 | - | // Verify device belongs to this user + app | |
| 687 | - | let devices = db::synckit::get_sync_devices(&state.db, sync_user.app_id, sync_user.user_id).await?; | |
| 688 | - | if !devices.iter().any(|d| d.id == req.device_id) { | |
| 686 | + | // Verify device belongs to this user + app via an indexed point lookup, not a | |
| 687 | + | // fetch-all-then-scan (ultra-fuzz Run 4 Perf). | |
| 688 | + | if !db::synckit::sync_device_belongs(&state.db, req.device_id, sync_user.app_id, sync_user.user_id).await? { | |
| 689 | 689 | return Err(AppError::BadRequest("Unknown device".to_string())); | |
| 690 | 690 | } | |
| 691 | 691 |
| @@ -457,7 +457,16 @@ impl ScanPipeline { | |||
| 457 | 457 | let urlhaus_key = self.abuse_ch_auth_key.clone(); | |
| 458 | 458 | let urlhaus_fut = async move { | |
| 459 | 459 | if urlhaus_enabled { | |
| 460 | - | urlhaus::check_urlhaus(&urlhaus_map, urlhaus_key.as_deref()).await | |
| 460 | + | // Host extraction walks the memory-mapped buffer and can | |
| 461 | + | // page-fault; run it on the blocking pool so it doesn't stall a | |
| 462 | + | // tokio worker, then do only the network lookups async | |
| 463 | + | // (ultra-fuzz Run 4 Perf S2). | |
| 464 | + | let hosts = tokio::task::spawn_blocking(move || { | |
| 465 | + | urlhaus::extract_unique_hosts(&urlhaus_map[..], urlhaus::MAX_HOSTS_PER_FILE) | |
| 466 | + | }) | |
| 467 | + | .await | |
| 468 | + | .unwrap_or_default(); | |
| 469 | + | urlhaus::check_urlhaus_hosts(hosts, urlhaus_key.as_deref()).await | |
| 461 | 470 | } else { | |
| 462 | 471 | LayerResult { | |
| 463 | 472 | layer: "urlhaus", |
| @@ -9,7 +9,10 @@ | |||
| 9 | 9 | //! Nothing in the scan pipeline calls this module yet; it is wired up in | |
| 10 | 10 | //! later chunks of the scanner-streaming refactor. | |
| 11 | 11 | ||
| 12 | - | use crate::constants::{SCAN_SPOOL_FREE_RESERVE_BYTES, SCAN_SPOOL_MAX_BYTES, SCAN_SPOOL_ORPHAN_AGE_SECS}; | |
| 12 | + | use crate::constants::{ | |
| 13 | + | SCAN_SPOOL_FREE_RESERVE_BYTES, SCAN_SPOOL_MAX_BYTES, SCAN_SPOOL_ORPHAN_AGE_SECS, | |
| 14 | + | SCAN_WORKER_COUNT, | |
| 15 | + | }; | |
| 13 | 16 | use s3_storage::ByteStream; | |
| 14 | 17 | use std::path::{Path, PathBuf}; | |
| 15 | 18 | use tokio::fs::{File, OpenOptions}; | |
| @@ -74,10 +77,16 @@ pub fn check_free_space(spool_dir: &Path, expected_size: u64) -> Result<(), Stri | |||
| 74 | 77 | }; | |
| 75 | 78 | let free = fs2::available_space(probe) | |
| 76 | 79 | .map_err(|e| format!("statvfs {}: {e}", probe.display()))?; | |
| 77 | - | if free.saturating_sub(expected_size) < SCAN_SPOOL_FREE_RESERVE_BYTES { | |
| 80 | + | // The check and the write aren't atomic: with `SCAN_WORKER_COUNT` workers, up | |
| 81 | + | // to that many spool downloads can pass this check and then write at once. | |
| 82 | + | // Reserve headroom for ALL of them rather than just this one, so concurrent | |
| 83 | + | // workers can't collectively overrun the free-space floor (ultra-fuzz Run 4 | |
| 84 | + | // Perf TOCTOU). Conservative by at most `(workers-1) * expected`. | |
| 85 | + | let concurrent_reserve = expected_size.saturating_mul(SCAN_WORKER_COUNT as u64); | |
| 86 | + | if free.saturating_sub(concurrent_reserve) < SCAN_SPOOL_FREE_RESERVE_BYTES { | |
| 78 | 87 | return Err(format!( | |
| 79 | - | "scan spool volume short on space: free={} expected={} reserve={}", | |
| 80 | - | free, expected_size, SCAN_SPOOL_FREE_RESERVE_BYTES | |
| 88 | + | "scan spool volume short on space: free={} expected={} workers={} reserve={}", | |
| 89 | + | free, expected_size, SCAN_WORKER_COUNT, SCAN_SPOOL_FREE_RESERVE_BYTES | |
| 81 | 90 | )); | |
| 82 | 91 | } | |
| 83 | 92 | Ok(()) |
| @@ -23,7 +23,7 @@ pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen; | |||
| 23 | 23 | ||
| 24 | 24 | const URLHAUS_API_URL: &str = "https://urlhaus-api.abuse.ch/v1/host/"; | |
| 25 | 25 | /// Cap per-upload host lookups to keep within free-tier quotas and bound work. | |
| 26 | - | const MAX_HOSTS_PER_FILE: usize = 5; | |
| 26 | + | pub(crate) const MAX_HOSTS_PER_FILE: usize = 5; | |
| 27 | 27 | /// Cap the byte window we scan for URLs; URLs in the wild fit easily here. | |
| 28 | 28 | const MAX_SCAN_BYTES: usize = 4 * 1024 * 1024; | |
| 29 | 29 | /// Minimum printable-ASCII run length to consider as a potential string. | |
| @@ -31,6 +31,25 @@ const MIN_RUN_LEN: usize = 6; | |||
| 31 | 31 | ||
| 32 | 32 | /// Check the file for URLs that appear in URLhaus's known-bad index. | |
| 33 | 33 | pub async fn check_urlhaus(data: &[u8], auth_key: Option<&str>) -> LayerResult { | |
| 34 | + | if auth_key.is_none() { | |
| 35 | + | return LayerResult { | |
| 36 | + | layer: "urlhaus", | |
| 37 | + | verdict: LayerVerdict::Skip, | |
| 38 | + | detail: Some("No abuse.ch Auth-Key configured".to_string()), | |
| 39 | + | }; | |
| 40 | + | } | |
| 41 | + | // In-memory path (small uploads, tests): extraction is cheap on a heap | |
| 42 | + | // buffer. The large-file mmap path extracts inside `spawn_blocking` and calls | |
| 43 | + | // `check_urlhaus_hosts` directly (ultra-fuzz Run 4 Perf S2). | |
| 44 | + | let hosts = extract_unique_hosts(data, MAX_HOSTS_PER_FILE); | |
| 45 | + | check_urlhaus_hosts(hosts, auth_key).await | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// URLhaus lookup over already-extracted hosts. Split from [`check_urlhaus`] so | |
| 49 | + | /// the mmap scan path can run the page-fault-prone host extraction off the async | |
| 50 | + | /// runtime (in `spawn_blocking`) and pass the result here, leaving only the | |
| 51 | + | /// network lookups on the runtime. | |
| 52 | + | pub(crate) async fn check_urlhaus_hosts(hosts: Vec<String>, auth_key: Option<&str>) -> LayerResult { | |
| 34 | 53 | let Some(key) = auth_key else { | |
| 35 | 54 | return LayerResult { | |
| 36 | 55 | layer: "urlhaus", | |
| @@ -39,7 +58,6 @@ pub async fn check_urlhaus(data: &[u8], auth_key: Option<&str>) -> LayerResult { | |||
| 39 | 58 | }; | |
| 40 | 59 | }; | |
| 41 | 60 | ||
| 42 | - | let hosts = extract_unique_hosts(data, MAX_HOSTS_PER_FILE); | |
| 43 | 61 | if hosts.is_empty() { | |
| 44 | 62 | return LayerResult { | |
| 45 | 63 | layer: "urlhaus", | |
| @@ -160,7 +178,7 @@ fn classify_urlhaus_response(body: &serde_json::Value) -> HostVerdict { | |||
| 160 | 178 | ||
| 161 | 179 | /// Pull printable-ASCII URL hosts out of the byte buffer. Cap at `max` unique | |
| 162 | 180 | /// hosts to bound per-upload work and free-tier quota use. | |
| 163 | - | fn extract_unique_hosts(data: &[u8], max: usize) -> Vec<String> { | |
| 181 | + | pub(crate) fn extract_unique_hosts(data: &[u8], max: usize) -> Vec<String> { | |
| 164 | 182 | let scan = if data.len() > MAX_SCAN_BYTES { &data[..MAX_SCAN_BYTES] } else { data }; | |
| 165 | 183 | ||
| 166 | 184 | let mut hosts: HashSet<String> = HashSet::new(); |
| @@ -309,6 +309,13 @@ pub(super) async fn scrub_stale_ip_addresses(state: &AppState) { | |||
| 309 | 309 | /// Permanently delete items that were soft-deleted more than 7 days ago. | |
| 310 | 310 | /// Cleans up S3 objects (item files + version files) and decrements storage | |
| 311 | 311 | /// before DB deletion to prevent orphaned storage and accounting drift. | |
| 312 | + | /// | |
| 313 | + | /// Accepted residual (ultra-fuzz Run 4 Perf, decision 2026-06-23): this gathers | |
| 314 | + | /// all expired-item S3 keys into one Vec before the batch delete. It is a daily | |
| 315 | + | /// cron off the request path; steady-state (7-day window) is small, so the only | |
| 316 | + | /// large allocation is transient, after a rare mass-delete event. Bounding it | |
| 317 | + | /// per-tick means coupling a LIMIT on the key-gather to the same slice as the | |
| 318 | + | /// CASCADE delete; deferred as low-value for a cron path. Revisit if it matters. | |
| 312 | 319 | pub(super) async fn purge_expired_deleted_items(state: &AppState) { | |
| 313 | 320 | // Collect S3 keys from items AND their versions before CASCADE delete destroys the data | |
| 314 | 321 | let mut all_s3_keys: Vec<(String, String)> = Vec::new(); |
| @@ -96,6 +96,14 @@ pub fn spawn_scheduler( | |||
| 96 | 96 | // Pin advisory lock to a dedicated connection held for the entire tick. | |
| 97 | 97 | // pg_try_advisory_lock is session-scoped — holding the connection prevents | |
| 98 | 98 | // another instance from acquiring the lock until this tick completes. | |
| 99 | + | // | |
| 100 | + | // Accepted residual (ultra-fuzz Run 4 Perf, decision 2026-06-23): this | |
| 101 | + | // borrows 1 of the request pool's connections for the tick's duration | |
| 102 | + | // (effective serving capacity 24/25 during a tick). The architecturally | |
| 103 | + | // cleaner shape is a dedicated 1-connection side-pool for the lock, but | |
| 104 | + | // that change touches the deploy-critical scheduler's connection | |
| 105 | + | // lifecycle for a small steady-state gain; revisit if pool-pressure | |
| 106 | + | // metrics show it matters. | |
| 99 | 107 | let mut lock_conn = match state.db.acquire().await { | |
| 100 | 108 | Ok(conn) => conn, | |
| 101 | 109 | Err(e) => { |