-
Notifications
You must be signed in to change notification settings - Fork 29
feat: implement indexer foundation #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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(); |
| 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; | ||
| } |
| 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); | ||
| }); | ||
| }); |
| 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; | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤖 Prompt for AI Agents |
||
| export { IndexedEvent } from "./db/entity/IndexedEvent.js"; | ||
| export { EventRepository } from "./db/repository.js"; | ||
| export { SorobanPoller, type PollerOptions, type PollResult } from "./poller/index.js"; | ||
| 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(); | ||
| }); | ||
| }); |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Retry classification misses HTTP 429 responses.
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * 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)) }; | ||
| } | ||
| } | ||
| } | ||
| 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/"); | ||
| }); | ||
| }); |
| 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(); |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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:
Repository: Fundable-Protocol/Backend
Length of output: 240
🏁 Script executed:
Repository: Fundable-Protocol/Backend
Length of output: 2195
🏁 Script executed:
Repository: Fundable-Protocol/Backend
Length of output: 1772
🏁 Script executed:
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 behaviorRepository: Fundable-Protocol/Backend
Length of output: 252
🌐 Web query:
TypeORM InsertQueryBuilder orIgnore execute InsertResult affected rows duplicate ignored behavior💡 Result:
In TypeORM, using
.orIgnore()withInsertQueryBuilderenables theON CONFLICT DO NOTHINGbehavior (orINSERT IGNOREfor MySQL) to suppress errors when unique constraint violations occur [1][2][3]. Key behaviors and limitations regardingInsertResultand affected rows include: 1. No Reliable Affected Row Count: TypeORM'sInsertResultdoes not consistently or accurately report the number of "affected" rows whenorIgnore()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 toorIgnore()(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 likeafterInsert()are triggered even for rows that were not actually inserted [5]. - Entity ID mapping or generated column assignment may become inconsistent because theInsertResultdoes 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:
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 anInsertQueryBuilderto 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 asINSERT IGNORE(MySQL) orON CONFLICT DO NOTHING(PostgreSQL, SQLite, etc.) [1][3][4][5]. Regarding the behavior oforIgnore()and the returnedInsertResult: 1. Effect on Affected Rows: WhenorIgnore()is used, theaffectedproperty in theInsertResultmay 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: TheorIgnore()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 (likeafterInsert()) 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, whileorIgnore()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 returnstruewhen 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. Returnfalsefor the ignored case and add coverage for the duplicate-suppressed path.🤖 Prompt for AI Agents
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.indexer/common/src/db/repository.tssorecordEventProcessed()distinguishes a real insert from anorIgnore()no-op and returnsfalsefor the ignored duplicate case.catchblock aligned with that same duplicate semantics.indexer/common/src/db/repository.test.tsfor the duplicate-suppressed path.Failed to handle agent chat message. Please try again.