From c6cbff853dc17f34feb885735a598e992bba39ca Mon Sep 17 00:00:00 2001 From: presidojay1 Date: Mon, 22 Jun 2026 15:06:32 +0100 Subject: [PATCH 1/2] feat: add GET /creators/:id/holders endpoint (closes #422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a paginated key holder list endpoint so creators can see which wallets hold their keys and how many. ## Endpoint GET /api/v1/creators/:id/holders Query params: - limit (default 20, max 100) - offset (default 0) - sort "key_balance" (default) | "held_since" Response body: { "success": true, "data": { "items": [{ "wallet_address", "key_balance", "held_since" }], "meta": { "limit", "offset", "total", "hasMore" } } } ## Behaviour - Default sort is key_balance desc (largest holders first) - sort=held_since returns earliest buyers first (held_since asc) - held_since is derived from KeyOwnership.createdAt — the timestamp when the ownership row was first created (wallet's first buy) - Returns 404 when the creator id/handle does not exist - Returns empty items [] (not 404) when creator exists but has no holders - Accepts creator cuid id OR handle as the :id param ## Files added / changed - src/modules/creators/creator-holders.schemas.ts — Zod query schema - src/modules/creators/creator-holders.service.ts — DB queries - src/modules/creators/creator-holders.controller.ts — HTTP handler - src/modules/creators/creator-holders.integration.test.ts — Jest tests - src/modules/creators/creators.routes.ts — register route - src/constants/creator-public-routes.constants.ts — GET_HOLDERS constant - src/constants/creator-public-cache.constants.ts — 5-min cache preset Co-Authored-By: Claude Sonnet 4.6 --- .../creator-public-cache.constants.ts | 5 + .../creator-public-routes.constants.ts | 2 + .../creators/creator-holders.controller.ts | 57 ++++ .../creator-holders.integration.test.ts | 268 ++++++++++++++++++ .../creators/creator-holders.schemas.ts | 38 +++ .../creators/creator-holders.service.ts | 80 ++++++ src/modules/creators/creators.routes.ts | 19 ++ 7 files changed, 469 insertions(+) create mode 100644 src/modules/creators/creator-holders.controller.ts create mode 100644 src/modules/creators/creator-holders.integration.test.ts create mode 100644 src/modules/creators/creator-holders.schemas.ts create mode 100644 src/modules/creators/creator-holders.service.ts diff --git a/src/constants/creator-public-cache.constants.ts b/src/constants/creator-public-cache.constants.ts index 537d0b5..a59f47d 100644 --- a/src/constants/creator-public-cache.constants.ts +++ b/src/constants/creator-public-cache.constants.ts @@ -34,6 +34,11 @@ export const CREATOR_PUBLIC_ROUTE_CACHE_PRESETS = { type: 'public' as const, staleIfError: 86400, }, + [CREATOR_PUBLIC_ROUTE_NAMES.GET_HOLDERS]: { + maxAge: publicReadSeconds, + type: 'public' as const, + staleIfError: 86400, + }, } as const; /** diff --git a/src/constants/creator-public-routes.constants.ts b/src/constants/creator-public-routes.constants.ts index b30b0cf..9a29b9f 100644 --- a/src/constants/creator-public-routes.constants.ts +++ b/src/constants/creator-public-routes.constants.ts @@ -14,4 +14,6 @@ export const CREATOR_PUBLIC_ROUTE_NAMES = { UPSERT_PROFILE: 'creators:profile:upsert', /** GET /api/v1/creators/:creatorId/stats - Public creator stats */ GET_STATS: 'creators:stats:get', + /** GET /api/v1/creators/:creatorId/holders - Paginated key holder list */ + GET_HOLDERS: 'creators:holders:get', } as const; diff --git a/src/modules/creators/creator-holders.controller.ts b/src/modules/creators/creator-holders.controller.ts new file mode 100644 index 0000000..63657c6 --- /dev/null +++ b/src/modules/creators/creator-holders.controller.ts @@ -0,0 +1,57 @@ +import { AsyncController } from '../../types/auth.types'; +import { CreatorHoldersQuerySchema } from './creator-holders.schemas'; +import { + findCreatorByIdOrHandle, + fetchCreatorHolders, +} from './creator-holders.service'; +import { + sendSuccess, + sendNotFound, + sendValidationError, +} from '../../utils/api-response.utils'; +import { attachTimestampHeader } from '../../utils/timestamp-headers.utils'; +import { parsePublicQuery } from '../../utils/public-query-parse.utils'; +import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; + +/** + * Controller for GET /api/v1/creators/:id/holders + * + * Returns a paginated list of wallets that hold keys for the given creator, + * along with each wallet's current balance and the timestamp of their first buy. + * + * - Returns 404 if the creator does not exist. + * - Returns an empty items array (not 404) if the creator exists but has no holders. + * - Default sort: largest key_balance first. + * - Optional ?sort=held_since returns earliest buyers first. + */ +export const httpGetCreatorHolders: AsyncController = async (req, res, next) => { + try { + const { id } = req.params; + + const parsed = parsePublicQuery(CreatorHoldersQuerySchema, req.query, { + debugContext: 'creator-holders-query', + }); + + if (!parsed.ok) { + return sendValidationError(res, 'Invalid query parameters', parsed.details); + } + + const creator = await findCreatorByIdOrHandle(id); + if (!creator) { + return sendNotFound(res, 'Creator'); + } + + const [holders, total] = await fetchCreatorHolders(creator.id, parsed.data); + + const meta = buildOffsetPaginationMeta({ + limit: parsed.data.limit, + offset: parsed.data.offset, + total, + }); + + attachTimestampHeader(res); + sendSuccess(res, { items: holders, meta }); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/creators/creator-holders.integration.test.ts b/src/modules/creators/creator-holders.integration.test.ts new file mode 100644 index 0000000..7a40fa9 --- /dev/null +++ b/src/modules/creators/creator-holders.integration.test.ts @@ -0,0 +1,268 @@ +// Integration test: GET /creators/:id/holders +// +// Exercises the full flow of the creator holders endpoint: +// 1. 404 when creator does not exist +// 2. Empty items list when creator exists but has no holders +// 3. Paginated holder list sorted by key_balance desc (default) +// 4. Holder list sorted by held_since asc (?sort=held_since) +// 5. Pagination meta (hasMore, total, limit, offset) +// +// Uses Jest mocks — no database required. + +import { httpGetCreatorHolders } from './creator-holders.controller'; +import * as holdersService from './creator-holders.service'; +import type { HolderRecord } from './creator-holders.service'; + +// ── Lightweight request/response mocks ──────────────────────────────────────── + +function makeReq( + params: Record = {}, + query: Record = {}, +): any { + return { params, query }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const CREATOR_STUB = { id: 'creator-cuid-1', handle: 'alice' }; + +function makeHolder( + index: number, + overrides: Partial = {}, +): HolderRecord { + return { + wallet_address: `GWALLETADDRESS${String(index).padStart(46, '0')}`, + key_balance: (4 - index) * 10, // 30, 20, 10 for indices 1, 2, 3 + held_since: new Date(`2024-0${index}-01T00:00:00.000Z`), + ...overrides, + }; +} + +const HOLDER_A = makeHolder(1); // balance: 30, earliest buyer +const HOLDER_B = makeHolder(2); // balance: 20 +const HOLDER_C = makeHolder(3); // balance: 10, latest buyer + +const ALL_HOLDERS = [HOLDER_A, HOLDER_B, HOLDER_C]; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /creators/:id/holders', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── Creator not found ────────────────────────────────────────────────────── + + it('returns 404 when the creator does not exist', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(null); + + const req = makeReq({ id: 'nonexistent-creator' }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(404); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + expect(body.error.code).toBe('NOT_FOUND'); + }); + + // ── Creator exists but no holders ───────────────────────────────────────── + + it('returns empty items list (not 404) when creator exists but has no holders', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([[], 0]); + + const req = makeReq({ id: CREATOR_STUB.handle }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toEqual([]); + expect(body.data.meta.total).toBe(0); + expect(body.data.meta.hasMore).toBe(false); + }); + + // ── Default sort: key_balance desc ──────────────────────────────────────── + + it('returns holders sorted by key_balance desc by default', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([ALL_HOLDERS, ALL_HOLDERS.length]); + + const req = makeReq({ id: CREATOR_STUB.id }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + + const body = res.json.mock.calls[0][0]; + const items = body.data.items as HolderRecord[]; + + expect(items).toHaveLength(3); + expect(items[0].wallet_address).toBe(HOLDER_A.wallet_address); + expect(items[0].key_balance).toBe(HOLDER_A.key_balance); // 30 + expect(items[2].key_balance).toBe(HOLDER_C.key_balance); // 10 + + // Verify fetchCreatorHolders was called with sort=key_balance + const [, query] = (holdersService.fetchCreatorHolders as jest.Mock).mock.calls[0]; + expect(query.sort).toBe('key_balance'); + }); + + // ── Sort by held_since ───────────────────────────────────────────────────── + + it('returns holders sorted by held_since asc when ?sort=held_since', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([ALL_HOLDERS, ALL_HOLDERS.length]); + + const req = makeReq({ id: CREATOR_STUB.id }, { sort: 'held_since' }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + + // Verify fetchCreatorHolders was called with sort=held_since + const [, query] = (holdersService.fetchCreatorHolders as jest.Mock).mock.calls[0]; + expect(query.sort).toBe('held_since'); + }); + + // ── Pagination ───────────────────────────────────────────────────────────── + + it('returns correct pagination meta for a partial page', async () => { + const TOTAL = 10; + const PAGE_ITEMS = ALL_HOLDERS; // 3 of 10 + + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([PAGE_ITEMS, TOTAL]); + + const req = makeReq({ id: CREATOR_STUB.id }, { limit: '3', offset: '0' }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + const { meta } = body.data; + + expect(meta.limit).toBe(3); + expect(meta.offset).toBe(0); + expect(meta.total).toBe(TOTAL); + expect(meta.hasMore).toBe(true); + }); + + it('returns hasMore=false on the last page', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([ALL_HOLDERS, ALL_HOLDERS.length]); + + const req = makeReq({ id: CREATOR_STUB.id }, { limit: '20', offset: '0' }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + const { meta } = res.json.mock.calls[0][0].data; + expect(meta.hasMore).toBe(false); + expect(meta.total).toBe(3); + }); + + // ── Response shape ───────────────────────────────────────────────────────── + + it('each holder item has wallet_address, key_balance, and held_since', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([[HOLDER_A], 1]); + + const req = makeReq({ id: CREATOR_STUB.id }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + const [item] = res.json.mock.calls[0][0].data.items as HolderRecord[]; + expect(item).toHaveProperty('wallet_address', HOLDER_A.wallet_address); + expect(item).toHaveProperty('key_balance', HOLDER_A.key_balance); + expect(item).toHaveProperty('held_since', HOLDER_A.held_since); + }); + + // ── Validation ───────────────────────────────────────────────────────────── + + it('returns 400 for an unknown query param (strict schema)', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + + const req = makeReq({ id: CREATOR_STUB.id }, { unknown_param: 'oops' }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); + + it('returns 400 for an invalid sort value', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + + const req = makeReq({ id: CREATOR_STUB.id }, { sort: 'invalid_sort' }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + // ── Error propagation ────────────────────────────────────────────────────── + + it('calls next(error) when the service throws', async () => { + const dbError = new Error('DB connection failed'); + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockRejectedValue(dbError); + + const req = makeReq({ id: CREATOR_STUB.id }); + const res = makeRes(); + const next = makeNext(); + await httpGetCreatorHolders(req, res, next); + + expect(next).toHaveBeenCalledWith(dbError); + expect(res.json).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/creators/creator-holders.schemas.ts b/src/modules/creators/creator-holders.schemas.ts new file mode 100644 index 0000000..72aaaa0 --- /dev/null +++ b/src/modules/creators/creator-holders.schemas.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; +import { safeIntParam } from '../../utils/query.utils'; +import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants'; +import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults'; + +/** + * Valid sort fields for the creator holders endpoint. + * - key_balance: sort by number of keys held (default, largest first) + * - held_since: sort by when the wallet first bought a key (earliest first) + */ +export const CREATOR_HOLDER_SORT_FIELDS = ['key_balance', 'held_since'] as const; +export type CreatorHolderSortField = (typeof CREATOR_HOLDER_SORT_FIELDS)[number]; + +/** + * Validation schema for GET /creators/:id/holders query parameters. + */ +export const CreatorHoldersQuerySchema = z + .object({ + limit: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, + min: MIN_PAGE_SIZE, + max: MAX_PAGE_SIZE, + label: 'Limit', + }), + offset: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, + min: 0, + max: Number.MAX_SAFE_INTEGER, + label: 'Offset', + }), + sort: z + .enum(CREATOR_HOLDER_SORT_FIELDS) + .optional() + .default('key_balance'), + }) + .strict(); + +export type CreatorHoldersQueryType = z.infer; diff --git a/src/modules/creators/creator-holders.service.ts b/src/modules/creators/creator-holders.service.ts new file mode 100644 index 0000000..181f6b0 --- /dev/null +++ b/src/modules/creators/creator-holders.service.ts @@ -0,0 +1,80 @@ +import { Prisma } from '@prisma/client'; +import { prisma } from '../../utils/prisma.utils'; +import { CreatorHoldersQueryType } from './creator-holders.schemas'; + +/** + * Public-facing holder record returned by the holders endpoint. + */ +export interface HolderRecord { + wallet_address: string; + key_balance: number; + held_since: Date; +} + +/** + * Look up a creator profile by cuid id or handle. + * Returns null if no creator matches either field. + */ +export async function findCreatorByIdOrHandle( + idOrHandle: string, +): Promise<{ id: string; handle: string } | null> { + return prisma.creatorProfile.findFirst({ + where: { + OR: [{ id: idOrHandle }, { handle: idOrHandle }], + }, + select: { id: true, handle: true }, + }); +} + +/** + * Fetch a paginated, sorted list of key holders for a creator. + * + * - Default sort: largest balance first (key_balance desc) + * - sort=held_since: earliest buyer first (createdAt asc) + * - Only returns records with balance > 0 (excludes wallets that sold all keys) + * - held_since is derived from KeyOwnership.createdAt, which is set when the + * ownership row is first created (i.e. the wallet's first buy for this creator) + * + * @param creatorId - The creator's cuid from CreatorProfile + * @param query - Validated query params (limit, offset, sort) + * @returns Tuple of [holder records, total count] + */ +export async function fetchCreatorHolders( + creatorId: string, + query: CreatorHoldersQueryType, +): Promise<[HolderRecord[], number]> { + const { limit, offset, sort } = query; + + const where: Prisma.KeyOwnershipWhereInput = { + creatorId, + balance: { gt: 0 }, + }; + + const orderBy: Prisma.KeyOwnershipOrderByWithRelationInput = + sort === 'held_since' + ? { createdAt: 'asc' } + : { balance: 'desc' }; + + const [rows, total] = await Promise.all([ + prisma.keyOwnership.findMany({ + where, + orderBy, + skip: offset, + take: limit, + select: { + ownerAddress: true, + balance: true, + createdAt: true, + }, + }), + prisma.keyOwnership.count({ where }), + ]); + + const holders: HolderRecord[] = rows.map((row) => ({ + wallet_address: row.ownerAddress, + key_balance: Number(row.balance), + held_since: row.createdAt, + })); + + return [holders, total]; +} diff --git a/src/modules/creators/creators.routes.ts b/src/modules/creators/creators.routes.ts index d1f752c..d121179 100644 --- a/src/modules/creators/creators.routes.ts +++ b/src/modules/creators/creators.routes.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import { httpListCreators, httpGetCreatorStats } from './creators.controllers'; +import { httpGetCreatorHolders } from './creator-holders.controller'; import { cacheControl } from '../../middlewares/cache-control.middleware'; import { CREATOR_PUBLIC_ROUTE_CACHE_PRESETS } from '../../constants/creator-public-cache.constants'; import { CREATOR_PUBLIC_ROUTE_NAMES } from '../../constants/creator-public-routes.constants'; @@ -47,4 +48,22 @@ creatorsRouter.all('/:id/stats', (_req, res) => { res.set('Allow', 'GET').sendStatus(405); }); +/** + * GET /api/v1/creators/:id/holders + * + * Returns a paginated list of wallets that hold keys for a creator. + * Supports ?sort=held_since to surface earliest supporters first. + * Public endpoint with 5-minute cache. + */ +creatorsRouter.get( + '/:id/holders', + createCreatorReadMetricsMiddleware('holders'), + cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_HOLDERS]), + httpGetCreatorHolders +); +// 405 handler for /:id/holders +creatorsRouter.all('/:id/holders', (_req, res) => { + res.set('Allow', 'GET').sendStatus(405); +}); + export default creatorsRouter; \ No newline at end of file From e7ae1b3a60d83c7ef83575ede1baff3c130c6826 Mon Sep 17 00:00:00 2001 From: presidojay1 Date: Mon, 22 Jun 2026 15:18:11 +0100 Subject: [PATCH 2/2] fix: resolve tsc errors for creator holders endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'holders' to CreatorReadEndpoint union so createCreatorReadMetricsMiddleware accepts it; update registry, snapshot, and reset accordingly - Narrow req.params['id'] from string | string[] to string before passing to findCreatorByIdOrHandle — Express v5 types route params as string | string[] Co-Authored-By: Claude Sonnet 4.6 --- src/modules/creators/creator-holders.controller.ts | 3 ++- src/utils/creator-read-metrics.utils.ts | 13 ++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/modules/creators/creator-holders.controller.ts b/src/modules/creators/creator-holders.controller.ts index 63657c6..a2b6b4f 100644 --- a/src/modules/creators/creator-holders.controller.ts +++ b/src/modules/creators/creator-holders.controller.ts @@ -26,7 +26,8 @@ import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; */ export const httpGetCreatorHolders: AsyncController = async (req, res, next) => { try { - const { id } = req.params; + const rawId = req.params['id']; + const id = typeof rawId === 'string' ? rawId : String(rawId ?? ''); const parsed = parsePublicQuery(CreatorHoldersQuerySchema, req.query, { debugContext: 'creator-holders-query', diff --git a/src/utils/creator-read-metrics.utils.ts b/src/utils/creator-read-metrics.utils.ts index 6a22443..694a342 100644 --- a/src/utils/creator-read-metrics.utils.ts +++ b/src/utils/creator-read-metrics.utils.ts @@ -1,6 +1,6 @@ import { RequestHandler } from 'express'; -export type CreatorReadEndpoint = 'list' | 'detail'; +export type CreatorReadEndpoint = 'list' | 'detail' | 'holders'; const DURATION_BUCKETS_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000] as const; @@ -64,6 +64,7 @@ const createEndpointState = (): EndpointState => ({ const registry: Record = { list: createEndpointState(), detail: createEndpointState(), + holders: createEndpointState(), }; function classifyStatus(statusCode: number): 'success' | 'clientErrors' | 'serverErrors' { @@ -135,13 +136,14 @@ function snapshotEndpoint(entry: EndpointState): CreatorReadEndpointSnapshot { export function getCreatorReadMetrics(): CreatorReadMetricsSnapshot { return { counters: { - totalRequests: registry.list.requests + registry.detail.requests, - totalSuccess: registry.list.success + registry.detail.success, - totalClientErrors: registry.list.clientErrors + registry.detail.clientErrors, - totalServerErrors: registry.list.serverErrors + registry.detail.serverErrors, + totalRequests: registry.list.requests + registry.detail.requests + registry.holders.requests, + totalSuccess: registry.list.success + registry.detail.success + registry.holders.success, + totalClientErrors: registry.list.clientErrors + registry.detail.clientErrors + registry.holders.clientErrors, + totalServerErrors: registry.list.serverErrors + registry.detail.serverErrors + registry.holders.serverErrors, byEndpoint: { list: snapshotEndpoint(registry.list), detail: snapshotEndpoint(registry.detail), + holders: snapshotEndpoint(registry.holders), }, }, }; @@ -150,4 +152,5 @@ export function getCreatorReadMetrics(): CreatorReadMetricsSnapshot { export function resetCreatorReadMetrics(): void { registry.list = createEndpointState(); registry.detail = createEndpointState(); + registry.holders = createEndpointState(); } \ No newline at end of file