From a402ad8c669d32de87964b350ea4a38e0fcef19e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Thu, 26 Jun 2025 12:11:12 +0200 Subject: [PATCH] feat: ability to search events by type with pagination --- .../event-search-by-type-read-model.test.ts | 110 ++++++++++++++++++ .../events/event-search-by-type-read-model.ts | 72 ++++++++++++ src/lib/types/stores/event-store.ts | 1 - 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 src/lib/features/events/event-search-by-type-read-model.test.ts create mode 100644 src/lib/features/events/event-search-by-type-read-model.ts diff --git a/src/lib/features/events/event-search-by-type-read-model.test.ts b/src/lib/features/events/event-search-by-type-read-model.test.ts new file mode 100644 index 0000000000..8359ae491a --- /dev/null +++ b/src/lib/features/events/event-search-by-type-read-model.test.ts @@ -0,0 +1,110 @@ +import EventStore from './event-store.js'; +import getLogger from '../../../test/fixtures/no-logger.js'; +import dbInit, { + type ITestDb, +} from '../../../test/e2e/helpers/database-init.js'; +import { + USER_CREATED, + USER_DELETED, + USER_UPDATED, +} from '../../events/index.js'; +import { EventSearchByType } from './event-search-by-type-read-model.js'; + +let db: ITestDb; +beforeAll(async () => { + getLogger.setMuteError(true); + db = await dbInit('event-search-by-type', getLogger); +}); + +afterAll(() => { + getLogger.setMuteError(false); +}); + +test('Can read search events by type', async () => { + const store = new EventStore(db.rawDatabase, getLogger); + const readModel = new EventSearchByType(db.rawDatabase, getLogger); + + await store.store({ + type: USER_CREATED, + createdBy: 'test', + ip: '127.0.0.1', + createdByUserId: 1, + }); + + const updatedEvents = await readModel.search({ + types: [USER_UPDATED], + offset: 0, + limit: 10, + }); + expect(updatedEvents).toBeTruthy(); + expect(updatedEvents.length).toBe(0); + + const createdEvents = await readModel.search({ + types: [USER_CREATED], + offset: 0, + limit: 10, + }); + expect(createdEvents).toBeTruthy(); + expect(createdEvents.length).toBe(1); +}); + +test('Events by type are sorted and can be paginated', async () => { + const store = new EventStore(db.rawDatabase, getLogger); + const readModel = new EventSearchByType(db.rawDatabase, getLogger); + + await store.store({ + type: USER_DELETED, + data: { id: 1 }, + createdBy: 'test', + ip: '127.0.0.1', + createdByUserId: 1, + }); + await store.store({ + type: USER_UPDATED, + data: { id: 2 }, + createdBy: 'test', + ip: '127.0.0.1', + createdByUserId: 1, + }); + await store.store({ + type: USER_DELETED, + data: { id: 3 }, + createdBy: 'test', + ip: '127.0.0.1', + createdByUserId: 1, + }); + + const allEvents = await readModel.search({ + types: [USER_UPDATED, USER_DELETED], + offset: 0, + limit: 10, + }); + expect(allEvents).toBeTruthy(); + expect(allEvents.length).toBe(3); + + const firstPage = await readModel.search({ + types: [USER_UPDATED, USER_DELETED], + offset: 0, + limit: 2, + }); + expect(firstPage).toBeTruthy(); + expect(firstPage.length).toBe(2); + + const secondPage = await readModel.search({ + types: [USER_UPDATED, USER_DELETED], + offset: 2, + limit: 2, + }); + expect(secondPage).toBeTruthy(); + expect(secondPage.length).toBe(1); + expect(secondPage[0].type).toBe(USER_DELETED); + expect(secondPage[0].data.id).toBe(3); + + const nonExistingPage = await readModel.search({ + types: [USER_UPDATED, USER_DELETED], + offset: 4, + limit: 2, + }); + expect(nonExistingPage).toBeTruthy(); + expect(nonExistingPage.length).toBe(0); +}); diff --git a/src/lib/features/events/event-search-by-type-read-model.ts b/src/lib/features/events/event-search-by-type-read-model.ts new file mode 100644 index 0000000000..014d596739 --- /dev/null +++ b/src/lib/features/events/event-search-by-type-read-model.ts @@ -0,0 +1,72 @@ +import type { IEvent, IEventType } from '../../events/index.js'; +import type { Logger, LogProvider } from '../../logger.js'; +import type { Db } from '../../db/db.js'; +import type { IEventTable } from './event-store.js'; + +const EVENT_COLUMNS = [ + 'id', + 'type', + 'created_by', + 'created_at', + 'created_by_user_id', + 'data', + 'pre_data', + 'tags', + 'feature_name', + 'project', + 'environment', +] as const; + +const TABLE = 'events'; + +export interface EventSearchByTypeQueryParams { + types: string[]; + offset: number; + limit: number; + order?: 'asc' | 'desc'; // asc by default +} + +export interface EventSearchByTypeReadModel { + search(params: EventSearchByTypeQueryParams): Promise; +} + +export class EventSearchByType implements EventSearchByTypeReadModel { + private db: Db; + + private logger: Logger; + + // a new DB has to be injected per transaction + constructor(db: Db, getLogger: LogProvider) { + this.db = db; + this.logger = getLogger('event-by-type'); + } + + async search(params: EventSearchByTypeQueryParams): Promise { + const query = this.db + .select(EVENT_COLUMNS) + .from(TABLE) + .whereIn('type', params.types) + .orderBy('id', params.order || 'asc') + .offset(params.offset) + .limit(params.limit); + + const rows = await query; + return rows.map(this.rowToEvent); + } + + rowToEvent(row: IEventTable): IEvent { + return { + id: row.id, + type: row.type as IEventType, + createdBy: row.created_by, + createdAt: row.created_at, + createdByUserId: row.created_by_user_id, + data: row.data, + preData: row.pre_data, + tags: row.tags || [], + featureName: row.feature_name, + project: row.project, + environment: row.environment, + }; + } +} diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index 98a3804047..581cd03316 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -14,7 +14,6 @@ export interface IEventSearchParams { to?: string; createdBy?: string; type?: string; - types?: string[]; environment?: string; offset: number; limit: number;