diff --git a/backend/src/dispute/arweave-archive.service.ts b/backend/src/dispute/arweave-archive.service.ts index ae5c7c8..80c8d5c 100644 --- a/backend/src/dispute/arweave-archive.service.ts +++ b/backend/src/dispute/arweave-archive.service.ts @@ -88,11 +88,34 @@ export class ArweaveArchiveService implements OnModuleInit { ['Step', saga.currentStep], ]; + let uploadData = jsonString; if (jsonString.length > MAX_JSON_SIZE) { - this.logger.warn(`Archive data exceeds ${MAX_JSON_SIZE / 1024}KB, truncating for Arweave bundle`); + this.logger.warn( + `Archive data exceeds ${MAX_JSON_SIZE / 1024}KB, truncating votes and stepHistory for Arweave bundle`, + ); + const truncated = { + ...archiveData, + votes: (archiveData.votes as unknown[]).slice(0, 10), + stepHistory: (archiveData.stepHistory as unknown[]).slice(0, 20), + _truncated: true, + }; + uploadData = JSON.stringify(truncated); + if (uploadData.length > MAX_JSON_SIZE) { + uploadData = JSON.stringify({ + sagaId: archiveData.sagaId, + escrowId: archiveData.escrowId, + initiator: archiveData.initiator, + verdict: archiveData.verdict, + currentStep: archiveData.currentStep, + createdAt: archiveData.createdAt, + completedAt: archiveData.completedAt, + _truncated: true, + _reason: 'archive exceeded max size after initial truncation', + }); + } } - const txId = await this.uploader.upload(jsonString, tags); + const txId = await this.uploader.upload(uploadData, tags); this.metrics?.increment('arweave_archive_success_total', { result: 'success' }); this.logger.log(`Arweave archive uploaded for saga ${saga.sagaId}, txId: ${txId}`); diff --git a/backend/src/escrow-reconciliation/escrow-reconciliation.service.ts b/backend/src/escrow-reconciliation/escrow-reconciliation.service.ts index d2c6280..0d9df1d 100644 --- a/backend/src/escrow-reconciliation/escrow-reconciliation.service.ts +++ b/backend/src/escrow-reconciliation/escrow-reconciliation.service.ts @@ -64,19 +64,8 @@ export class EscrowReconciliationService { const drifts: DriftRecord[] = []; const errors: ReconciliationError[] = []; - // Use cursor-based pagination to load escrows in batches instead of loading all at once - const BATCH_SIZE = 50; - const linked: Escrow[] = []; - let offset = 0; - let hasMore = true; - - while (hasMore) { - const batch = await this.escrowService.findAll(); - const batchLinked = batch.slice(offset, offset + BATCH_SIZE).filter(e => e.contractEscrowId); - linked.push(...batchLinked); - offset += BATCH_SIZE; - hasMore = offset < batch.length; - } + const all = await this.escrowService.findAll(); + const linked = all.filter(e => e.contractEscrowId); const knownIds = new Set(linked.map(e => e.contractEscrowId as string)); diff --git a/backend/src/escrow/graphql/escrow-graphql.integration.spec.ts b/backend/src/escrow/graphql/escrow-graphql.integration.spec.ts index 4203248..21e3b9c 100644 --- a/backend/src/escrow/graphql/escrow-graphql.integration.spec.ts +++ b/backend/src/escrow/graphql/escrow-graphql.integration.spec.ts @@ -4,7 +4,7 @@ import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; import { join } from 'path'; import request from 'supertest'; import { INestApplication } from '@nestjs/common'; -import { EscrowResolver, MilestoneResolver, DeliverableResolver } from './escrow.resolver'; +import { EscrowResolver, MilestoneResolver, DeliverableResolver, EscrowPage } from './escrow.resolver'; import { EscrowType } from './types/escrow.type'; import { MilestoneType } from './types/milestone.type'; import { DeliverableType } from './types/deliverable.type'; @@ -57,6 +57,7 @@ describe('EscrowGraphQL Integration', () => { EscrowResolver, MilestoneResolver, DeliverableResolver, + EscrowPage, { provide: EscrowService, useValue: mockEscrowService }, { provide: MilestoneLoader, useValue: mockMilestoneLoader }, { provide: DeliverableLoader, useValue: mockDeliverableLoader }, @@ -80,18 +81,19 @@ describe('EscrowGraphQL Integration', () => { // ─── escrows query ───────────────────────────────────────────────────────── describe('escrows query', () => { - it('returns an empty array when no escrows exist', async () => { + it('returns an empty page when no escrows exist', async () => { mockEscrowService.findAll.mockResolvedValue([]); const res = await request(app.getHttpServer()) .post('/graphql') - .send({ query: `{ escrows { id status } }` }); + .send({ query: `{ escrows { data { id status } total offset limit } }` }); expect(res.status).toBe(200); - expect(res.body.data.escrows).toEqual([]); + expect(res.body.data.escrows.data).toEqual([]); + expect(res.body.data.escrows.total).toBe(0); }); - it('returns escrows with scalar fields', async () => { + it('returns escrows with scalar fields and pagination metadata', async () => { const escrows = [ { id: 'esc-1', @@ -114,12 +116,33 @@ describe('EscrowGraphQL Integration', () => { const res = await request(app.getHttpServer()) .post('/graphql') - .send({ query: `{ escrows { id depositor beneficiary amountXLM status createdAt } }` }); + .send({ query: `{ escrows { data { id depositor beneficiary amountXLM status createdAt } total offset limit } }` }); expect(res.status).toBe(200); - expect(res.body.data.escrows).toHaveLength(2); - expect(res.body.data.escrows[0].id).toBe('esc-1'); - expect(res.body.data.escrows[1].status).toBe('pending'); + expect(res.body.data.escrows.data).toHaveLength(2); + expect(res.body.data.escrows.data[0].id).toBe('esc-1'); + expect(res.body.data.escrows.data[1].status).toBe('pending'); + expect(res.body.data.escrows.total).toBe(2); + }); + + it('respects offset and limit args', async () => { + const escrows = [ + { id: 'esc-1', depositor: 'GABC', beneficiary: 'GDEF', amountXLM: '100', status: 'active', createdAt: '2026-01-01T00:00:00.000Z' }, + { id: 'esc-2', depositor: 'GGHI', beneficiary: 'GJKL', amountXLM: '200', status: 'pending', createdAt: '2026-01-02T00:00:00.000Z' }, + { id: 'esc-3', depositor: 'GMNO', beneficiary: 'GPQR', amountXLM: '300', status: 'released', createdAt: '2026-01-03T00:00:00.000Z' }, + ]; + mockEscrowService.findAll.mockResolvedValue(escrows); + + const res = await request(app.getHttpServer()) + .post('/graphql') + .send({ query: `{ escrows(offset: 1, limit: 1) { data { id } total offset limit } }` }); + + expect(res.status).toBe(200); + expect(res.body.data.escrows.data).toHaveLength(1); + expect(res.body.data.escrows.data[0].id).toBe('esc-2'); + expect(res.body.data.escrows.total).toBe(3); + expect(res.body.data.escrows.offset).toBe(1); + expect(res.body.data.escrows.limit).toBe(1); }); }); @@ -291,7 +314,7 @@ describe('EscrowGraphQL Integration', () => { const res = await request(app.getHttpServer()) .post('/graphql') - .send({ query: `{ escrows { id milestones { id } } }` }); + .send({ query: `{ escrows { data { id milestones { id } } } }` }); expect(res.status).toBe(200); // DataLoader should have been called once per unique escrow ID diff --git a/backend/src/escrow/graphql/escrow.resolver.ts b/backend/src/escrow/graphql/escrow.resolver.ts index 6a85e44..cd3a32b 100644 --- a/backend/src/escrow/graphql/escrow.resolver.ts +++ b/backend/src/escrow/graphql/escrow.resolver.ts @@ -1,4 +1,4 @@ -import { Resolver, Query, Args, ResolveField, Parent } from '@nestjs/graphql'; +import { Resolver, Query, Args, ResolveField, Parent, Int, ObjectType, Field } from '@nestjs/graphql'; import { EscrowType } from './types/escrow.type'; import { MilestoneType } from './types/milestone.type'; import { DeliverableType } from './types/deliverable.type'; @@ -11,6 +11,21 @@ import { JurorVoteLoader } from './dataloader/juror-vote.loader'; import { UserProfileLoader } from './dataloader/user-profile.loader'; import { Inject } from '@nestjs/common'; +@ObjectType() +export class EscrowPage { + @Field(() => [EscrowType]) + data!: EscrowType[]; + + @Field(() => Int) + total!: number; + + @Field(() => Int) + offset!: number; + + @Field(() => Int) + limit!: number; +} + @Resolver(() => EscrowType) export class EscrowResolver { constructor( @@ -21,9 +36,21 @@ export class EscrowResolver { @Inject(UserProfileLoader) private readonly userProfileLoader: UserProfileLoader, ) {} - @Query(() => [EscrowType]) - async escrows(): Promise { - return this.escrowService.findAll() as unknown as EscrowType[]; + @Query(() => EscrowPage) + async escrows( + @Args('offset', { type: () => Int, nullable: true, defaultValue: 0 }) offset: number, + @Args('limit', { type: () => Int, nullable: true, defaultValue: 20 }) limit: number, + ): Promise { + const safeOffset = Math.max(0, offset); + const safeLimit = Math.min(Math.max(1, limit), 100); + const all = await this.escrowService.findAll(); + const data = all.slice(safeOffset, safeOffset + safeLimit); + return { + data: data as unknown as EscrowType[], + total: all.length, + offset: safeOffset, + limit: safeLimit, + }; } @Query(() => EscrowType, { nullable: true }) diff --git a/backend/src/reputation/reputation.controller.spec.ts b/backend/src/reputation/reputation.controller.spec.ts index c2fd6b7..370fe37 100644 --- a/backend/src/reputation/reputation.controller.spec.ts +++ b/backend/src/reputation/reputation.controller.spec.ts @@ -1,4 +1,4 @@ -import { INestApplication } from '@nestjs/common'; +import { INestApplication, VersioningType } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; import { ReputationController } from './reputation.controller'; @@ -91,6 +91,7 @@ describe('ReputationController Supertest integration', () => { }).compile(); app = module.createNestApplication(); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); await app.init(); });