From 152c21f4a5e2fccdf0cd363f3bb3cdc6f3ae95f5 Mon Sep 17 00:00:00 2001 From: jckbtchr <77942319+jckbtchr-bot@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:29:43 -0500 Subject: [PATCH] Paginate set contributions instead of embedding them GET /v1/set-submissions/:uuid returned 4.8MB for a busy set. 98% of that was participationImages: 3,213 rows, each preloading its image, its creator and the creator's pfp. Without them the same response is 99KB. The client already virtualises the contributions grid, so this was never a render cost. It was transfer, paid by every visitor before the page could paint, and again on the frontend's refresh interval. participationImages is now only embedded when asked for (?includes[]=participationImages), so existing consumers can opt back in unchanged. New alongside it: GET /v1/set-submissions/:uuid/participation page, limit, filter, sort. filter[creatorAddress] pages a single artist's contributions, which is what grouping the grid by artist needs. GET /v1/set-submissions/:uuid/participation-stats contribution and contributor counts, so a client can size the list without fetching it. Co-Authored-By: Claude Opus 5 (1M context) --- .../Http/ParticipationImagesController.ts | 41 +++++++++++++++++++ .../Http/SetSubmissionsController.ts | 25 +++++++---- start/routes.ts | 3 ++ 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/app/Controllers/Http/ParticipationImagesController.ts b/app/Controllers/Http/ParticipationImagesController.ts index b65b2c8..fbfc740 100644 --- a/app/Controllers/Http/ParticipationImagesController.ts +++ b/app/Controllers/Http/ParticipationImagesController.ts @@ -14,6 +14,47 @@ import InvalidInput from 'App/Exceptions/InvalidInput' import { selectedImageIds } from 'App/Helpers/coCreatorAttribution' export default class ParticipationImagesController extends BaseController { + /** + * Contributions for a set, paginated. + * + * Previously these were embedded in the set submission response in full, + * which meant every visitor downloaded every contribution before the page + * could paint. Filter by `filter[creatorAddress]` to page through a single + * artist's work. + */ + public async list({ params, request }: HttpContextContract) { + const { page = 1, limit = 24, filter = {}, sort = '-createdAt' } = request.qs() + + const submission = await SetSubmission.query().where('uuid', params.id).firstOrFail() + + const query = ParticipationImage.query() + .where('setSubmissionId', submission.id) + .whereNull('deletedAt') + .preload('image') + .preload('creator', (creatorQuery) => creatorQuery.preload('pfp')) + + await this.applyFilters(query, filter) + await this.applySorts(query, sort) + + return query.paginate(page, limit) + } + + /** Contribution and contributor counts, so a client can size a list without fetching it. */ + public async stats({ params }: HttpContextContract) { + const submission = await SetSubmission.query().where('uuid', params.id).firstOrFail() + + const [contributions] = await Database.from('participation_images') + .where('set_submission_id', submission.id) + .whereNull('deleted_at') + .count('* as total') + .countDistinct('creator_address as contributors') + + return { + contributions: Number(contributions.total), + contributors: Number(contributions.contributors), + } + } + public async store({ request, session }: HttpContextContract) { const address = session.get('siwe')?.address?.toLowerCase() if (!address) throw new NotAuthenticated() diff --git a/app/Controllers/Http/SetSubmissionsController.ts b/app/Controllers/Http/SetSubmissionsController.ts index 87db846..2846fbf 100644 --- a/app/Controllers/Http/SetSubmissionsController.ts +++ b/app/Controllers/Http/SetSubmissionsController.ts @@ -155,7 +155,10 @@ export default class SetSubmissionsController extends BaseController { return submission } - public async show({ params }: HttpContextContract) { + public async show({ params, request }: HttpContextContract) { + const { includes = [] } = request.qs() + const requestedIncludes = Array.isArray(includes) ? includes : [includes] + const submission = await SetSubmission.query() .where('uuid', params.id) .preload('set') @@ -177,12 +180,20 @@ export default class SetSubmissionsController extends BaseController { query.preload('cover') query.orderBy('sortIndex') }) - .preload('participationImages', (query) => { - query.whereNull('deletedAt') - query.preload('image') - query.preload('creator', (creatorQuery) => creatorQuery.preload('pfp')) - query.orderBy('createdAt', 'desc') - }) + /* + * Only embedded when asked for. A busy set carries thousands of + * contributions, each with its image and creator, which made this + * response several megabytes and dwarfed everything else on it. + * Use the paginated `/:id/participation` route instead. + */ + .if(requestedIncludes.includes('participationImages'), (query) => + query.preload('participationImages', (participationQuery) => { + participationQuery.whereNull('deletedAt') + participationQuery.preload('image') + participationQuery.preload('creator', (creatorQuery) => creatorQuery.preload('pfp')) + participationQuery.orderBy('createdAt', 'desc') + }), + ) .firstOrFail() // TODO: Implement rich content links // .preload('richContentLinks', query => { diff --git a/start/routes.ts b/start/routes.ts index cd0248f..38cab95 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -117,6 +117,9 @@ Route.group(() => { Route.post('/:id/subscribe', 'SetSubscriptionsController.subscribe') }).middleware(['auth']) + Route.get('/:id/participation', 'ParticipationImagesController.list') + Route.get('/:id/participation-stats', 'ParticipationImagesController.stats') + Route.get('/:id/subscribers', 'SetSubscriptionsController.listSubscribers') Route.get('/:id/history', 'SetSubscriptionsController.history') Route.get('/:id/nodes-stats', 'SetSubscriptionsController.nodesStats')