Skip to content
Merged
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
32 changes: 32 additions & 0 deletions Backend/src/gists/gist.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,4 +217,36 @@ export class GistRepository {
);
return rows.map((r) => ({ cell: r.location_cell, count: parseInt(r.count, 10) }));
}

async updateContentHash(stellarGistId: string, contentHash: string): Promise<boolean> {
const result = await this.dataSource.query<Array<{ id: string }>>(
`UPDATE gists SET content_hash = $2 WHERE stellar_gist_id = $1 RETURNING id`,
[stellarGistId, contentHash],
);
return result.length > 0;
}

async setGistActive(stellarGistId: string, isActive: boolean): Promise<boolean> {
const result = await this.dataSource.query<Array<{ id: string }>>(
`UPDATE gists SET is_active = $2 WHERE stellar_gist_id = $1 RETURNING id`,
[stellarGistId, isActive],
);
return result.length > 0;
}

async setGistHidden(stellarGistId: string, hidden: boolean): Promise<boolean> {
const result = await this.dataSource.query<Array<{ id: string }>>(
`UPDATE gists SET hidden = $2 WHERE stellar_gist_id = $1 RETURNING id`,
[stellarGistId, hidden],
);
return result.length > 0;
}

async updateReportCount(stellarGistId: string, count: number): Promise<boolean> {
const result = await this.dataSource.query<Array<{ id: string }>>(
`UPDATE gists SET report_count = $2 WHERE stellar_gist_id = $1 RETURNING id`,
[stellarGistId, count],
);
return result.length > 0;
}
}
70 changes: 63 additions & 7 deletions Backend/src/indexer/indexer.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ describe('IndexerService', () => {
findByStellarGistId: jest.fn(),
existsByStellarGistId: jest.fn(),
create: jest.fn(),
updateContentHash: jest.fn(),
setGistActive: jest.fn(),
setGistHidden: jest.fn(),
updateReportCount: jest.fn(),
} as unknown as jest.Mocked<GistRepository>;

geoService = {
Expand Down Expand Up @@ -443,13 +447,9 @@ describe('IndexerService', () => {
secondEvent,
]);

gistRepo.findByStellarGistId
.mockRejectedValueOnce(
new Error('Database failure'),
)
.mockResolvedValueOnce(null);

gistRepo.existsByStellarGistId.mockResolvedValue(false);
gistRepo.existsByStellarGistId
.mockRejectedValueOnce(new Error('Database failure'))
.mockResolvedValueOnce(false);
gistRepo.create.mockResolvedValue({} as never);

await expect(
Expand Down Expand Up @@ -570,5 +570,61 @@ describe('IndexerService', () => {

await firstPoll;
});

it('handles gist_edited event by updating content_hash', async () => {
soroban.getEventsSince.mockResolvedValue([
{
type: 'gist_edited',
ledger: 105,
gist: {
gistId: 'gist-1',
locationCell: 'u4pruyd',
contentHash: 'QmUpdatedHash',
author: 'GABCD',
createdAt: 1700000000,
expiresAt: 1700086400,
hidden: false,
},
},
]);

await service.poll();

expect(gistRepo.updateContentHash).toHaveBeenCalledWith('gist-1', 'QmUpdatedHash');
});

it('handles gist_deleted and gist_removed events by setting is_active = false', async () => {
soroban.getEventsSince.mockResolvedValue([
{ type: 'gist_deleted', ledger: 106, gistId: 'gist-1' },
{ type: 'gist_removed', ledger: 107, gistId: 'gist-2' },
]);

await service.poll();

expect(gistRepo.setGistActive).toHaveBeenCalledWith('gist-1', false);
expect(gistRepo.setGistActive).toHaveBeenCalledWith('gist-2', false);
});

it('handles gist_hidden and gist_unhidden events by updating hidden column', async () => {
soroban.getEventsSince.mockResolvedValue([
{ type: 'gist_hidden', ledger: 108, gistId: 'gist-1' },
{ type: 'gist_unhidden', ledger: 109, gistId: 'gist-1' },
]);

await service.poll();

expect(gistRepo.setGistHidden).toHaveBeenCalledWith('gist-1', true);
expect(gistRepo.setGistHidden).toHaveBeenCalledWith('gist-1', false);
});

it('handles gist_reported event by updating report count', async () => {
soroban.getEventsSince.mockResolvedValue([
{ type: 'gist_reported', ledger: 110, gistId: 'gist-1', count: 3 },
]);

await service.poll();

expect(gistRepo.updateReportCount).toHaveBeenCalledWith('gist-1', 3);
});
});
});
132 changes: 69 additions & 63 deletions Backend/src/indexer/indexer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { SorobanService } from '../soroban/soroban.service';
import { GistRepository } from '../gists/gist.repository';
import { GistRepository, PG_UNIQUE_VIOLATION } from '../gists/gist.repository';
import { GeoService } from '../geo/geo.service';
import { IndexerState } from './indexer-state.entity';

Expand Down Expand Up @@ -120,75 +120,81 @@ export class IndexerService {
* dedicated persistence handler when that issue lands.
*/
private async handleEvent(event: any): Promise<void> {
if (
event.type !== 'gist_posted' &&
event.type !== 'gist_edited'
) {
this.logger.debug(
`Skipping non-indexable event: ${event.type}`,
);
return;
}

const { gist } = event;
switch (event.type) {
case 'gist_posted': {
const { gist } = event;
if (!gist) {
throw new Error(`Missing gist payload for ${event.type} event`);
}
if (!gist.gistId || !gist.locationCell) {
throw new Error(`Malformed gist event at ledger ${event.ledger}`);
}
const alreadyIndexed = await this.gistRepository.existsByStellarGistId(gist.gistId);
if (alreadyIndexed) {
this.logger.debug(`Gist ${gist.gistId} already indexed — skipping duplicate insert`);
break;
}
const { lat, lon } = this.geoService.decode(gist.locationCell);
try {
await this.gistRepository.create({
content: '',
lat,
lon,
location_cell: gist.locationCell,
content_hash: gist.contentHash,
stellar_gist_id: gist.gistId,
tx_hash: null,
});
this.logger.debug(`Indexed gist_posted: ${gist.gistId}`);
} catch (err) {
const code = (err as { code?: string })?.code;
if (code === PG_UNIQUE_VIOLATION) {
this.logger.debug(`Gist ${gist.gistId} unique violation — duplicate event handled`);
} else {
throw err;
}
}
break;
}

if (!gist) {
throw new Error(
`Missing gist payload for ${event.type} event`,
);
}
case 'gist_edited': {
const { gist } = event;
if (!gist?.gistId) {
throw new Error(`Malformed gist_edited event at ledger ${event.ledger}`);
}
await this.gistRepository.updateContentHash(gist.gistId, gist.contentHash);
this.logger.debug(`Indexed gist_edited: ${gist.gistId}`);
break;
}

if (
!gist.gistId ||
!gist.locationCell
) {
throw new Error(
`Malformed gist event at ledger ${event.ledger}`,
);
}
case 'gist_deleted':
case 'gist_removed': {
await this.gistRepository.setGistActive(event.gistId, false);
this.logger.debug(`Indexed ${event.type}: ${event.gistId}`);
break;
}

const existing =
await this.gistRepository.findByStellarGistId(
gist.gistId,
);
case 'gist_hidden': {
await this.gistRepository.setGistHidden(event.gistId, true);
this.logger.debug(`Indexed gist_hidden: ${event.gistId}`);
break;
}

if (existing) {
this.logger.debug(
`Skipping already-indexed gist ${gist.gistId}`,
);
return;
}
case 'gist_unhidden': {
await this.gistRepository.setGistHidden(event.gistId, false);
this.logger.debug(`Indexed gist_unhidden: ${event.gistId}`);
break;
}

const alreadyIndexed =
await this.gistRepository.existsByStellarGistId(
gist.gistId,
);
case 'gist_reported': {
await this.gistRepository.updateReportCount(event.gistId, event.count);
this.logger.debug(`Indexed gist_reported: ${event.gistId} count=${event.count}`);
break;
}

if (alreadyIndexed) {
this.logger.debug(
`Gist ${gist.gistId} already indexed`,
);
return;
default:
this.logger.debug(`Skipping non-indexable event: ${event.type}`);
}

const { lat, lon } =
this.geoService.decode(
gist.locationCell,
);

await this.gistRepository.create({
content: '',
lat,
lon,
location_cell: gist.locationCell,
content_hash: gist.contentHash,
stellar_gist_id: gist.gistId,
tx_hash: null,
});

this.logger.debug(
`Indexed gist ${gist.gistId} @ cell ${gist.locationCell} (ledger ${event.ledger})`,
);
}

/**
Expand Down
Loading