From 076aea459b3a9eff6b5ef82f6e05999b99e1b8b7 Mon Sep 17 00:00:00 2001 From: slaveeks Date: Tue, 4 Nov 2025 22:58:44 +0300 Subject: [PATCH 1/7] feat(perf): rm duplicated ids from bathched queries --- src/dataLoaders.ts | 13 ++++++++-- src/resolvers/event.js | 35 ++++++-------------------- src/resolvers/helpers/eventsFactory.ts | 24 ++++++++++++++++++ src/resolvers/project.js | 35 +++++--------------------- 4 files changed, 49 insertions(+), 58 deletions(-) create mode 100644 src/resolvers/helpers/eventsFactory.ts diff --git a/src/dataLoaders.ts b/src/dataLoaders.ts index f91e68df..5b21a4e0 100644 --- a/src/dataLoaders.ts +++ b/src/dataLoaders.ts @@ -84,9 +84,15 @@ export default class DataLoaders { T extends { [key: string]: any }, FieldType extends object | string >(collectionName: string, values: ReadonlyArray, fieldName: string): Promise<(T | null | Error)[]> { + const valuesMap = new Map(); + + for (const value of values) { + valuesMap.set(value.toString(), value); + } + const queryResult = await this.dbConnection.collection(collectionName) .find({ - [fieldName]: { $in: values }, + [fieldName]: { $in: Array.from(valuesMap.values()) }, }) .toArray(); @@ -115,7 +121,10 @@ export function createProjectEventsByIdLoader( projectId: string ): DataLoader { return new DataLoader(async (ids) => { - const objectIds = ids.map((id) => new ObjectId(id)); + /** + * Deduplicate only for the DB query; keep original ids array for mapping + */ + const objectIds = [ ...new Set(ids) ].map(id => new ObjectId(id)); const docs = await eventsDb .collection(`events:${projectId}`) diff --git a/src/resolvers/event.js b/src/resolvers/event.js index 52ec0a06..5b8804da 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -1,25 +1,6 @@ -const EventsFactory = require('../models/eventsFactory'); +const getEventsFactory = require('./helpers/eventsFactory').default; const sendPersonalNotification = require('../utils/personalNotifications').default; -/** - * Returns a per-request, per-project EventsFactory instance - * Uses context.eventsFactoryCache to memoize by projectId - * - * @param {ResolverContextBase} context - resolver context - * @param {string} projectId - project id to get EventsFactory instance for - * @returns {EventsFactory} - EventsFactory instance bound to a specific project object - */ -function getEventsFactoryForProjectId(context, projectId) { - const cache = context.eventsFactoryCache || (context.eventsFactoryCache = new Map()); - const cacheKey = projectId.toString(); - - if (!cache.has(cacheKey)) { - cache.set(cacheKey, new EventsFactory(projectId)); - } - - return cache.get(cacheKey); -} - /** * See all types and fields here {@see ../typeDefs/event.graphql} */ @@ -48,7 +29,7 @@ module.exports = { * @return {RepetitionsPortion} */ async repetitionsPortion({ projectId, originalEventId }, { limit, cursor }, context) { - const factory = getEventsFactoryForProjectId(context, projectId); + const factory = getEventsFactory(context, projectId); return factory.getEventRepetitions(originalEventId, limit, cursor); }, @@ -103,7 +84,7 @@ module.exports = { * @returns {Promise} */ async chartData({ projectId, groupHash }, { days, timezoneOffset }, context) { - const factory = getEventsFactoryForProjectId(context, projectId); + const factory = getEventsFactory(context, projectId); return factory.findChartData(days, timezoneOffset, groupHash); }, @@ -116,7 +97,7 @@ module.exports = { * @returns {Promise} */ async release({ projectId, id: eventId }, _args, context) { - const factory = getEventsFactoryForProjectId(context, projectId); + const factory = getEventsFactory(context, projectId); const release = await factory.getEventRelease(eventId); return release; @@ -133,7 +114,7 @@ module.exports = { * @return {Promise} */ async visitEvent(_obj, { projectId, eventId }, { user, ...context }) { - const factory = getEventsFactoryForProjectId(context, projectId); + const factory = getEventsFactory(context, projectId); const { result } = await factory.visitEvent(eventId, user.id); @@ -150,7 +131,7 @@ module.exports = { * @return {Promise} */ async toggleEventMark(_obj, { project, eventId, mark }, context) { - const factory = getEventsFactoryForProjectId(context, project); + const factory = getEventsFactory(context, project); const { result } = await factory.toggleEventMark(eventId, mark); @@ -175,7 +156,7 @@ module.exports = { */ async updateAssignee(_obj, { input }, { factories, user, ...context }) { const { projectId, eventId, assignee } = input; - const factory = getEventsFactoryForProjectId(context, projectId); + const factory = getEventsFactory(context, projectId); const userExists = await factories.usersFactory.findById(assignee); @@ -226,7 +207,7 @@ module.exports = { */ async removeAssignee(_obj, { input }, context) { const { projectId, eventId } = input; - const factory = getEventsFactoryForProjectId(context, projectId); + const factory = getEventsFactory(context, projectId); const { result } = await factory.updateAssignee(eventId, ''); diff --git a/src/resolvers/helpers/eventsFactory.ts b/src/resolvers/helpers/eventsFactory.ts new file mode 100644 index 00000000..a89cc26c --- /dev/null +++ b/src/resolvers/helpers/eventsFactory.ts @@ -0,0 +1,24 @@ +import { ResolverContextBase } from '../../types/graphql'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const EventsFactory = require('../../models/eventsFactory'); + +/** + * Returns a request-scoped, per-project EventsFactory instance using context cache + * Falls back to a fresh instance if cache is not available (shouldn't happen in normal flow) + */ +export function getEventsFactory(context: ResolverContextBase, projectId: string) { + const cache = context && context.eventsFactoryCache; + + if (cache) { + if (!cache.has(projectId)) { + cache.set(projectId, new EventsFactory(projectId)); + } + + return cache.get(projectId); + } + + return new EventsFactory(projectId); +} + +export default getEventsFactory; diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 13e0f35d..b3e5d9b0 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -5,6 +5,7 @@ const { ApolloError, UserInputError } = require('apollo-server-express'); const Validator = require('../utils/validator'); const UserInProject = require('../models/userInProject'); const EventsFactory = require('../models/eventsFactory'); +const getEventsFactory = require('./helpers/eventsFactory').default; const ProjectToWorkspace = require('../models/projectToWorkspace'); const { dateFromObjectId } = require('../utils/dates'); const ProjectModel = require('../models/project').default; @@ -14,30 +15,6 @@ const REPETITIONS_GROUP_HASH_INDEX_NAME = 'groupHash_hashed'; const REPETITIONS_USER_ID_INDEX_NAME = 'userId'; const MAX_SEARCH_QUERY_LENGTH = 50; -/** - * Returns a singleton EventsFactory instance bound to a specific project object. - * Uses request-scoped cache to share across nested resolvers. - * - * @param {ProjectDBScheme|Object} project - project instance to make a instance of EventsFactory - * @param {ResolverContextBase} context - resolver context - * @returns {EventsFactory} - EventsFactory instance bound to a specific project object - */ -function getEventsFactoryForProject(project, context) { - const cache = context && context.eventsFactoryCache; - const key = project._id.toString(); - - if (cache) { - if (!cache.has(key)) { - cache.set(key, new EventsFactory(project._id)); - } - - return cache.get(key); - } - - // Fallback (shouldn't happen in normal resolver flow): return a fresh instance - return new EventsFactory(project._id); -} - /** * See all types and fields here {@see ../typeDefs/project.graphql} */ @@ -313,7 +290,7 @@ module.exports = { * @returns {EventRepetitionSchema} */ async event(project, { eventId: repetitionId, originalEventId }, context) { - const factory = getEventsFactoryForProject(project, context); + const factory = getEventsFactory(context, project._id); const repetition = await factory.getEventRepetition(repetitionId, originalEventId); if (!repetition) { @@ -335,7 +312,7 @@ module.exports = { * @returns {Event[]} */ async events(project, { limit, skip }, context) { - const factory = getEventsFactoryForProject(project, context); + const factory = getEventsFactory(context, project._id); return factory.find({}, limit, skip); }, @@ -350,7 +327,7 @@ module.exports = { * @return {Promise} */ async unreadCount(project, data, { user, ...context }) { - const eventsFactory = getEventsFactoryForProject(project, context); + const eventsFactory = getEventsFactory(context, project._id); const userInProject = new UserInProject(user.id, project._id); const lastVisit = await userInProject.getLastVisit(); @@ -376,7 +353,7 @@ module.exports = { } } - const factory = getEventsFactoryForProject(project, context); + const factory = getEventsFactory(context, project._id); const dailyEventsPortion = await factory.findDailyEventsPortion(limit, nextCursor, sort, filters, search); @@ -393,7 +370,7 @@ module.exports = { * @return {Promise} */ async chartData(project, { days, timezoneOffset }, context) { - const factory = getEventsFactoryForProject(project, context); + const factory = getEventsFactory(context, project._id); return factory.findChartData(days, timezoneOffset); }, From f9e59c1c11d9be7869efd10072ba6ab11dca8d05 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 20:00:23 +0000 Subject: [PATCH 2/7] Bump version up to 1.2.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5511bb43..c5c66ea4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.2.8", + "version": "1.2.9", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From 5d193956bc73843e87bc8364ab100e0d61234550 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 20:01:45 +0000 Subject: [PATCH 3/7] Bump version up to 1.2.11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 70bba2d9..d234474a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.2.10", + "version": "1.2.11", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From 8c6b8f3120084e3b4f696b6f7b545f471639aab8 Mon Sep 17 00:00:00 2001 From: slaveeks Date: Tue, 4 Nov 2025 23:37:12 +0300 Subject: [PATCH 4/7] jsdoc --- src/resolvers/helpers/eventsFactory.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/resolvers/helpers/eventsFactory.ts b/src/resolvers/helpers/eventsFactory.ts index a89cc26c..d1de8725 100644 --- a/src/resolvers/helpers/eventsFactory.ts +++ b/src/resolvers/helpers/eventsFactory.ts @@ -4,8 +4,12 @@ import { ResolverContextBase } from '../../types/graphql'; const EventsFactory = require('../../models/eventsFactory'); /** - * Returns a request-scoped, per-project EventsFactory instance using context cache - * Falls back to a fresh instance if cache is not available (shouldn't happen in normal flow) + * Returns a per-request, per-project EventsFactory instance + * Uses context.eventsFactoryCache to memoize by projectId + * + * @param {ResolverContextBase} context - resolver context + * @param {string} projectId - project id to get EventsFactory instance for + * @returns {EventsFactory} - EventsFactory instance bound to a specific project object */ export function getEventsFactory(context: ResolverContextBase, projectId: string) { const cache = context && context.eventsFactoryCache; From 5d5e382685550772cb4adc1e26c65eee65b17f28 Mon Sep 17 00:00:00 2001 From: slaveeks Date: Tue, 4 Nov 2025 23:43:18 +0300 Subject: [PATCH 5/7] replace cache initing to helper --- src/index.ts | 1 - src/resolvers/helpers/eventsFactory.ts | 6 +++++- src/types/graphql.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index b7173f12..0f847d41 100644 --- a/src/index.ts +++ b/src/index.ts @@ -237,7 +237,6 @@ class HawkAPI { id: userId, accessTokenExpired: isAccessTokenExpired, }, - eventsFactoryCache: new Map(), // accounting, }; } diff --git a/src/resolvers/helpers/eventsFactory.ts b/src/resolvers/helpers/eventsFactory.ts index d1de8725..6cdc105d 100644 --- a/src/resolvers/helpers/eventsFactory.ts +++ b/src/resolvers/helpers/eventsFactory.ts @@ -12,7 +12,11 @@ const EventsFactory = require('../../models/eventsFactory'); * @returns {EventsFactory} - EventsFactory instance bound to a specific project object */ export function getEventsFactory(context: ResolverContextBase, projectId: string) { - const cache = context && context.eventsFactoryCache; + if (!context.eventsFactoryCache) { + context.eventsFactoryCache = new Map(); + } + + const cache = context.eventsFactoryCache; if (cache) { if (!cache.has(projectId)) { diff --git a/src/types/graphql.ts b/src/types/graphql.ts index b7e19024..2ff39bdd 100644 --- a/src/types/graphql.ts +++ b/src/types/graphql.ts @@ -24,7 +24,7 @@ export interface ResolverContextBase { * Request-scoped cache for EventsFactory instances keyed by projectId */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventsFactoryCache: Map; + eventsFactoryCache?: Map; // /** // * SDK for working with CodeX Accounting API From 912c30cd5620d4b62e5c5dd5a1e3b1a9ffd0d75a Mon Sep 17 00:00:00 2001 From: slaveeks Date: Tue, 4 Nov 2025 23:59:13 +0300 Subject: [PATCH 6/7] upd doc --- src/types/graphql.ts | 1 + test/resolvers/billingNew.test.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/graphql.ts b/src/types/graphql.ts index 2ff39bdd..0c1e09f4 100644 --- a/src/types/graphql.ts +++ b/src/types/graphql.ts @@ -22,6 +22,7 @@ export interface ResolverContextBase { /** * Request-scoped cache for EventsFactory instances keyed by projectId + * Set by getEventsFactory helper in event & project resolvers */ // eslint-disable-next-line @typescript-eslint/no-explicit-any eventsFactoryCache?: Map; diff --git a/test/resolvers/billingNew.test.ts b/test/resolvers/billingNew.test.ts index d8f1a9bf..a42fafa3 100644 --- a/test/resolvers/billingNew.test.ts +++ b/test/resolvers/billingNew.test.ts @@ -71,7 +71,6 @@ function createComposePaymentTestSetup(options: { }; const mockContext: ResolverContextWithUser = { - eventsFactoryCache: new Map(), user: { id: userId, accessTokenExpired: false, From c3f59bddfeedaf6c51d9ccbf04af16b287421937 Mon Sep 17 00:00:00 2001 From: slaveeks Date: Wed, 5 Nov 2025 00:23:44 +0300 Subject: [PATCH 7/7] fix type --- src/dataLoaders.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataLoaders.ts b/src/dataLoaders.ts index 5b21a4e0..c41a5458 100644 --- a/src/dataLoaders.ts +++ b/src/dataLoaders.ts @@ -82,7 +82,7 @@ export default class DataLoaders { private async batchByField< // eslint-disable-next-line @typescript-eslint/no-explicit-any T extends { [key: string]: any }, - FieldType extends object | string + FieldType extends ObjectId | string >(collectionName: string, values: ReadonlyArray, fieldName: string): Promise<(T | null | Error)[]> { const valuesMap = new Map();