From 68b4ac7b720e57ddc8a0c7ec9ddefa2b554c415e Mon Sep 17 00:00:00 2001 From: Adeswalla Date: Wed, 24 Jun 2026 08:08:15 +0100 Subject: [PATCH 1/3] [Bug] Resolve Pagination Inconsistencies in Notification History --- listener/API.md | 7 +- listener/src/api/events-server.ts | 3 + .../src/api/notifications-history.test.ts | 98 +++++++++++++++++++ listener/src/services/notification-history.ts | 38 +++++-- listener/src/utils/pagination.ts | 22 +++++ 5 files changed, 159 insertions(+), 9 deletions(-) diff --git a/listener/API.md b/listener/API.md index 352a926..45a048f 100644 --- a/listener/API.md +++ b/listener/API.md @@ -303,7 +303,8 @@ Returns paginated delivery execution records from `notification_execution_log`. | Name | Type | Required | Description | |-----------|--------|----------|-------------------------------------------------------------------| | limit | number | No | Maximum records per page (default `20`, max `100`) | -| offset | number | No | Number of records to skip (default `0`) | +| offset | number | No | Number of records to skip (default `0`). Prefer `cursor`. | +| cursor | string | No | Opaque token for cursor-based pagination | | status | string | No | Filter by execution status: `SUCCESS`, `FAILED`, or `RETRY` | | startDate | string | No | ISO 8601 lower bound on `execution_time` (inclusive) | | endDate | string | No | ISO 8601 upper bound on `execution_time` (inclusive) | @@ -327,7 +328,8 @@ Returns paginated delivery execution records from `notification_execution_log`. "itemCount": 5, "totalPages": 3, "limit": 2, - "offset": 0 + "offset": 0, + "nextCursor": "MjAyNC0wNi0yMFQxNTowMDowMC4wMDBaLDQy" } ``` @@ -339,6 +341,7 @@ Returns paginated delivery execution records from `notification_execution_log`. | totalPages | number | Total pages available at the requested `limit` (`0` when `itemCount` is `0`) | | limit | number | Effective page size applied to the query | | offset | number | Number of records skipped before this page | +| nextCursor | string | Opaque token to fetch the next page of results | Existing clients that read `total`, `limit`, `offset`, and `records` continue to work unchanged. New clients should prefer `itemCount` and `totalPages` for pagination UI. diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index c1153c5..4b92512 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -456,6 +456,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server { const url = new URL(req.url, 'http://localhost'); const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit')!, 10) : undefined; const offset = url.searchParams.get('offset') ? parseInt(url.searchParams.get('offset')!, 10) : undefined; + const cursor = url.searchParams.get('cursor') || undefined; const status = url.searchParams.get('status') as 'SUCCESS' | 'FAILED' | 'RETRY' | null; const startDate = url.searchParams.get('startDate'); const endDate = url.searchParams.get('endDate'); @@ -465,6 +466,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server { correlationId, limit, offset, + cursor, status, startDate, endDate, @@ -473,6 +475,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server { historyService.getHistory({ limit, offset, + cursor, status: status || undefined, startDate: startDate || undefined, endDate: endDate || undefined, diff --git a/listener/src/api/notifications-history.test.ts b/listener/src/api/notifications-history.test.ts index 65197d4..73e0b89 100644 --- a/listener/src/api/notifications-history.test.ts +++ b/listener/src/api/notifications-history.test.ts @@ -225,6 +225,104 @@ describe('GET /api/notifications/history', () => { expect(status).toBe(200); expect((body as any).limit).toBeLessThanOrEqual(100); }); + + it('supports cursor-based pagination', async () => { + server = await startServer(BASE_OPTIONS); + + for (let i = 0; i < 5; i++) { + await db.run( + `INSERT INTO scheduled_notifications + (payload, notification_type, target_recipient, execute_at, status) + VALUES (?, ?, ?, ?, ?)`, + [JSON.stringify({ test: true }), 'discord', 'test_user', new Date().toISOString(), 'COMPLETED'] + ); + } + + const times = [ + '2026-06-20T10:00:00.000Z', + '2026-06-20T10:00:01.000Z', + '2026-06-20T10:00:02.000Z', + '2026-06-20T10:00:03.000Z', + '2026-06-20T10:00:04.000Z', + ]; + + for (let i = 1; i <= 5; i++) { + await db.run( + `INSERT INTO notification_execution_log + (scheduled_notification_id, execution_attempt, execution_time, status, duration_ms) + VALUES (?, ?, ?, ?, ?)`, + [i, 1, times[i - 1], 'SUCCESS', 100] + ); + } + + const { status: status1, body: body1 } = await makeRequest( + server, + '/api/notifications/history?limit=2' + ); + + expect(status1).toBe(200); + expect((body1 as any).records.length).toBe(2); + expect((body1 as any).records[0].executionTime).toBe(times[4]); // DESC order + expect((body1 as any).records[1].executionTime).toBe(times[3]); + expect((body1 as any).nextCursor).toBeDefined(); + + const cursor = encodeURIComponent((body1 as any).nextCursor); + const { status: status2, body: body2 } = await makeRequest( + server, + `/api/notifications/history?limit=2&cursor=${cursor}` + ); + + expect(status2).toBe(200); + expect((body2 as any).records.length).toBe(2); + expect((body2 as any).records[0].executionTime).toBe(times[2]); + expect((body2 as any).records[1].executionTime).toBe(times[1]); + }); + + it('handles sorting consistency with tie-breakers', async () => { + server = await startServer(BASE_OPTIONS); + + for (let i = 0; i < 3; i++) { + await db.run( + `INSERT INTO scheduled_notifications + (payload, notification_type, target_recipient, execute_at, status) + VALUES (?, ?, ?, ?, ?)`, + [JSON.stringify({ test: true }), 'discord', 'test_user', new Date().toISOString(), 'COMPLETED'] + ); + } + + const sameTime = '2026-06-20T10:00:00.000Z'; + + // Insert multiple records with the exact same execution_time + for (let i = 1; i <= 3; i++) { + await db.run( + `INSERT INTO notification_execution_log + (scheduled_notification_id, execution_attempt, execution_time, status, duration_ms) + VALUES (?, ?, ?, ?, ?)`, + [i, 1, sameTime, 'SUCCESS', 100] + ); + } + + const { status: status1, body: body1 } = await makeRequest( + server, + '/api/notifications/history?limit=2' + ); + + expect(status1).toBe(200); + expect((body1 as any).records.length).toBe(2); + expect((body1 as any).records[0].id).toBe(3); // DESC order + expect((body1 as any).records[1].id).toBe(2); + expect((body1 as any).nextCursor).toBeDefined(); + + const cursor = encodeURIComponent((body1 as any).nextCursor); + const { status: status2, body: body2 } = await makeRequest( + server, + `/api/notifications/history?limit=2&cursor=${cursor}` + ); + + expect(status2).toBe(200); + expect((body2 as any).records.length).toBe(1); + expect((body2 as any).records[0].id).toBe(1); + }); }); describe('GET /api/notifications/history database failures', () => { diff --git a/listener/src/services/notification-history.ts b/listener/src/services/notification-history.ts index 215adf9..2077ce0 100644 --- a/listener/src/services/notification-history.ts +++ b/listener/src/services/notification-history.ts @@ -1,6 +1,6 @@ import { getDatabase } from '../database/database'; import logger from '../utils/logger'; -import { buildPaginationMetadata, normalizePaginationParams } from '../utils/pagination'; +import { buildPaginationMetadata, normalizePaginationParams, encodeCursor, decodeCursor } from '../utils/pagination'; export interface NotificationHistoryRecord { id: number; @@ -15,6 +15,7 @@ export interface NotificationHistoryRecord { export interface HistoryQueryOptions { limit?: number; offset?: number; + cursor?: string; status?: 'SUCCESS' | 'FAILED' | 'RETRY'; startDate?: string; endDate?: string; @@ -27,6 +28,7 @@ export interface PaginatedHistoryResponse { offset: number; itemCount: number; totalPages: number; + nextCursor?: string | null; } export class NotificationHistoryService { @@ -55,15 +57,25 @@ export class NotificationHistoryService { params.push(options.endDate); } + const baseConditions = [...conditions]; + const baseParams = [...params]; + + const decodedCursor = options.cursor ? decodeCursor(options.cursor) : null; + if (decodedCursor) { + conditions.push('(execution_time < ? OR (execution_time = ? AND id < ?))'); + params.push(decodedCursor.executionTime, decodedCursor.executionTime, decodedCursor.id); + } + + const countWhereClause = baseConditions.length > 0 ? `WHERE ${baseConditions.join(' AND ')}` : ''; const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; // Get total count - const countSql = `SELECT COUNT(*) as count FROM notification_execution_log ${whereClause}`; - const countResult = await this.db.get<{ count: number }>(countSql, params); + const countSql = `SELECT COUNT(*) as count FROM notification_execution_log ${countWhereClause}`; + const countResult = await this.db.get<{ count: number }>(countSql, baseParams); const total = countResult?.count || 0; // Get paginated records - const sql = ` + let sql = ` SELECT id, scheduled_notification_id as scheduledNotificationId, @@ -74,13 +86,20 @@ export class NotificationHistoryService { duration_ms as responseDuration FROM notification_execution_log ${whereClause} - ORDER BY execution_time DESC - LIMIT ? OFFSET ? + ORDER BY execution_time DESC, id DESC + LIMIT ? `; + + const queryParams = [...params, limit]; + + if (!decodedCursor) { + sql += ` OFFSET ?`; + queryParams.push(offset); + } const records = await this.db.all( sql, - [...params, limit, offset] + queryParams ); logger.info('Notification history retrieved', { @@ -92,6 +111,10 @@ export class NotificationHistoryService { const pagination = buildPaginationMetadata(total, limit, offset); + const nextCursor = records.length > 0 + ? encodeCursor(records[records.length - 1].executionTime, records[records.length - 1].id) + : null; + return { records, total, @@ -99,6 +122,7 @@ export class NotificationHistoryService { offset: pagination.offset, itemCount: pagination.itemCount, totalPages: pagination.totalPages, + nextCursor, }; } catch (error) { logger.error('Failed to retrieve notification history', { error }); diff --git a/listener/src/utils/pagination.ts b/listener/src/utils/pagination.ts index 8df07ef..6ba4b3a 100644 --- a/listener/src/utils/pagination.ts +++ b/listener/src/utils/pagination.ts @@ -5,6 +5,28 @@ export interface PaginationMetadata { offset: number; } +export interface CursorData { + executionTime: string; + id: number; +} + +export function encodeCursor(executionTime: string, id: number): string { + return Buffer.from(`${executionTime},${id}`).toString('base64'); +} + +export function decodeCursor(cursor: string): CursorData | null { + try { + const decoded = Buffer.from(cursor, 'base64').toString('utf-8'); + const [executionTime, idStr] = decoded.split(','); + if (!executionTime || !idStr) return null; + const id = parseInt(idStr, 10); + if (isNaN(id)) return null; + return { executionTime, id }; + } catch { + return null; + } +} + export interface PaginationQueryParams { limit: number; offset: number; From 3af8faeba149b5e67a9a77abc9c8e13cbce2e4dc Mon Sep 17 00:00:00 2001 From: Adeswalla Date: Wed, 24 Jun 2026 08:40:27 +0100 Subject: [PATCH 2/3] [Bug] Fix Delayed Notification Status Synchronization --- listener/data/debug-test.db | Bin 0 -> 98304 bytes .../scheduled-notification-repository.ts | 37 +++-- .../src/tests/notification-scheduler.test.ts | 135 ++++++++++++++++++ 3 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 listener/data/debug-test.db diff --git a/listener/data/debug-test.db b/listener/data/debug-test.db new file mode 100644 index 0000000000000000000000000000000000000000..454e5196194a0565f43f47e35c93ab5b3208b446 GIT binary patch literal 98304 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 literal 0 HcmV?d00001 diff --git a/listener/src/services/scheduled-notification-repository.ts b/listener/src/services/scheduled-notification-repository.ts index 592273e..8647cb8 100644 --- a/listener/src/services/scheduled-notification-repository.ts +++ b/listener/src/services/scheduled-notification-repository.ts @@ -153,6 +153,7 @@ export class ScheduledNotificationRepository { last_error = ?, error_details = ?, processing_completed_at = ?, + updated_at = ?, processor_id = NULL, lock_expires_at = NULL WHERE id = ? @@ -170,6 +171,7 @@ export class ScheduledNotificationRepository { errorMsg, errorDetails, isFailed ? now.toISOString() : null, + now.toISOString(), model.id, ]); @@ -200,14 +202,17 @@ export class ScheduledNotificationRepository { SET status = ?, processing_completed_at = ?, + updated_at = ?, processor_id = NULL, lock_expires_at = NULL WHERE id = ? `; + const now = new Date().toISOString(); await this.db.run(sql, [ NotificationStatus.COMPLETED, - new Date().toISOString(), + now, + now, id, ]); @@ -234,15 +239,17 @@ export class ScheduledNotificationRepository { last_error = ?, error_details = ?, processing_completed_at = ?, + updated_at = ?, processor_id = NULL, lock_expires_at = NULL WHERE id = ? `; + const now = new Date().toISOString(); const errorDetails = JSON.stringify({ message: error.message, stack: error.stack, - timestamp: new Date().toISOString(), + timestamp: now, }); await this.db.run(sql, [ @@ -250,7 +257,8 @@ export class ScheduledNotificationRepository { currentRetryCount + 1, error.message, errorDetails, - isFailed ? new Date().toISOString() : null, + isFailed ? now : null, + now, id, ]); @@ -372,23 +380,30 @@ export class ScheduledNotificationRepository { * Convert database row to model */ private rowToModel(row: ScheduledNotificationRow): ScheduledNotification { + // SQLite CURRENT_TIMESTAMP produces "YYYY-MM-DD HH:MM:SS" (UTC, no Z suffix). + // Appending Z ensures JS parses the value as UTC rather than local time, + // which prevents timezone-shifted timestamps in rowToModel output. + const parseUtc = (value: string | null | undefined): Date | undefined => { + if (!value) return undefined; + const normalized = value.includes('T') || value.endsWith('Z') ? value : value.replace(' ', 'T') + 'Z'; + return new Date(normalized); + }; + return { id: row.id, payload: row.payload, notificationType: row.notification_type as any, targetRecipient: row.target_recipient, - executeAt: new Date(row.execute_at), - createdAt: new Date(row.created_at), - updatedAt: new Date(row.updated_at), + executeAt: parseUtc(row.execute_at) as Date, + createdAt: parseUtc(row.created_at), + updatedAt: parseUtc(row.updated_at), status: row.status as NotificationStatus, retryCount: row.retry_count, maxRetries: row.max_retries, - processingStartedAt: row.processing_started_at ? new Date(row.processing_started_at) : null, - processingCompletedAt: row.processing_completed_at - ? new Date(row.processing_completed_at) - : null, + processingStartedAt: parseUtc(row.processing_started_at) ?? null, + processingCompletedAt: parseUtc(row.processing_completed_at) ?? null, processorId: row.processor_id, - lockExpiresAt: row.lock_expires_at ? new Date(row.lock_expires_at) : null, + lockExpiresAt: parseUtc(row.lock_expires_at) ?? null, lastError: row.last_error, errorDetails: row.error_details, eventId: row.event_id, diff --git a/listener/src/tests/notification-scheduler.test.ts b/listener/src/tests/notification-scheduler.test.ts index 496cba0..829e00e 100644 --- a/listener/src/tests/notification-scheduler.test.ts +++ b/listener/src/tests/notification-scheduler.test.ts @@ -336,3 +336,138 @@ describe('NotificationScheduler', () => { }); }); }); + +describe('Stale cache regression tests', () => { + let db: Database; + let repository: ScheduledNotificationRepository; + + const testDbPath = './data/test-stale-cache.db'; + + beforeAll(async () => { + const dbDir = path.dirname(testDbPath); + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); + } + if (fs.existsSync(testDbPath)) { + fs.unlinkSync(testDbPath); + } + db = new Database(testDbPath); + await db.initialize(); + repository = new ScheduledNotificationRepository(db); + }); + + afterAll(async () => { + await db.close(); + if (fs.existsSync(testDbPath)) { + fs.unlinkSync(testDbPath); + } + }); + + beforeEach(async () => { + await db.run('DELETE FROM notification_execution_log'); + await db.run('DELETE FROM scheduled_notifications'); + }); + + test('markAsCompleted updates updated_at — no stale timestamp after delivery', async () => { + const id = await repository.create({ + payload: { message: 'Stale test' }, + notificationType: NotificationType.DISCORD, + targetRecipient: 'test-webhook', + executeAt: new Date(Date.now() - 1000), + }); + + await repository.markAsCompleted(id); + + const notification = await repository.getById(id); + expect(notification?.status).toBe(NotificationStatus.COMPLETED); + // updated_at must be a defined, valid date — confirms the column is written on status change + expect(notification?.updatedAt).toBeDefined(); + expect(notification?.updatedAt).toBeInstanceOf(Date); + expect(isNaN(notification?.updatedAt?.getTime() ?? NaN)).toBe(false); + }); + + test('markAsFailedOrRetry updates updated_at — no stale timestamp on retry', async () => { + const id = await repository.create({ + payload: { message: 'Retry stale test' }, + notificationType: NotificationType.DISCORD, + targetRecipient: 'test-webhook', + executeAt: new Date(Date.now() - 1000), + maxRetries: 3, + }); + + await repository.markAsFailedOrRetry(id, new Error('Delivery failed'), 0, 3); + + const notification = await repository.getById(id); + expect(notification?.status).toBe(NotificationStatus.PENDING); + expect(notification?.updatedAt).toBeDefined(); + expect(notification?.updatedAt).toBeInstanceOf(Date); + expect(isNaN(notification?.updatedAt?.getTime() ?? NaN)).toBe(false); + }); + + test('markAsFailedOrRetry updates updated_at when permanently failed', async () => { + const id = await repository.create({ + payload: { message: 'Final failure stale test' }, + notificationType: NotificationType.DISCORD, + targetRecipient: 'test-webhook', + executeAt: new Date(Date.now() - 1000), + maxRetries: 2, + }); + + await repository.markAsFailedOrRetry(id, new Error('Max retries exceeded'), 2, 2); + + const notification = await repository.getById(id); + expect(notification?.status).toBe(NotificationStatus.FAILED); + expect(notification?.updatedAt).toBeDefined(); + expect(notification?.updatedAt).toBeInstanceOf(Date); + expect(isNaN(notification?.updatedAt?.getTime() ?? NaN)).toBe(false); + }); + + test('recoverStaleLocks updates updated_at — no stale status after lock recovery', async () => { + const id = await repository.create({ + payload: { message: 'Stale lock test' }, + notificationType: NotificationType.DISCORD, + targetRecipient: 'test-webhook', + executeAt: new Date(Date.now() - 1000), + }); + + await repository.fetchAndLockPendingNotifications('processor-stale', 30000, 10); + + // Expire the lock + const pastLock = new Date(Date.now() - 1000); + await db.run('UPDATE scheduled_notifications SET lock_expires_at = ? WHERE id = ?', [ + pastLock.toISOString(), + id, + ]); + + await repository.recoverStaleLocks(); + + const recovered = await repository.getById(id); + expect(recovered?.status).toBe(NotificationStatus.PENDING); + expect(recovered?.processorId).toBeNull(); + // updated_at must be a valid date — confirms the column is written on lock recovery + expect(recovered?.updatedAt).toBeDefined(); + expect(recovered?.updatedAt).toBeInstanceOf(Date); + expect(isNaN(recovered?.updatedAt?.getTime() ?? NaN)).toBe(false); + }); + + test('stale status is not served after delivery: re-fetch reflects COMPLETED', async () => { + const id = await repository.create({ + payload: { message: 'Delivery stale check' }, + notificationType: NotificationType.DISCORD, + targetRecipient: 'test-webhook', + executeAt: new Date(Date.now() - 1000), + }); + + // Simulate the state a UI would cache before delivery + const beforeDelivery = await repository.getById(id); + expect(beforeDelivery?.status).toBe(NotificationStatus.PENDING); + + // Delivery happens + await repository.markAsCompleted(id); + + // Re-fetch (simulating a poll/refresh) must return the new status + const afterDelivery = await repository.getById(id); + expect(afterDelivery?.status).toBe(NotificationStatus.COMPLETED); + expect(afterDelivery?.status).not.toBe(beforeDelivery?.status); + }); +}); From 74796d465b8a074790f8dab997f515c8ed826e43 Mon Sep 17 00:00:00 2001 From: Adeswalla Date: Thu, 25 Jun 2026 11:52:08 +0100 Subject: [PATCH 3/3] [CI] Fix all CI check failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dashboard: raise jest testTimeout to 15s to prevent flaky timeouts when render-benchmark.test runs in parallel with async userEvent tests - dashboard: fix stale-cache deduplication (Map last-write-wins, correct appendEvents merge order) and add regression tests - dashboard: add 15s polling to EventExplorerPage and EventsPage so status updates appear without a manual refresh - listener: fix notification-scheduler-refactored test — fetchAndLock was picking up both past notifications; restore item 2 to PENDING so only item 3 is left as PROCESSING with an expired lock - listener: remove accidentally committed debug-test.db; add data/ to .gitignore --- dashboard/jest.config.cjs | 1 + dashboard/src/pages/EventExplorerPage.tsx | 16 +++++++ dashboard/src/pages/EventsPage.tsx | 13 ++++++ dashboard/src/store/eventStore.test.tsx | 42 ++++++++++++++++++ dashboard/src/store/eventStore.ts | 19 ++++---- listener/.gitignore | 1 + listener/data/debug-test.db | Bin 98304 -> 0 bytes .../notification-scheduler-refactored.test.ts | 13 +++++- 8 files changed, 93 insertions(+), 12 deletions(-) delete mode 100644 listener/data/debug-test.db diff --git a/dashboard/jest.config.cjs b/dashboard/jest.config.cjs index 87d18b3..746a4e4 100644 --- a/dashboard/jest.config.cjs +++ b/dashboard/jest.config.cjs @@ -1,6 +1,7 @@ module.exports = { preset: 'ts-jest/presets/default-esm', testEnvironment: 'jsdom', + testTimeout: 15000, setupFilesAfterEnv: ['/jest.setup.cjs'], extensionsToTreatAsEsm: ['.ts', '.tsx'], moduleNameMapper: { diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index 724938b..33c64f9 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -13,6 +13,7 @@ import { restoreWalletSession } from '../services/wallet'; const DEFAULT_EVENT_COUNT = 5000; const DEFAULT_LIMIT = 12; const API_URL = import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/api/events'; +const POLL_INTERVAL_MS = 15_000; function parsePageParam(search: string) { const params = new URLSearchParams(search); @@ -68,8 +69,23 @@ export function EventExplorerPage() { loadEvents(); + // Poll for status updates so delivered/failed notifications are reflected + // without requiring a manual page refresh. + const intervalId = setInterval(async () => { + try { + const remoteEvents = await fetchEvents(API_URL); + if (!cancelled) { + setEvents(remoteEvents); + } + } catch { + // Silently ignore polling errors — the error banner is reserved for + // the initial load failure so background polls don't disrupt the user. + } + }, POLL_INTERVAL_MS); + return () => { cancelled = true; + clearInterval(intervalId); }; }, [setEvents, setError, setLoading]); diff --git a/dashboard/src/pages/EventsPage.tsx b/dashboard/src/pages/EventsPage.tsx index e2415dc..a07db07 100644 --- a/dashboard/src/pages/EventsPage.tsx +++ b/dashboard/src/pages/EventsPage.tsx @@ -11,6 +11,7 @@ import { restoreWalletSession } from '../services/wallet'; const DEFAULT_EVENT_COUNT = 5000; const API_URL = import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/api/events'; +const POLL_INTERVAL_MS = 15_000; export function EventsPage() { const setEvents = useEventStore((state) => state.setEvents); @@ -48,8 +49,20 @@ export function EventsPage() { loadEvents(); + const intervalId = setInterval(async () => { + try { + const remoteEvents = await fetchEvents(API_URL); + if (!cancelled) { + setEvents(remoteEvents); + } + } catch { + // Silently ignore background poll errors. + } + }, POLL_INTERVAL_MS); + return () => { cancelled = true; + clearInterval(intervalId); }; }, [setEvents, setError, setLoading]); diff --git a/dashboard/src/store/eventStore.test.tsx b/dashboard/src/store/eventStore.test.tsx index c7771aa..4671977 100644 --- a/dashboard/src/store/eventStore.test.tsx +++ b/dashboard/src/store/eventStore.test.tsx @@ -115,3 +115,45 @@ describe('pagination + filter interaction', () => { expect(after[0].textContent).toContain('Withdrawal'); }); }); + +describe('stale cache regression tests', () => { + beforeEach(() => { + useEventStore.setState({ events: [], filters: { search: '', contractAddress: 'all', eventType: 'all' }, isLoading: false, error: null }); + }); + + it('setEvents with an updated record replaces the stale copy, not silently dropped', () => { + const [event] = generateMockEvents(1); + const staleEvent = { ...event, value: 'stale-value' }; + const freshEvent = { ...event, value: 'fresh-value' }; + + useEventStore.getState().setEvents([staleEvent]); + // Simulate a poll returning the same eventId with updated data + useEventStore.getState().setEvents([freshEvent]); + + const stored = useEventStore.getState().events; + expect(stored).toHaveLength(1); + expect(stored[0].value).toBe('fresh-value'); + }); + + it('appendEvents with an updated record replaces the stale copy', () => { + const [event] = generateMockEvents(1); + const staleEvent = { ...event, value: 'stale-value' }; + const freshEvent = { ...event, value: 'fresh-value' }; + + useEventStore.getState().setEvents([staleEvent]); + // Poll brings in the updated record + useEventStore.getState().appendEvents([freshEvent]); + + const stored = useEventStore.getState().events; + expect(stored).toHaveLength(1); + expect(stored[0].value).toBe('fresh-value'); + }); + + it('setEvents keeps order stable when no duplicates are present', () => { + const [a, b, c] = generateMockEvents(3); + useEventStore.getState().setEvents([a, b, c]); + + const stored = useEventStore.getState().events; + expect(stored.map((e) => e.eventId)).toEqual([a.eventId, b.eventId, c.eventId]); + }); +}); diff --git a/dashboard/src/store/eventStore.ts b/dashboard/src/store/eventStore.ts index 5dcd525..f2f633f 100644 --- a/dashboard/src/store/eventStore.ts +++ b/dashboard/src/store/eventStore.ts @@ -17,16 +17,13 @@ interface EventStoreState { } function dedupeEventsById(events: BlockchainEvent[]): BlockchainEvent[] { - const seenEventIds = new Set(); - - return events.filter((event) => { - if (seenEventIds.has(event.eventId)) { - return false; - } - - seenEventIds.add(event.eventId); - return true; - }); + // Use a Map to keep the last-seen record for each eventId so that status + // updates (newer entries) overwrite stale cached copies rather than being dropped. + const byId = new Map(); + for (const event of events) { + byId.set(event.eventId, event); + } + return Array.from(byId.values()); } export const useEventStore = create((set) => ({ @@ -41,6 +38,8 @@ export const useEventStore = create((set) => ({ setEvents: (events) => set({ events: dedupeEventsById(events) }), appendEvents: (events) => set((state) => ({ + // Existing events go first so incoming (fresh) events overwrite stale + // copies when the Map processes duplicates last-write-wins. events: dedupeEventsById([...state.events, ...events]), })), setSearch: (search) => diff --git a/listener/.gitignore b/listener/.gitignore index aa0926a..caf428f 100644 --- a/listener/.gitignore +++ b/listener/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .env *.log +data/ diff --git a/listener/data/debug-test.db b/listener/data/debug-test.db deleted file mode 100644 index 454e5196194a0565f43f47e35c93ab5b3208b446..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98304 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 diff --git a/listener/src/tests/notification-scheduler-refactored.test.ts b/listener/src/tests/notification-scheduler-refactored.test.ts index 51f5897..dd9128e 100644 --- a/listener/src/tests/notification-scheduler-refactored.test.ts +++ b/listener/src/tests/notification-scheduler-refactored.test.ts @@ -307,7 +307,7 @@ describe('NotificationScheduler (Refactored)', () => { ); // 2. Create a notification in the past (overdue, pending) - await repository.create( + const overdueId = await repository.create( NotificationFixtureBuilder .aScheduledNotificationInput() .forImmediateExecution() @@ -321,14 +321,23 @@ describe('NotificationScheduler (Refactored)', () => { .forImmediateExecution() .build() ); + // Lock only the stale notification — fetchAndLock picks up past items by priority order, + // so we lock both past items then restore item 2 to PENDING to isolate the stale case. await repository.fetchAndLockPendingNotifications('processor-1', 30000, 10); + await db.run( + "UPDATE scheduled_notifications SET status = 'PENDING', processor_id = NULL, lock_expires_at = NULL WHERE id = ?", + [overdueId] + ); const pastLock = NotificationFixtureBuilder.dates.past(1000); await db.run('UPDATE scheduled_notifications SET lock_expires_at = ? WHERE id = ?', [ pastLock.toISOString(), staleId, ]); - // Get stats BEFORE recovery + // Get stats BEFORE recovery: + // - item 1: PENDING (future) + // - item 2: PENDING (restored, overdue) + // - item 3: PROCESSING with expired lock → getStats adjusts to PENDING const stats = await repository.getStats(); expect(stats.pending).toBe(3);