diff --git a/package.json b/package.json index ab70df85..968062e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.1.33", + "version": "1.1.34", "main": "index.ts", "license": "UNLICENSED", "scripts": { diff --git a/src/models/event.js b/src/models/event.js index 0469e024..fc09f2f0 100644 --- a/src/models/event.js +++ b/src/models/event.js @@ -38,12 +38,13 @@ /** * @typedef {Object} EventSchema * @property {String} _id - event ID - * @property {String} catcherType - type of an event - * @property {Number} count - event repetitions count * @property {String} groupHash - event's hash (catcherType + title + salt) - * @property {User[]} visitedBy - array of users who visited this event + * @property {Number} totalCount - event repetitions count + * @property {String} catcherType - type of an event * @property {EventPayload} payload - event's payload * @property {Number} timestamp - event's Unix timestamp + * @property {Number} usersAffected - number of users that were affected by the event + * @property {User[]} visitedBy - array of users who visited this event */ /** diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 518a290d..e1faf0cc 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -6,12 +6,10 @@ const Factory = require('./modelFactory'); const mongo = require('../mongo'); const Event = require('../models/event'); const { ObjectID } = require('mongodb'); +const { composeEventPayloadByRepetition } = require('../utils/merge'); /** - * @typedef {Object} RecentEventSchema - * @property {Event} event - event model - * @property {Number} count - recent error occurred count - * @property {String} data - error occurred date (string) + * @typedef {import('mongodb').UpdateWriteOpResult} UpdateWriteOpResult */ /** @@ -20,6 +18,40 @@ const { ObjectID } = require('mongodb'); * @property {String} groupHash - event's hash. Generates according to the rule described in EventSchema * @property {EventPayload} payload - repetition's payload * @property {Number} timestamp - repetition's Unix timestamp + * @property {Number} originalTimestamp - UNIX timestmap of the original event + * @property {String} originalEventId - id of the original event + * @property {String} projectId - id of the project, which repetition it is + */ + +/** + * @typedef {Object} EventRepetitionsPortionSchema + * @property {EventRepetitionSchema[]} repetitions - list of repetitions + * @property {String | null} nextCursor - pointer to the first repetition of the next portion, null if there are no repetitions left + */ + +/** + * @typedef {Object} DailyEventSchema + * @property {String} _id - id of the dailyEvent + * @property {String} groupHash - group hash of the dailyEvent + * @property {Number} groupingTimestamp - UNIX timestamp that represents the day of dailyEvent + * @property {Number} affectedUsers - number of users affected this day + * @property {Number} count - number of events this day + * @property {String} lastRepetitionId - id of the last repetition this day + * @property {Number} lastRepetitionTime - UNIX timestamp that represent time of the last repetition this day + * @property {Event} event - one certain event that represents all of the repetitions this day + */ + +/** + * @typedef {Object} DailyEventsCursor + * @property {Number} groupingTimestampBoundary - boundary value of groupingTimestamp field of the last event in the portion + * @property {Number} sortValueBoundary - boundary value of the field by which events are sorted (count/affectedUsers/lastRepetitionTime) of the last event in the portion + * @property {String} idBoundary - boundary value of _id field of the last event in the portion + */ + +/** + * @typedef {Object} DaylyEventsPortionSchema + * @property {DailyEventSchema[]} dailyEvents - original event of the daily one + * @property {DailyEventsCursor | null} nextCursor - object with boundary values of the first event in the next portion */ /** @@ -128,7 +160,9 @@ class EventsFactory extends Factory { _id: new ObjectID(id), }); - return searchResult ? new Event(searchResult) : null; + const event = searchResult ? new Event(searchResult) : null; + + return event; } /** @@ -148,16 +182,16 @@ class EventsFactory extends Factory { * Returns events that grouped by day * * @param {Number} limit - events count limitations - * @param {Number} skip - certain number of documents to skip + * @param {DailyEventsCursor} paginationCursor - object that contains boundary values of the last event in the previous portion * @param {'BY_DATE' | 'BY_COUNT'} sort - events sort order * @param {EventsFilters} filters - marks by which events should be filtered * @param {String} search - Search query * - * @return {RecentEventSchema[]} + * @return {DaylyEventsPortionSchema} */ - async findRecent( + async findDailyEventsPortion( limit = 10, - skip = 0, + paginationCursor = null, sort = 'BY_DATE', filters = {}, search = '' @@ -173,10 +207,6 @@ class EventsFactory extends Factory { throw new Error('Invalid regular expression pattern'); } - const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - - limit = this.validateLimit(limit); - switch (sort) { case 'BY_COUNT': sort = 'count'; @@ -192,11 +222,55 @@ class EventsFactory extends Factory { break; } + const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + limit = this.validateLimit(limit); + const pipeline = [ + { + $match: paginationCursor ? { + /** + * This condition is used for cursor-based pagination + * We sort result by groupingTimestamp desc, [sort] desc, _id desc + * So we need to fetch documents that are less than the last document of the previous portion (based on all three conditions) + */ + $or: [ + { + /** + * If groupingTimestamp is less than the cursors one + * - daily events of the next day + */ + groupingTimestamp: { $lt: paginationCursor.groupingTimestampBoundary }, + }, + { + /** + * If groupingTimestamp equals to the cursor one, but [sort] is less than the cursors one + * - daily events of the same day, but with less count/affectedUsers/lastRepetitionTime + */ + $and: [ + { groupingTimestamp: paginationCursor.groupingTimestampBoundary }, + { [sort]: { $lt: paginationCursor.sortValueBoundary } }, + ], + }, + { + /** + * If groupingTimestamp and [sort] equals to the cursors ones, but _id is less or equal to the cursors one + * - daily events of the same day with the same count/affectedUsers/lastRepetitionTime, but that were created earlier + */ + $and: [ + { groupingTimestamp: paginationCursor.groupingTimestampBoundary }, + { [sort]: paginationCursor.sortValueBoundary }, + { _id: { $lte: new ObjectID(paginationCursor.idBoundary) } }, + ], + }, + ], + } : {}, + }, { $sort: { groupingTimestamp: -1, [sort]: -1, + _id: -1, }, }, ]; @@ -241,6 +315,9 @@ class EventsFactory extends Factory { : {}; pipeline.push( + /** + * Left outer join original event on groupHash field + */ { $lookup: { from: 'events:' + this.projectId, @@ -249,48 +326,70 @@ class EventsFactory extends Factory { as: 'event', }, }, + { + $lookup: { + from: 'repetitions:' + this.projectId, + localField: 'lastRepetitionId', + foreignField: '_id', + as: 'repetition', + }, + }, + /** + * Desctruct event and repetition arrays since there are only one document in both arrays + */ { $unwind: '$event', }, { - $match: { - ...matchFilter, - ...searchFilter, + $unwind: { + path: '$repetition', + preserveNullAndEmptyArrays: true, }, }, - { $skip: skip }, - { $limit: limit }, { - $group: { - _id: null, - dailyInfo: { $push: '$$ROOT' }, - events: { $push: '$event' }, + $match: { + ...matchFilter, + ...searchFilter, }, }, + { $limit: limit + 1 }, { - $unset: 'dailyInfo.event', + $unset: 'groupHash', } ); const cursor = this.getCollection(this.TYPES.DAILY_EVENTS).aggregate(pipeline); + const result = await cursor.toArray(); - const result = (await cursor.toArray()).shift(); + let nextCursor; - /** - * aggregation can return empty array so that - * result can be undefined - * - * for that we check result existence - * - * extra field `projectId` needs to satisfy GraphQL query - */ - if (result && result.events) { - result.events.forEach(event => { - event.projectId = this.projectId; - }); + if (result.length === limit + 1) { + const nextCursorEvent = result.pop(); + + nextCursor = { + groupingTimestampBoundary: nextCursorEvent.groupingTimestamp, + sortValueBoundary: nextCursorEvent[sort], + idBoundary: nextCursorEvent._id.toString(), + }; } - return result; + const composedResult = result.map(dailyEvent => { + const repetition = dailyEvent.repetition; + const event = dailyEvent.event; + + dailyEvent.event = this._composeEventWithRepetition(event, repetition); + dailyEvent.id = dailyEvent._id.toString(); + + delete dailyEvent.repetition; + delete dailyEvent._id; + + return dailyEvent; + }); + + return { + nextCursor: nextCursor, + dailyEvents: composedResult, + }; } /** @@ -391,38 +490,67 @@ class EventsFactory extends Factory { /** * Returns Event repetitions * - * @param {string|ObjectID} eventId - Event's id + * @param {string|ObjectID} eventId - Event's id, could be repetitionId in case when we want to get repetitions portion by one repetition + * @param {string|ObjectID} originalEventId - id of the original event * @param {Number} limit - count limitations - * @param {Number} skip - selection offset - * - * @return {EventRepetitionSchema[]} + * @param {Number} cursor - pointer to the next repetition * - * @todo move to Repetitions(?) model + * @return {EventRepetitionsPortionSchema} */ - async getEventRepetitions(eventId, limit = 10, skip = 0) { + async getEventRepetitions(originalEventId, limit = 10, cursor = null) { limit = this.validateLimit(limit); - skip = this.validateSkip(skip); + + cursor = cursor ? new ObjectID(cursor) : null; + + const result = { + repetitions: [], + nextCursor: null, + }; /** * Get original event - * @type {EventSchema} + * @type {Event} */ - const eventOriginal = await this.findById(eventId); + const eventOriginal = await this.findById(originalEventId); + + if (!eventOriginal) { + throw new Error(`Original event not found for ${originalEventId}`); + } + + /** + * Get portion based on cursor if cursor is not null + */ + const query = cursor ? { + groupHash: eventOriginal.groupHash, + _id: { $lte: cursor }, + } : { + groupHash: eventOriginal.groupHash, + }; /** * Collect repetitions * @type {EventRepetitionSchema[]} */ const repetitions = await this.getCollection(this.TYPES.REPETITIONS) - .find({ - groupHash: eventOriginal.groupHash, - }) + .find(query) .sort({ _id: -1 }) - .limit(limit) - .skip(skip) + .limit(limit + 1) .toArray(); - const isLastPortion = repetitions.length < limit && skip === 0; + if (repetitions.length === limit + 1) { + result.nextCursor = repetitions.pop()._id; + } + + for (const repetition of repetitions) { + const event = this._composeEventWithRepetition(eventOriginal, repetition); + + result.repetitions.push({ + ...event, + projectId: this.projectId, + }); + } + + const isLastPortion = result.nextCursor === null; /** * For last portion: @@ -434,31 +562,66 @@ class EventsFactory extends Factory { * @type {EventRepetitionSchema} */ const firstRepetition = { - _id: eventOriginal._id, - payload: eventOriginal.payload, - groupHash: eventOriginal.groupHash, - timestamp: eventOriginal.timestamp, + ...eventOriginal, + originalTimestamp: eventOriginal.timestamp, + originalEventId: eventOriginal._id, + projectId: this.projectId, }; - repetitions.push(firstRepetition); + result.repetitions.push(firstRepetition); } - return repetitions; + return result; } /** - * Returns Event concrete repetition + * Returns certain repetition of the original event * * @param {String} repetitionId - id of Repetition to find + * @param {String} originalEventId - id of the original event * @return {EventRepetitionSchema|null} - * - * @todo move to Repetitions(?) model */ - async getEventRepetition(repetitionId) { - return this.getCollection(this.TYPES.REPETITIONS) + async getEventRepetition(repetitionId, originalEventId) { + /** + * 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), + }); + + /** + * All events have same type with originalEvent id + */ + originalEvent.originalEventId = originalEventId; + originalEvent.originalTimestamp = originalEvent.timestamp; + originalEvent.projectId = this.projectId; + + return originalEvent || null; + } + + /** + * Otherwise we need to get original event and repetition and merge them + */ + const repetition = await this.getCollection(this.TYPES.REPETITIONS) .findOne({ _id: ObjectID(repetitionId), }); + + const originalEvent = await this.getCollection(this.TYPES.EVENTS) + .findOne({ + _id: ObjectID(originalEventId), + }); + + /** + * If one of the ids are invalid (originalEvent or repetition not found) return null + */ + if (!originalEvent || !repetition) { + throw new Error(`Cant find event repetition for repetitionId: ${repetitionId} and originalEventId: ${originalEventId}`); + } + + return this._composeEventWithRepetition(originalEvent, repetition); } /** @@ -485,6 +648,10 @@ class EventsFactory extends Factory { async getEventRelease(eventId) { const eventOriginal = await this.findById(eventId); + if (!eventOriginal) { + return null; + } + const release = await mongo.databases.events.collection(this.TYPES.RELEASES).findOne({ release: eventOriginal.payload.release, projectId: this.projectId.toString(), @@ -496,32 +663,44 @@ class EventsFactory extends Factory { /** * Mark event as visited for passed user * - * @param {string|ObjectId} eventId - * @param {string|ObjectId} userId + * @param {string|ObjectId} eventId - id of the original event + * @param {string|ObjectId} userId - id of the user who is visiting the event * - * @return {Promise} + * @return {Promise} */ async visitEvent(eventId, userId) { - return this.getCollection(this.TYPES.EVENTS) + const result = await this.getCollection(this.TYPES.EVENTS) .updateOne( { _id: new ObjectID(eventId) }, { $addToSet: { visitedBy: new ObjectID(userId) } } ); + + if (result.matchedCount === 0) { + throw new Error(`Event not found for eventId: ${eventId}`); + } + + return result; } /** * Mark or unmark event as Resolved, Ignored or Starred * - * @param {string|ObjectId} eventId - event to mark + * @param {string|ObjectId} eventId - id of the original event to mark * @param {string} mark - mark label * - * @return {Promise} + * @return {Promise} */ async toggleEventMark(eventId, mark) { const collection = this.getCollection(this.TYPES.EVENTS); - const query = { _id: new ObjectID(eventId) }; - const event = await collection.findOne(query); + const event = await this.findById(eventId); + + if (!event) { + throw new Error(`Event not found for eventId: ${eventId}`); + } + + const query = { _id: new ObjectID(event._id) }; + const markKey = `marks.${mark}`; let update; @@ -565,18 +744,54 @@ class EventsFactory extends Factory { /** * Update assignee to selected event * - * @param {string} eventId - event id + * @param {string} eventId - id of the original event to update * @param {string} assignee - assignee id for this event * @return {Promise} */ async updateAssignee(eventId, assignee) { const collection = this.getCollection(this.TYPES.EVENTS); + const query = { _id: new ObjectID(eventId) }; + const update = { $set: { assignee: assignee }, }; - return collection.updateOne(query, update); + const result = await collection.updateOne(query, update); + + if (result.updatedCount === 0) { + throw new Error(`Event not found for eventId: ${eventId}`); + } + + return result; + } + + /** + * Compose event with repetition + * + * @param {Event} event - event + * @param {Repetition|null} repetition - repetition null + * @returns {Event} event merged with repetition + */ + _composeEventWithRepetition(event, repetition) { + if (!repetition) { + return { + ...event, + originalTimestamp: event.timestamp, + originalEventId: event._id, + projectId: this.projectId, + }; + } + + return { + ...event, + _id: repetition._id, + originalTimestamp: event.timestamp, + originalEventId: event._id, + timestamp: repetition.timestamp, + payload: composeEventPayloadByRepetition(event.payload, repetition), + projectId: this.projectId, + }; } } diff --git a/src/resolvers/event.js b/src/resolvers/event.js index 5a2cbb51..bfad30a9 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -18,39 +18,21 @@ module.exports = { }, }, Event: { - /** - * Returns Event with concrete repetition - * - * @param {string} eventId - id of Event of which repetition requested - * @param {string} projectId - projectId of Event of which repetition requested - * @param {string|null} [repetitionId] - if not specified, last repetition will returned - * @return {Promise} - */ - async repetition({ id: eventId, projectId }, { id: repetitionId }) { - const factory = new EventsFactory(projectId); - - if (!repetitionId) { - return factory.getEventLastRepetition(eventId); - } - - return factory.getEventRepetition(repetitionId); - }, /** - * Returns repetitions list of the event + * Returns repetitions portion of the event * - * @param {ResolverObj} _obj - * @param {String} eventId - * @param {String} projectId - * @param {Number} limit - * @param {Number} skip + * @param {String} projectId - id of the project got from the parent node (event) + * @param {String} originalEventId - id of the original event of the repetitions to get, got from parent node (event) + * @param {Number} limit - argument of the query, maximal count of the repetitions in one portion + * @param {Number|null} cursor - pointer to the next portion of repetition, could be null if we want to get first portion * - * @return {EventRepetitionSchema[]} + * @return {RepetitionsPortion} */ - async repetitions({ _id: eventId, projectId }, { limit, skip }) { + async repetitionsPortion({ projectId, originalEventId }, { limit, cursor }) { const factory = new EventsFactory(projectId); - return factory.getEventRepetitions(eventId, limit, skip); + return factory.getEventRepetitions(originalEventId, limit, cursor); }, /** @@ -127,15 +109,15 @@ module.exports = { * Mark event as visited for current user * * @param {ResolverObj} _obj - resolver context - * @param {string} project - project id - * @param {string} id - event id + * @param {string} projectId - project id + * @param {string} eventId - event id * @param {UserInContext} user - user context * @return {Promise} */ - async visitEvent(_obj, { project, id }, { user }) { - const factory = new EventsFactory(project); + async visitEvent(_obj, { projectId, eventId }, { user }) { + const factory = new EventsFactory(projectId); - const { result } = await factory.visitEvent(id, user.id); + const { result } = await factory.visitEvent(eventId, user.id); return !!result.ok; }, diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 3849787e..bfe2ef31 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -284,20 +284,21 @@ module.exports = { * * @param {ProjectDBScheme} project - result of parent resolver * @param {String} eventId - event's identifier + * @param {String} originalEventId - id of the original event * - * @returns {Event} + * @returns {EventRepetitionSchema} */ - async event(project, { id: eventId }) { + async event(project, { eventId: repetitionId, originalEventId }) { const factory = new EventsFactory(project._id); - const event = await factory.findById(eventId); + const repetition = await factory.getEventRepetition(repetitionId, originalEventId); - if (!event) { + if (!repetition) { return null; } - event.projectId = project._id; + repetition.projectId = project._id; - return event; + return repetition; }, /** @@ -337,14 +338,14 @@ module.exports = { * * @param {ProjectDBScheme} project - result of parent resolver * @param {Number} limit - limit for events count - * @param {Number} skip - certain number of documents to skip + * @param {DailyEventsCursor} cursor - object with boundary values of the first event in the next portion * @param {'BY_DATE' | 'BY_COUNT'} sort - events sort order * @param {EventsFilters} filters - marks by which events should be filtered * @param {String} search - search query * * @return {Promise} */ - async recentEvents(project, { limit, skip, sort, filters, search }) { + async dailyEventsPortion(project, { limit, nextCursor, sort, filters, search }) { if (search) { if (search.length > MAX_SEARCH_QUERY_LENGTH) { search = search.slice(0, MAX_SEARCH_QUERY_LENGTH); @@ -353,7 +354,9 @@ module.exports = { const factory = new EventsFactory(project._id); - return factory.findRecent(limit, skip, sort, filters, search); + const dailyEventsPortion = await factory.findDailyEventsPortion(limit, nextCursor, sort, filters, search); + + return dailyEventsPortion; }, /** diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index a7990679..cd494ad1 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -176,95 +176,6 @@ type EventPayload { addons: EncodedJSON } -""" -Type representing Event payload. All fields can be omitted if there are no difference with the original -""" -type RepetitionPayload { - """ - Event title - """ - title: String - - """ - Event type: TypeError, ReferenceError etc. - """ - type: String - - """ - Event severity level - """ - level: Int - - """ - Event stack array from the latest call to the earliest - """ - backtrace: [EventBacktraceFrame!] - - """ - Additional data about GET request - """ - get: JSONObject - - """ - Additional data about POST request - """ - post: JSONObject - - """ - HTTP headers - """ - headers: JSONObject - - """ - Source code version identifier - """ - release: String - - """ - Current authenticated user - """ - user: EventUser - - """ - Any additional data of Event - """ - context: EncodedJSON - - """ - Custom data provided by project users - """ - addons: EncodedJSON -} - -""" -Repetition of the event -""" -type Repetition { - """ - Standalone repetition ID - """ - id: ID! @renameFrom(name: "_id") - - """ - Event's hash - """ - groupHash: String! - - """ - Event's payload patch - """ - payload: RepetitionPayload - - """ - Delta of the event's payload, stringified JSON - """ - delta: String - - """ - Event timestamp - """ - timestamp: Float! -} """ Possible event marks @@ -284,6 +195,14 @@ type EventMarks { ignored: Boolean! } +""" +Object returned in repetitions property of event object +""" +type RepetitionsPortion { + repetitions: [Event!] + nextCursor: String +} + """ Type representing Hawk single Event """ @@ -324,19 +243,24 @@ type Event { timestamp: Float! """ - Release data + First occurrence timestamp """ - release: Release + originalTimestamp: Float! + + """ + Id of the original event + """ + originalEventId: ID! """ - Event concrete repetition + Release data """ - repetition(id: ID): Repetition + release: Release """ - Event repetitions + Event repetitions portion """ - repetitions(skip: Int = 0, limit: Int = 10): [Repetition!] + repetitionsPortion(cursor: String = null, limit: Int = 10): RepetitionsPortion! """ Array of users who visited event @@ -495,8 +419,15 @@ extend type Mutation { Mutation marks event as visited for current user """ visitEvent( - project: ID! - id: ID! + """ + ID of project event is related to + """ + projectId: ID! + + """ + ID of the event to visit + """ + eventId: ID! ): Boolean! @requireAuth """ diff --git a/src/typeDefs/project.ts b/src/typeDefs/project.ts index f0fab0e2..8401c129 100644 --- a/src/typeDefs/project.ts +++ b/src/typeDefs/project.ts @@ -11,6 +11,73 @@ enum EventsSortOrder { BY_AFFECTED_USERS } +""" +Pagination cursor of events portion and list of daily events +""" +type DailyEventsPortion { + """ + Pointer to the next portion of dailyEvents, null if there are no events left + """ + nextCursor: DailyEventsCursor + + """ + List of daily events + """ + dailyEvents: [DailyEvent] +} + +""" +Daily event information with event itself +""" +type DailyEvent { + """ + ID of the daily event + """ + id: ID! + """ + Count of events in this day + """ + count: Int! + """ + Count of the users affected by this event in this day + """ + affectedUsers: Int! + """ + Timestamp of the event grouping + """ + groupingTimestamp: Int! + """ + Last repetition of the day that represents all of the repetition this day + """ + event: Event! +} + +""" +Cursor for fetching daily events portion +""" +type DailyEventsCursor { + """ + Grouping timestamp of the first event in the next portion + """ + groupingTimestampBoundary: Int! + + """ + Sort key value of the first event in the next portion + """ + sortValueBoundary: Int! + + """ + ID of the first event of in the next portion + """ + idBoundary: ID! +} + +input DailyEventsCursorInput { + groupingTimestampBoundary: Int! + sortValueBoundary: Int! + idBoundary: ID! +} + """ Events filters input type """ @@ -96,7 +163,7 @@ type Project { """ Project's Event """ - event(id: ID!): Event + event(eventId: ID!, originalEventId: ID!): Event """ Project events @@ -128,6 +195,36 @@ type Project { "Search query" search: String ): RecentEvents + """ + Portion of daily events + """ + dailyEventsPortion( + """ + Maximum number of results + """ + limit: Int! = 50 + + """ + Pointer to the first event of the portion that would be fetched + """ + nextCursor: DailyEventsCursorInput + + """ + Events sort order + """ + sort: EventsSortOrder = lastRepetitionTime + + """ + Event marks by which events should be filtered + """ + filters: EventsFiltersInput + + """ + Search query + """ + search: String + ): DailyEventsPortion + """ Return events that occurred after a certain timestamp """ diff --git a/src/utils/merge.ts b/src/utils/merge.ts index 090ada79..3023f962 100644 --- a/src/utils/merge.ts +++ b/src/utils/merge.ts @@ -71,22 +71,22 @@ function stringifyPayloadField(payload: GroupedEventDBScheme['payload'], field: } /** - * Helps to merge original event and repetition due to delta format, + * Helps to merge original event payload and repetition due to delta format, * in case of old delta format, we need to patch the payload * in case of new delta format, we need to assemble the payload * - * @param originalEvent {HawkEvent} - The original event - * @param repetition {HawkEventRepetition} - The repetition to process - * @returns {HawkEvent} Updated event with processed repetition payload + * @param originalEventPayload {GroupedEventDBScheme['payload']} - The original event payload + * @param repetition {RepetitionDBScheme} - The repetition to process + * @returns {GroupedEventDBScheme['payload']} Updated event with processed repetition payload */ -export function composeFullRepetitionEvent(originalEvent: GroupedEventDBScheme, repetition: RepetitionDBScheme | undefined): GroupedEventDBScheme { +export function composeEventPayloadByRepetition(originalEventPayload: GroupedEventDBScheme['payload'], repetition: RepetitionDBScheme | undefined): GroupedEventDBScheme['payload'] { /** * Make a deep copy of the original event, because we need to avoid mutating the original event */ - const event = cloneDeep(originalEvent); + let result = cloneDeep(originalEventPayload); if (!repetition) { - return event; + return result; } /** @@ -96,35 +96,35 @@ export function composeFullRepetitionEvent(originalEvent: GroupedEventDBScheme, /** * Parse addons and context fields from string to object before patching */ - event.payload = parsePayloadField(event.payload, 'addons'); - event.payload = parsePayloadField(event.payload, 'context'); + result = parsePayloadField(result, 'addons'); + result = parsePayloadField(result, 'context'); - event.payload = patch({ - left: event.payload, + result = patch({ + left: result, delta: JSON.parse(repetition.delta), }); /** * Stringify addons and context fields from object to string after patching */ - event.payload = stringifyPayloadField(event.payload, 'addons'); - event.payload = stringifyPayloadField(event.payload, 'context'); + result = stringifyPayloadField(result, 'addons'); + result = stringifyPayloadField(result, 'context'); - return event; + return result; } /** * New delta format (repetition.payload is null) and repetition.delta is null (there is no delta between original and repetition) */ if (!repetition.payload) { - return event; + return result; } /** * Old delta format (repetition.payload is not null) * @todo remove after 6 september 2025 */ - event.payload = repetitionAssembler(event.payload, repetition.payload); + result = repetitionAssembler(result, repetition.payload); - return event; + return result; } diff --git a/test/utils/merge.test.ts b/test/utils/merge.test.ts index c065d338..eb0ec6ab 100644 --- a/test/utils/merge.test.ts +++ b/test/utils/merge.test.ts @@ -1,9 +1,9 @@ -import { composeFullRepetitionEvent } from '../../src/utils/merge'; +import { composeEventPayloadByRepetition } from '../../src/utils/merge'; import { GroupedEventDBScheme, RepetitionDBScheme } from '@hawk.so/types'; import { diff } from '@n1ru4l/json-patch-plus'; -describe('composeFullRepetitionEvent', () => { +describe('composeEventPayloadByRepetition', () => { const mockOriginalEvent: GroupedEventDBScheme = { groupHash: 'original-event-1', totalCount: 1, @@ -33,14 +33,13 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadByRepetition(mockOriginalEvent.payload, repetition); /** * Assert */ - expect(result).toEqual(mockOriginalEvent); - expect(result).toMatchObject(mockOriginalEvent); - expect(result.payload).toMatchObject(mockOriginalEvent.payload); + expect(result).toMatchObject(mockOriginalEvent.payload); + expect(result).not.toBe(mockOriginalEvent.payload); }); }); @@ -55,6 +54,8 @@ describe('composeFullRepetitionEvent', () => { ...mockOriginalEvent.payload, title: 'Updated message', type: 'warning', + addons: { userId: 8888 }, + context: { sessionId: 'qwery' }, }, }); @@ -67,27 +68,34 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadByRepetition(mockOriginalEvent.payload, repetition); /** * Assert */ - expect(result.payload).toEqual({ + expect(result).toEqual({ title: 'Updated message', type: 'warning', - addons: JSON.stringify({ userId: 123 }), - context: JSON.stringify({ sessionId: 'abc' }), + addons: JSON.stringify({ userId: 8888 }), + context: JSON.stringify({ sessionId: 'qwery' }), }); }); it('should handle delta with new fields', () => { + + const originalEventPayload = { + title: 'Original message', + type: 'error', + addons: JSON.stringify({ userId: 777 }), + context: JSON.stringify({ sessionId: 'xyz' }), + }; /** * Arrange */ const delta = diff({ - left: mockOriginalEvent.payload, + left: originalEventPayload, right: { - ...mockOriginalEvent.payload, + ...originalEventPayload, release: 'v1.0.0', catcherVersion: '2.0.0', }, @@ -102,18 +110,18 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadByRepetition(originalEventPayload, repetition); /** * Assert */ - expect(result.payload).toEqual({ + expect(result).toEqual({ title: 'Original message', type: 'error', release: 'v1.0.0', catcherVersion: '2.0.0', - addons: JSON.stringify({ userId: 123 }), - context: JSON.stringify({ sessionId: 'abc' }), + addons: JSON.stringify({ userId: 777 }), + context: JSON.stringify({ sessionId: 'xyz' }), }); }); }); @@ -133,17 +141,24 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadByRepetition(mockOriginalEvent.payload, repetition); /** * Assert */ - expect(result).toEqual(mockOriginalEvent); - expect(result).not.toBe(mockOriginalEvent); // Должна быть глубокая копия + expect(result).toEqual(mockOriginalEvent.payload); }); }); describe('when repetition.delta is undefined and repetition.payload is provided (old delta format)', () => { + + const originalEventPayload = { + title: 'Original message', + type: 'error', + addons: JSON.stringify({ userId: 123 }), + context: JSON.stringify({ sessionId: 'abc' }), + }; + it('should use repetitionAssembler to merge payloads', () => { /** * Arrange @@ -162,12 +177,12 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadByRepetition(originalEventPayload, repetition); /** * Assert */ - expect(result.payload).toEqual({ + expect(result).toEqual({ title: 'Updated message', type: 'warning', release: 'v1.0.0', @@ -178,6 +193,13 @@ describe('composeFullRepetitionEvent', () => { }); it('should handle null values in repetition payload', () => { + + const originalEventPayload = { + title: 'Original message', + type: 'error', + addons: JSON.stringify({ userId: 123 }), + context: JSON.stringify({ sessionId: 'abc' }), + }; /** * Arrange */ @@ -194,12 +216,12 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadByRepetition(originalEventPayload, repetition); /** * Assert */ - expect(result.payload).toEqual({ + expect(result).toEqual({ title: 'Updated title', // repetition value replaces original type: 'info', // Addons and context should be, because old format doesn't remove fields @@ -209,6 +231,14 @@ describe('composeFullRepetitionEvent', () => { }); it('should preserve original value when repetition payload has null', () => { + + const originalEventPayload = { + title: 'Original message', + type: 'error', + addons: JSON.stringify({ userId: 123 }), + context: JSON.stringify({ sessionId: 'abc' }), + }; + /** * Arrange */ @@ -225,13 +255,13 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadByRepetition(originalEventPayload, repetition); /** * Assert */ - expect(result.payload).toEqual({ - title: 'Original message', // null в repetition должно сохранить оригинальное значение + expect(result).toEqual({ + title: 'Original message', type: 'info', addons: JSON.stringify({ userId: 123 }), context: JSON.stringify({ sessionId: 'abc' }), @@ -273,12 +303,12 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(eventWithEmptyPayload, repetition); + const result = composeEventPayloadByRepetition(eventWithEmptyPayload.payload, repetition); /** * Assert */ - expect(result.payload).toEqual({ + expect(result).toEqual({ title: 'New message', }); }); @@ -313,12 +343,12 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(eventWithNullPayload, repetition); + const result = composeEventPayloadByRepetition(eventWithNullPayload.payload, repetition); /** * Assert */ - expect(result.payload).toEqual({ + expect(result).toEqual({ title: 'New message', }); }); @@ -359,8 +389,8 @@ describe('composeFullRepetitionEvent', () => { * Act & Assert */ expect(() => { - composeFullRepetitionEvent(eventWithInvalidJSON, repetition); - }).toThrow(); // Должно выбросить ошибку при парсинге невалидного JSON + composeEventPayloadByRepetition(eventWithInvalidJSON.payload, repetition); + }).toThrow(); }); }); }); \ No newline at end of file