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
96 changes: 67 additions & 29 deletions bun.lock

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions indexer/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,14 @@
"lint": "biome check .",
"test": "vitest run src",
"type-check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@stellar/stellar-sdk": "^12.0.0",
"pg": "^8.11.3",
"typeorm": "^0.3.20",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/pg": "^8.11.0"
}
}
18 changes: 18 additions & 0 deletions indexer/common/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { z } from "zod";

export const ConfigSchema = z.object({
RPC_URL: z.string().url().default("https://soroban-testnet.stellar.org"),
NETWORK_PASSPHRASE: z.string().default("Test SDF Network ; September 2015"),
});

export type Config = z.infer<typeof ConfigSchema>;

export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const result = ConfigSchema.safeParse(env);
if (!result.success) {
throw new Error(`Invalid configuration: ${result.error.message}`);
}
return result.data;
}

export const config = loadConfig();
23 changes: 23 additions & 0 deletions indexer/common/src/db/entity/IndexedEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, Unique } from "typeorm";

@Entity("indexed_event")
@Unique("uq_indexed_event_identity", ["contractId", "ledgerNumber", "txHash", "eventIndex"])
export class IndexedEvent {
@PrimaryGeneratedColumn("uuid")
id!: string;

@Column({ type: "varchar" })
contractId!: string;

@Column({ type: "int" })
ledgerNumber!: number;

@Column({ type: "varchar" })
txHash!: string;

@Column({ type: "int" })
eventIndex!: number;

@CreateDateColumn()
processedAt!: Date;
}
72 changes: 72 additions & 0 deletions indexer/common/src/db/repository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { type DataSource, InsertQueryBuilder, Repository } from "typeorm";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { IndexedEvent } from "./entity/IndexedEvent.js";
import { EventRepository } from "./repository.js";

describe("EventRepository", () => {
// biome-ignore lint/suspicious/noExplicitAny: mock objects
let mockDataSource: any;
// biome-ignore lint/suspicious/noExplicitAny: mock objects
let mockRepo: any;
// biome-ignore lint/suspicious/noExplicitAny: mock objects
let mockQueryBuilder: any;

beforeEach(() => {
mockQueryBuilder = {
insert: vi.fn().mockReturnThis(),
into: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
orIgnore: vi.fn().mockReturnThis(),
execute: vi.fn(),
};

mockRepo = {
count: vi.fn(),
createQueryBuilder: vi.fn(() => mockQueryBuilder),
};

mockDataSource = {
getRepository: vi.fn(() => mockRepo),
};
});

it("should return true if event is processed", async () => {
mockRepo.count.mockResolvedValue(1);
const repo = new EventRepository(mockDataSource as unknown as DataSource);
const isProcessed = await repo.isEventProcessed("contract1", 100, "txHash1", 0);

expect(isProcessed).toBe(true);
expect(mockRepo.count).toHaveBeenCalledWith({
where: {
contractId: "contract1",
ledgerNumber: 100,
txHash: "txHash1",
eventIndex: 0,
},
});
});

it("should record event successfully", async () => {
const repo = new EventRepository(mockDataSource as unknown as DataSource);
mockQueryBuilder.execute.mockResolvedValue({});

const result = await repo.recordEventProcessed("contract1", 100, "txHash1", 0);

expect(result).toBe(true);
expect(mockQueryBuilder.values).toHaveBeenCalledWith({
contractId: "contract1",
ledgerNumber: 100,
txHash: "txHash1",
eventIndex: 0,
});
expect(mockQueryBuilder.orIgnore).toHaveBeenCalledWith("uq_indexed_event_identity");
});

it("should catch unique constraint errors and return false", async () => {
const repo = new EventRepository(mockDataSource as unknown as DataSource);
mockQueryBuilder.execute.mockRejectedValue(new Error("unique constraint violation"));

const result = await repo.recordEventProcessed("contract1", 100, "txHash1", 0);
expect(result).toBe(false);
});
});
62 changes: 62 additions & 0 deletions indexer/common/src/db/repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { DataSource, Repository } from "typeorm";
import { IndexedEvent } from "./entity/IndexedEvent.js";

export class EventRepository {
private repo: Repository<IndexedEvent>;

constructor(private dataSource: DataSource) {
this.repo = this.dataSource.getRepository(IndexedEvent);
}

/**
* Checks if an event has already been processed based on its deterministic identity.
*/
async isEventProcessed(
contractId: string,
ledgerNumber: number,
txHash: string,
eventIndex: number,
): Promise<boolean> {
const count = await this.repo.count({
where: {
contractId,
ledgerNumber,
txHash,
eventIndex,
},
});
return count > 0;
}

/**
* Records an event as processed safely handling duplicates without throwing.
*/
async recordEventProcessed(
contractId: string,
ledgerNumber: number,
txHash: string,
eventIndex: number,
): Promise<boolean> {
try {
await this.repo
.createQueryBuilder()
.insert()
.into(IndexedEvent)
.values({
contractId,
ledgerNumber,
txHash,
eventIndex,
})
.orIgnore("uq_indexed_event_identity") // Safe duplicate insert
.execute();
return true;
} catch (err) {
// If the driver doesn't support orIgnore, fallback to simple insert and catch unique violation.
if (err instanceof Error && err.message.includes("unique constraint")) {
return false;
Comment on lines +40 to +57

@coderabbitai coderabbitai Bot Jun 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Inspect the repository file and surrounding call sites/tests for EventRepository behavior
git ls-files | rg '^indexer/common/src/db/repository\.ts$|^indexer/common/src/db/.*test.*|^indexer/common/src/db/.*spec.*|^indexer/common/src/db/.*repository.*'

Repository: Fundable-Protocol/Backend

Length of output: 240


🏁 Script executed:

# Read the target file with line numbers and map its structure
wc -l indexer/common/src/db/repository.ts && cat -n indexer/common/src/db/repository.ts | sed -n '1,220p'

Repository: Fundable-Protocol/Backend

Length of output: 2195


🏁 Script executed:

# Search for repository usage and tests that assert boolean semantics
rg -n "recordEventProcessed|isEventProcessed|orIgnore\\(|unique constraint|already processed|IndexedEvent" indexer/common/src

Repository: Fundable-Protocol/Backend

Length of output: 1772


🏁 Script executed:

# Read the repository tests to see what is already covered
cat -n indexer/common/src/db/repository.test.ts | sed -n '1,220p'

Repository: Fundable-Protocol/Backend

Length of output: 3082


🏁 Script executed:

# Inspect TypeORM insert/orIgnore result shape from public docs or source references
web_search
TypeORM InsertQueryBuilder orIgnore execute InsertResult affected rows duplicate ignored behavior

Repository: Fundable-Protocol/Backend

Length of output: 252


🌐 Web query:

TypeORM InsertQueryBuilder orIgnore execute InsertResult affected rows duplicate ignored behavior

💡 Result:

In TypeORM, using .orIgnore() with InsertQueryBuilder enables the ON CONFLICT DO NOTHING behavior (or INSERT IGNORE for MySQL) to suppress errors when unique constraint violations occur [1][2][3]. Key behaviors and limitations regarding InsertResult and affected rows include: 1. No Reliable Affected Row Count: TypeORM's InsertResult does not consistently or accurately report the number of "affected" rows when orIgnore() is used [4]. Because the database operation is suppressed by the SQL engine, TypeORM often cannot distinguish between successfully inserted rows and those that were ignored due to conflicts [4][5]. 2. Limitations on Conflict Targets: The .orIgnore() method currently functions as a simple boolean toggle [6][7]. It does not support specifying a conflict target (e.g., a specific unique index or column) [6][8]. Providing a string argument to orIgnore() (e.g., .orIgnore("id")) is discarded because the method coerces the input to a boolean [6][7]. Consequently, it will ignore conflicts on any unique index defined on the table, not just the one you might intend [8]. 3. Impact on Subscribers and Result Mapping: When using .orIgnore(), you may encounter issues where: - Subscribers like afterInsert() are triggered even for rows that were not actually inserted [5]. - Entity ID mapping or generated column assignment may become inconsistent because the InsertResult does not account for the skipped rows, potentially leading to incorrect data mapping back to your entity objects [4]. If you require precise control over conflict handling or need accurate feedback on which rows were inserted versus ignored, .orIgnore() is generally insufficient. It is recommended to handle conflict logic through database-specific constraints or, where supported, more granular upsert logic [1][2].

Citations:


🏁 Script executed:

cat -n indexer/common/src/db/repository.test.ts | sed -n '1,220p'

Repository: Fundable-Protocol/Backend

Length of output: 3082


🌐 Web query:

TypeORM InsertQueryBuilder orIgnore execute InsertResult affected rows duplicate ignored behavior

💡 Result:

In TypeORM, the .orIgnore() function is used within an InsertQueryBuilder to suppress errors caused by duplicate key conflicts or invalid data during an insertion [1][2]. When executed, this method instructs the database to perform an "insert ignore" operation, such as INSERT IGNORE (MySQL) or ON CONFLICT DO NOTHING (PostgreSQL, SQLite, etc.) [1][3][4][5]. Regarding the behavior of orIgnore() and the returned InsertResult: 1. Effect on Affected Rows: When orIgnore() is used, the affected property in the InsertResult may not accurately reflect the number of rows actually written to the database [6]. Because the database is instructed to skip conflicts rather than throw an error, it may still report the operation as successful, and the ORM might count intended rows rather than rows successfully committed [6]. 2. Limitations: The orIgnore() method applies a blanket "ignore" policy for the entire insert statement [7]. It does not support specifying individual columns or specific constraints to ignore (e.g., ON CONFLICT (col_name) DO NOTHING) [7][8]. It simply maps to the database's native "ignore" or "do nothing" syntax [3][4]. 3. Subscriber Behavior: A known side effect is that TypeORM's lifecycle subscribers (like afterInsert()) may still be triggered for entities that were ultimately ignored by the database [6]. Developers have reported needing to implement manual checks (e.g., verifying if a generated date or ID field is populated) within these subscribers to determine if an entity was truly persisted [6]. In summary, while orIgnore() is effective for suppressing errors during bulk inserts of potentially redundant data, it provides limited granularity and may require cautious handling of returned result data and lifecycle hooks [7][6].

Citations:


recordEventProcessed() still returns true when the row is ignored. .orIgnore("uq_indexed_event_identity") does not target that constraint; it suppresses conflicts broadly, so a duplicate can resolve without writing anything. Return false for the ignored case and add coverage for the duplicate-suppressed path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indexer/common/src/db/repository.ts` around lines 40 - 57, In
recordEventProcessed, the current .orIgnore path can silently skip duplicate
inserts but still returns true, so update the logic to detect the ignored insert
result and return false when no row was written. Use the
repository/createQueryBuilder insert flow in repository.ts to distinguish a
successful insert from a conflict-suppressed no-op, and keep the
unique-violation fallback in the catch block consistent with the duplicate case.
Add test coverage around recordEventProcessed for the duplicate-suppressed path
to verify it returns false when the insert is ignored.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recordEventProcessed() still returns true when the row is ignored. .orIgnore("uq_indexed_event_identity") does not target that constraint; it suppresses conflicts broadly, so a duplicate can resolve without writing anything. Return false for the ignored case and add coverage for the duplicate-suppressed path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pragmaticAweds, good catch — I’d make this a small two-part fix.

  • Update indexer/common/src/db/repository.ts so recordEventProcessed() distinguishes a real insert from an orIgnore() no-op and returns false for the ignored duplicate case.
  • Keep the existing unique-violation fallback in the catch block aligned with that same duplicate semantics.
  • Add a focused test in indexer/common/src/db/repository.test.ts for the duplicate-suppressed path.

Failed to handle agent chat message. Please try again.

}
throw err;
}
}
}
6 changes: 6 additions & 0 deletions indexer/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@ export const commonPackage = {
name: "@fundable-indexer/common",
role: "shared-infrastructure",
} as const;

export { config, loadConfig } from "./config/index.js";
export { createSorobanClient, sorobanClient } from "./rpc/client.js";
Comment on lines +6 to +7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Avoid import-time config/client side effects in the package entrypoint.

These barrel exports now force ./config/index.js and ./rpc/client.js to execute on every @fundable-indexer/common import. Because those modules eagerly call loadConfig() and createSorobanClient(), even consumers that only import unrelated metadata become dependent on valid runtime env and client construction at module-load time. Please keep the barrel side-effect-free by exporting factories/types here and moving the singletons behind an explicit runtime import.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indexer/common/src/index.ts` around lines 6 - 7, The package entrypoint is
pulling in eager side effects by re-exporting from config and RPC modules that
call loadConfig() and createSorobanClient() at import time. Update the barrel to
avoid importing those singleton-producing modules directly; instead export only
factories/types from index.ts and move singleton initialization behind an
explicit runtime entrypoint or separate import. Keep the symbols config,
loadConfig, createSorobanClient, and sorobanClient available without forcing
module-load configuration/client construction.

export { IndexedEvent } from "./db/entity/IndexedEvent.js";
export { EventRepository } from "./db/repository.js";
export { SorobanPoller, type PollerOptions, type PollResult } from "./poller/index.js";
52 changes: 52 additions & 0 deletions indexer/common/src/poller/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { SorobanPoller } from "./index.js";

describe("SorobanPoller", () => {
it("should successfully process events and advance cursor", async () => {
const poller = new SorobanPoller({ retryDelayMs: 1 });
const fetchEvents = vi.fn().mockResolvedValue([{ id: 1 }]);
const processEvent = vi.fn().mockResolvedValue(undefined);
const updateCursor = vi.fn().mockResolvedValue(undefined);

const result = await poller.processLedgerRange(10, 20, fetchEvents, processEvent, updateCursor);

expect(result.success).toBe(true);
expect(result.lastProcessedLedger).toBe(20);
expect(fetchEvents).toHaveBeenCalledWith(10, 20);
expect(processEvent).toHaveBeenCalledWith({ id: 1 });
expect(updateCursor).toHaveBeenCalledWith(20);
});

it("should retry on transient errors when fetching events", async () => {
const poller = new SorobanPoller({ retryDelayMs: 1, maxRetries: 2 });
let attempts = 0;
const fetchEvents = vi.fn().mockImplementation(() => {
attempts++;
if (attempts === 1) return Promise.reject(new Error("network timeout"));
return Promise.resolve([{ id: 2 }]);
});
const processEvent = vi.fn().mockResolvedValue(undefined);
const updateCursor = vi.fn().mockResolvedValue(undefined);

const result = await poller.processLedgerRange(10, 20, fetchEvents, processEvent, updateCursor);

expect(result.success).toBe(true);
expect(attempts).toBe(2);
expect(fetchEvents).toHaveBeenCalledTimes(2);
expect(processEvent).toHaveBeenCalledWith({ id: 2 });
expect(updateCursor).toHaveBeenCalledWith(20);
});

it("should not advance cursor if handler fails", async () => {
const poller = new SorobanPoller({ retryDelayMs: 1 });
const fetchEvents = vi.fn().mockResolvedValue([{ id: 3 }]);
const processEvent = vi.fn().mockRejectedValue(new Error("Handler failure"));
const updateCursor = vi.fn();

const result = await poller.processLedgerRange(10, 20, fetchEvents, processEvent, updateCursor);

expect(result.success).toBe(false);
expect(result.error?.message).toBe("Handler failure");
expect(updateCursor).not.toHaveBeenCalled();
});
});
92 changes: 92 additions & 0 deletions indexer/common/src/poller/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
export interface PollerOptions {
maxRetries?: number;
retryDelayMs?: number;
}

export interface PollResult {
success: boolean;
error?: Error;
lastProcessedLedger?: number;
}

export class SorobanPoller {
private maxRetries: number;
private retryDelayMs: number;
private isRunning = false;

constructor(options: PollerOptions = {}) {
this.maxRetries = options.maxRetries ?? 3;
this.retryDelayMs = options.retryDelayMs ?? 1000;
}

/**
* Utility to wait for a given amount of time.
*/
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Executes a block of logic with retry for transient RPC errors.
*/
public async withRetry<T>(operation: () => Promise<T>): Promise<T> {
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error) {
attempt++;
if (attempt > this.maxRetries || !this.isTransientError(error)) {
throw error;
}
await this.delay(this.retryDelayMs);
}
}
}

/**
* Determines if an error is transient and should be retried.
*/
private isTransientError(error: unknown): boolean {
// Basic transient error checking - expand based on specific RPC error formats
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("timeout") ||
message.includes("rate limit") ||
message.includes("ECONNRESET") ||
message.includes("503") ||
message.includes("504") ||
message.includes("502")
);
Comment on lines +53 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Retry classification misses HTTP 429 responses.

withRetry() claims to handle rate limiting, but isTransientError() never matches a plain 429 Too Many Requests error. Those failures will abort polling immediately instead of retrying. Add 429/too many requests handling and normalize message casing before matching.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indexer/common/src/poller/index.ts` around lines 53 - 60, The transient error
check in isTransientError() is missing plain HTTP 429 rate-limit failures, so
polling can stop instead of retrying; update the matching logic to recognize 429
and “too many requests” in addition to the existing timeout/rate limit cases.
Normalize the error message casing before checking, and keep the changes
localized to isTransientError() inside the retry path used by withRetry().

}

/**
* Processes a ledger range. Returns success=true if processing completes,
* otherwise returns an error and the operation stops without advancing cursor.
*/
public async processLedgerRange<TEvent = unknown>(
startLedger: number,
endLedger: number,
fetchEvents: (start: number, end: number) => Promise<TEvent[]>,
processEvent: (event: TEvent) => Promise<void>,
updateCursor: (ledger: number) => Promise<void>,
): Promise<PollResult> {
try {
// 1. Fetch events with retry logic for RPC
const events = await this.withRetry(() => fetchEvents(startLedger, endLedger));

// 2. Process events sequentially
for (const event of events) {
// If a handler fails, it throws, skipping the updateCursor step
await processEvent(event);
}

// 3. Update cursor ONLY if all events in the range succeeded
await updateCursor(endLedger);
return { success: true, lastProcessedLedger: endLedger };
} catch (error) {
// Return the error to surface it. Cursor is intentionally not advanced.
return { success: false, error: error instanceof Error ? error : new Error(String(error)) };
}
}
}
15 changes: 15 additions & 0 deletions indexer/common/src/rpc/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { createSorobanClient } from "./client.js";

describe("RPC Client Wrapper", () => {
it("should create a client with the provided URL", () => {
const url = "https://soroban-testnet.stellar.org";
const client = createSorobanClient(url);
expect(client.serverURL.toString()).toBe("https://soroban-testnet.stellar.org/");
});

it("should use the default configuration if no URL is provided", () => {
const client = createSorobanClient();
expect(client.serverURL.toString()).toBe("https://soroban-testnet.stellar.org/");
});
});
12 changes: 12 additions & 0 deletions indexer/common/src/rpc/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { rpc } from "@stellar/stellar-sdk";
import { config } from "../config/index.js";

/**
* Creates and configures a Soroban Server instance.
* Reads the RPC URL from the validated configuration.
*/
export function createSorobanClient(rpcUrl: string = config.RPC_URL): rpc.Server {
return new rpc.Server(rpcUrl);
}

export const sorobanClient = createSorobanClient();
3 changes: 2 additions & 1 deletion indexer/streams/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"type-check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@fundable-indexer/common": "workspace:*"
"@fundable-indexer/common": "workspace:*",
"typeorm": "^0.3.20"
}
}
Loading