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
2 changes: 2 additions & 0 deletions Backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { CorrelationIdMiddleware } from './common/middleware/correlation-id.midd
import { InFlightRequestMiddleware } from './common/shutdown/in-flight.middleware';
import { buildWinstonOptions } from './common/logger/winston.config';
import { IndexerModule } from './indexer/indexer.module';
import { StatsModule } from './stats/stats.module';

@Module({
imports: [
Expand Down Expand Up @@ -64,6 +65,7 @@ import { IndexerModule } from './indexer/indexer.module';

// Enables the Soroban indexer when the application starts
IndexerModule,
StatsModule,
],

providers: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateIndexedEventsTable1787200000000 implements MigrationInterface {
name = 'CreateIndexedEventsTable1787200000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "indexed_events" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"event_id" character varying NOT NULL,
"ledger" bigint NOT NULL,
"event_type" character varying NOT NULL,
"stellar_gist_id" character varying,
"observed_at" TIMESTAMP NOT NULL DEFAULT now(),
"payload" jsonb,
CONSTRAINT "UQ_indexed_events_event_id" UNIQUE ("event_id"),
CONSTRAINT "PK_indexed_events_id" PRIMARY KEY ("id")
)
`);

await queryRunner.query(
`CREATE INDEX "IDX_indexed_events_event_type" ON "indexed_events" ("event_type")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_indexed_events_stellar_gist_id" ON "indexed_events" ("stellar_gist_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_indexed_events_observed_at" ON "indexed_events" ("observed_at")`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_indexed_events_observed_at"`);
await queryRunner.query(`DROP INDEX "IDX_indexed_events_stellar_gist_id"`);
await queryRunner.query(`DROP INDEX "IDX_indexed_events_event_type"`);
await queryRunner.query(`DROP TABLE "indexed_events"`);
}
}
3 changes: 3 additions & 0 deletions Backend/src/indexer/contract-indexer.integration-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ describe('Contract-Indexer Integration Flow (integration)', () => {
sorobanService,
gistRepository,
geoService,
{ record: jest.fn().mockResolvedValue(undefined) } as any,
indexerStateRepo as any,
);
});
Expand All @@ -107,6 +108,7 @@ describe('Contract-Indexer Integration Flow (integration)', () => {
{
type: 'gist_posted',
ledger: 100,
id: 'evt-int-1',
gist: {
gistId: stellarGistId,
locationCell: 's1t7d8c',
Expand Down Expand Up @@ -135,6 +137,7 @@ describe('Contract-Indexer Integration Flow (integration)', () => {
{
type: 'gist_posted',
ledger: 100,
id: 'evt-int-1',
gist: {
gistId: stellarGistId,
locationCell: 's1t7d8c',
Expand Down
54 changes: 54 additions & 0 deletions Backend/src/indexer/event-log.repository.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { EventLogRepository } from './event-log.repository';
import { PG_UNIQUE_VIOLATION } from '../gists/gist.repository';
import type { GistPostedEvent } from '../soroban/soroban.service';

describe('EventLogRepository', () => {
let repository: { create: jest.Mock; insert: jest.Mock };
let service: EventLogRepository;

const event: GistPostedEvent = {
type: 'gist_posted',
ledger: 100,
id: 'evt-1',
gist: {
gistId: 'gist-1',
locationCell: 'u4pruyd',
contentHash: 'Qm123',
author: null,
createdAt: 1700000000,
expiresAt: 1700086400,
hidden: false,
},
};

beforeEach(() => {
repository = {
create: jest.fn((data) => data),
insert: jest.fn(),
};
service = new EventLogRepository(repository as any);
});

it('inserts a row keyed by the event id', async () => {
repository.insert.mockResolvedValue(undefined);

await service.record(event);

expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({ eventId: 'evt-1', ledger: 100, eventType: 'gist_posted', stellarGistId: 'gist-1' }),
);
expect(repository.insert).toHaveBeenCalledTimes(1);
});

it('is idempotent: a duplicate event id does not throw', async () => {
repository.insert.mockRejectedValue({ code: PG_UNIQUE_VIOLATION });

await expect(service.record(event)).resolves.toBeUndefined();
});

it('re-throws non-duplicate errors', async () => {
repository.insert.mockRejectedValue(new Error('connection lost'));

await expect(service.record(event)).rejects.toThrow('connection lost');
});
});
56 changes: 56 additions & 0 deletions Backend/src/indexer/event-log.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { IndexedEvent } from './indexed-event.entity';
import { PG_UNIQUE_VIOLATION } from '../gists/gist.repository';
import type { GistRegistryEvent } from '../soroban/soroban.service';

/**
* Extracts the gist id a decoded event refers to, when it has one.
* `gist_posted`/`gist_edited` carry it nested under `gist.gistId`; the
* rest carry a bare `gistId`.
*/
function extractStellarGistId(event: GistRegistryEvent): string | null {
if (event.type === 'gist_posted' || event.type === 'gist_edited') {
return event.gist.gistId ?? null;
}
return 'gistId' in event ? event.gistId : null;
}

@Injectable()
export class EventLogRepository {
private readonly logger = new Logger(EventLogRepository.name);

constructor(
@InjectRepository(IndexedEvent)
private readonly repository: Repository<IndexedEvent>,
) {}

/**
* Append one decoded event to the log. Idempotent: replaying an event
* with an `id` already on file is a no-op, not an error — matching the
* indexer's "one malformed/duplicate event must never wedge the poll
* loop" design.
*/
async record(event: GistRegistryEvent): Promise<void> {
const entity = this.repository.create({
eventId: event.id,
ledger: event.ledger,
eventType: event.type,
stellarGistId: extractStellarGistId(event),
payload: event as unknown as Record<string, unknown>,
});

try {
await this.repository.insert(entity);
} catch (err) {
const code = (err as { code?: string })?.code;
if (code === PG_UNIQUE_VIOLATION) {
this.logger.debug(`Event ${event.id} already logged — duplicate handled`);
return;
}
throw err;
}
}
}
44 changes: 44 additions & 0 deletions Backend/src/indexer/indexed-event.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';

/**
* Append-only log of every decoded on-chain event, including event types
* that don't otherwise mutate the `gists` table. Lets stats endpoints
* derive counts of edits/hides/reports over time without those actions
* being separately tracked elsewhere.
*
* `event_id` is the Soroban RPC's own unique event id — replaying the
* same event (e.g. after an indexer restart re-fetches a ledger range)
* is idempotent via the unique constraint on that column.
*/
@Entity('indexed_events')
export class IndexedEvent {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ name: 'event_id', unique: true })
eventId: string;

@Column({ type: 'bigint' })
ledger: number;

@Index()
@Column({ name: 'event_type' })
eventType: string;

@Index()
@Column({ name: 'stellar_gist_id', nullable: true })
stellarGistId: string | null;

@Index()
@CreateDateColumn({ name: 'observed_at' })
observedAt: Date;

@Column({ type: 'jsonb', nullable: true })
payload: Record<string, unknown> | null;
}
7 changes: 5 additions & 2 deletions Backend/src/indexer/indexer.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ import { TypeOrmModule } from '@nestjs/typeorm';

import { IndexerService } from './indexer.service';
import { IndexerState } from './indexer-state.entity';
import { IndexedEvent } from './indexed-event.entity';
import { EventLogRepository } from './event-log.repository';

import { SorobanModule } from '../soroban/soroban.module';
import { GistsModule } from '../gists/gists.module';
import { GeoModule } from '../geo/geo.module';

@Module({
imports: [
TypeOrmModule.forFeature([IndexerState]),
TypeOrmModule.forFeature([IndexerState, IndexedEvent]),
SorobanModule,
GistsModule,
GeoModule,
],
providers: [IndexerService],
providers: [IndexerService, EventLogRepository],
exports: [EventLogRepository],
})
export class IndexerModule {}
18 changes: 13 additions & 5 deletions Backend/src/indexer/indexer.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function makeEvent(
return {
type: 'gist_posted',
ledger: 100,
id: `evt-${Math.random().toString(36).slice(2)}`,
gist: {
gistId: 'gist-1',
locationCell: 'u4pruyd',
Expand Down Expand Up @@ -65,6 +66,7 @@ describe('IndexerService', () => {
create: jest.Mock;
save: jest.Mock;
};
let eventLogRepo: { record: jest.Mock };

let logSpy: ReturnType<typeof jest.spyOn>;
let warnSpy: ReturnType<typeof jest.spyOn>;
Expand Down Expand Up @@ -115,10 +117,15 @@ describe('IndexerService', () => {
async (state: Partial<IndexerState>) => state,
);

eventLogRepo = {
record: jest.fn().mockResolvedValue(undefined),
};

service = new IndexerService(
soroban,
gistRepo,
geoService,
eventLogRepo as any,
indexerStateRepo as any,
);

Expand Down Expand Up @@ -576,6 +583,7 @@ describe('IndexerService', () => {
{
type: 'gist_edited',
ledger: 105,
id: 'evt-edited-1',
gist: {
gistId: 'gist-1',
locationCell: 'u4pruyd',
Expand All @@ -595,8 +603,8 @@ describe('IndexerService', () => {

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' },
{ type: 'gist_deleted', ledger: 106, gistId: 'gist-1', id: 'evt-deleted-1' },
{ type: 'gist_removed', ledger: 107, gistId: 'gist-2', id: 'evt-removed-1' },
]);

await service.poll();
Expand All @@ -607,8 +615,8 @@ describe('IndexerService', () => {

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' },
{ type: 'gist_hidden', ledger: 108, gistId: 'gist-1', id: 'evt-hidden-1' },
{ type: 'gist_unhidden', ledger: 109, gistId: 'gist-1', id: 'evt-unhidden-1' },
]);

await service.poll();
Expand All @@ -619,7 +627,7 @@ describe('IndexerService', () => {

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

await service.poll();
Expand Down
17 changes: 17 additions & 0 deletions Backend/src/indexer/indexer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { SorobanService } from '../soroban/soroban.service';
import { GistRepository, PG_UNIQUE_VIOLATION } from '../gists/gist.repository';
import { GeoService } from '../geo/geo.service';
import { IndexerState } from './indexer-state.entity';
import { EventLogRepository } from './event-log.repository';

@Injectable()
export class IndexerService {
Expand All @@ -23,6 +24,7 @@ export class IndexerService {
private readonly soroban: SorobanService,
private readonly gistRepository: GistRepository,
private readonly geoService: GeoService,
private readonly eventLogRepository: EventLogRepository,

@InjectRepository(IndexerState)
private readonly indexerStateRepository: Repository<IndexerState>,
Expand Down Expand Up @@ -75,6 +77,21 @@ export class IndexerService {
event.ledger,
);

// Log every decoded event — including types handleEvent doesn't
// otherwise persist to the gists table — so stats can be derived
// from the log alone. Isolated from handleEvent's own
// success/failure: a log-write failure must not skip the
// gists-table update, and vice versa.
try {
await this.eventLogRepository.record(event);
} catch (err) {
this.logger.warn(
`Failed to log event at ledger ${event.ledger}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}

try {
await this.handleEvent(event);
} catch (err) {
Expand Down
Loading
Loading