Skip to content
Merged
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.2.7",
"version": "1.2.8",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
40 changes: 36 additions & 4 deletions src/dataLoaders.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import DataLoader from 'dataloader';
import { Db, ObjectId } from 'mongodb';
import { PlanDBScheme, UserDBScheme, WorkspaceDBScheme, ProjectDBScheme } from '@hawk.so/types';
import { PlanDBScheme, UserDBScheme, WorkspaceDBScheme, ProjectDBScheme, EventData, EventAddons } from '@hawk.so/types';

type EventDbScheme = {
_id: ObjectId;
} & EventData<EventAddons>;

/**
* Class for setting up data loaders
Expand Down Expand Up @@ -65,7 +69,7 @@
* @param collectionName - collection name to get entities
* @param ids - ids for resolving
*/
private async batchByIds<T extends {_id: ObjectId}>(collectionName: string, ids: ReadonlyArray<string>): Promise<(T | null | Error)[]> {
private async batchByIds<T extends { _id: ObjectId }>(collectionName: string, ids: ReadonlyArray<string>): Promise<(T | null | Error)[]> {
return this.batchByField<T, ObjectId>(collectionName, ids.map(id => new ObjectId(id)), '_id');
}

Expand All @@ -77,9 +81,9 @@
*/
private async batchByField<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends {[key: string]: any},
T extends { [key: string]: any },
FieldType extends object | string
>(collectionName: string, values: ReadonlyArray<FieldType>, fieldName: string): Promise<(T | null | Error)[]> {
>(collectionName: string, values: ReadonlyArray<FieldType>, fieldName: string): Promise<(T | null | Error)[]> {
const queryResult = await this.dbConnection.collection(collectionName)
.find({
[fieldName]: { $in: values },
Expand All @@ -99,3 +103,31 @@
return values.map((field) => entitiesMap[field.toString()] || null);
}
}

/**

Check warning on line 107 in src/dataLoaders.ts

View workflow job for this annotation

GitHub Actions / ESlint

Missing JSDoc @returns for function
* Create DataLoader for events in dynamic collections `events:<projectId>` stored in the events DB
*
* @param eventsDb - MongoDB connection to the events database
* @param projectId - project id used to pick a dynamic collection
*/
export function createProjectEventsByIdLoader(
eventsDb: Db,
projectId: string
): DataLoader<string, EventDbScheme | null> {
return new DataLoader<string, EventDbScheme | null>(async (ids) => {
const objectIds = ids.map((id) => new ObjectId(id));

const docs = await eventsDb
.collection(`events:${projectId}`)
.find({ _id: { $in: objectIds } })
.toArray();

const map: Record<string, EventDbScheme> = {};

docs.forEach((doc) => {
map[doc._id.toString()] = doc as EventDbScheme;
});

return ids.map((id) => map[id] || null);
}, { cache: true });
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ class HawkAPI {
id: userId,
accessTokenExpired: isAccessTokenExpired,
},
eventsFactoryCache: new Map(),
// accounting,
};
}
Expand Down
19 changes: 6 additions & 13 deletions src/models/eventsFactory.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getMidnightWithTimezoneOffset, getUTCMidnight } from '../utils/dates';
import safe from 'safe-regex';
import { createProjectEventsByIdLoader } from '../dataLoaders';

const Factory = require('./modelFactory');
const mongo = require('../mongo');
Expand Down Expand Up @@ -94,6 +95,7 @@ class EventsFactory extends Factory {
}

this.projectId = projectId;
this.eventsDataLoader = createProjectEventsByIdLoader(mongo.databases.events, this.projectId);
}

/**
Expand Down Expand Up @@ -156,10 +158,7 @@ class EventsFactory extends Factory {
* @returns {Event|null}
*/
async findById(id) {
const searchResult = await this.getCollection(this.TYPES.EVENTS)
.findOne({
_id: new ObjectID(id),
});
const searchResult = await this.eventsDataLoader.load(id);

const event = searchResult ? new Event(searchResult) : null;

Expand Down Expand Up @@ -603,10 +602,7 @@ class EventsFactory extends Factory {
* If originalEventId equals repetitionId than user wants to get first repetition which is original event
*/
if (repetitionId === originalEventId) {
const originalEvent = await this.getCollection(this.TYPES.EVENTS)
.findOne({
_id: ObjectID(originalEventId),
});
const originalEvent = await this.eventsDataLoader.load(originalEventId);

/**
* All events have same type with originalEvent id
Expand All @@ -626,10 +622,7 @@ class EventsFactory extends Factory {
_id: ObjectID(repetitionId),
});

const originalEvent = await this.getCollection(this.TYPES.EVENTS)
.findOne({
_id: ObjectID(originalEventId),
});
const originalEvent = await this.eventsDataLoader.load(originalEventId);

/**
* If one of the ids are invalid (originalEvent or repetition not found) return null
Expand Down Expand Up @@ -710,7 +703,7 @@ class EventsFactory extends Factory {
async toggleEventMark(eventId, mark) {
const collection = this.getCollection(this.TYPES.EVENTS);

const event = await this.findById(eventId);
const event = await this.eventsDataLoader.load(eventId);

if (!event) {
throw new Error(`Event not found for eventId: ${eventId}`);
Expand Down
48 changes: 33 additions & 15 deletions src/resolvers/event.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
const EventsFactory = require('../models/eventsFactory');
const { ObjectID } = require('mongodb');
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}
*/
Expand Down Expand Up @@ -29,8 +47,8 @@ module.exports = {
*
* @return {RepetitionsPortion}
*/
async repetitionsPortion({ projectId, originalEventId }, { limit, cursor }) {
const factory = new EventsFactory(projectId);
async repetitionsPortion({ projectId, originalEventId }, { limit, cursor }, context) {
const factory = getEventsFactoryForProjectId(context, projectId);

return factory.getEventRepetitions(originalEventId, limit, cursor);
},
Expand Down Expand Up @@ -84,8 +102,8 @@ module.exports = {
* @param {number} timezoneOffset - user's local timezone offset in minutes
* @returns {Promise<ProjectChartItem[]>}
*/
async chartData({ projectId, groupHash }, { days, timezoneOffset }) {
const factory = new EventsFactory(new ObjectID(projectId));
async chartData({ projectId, groupHash }, { days, timezoneOffset }, context) {
const factory = getEventsFactoryForProjectId(context, projectId);

return factory.findChartData(days, timezoneOffset, groupHash);
},
Expand All @@ -97,8 +115,8 @@ module.exports = {
* @param {String} eventId - event id
* @returns {Promise<Release>}
*/
async release({ projectId, id: eventId }) {
const factory = new EventsFactory(new ObjectID(projectId));
async release({ projectId, id: eventId }, _args, context) {
const factory = getEventsFactoryForProjectId(context, projectId);
const release = await factory.getEventRelease(eventId);

return release;
Expand All @@ -114,8 +132,8 @@ module.exports = {
* @param {UserInContext} user - user context
* @return {Promise<boolean>}
*/
async visitEvent(_obj, { projectId, eventId }, { user }) {
const factory = new EventsFactory(projectId);
async visitEvent(_obj, { projectId, eventId }, { user, ...context }) {
const factory = getEventsFactoryForProjectId(context, projectId);

const { result } = await factory.visitEvent(eventId, user.id);

Expand All @@ -131,8 +149,8 @@ module.exports = {
* @param {string} mark - mark to set
* @return {Promise<boolean>}
*/
async toggleEventMark(_obj, { project, eventId, mark }) {
const factory = new EventsFactory(project);
async toggleEventMark(_obj, { project, eventId, mark }, context) {
const factory = getEventsFactoryForProjectId(context, project);

const { result } = await factory.toggleEventMark(eventId, mark);

Expand All @@ -155,9 +173,9 @@ module.exports = {
* @param factories - factories for working with models
* @return {Promise<boolean>}
*/
async updateAssignee(_obj, { input }, { factories, user }) {
async updateAssignee(_obj, { input }, { factories, user, ...context }) {
const { projectId, eventId, assignee } = input;
const factory = new EventsFactory(projectId);
const factory = getEventsFactoryForProjectId(context, projectId);

const userExists = await factories.usersFactory.findById(assignee);

Expand Down Expand Up @@ -206,9 +224,9 @@ module.exports = {
* @param factories - factories for working with models
* @return {Promise<boolean>}
*/
async removeAssignee(_obj, { input }) {
async removeAssignee(_obj, { input }, context) {
const { projectId, eventId } = input;
const factory = new EventsFactory(projectId);
const factory = getEventsFactoryForProjectId(context, projectId);

const { result } = await factory.updateAssignee(eventId, '');

Expand Down
44 changes: 34 additions & 10 deletions src/resolvers/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,30 @@ 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}
*/
Expand Down Expand Up @@ -288,8 +312,8 @@ module.exports = {
*
* @returns {EventRepetitionSchema}
*/
async event(project, { eventId: repetitionId, originalEventId }) {
const factory = new EventsFactory(project._id);
async event(project, { eventId: repetitionId, originalEventId }, context) {
const factory = getEventsFactoryForProject(project, context);
const repetition = await factory.getEventRepetition(repetitionId, originalEventId);

if (!repetition) {
Expand All @@ -310,8 +334,8 @@ module.exports = {
* @param {Context.user} user - current authorized user {@see ../index.js}
* @returns {Event[]}
*/
async events(project, { limit, skip }) {
const factory = new EventsFactory(project._id);
async events(project, { limit, skip }, context) {
const factory = getEventsFactoryForProject(project, context);

return factory.find({}, limit, skip);
},
Expand All @@ -325,8 +349,8 @@ module.exports = {
*
* @return {Promise<number>}
*/
async unreadCount(project, data, { user }) {
const eventsFactory = new EventsFactory(project._id);
async unreadCount(project, data, { user, ...context }) {
const eventsFactory = getEventsFactoryForProject(project, context);
const userInProject = new UserInProject(user.id, project._id);
const lastVisit = await userInProject.getLastVisit();

Expand All @@ -345,14 +369,14 @@ module.exports = {
*
* @return {Promise<RecentEventSchema[]>}
*/
async dailyEventsPortion(project, { limit, nextCursor, sort, filters, search }) {
async dailyEventsPortion(project, { limit, nextCursor, sort, filters, search }, context) {
if (search) {
if (search.length > MAX_SEARCH_QUERY_LENGTH) {
search = search.slice(0, MAX_SEARCH_QUERY_LENGTH);
}
}

const factory = new EventsFactory(project._id);
const factory = getEventsFactoryForProject(project, context);

const dailyEventsPortion = await factory.findDailyEventsPortion(limit, nextCursor, sort, filters, search);

Expand All @@ -368,8 +392,8 @@ module.exports = {
*
* @return {Promise<ProjectChartItem[]>}
*/
async chartData(project, { days, timezoneOffset }) {
const factory = new EventsFactory(project._id);
async chartData(project, { days, timezoneOffset }, context) {
const factory = getEventsFactoryForProject(project, context);

return factory.findChartData(days, timezoneOffset);
},
Expand Down
10 changes: 8 additions & 2 deletions src/types/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export interface ResolverContextBase {
*/
factories: ContextFactories;

/**
* Request-scoped cache for EventsFactory instances keyed by projectId
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventsFactoryCache: Map<string, any>;

// /**
// * SDK for working with CodeX Accounting API
// */
Expand Down Expand Up @@ -96,13 +102,13 @@ export interface ResolverContextWithUser extends ResolverContextBase {
* e.g. in directive definition
*/
export type UnknownGraphQLField<TContext extends ResolverContextBase = ResolverContextBase>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
= GraphQLField<any, TContext>

/**
* Use this type when you want to show that you don't know what GraphQL field resolver returns (to avoid 'any' type),
* e.g. in directive definition
*/
export type UnknownGraphQLResolverResult
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
= Promise<any>;
1 change: 1 addition & 0 deletions test/resolvers/billingNew.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function createComposePaymentTestSetup(options: {
};

const mockContext: ResolverContextWithUser = {
eventsFactoryCache: new Map(),
user: {
id: userId,
accessTokenExpired: false,
Expand Down
Loading