diff --git a/server/.env.example b/server/.env.example index b1ae642..00b9ee9 100644 --- a/server/.env.example +++ b/server/.env.example @@ -30,6 +30,8 @@ SOROBAN_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 PRODUCTION_ESCROW_CONTRACT_ID= ESCROW_CONTRACT_ID= EVENT_POLL_INTERVAL_MS=5000 +# Retention window in days for generic Transaction event audit log rows (pruned daily). +# Domain records (Investment, Tranche, Dispute, Campaign, User) are permanently retained. EVENT_RETENTION_DAYS=7 # Ledger to start indexing from on first run (no persisted cursor yet). # Leave unset to start from the current latest ledger. diff --git a/server/package-lock.json b/server/package-lock.json index c638444..aea5d04 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -16,6 +16,7 @@ "@nestjs/core": "^10.4.4", "@nestjs/platform-express": "^10.4.4", "@nestjs/platform-socket.io": "^10.4.22", + "@nestjs/schedule": "^4.1.2", "@nestjs/terminus": "^10.2.3", "@nestjs/throttler": "^6.5.0", "@nestjs/websockets": "^10.4.22", @@ -2039,6 +2040,20 @@ "node": ">=10.2.0" } }, + "node_modules/@nestjs/schedule": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz", + "integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==", + "license": "MIT", + "dependencies": { + "cron": "3.2.1", + "uuid": "11.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", @@ -3100,6 +3115,12 @@ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -5020,6 +5041,16 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cron": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz", + "integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.4.0", + "luxon": "~3.5.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8310,6 +8341,15 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -11265,6 +11305,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -13107,6 +13160,15 @@ } } }, + "@nestjs/schedule": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz", + "integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==", + "requires": { + "cron": "3.2.1", + "uuid": "11.0.3" + } + }, "@nestjs/schematics": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", @@ -13862,6 +13924,11 @@ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==" }, + "@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==" + }, "@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -15275,6 +15342,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "cron": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz", + "integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==", + "requires": { + "@types/luxon": "~3.4.0", + "luxon": "~3.5.0" + } + }, "cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -17654,6 +17730,11 @@ "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==" }, + "luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==" + }, "magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -19687,6 +19768,11 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" }, + "uuid": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==" + }, "v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/server/package.json b/server/package.json index 65fa0bf..39759c5 100644 --- a/server/package.json +++ b/server/package.json @@ -42,6 +42,7 @@ "@nestjs/core": "^10.4.4", "@nestjs/platform-express": "^10.4.4", "@nestjs/platform-socket.io": "^10.4.22", + "@nestjs/schedule": "^4.1.2", "@nestjs/terminus": "^10.2.3", "@nestjs/throttler": "^6.5.0", "@nestjs/websockets": "^10.4.22", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 4e37097..b6f36df 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -4,6 +4,7 @@ import { ConfigModule as NestConfigModule, ConfigService, } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ConfigModule } from './config/config.module'; import { LoggerModule } from './common/logger/logger.module'; @@ -22,6 +23,7 @@ import { InvestorsModule } from './modules/investors/investors.module'; imports: [ ConfigModule, LoggerModule, + ScheduleModule.forRoot(), ThrottlerModule.forRootAsync({ imports: [NestConfigModule], inject: [ConfigService], diff --git a/server/src/indexer/indexer.module.ts b/server/src/indexer/indexer.module.ts index 3775056..b1e915d 100644 --- a/server/src/indexer/indexer.module.ts +++ b/server/src/indexer/indexer.module.ts @@ -1,11 +1,20 @@ import { Module } from '@nestjs/common'; import { SorobanEventListenerService } from './soroban-event-listener.service'; import { EventParserService } from './parsers/event-parser.service'; +import { EventRetentionService } from './retention/event-retention.service'; import { WebsocketModule } from '../websocket/websocket.module'; @Module({ imports: [WebsocketModule], - providers: [SorobanEventListenerService, EventParserService], - exports: [SorobanEventListenerService, EventParserService], + providers: [ + SorobanEventListenerService, + EventParserService, + EventRetentionService, + ], + exports: [ + SorobanEventListenerService, + EventParserService, + EventRetentionService, + ], }) export class IndexerModule {} diff --git a/server/src/indexer/retention/event-retention.service.spec.ts b/server/src/indexer/retention/event-retention.service.spec.ts new file mode 100644 index 0000000..25b26a8 --- /dev/null +++ b/server/src/indexer/retention/event-retention.service.spec.ts @@ -0,0 +1,304 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EventRetentionService } from './event-retention.service'; +import { PrismaClient } from '../../../generated/prisma/client'; + +type MockPrisma = { + transaction: { + findMany: jest.Mock; + deleteMany: jest.Mock; + }; + investment: { + deleteMany: jest.Mock; + }; + tranche: { + deleteMany: jest.Mock; + }; + dispute: { + deleteMany: jest.Mock; + }; + campaign: { + deleteMany: jest.Mock; + }; + user: { + deleteMany: jest.Mock; + }; +}; + +function makeMockPrisma(): MockPrisma { + return { + transaction: { + findMany: jest.fn().mockResolvedValue([]), + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + investment: { + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + tranche: { + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + dispute: { + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + campaign: { + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + user: { + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + }; +} + +function makeConfigService(retentionDays = 7): ConfigService { + return { + get: jest.fn((key: string) => { + if (key === 'soroban.eventRetentionDays') return retentionDays; + return undefined; + }), + } as unknown as ConfigService; +} + +describe('EventRetentionService', () => { + let prisma: MockPrisma; + let configService: ConfigService; + let service: EventRetentionService; + let logSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + prisma = makeMockPrisma(); + configService = makeConfigService(7); + service = new EventRetentionService( + prisma as unknown as PrismaClient, + configService, + ); + + logSpy = jest + .spyOn(Logger.prototype, 'log') + .mockImplementation(() => undefined); + warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('cleanupExpiredTransactions', () => { + const fixedNow = new Date('2026-08-29T12:00:00.000Z'); + // With 7 days retention, cutoff should be 2026-08-22T12:00:00.000Z + const expectedCutoff = new Date('2026-08-22T12:00:00.000Z'); + + it('calculates correct cutoff date based on EVENT_RETENTION_DAYS and deletes expired rows', async () => { + const expiredRows = [{ id: 'tx-old-1' }, { id: 'tx-old-2' }]; + prisma.transaction.findMany.mockResolvedValueOnce(expiredRows); + prisma.transaction.deleteMany.mockResolvedValueOnce({ count: 2 }); + + const result = await service.cleanupExpiredTransactions(500, fixedNow); + + expect(configService.get).toHaveBeenCalledWith( + 'soroban.eventRetentionDays', + ); + expect(prisma.transaction.findMany).toHaveBeenCalledWith({ + where: { + createdAt: { + lt: expectedCutoff, + }, + }, + select: { id: true }, + take: 500, + }); + + expect(prisma.transaction.deleteMany).toHaveBeenCalledWith({ + where: { + id: { + in: ['tx-old-1', 'tx-old-2'], + }, + }, + }); + + expect(result.deletedCount).toBe(2); + expect(result.batches).toBe(1); + expect(result.cutoffDate).toEqual(expectedCutoff); + expect(result.retentionDays).toBe(7); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + + // Verify domain tables were never touched + expect(prisma.investment.deleteMany).not.toHaveBeenCalled(); + expect(prisma.tranche.deleteMany).not.toHaveBeenCalled(); + expect(prisma.dispute.deleteMany).not.toHaveBeenCalled(); + expect(prisma.campaign.deleteMany).not.toHaveBeenCalled(); + expect(prisma.user.deleteMany).not.toHaveBeenCalled(); + }); + + it('handles empty results when no transactions are expired', async () => { + prisma.transaction.findMany.mockResolvedValueOnce([]); + + const result = await service.cleanupExpiredTransactions(500, fixedNow); + + expect(prisma.transaction.findMany).toHaveBeenCalledTimes(1); + expect(prisma.transaction.deleteMany).not.toHaveBeenCalled(); + expect(result.deletedCount).toBe(0); + expect(result.batches).toBe(0); + }); + + it('processes multiple batches when expired rows exceed batchSize', async () => { + const batchSize = 2; + const batch1 = [{ id: 'tx-1' }, { id: 'tx-2' }]; + const batch2 = [{ id: 'tx-3' }, { id: 'tx-4' }]; + const batch3 = [{ id: 'tx-5' }]; // final short batch (< batchSize) + + prisma.transaction.findMany + .mockResolvedValueOnce(batch1) + .mockResolvedValueOnce(batch2) + .mockResolvedValueOnce(batch3); + + prisma.transaction.deleteMany + .mockResolvedValueOnce({ count: 2 }) + .mockResolvedValueOnce({ count: 2 }) + .mockResolvedValueOnce({ count: 1 }); + + const result = await service.cleanupExpiredTransactions( + batchSize, + fixedNow, + ); + + expect(prisma.transaction.findMany).toHaveBeenCalledTimes(3); + expect(prisma.transaction.deleteMany).toHaveBeenCalledTimes(3); + + expect(prisma.transaction.deleteMany).toHaveBeenNthCalledWith(1, { + where: { id: { in: ['tx-1', 'tx-2'] } }, + }); + expect(prisma.transaction.deleteMany).toHaveBeenNthCalledWith(2, { + where: { id: { in: ['tx-3', 'tx-4'] } }, + }); + expect(prisma.transaction.deleteMany).toHaveBeenNthCalledWith(3, { + where: { id: { in: ['tx-5'] } }, + }); + + expect(result.deletedCount).toBe(5); + expect(result.batches).toBe(3); + }); + + it('respects a custom retention days setting (e.g. 3 days)', async () => { + const customConfig = makeConfigService(3); + const customService = new EventRetentionService( + prisma as unknown as PrismaClient, + customConfig, + ); + + // 3 days before fixedNow (2026-08-29) is 2026-08-26 + const expected3DayCutoff = new Date('2026-08-26T12:00:00.000Z'); + + prisma.transaction.findMany.mockResolvedValueOnce([{ id: 'tx-old' }]); + prisma.transaction.deleteMany.mockResolvedValueOnce({ count: 1 }); + + const result = await customService.cleanupExpiredTransactions( + 100, + fixedNow, + ); + + expect(prisma.transaction.findMany).toHaveBeenCalledWith({ + where: { + createdAt: { + lt: expected3DayCutoff, + }, + }, + select: { id: true }, + take: 100, + }); + expect(result.retentionDays).toBe(3); + expect(result.cutoffDate).toEqual(expected3DayCutoff); + }); + + it('logs warning and skips deletion if retentionDays <= 0', async () => { + const invalidConfig = makeConfigService(0); + const invalidService = new EventRetentionService( + prisma as unknown as PrismaClient, + invalidConfig, + ); + + const result = await invalidService.cleanupExpiredTransactions( + 100, + fixedNow, + ); + + expect(warnSpy).toHaveBeenCalledWith( + expect.objectContaining({ retentionDays: 0 }), + expect.stringContaining('EVENT_RETENTION_DAYS must be greater than 0'), + ); + expect(prisma.transaction.findMany).not.toHaveBeenCalled(); + expect(prisma.transaction.deleteMany).not.toHaveBeenCalled(); + expect(result.deletedCount).toBe(0); + }); + + it('logs execution and completion metrics', async () => { + prisma.transaction.findMany.mockResolvedValueOnce([{ id: 'tx-1' }]); + prisma.transaction.deleteMany.mockResolvedValueOnce({ count: 1 }); + + await service.cleanupExpiredTransactions(500, fixedNow); + + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + retentionDays: 7, + cutoffDate: expectedCutoff.toISOString(), + batchSize: 500, + }), + 'Running transaction audit log retention cleanup', + ); + + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + deletedCount: 1, + batches: 1, + cutoffDate: expectedCutoff.toISOString(), + retentionDays: 7, + }), + 'Event retention cleanup completed successfully', + ); + }); + + it('logs error and rethrows when a database exception occurs', async () => { + const dbError = new Error('Database connection failed'); + prisma.transaction.findMany.mockRejectedValueOnce(dbError); + + await expect( + service.cleanupExpiredTransactions(500, fixedNow), + ).rejects.toThrow('Database connection failed'); + + expect(errorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Database connection failed', + }), + 'Failed to complete event retention cleanup', + ); + }); + }); + + describe('handleCron', () => { + it('executes scheduled cleanup and delegates to cleanupExpiredTransactions', async () => { + const cleanupSpy = jest + .spyOn(service, 'cleanupExpiredTransactions') + .mockResolvedValue({ + deletedCount: 10, + durationMs: 42, + batches: 1, + cutoffDate: new Date(), + retentionDays: 7, + }); + + const result = await service.handleCron(); + + expect(logSpy).toHaveBeenCalledWith( + 'Starting scheduled event retention cleanup job', + ); + expect(cleanupSpy).toHaveBeenCalledTimes(1); + expect(result.deletedCount).toBe(10); + }); + }); +}); diff --git a/server/src/indexer/retention/event-retention.service.ts b/server/src/indexer/retention/event-retention.service.ts new file mode 100644 index 0000000..45b299b --- /dev/null +++ b/server/src/indexer/retention/event-retention.service.ts @@ -0,0 +1,157 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { PrismaClient } from '../../../generated/prisma/client'; + +export interface CleanupResult { + deletedCount: number; + durationMs: number; + batches: number; + cutoffDate: Date; + retentionDays: number; +} + +/** + * EventRetentionService manages the retention lifecycle of raw event audit records. + * + * Scope and Data Integrity: + * Retention pruning is strictly limited to generic `Transaction` audit log rows. + * Domain records representing actual financial commitments and on-chain state + * transitions (`Investment`, `Tranche`, `Dispute`, `Order`, `Campaign`, `User`) + * are permanently preserved to retain full auditability and historical accounting integrity. + */ +@Injectable() +export class EventRetentionService { + private readonly logger = new Logger(EventRetentionService.name); + public static readonly DEFAULT_BATCH_SIZE = 500; + + constructor( + private readonly prisma: PrismaClient, + private readonly configService: ConfigService, + ) {} + + /** + * Daily scheduled cleanup job running at midnight. Prunes `Transaction` rows + * older than the configured `EVENT_RETENTION_DAYS`. + */ + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT, { + name: 'event-retention-cleanup', + }) + async handleCron(): Promise { + this.logger.log('Starting scheduled event retention cleanup job'); + return this.cleanupExpiredTransactions(); + } + + /** + * Purges `Transaction` audit log rows older than `EVENT_RETENTION_DAYS`. + * Deletions are executed in batches to prevent database locks and memory bloat. + * + * @param batchSize Number of records to delete per batch (default: 500) + * @param now Reference timestamp (defaults to current wall-clock time) + */ + async cleanupExpiredTransactions( + batchSize = EventRetentionService.DEFAULT_BATCH_SIZE, + now: Date = new Date(), + ): Promise { + const startTime = Date.now(); + const retentionDays = + this.configService.get('soroban.eventRetentionDays') ?? 7; + + if (retentionDays <= 0) { + this.logger.warn( + { retentionDays }, + 'EVENT_RETENTION_DAYS must be greater than 0; skipping cleanup', + ); + return { + deletedCount: 0, + durationMs: Date.now() - startTime, + batches: 0, + cutoffDate: now, + retentionDays, + }; + } + + const cutoffDate = new Date( + now.getTime() - retentionDays * 24 * 60 * 60 * 1000, + ); + + this.logger.log( + { + retentionDays, + cutoffDate: cutoffDate.toISOString(), + batchSize, + }, + 'Running transaction audit log retention cleanup', + ); + + let totalDeleted = 0; + let batches = 0; + + try { + while (true) { + const expiredRows = await this.prisma.transaction.findMany({ + where: { + createdAt: { + lt: cutoffDate, + }, + }, + select: { id: true }, + take: batchSize, + }); + + if (expiredRows.length === 0) { + break; + } + + const ids = expiredRows.map((row) => row.id); + const result = await this.prisma.transaction.deleteMany({ + where: { + id: { + in: ids, + }, + }, + }); + + totalDeleted += result.count; + batches += 1; + + if (result.count === 0 || expiredRows.length < batchSize) { + break; + } + } + + const durationMs = Date.now() - startTime; + this.logger.log( + { + deletedCount: totalDeleted, + durationMs, + batches, + cutoffDate: cutoffDate.toISOString(), + retentionDays, + }, + 'Event retention cleanup completed successfully', + ); + + return { + deletedCount: totalDeleted, + durationMs, + batches, + cutoffDate, + retentionDays, + }; + } catch (error) { + const durationMs = Date.now() - startTime; + this.logger.error( + { + error: error instanceof Error ? error.message : String(error), + deletedCount: totalDeleted, + durationMs, + batches, + cutoffDate: cutoffDate.toISOString(), + }, + 'Failed to complete event retention cleanup', + ); + throw error; + } + } +} diff --git a/server/src/indexer/soroban-event-listener.service.spec.ts b/server/src/indexer/soroban-event-listener.service.spec.ts index 6118128..023c71f 100644 --- a/server/src/indexer/soroban-event-listener.service.spec.ts +++ b/server/src/indexer/soroban-event-listener.service.spec.ts @@ -263,7 +263,9 @@ describe('SorobanEventListenerService', () => { // continuation cursor), then a short 20-event page that signals the // range is exhausted. const page1Events = Array.from({ length: 100 }, (_, i) => makeEvent(i)); - const page2Events = Array.from({ length: 20 }, (_, i) => makeEvent(100 + i)); + const page2Events = Array.from({ length: 20 }, (_, i) => + makeEvent(100 + i), + ); const getEvents = jest .fn() diff --git a/server/test/event-retention.e2e-spec.ts b/server/test/event-retention.e2e-spec.ts new file mode 100644 index 0000000..d2726bb --- /dev/null +++ b/server/test/event-retention.e2e-spec.ts @@ -0,0 +1,175 @@ +import { rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { ConfigService } from '@nestjs/config'; +import { PrismaClient } from '../generated/prisma/client'; +import { PrismaLibSql } from '@prisma/adapter-libsql'; +import { EventRetentionService } from '../src/indexer/retention/event-retention.service'; +import { applyMigrations } from './apply-migrations'; + +describe('EventRetentionService (e2e)', () => { + let prisma: PrismaClient; + let service: EventRetentionService; + const dbPath = path.join( + os.tmpdir(), + `agro-retention-e2e-${process.pid}-${Date.now()}.db`, + ); + + beforeAll(async () => { + rmSync(dbPath, { force: true }); + prisma = new PrismaClient({ + adapter: new PrismaLibSql({ url: `file:${dbPath}` }), + }); + await applyMigrations(prisma); + + const configService = { + get: jest.fn((key: string) => { + if (key === 'soroban.eventRetentionDays') return 7; + return undefined; + }), + } as unknown as ConfigService; + + service = new EventRetentionService(prisma, configService); + }); + + afterAll(async () => { + await prisma.$disconnect(); + rmSync(dbPath, { force: true }); + }); + + it('prunes Transaction rows older than retention window while preserving recent transactions and domain rows', async () => { + const now = new Date('2026-08-29T12:00:00.000Z'); + const tenDaysAgo = new Date('2026-08-19T12:00:00.000Z'); + const eightDaysAgo = new Date('2026-08-21T12:00:00.000Z'); + const oneDayAgo = new Date('2026-08-28T12:00:00.000Z'); + + // 1. Seed domain data + const userAddress = 'GAUDIT_USER_1'; + const campaignId = 'retention-test-campaign-1'; + + await prisma.user.create({ + data: { + address: userAddress, + firstSeenAt: 1000n, + }, + }); + + await prisma.campaign.create({ + data: { + id: campaignId, + farmer: userAddress, + title: 'Retention Test Crop', + createdAt: 1000n, + }, + }); + + const investment = await prisma.investment.create({ + data: { + id: 'inv-1', + campaignId, + investor: userAddress, + amount: 5000n, + createdAt: tenDaysAgo, + }, + }); + + const tranche = await prisma.tranche.create({ + data: { + id: 'tranche-1', + campaignId, + recipient: userAddress, + amount: 1000n, + releasedAt: 1000n, + createdAt: tenDaysAgo, + }, + }); + + const dispute = await prisma.dispute.create({ + data: { + id: 'disp-1', + campaignId, + opener: userAddress, + reason: 'Crop issue', + status: 'Open', + openedAt: 1000n, + createdAt: tenDaysAgo, + }, + }); + + // 2. Seed Transaction audit rows with explicit created dates + // Using raw insert or standard create + update for custom createdAt + await prisma.transaction.create({ + data: { + id: 'tx-old-1', + type: 'campaign.invested', + campaignId, + userId: userAddress, + createdAt: tenDaysAgo, + }, + }); + + await prisma.transaction.create({ + data: { + id: 'tx-old-2', + type: 'campaign.tranche_released', + campaignId, + userId: userAddress, + createdAt: eightDaysAgo, + }, + }); + + await prisma.transaction.create({ + data: { + id: 'tx-recent', + type: 'campaign.dispute_opened', + campaignId, + userId: userAddress, + createdAt: oneDayAgo, + }, + }); + + // Verify initial count + const initialTxs = await prisma.transaction.findMany(); + expect(initialTxs).toHaveLength(3); + + // 3. Execute cleanup with 7-day retention + const result = await service.cleanupExpiredTransactions(500, now); + + expect(result.deletedCount).toBe(2); + expect(result.retentionDays).toBe(7); + + // 4. Assert Transaction state + const remainingTxs = await prisma.transaction.findMany(); + expect(remainingTxs).toHaveLength(1); + expect(remainingTxs[0].id).toBe('tx-recent'); + + // 5. Assert domain entities remain fully intact + const remainingInvestment = await prisma.investment.findUnique({ + where: { id: investment.id }, + }); + expect(remainingInvestment).not.toBeNull(); + expect(remainingInvestment?.amount).toBe(5000n); + + const remainingTranche = await prisma.tranche.findUnique({ + where: { id: tranche.id }, + }); + expect(remainingTranche).not.toBeNull(); + expect(remainingTranche?.amount).toBe(1000n); + + const remainingDispute = await prisma.dispute.findUnique({ + where: { id: dispute.id }, + }); + expect(remainingDispute).not.toBeNull(); + expect(remainingDispute?.reason).toBe('Crop issue'); + + const remainingCampaign = await prisma.campaign.findUnique({ + where: { id: campaignId }, + }); + expect(remainingCampaign).not.toBeNull(); + + const remainingUser = await prisma.user.findUnique({ + where: { address: userAddress }, + }); + expect(remainingUser).not.toBeNull(); + }); +});