-
Notifications
You must be signed in to change notification settings - Fork 20
feat(indexer): add handler registry, stream lifecycle handlers, distribution event handlers #48
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
Closed
pre-cious-Igwealor
wants to merge
1
commit into
Fundable-Protocol:dev
from
pre-cious-Igwealor:fix/precious-backend-30-35-38
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| import { describe, expect, test, vi } from "vitest"; | ||
|
|
||
| import { EventHandlerRegistry, createHandlerRegistry } from "./registry.js"; | ||
| import type { HandlerResult, SorobanEvent } from "./types.js"; | ||
|
|
||
| function makeEvent(overrides?: Partial<SorobanEvent>): SorobanEvent { | ||
| return { | ||
| contractId: "CONTRACT_A", | ||
| ledger: 1000, | ||
| txHash: "TX_HASH_1", | ||
| eventIndex: 0, | ||
| topics: ["StreamCreated"], | ||
| data: JSON.stringify({ streamId: "s-1" }), | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| const OK: HandlerResult = { success: true, summary: "ok" }; | ||
|
|
||
| describe("EventHandlerRegistry (issue #30)", () => { | ||
| describe("registration and matching", () => { | ||
| test("a handler with no filter fields matches every event", async () => { | ||
| const registry = createHandlerRegistry(); | ||
| const handler = vi.fn().mockResolvedValue(OK); | ||
|
|
||
| registry.register({}, handler); | ||
|
|
||
| const eventA = makeEvent({ contractId: "A" }); | ||
| const eventB = makeEvent({ contractId: "B", topics: ["Transfer"] }); | ||
|
|
||
| expect(registry.match(eventA)).toHaveLength(1); | ||
| expect(registry.match(eventB)).toHaveLength(1); | ||
| }); | ||
|
|
||
| test("contractId filter only matches events from that contract", () => { | ||
| const registry = createHandlerRegistry(); | ||
| registry.register({ contractId: "CONTRACT_A" }, vi.fn().mockResolvedValue(OK)); | ||
|
|
||
| expect(registry.match(makeEvent({ contractId: "CONTRACT_A" }))).toHaveLength(1); | ||
| expect(registry.match(makeEvent({ contractId: "CONTRACT_B" }))).toHaveLength(0); | ||
| }); | ||
|
|
||
| test("eventName filter only matches events whose first topic equals it", () => { | ||
| const registry = createHandlerRegistry(); | ||
| registry.register({ eventName: "StreamCreated" }, vi.fn().mockResolvedValue(OK)); | ||
|
|
||
| expect(registry.match(makeEvent({ topics: ["StreamCreated"] }))).toHaveLength(1); | ||
| expect(registry.match(makeEvent({ topics: ["StreamCancelled"] }))).toHaveLength(0); | ||
| }); | ||
|
|
||
| test("both contractId and eventName must match", () => { | ||
| const registry = createHandlerRegistry(); | ||
| registry.register( | ||
| { contractId: "CONTRACT_A", eventName: "StreamCreated" }, | ||
| vi.fn().mockResolvedValue(OK), | ||
| ); | ||
|
|
||
| expect( | ||
| registry.match(makeEvent({ contractId: "CONTRACT_A", topics: ["StreamCreated"] })), | ||
| ).toHaveLength(1); | ||
|
|
||
| // contract matches but event name does not | ||
| expect( | ||
| registry.match(makeEvent({ contractId: "CONTRACT_A", topics: ["Transfer"] })), | ||
| ).toHaveLength(0); | ||
|
|
||
| // event name matches but contract does not | ||
| expect( | ||
| registry.match(makeEvent({ contractId: "CONTRACT_B", topics: ["StreamCreated"] })), | ||
| ).toHaveLength(0); | ||
| }); | ||
|
|
||
| test("multiple handlers can match the same event", () => { | ||
| const registry = createHandlerRegistry(); | ||
| registry.register({ eventName: "StreamCreated" }, vi.fn().mockResolvedValue(OK)); | ||
| registry.register({ contractId: "CONTRACT_A" }, vi.fn().mockResolvedValue(OK)); | ||
| registry.register({}, vi.fn().mockResolvedValue(OK)); | ||
|
|
||
| expect(registry.match(makeEvent())).toHaveLength(3); | ||
| }); | ||
|
|
||
| test("returns handlers in registration order", async () => { | ||
| const registry = createHandlerRegistry(); | ||
| const order: number[] = []; | ||
|
|
||
| registry.register({}, async () => { order.push(1); return OK; }); | ||
| registry.register({}, async () => { order.push(2); return OK; }); | ||
| registry.register({}, async () => { order.push(3); return OK; }); | ||
|
|
||
| await registry.dispatch(makeEvent()); | ||
|
|
||
| expect(order).toEqual([1, 2, 3]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("dispatch", () => { | ||
| test("calls all matching handlers and collects results", async () => { | ||
| const registry = createHandlerRegistry(); | ||
| const h1 = vi.fn().mockResolvedValue({ success: true, summary: "h1" }); | ||
| const h2 = vi.fn().mockResolvedValue({ success: true, summary: "h2" }); | ||
|
|
||
| registry.register({ eventName: "StreamCreated" }, h1); | ||
| registry.register({ eventName: "StreamCreated" }, h2); | ||
|
|
||
| const results = await registry.dispatch(makeEvent()); | ||
|
|
||
| expect(results).toEqual([ | ||
| { success: true, summary: "h1" }, | ||
| { success: true, summary: "h2" }, | ||
| ]); | ||
| expect(h1).toHaveBeenCalledOnce(); | ||
| expect(h2).toHaveBeenCalledOnce(); | ||
| }); | ||
|
|
||
| test("catches a throwing handler and records success:false without stopping others", async () => { | ||
| const registry = createHandlerRegistry(); | ||
| const failing = vi.fn().mockRejectedValue(new Error("db connection lost")); | ||
| const succeeding = vi.fn().mockResolvedValue({ success: true }); | ||
|
|
||
| registry.register({}, failing); | ||
| registry.register({}, succeeding); | ||
|
|
||
| const results = await registry.dispatch(makeEvent()); | ||
|
|
||
| expect(results[0]).toEqual({ success: false, summary: "db connection lost" }); | ||
| expect(results[1]).toEqual({ success: true }); | ||
| expect(succeeding).toHaveBeenCalledOnce(); | ||
| }); | ||
|
|
||
| test("returns empty array when no handlers match", async () => { | ||
| const registry = createHandlerRegistry(); | ||
| registry.register({ eventName: "StreamCreated" }, vi.fn().mockResolvedValue(OK)); | ||
|
|
||
| const results = await registry.dispatch(makeEvent({ topics: ["Transfer"] })); | ||
|
|
||
| expect(results).toEqual([]); | ||
| }); | ||
|
|
||
| test("passes the full event to each handler", async () => { | ||
| const registry = createHandlerRegistry(); | ||
| const handler = vi.fn().mockResolvedValue(OK); | ||
|
|
||
| registry.register({}, handler); | ||
|
|
||
| const event = makeEvent({ contractId: "SPECIFIC", ledger: 9999, txHash: "TX_XYZ" }); | ||
| await registry.dispatch(event); | ||
|
|
||
| expect(handler).toHaveBeenCalledWith(event); | ||
| }); | ||
|
|
||
| test("is idempotent — dispatching the same event twice calls handlers twice", async () => { | ||
| const registry = createHandlerRegistry(); | ||
| const handler = vi.fn().mockResolvedValue(OK); | ||
| registry.register({}, handler); | ||
|
|
||
| const event = makeEvent(); | ||
| await registry.dispatch(event); | ||
| await registry.dispatch(event); | ||
|
|
||
| expect(handler).toHaveBeenCalledTimes(2); | ||
| }); | ||
| }); | ||
|
|
||
| describe("createHandlerRegistry factory", () => { | ||
| test("returns a fresh registry with no handlers", async () => { | ||
| const registry = createHandlerRegistry(); | ||
| expect(registry.match(makeEvent())).toHaveLength(0); | ||
| }); | ||
|
|
||
| test("two registries are independent", () => { | ||
| const r1 = createHandlerRegistry(); | ||
| const r2 = createHandlerRegistry(); | ||
|
|
||
| r1.register({}, vi.fn().mockResolvedValue(OK)); | ||
|
|
||
| expect(r1.match(makeEvent())).toHaveLength(1); | ||
| expect(r2.match(makeEvent())).toHaveLength(0); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import type { | ||
| EventHandler, | ||
| HandlerEntry, | ||
| HandlerFilter, | ||
| HandlerRegistry, | ||
| HandlerResult, | ||
| SorobanEvent, | ||
| } from "./types.js"; | ||
|
|
||
| function matchesFilter(filter: HandlerFilter, event: SorobanEvent): boolean { | ||
| if (filter.contractId !== undefined && filter.contractId !== event.contractId) { | ||
| return false; | ||
| } | ||
| if (filter.eventName !== undefined && filter.eventName !== event.topics[0]) { | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| export class EventHandlerRegistry implements HandlerRegistry { | ||
| private readonly entries: HandlerEntry[] = []; | ||
|
|
||
| register<TEvent extends SorobanEvent>( | ||
| filter: HandlerFilter, | ||
| handler: EventHandler<TEvent>, | ||
| ): void { | ||
| this.entries.push({ filter, handler: handler as EventHandler }); | ||
| } | ||
|
|
||
| match(event: SorobanEvent): ReadonlyArray<EventHandler> { | ||
| return this.entries | ||
| .filter((e) => matchesFilter(e.filter, event)) | ||
| .map((e) => e.handler); | ||
| } | ||
|
|
||
| async dispatch(event: SorobanEvent): Promise<HandlerResult[]> { | ||
| const handlers = this.match(event); | ||
| const results: HandlerResult[] = []; | ||
|
|
||
| for (const handler of handlers) { | ||
| try { | ||
| const result = await handler(event); | ||
| results.push(result); | ||
| } catch (error) { | ||
| results.push({ | ||
| success: false, | ||
| summary: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| return results; | ||
| } | ||
| } | ||
|
|
||
| export function createHandlerRegistry(): HandlerRegistry { | ||
| return new EventHandlerRegistry(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| /** | ||
| * Core handler input/result types and registry interface for the Fundable | ||
| * Soroban event indexer (issue #30). | ||
| * | ||
| * Domain packages import these shared types so they can register event handlers | ||
| * without coupling to the poller internals. | ||
| */ | ||
|
|
||
| /** Identity fields that uniquely identify any Soroban event. */ | ||
| export interface SorobanEventIdentity { | ||
| contractId: string; | ||
| ledger: number; | ||
| txHash: string; | ||
| eventIndex: number; | ||
| } | ||
|
|
||
| /** Minimal shape of a Soroban event delivered to handlers. */ | ||
| export interface SorobanEvent extends SorobanEventIdentity { | ||
| /** Array of XDR-encoded topics. The first element is conventionally the event name. */ | ||
| topics: string[]; | ||
| /** JSON-encoded event data. */ | ||
| data: string; | ||
| } | ||
|
|
||
| /** What every handler must return. */ | ||
| export interface HandlerResult { | ||
| /** Whether the handler processed the event without error. */ | ||
| success: boolean; | ||
| /** Human-readable summary (logged at debug level). */ | ||
| summary?: string; | ||
| } | ||
|
|
||
| /** The function signature every event handler must conform to. */ | ||
| export type EventHandler<TEvent extends SorobanEvent = SorobanEvent> = ( | ||
| event: TEvent, | ||
| ) => Promise<HandlerResult>; | ||
|
|
||
| /** Criteria used to match events to registered handlers. */ | ||
| export interface HandlerFilter { | ||
| /** Match only events emitted by this contract address. Omit to match all. */ | ||
| contractId?: string; | ||
| /** Match only events whose first topic equals this value (the event name). */ | ||
| eventName?: string; | ||
| } | ||
|
|
||
| /** A handler entry stored in the registry. */ | ||
| export interface HandlerEntry<TEvent extends SorobanEvent = SorobanEvent> { | ||
| filter: HandlerFilter; | ||
| handler: EventHandler<TEvent>; | ||
| } | ||
|
|
||
| /** Public API of the handler registry. */ | ||
| export interface HandlerRegistry { | ||
| /** | ||
| * Register a handler that will be called for events matching `filter`. | ||
| * Multiple handlers may match the same event; all are called in | ||
| * registration order. | ||
| */ | ||
| register<TEvent extends SorobanEvent>( | ||
| filter: HandlerFilter, | ||
| handler: EventHandler<TEvent>, | ||
| ): void; | ||
|
|
||
| /** | ||
| * Return all handlers whose filter matches the given event. | ||
| * Matching rules: | ||
| * - If `filter.contractId` is set, the event's `contractId` must equal it. | ||
| * - If `filter.eventName` is set, the event's first topic must equal it. | ||
| * - A filter with no fields matches every event. | ||
| */ | ||
| match(event: SorobanEvent): ReadonlyArray<EventHandler>; | ||
|
|
||
| /** | ||
| * Dispatch an event to every matching handler sequentially. | ||
| * Returns the array of results in the order handlers were called. | ||
| * A handler that throws is caught; `success: false` is recorded for it. | ||
| */ | ||
| dispatch(event: SorobanEvent): Promise<HandlerResult[]>; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Align the shared topic contract with
eventNamefiltering.SorobanEvent.topicsis documented as XDR-encoded here, but the registry and tests treattopics[0]as a decoded name like"StreamCreated". If dispatch receives raw Soroban topics, everyeventNamefilter will miss. Either expose decoded topics/event names in the shared event contract or make the filter explicitly operate on the encoded value.🤖 Prompt for AI Agents