Skip to content
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.6.1",
"version": "1.7.0",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
46 changes: 17 additions & 29 deletions src/models/eventsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createProjectEventsByIdLoader } from '../dataLoaders';
import RedisHelper from '../redisHelper';
import ChartDataService from '../services/chartDataService';
import { getMidnightWithTimezoneOffset, getUTCMidnight } from '../utils/dates';
import { normalizeReleaseName } from '../utils/releaseName';

const Factory = require('./modelFactory');
const mongo = require('../mongo');
Expand Down Expand Up @@ -770,10 +771,7 @@ class EventsFactory extends Factory {
for (const repetition of repetitions) {
const event = this._composeEventWithRepetition(eventOriginal, repetition);

result.repetitions.push({
...event,
projectId: this.projectId,
});
result.repetitions.push(event);
}

const isLastPortion = result.nextCursor === null;
Expand All @@ -787,12 +785,7 @@ class EventsFactory extends Factory {
* Get only 'repetitions' fields from event to fit Repetition scheme
* @type {EventRepetitionSchema}
*/
const firstRepetition = {
...eventOriginal,
originalTimestamp: eventOriginal.timestamp,
originalEventId: eventOriginal._id,
projectId: this.projectId,
};
const firstRepetition = this._composeEventWithRepetition(eventOriginal);

result.repetitions.push(firstRepetition);
}
Expand All @@ -814,14 +807,7 @@ class EventsFactory extends Factory {
if (repetitionId.toString() === originalEventId.toString()) {
const originalEvent = await this.eventsDataLoader.load(originalEventId);

/**
* All events have same type with originalEvent id
*/
originalEvent.originalEventId = originalEventId;
originalEvent.originalTimestamp = originalEvent.timestamp;
originalEvent.projectId = this.projectId;

return originalEvent || null;
return originalEvent ? this._composeEventWithRepetition(originalEvent) : null;
}

/**
Expand Down Expand Up @@ -1104,23 +1090,25 @@ class EventsFactory extends Factory {
* @returns {Event} event merged with repetition
*/
_composeEventWithRepetition(event, repetition) {
const original = {
...event,
originalTimestamp: event.timestamp,
originalEventId: event._id,
firstSeenInRelease: normalizeReleaseName(event.payload && event.payload.release),
resolvedInRelease: normalizeReleaseName(event.resolvedInRelease),
regressionInRelease: normalizeReleaseName(event.regressionInRelease),
projectId: this.projectId,
};

if (!repetition) {
return {
...event,
originalTimestamp: event.timestamp,
originalEventId: event._id,
projectId: this.projectId,
};
return original;
}

return {
...event,
...original,
_id: repetition._id,
originalTimestamp: event.timestamp,
originalEventId: event._id,
timestamp: repetition.timestamp,
payload: composeEventPayloadByRepetition(event.payload, repetition),
projectId: this.projectId,
payload: composeEventPayloadByRepetition(event.payload == null ? {} : event.payload, repetition),
};
}
}
Expand Down
91 changes: 88 additions & 3 deletions src/models/releasesFactory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// TODO: it would be great to move release logic from another factories/resolvers to this class
/**
* TODO: it would be great to move release logic from another factories/resolvers to this class
*/
import type { Collection, Db, ObjectId } from 'mongodb';
import type { ReleaseDBScheme } from '@hawk.so/types';
import { normalizeReleaseName } from '../utils/releaseName';

/**
* Interface representing how release files are stored in the DB
Expand Down Expand Up @@ -57,6 +60,43 @@ export default class ReleasesFactory {
this.filesCollection = dbConnection.collection<ReleaseFileDBScheme>('releases.files');
}

/**
* Compare against the newest valid registered release, not the newest observed/deployed release.
* @param projectId - event's project
* @param firstSeenRelease - original event's release name
* @param latestReleaseCache - caller-owned request cache keyed by project ID; shares in-flight lookups
* and null results (including failures) only within that request. Without it, every call reads afresh.
* @returns whether the original first appeared in the newest valid registered release, or false on failure
*/
public async isNewInLatestRelease(
projectId: string | ObjectId | null | undefined,
firstSeenRelease: unknown,
latestReleaseCache?: Map<string, Promise<string | null>>
): Promise<boolean> {
try {
const name = normalizeReleaseName(firstSeenRelease);

if (!projectId || name === null) {
return false;
}
const key = projectId.toString();
let latest = latestReleaseCache?.get(key);

if (!latest) {
latest = this.findLatestValidRelease(key);
if (latestReleaseCache) {
latestReleaseCache.set(key, latest);
}
}

return name === await latest;
} catch (error) {
console.error('Failed to compare event release with latest registered release', error);

return false;
}
}

/**
* Find one release document by projectId and release label.
* Tries both exact string match and numeric fallback (if release can be cast to number).
Expand All @@ -67,13 +107,17 @@ export default class ReleasesFactory {
): Promise<ReleaseDBScheme | null> {
const projectIdStr = projectId.toString();

// Try exact match as stored
/*
* Try exact match as stored
*/
let doc = await this.collection.findOne({
projectId: projectIdStr,
release: release as ReleaseDBScheme['release'],
});

// Fallback if release stored as number
/*
* Fallback if release stored as number
*/
if (!doc) {
const asNumber = Number(release);

Expand All @@ -96,4 +140,45 @@ export default class ReleasesFactory {
public async findFilesByFileIds(fileIds: ObjectId[]): Promise<ReleaseFileDBScheme[]> {
return this.filesCollection.find({ _id: { $in: fileIds } }).toArray();
}

/**
* Read newest first, skipping legacy invalid names without materializing the release history.
* Failures resolve to null so concurrent rows share one logged failure, not rejected promises.
* @param projectId - normalized project ID
* @returns newest valid release name, or null if none exists or the lookup fails
*/
private async findLatestValidRelease(projectId: string): Promise<string | null> {
try {
/*
* The existing index bounds the project, but does not cover the _id sort.
*/
const cursor = this.collection.find({ projectId }, {
sort: { _id: -1 },
projection: {
release: 1,
_id: 0,
},
hint: {
projectId: 1,
release: 1,
},
});

try {
for await (const doc of cursor) {
const name = normalizeReleaseName(doc.release);

if (name !== null) {
return name;
}
}
} finally {
await cursor.close();
}
} catch (error) {
console.error(`Failed to look up latest registered release for project ${projectId}`, error);
}

return null;
}
}
53 changes: 53 additions & 0 deletions src/resolvers/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
const { askAiService } = require('../services/askAi');
const { UserInputError } = require('apollo-server-express');
const { ObjectId } = require('mongodb');
const { normalizeReleaseName } = require('../utils/releaseName');

/**
* See all types and fields here {@see ../typeDefs/event.graphql}
Expand All @@ -23,6 +24,58 @@ module.exports = {
},
},
Event: {
/**
* Returns the original release name, preserving null for repetitions of legacy events.
*
* @param {Object} event - original event or composed repetition

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jsdoc params are not fit actual params (firstSeenInRelease, payload)

* @param {*} event.firstSeenInRelease - original release name; explicit null preserves legacy absence
* @param {Object} event.payload - selected occurrence payload, used only when original metadata is undefined
* @returns {string|null} normalized original release name
*/
firstSeenInRelease(event) {
const { firstSeenInRelease, payload } = event;

return normalizeReleaseName(firstSeenInRelease !== undefined ? firstSeenInRelease : payload && payload.release);
},

/**
* Compare the original release name with the newest valid registered release.
* @param {Object} event - original event or composed repetition
* @param {Object} _args - query arguments
* @param {Object} context - request context owning the lazy per-project release cache, independent of factory lifetime
* @returns {Promise<boolean>} whether the original first appeared in the newest valid registered release
*/
isNew(event, _args, context) {
const { projectId, firstSeenInRelease, payload } = event;

if (!context.latestRegisteredReleaseByProject) {
context.latestRegisteredReleaseByProject = new Map();
}

return context.factories.releasesFactory.isNewInLatestRelease(
projectId,
firstSeenInRelease !== undefined ? firstSeenInRelease : payload && payload.release,
context.latestRegisteredReleaseByProject
);
},

/**
* Return the stored resolution release name.
* @param {Object} event - event with stored lifecycle metadata
* @returns {string|null} normalized resolution release
*/
resolvedInRelease(event) {
return normalizeReleaseName(event.resolvedInRelease);
},

/**
* Return the stored regression release name.
* @param {Object} event - event with stored lifecycle metadata
* @returns {string|null} normalized regression release
*/
regressionInRelease(event) {
return normalizeReleaseName(event.regressionInRelease);
},

/**
* Returns repetitions portion of the event
Expand Down
20 changes: 20 additions & 0 deletions src/typeDefs/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,26 @@ type Event {
"""
release: Release

"""
Release of the original event, regardless of the selected repetition
"""
firstSeenInRelease: String

"""
Whether the original event first appeared in the newest valid registered release for its project
"""
isNew: Boolean!

"""
Release in which the event was resolved
"""
resolvedInRelease: String

"""
Release in which the event regressed
"""
regressionInRelease: String

"""
Event repetitions portion
"""
Expand Down
15 changes: 11 additions & 4 deletions src/types/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,17 @@ export interface ResolverContextBase {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventsFactoryCache?: Map<string, any>;

// /**
// * SDK for working with CodeX Accounting API
// */
// accounting: Accounting;
/**
* Request-owned latest valid registered release lookups keyed by string project ID.
* Lazily initialized by Event.isNew to deduplicate in-flight lookups across rows, even with shared factories.
* Null results, including logged lookup failures, last only for this request; a new context reads afresh.
*/
latestRegisteredReleaseByProject?: Map<string, Promise<string | null>>;

/*
* SDK for working with CodeX Accounting API
* accounting: Accounting;
*/
}

/**
Expand Down
19 changes: 19 additions & 0 deletions src/utils/releaseName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Normalize absent or invalid release names without changing opaque identifiers.
*
* @param value - raw release name
* @returns the original identifier, a finite number as a string, or null
*/
export function normalizeReleaseName(value: unknown): string | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? String(value) : null;
}

if (typeof value !== 'string') {
return null;
}

const name = value.trim().toLowerCase();

return name === '' || name === 'null' || name === 'undefined' || name === 'nan' ? null : value;
}
Loading
Loading