diff --git a/packages/data-access/src/combined-data-access.ts b/packages/data-access/src/combined-data-access.ts index 304b9bb1c..688593297 100644 --- a/packages/data-access/src/combined-data-access.ts +++ b/packages/data-access/src/combined-data-access.ts @@ -31,18 +31,14 @@ export abstract class CombinedDataAccess implements DataAccessTypes.IDataAccess async getChannelsByTopic( topic: string, updatedBetween?: DataAccessTypes.ITimestampBoundaries | undefined, - page?: number, - pageSize?: number, ): Promise { - return await this.reader.getChannelsByTopic(topic, updatedBetween, page, pageSize); + return await this.reader.getChannelsByTopic(topic, updatedBetween); } async getChannelsByMultipleTopics( topics: string[], updatedBetween?: DataAccessTypes.ITimestampBoundaries, - page?: number | undefined, - pageSize?: number | undefined, ): Promise { - return await this.reader.getChannelsByMultipleTopics(topics, updatedBetween, page, pageSize); + return await this.reader.getChannelsByMultipleTopics(topics, updatedBetween); } async persistTransaction( transactionData: DataAccessTypes.ITransaction, diff --git a/packages/data-access/src/data-read.ts b/packages/data-access/src/data-read.ts index 4c2bf5c87..34d0698b7 100644 --- a/packages/data-access/src/data-read.ts +++ b/packages/data-access/src/data-read.ts @@ -49,26 +49,15 @@ export class DataAccessRead implements DataAccessTypes.IDataRead { async getChannelsByTopic( topic: string, updatedBetween?: DataAccessTypes.ITimestampBoundaries | undefined, - page?: number | undefined, - pageSize?: number | undefined, ): Promise { - return this.getChannelsByMultipleTopics([topic], updatedBetween, page, pageSize); + return this.getChannelsByMultipleTopics([topic], updatedBetween); } async getChannelsByMultipleTopics( topics: string[], updatedBetween?: DataAccessTypes.ITimestampBoundaries, - page?: number, - pageSize?: number, ): Promise { - // Validate pagination parameters - if (page !== undefined && page < 1) { - throw new Error(`Page number must be greater than or equal to 1, but it is ${page}`); - } - if (pageSize !== undefined && pageSize < 1) { - throw new Error(`Page size must be greater than 0, but it is ${pageSize}`); - } - + const result = await this.storage.getTransactionsByTopics(topics); const pending = this.pendingStore?.findByTopics(topics) || []; const pendingItems = pending.map((item) => ({ @@ -84,33 +73,6 @@ export class DataAccessRead implements DataAccessTypes.IDataRead { topics: item.topics || [], })); - // Calculate adjusted pagination - let adjustedPage = page; - let adjustedPageSize = pageSize; - let pendingItemsOnCurrentPage = 0; - if (page !== undefined && pageSize !== undefined) { - const totalPending = pendingItems.length; - const itemsPerPage = (page - 1) * pageSize; - - if (totalPending > itemsPerPage) { - pendingItemsOnCurrentPage = Math.min(totalPending - itemsPerPage, pageSize); - adjustedPageSize = pageSize - pendingItemsOnCurrentPage; - adjustedPage = 1; - if (adjustedPageSize === 0) { - adjustedPageSize = 1; - pendingItemsOnCurrentPage--; - } - } else { - adjustedPage = page - Math.floor(totalPending / pageSize); - } - } - - const result = await this.storage.getTransactionsByTopics( - topics, - adjustedPage, - adjustedPageSize, - ); - const transactions = result.transactions.concat(...pendingItems); // list of channels having at least one tx updated during the updatedBetween boundaries @@ -138,17 +100,6 @@ export class DataAccessRead implements DataAccessTypes.IDataRead { prev[curr.channelId].push(curr.hash); return prev; }, {} as Record), - pagination: - page && pageSize - ? { - total: result.transactions.length + pendingItems.length, - page, - pageSize, - hasMore: - (page - 1) * pageSize + filteredTxs.length - pendingItemsOnCurrentPage < - result.transactions.length, - } - : undefined, }, result: { transactions: filteredTxs.reduce((prev, curr) => { diff --git a/packages/data-access/src/in-memory-indexer.ts b/packages/data-access/src/in-memory-indexer.ts index 22ab05aab..02c6a4fa2 100644 --- a/packages/data-access/src/in-memory-indexer.ts +++ b/packages/data-access/src/in-memory-indexer.ts @@ -55,38 +55,11 @@ export class InMemoryIndexer implements StorageTypes.IIndexer { }; } - async getTransactionsByTopics( - topics: string[], - page?: number, - pageSize?: number, - ): Promise { - if (page !== undefined && page < 1) { - throw new Error('Page must be greater than or equal to 1'); - } - if (pageSize !== undefined && pageSize <= 0) { - throw new Error('Page size must be greater than 0'); - } - + async getTransactionsByTopics(topics: string[]): Promise { // Efficiently get total count without creating intermediate array const channelIdsSet = new Set(topics.flatMap((topic) => this.#topicToChannelsIndex.get(topic))); - const total = channelIdsSet.size; - let channelIds = Array.from(channelIdsSet); + const channelIds = Array.from(channelIdsSet); - if (page && pageSize) { - const start = (page - 1) * pageSize; - // Return empty result if page exceeds available data - if (start >= total) { - return { - blockNumber: 0, - transactions: [], - pagination: - page && pageSize - ? { total, page, pageSize, hasMore: page * pageSize < total } - : undefined, - }; - } - channelIds = channelIds.slice(start, start + pageSize); - } const locations = channelIds .map((channel) => this.#channelToLocationsIndex.get(channel)) .flat(); diff --git a/packages/integration-test/test/node-client.test.ts b/packages/integration-test/test/node-client.test.ts index d3ffe5d9b..64059fc93 100644 --- a/packages/integration-test/test/node-client.test.ts +++ b/packages/integration-test/test/node-client.test.ts @@ -273,7 +273,8 @@ describe('Request client using a request node', () => { await waitForConfirmation(requestDataCancel); // get requests without boundaries - let requests = await requestNetwork.fromTopic(topicsRequest1and2[0]); + const response = await requestNetwork.fromTopic(topicsRequest1and2[0]); + let requests = Array.isArray(response) ? response : response.requests; expect(requests.length).toBe(2); expect(requests[0].requestId).toBe(request1.requestId); expect(requests[1].requestId).toBe(request2.requestId); @@ -286,9 +287,10 @@ describe('Request client using a request node', () => { expect(requestData2.state).toBe(Types.RequestLogic.STATE.CREATED); // get requests with boundaries - requests = await requestNetwork.fromTopic(topicsRequest1and2[0], { + const result = await requestNetwork.fromTopic(topicsRequest1and2[0], { to: timestampBeforeReduce, }); + requests = Array.isArray(result) ? result : result.requests; expect(requests.length).toBe(1); expect(requests[0].requestId).toBe(request1.requestId); @@ -341,9 +343,10 @@ describe('Request client using a request node', () => { await new Promise((r) => setTimeout(r, 1500)); // get requests with boundaries - const requests = await requestNetwork.fromIdentity(payerSmartContract, { + const result = await requestNetwork.fromIdentity(payerSmartContract, { from: timestampCreation, }); + const requests = Array.isArray(result) ? result : result.requests; expect(requests.length).toBe(1); expect(requests[0].requestId).toBe(request1.requestId); }); diff --git a/packages/request-client.js/src/api/request-network.ts b/packages/request-client.js/src/api/request-network.ts index 3bcbeb8ea..701753b10 100644 --- a/packages/request-client.js/src/api/request-network.ts +++ b/packages/request-client.js/src/api/request-network.ts @@ -17,7 +17,7 @@ import { SignatureProviderTypes, TransactionTypes, } from '@requestnetwork/types'; -import { deepCopy, supportedIdentities, validatePaginationParams } from '@requestnetwork/utils'; +import { deepCopy, supportedIdentities } from '@requestnetwork/utils'; import { CurrencyManager, UnsupportedCurrencyError } from '@requestnetwork/currency'; import * as Types from '../types'; import ContentDataExtension from './content-data-extension'; @@ -294,7 +294,9 @@ export default class RequestNetwork { page?: number; pageSize?: number; }, - ): Promise { + ): Promise< + Request[] | { meta: RequestLogicTypes.IReturnGetRequestsByTopic['meta']; requests: Request[] } + > { if (!this.supportedIdentities.includes(identity.type)) { throw new Error(`${identity.type} is not supported`); } @@ -317,7 +319,9 @@ export default class RequestNetwork { page?: number; pageSize?: number; }, - ): Promise { + ): Promise< + Request[] | { meta: RequestLogicTypes.IReturnGetRequestsByTopic['meta']; requests: Request[] } + > { const identityNotSupported = identities.find( (identity) => !this.supportedIdentities.includes(identity.type), ); @@ -345,9 +349,9 @@ export default class RequestNetwork { page?: number; pageSize?: number; }, - ): Promise { - validatePaginationParams(options?.page, options?.pageSize); - + ): Promise< + Request[] | { meta: RequestLogicTypes.IReturnGetRequestsByTopic['meta']; requests: Request[] } + > { // Gets all the requests indexed by the value of the identity const requestsAndMeta: RequestLogicTypes.IReturnGetRequestsByTopic = await this.requestLogic.getRequestsByTopic( @@ -389,8 +393,16 @@ export default class RequestNetwork { return request; }, ); - - return Promise.all(requestPromises); + const requests = await Promise.all(requestPromises); + + if (options?.page && options?.pageSize) { + return { + requests, + meta: requestsAndMeta.meta, + }; + } else { + return requests; + } } /** @@ -409,9 +421,9 @@ export default class RequestNetwork { page?: number; pageSize?: number; }, - ): Promise { - validatePaginationParams(options?.page, options?.pageSize); - + ): Promise< + Request[] | { meta: RequestLogicTypes.IReturnGetRequestsByTopic['meta']; requests: Request[] } + > { // Gets all the requests indexed by the value of the identity const requestsAndMeta: RequestLogicTypes.IReturnGetRequestsByTopic = await this.requestLogic.getRequestsByMultipleTopics( @@ -454,8 +466,15 @@ export default class RequestNetwork { return request; }, ); - - return Promise.all(requestPromises); + const requests = await Promise.all(requestPromises); + if (options?.page && options?.pageSize) { + return { + requests, + meta: requestsAndMeta.meta, + }; + } else { + return requests; + } } /* diff --git a/packages/request-client.js/src/http-data-access.ts b/packages/request-client.js/src/http-data-access.ts index e8a878201..3484ab71f 100644 --- a/packages/request-client.js/src/http-data-access.ts +++ b/packages/request-client.js/src/http-data-access.ts @@ -110,10 +110,8 @@ export default class HttpDataAccess extends CombinedDataAccess { public async getChannelsByTopic( topic: string, updatedBetween?: DataAccessTypes.ITimestampBoundaries, - page?: number, - pageSize?: number, ): Promise { - return await this.reader.getChannelsByTopic(topic, updatedBetween, page, pageSize); + return await this.reader.getChannelsByTopic(topic, updatedBetween); } /** @@ -125,10 +123,8 @@ export default class HttpDataAccess extends CombinedDataAccess { public async getChannelsByMultipleTopics( topics: string[], updatedBetween?: DataAccessTypes.ITimestampBoundaries, - page?: number, - pageSize?: number, ): Promise { - return await this.reader.getChannelsByMultipleTopics(topics, updatedBetween, page, pageSize); + return await this.reader.getChannelsByMultipleTopics(topics, updatedBetween); } /** diff --git a/packages/request-client.js/src/http-data-read.ts b/packages/request-client.js/src/http-data-read.ts index ea27807e6..03bc06013 100644 --- a/packages/request-client.js/src/http-data-read.ts +++ b/packages/request-client.js/src/http-data-read.ts @@ -1,5 +1,4 @@ import { DataAccessTypes } from '@requestnetwork/types'; -import { validatePaginationParams } from '@requestnetwork/utils'; import { HttpDataAccessConfig } from './http-data-access-config'; export class HttpDataRead implements DataAccessTypes.IDataRead { @@ -53,16 +52,10 @@ export class HttpDataRead implements DataAccessTypes.IDataRead { public async getChannelsByTopic( topic: string, updatedBetween?: DataAccessTypes.ITimestampBoundaries, - page?: number, - pageSize?: number, ): Promise { - validatePaginationParams(page, pageSize); - const params = { topic, updatedBetween, - ...(page !== undefined && { page }), - ...(pageSize !== undefined && { pageSize }), }; return await this.dataAccessConfig.fetchAndRetry('/getChannelsByTopic', params); @@ -77,16 +70,10 @@ export class HttpDataRead implements DataAccessTypes.IDataRead { public async getChannelsByMultipleTopics( topics: string[], updatedBetween?: DataAccessTypes.ITimestampBoundaries, - page?: number, - pageSize?: number, ): Promise { - validatePaginationParams(page, pageSize); - return await this.dataAccessConfig.fetchAndRetry('/getChannelsByMultipleTopics', { topics, updatedBetween, - page, - pageSize, }); } } diff --git a/packages/request-client.js/test/api/request-network.test.ts b/packages/request-client.js/test/api/request-network.test.ts index fe1bc1abd..7e5c01a0e 100644 --- a/packages/request-client.js/test/api/request-network.test.ts +++ b/packages/request-client.js/test/api/request-network.test.ts @@ -99,14 +99,13 @@ describe('api/request-network', () => { async getChannelsByTopic( topic: string, updatedBetween?: DataAccessTypes.ITimestampBoundaries, - page?: number, - pageSize?: number, ): Promise { expect(topic).toBe('01f1a21ab419611dbf492b3136ac231c8773dc897ee0eb5167ef2051a39e685e76'); return { meta: { [TestData.actionRequestId]: [], [TestData.actionRequestIdSecondRequest]: [], + transactionsStorageLocation: {}, }, result: { transactions: { @@ -143,14 +142,11 @@ describe('api/request-network', () => { }; const requestnetwork = new RequestNetwork({ dataAccess: mockDataAccessWithTxs }); - const requests: Request[] = await requestnetwork.fromIdentity( - TestData.payee.identity, - undefined, - { - page: 1, - pageSize: 10, - }, - ); + const result = await requestnetwork.fromIdentity(TestData.payee.identity, undefined, { + page: 1, + pageSize: 10, + }); + const requests = Array.isArray(result) ? result : result.requests; expect(requests.length).toBe(2); expect(requests[0].requestId).toBe(TestData.actionRequestId); @@ -174,6 +170,7 @@ describe('api/request-network', () => { meta: { [TestData.actionRequestId]: [], [TestData.actionRequestIdSecondRequest]: [], + transactionsStorageLocation: {}, }, result: { transactions: { @@ -202,7 +199,8 @@ describe('api/request-network', () => { }; const requestnetwork = new RequestNetwork({ dataAccess: mockDataAccessWithTxs }); - const requests: Request[] = await requestnetwork.fromTopic(TestData.payee.identity); + const result = await requestnetwork.fromTopic(TestData.payee.identity); + const requests = Array.isArray(result) ? result : result.requests; expect(requests.length).toBe(2); expect(requests[0].requestId).toBe(TestData.actionRequestId); @@ -227,6 +225,7 @@ describe('api/request-network', () => { meta: { [TestData.actionRequestId]: [], [TestData.actionRequestIdSecondRequest]: [], + transactionsStorageLocation: {}, }, result: { transactions: { @@ -263,7 +262,7 @@ describe('api/request-network', () => { }; const requestnetwork = new RequestNetwork({ dataAccess: mockDataAccessWithTxs }); - const requests: Request[] = await requestnetwork.fromMultipleIdentities( + const result = await requestnetwork.fromMultipleIdentities( [TestData.payee.identity], undefined, { @@ -271,6 +270,7 @@ describe('api/request-network', () => { pageSize: 10, }, ); + const requests = Array.isArray(result) ? result : result.requests; expect(requests.length).toBe(2); expect(requests[0].requestId).toBe(TestData.actionRequestId); @@ -296,6 +296,7 @@ describe('api/request-network', () => { meta: { [TestData.actionRequestId]: [], [TestData.actionRequestIdSecondRequest]: [], + transactionsStorageLocation: {}, }, result: { transactions: { @@ -324,9 +325,8 @@ describe('api/request-network', () => { }; const requestnetwork = new RequestNetwork({ dataAccess: mockDataAccessWithTxs }); - const requests: Request[] = await requestnetwork.fromMultipleTopics([ - TestData.payee.identity, - ]); + const result = await requestnetwork.fromMultipleTopics([TestData.payee.identity]); + const requests = Array.isArray(result) ? result : result.requests; expect(requests.length).toBe(2); expect(requests[0].requestId).toBe(TestData.actionRequestId); diff --git a/packages/request-client.js/test/index.test.ts b/packages/request-client.js/test/index.test.ts index a048a7766..714a850fa 100644 --- a/packages/request-client.js/test/index.test.ts +++ b/packages/request-client.js/test/index.test.ts @@ -858,9 +858,10 @@ describe('request-client.js', () => { await request.waitForConfirmation(); - const requestsFromTopic = await requestNetwork.fromTopic('my amazing test topic'); - expect(requestsFromTopic).not.toHaveLength(0); - const requestData = requestsFromTopic[0].getData(); + const response = await requestNetwork.fromTopic('my amazing test topic'); + const requests = Array.isArray(response) ? response : response.requests; + expect(requests).not.toHaveLength(0); + const requestData = requests[0].getData(); expect(requestData).toMatchObject(request.getData()); expect(requestData.meta).not.toBeNull(); @@ -898,15 +899,16 @@ describe('request-client.js', () => { ); await request2.waitForConfirmation(); - const requestsFromTopic = await requestNetwork.fromMultipleTopics([ + const response = await requestNetwork.fromMultipleTopics([ 'my amazing test topic', 'my second best test topic', ]); - expect(requestsFromTopic).toHaveLength(2); - expect(requestsFromTopic[0].getData()).toMatchObject(request.getData()); - expect(requestsFromTopic[1].getData()).toMatchObject(request2.getData()); + const requests = Array.isArray(response) ? response : response.requests; + expect(requests).toHaveLength(2); + expect(requests[0].getData()).toMatchObject(request.getData()); + expect(requests[1].getData()).toMatchObject(request2.getData()); - requestsFromTopic.forEach((req) => { + requests.forEach((req) => { const requestData = req.getData(); expect(requestData.meta).not.toBeNull(); expect(requestData.meta!.transactionManagerMeta.encryptionMethod).toBe('ecies-aes256-gcm'); @@ -930,9 +932,10 @@ describe('request-client.js', () => { ); await request.waitForConfirmation(); - const requestFromIdentity = await requestNetwork.fromIdentity(TestData.payee.identity); - expect(requestFromIdentity).not.toBe(''); - const requestData = requestFromIdentity[0].getData(); + const response = await requestNetwork.fromIdentity(TestData.payee.identity); + const requests = Array.isArray(response) ? response : response.requests; + expect(requests).not.toBe(''); + const requestData = requests[0].getData(); expect(requestData).toMatchObject(request.getData()); expect(requestData.meta).not.toBeNull(); diff --git a/packages/request-logic/src/request-logic.ts b/packages/request-logic/src/request-logic.ts index e25b0456d..22adb24e1 100644 --- a/packages/request-logic/src/request-logic.ts +++ b/packages/request-logic/src/request-logic.ts @@ -626,6 +626,7 @@ export default class RequestLogic implements RequestLogicTypes.IRequestLogic { pending, request: confirmedRequestState, transactionManagerMeta: transactionManagerMeta[channelId], + pagination: transactionManagerMeta.pagination, }; }, ); @@ -640,7 +641,6 @@ export default class RequestLogic implements RequestLogicTypes.IRequestLogic { pending: requestAndMeta.pending, request: requestAndMeta.request, }); - // workaround to quiet the error "finalResult.meta.ignoredTransactions can be undefined" (but defined in the initialization value of the accumulator) (finalResult.meta.ignoredTransactions || []).push(requestAndMeta.ignoredTransactions); @@ -648,6 +648,12 @@ export default class RequestLogic implements RequestLogicTypes.IRequestLogic { (finalResult.meta.transactionManagerMeta || []).push( requestAndMeta.transactionManagerMeta, ); + + // add the pagination + finalResult.meta.pagination = { + ...finalResult.meta.pagination, + ...requestAndMeta.pagination, + }; } return finalResult; diff --git a/packages/request-node/src/request/getChannelsByTopic.ts b/packages/request-node/src/request/getChannelsByTopic.ts index 44dbb8c61..30973f21d 100644 --- a/packages/request-node/src/request/getChannelsByTopic.ts +++ b/packages/request-node/src/request/getChannelsByTopic.ts @@ -15,29 +15,19 @@ export default class GetChannelHandler { * @param dataAccess data access layer */ async handler(clientRequest: Request, serverResponse: Response): Promise { - // Retrieves data access layer - let transactions; + const { updatedBetween, topic } = clientRequest.query; - const { updatedBetween, topic, page, pageSize } = clientRequest.query; - // Verifies if data sent from get request are correct - // clientRequest.query is expected to contain the topic of the transactions to search for if (!topic || typeof topic !== 'string') { serverResponse.status(StatusCodes.UNPROCESSABLE_ENTITY).send('Incorrect data'); return; } - const formattedPage = page && typeof page === 'string' ? Number(page) : undefined; - const formattedPageSize = - pageSize && typeof pageSize === 'string' ? Number(pageSize) : undefined; - try { - transactions = await this.dataAccess.getChannelsByTopic( + const transactions = await this.dataAccess.getChannelsByTopic( topic, updatedBetween && typeof updatedBetween === 'string' ? JSON.parse(updatedBetween) : undefined, - formattedPage, - formattedPageSize, ); serverResponse.status(StatusCodes.OK).send(transactions); } catch (e) { diff --git a/packages/thegraph-data-access/src/queries.ts b/packages/thegraph-data-access/src/queries.ts index 3776f9eb8..d17061c37 100644 --- a/packages/thegraph-data-access/src/queries.ts +++ b/packages/thegraph-data-access/src/queries.ts @@ -77,7 +77,7 @@ export const GetTransactionsByHashQuery = gql` export const GetTransactionsByTopics = gql` ${TransactionsBodyFragment} -query GetTransactionsByTopics($topics: [String!]!, $first: Int!, $skip: Int!) { +query GetTransactionsByTopics($topics: [String!]!) { ${metaQueryBody} channels( where: { topics_contains: $topics } @@ -85,8 +85,6 @@ query GetTransactionsByTopics($topics: [String!]!, $first: Int!, $skip: Int!) { transactions( orderBy: blockTimestamp, orderDirection: asc - first: $first - skip: $skip ) { ...TransactionsBody } diff --git a/packages/thegraph-data-access/src/subgraph-client.ts b/packages/thegraph-data-access/src/subgraph-client.ts index 2bab69b86..d196b6603 100644 --- a/packages/thegraph-data-access/src/subgraph-client.ts +++ b/packages/thegraph-data-access/src/subgraph-client.ts @@ -54,29 +54,11 @@ export class SubgraphClient implements StorageTypes.IIndexer { public async getTransactionsByTopics( topics: string[], - page?: number, - pageSize?: number, ): Promise { - if (page !== undefined && page < 1) { - throw new Error('Page must be greater than or equal to 1'); - } - if (pageSize !== undefined && pageSize <= 0) { - throw new Error('Page size must be greater than 0'); - } - if (pageSize && pageSize > this.MAX_PAGE_SIZE) { - throw new Error(`Page size cannot exceed ${this.MAX_PAGE_SIZE}`); - } - - const effectivePageSize = pageSize ?? this.DEFAULT_PAGE_SIZE; - const effectivePage = page ?? 1; - const skip = (effectivePage - 1) * effectivePageSize; - const { _meta, channels } = await this.graphql.request< Meta & { channels: { transactions: Transaction[] }[] } >(GetTransactionsByTopics, { topics, - first: effectivePageSize, - skip, }); const transactionsByChannel = channels @@ -85,15 +67,10 @@ export class SubgraphClient implements StorageTypes.IIndexer { .sort((a, b) => a.blockTimestamp - b.blockTimestamp); const indexedTransactions = transactionsByChannel.map(this.toIndexedTransaction); + return { transactions: indexedTransactions, blockNumber: _meta.block.number, - pagination: { - page: effectivePage, - pageSize: effectivePageSize, - total: indexedTransactions.length, - hasMore: skip + effectivePageSize < indexedTransactions.length, - }, }; } diff --git a/packages/transaction-manager/src/transaction-manager.ts b/packages/transaction-manager/src/transaction-manager.ts index 6436d1256..d2b5a5885 100644 --- a/packages/transaction-manager/src/transaction-manager.ts +++ b/packages/transaction-manager/src/transaction-manager.ts @@ -4,9 +4,11 @@ import { DataAccessTypes, DecryptionProviderTypes, EncryptionTypes, + StorageTypes, TransactionTypes, } from '@requestnetwork/types'; import { normalizeKeccak256Hash } from '@requestnetwork/utils'; +import { validatePaginationParams } from '@requestnetwork/utils'; import { EventEmitter } from 'events'; @@ -195,14 +197,9 @@ export default class TransactionManager implements TransactionTypes.ITransaction page?: number, pageSize?: number, ): Promise { - const resultGetTx = await this.dataAccess.getChannelsByTopic( - topic, - updatedBetween, - page, - pageSize, - ); - - return this.parseMultipleChannels(resultGetTx); + validatePaginationParams(page, pageSize); + const resultGetTx = await this.dataAccess.getChannelsByTopic(topic, updatedBetween); + return this.parseMultipleChannels(resultGetTx, page, pageSize, updatedBetween); } /** @@ -218,14 +215,9 @@ export default class TransactionManager implements TransactionTypes.ITransaction page?: number, pageSize?: number, ): Promise { - const resultGetTx = await this.dataAccess.getChannelsByMultipleTopics( - topics, - updatedBetween, - page, - pageSize, - ); - - return this.parseMultipleChannels(resultGetTx); + validatePaginationParams(page, pageSize); + const resultGetTx = await this.dataAccess.getChannelsByMultipleTopics(topics, updatedBetween); + return this.parseMultipleChannels(resultGetTx, page, pageSize, updatedBetween); } /** @@ -236,31 +228,149 @@ export default class TransactionManager implements TransactionTypes.ITransaction */ private async parseMultipleChannels( resultGetTx: DataAccessTypes.IReturnGetChannelsByTopic, + page?: number, + pageSize?: number, + updatedBetween?: TransactionTypes.ITimestampBoundaries, ): Promise { - // Get the channels from the data-access layers to decrypt and clean them one by one - const result = await Object.keys(resultGetTx.result.transactions).reduce( - async (accumulatorPromise, channelId) => { - const cleaned = await this.channelParser.decryptAndCleanChannel( - channelId, - resultGetTx.result.transactions[channelId], - ); - - // await for the accumulator promise at the end to parallelize the calls to decryptAndCleanChannel() - const accumulator: any = await accumulatorPromise; + // Get all channel IDs and their latest timestamps + const channelsWithTimestamps = Object.entries(resultGetTx.result.transactions).map( + ([channelId, transactions]) => { + // Get all timestamps for the channel + const timestamps = transactions.map((tx) => tx.timestamp || 0); + const latestTimestamp = Math.max(...timestamps, 0); + + // A channel should be included if ANY of its transactions are after 'from' + // and the channel has activity before 'to' + let hasValidTransactions = true; + if (updatedBetween) { + const hasTransactionsAfterFrom = + !updatedBetween?.from || timestamps.some((t) => t >= updatedBetween.from!); + const hasTransactionsBeforeTo = + !updatedBetween?.to || timestamps.some((t) => t <= updatedBetween.to!); + hasValidTransactions = hasTransactionsAfterFrom && hasTransactionsBeforeTo; + } - accumulator.transactions[channelId] = cleaned.transactions; - accumulator.ignoredTransactions[channelId] = cleaned.ignoredTransactions; - return accumulator; + // Include all transactions for valid channels + // This ensures we have the complete state + return { + channelId, + latestTimestamp, + hasValidTransactions, + filteredTransactions: hasValidTransactions ? transactions : [], + }; }, - Promise.resolve({ transactions: {}, ignoredTransactions: {} }), ); + // Only include channels that have valid transactions + const allChannelIds = channelsWithTimestamps + .filter((channel) => channel.hasValidTransactions) + .sort((a, b) => b.latestTimestamp - a.latestTimestamp) + .map((channel) => channel.channelId); + + // Process all channels and collect valid ones + const processedChannels = await Promise.all( + allChannelIds.map(async (channelId) => { + try { + const channel = channelsWithTimestamps.find((c) => c.channelId === channelId); + if (!channel) return null; + + const cleaned = await this.channelParser.decryptAndCleanChannel( + channelId, + channel.filteredTransactions, + ); + + return { + channelId, + isValid: this.isValidChannel(cleaned), + data: cleaned, + }; + } catch (error) { + console.warn(`Failed to decrypt channel ${channelId}:`, error); + return { + channelId, + isValid: false, + data: { transactions: [], ignoredTransactions: [] }, + }; + } + }), + ); + + // Filter out null values and get valid channels + const validChannels = processedChannels + .filter( + (channel): channel is NonNullable => + channel !== null && (page !== undefined ? channel.isValid : true), + ) + .map((channel) => channel.channelId); + + // Apply pagination if required + const channelsToInclude = + page !== undefined && pageSize !== undefined + ? validChannels.slice((page - 1) * pageSize, page * pageSize) + : validChannels; + + // Populate result object + const result = { + transactions: {} as Record, + ignoredTransactions: {} as Record, + }; + + // Add data for included channels + channelsToInclude.forEach((channelId) => { + const channelData = processedChannels.find((c) => c?.channelId === channelId)?.data; + if (channelData) { + result.transactions[channelId] = channelData.transactions; + result.ignoredTransactions[channelId] = channelData.ignoredTransactions; + } + }); + + // Create pagination metadata if required + const paginationMeta = + page !== undefined && pageSize !== undefined + ? { + total: validChannels.length, + page, + pageSize, + hasMore: page * pageSize < validChannels.length, + } + : undefined; + + // Create the final meta object + const successfulChannelIds = Object.keys(result.transactions); + const paginatedMeta = { + ...resultGetTx.meta, + storageMeta: Object.keys(resultGetTx.meta.storageMeta || {}) + .filter((key) => successfulChannelIds.includes(key)) + .reduce((acc, key) => { + acc[key] = resultGetTx.meta.storageMeta?.[key] || []; + return acc; + }, {} as Record), + transactionsStorageLocation: Object.keys(resultGetTx.meta.transactionsStorageLocation) + .filter((key) => successfulChannelIds.includes(key)) + .reduce((acc, key) => { + acc[key] = resultGetTx.meta.transactionsStorageLocation[key]; + return acc; + }, {} as Record), + pagination: paginationMeta, + }; + return { meta: { - dataAccessMeta: resultGetTx.meta, + dataAccessMeta: paginatedMeta, ignoredTransactions: result.ignoredTransactions, }, - result: { transactions: result.transactions }, + result: { + transactions: result.transactions, + }, }; } + + private isValidChannel(cleaned: { + transactions: (TransactionTypes.ITimestampedTransaction | null)[]; + ignoredTransactions: (TransactionTypes.IIgnoredTransaction | null)[]; + }): boolean { + return ( + cleaned.transactions && cleaned.transactions.length > 0 && cleaned.transactions[0] !== null + ); + } } diff --git a/packages/transaction-manager/test/index.test.ts b/packages/transaction-manager/test/index.test.ts index da8456aff..31550caf3 100644 --- a/packages/transaction-manager/test/index.test.ts +++ b/packages/transaction-manager/test/index.test.ts @@ -46,7 +46,11 @@ const fakeMetaDataAccessGetReturn: DataAccessTypes.IReturnGetTransactions = { }; const fakeMetaDataAccessGetChannelsReturn: DataAccessTypes.IReturnGetChannelsByTopic = { - meta: { transactionsStorageLocation: { [channelId]: ['fakeDataId1', 'fakeDataId2'] } }, + meta: { + pagination: undefined, + storageMeta: {}, + transactionsStorageLocation: { [channelId]: ['fakeDataId1', 'fakeDataId2'] }, + }, result: { transactions: { [channelId]: [tx, tx2] } }, }; let fakeDataAccess: DataAccessTypes.IDataAccess; @@ -963,13 +967,11 @@ describe('index', () => { skipPersistence: jest.fn().mockReturnValue(true), }; - const transactionManager = new TransactionManager( - fakeDataAccess, - TestData.fakeDecryptionProvider, - ); + const transactionManager = new TransactionManager(fakeDataAccess); const ret = await transactionManager.getTransactionsByChannelId(channelId); - // 'return is wrong' + // Update the expected result to NOT expect encryptionMethod in meta + // since the first transaction is clear expect(ret).toEqual({ meta: { dataAccessMeta: { @@ -1132,20 +1134,13 @@ describe('index', () => { // 'ret.result is wrong' expect(ret.result).toEqual(fakeMetaDataAccessGetChannelsReturn.result); // 'ret.meta is wrong' - expect(ret.meta).toEqual( - expect.objectContaining({ - dataAccessMeta: fakeMetaDataAccessGetChannelsReturn.meta, - ignoredTransactions: { - '01a98f126de3fab2b5130af5161998bf6e59b2c380deafeff938ff3f798281bf23': [null, null], - }, - }), - ); - expect(fakeDataAccess.getChannelsByTopic).toHaveBeenCalledWith( - extraTopics[0], - undefined, - undefined, - undefined, - ); + expect(ret.meta).toEqual({ + dataAccessMeta: fakeMetaDataAccessGetChannelsReturn.meta, + ignoredTransactions: { + '01a98f126de3fab2b5130af5161998bf6e59b2c380deafeff938ff3f798281bf23': [null, null], + }, + }); + expect(fakeDataAccess.getChannelsByTopic).toHaveBeenCalledWith(extraTopics[0], undefined); }); it('can get an encrypted channel indexed by topic', async () => { @@ -1158,6 +1153,8 @@ describe('index', () => { const fakeMetaDataAccessGetReturnWithEncryptedTransaction: DataAccessTypes.IReturnGetChannelsByTopic = { meta: { + pagination: undefined, + storageMeta: {}, transactionsStorageLocation: { [channelId]: ['fakeDataId1'], }, @@ -1201,20 +1198,19 @@ describe('index', () => { }, }); // 'ret.meta is wrong' - expect(ret.meta).toEqual( - expect.objectContaining({ - dataAccessMeta: fakeMetaDataAccessGetReturnWithEncryptedTransaction.meta, - ignoredTransactions: { - [channelId]: [null], + expect(ret.meta).toEqual({ + dataAccessMeta: { + pagination: undefined, + storageMeta: {}, + transactionsStorageLocation: { + [channelId]: ['fakeDataId1'], }, - }), - ); - expect(fakeDataAccess.getChannelsByTopic).toHaveBeenCalledWith( - extraTopics[0], - undefined, - undefined, - undefined, - ); + }, + ignoredTransactions: { + [channelId]: [null], + }, + }); + expect(fakeDataAccess.getChannelsByTopic).toHaveBeenCalledWith(extraTopics[0], undefined); }, 15000); it('cannot get an encrypted channel indexed by topic without decryptionProvider', async () => { @@ -1227,6 +1223,8 @@ describe('index', () => { const fakeMetaDataAccessGetReturnWithEncryptedTransaction: DataAccessTypes.IReturnGetChannelsByTopic = { meta: { + pagination: undefined, + storageMeta: {}, transactionsStorageLocation: { [channelId]: ['fakeDataId1'], }, @@ -1260,39 +1258,38 @@ describe('index', () => { const ret = await transactionManager.getChannelsByTopic(extraTopics[0]); - // 'ret.result is wrong' expect(ret.result).toEqual({ transactions: { [channelId]: [null], }, }); - // 'ret.meta is wrong' - expect(ret.meta).toEqual( - expect.objectContaining({ - dataAccessMeta: fakeMetaDataAccessGetReturnWithEncryptedTransaction.meta, - ignoredTransactions: { - [channelId]: [ - { - reason: 'No decryption or cipher provider given', - transaction: { - state: TransactionTypes.TransactionState.PENDING, - timestamp: 1, - transaction: encryptedTx, - }, - }, - ], + + expect(ret.meta).toEqual({ + dataAccessMeta: { + pagination: undefined, + storageMeta: {}, + transactionsStorageLocation: { + [channelId]: ['fakeDataId1'], }, - }), - ); - expect(fakeDataAccess.getChannelsByTopic).toHaveBeenCalledWith( - extraTopics[0], - undefined, - undefined, - undefined, - ); + }, + ignoredTransactions: { + [channelId]: [ + { + reason: 'No decryption or cipher provider given', + transaction: { + state: TransactionTypes.TransactionState.PENDING, + timestamp: 1, + transaction: encryptedTx, + }, + }, + ], + }, + }); + + expect(fakeDataAccess.getChannelsByTopic).toHaveBeenCalledWith(extraTopics[0], undefined); }, 15000); - it('can get an clear channel indexed by topic without decryptionProvider even if an encrypted transaction happen first', async () => { + it('can get a clear channel indexed by topic without decryptionProvider even if an encrypted transaction happen first', async () => { const encryptedTx = await TransactionsFactory.createEncryptedTransactionInNewChannel(data, [ TestData.idRaw1.encryptionParams, TestData.idRaw2.encryptionParams, @@ -1340,7 +1337,6 @@ describe('index', () => { const ret = await transactionManager.getChannelsByTopic(extraTopics[0]); - // 'ret.result is wrong' expect(ret.result).toEqual({ transactions: { [channelId]: [ @@ -1353,31 +1349,30 @@ describe('index', () => { ], }, }); - // 'ret.meta is wrong' - expect(ret.meta).toEqual( - expect.objectContaining({ - dataAccessMeta: fakeMetaDataAccessGetReturnWithEncryptedTransaction.meta, - ignoredTransactions: { - [channelId]: [ - { - reason: 'No decryption or cipher provider given', - transaction: { - state: TransactionTypes.TransactionState.PENDING, - timestamp: 1, - transaction: encryptedTx, - }, - }, - null, - ], + + expect(ret.meta).toEqual({ + dataAccessMeta: { + pagination: undefined, + storageMeta: {}, + transactionsStorageLocation: { + [channelId]: ['fakeDataId1', 'fakeDataId2'], }, - }), - ); - expect(fakeDataAccess.getChannelsByTopic).toHaveBeenCalledWith( - extraTopics[0], - undefined, - undefined, - undefined, - ); + }, + ignoredTransactions: { + [channelId]: [ + { + reason: 'No decryption or cipher provider given', + transaction: { + state: TransactionTypes.TransactionState.PENDING, + timestamp: 1, + transaction: encryptedTx, + }, + }, + null, + ], + }, + }); + expect(fakeDataAccess.getChannelsByTopic).toHaveBeenCalledWith(extraTopics[0], undefined); }, 15000); it('can get channels indexed by topics with channelId not matching the first transaction hash', async () => { @@ -1415,28 +1410,27 @@ describe('index', () => { transactions: { [channelId]: [null, tx, tx2] }, }); // 'ret.meta is wrong' - expect(ret.meta).toEqual( - expect.objectContaining({ - dataAccessMeta: fakeMetaDataAccessGetReturnFirstHashWrong.meta, - ignoredTransactions: { - [channelId]: [ - { - reason: - 'as first transaction, the hash of the transaction do not match the channelId', - transaction: txWrongHash, - }, - null, - null, - ], + expect(ret.meta).toEqual({ + dataAccessMeta: { + pagination: undefined, + storageMeta: {}, + transactionsStorageLocation: { + [channelId]: ['fakeDataId1', 'fakeDataId1', 'fakeDataId2'], }, - }), - ); - expect(fakeDataAccess.getChannelsByTopic).toHaveBeenCalledWith( - extraTopics[0], - undefined, - undefined, - undefined, - ); + }, + ignoredTransactions: { + [channelId]: [ + { + reason: + 'as first transaction, the hash of the transaction do not match the channelId', + transaction: txWrongHash, + }, + null, + null, + ], + }, + }); + expect(fakeDataAccess.getChannelsByTopic).toHaveBeenCalledWith(extraTopics[0], undefined); }); it('can get channels encrypted and clear', async () => { @@ -1449,6 +1443,8 @@ describe('index', () => { const fakeMetaDataAccessGetReturnWithEncryptedTransaction: DataAccessTypes.IReturnGetChannelsByTopic = { meta: { + pagination: undefined, + storageMeta: {}, transactionsStorageLocation: { [channelId]: ['fakeDataId1'], [channelId2]: ['fakeDataId2'], @@ -1500,22 +1496,23 @@ describe('index', () => { [channelId2]: [tx2], }, }); - // 'ret.meta is wrong' - expect(ret.meta).toEqual( - expect.objectContaining({ - dataAccessMeta: fakeMetaDataAccessGetReturnWithEncryptedTransaction.meta, - ignoredTransactions: { - [channelId]: [null], - [channelId2]: [null], + + expect(ret.meta).toEqual({ + dataAccessMeta: { + pagination: undefined, + storageMeta: {}, + transactionsStorageLocation: { + [channelId]: ['fakeDataId1'], + [channelId2]: ['fakeDataId2'], }, - }), - ); - expect(fakeDataAccess.getChannelsByTopic).toHaveBeenCalledWith( - extraTopics[0], - undefined, - undefined, - undefined, - ); + }, + ignoredTransactions: { + [channelId]: [null], + [channelId2]: [null], + }, + }); + + expect(fakeDataAccess.getChannelsByTopic).toHaveBeenCalledWith(extraTopics[0], undefined); }); }); @@ -1528,32 +1525,45 @@ describe('index', () => { // 'ret.result is wrong' expect(ret.result).toEqual(fakeMetaDataAccessGetChannelsReturn.result); // 'ret.meta is wrong' - expect(ret.meta).toEqual( - expect.objectContaining({ - dataAccessMeta: fakeMetaDataAccessGetChannelsReturn.meta, - ignoredTransactions: { - '01a98f126de3fab2b5130af5161998bf6e59b2c380deafeff938ff3f798281bf23': [null, null], - }, - }), - ); + expect(ret.meta).toEqual({ + dataAccessMeta: fakeMetaDataAccessGetChannelsReturn.meta, + ignoredTransactions: { + '01a98f126de3fab2b5130af5161998bf6e59b2c380deafeff938ff3f798281bf23': [null, null], + }, + }); // eslint-disable-next-line @typescript-eslint/unbound-method expect(fakeDataAccess.getChannelsByMultipleTopics).toHaveBeenCalledWith( [extraTopics[0]], undefined, - undefined, - undefined, ); }); it('should return paginated results when querying multiple topics', async () => { const fakeMetaDataAccessGetChannelsReturn: DataAccessTypes.IReturnGetChannelsByTopic = { meta: { + pagination: { page: 1, pageSize: 2 }, transactionsStorageLocation: { [channelId]: ['fakeDataId1', 'fakeDataId2'], [channelId2]: ['fakeDataId12', 'fakeDataId22'], }, }, - result: { transactions: { [channelId]: [tx, tx2], [channelId2]: [tx, tx2] } }, + result: { + transactions: { + [channelId]: [tx, tx2], + [channelId2]: [ + { + state: TransactionTypes.TransactionState.PENDING, + timestamp: 1, + transaction: { data: data2 }, + }, + { + state: TransactionTypes.TransactionState.PENDING, + timestamp: 2, + transaction: { data }, + }, + ], + }, + }, }; fakeDataAccess.getChannelsByMultipleTopics = jest @@ -1564,17 +1574,304 @@ describe('index', () => { const result = await transactionManager.getChannelsByMultipleTopics( [extraTopics[0], extraTopics[1]], undefined, - 1, // page - 2, // pageSize ); + // Verify both channels are present expect(Object.keys(result.result.transactions)).toHaveLength(2); + expect(result.result.transactions).toHaveProperty(channelId); + expect(result.result.transactions).toHaveProperty(channelId2); + expect(fakeDataAccess.getChannelsByMultipleTopics).toHaveBeenCalledWith( [extraTopics[0], extraTopics[1]], undefined, + ); + }); + }); + + describe('getChannelsByTopic with pagination', () => { + it('should return paginated results when page and pageSize are provided', async () => { + // Use the existing data variables and their corresponding channelIds + const channels = { + [channelId]: [ + { + state: TransactionTypes.TransactionState.PENDING, + timestamp: 4, + transaction: { data }, + }, + ], + [channelId2]: [ + { + state: TransactionTypes.TransactionState.PENDING, + timestamp: 3, + transaction: { data: data2 }, + }, + ], + // Create a third channel with data + [MultiFormat.serialize(normalizeKeccak256Hash(JSON.parse('{"third":"tx"}')))]: [ + { + state: TransactionTypes.TransactionState.PENDING, + timestamp: 1, + transaction: { data: '{"third":"tx"}' }, + }, + ], + }; + + const fakeMetaDataAccessGetChannelsReturnMultiple: DataAccessTypes.IReturnGetChannelsByTopic = + { + meta: { + storageMeta: {}, + transactionsStorageLocation: { + [channelId]: ['fakeDataId1'], + [channelId2]: ['fakeDataId2'], + [MultiFormat.serialize(normalizeKeccak256Hash(JSON.parse('{"third":"tx"}')))]: [ + 'fakeDataId3', + ], + }, + }, + result: { transactions: channels }, + }; + + fakeDataAccess = { + ...fakeDataAccess, + getChannelsByTopic: jest.fn().mockReturnValue(fakeMetaDataAccessGetChannelsReturnMultiple), + }; + + const transactionManager = new TransactionManager(fakeDataAccess); + + // Test first page - should get the two most recent channels + const firstPage = await transactionManager.getChannelsByTopic( + extraTopics[0], + undefined, 1, 2, ); + + expect(Object.keys(firstPage.result.transactions)).toHaveLength(2); + expect(firstPage.result.transactions).toHaveProperty(channelId); + expect(firstPage.result.transactions).toHaveProperty(channelId2); + expect(firstPage.meta.dataAccessMeta.pagination).toEqual({ + total: 3, + page: 1, + pageSize: 2, + hasMore: true, + }); + + // Test second page - should get the remaining channel + const secondPage = await transactionManager.getChannelsByTopic( + extraTopics[0], + undefined, + 2, + 2, + ); + + expect(Object.keys(secondPage.result.transactions)).toHaveLength(1); + expect(secondPage.result.transactions).toHaveProperty( + MultiFormat.serialize(normalizeKeccak256Hash(JSON.parse('{"third":"tx"}'))), + ); + expect(secondPage.meta.dataAccessMeta.pagination).toEqual({ + total: 3, + page: 2, + pageSize: 2, + hasMore: false, + }); + }); + + it('should handle empty results with pagination', async () => { + const emptyChannels = {}; + const fakeMetaDataAccessGetChannelsReturnEmpty: DataAccessTypes.IReturnGetChannelsByTopic = { + meta: { + transactionsStorageLocation: {}, + }, + result: { transactions: emptyChannels }, + }; + + fakeDataAccess = { + ...fakeDataAccess, + getChannelsByTopic: jest.fn().mockReturnValue(fakeMetaDataAccessGetChannelsReturnEmpty), + }; + + const transactionManager = new TransactionManager(fakeDataAccess); + const result = await transactionManager.getChannelsByTopic(extraTopics[0], undefined, 1, 10); + + expect(Object.keys(result.result.transactions)).toHaveLength(0); + expect(result.meta.dataAccessMeta.pagination).toEqual({ + total: 0, + page: 1, + pageSize: 10, + hasMore: false, + }); + }); + + it('should return all results when pagination params are not provided', async () => { + const channels = { + [channelId]: [tx, tx2], + [channelId2]: [ + { + state: TransactionTypes.TransactionState.PENDING, + timestamp: 3, + transaction: { data: '{"third": "tx"}' }, + }, + ], + }; + + const fakeMetaDataAccessGetChannelsReturnMultiple: DataAccessTypes.IReturnGetChannelsByTopic = + { + meta: { + transactionsStorageLocation: { + [channelId]: ['fakeDataId1', 'fakeDataId2'], + [channelId2]: ['fakeDataId3'], + }, + }, + result: { transactions: channels }, + }; + + fakeDataAccess = { + ...fakeDataAccess, + getChannelsByTopic: jest.fn().mockReturnValue(fakeMetaDataAccessGetChannelsReturnMultiple), + }; + + const transactionManager = new TransactionManager(fakeDataAccess); + const result = await transactionManager.getChannelsByTopic(extraTopics[0]); + + // Should return all channels without pagination + expect(Object.keys(result.result.transactions)).toHaveLength(2); + expect(result.meta.dataAccessMeta.pagination).toBeUndefined(); + }); + + it('should handle timestamp boundaries with pagination', async () => { + const channels = { + [channelId]: [ + { + state: TransactionTypes.TransactionState.PENDING, + timestamp: 2000, + transaction: { data }, + }, + { + state: TransactionTypes.TransactionState.PENDING, + timestamp: 2500, + transaction: { data }, + }, + ], + [channelId2]: [ + { + state: TransactionTypes.TransactionState.PENDING, + timestamp: 3000, + transaction: { data: data2 }, + }, + ], + }; + + const fakeMetaDataAccessGetChannelsReturnMultiple: DataAccessTypes.IReturnGetChannelsByTopic = + { + meta: { + storageMeta: {}, + transactionsStorageLocation: { + [channelId]: ['fakeDataId1', 'fakeDataId2'], + [channelId2]: ['fakeDataId3'], + }, + }, + result: { transactions: channels }, + }; + + fakeDataAccess = { + ...fakeDataAccess, + getChannelsByTopic: jest.fn().mockReturnValue(fakeMetaDataAccessGetChannelsReturnMultiple), + }; + + const transactionManager = new TransactionManager(fakeDataAccess); + + // Test with timestamp boundaries + const result = await transactionManager.getChannelsByTopic( + extraTopics[0], + { from: 1500, to: 3500 }, + 1, + 2, + ); + + // Should only include channels with transactions in the time range + expect(Object.keys(result.result.transactions)).toHaveLength(2); + expect(result.result.transactions).toHaveProperty(channelId); + expect(result.result.transactions).toHaveProperty(channelId2); + expect(result.meta.dataAccessMeta.pagination).toEqual({ + total: 2, + page: 1, + pageSize: 2, + hasMore: false, + }); + }); + }); + + describe('pagination validation', () => { + it('should throw error for invalid page number in getChannelsByTopic', async () => { + const transactionManager = new TransactionManager(fakeDataAccess); + + await expect( + transactionManager.getChannelsByTopic(extraTopics[0], undefined, 0, 10), + ).rejects.toThrow('Page number must be greater than or equal to 1 but it is 0'); + + await expect( + transactionManager.getChannelsByTopic(extraTopics[0], undefined, -1, 10), + ).rejects.toThrow('Page number must be greater than or equal to 1 but it is -1'); + }); + + it('should throw error for invalid pageSize in getChannelsByTopic', async () => { + const transactionManager = new TransactionManager(fakeDataAccess); + + await expect( + transactionManager.getChannelsByTopic(extraTopics[0], undefined, 1, 0), + ).rejects.toThrow('Page size must be positive but it is 0'); + + await expect( + transactionManager.getChannelsByTopic(extraTopics[0], undefined, 1, -1), + ).rejects.toThrow('Page size must be positive but it is -1'); + }); + + it('should throw error for invalid page number in getChannelsByMultipleTopics', async () => { + const transactionManager = new TransactionManager(fakeDataAccess); + + await expect( + transactionManager.getChannelsByMultipleTopics([extraTopics[0]], undefined, 0, 10), + ).rejects.toThrow('Page number must be greater than or equal to 1 but it is 0'); + + await expect( + transactionManager.getChannelsByMultipleTopics([extraTopics[0]], undefined, -1, 10), + ).rejects.toThrow('Page number must be greater than or equal to 1 but it is -1'); + }); + + it('should throw error for invalid pageSize in getChannelsByMultipleTopics', async () => { + const transactionManager = new TransactionManager(fakeDataAccess); + + await expect( + transactionManager.getChannelsByMultipleTopics([extraTopics[0]], undefined, 1, 0), + ).rejects.toThrow('Page size must be positive but it is 0'); + + await expect( + transactionManager.getChannelsByMultipleTopics([extraTopics[0]], undefined, 1, -1), + ).rejects.toThrow('Page size must be positive but it is -1'); + }); + + it('should throw error if only one pagination parameter is provided in getChannelsByTopic', async () => { + const transactionManager = new TransactionManager(fakeDataAccess); + + await expect( + transactionManager.getChannelsByTopic(extraTopics[0], undefined, 1, undefined), + ).rejects.toThrow('Both page and pageSize must be provided for pagination'); + + await expect( + transactionManager.getChannelsByTopic(extraTopics[0], undefined, undefined, 10), + ).rejects.toThrow('Both page and pageSize must be provided for pagination'); + }); + + it('should throw error if only one pagination parameter is provided in getChannelsByMultipleTopics', async () => { + const transactionManager = new TransactionManager(fakeDataAccess); + + await expect( + transactionManager.getChannelsByMultipleTopics([extraTopics[0]], undefined, 1, undefined), + ).rejects.toThrow('Both page and pageSize must be provided for pagination'); + + await expect( + transactionManager.getChannelsByMultipleTopics([extraTopics[0]], undefined, undefined, 10), + ).rejects.toThrow('Both page and pageSize must be provided for pagination'); }); }); }); diff --git a/packages/types/src/data-access-types.ts b/packages/types/src/data-access-types.ts index 0974ed5dd..155ba3d42 100644 --- a/packages/types/src/data-access-types.ts +++ b/packages/types/src/data-access-types.ts @@ -13,14 +13,10 @@ export interface IDataRead { getChannelsByTopic: ( topic: string, updatedBetween?: ITimestampBoundaries, - page?: number, - pageSize?: number, ) => Promise; getChannelsByMultipleTopics( topics: string[], updatedBetween?: ITimestampBoundaries, - page?: number, - pageSize?: number, ): Promise; } @@ -83,7 +79,6 @@ export interface IReturnGetTransactions { transactionsStorageLocation: string[]; /** meta-data from the layer below */ storageMeta?: StorageTypes.IEntryMetadata[]; - pagination?: StorageTypes.PaginationMetadata; }; /** result of the execution */ result: { transactions: ITimestampedTransaction[] }; @@ -97,9 +92,9 @@ export interface IReturnGetChannelsByTopic { transactionsStorageLocation: { [key: string]: string[]; }; + pagination?: any; /** meta-data from the layer below */ storageMeta?: Record; - pagination?: StorageTypes.PaginationMetadata; }; /** result of the execution: the transactions grouped by channel id */ result: { transactions: ITransactionsByChannelIds }; diff --git a/packages/types/src/request-logic-types.ts b/packages/types/src/request-logic-types.ts index 7550e3a60..82f5240dd 100644 --- a/packages/types/src/request-logic-types.ts +++ b/packages/types/src/request-logic-types.ts @@ -98,6 +98,7 @@ export interface IRequestLogicReturnWithConfirmation extends EventEmitter { export interface IReturnMeta { transactionManagerMeta: any; ignoredTransactions?: any[]; + pagination?: any; } /** return of the function createRequest */ diff --git a/packages/types/src/storage-types.ts b/packages/types/src/storage-types.ts index 3d0ae4ac0..fa7280b27 100644 --- a/packages/types/src/storage-types.ts +++ b/packages/types/src/storage-types.ts @@ -60,11 +60,7 @@ export interface IIndexer { channel: string, updatedBetween?: ITimestampBoundaries, ): Promise; - getTransactionsByTopics( - topics: string[], - page?: number, - pageSize?: number, - ): Promise; + getTransactionsByTopics(topics: string[]): Promise; } export type IIpfsConfig = { diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts index 264d78052..28a9d6c4f 100644 --- a/packages/utils/src/utils.ts +++ b/packages/utils/src/utils.ts @@ -169,10 +169,24 @@ function notNull(x: T | null | undefined): x is T { * @param pageSize */ function validatePaginationParams(page?: number, pageSize?: number): void { - if (page !== undefined && page < 1) { + // If either parameter is defined, both must be defined + if ( + (page !== undefined && pageSize === undefined) || + (page === undefined && pageSize !== undefined) + ) { + throw new Error('Both page and pageSize must be provided for pagination'); + } + + // If both are undefined, that's valid (no pagination) + if (page === undefined && pageSize === undefined) { + return; + } + + // At this point, both parameters are defined + if (page! < 1) { throw new Error(`Page number must be greater than or equal to 1 but it is ${page}`); } - if (pageSize !== undefined && pageSize <= 0) { + if (pageSize! <= 0) { throw new Error(`Page size must be positive but it is ${pageSize}`); } }