Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
40 changes: 40 additions & 0 deletions src/models/releasesFactory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// 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 @@ -47,6 +48,7 @@ export default class ReleasesFactory {
*/
private readonly collection: Collection<ReleaseDBScheme>;
private readonly filesCollection: Collection<ReleaseFileDBScheme>;
private readonly latestRegisteredReleaseByProject = new Map<string, Promise<string | null>>();

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.

docs missed


/**
* Creates releases factory instance
Expand All @@ -57,6 +59,44 @@ export default class ReleasesFactory {
this.filesCollection = dbConnection.collection<ReleaseFileDBScheme>('releases.files');
}

/**
* Compare against the newest registered release, not the newest observed/deployed release.
* @param projectId - event's project
* @param firstSeenRelease - original event's release
*/
public async isNewInLatestRelease(
projectId: string | ObjectId | null | undefined,
firstSeenRelease: unknown
): Promise<boolean> {
const name = normalizeReleaseName(firstSeenRelease);

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

if (!latest) {
/*
* The existing index bounds the project, but does not cover the _id sort.
*/
latest = this.collection.findOne({ projectId: key }, {
sort: { _id: -1 },
Comment thread
neSpecc marked this conversation as resolved.
Resolved
projection: {
release: 1,
_id: 0,
},
hint: {
projectId: 1,
release: 1,
},
}).then(doc => normalizeReleaseName(doc?.release));

@neSpecc neSpecc Nov 1, 2026 •

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.

findOne with sort: { _id: -1 } always takes the newest release document. normalizeReleaseName then turns '', whitespace, 'undefined', 'null', and 'NaN' into null, and name === null is false for every real event release. isNew stays false for the whole project until a newer document with a valid name is inserted.

Example: CI uploads release: process.env.VERSION while the variable is unset, so the newest document is stored as 'undefined'. Events first seen in the previous real release are not labeled New, and neither are events from that 'undefined' build.

Skip documents whose name normalizes to null and compare against the newest remaining one. The same { projectId, release } index still bounds the scan; stop at the first valid document instead of treating a placeholder as "there is no latest release". does not skip an invalid newest document currently locks this in.

const cursor = this.collection.find({ projectId: key }, {
  sort: { _id: -1 },
  projection: { release: 1, _id: 0 },
  hint: { projectId: 1, release: 1 },
});

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

    if (latestName !== null) {
      return latestName;
    }
  }

  return null;
} finally {
  await cursor.close();
}

this.latestRegisteredReleaseByProject.set(key, latest);

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.

We have 2 kind of factory calls:

  1. on request call (factory will be cleared after request along with this "cache")
  2. sharedFactories (used for SSH and GitHub) — it that case this cache would be never cleared.

Consider using DataLoader with case: true option. Like we have near planById.

}

return name === await latest;

@neSpecc neSpecc Nov 1, 2026 •

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.

If findOne rejects, this await throws out of isNew. isNew is Boolean! and DailyEvent.event is Event!, so GraphQL drops the whole Event, then the whole DailyEvent. dailyEvents: [DailyEvent] keeps the list, but every row becomes null. The rejected promise is cached on the factory, so one Mongo failure does this to every event of the project in the request. The client gets a portion of nulls and the errors array, while the events are still in the database.

false is a valid Boolean!, so the row can stay on screen and the New badge stays off for this request. The factory is created per request, so the next request tries findOne again. Log the error; otherwise a Mongo failure looks like "nothing is new".

Catch the whole method, not only the await. findOne can throw synchronously (bad options, missing hinted index) before a promise exists, and that throw never reaches a catch wrapped around return name === await latest. The cache lookup stays inside the same try, so a cached rejection is handled too.

public async isNewInLatestRelease(
  projectId: string | ObjectId | null | undefined,
  firstSeenRelease: unknown
): Promise<boolean> {
  try {
    const name = normalizeReleaseName(firstSeenRelease);

    if (!projectId || name === null) {
      return false;
    }

    const key = projectId.toString();
    let latest = this.latestRegisteredReleaseByProject.get(key);

    if (!latest) {
      latest = this.collection.findOne({ projectId: key }, {
        sort: { _id: -1 },
        projection: { release: 1, _id: 0 },
        hint: { projectId: 1, release: 1 },
      }).then(doc => normalizeReleaseName(doc?.release));
      this.latestRegisteredReleaseByProject.set(key, latest);
    }

    return name === await latest;
  } catch (error) {
    console.error('Failed to resolve latest release', error);
    return false;
  }
}

The integration test does not catch this: its release documents are tiny, findOne resolves, and isNew returns a boolean.

Add this to test/resolvers/event-release-metadata.test.ts. The hand-built schema has events: [Event!]!, so today the query comes back as data: null plus an error per row. After the catch it should come back as booleans and no errors:

it('keeps the event list when the latest-release query fails', async () => {
  findOne.mockRejectedValue(new Error('mongo unavailable'));
  const events = [
    { projectId, payload: { release: latestName } },
    { projectId, payload: { release: 'older' } },
  ];
  const { data, errors } = await graphql({
    schema,
    source: '{ events { isNew } }',
    rootValue: { events },
    contextValue: context,
  });

  expect(errors).toBeUndefined();
  expect(data?.events).toEqual([{ isNew: false }, { isNew: false }]);
  expect(findOne).toHaveBeenCalledTimes(1);
});

}

/**
* 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 Down
40 changes: 40 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,45 @@ module.exports = {
},
},
Event: {
/**
* Returns the original release, preserving null for repetitions of legacy events.

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.

Suggested change
* Returns the original release, preserving null for repetitions of legacy events.
* 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)

* @returns {string|null}
*/
firstSeenInRelease({ firstSeenInRelease, payload }) {
return normalizeReleaseName(firstSeenInRelease !== undefined ? firstSeenInRelease : payload && payload.release);
},

/**
* Compare the original release with the newest registered release.
* @param {Object} event - original event or composed repetition
* @param {Object} _args - query arguments
* @param {Object} context - request-scoped factories
* @returns {Promise<boolean>} whether the original first appeared in the newest registered release
*/
isNew({ projectId, firstSeenInRelease, payload }, _args, { factories }) {
return factories.releasesFactory.isNewInLatestRelease(
projectId,
firstSeenInRelease !== undefined ? firstSeenInRelease : payload && payload.release
);
},

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

/**
* @param {Object} event - event with stored lifecycle metadata
* @returns {string|null} normalized regression release
*/
regressionInRelease({ regressionInRelease }) {
return normalizeReleaseName(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 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
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