diff --git a/README.md b/README.md index 2c2b4e52c..40045dab8 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,8 @@ console.log(data); // Expected response // { -// "total": 42, +// "hasMore": true, +// "nextCursor": "MTIzNDU=", // "limit": 10, // "offset": 0, // "transfers": [ @@ -239,6 +240,13 @@ console.log(data); // } ``` +> **Pagination and totals.** List endpoints (`/transfers/*`, `/nfts/transfers`, GraphQL `transfers`) +> do not run a `COUNT` by default. They fetch one row past the page and return +> `hasMore` (plus `nextCursor` to continue), which is all pagination needs. Pass +> `?includeTotal=true` (GraphQL: `includeTotal: true`) to also get an exact +> `total`. This runs a `COUNT` over every matching row, so it is markedly slower +> on large result sets. Without it, `total` is absent from the response. + *** ### `GET /transfers/address/:address` — All transfers (sent & received, merged) @@ -263,7 +271,7 @@ console.log(data); // Expected response (same shape as /transfers/incoming — adds "direction" per row) // { -// "total": 85, +// "hasMore": true, // "limit": 20, // "offset": 0, // "transfers": [ @@ -367,8 +375,8 @@ both chains in a single round-trip: ```graphql { - testnet: transfers(address: "GABC…", network: TESTNET) { total } - mainnet: transfers(address: "GABC…", network: MAINNET) { total } + testnet: transfers(address: "GABC…", network: TESTNET, includeTotal: true) { total hasMore } + mainnet: transfers(address: "GABC…", network: MAINNET, includeTotal: true) { total hasMore } } ``` diff --git a/openapi.json b/openapi.json index 4682ea686..67f721217 100644 --- a/openapi.json +++ b/openapi.json @@ -697,6 +697,17 @@ "description": "Comma-separated field list", "name": "$select", "in": "query" + }, + { + "schema": { + "type": "boolean", + "nullable": true, + "description": "Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate" + }, + "required": false, + "description": "Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate", + "name": "includeTotal", + "in": "query" } ], "responses": { @@ -708,7 +719,12 @@ "type": "object", "properties": { "total": { - "type": "integer" + "type": "integer", + "description": "Exact match count. Only present when the request set includeTotal=true" + }, + "hasMore": { + "type": "boolean", + "description": "True when another page exists" }, "limit": { "type": "integer" @@ -798,7 +814,7 @@ } }, "required": [ - "total", + "hasMore", "limit", "offset", "transfers" @@ -1031,6 +1047,17 @@ "description": "Comma-separated field list", "name": "$select", "in": "query" + }, + { + "schema": { + "type": "boolean", + "nullable": true, + "description": "Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate" + }, + "required": false, + "description": "Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate", + "name": "includeTotal", + "in": "query" } ], "responses": { @@ -1042,7 +1069,12 @@ "type": "object", "properties": { "total": { - "type": "integer" + "type": "integer", + "description": "Exact match count. Only present when the request set includeTotal=true" + }, + "hasMore": { + "type": "boolean", + "description": "True when another page exists" }, "limit": { "type": "integer" @@ -1132,7 +1164,7 @@ } }, "required": [ - "total", + "hasMore", "limit", "offset", "transfers" @@ -1365,6 +1397,17 @@ "description": "Comma-separated field list", "name": "$select", "in": "query" + }, + { + "schema": { + "type": "boolean", + "nullable": true, + "description": "Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate" + }, + "required": false, + "description": "Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate", + "name": "includeTotal", + "in": "query" } ], "responses": { @@ -1376,7 +1419,12 @@ "type": "object", "properties": { "total": { - "type": "integer" + "type": "integer", + "description": "Exact match count. Only present when the request set includeTotal=true" + }, + "hasMore": { + "type": "boolean", + "description": "True when another page exists" }, "limit": { "type": "integer" @@ -1466,7 +1514,7 @@ } }, "required": [ - "total", + "hasMore", "limit", "offset", "transfers" @@ -1639,6 +1687,17 @@ "description": "Comma-separated list of event types", "name": "eventType", "in": "query" + }, + { + "schema": { + "type": "boolean", + "nullable": true, + "description": "Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate" + }, + "required": false, + "description": "Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate", + "name": "includeTotal", + "in": "query" } ], "responses": { @@ -2465,6 +2524,17 @@ "description": "Comma-separated field list", "name": "$select", "in": "query" + }, + { + "schema": { + "type": "boolean", + "nullable": true, + "description": "Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate" + }, + "required": false, + "description": "Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate", + "name": "includeTotal", + "in": "query" } ], "responses": { @@ -2476,7 +2546,12 @@ "type": "object", "properties": { "total": { - "type": "integer" + "type": "integer", + "description": "Exact match count. Only present when the request set includeTotal=true" + }, + "hasMore": { + "type": "boolean", + "description": "True when another page exists" }, "limit": { "type": "integer" @@ -2566,7 +2641,7 @@ } }, "required": [ - "total", + "hasMore", "limit", "offset", "transfers" @@ -3572,6 +3647,17 @@ "description": "Comma-separated field list", "name": "$select", "in": "query" + }, + { + "schema": { + "type": "boolean", + "nullable": true, + "description": "Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate" + }, + "required": false, + "description": "Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate", + "name": "includeTotal", + "in": "query" } ], "responses": { @@ -3583,7 +3669,12 @@ "type": "object", "properties": { "total": { - "type": "integer" + "type": "integer", + "description": "Exact match count. Only present when the request set includeTotal=true" + }, + "hasMore": { + "type": "boolean", + "description": "True when another page exists" }, "limit": { "type": "integer" @@ -3657,7 +3748,7 @@ } }, "required": [ - "total", + "hasMore", "limit", "offset", "transfers" diff --git a/src/__tests__/listPagination.test.ts b/src/__tests__/listPagination.test.ts new file mode 100644 index 000000000..6d3e65863 --- /dev/null +++ b/src/__tests__/listPagination.test.ts @@ -0,0 +1,118 @@ +/** + * List queries must not pay for a COUNT by default (#206). + * + * db.ts resolves its client from `globalThis.prisma`, so a recording stub keeps + * this a unit test with no database. The assertions are about which calls were + * issued (no `count` on the default path) and about the n+1 boundary that + * replaces it, not about row contents. + */ +import { StrKey } from "@stellar/stellar-sdk"; + +type AnyRecord = Record; + +const ALICE = "GDWCO35QUYQLGO6P7OLW4BZWNMMGGUWNPLRVPLCBVG7YNVDZKUDIW4KN"; +// Built from raw bytes so it is a valid strkey by construction. +const CONTRACT = StrKey.encodeContract(Buffer.alloc(32, 7)); + +const calls: Array<{ model: string; op: string; args: AnyRecord }> = []; +let rows: AnyRecord[] = []; +let exactCount = 0; +let transactionCalls = 0; + +const model = (name: string) => + new Proxy( + {}, + { + get: (_t, op: string) => (args: AnyRecord) => { + calls.push({ model: name, op, args: args ?? {} }); + if (op === "count") return Promise.resolve(exactCount); + if (op === "findMany") return Promise.resolve(rows.slice(0, args?.take ?? rows.length)); + return Promise.resolve(null); + }, + } + ) as AnyRecord; + +const stub: AnyRecord = { + tokenTransfer: model("tokenTransfer"), + nftTransfer: model("nftTransfer"), + accountSummary: model("accountSummary"), + $transaction: (ops: unknown) => { + transactionCalls++; + return Array.isArray(ops) ? Promise.all(ops) : (ops as (tx: AnyRecord) => unknown)(stub); + }, +}; +(globalThis as AnyRecord).prisma = stub; + +// Imported after the stub is installed — db.ts binds its client at module load. +import * as db from "../db"; + +const makeRows = (n: number) => Array.from({ length: n }, (_, i) => ({ id: n - i, ledger: 1000 + n - i, amount: "10000000" })); +const countCalls = () => calls.filter((c) => c.op === "count"); + +beforeEach(() => { + calls.length = 0; + rows = []; + exactCount = 0; + transactionCalls = 0; +}); + +it("fixtures are valid Stellar strkeys", () => { + expect(StrKey.isValidEd25519PublicKey(ALICE)).toBe(true); + expect(StrKey.isValidContract(CONTRACT)).toBe(true); +}); + +const listQueries: Array<[string, string, (extra?: AnyRecord) => Promise]> = [ + ["queryTransfers", "tokenTransfer", (e) => db.queryTransfers({ address: ALICE, direction: "incoming", ...e })], + ["queryAllTransfers", "tokenTransfer", (e) => db.queryAllTransfers({ address: ALICE, ...e })], + ["queryNftTransfers", "nftTransfer", (e) => db.queryNftTransfers({ contractId: CONTRACT, ...e })], + ["queryAccountSummaries", "accountSummary", (e) => db.queryAccountSummaries({ address: ALICE, ...e })], +]; + +describe.each(listQueries)("%s", (_name, modelName, run) => { + it("issues no COUNT by default and omits total", async () => { + rows = makeRows(3); + const result = await run({ limit: 10 }); + + expect(countCalls()).toHaveLength(0); + expect(transactionCalls).toBe(0); + expect(calls.filter((c) => c.model === modelName && c.op === "findMany")).toHaveLength(1); + expect(result).not.toHaveProperty("total"); + }); + + it("reports hasMore=false and no cursor when the page is exactly full (boundary)", async () => { + rows = makeRows(5); // limit 5 -> fetches 6, gets 5 + const result = await run({ limit: 5 }); + + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + expect(result.transfers).toHaveLength(5); + }); + + it("reports hasMore=true with a cursor when one row exists past the page (boundary)", async () => { + rows = makeRows(6); // limit 5 -> fetches 6, gets 6 + const result = await run({ limit: 5 }); + + expect(calls.find((c) => c.op === "findMany")!.args.take).toBe(6); // n+1 + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toEqual(expect.any(String)); + expect(result.transfers).toHaveLength(5); + }); + + it("returns an exact total only when includeTotal is requested", async () => { + rows = makeRows(2); + exactCount = 1234; + const result = await run({ limit: 10, includeTotal: true }); + + expect(countCalls()).toHaveLength(1); + expect(countCalls()[0].model).toBe(modelName); + expect(result.total).toBe(1234); + expect(result.hasMore).toBe(false); + }); + + it("includeTotal=false behaves like the default", async () => { + rows = makeRows(2); + const result = await run({ limit: 10, includeTotal: false }); + expect(countCalls()).toHaveLength(0); + expect(result).not.toHaveProperty("total"); + }); +}); diff --git a/src/__tests__/network.test.ts b/src/__tests__/network.test.ts index 3eda94ca9..2aa28152f 100644 --- a/src/__tests__/network.test.ts +++ b/src/__tests__/network.test.ts @@ -152,7 +152,8 @@ describe("reads are network-scoped", () => { it("counts and rows agree on the network, so total matches the page", async () => { process.env.STELLAR_NETWORK = "mainnet"; - await db.queryTransfers({ address: ADDR, direction: "incoming" }); + // The count is opt-in (#206); ask for it so both queries are recorded. + await db.queryTransfers({ address: ADDR, direction: "incoming", includeTotal: true }); expect(lastWhere("tokenTransfer", "count").network).toBe("mainnet"); expect(lastWhere("tokenTransfer", "findMany").network).toBe("mainnet"); }); diff --git a/src/__tests__/routes/transfers.test.ts b/src/__tests__/routes/transfers.test.ts index 709964699..10a295ff8 100644 --- a/src/__tests__/routes/transfers.test.ts +++ b/src/__tests__/routes/transfers.test.ts @@ -117,7 +117,7 @@ describe("Transfer route handlers", () => { describe("GET /transfers/incoming/:address", () => { it("returns all incoming transfers for a known address", async () => { const incoming = SEED_TRANSFERS.filter((t) => t.toAddress === ALICE); - mockQueryTransfers.mockResolvedValue({ total: incoming.length, transfers: incoming, nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: incoming.length, transfers: incoming, hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/incoming/${ALICE}`); @@ -128,9 +128,25 @@ describe("Transfer route handlers", () => { expect(res.body.offset).toBe(0); }); + it("does not ask for a total by default, and forwards includeTotal=true when set", async () => { + mockQueryTransfers.mockResolvedValue({ transfers: [], hasMore: false, nextCursor: null }); + + const plain = await request(app).get(`/transfers/incoming/${ALICE}`); + expect(plain.status).toBe(200); + expect(plain.body).not.toHaveProperty("total"); + expect(plain.body.hasMore).toBe(false); + expect(mockQueryTransfers.mock.calls.at(-1)![0].includeTotal).toBeUndefined(); + + mockQueryTransfers.mockResolvedValue({ total: 7, transfers: [], hasMore: false, nextCursor: null }); + const counted = await request(app).get(`/transfers/incoming/${ALICE}`).query({ includeTotal: "true" }); + expect(counted.status).toBe(200); + expect(counted.body.total).toBe(7); + expect(mockQueryTransfers.mock.calls.at(-1)![0].includeTotal).toBe(true); + }); + it("attaches displayAmount to every transfer", async () => { const transfer = makeTransfer({ amount: "10000000" }); - mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [transfer], nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [transfer], hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/incoming/${ALICE}`); @@ -139,7 +155,7 @@ describe("Transfer route handlers", () => { }); it("returns empty array for an unknown address", async () => { - mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [], hasMore: false, nextCursor: null }); const res = await request(app).get("/transfers/incoming/GUNKNOWNADDRESS"); @@ -152,7 +168,7 @@ describe("Transfer route handlers", () => { const filtered = SEED_TRANSFERS.filter( (t) => t.toAddress === ALICE && t.contractId === CONTRACT_A ); - mockQueryTransfers.mockResolvedValue({ total: filtered.length, transfers: filtered, nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: filtered.length, transfers: filtered, hasMore: false, nextCursor: null }); const res = await request(app) .get(`/transfers/incoming/${ALICE}`) @@ -168,6 +184,7 @@ describe("Transfer route handlers", () => { mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ amount: "10000000" })], + hasMore: true, nextCursor: "cursor-1", }); @@ -192,7 +209,7 @@ describe("Transfer route handlers", () => { }); it("passes fromDate and toDate to queryTransfers", async () => { - mockQueryTransfers.mockResolvedValue({ total: 2, transfers: SEED_TRANSFERS.slice(14, 16), nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 2, transfers: SEED_TRANSFERS.slice(14, 16), hasMore: false, nextCursor: null }); const res = await request(app) .get(`/transfers/incoming/${ALICE}`) @@ -232,7 +249,7 @@ describe("Transfer route handlers", () => { }); it("accepts valid eventType values", async () => { - mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ eventType: "mint" })], nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ eventType: "mint" })], hasMore: false, nextCursor: null }); const res = await request(app) .get(`/transfers/incoming/${ALICE}`) @@ -245,7 +262,7 @@ describe("Transfer route handlers", () => { }); it("accepts comma-separated eventType values", async () => { - mockQueryTransfers.mockResolvedValue({ total: 2, transfers: [], nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 2, transfers: [], hasMore: false, nextCursor: null }); const res = await request(app) .get(`/transfers/incoming/${ALICE}`) @@ -259,7 +276,7 @@ describe("Transfer route handlers", () => { it("honours limit and offset for pagination", async () => { const page = SEED_TRANSFERS.slice(0, 5); - mockQueryTransfers.mockResolvedValue({ total: 20, transfers: page, nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 20, transfers: page, hasMore: false, nextCursor: null }); const res = await request(app) .get(`/transfers/incoming/${ALICE}`) @@ -274,7 +291,7 @@ describe("Transfer route handlers", () => { }); it("falls back to limit=50, offset=0 when not provided", async () => { - mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [], hasMore: false, nextCursor: null }); await request(app).get(`/transfers/incoming/${ALICE}`); @@ -284,7 +301,7 @@ describe("Transfer route handlers", () => { }); it("forwards fromLedger and toLedger filters", async () => { - mockQueryTransfers.mockResolvedValue({ total: 3, transfers: SEED_TRANSFERS.slice(0, 3), nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 3, transfers: SEED_TRANSFERS.slice(0, 3), hasMore: false, nextCursor: null }); await request(app) .get(`/transfers/incoming/${ALICE}`) @@ -300,7 +317,7 @@ describe("Transfer route handlers", () => { describe("GET /transfers/outgoing/:address", () => { it("returns outgoing transfers with direction=outgoing", async () => { const outgoing = SEED_TRANSFERS.filter((t) => t.fromAddress === ALICE); - mockQueryTransfers.mockResolvedValue({ total: outgoing.length, transfers: outgoing, nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: outgoing.length, transfers: outgoing, hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/outgoing/${ALICE}`); @@ -312,7 +329,7 @@ describe("Transfer route handlers", () => { }); it("returns empty array for address with no outgoing transfers", async () => { - mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [], hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/outgoing/GNOBODY`); @@ -322,7 +339,7 @@ describe("Transfer route handlers", () => { it("attaches displayAmount for large i128 amounts", async () => { const t = makeTransfer({ amount: "1000000000000000" }); // 100000000.0000000 - mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [t], nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [t], hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/outgoing/${ALICE}`); @@ -345,7 +362,7 @@ describe("Transfer route handlers", () => { (t) => t.toAddress === ALICE || t.fromAddress === ALICE ).map((t) => ({ ...t, direction: t.toAddress === ALICE ? "incoming" : "outgoing" })); - mockQueryAllTransfers.mockResolvedValue({ total: combined.length, transfers: combined, nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: combined.length, transfers: combined, hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}`); @@ -357,7 +374,7 @@ describe("Transfer route handlers", () => { it("direction field is present on each record", async () => { const t1 = { ...makeTransfer({ id: 1, toAddress: ALICE, fromAddress: BOB }), direction: "incoming" }; const t2 = { ...makeTransfer({ id: 2, toAddress: BOB, fromAddress: ALICE }), direction: "outgoing" }; - mockQueryAllTransfers.mockResolvedValue({ total: 2, transfers: [t1, t2], nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 2, transfers: [t1, t2], hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}`); @@ -366,7 +383,7 @@ describe("Transfer route handlers", () => { }); it("returns empty array for unknown address", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], hasMore: false, nextCursor: null }); const res = await request(app).get("/transfers/address/GUNKNOWN"); @@ -375,7 +392,7 @@ describe("Transfer route handlers", () => { }); it("honours pagination params", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 20, transfers: [], nextCursor: "cursor-2" }); + mockQueryAllTransfers.mockResolvedValue({ total: 20, transfers: [], hasMore: true, nextCursor: "cursor-2" }); await request(app) .get(`/transfers/address/${ALICE}`) @@ -387,7 +404,7 @@ describe("Transfer route handlers", () => { }); it("filters by contractId", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 3, transfers: [], nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 3, transfers: [], hasMore: false, nextCursor: null }); await request(app) .get(`/transfers/address/${ALICE}`) @@ -417,6 +434,7 @@ describe("Transfer route handlers", () => { mockQueryAllTransfers.mockResolvedValue({ total: tokenFiltered.length, transfers: tokenFiltered, + hasMore: false, nextCursor: null, }); @@ -455,7 +473,7 @@ describe("Transfer route handlers", () => { .filter((t) => t.toAddress === ALICE || t.fromAddress === ALICE) .map((t) => ({ ...t, direction: t.toAddress === ALICE ? "incoming" : "outgoing" })); - mockQueryAllTransfers.mockResolvedValue({ total: combined.length, transfers: combined, nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: combined.length, transfers: combined, hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}`); @@ -547,19 +565,19 @@ describe("Transfer route handlers", () => { // ── toDisplayAmount edge cases ───────────────────────────────────────────── describe("toDisplayAmount formatting", () => { it("formats 0 correctly", async () => { - mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ amount: "0" })], nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ amount: "0" })], hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/incoming/${ALICE}`); expect(res.body.transfers[0].displayAmount).toBe("0.0000000"); }); it("formats small amounts with leading zeros in fractional part", async () => { - mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ amount: "1" })], nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ amount: "1" })], hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/incoming/${ALICE}`); expect(res.body.transfers[0].displayAmount).toBe("0.0000001"); }); it("formats exactly 1 token (10000000 stroops)", async () => { - mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ amount: "10000000" })], nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ amount: "10000000" })], hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/incoming/${ALICE}`); expect(res.body.transfers[0].displayAmount).toBe("1.0000000"); }); @@ -607,7 +625,7 @@ describe("Transfer route handlers", () => { eventType: "transfer", }), direction: "incoming" as const }, ]; - mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -639,7 +657,7 @@ describe("Transfer route handlers", () => { eventType: "mint", }), direction: "incoming" as const }, ]; - mockQueryAllTransfers.mockResolvedValue({ total: 2, transfers, nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 2, transfers, hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -665,7 +683,7 @@ describe("Transfer route handlers", () => { eventType: "transfer", }), direction: "outgoing" as const }, ]; - mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -686,7 +704,7 @@ describe("Transfer route handlers", () => { eventType: "mint", }), direction: "incoming" as const }, ]; - mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -707,7 +725,7 @@ describe("Transfer route handlers", () => { eventType: "burn", }), direction: "outgoing" as const }, ]; - mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -717,7 +735,7 @@ describe("Transfer route handlers", () => { }); it("sets Content-Disposition header with filename", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -728,7 +746,7 @@ describe("Transfer route handlers", () => { }); it("respects contractId filter", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], hasMore: false, nextCursor: null }); await request(app) .get(`/transfers/address/${ALICE}/export.csv`) @@ -740,7 +758,7 @@ describe("Transfer route handlers", () => { }); it("respects token filter for CSV export", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], hasMore: false, nextCursor: null }); await request(app) .get(`/transfers/address/${ALICE}/export.csv`) @@ -761,7 +779,7 @@ describe("Transfer route handlers", () => { }); it("respects fromDate and toDate filters", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], hasMore: false, nextCursor: null }); await request(app) .get(`/transfers/address/${ALICE}/export.csv`) @@ -779,7 +797,7 @@ describe("Transfer route handlers", () => { }); it("respects eventType filter", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], hasMore: false, nextCursor: null }); await request(app) .get(`/transfers/address/${ALICE}/export.csv`) @@ -791,7 +809,7 @@ describe("Transfer route handlers", () => { }); it("enforces a 10,000 row cap for export", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 50000, transfers: [], nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 50000, transfers: [], hasMore: false, nextCursor: null }); await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -801,7 +819,7 @@ describe("Transfer route handlers", () => { }); it("always uses offset=0 for CSV export", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 100, transfers: [], nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 100, transfers: [], hasMore: false, nextCursor: null }); await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -829,7 +847,7 @@ describe("Transfer route handlers", () => { }); it("returns empty CSV (header only) for address with no transfers", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -850,7 +868,7 @@ describe("Transfer route handlers", () => { eventType: "transfer", }), direction: "incoming" as const }, ]; - mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, hasMore: false, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -863,7 +881,7 @@ describe("Transfer route handlers", () => { describe("JSON:API content negotiation", () => { it("returns JSON:API format for transfers/address/:address", async () => { const t = { ...makeTransfer({ id: 1, toAddress: ALICE, fromAddress: BOB, eventType: "transfer", ledger: 1001, amount: "10000000" }), direction: "incoming" as const }; - mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers: [t], nextCursor: null }); + mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers: [t], hasMore: false, nextCursor: null }); const res = await request(app) .get(`/transfers/address/${ALICE}`) @@ -879,7 +897,7 @@ describe("Transfer route handlers", () => { it("returns JSON:API format for transfers/incoming/:address", async () => { const t = { ...makeTransfer({ id: 1, toAddress: ALICE, fromAddress: BOB }), direction: "incoming" as const }; - mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [t], nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [t], hasMore: false, nextCursor: null }); const res = await request(app) .get(`/transfers/incoming/${ALICE}`) diff --git a/src/__tests__/staleReads.test.ts b/src/__tests__/staleReads.test.ts index fbbec829f..2d65d0ece 100644 --- a/src/__tests__/staleReads.test.ts +++ b/src/__tests__/staleReads.test.ts @@ -65,6 +65,7 @@ describe("Graceful stale reads during RPC outage (#164)", () => { eventId: "evt123", }, ], + hasMore: false, nextCursor: null, }); @@ -80,7 +81,7 @@ describe("Graceful stale reads during RPC outage (#164)", () => { it("resumes normal non-stale headers after RPC recovers", async () => { mockGetLatestLedger.mockRejectedValueOnce(new Error("RPC outage")); - mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); + mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [], hasMore: false, nextCursor: null }); const res1 = await request(app).get(`/transfers/incoming/${ALICE}`); expect(res1.status).toBe(200); diff --git a/src/api.ts b/src/api.ts index 96128f439..e5fc684d5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -525,6 +525,7 @@ export function createApp(): express.Application { eventTypes: eventType as string[] | undefined, limit, offset, + includeTotal: (parsed as { includeTotal?: boolean }).includeTotal, }); res.json({ @@ -587,6 +588,7 @@ export function createApp(): express.Application { eventTypes: eventType as string[] | undefined, limit, offset, + includeTotal: (parsed as { includeTotal?: boolean }).includeTotal, }); res.json({ @@ -659,6 +661,7 @@ export function createApp(): express.Application { eventTypes: eventType as string[] | undefined, limit, offset, + includeTotal: (parsed as { includeTotal?: boolean }).includeTotal, }); res.json({ @@ -940,6 +943,7 @@ export function createApp(): express.Application { toLedger, limit, offset, + includeTotal: (parsed as { includeTotal?: boolean }).includeTotal, }); res.json({ ...result, limit, offset }); diff --git a/src/db.ts b/src/db.ts index 9b0e7221d..628bb5238 100644 --- a/src/db.ts +++ b/src/db.ts @@ -61,17 +61,30 @@ export interface TransferRecord { type ListPage = { rows: T[]; nextCursor: string | null; + /** True when the n+1 fetch found a row past this page. */ + hasMore: boolean; }; +/** + * A `COUNT(*)` over the same filter costs as much as walking every matching + * row, and on the tables this service exists to grow it dominates the request. + * Lists therefore skip it unless the caller asks with `includeTotal` (#206); + * `hasMore` from the n+1 fetch already answers what pagination needs. + */ +function totalField(total: number | undefined): { total?: number } { + return total === undefined ? {} : { total }; +} + function buildListPage(rows: T[], limit: number): ListPage { if (rows.length <= limit) { - return { rows, nextCursor: null }; + return { rows, nextCursor: null, hasMore: false }; } const pageRows = rows.slice(0, limit); return { rows: pageRows, nextCursor: encodeCursor(pageRows[pageRows.length - 1].id), + hasMore: true, }; } @@ -355,6 +368,8 @@ export type TransferQueryParams = { eventTypes?: string[]; limit?: number; offset?: number; + /** Also run an exact COUNT. Off by default because it is expensive. */ + includeTotal?: boolean; }; export async function queryTransfers(params: TransferQueryParams) { @@ -374,6 +389,7 @@ export async function queryTransfers(params: TransferQueryParams) { eventTypes, limit = 50, offset = 0, + includeTotal = false, } = params; const baseWhere: Prisma.TokenTransferWhereInput = { @@ -426,23 +442,24 @@ export async function queryTransfers(params: TransferQueryParams) { const cap = Math.min(limit, 200); const cursorId = decodeCursor(cursor); - const [total, transfers] = await observeDbQuery("queryTransfers", () => - prisma.$transaction([ - prisma.tokenTransfer.count({ where }), - prisma.tokenTransfer.findMany({ - where, - orderBy: [{ ledger: "desc" }, { id: "desc" }], - take: cap + 1, - ...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : { skip: offset }), - ...(prismaSelect ? { select: prismaSelect } : {}), - }), - ]) - ); + const [total, transfers] = await observeDbQuery("queryTransfers", async () => { + const rowsQuery = prisma.tokenTransfer.findMany({ + where, + orderBy: [{ ledger: "desc" }, { id: "desc" }], + take: cap + 1, + ...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : { skip: offset }), + ...(prismaSelect ? { select: prismaSelect } : {}), + }); + return includeTotal + ? prisma.$transaction([prisma.tokenTransfer.count({ where }), rowsQuery]) + : ([undefined, await rowsQuery] as const); + }); const page = buildListPage(transfers as Array<{ id: number }>, cap); return { - total, + ...totalField(total), + hasMore: page.hasMore, transfers: selectRows(page.rows as Array>, requestedSelect, { displayAmount: (row) => toDisplayAmount(String((row as { amount?: string }).amount)), }), @@ -606,6 +623,8 @@ export type NftTransferQueryParams = { toLedger?: number; limit?: number; offset?: number; + /** Also run an exact COUNT. Off by default because it is expensive. */ + includeTotal?: boolean; }; export async function queryNftTransfers(params: NftTransferQueryParams) { @@ -621,6 +640,7 @@ export async function queryNftTransfers(params: NftTransferQueryParams) { toLedger, limit = 50, offset = 0, + includeTotal = false, } = params; const baseWhere: Prisma.NftTransferWhereInput = { @@ -661,21 +681,22 @@ export async function queryNftTransfers(params: NftTransferQueryParams) { const cap = Math.min(limit, 200); const cursorId = decodeCursor(cursor); - const [total, transfers] = await prisma.$transaction([ - prisma.nftTransfer.count({ where }), - prisma.nftTransfer.findMany({ - where, - orderBy: [{ ledger: "desc" }, { id: "desc" }], - take: cap + 1, - ...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : { skip: offset }), - ...(prismaSelect ? { select: prismaSelect } : {}), - }), - ]); + const rowsQuery = prisma.nftTransfer.findMany({ + where, + orderBy: [{ ledger: "desc" }, { id: "desc" }], + take: cap + 1, + ...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : { skip: offset }), + ...(prismaSelect ? { select: prismaSelect } : {}), + }); + const [total, transfers] = includeTotal + ? await prisma.$transaction([prisma.nftTransfer.count({ where }), rowsQuery]) + : ([undefined, await rowsQuery] as const); const page = buildListPage(transfers as Array<{ id: number }>, cap); return { - total, + ...totalField(total), + hasMore: page.hasMore, transfers: selectRows(page.rows as Array>, requestedSelect), nextCursor: page.nextCursor, }; @@ -807,10 +828,22 @@ export type AccountSummaryQueryParams = { cursor?: string; limit?: number; offset?: number; + /** Also run an exact COUNT. Off by default because it is expensive. */ + includeTotal?: boolean; }; export async function queryAccountSummaries(params: AccountSummaryQueryParams) { - const { network, address, contractId, filter, select, cursor, limit = 50, offset = 0 } = params; + const { + network, + address, + contractId, + filter, + select, + cursor, + limit = 50, + offset = 0, + includeTotal = false, + } = params; const baseWhere: Prisma.AccountSummaryWhereInput = { network: resolveNetwork(network), @@ -840,21 +873,22 @@ export async function queryAccountSummaries(params: AccountSummaryQueryParams) { const cap = Math.min(limit, 200); const cursorId = decodeCursor(cursor); - const [total, rows] = await prisma.$transaction([ - prisma.accountSummary.count({ where }), - prisma.accountSummary.findMany({ - where, - orderBy: [{ lastActivityAt: "desc" }, { id: "desc" }], - take: cap + 1, - ...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : { skip: offset }), - ...(prismaSelect ? { select: prismaSelect } : {}), - }), - ]); + const rowsQuery = prisma.accountSummary.findMany({ + where, + orderBy: [{ lastActivityAt: "desc" }, { id: "desc" }], + take: cap + 1, + ...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : { skip: offset }), + ...(prismaSelect ? { select: prismaSelect } : {}), + }); + const [total, rows] = includeTotal + ? await prisma.$transaction([prisma.accountSummary.count({ where }), rowsQuery]) + : ([undefined, await rowsQuery] as const); const page = buildListPage(rows as Array<{ id: number }>, cap); return { - total, + ...totalField(total), + hasMore: page.hasMore, transfers: selectRows(page.rows as Array>, requestedSelect, { displayTotalSent: (row) => row.totalSent, displayTotalReceived: (row) => row.totalReceived, @@ -880,6 +914,8 @@ export type AllTransfersQueryParams = { eventTypes?: string[]; limit?: number; offset?: number; + /** Also run an exact COUNT. Off by default because it is expensive. */ + includeTotal?: boolean; }; export async function queryAllTransfers(params: AllTransfersQueryParams) { @@ -898,6 +934,7 @@ export async function queryAllTransfers(params: AllTransfersQueryParams) { eventTypes, limit = 50, offset = 0, + includeTotal = false, } = params; const baseWhere: Prisma.TokenTransferWhereInput = { @@ -949,16 +986,16 @@ export async function queryAllTransfers(params: AllTransfersQueryParams) { } : undefined; - const [total, rows] = await prisma.$transaction([ - prisma.tokenTransfer.count({ where }), - prisma.tokenTransfer.findMany({ - where, - orderBy: [{ ledger: "desc" }, { id: "desc" }], - take: cap + 1, - ...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : { skip: offset }), - ...(prismaSelect ? { select: prismaSelect } : {}), - }), - ]); + const rowsQuery = prisma.tokenTransfer.findMany({ + where, + orderBy: [{ ledger: "desc" }, { id: "desc" }], + take: cap + 1, + ...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : { skip: offset }), + ...(prismaSelect ? { select: prismaSelect } : {}), + }); + const [total, rows] = includeTotal + ? await prisma.$transaction([prisma.tokenTransfer.count({ where }), rowsQuery]) + : ([undefined, await rowsQuery] as const); const page = buildListPage(rows as Array<{ id: number }>, cap); @@ -967,7 +1004,7 @@ export async function queryAllTransfers(params: AllTransfersQueryParams) { direction: (row) => ((row as { toAddress?: string | null }).toAddress === address ? "incoming" : "outgoing"), }); - return { total, transfers, nextCursor: page.nextCursor }; + return { ...totalField(total), hasMore: page.hasMore, transfers, nextCursor: page.nextCursor }; } // ─── Popular assets query ────────────────────────────────────────────────── diff --git a/src/graphql/server.ts b/src/graphql/server.ts index c45de7c45..84f539a0a 100644 --- a/src/graphql/server.ts +++ b/src/graphql/server.ts @@ -43,7 +43,9 @@ export const typeDefs = `#graphql } type TransferConnection { - total: Int! + "Exact match count. Null unless the query passed includeTotal: true, because it costs a full COUNT." + total: Int + hasMore: Boolean! transfers: [Transfer!]! nextCursor: String } @@ -65,6 +67,8 @@ export const typeDefs = `#graphql network: Network limit: Int = 50 offset: Int = 0 + "Also compute the exact total. Slower on large result sets." + includeTotal: Boolean = false ): TransferConnection! transferByTx(txHash: String!, network: Network): [Transfer!]! summary(address: String!, contractId: String, network: Network): [TokenSummary!]! @@ -132,6 +136,7 @@ export const resolvers = { network?: NetworkArg; limit?: number; offset?: number; + includeTotal?: boolean; }, ctx?: GraphQLContext ) => { @@ -141,6 +146,7 @@ export const resolvers = { contractId: args.contractId, limit: args.limit, offset: args.offset, + includeTotal: args.includeTotal, }; const result = diff --git a/src/middleware/jsonapi.ts b/src/middleware/jsonapi.ts index 8cec74c8d..85d3d66be 100644 --- a/src/middleware/jsonapi.ts +++ b/src/middleware/jsonapi.ts @@ -95,11 +95,12 @@ function recordToResource(record: Record, type: string): JsonAp return resource; } -function transformTransfersResponse(body: { transfers: Record[]; total?: number; limit?: number; offset?: number; nextCursor?: string | null }, endpoint: string): { data: JsonApiResource[]; meta: Record } { +function transformTransfersResponse(body: { transfers: Record[]; total?: number; hasMore?: boolean; limit?: number; offset?: number; nextCursor?: string | null }, endpoint: string): { data: JsonApiResource[]; meta: Record } { const type = determineResourceType(endpoint, body) ?? "transfer"; const data = body.transfers.map((t) => recordToResource(t as Record, type)); const meta: Record = {}; if (typeof body.total === "number") meta.total = body.total; + if (typeof body.hasMore === "boolean") meta.hasMore = body.hasMore; if (typeof body.limit === "number") meta.limit = body.limit; if (typeof body.offset === "number") meta.offset = body.offset; if (body.nextCursor) meta.nextCursor = body.nextCursor; @@ -131,10 +132,11 @@ function transformAssetsResponse(body: { window: string; by: string; limit: numb return { data, meta }; } -function transformNftTransfersResponse(body: { transfers: Record[]; total?: number; limit?: number; offset?: number; nextCursor?: string | null }): { data: JsonApiResource[]; meta: Record } { +function transformNftTransfersResponse(body: { transfers: Record[]; total?: number; hasMore?: boolean; limit?: number; offset?: number; nextCursor?: string | null }): { data: JsonApiResource[]; meta: Record } { const data = body.transfers.map((t) => recordToResource(t as Record, "nft-transfer")); const meta: Record = {}; if (typeof body.total === "number") meta.total = body.total; + if (typeof body.hasMore === "boolean") meta.hasMore = body.hasMore; if (typeof body.limit === "number") meta.limit = body.limit; if (typeof body.offset === "number") meta.offset = body.offset; if (body.nextCursor) meta.nextCursor = body.nextCursor; @@ -182,7 +184,7 @@ function transformToJsonApi(body: unknown, endpoint: string, statusCode: number const bodyRecord = body as Record; if (bodyRecord.transfers && Array.isArray(bodyRecord.transfers)) { - return transformTransfersResponse(bodyRecord as { transfers: Record[]; total?: number; limit?: number; offset?: number; nextCursor?: string | null }, endpoint) as unknown as JsonApiResponse; + return transformTransfersResponse(bodyRecord as { transfers: Record[]; total?: number; hasMore?: boolean; limit?: number; offset?: number; nextCursor?: string | null }, endpoint) as unknown as JsonApiResponse; } if (bodyRecord.tokens && Array.isArray(bodyRecord.tokens)) { diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 1849a7700..3d27b88a1 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -88,6 +88,18 @@ const queryIntWithDefault = (defaultValue: number, options: { min?: number; max? .openapi({ description: options.description }) ); +const optionalQueryBool = (description?: string) => + z.preprocess( + (value) => { + const raw = firstValue(value); + if (raw === undefined || raw === null || raw === "") return undefined; + if (raw === "true" || raw === "1") return true; + if (raw === "false" || raw === "0") return false; + return raw; + }, + z.boolean().optional().openapi({ description }) + ); + const optionalCommaList = (items: z.ZodTypeAny, description?: string) => z.preprocess( (value) => { @@ -226,7 +238,8 @@ export const transferSchema = z.object({ }); export const transferListResponseSchema = z.object({ - total: z.number().int(), + total: z.number().int().optional().openapi({ description: "Exact match count. Only present when the request set includeTotal=true" }), + hasMore: z.boolean().openapi({ description: "True when another page exists" }), limit: z.number().int(), offset: z.number().int(), nextCursor: z.string().nullable().optional(), @@ -362,7 +375,8 @@ export const nftTransferSchema = z.object({ }); export const nftTransfersResponseSchema = z.object({ - total: z.number().int(), + total: z.number().int().optional().openapi({ description: "Exact match count. Only present when the request set includeTotal=true" }), + hasMore: z.boolean().openapi({ description: "True when another page exists" }), limit: z.number().int(), offset: z.number().int(), nextCursor: z.string().nullable().optional(), @@ -455,6 +469,7 @@ export const transferQuerySchema = z.object({ cursor: optionalQueryString("Opaque pagination cursor"), $filter: optionalQueryString("OData filter expression"), $select: optionalCommaList(z.string(), "Comma-separated field list"), + includeTotal: optionalQueryBool("Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate"), }).passthrough(); export const summaryQuerySchema = z.object({ @@ -489,6 +504,7 @@ export const nftTransfersQuerySchema = z.object({ cursor: optionalQueryString("Opaque pagination cursor"), $filter: optionalQueryString("OData filter expression"), $select: optionalCommaList(z.string(), "Comma-separated field list"), + includeTotal: optionalQueryBool("Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate"), }).passthrough(); export const nftOwnerParamsSchema = z.object({ diff --git a/src/routes/accounts/transfers.ts b/src/routes/accounts/transfers.ts index 790c5849c..ca9e3dcd1 100644 --- a/src/routes/accounts/transfers.ts +++ b/src/routes/accounts/transfers.ts @@ -89,6 +89,7 @@ export function createAccountsTransfersRouter(): Router { eventTypes: eventType as string[] | undefined, limit, offset, + includeTotal: (parsed as { includeTotal?: boolean }).includeTotal, }); res.json({