From 2147d4ecf012d0ea85fb52bc9af06ce3c223077b Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Mon, 28 Oct 2024 18:05:02 -0300 Subject: [PATCH] feat: dataLoader class --- packages/data-flow/package.json | 1 + .../data-flow/src/data-loader/dataLoader.ts | 82 ++++++++ .../handlers/application.handlers.ts | 31 +++ .../src/data-loader/handlers/index.ts | 3 + .../data-loader/handlers/project.handlers.ts | 60 ++++++ .../data-loader/handlers/round.handlers.ts | 87 ++++++++ packages/data-flow/src/data-loader/index.ts | 1 + .../data-flow/src/data-loader/types/index.ts | 9 + packages/data-flow/src/exceptions/index.ts | 1 + .../exceptions/invalidChangeset.exception.ts | 5 + packages/data-flow/src/external.ts | 2 + .../src/interfaces/dataLoader.interface.ts | 13 ++ .../src/interfaces/eventsFetcher.interface.ts | 20 ++ packages/data-flow/src/interfaces/index.ts | 22 +- packages/data-flow/src/internal.ts | 4 + packages/data-flow/src/types/index.ts | 9 + .../test/data-loader/dataLoader.spec.ts | 103 +++++++++ .../handlers/application.handlers.spec.ts | 48 +++++ .../handlers/project.handlers.spec.ts | 160 ++++++++++++++ .../handlers/round.handlers.spec.ts | 195 ++++++++++++++++++ pnpm-lock.yaml | 3 + 21 files changed, 839 insertions(+), 20 deletions(-) create mode 100644 packages/data-flow/src/data-loader/dataLoader.ts create mode 100644 packages/data-flow/src/data-loader/handlers/application.handlers.ts create mode 100644 packages/data-flow/src/data-loader/handlers/index.ts create mode 100644 packages/data-flow/src/data-loader/handlers/project.handlers.ts create mode 100644 packages/data-flow/src/data-loader/handlers/round.handlers.ts create mode 100644 packages/data-flow/src/data-loader/index.ts create mode 100644 packages/data-flow/src/data-loader/types/index.ts create mode 100644 packages/data-flow/src/exceptions/invalidChangeset.exception.ts create mode 100644 packages/data-flow/src/interfaces/dataLoader.interface.ts create mode 100644 packages/data-flow/src/interfaces/eventsFetcher.interface.ts create mode 100644 packages/data-flow/src/types/index.ts create mode 100644 packages/data-flow/test/data-loader/dataLoader.spec.ts create mode 100644 packages/data-flow/test/data-loader/handlers/application.handlers.spec.ts create mode 100644 packages/data-flow/test/data-loader/handlers/project.handlers.spec.ts create mode 100644 packages/data-flow/test/data-loader/handlers/round.handlers.spec.ts diff --git a/packages/data-flow/package.json b/packages/data-flow/package.json index 9d7d84f..4502619 100644 --- a/packages/data-flow/package.json +++ b/packages/data-flow/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@grants-stack-indexer/indexer-client": "workspace:*", + "@grants-stack-indexer/repository": "workspace:*", "@grants-stack-indexer/shared": "workspace:*", "viem": "2.21.19" } diff --git a/packages/data-flow/src/data-loader/dataLoader.ts b/packages/data-flow/src/data-loader/dataLoader.ts new file mode 100644 index 0000000..5aa6cfc --- /dev/null +++ b/packages/data-flow/src/data-loader/dataLoader.ts @@ -0,0 +1,82 @@ +import { + Changeset, + IApplicationRepository, + IProjectRepository, + IRoundRepository, +} from "@grants-stack-indexer/repository"; + +import { ExecutionResult, IDataLoader, InvalidChangeset } from "../internal.js"; +import { + createApplicationHandlers, + createProjectHandlers, + createRoundHandlers, +} from "./handlers/index.js"; +import { ChangesetHandlers } from "./types/index.js"; + +/** + * DataLoader is responsible for applying changesets to the database. + * It works by: + * 1. Taking an array of changesets representing data modifications + * 2. Validating that handlers exist for all changeset types + * 3. Sequentially executing each changeset using the appropriate handler + * 4. Tracking execution results including successes and failures + * 5. Breaking execution if any changeset fails + * + * The handlers are initialized for different entity types (projects, rounds, applications) + * and stored in a map for lookup during execution. + */ + +export class DataLoader implements IDataLoader { + private readonly handlers: ChangesetHandlers; + + constructor( + private readonly repositories: { + project: IProjectRepository; + round: IRoundRepository; + application: IApplicationRepository; + }, + ) { + this.handlers = { + ...createProjectHandlers(repositories.project), + ...createRoundHandlers(repositories.round), + ...createApplicationHandlers(repositories.application), + }; + } + + /** @inheritdoc */ + public async applyChanges(changesets: Changeset[]): Promise { + const result: ExecutionResult = { + changesets: [], + numExecuted: 0, + numSuccessful: 0, + numFailed: 0, + errors: [], + }; + + const invalidTypes = changesets.filter((changeset) => !this.handlers[changeset.type]); + if (invalidTypes.length > 0) { + throw new InvalidChangeset(invalidTypes.map((changeset) => changeset.type)); + } + + //TODO: research how to manage transactions so we can rollback on error + for (const changeset of changesets) { + result.numExecuted++; + try { + await this.handlers[changeset.type](changeset as never); + result.changesets.push(changeset.type); + result.numSuccessful++; + } catch (error) { + result.numFailed++; + result.errors.push( + `Failed to apply changeset ${changeset.type}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + console.error(error); + break; + } + } + + return result; + } +} diff --git a/packages/data-flow/src/data-loader/handlers/application.handlers.ts b/packages/data-flow/src/data-loader/handlers/application.handlers.ts new file mode 100644 index 0000000..5e1cbb6 --- /dev/null +++ b/packages/data-flow/src/data-loader/handlers/application.handlers.ts @@ -0,0 +1,31 @@ +import { IApplicationRepository } from "@grants-stack-indexer/repository"; + +import { ChangesetHandler } from "../types/index.js"; + +/** + * Collection of handlers for application-related operations. + * Each handler corresponds to a specific Application changeset type. + */ +export type ApplicationHandlers = { + InsertApplication: ChangesetHandler<"InsertApplication">; + UpdateApplication: ChangesetHandler<"UpdateApplication">; +}; + +/** + * Creates handlers for managing application-related operations. + * + * @param repository - The application repository instance used for database operations + * @returns An object containing all application-related handlers + */ +export const createApplicationHandlers = ( + repository: IApplicationRepository, +): ApplicationHandlers => ({ + InsertApplication: (async (changeset): Promise => { + await repository.insertApplication(changeset.args); + }) satisfies ChangesetHandler<"InsertApplication">, + + UpdateApplication: (async (changeset): Promise => { + const { chainId, roundId, applicationId, application } = changeset.args; + await repository.updateApplication({ chainId, roundId, id: applicationId }, application); + }) satisfies ChangesetHandler<"UpdateApplication">, +}); diff --git a/packages/data-flow/src/data-loader/handlers/index.ts b/packages/data-flow/src/data-loader/handlers/index.ts new file mode 100644 index 0000000..e92262b --- /dev/null +++ b/packages/data-flow/src/data-loader/handlers/index.ts @@ -0,0 +1,3 @@ +export * from "./application.handlers.js"; +export * from "./project.handlers.js"; +export * from "./round.handlers.js"; diff --git a/packages/data-flow/src/data-loader/handlers/project.handlers.ts b/packages/data-flow/src/data-loader/handlers/project.handlers.ts new file mode 100644 index 0000000..2acbaa2 --- /dev/null +++ b/packages/data-flow/src/data-loader/handlers/project.handlers.ts @@ -0,0 +1,60 @@ +import { IProjectRepository } from "@grants-stack-indexer/repository"; + +import { ChangesetHandler } from "../types/index.js"; + +/** + * Collection of handlers for project-related operations. + * Each handler corresponds to a specific Project changeset type. + */ +export type ProjectHandlers = { + InsertProject: ChangesetHandler<"InsertProject">; + UpdateProject: ChangesetHandler<"UpdateProject">; + InsertPendingProjectRole: ChangesetHandler<"InsertPendingProjectRole">; + DeletePendingProjectRoles: ChangesetHandler<"DeletePendingProjectRoles">; + InsertProjectRole: ChangesetHandler<"InsertProjectRole">; + DeleteAllProjectRolesByRole: ChangesetHandler<"DeleteAllProjectRolesByRole">; + DeleteAllProjectRolesByRoleAndAddress: ChangesetHandler<"DeleteAllProjectRolesByRoleAndAddress">; +}; + +/** + * Creates handlers for managing project-related operations. + * + * @param repository - The project repository instance used for database operations + * @returns An object containing all project-related handlers + */ +export const createProjectHandlers = (repository: IProjectRepository): ProjectHandlers => ({ + InsertProject: (async (changeset): Promise => { + const { project } = changeset.args; + await repository.insertProject(project); + }) satisfies ChangesetHandler<"InsertProject">, + + UpdateProject: (async (changeset): Promise => { + const { chainId, projectId, project } = changeset.args; + await repository.updateProject({ id: projectId, chainId }, project); + }) satisfies ChangesetHandler<"UpdateProject">, + + InsertPendingProjectRole: (async (changeset): Promise => { + const { pendingProjectRole } = changeset.args; + await repository.insertPendingProjectRole(pendingProjectRole); + }) satisfies ChangesetHandler<"InsertPendingProjectRole">, + + DeletePendingProjectRoles: (async (changeset): Promise => { + const { ids } = changeset.args; + await repository.deleteManyPendingProjectRoles(ids); + }) satisfies ChangesetHandler<"DeletePendingProjectRoles">, + + InsertProjectRole: (async (changeset): Promise => { + const { projectRole } = changeset.args; + await repository.insertProjectRole(projectRole); + }) satisfies ChangesetHandler<"InsertProjectRole">, + + DeleteAllProjectRolesByRole: (async (changeset): Promise => { + const { chainId, projectId, role } = changeset.args.projectRole; + await repository.deleteManyProjectRoles(chainId, projectId, role); + }) satisfies ChangesetHandler<"DeleteAllProjectRolesByRole">, + + DeleteAllProjectRolesByRoleAndAddress: (async (changeset): Promise => { + const { chainId, projectId, role, address } = changeset.args.projectRole; + await repository.deleteManyProjectRoles(chainId, projectId, role, address); + }) satisfies ChangesetHandler<"DeleteAllProjectRolesByRoleAndAddress">, +}); diff --git a/packages/data-flow/src/data-loader/handlers/round.handlers.ts b/packages/data-flow/src/data-loader/handlers/round.handlers.ts new file mode 100644 index 0000000..c9b0fde --- /dev/null +++ b/packages/data-flow/src/data-loader/handlers/round.handlers.ts @@ -0,0 +1,87 @@ +import { IRoundRepository } from "@grants-stack-indexer/repository"; + +import { ChangesetHandler } from "../types/index.js"; + +/** + * Collection of handlers for round-related operations. + * Each handler corresponds to a specific Round changeset type. + */ +export type RoundHandlers = { + InsertRound: ChangesetHandler<"InsertRound">; + UpdateRound: ChangesetHandler<"UpdateRound">; + UpdateRoundByStrategyAddress: ChangesetHandler<"UpdateRoundByStrategyAddress">; + IncrementRoundFundedAmount: ChangesetHandler<"IncrementRoundFundedAmount">; + IncrementRoundTotalDistributed: ChangesetHandler<"IncrementRoundTotalDistributed">; + InsertPendingRoundRole: ChangesetHandler<"InsertPendingRoundRole">; + DeletePendingRoundRoles: ChangesetHandler<"DeletePendingRoundRoles">; + InsertRoundRole: ChangesetHandler<"InsertRoundRole">; + DeleteAllRoundRolesByRoleAndAddress: ChangesetHandler<"DeleteAllRoundRolesByRoleAndAddress">; +}; + +/** + * Creates handlers for managing round-related operations. + * + * @param repository - The round repository instance used for database operations + * @returns An object containing all round-related handlers + */ +export const createRoundHandlers = (repository: IRoundRepository): RoundHandlers => ({ + InsertRound: (async (changeset): Promise => { + const { round } = changeset.args; + await repository.insertRound(round); + }) satisfies ChangesetHandler<"InsertRound">, + + UpdateRound: (async (changeset): Promise => { + const { chainId, roundId, round } = changeset.args; + await repository.updateRound({ id: roundId, chainId }, round); + }) satisfies ChangesetHandler<"UpdateRound">, + + UpdateRoundByStrategyAddress: (async (changeset): Promise => { + const { chainId, strategyAddress, round } = changeset.args; + if (round) { + await repository.updateRound({ strategyAddress, chainId: chainId }, round); + } + }) satisfies ChangesetHandler<"UpdateRoundByStrategyAddress">, + + IncrementRoundFundedAmount: (async (changeset): Promise => { + const { chainId, roundId, fundedAmount, fundedAmountInUsd } = changeset.args; + await repository.incrementRoundFunds( + { + chainId, + roundId, + }, + fundedAmount, + fundedAmountInUsd, + ); + }) satisfies ChangesetHandler<"IncrementRoundFundedAmount">, + + IncrementRoundTotalDistributed: (async (changeset): Promise => { + const { chainId, roundId, amount } = changeset.args; + await repository.incrementRoundTotalDistributed( + { + chainId, + roundId, + }, + amount, + ); + }) satisfies ChangesetHandler<"IncrementRoundTotalDistributed">, + + InsertPendingRoundRole: (async (changeset): Promise => { + const { pendingRoundRole } = changeset.args; + await repository.insertPendingRoundRole(pendingRoundRole); + }) satisfies ChangesetHandler<"InsertPendingRoundRole">, + + DeletePendingRoundRoles: (async (changeset): Promise => { + const { ids } = changeset.args; + await repository.deleteManyPendingRoundRoles(ids); + }) satisfies ChangesetHandler<"DeletePendingRoundRoles">, + + InsertRoundRole: (async (changeset): Promise => { + const { roundRole } = changeset.args; + await repository.insertRoundRole(roundRole); + }) satisfies ChangesetHandler<"InsertRoundRole">, + + DeleteAllRoundRolesByRoleAndAddress: (async (changeset): Promise => { + const { chainId, roundId, role, address } = changeset.args.roundRole; + await repository.deleteManyRoundRolesByRoleAndAddress(chainId, roundId, role, address); + }) satisfies ChangesetHandler<"DeleteAllRoundRolesByRoleAndAddress">, +}); diff --git a/packages/data-flow/src/data-loader/index.ts b/packages/data-flow/src/data-loader/index.ts new file mode 100644 index 0000000..c834edf --- /dev/null +++ b/packages/data-flow/src/data-loader/index.ts @@ -0,0 +1 @@ +export * from "./dataLoader.js"; diff --git a/packages/data-flow/src/data-loader/types/index.ts b/packages/data-flow/src/data-loader/types/index.ts new file mode 100644 index 0000000..c8cae2b --- /dev/null +++ b/packages/data-flow/src/data-loader/types/index.ts @@ -0,0 +1,9 @@ +import { Changeset } from "@grants-stack-indexer/repository"; + +export type ChangesetHandler = ( + changeset: Extract, +) => Promise; + +export type ChangesetHandlers = { + [K in Changeset["type"]]: ChangesetHandler; +}; diff --git a/packages/data-flow/src/exceptions/index.ts b/packages/data-flow/src/exceptions/index.ts index e69de29..6054bb7 100644 --- a/packages/data-flow/src/exceptions/index.ts +++ b/packages/data-flow/src/exceptions/index.ts @@ -0,0 +1 @@ +export * from "./invalidChangeset.exception.js"; diff --git a/packages/data-flow/src/exceptions/invalidChangeset.exception.ts b/packages/data-flow/src/exceptions/invalidChangeset.exception.ts new file mode 100644 index 0000000..da0c2af --- /dev/null +++ b/packages/data-flow/src/exceptions/invalidChangeset.exception.ts @@ -0,0 +1,5 @@ +export class InvalidChangeset extends Error { + constructor(invalidTypes: string[]) { + super(`Invalid changeset types: ${invalidTypes.join(", ")}`); + } +} diff --git a/packages/data-flow/src/external.ts b/packages/data-flow/src/external.ts index f35e641..5f0f84d 100644 --- a/packages/data-flow/src/external.ts +++ b/packages/data-flow/src/external.ts @@ -1 +1,3 @@ export { EventsFetcher } from "./internal.js"; + +export { DataLoader } from "./internal.js"; diff --git a/packages/data-flow/src/interfaces/dataLoader.interface.ts b/packages/data-flow/src/interfaces/dataLoader.interface.ts new file mode 100644 index 0000000..16a28fb --- /dev/null +++ b/packages/data-flow/src/interfaces/dataLoader.interface.ts @@ -0,0 +1,13 @@ +import type { Changeset } from "@grants-stack-indexer/repository"; + +import type { ExecutionResult } from "../internal.js"; + +export interface IDataLoader { + /** + * Applies the changesets to the database. + * @param changesets - The changesets to apply. + * @returns The execution result. + * @throws {InvalidChangeset} if there are changesets with invalid types. + */ + applyChanges(changesets: Changeset[]): Promise; +} diff --git a/packages/data-flow/src/interfaces/eventsFetcher.interface.ts b/packages/data-flow/src/interfaces/eventsFetcher.interface.ts new file mode 100644 index 0000000..a02c04e --- /dev/null +++ b/packages/data-flow/src/interfaces/eventsFetcher.interface.ts @@ -0,0 +1,20 @@ +import { AnyProtocolEvent } from "@grants-stack-indexer/shared"; + +/** + * Interface for the events fetcher + */ +export interface IEventsFetcher { + /** + * Fetch the events by block number and log index for a chain + * @param chainId id of the chain + * @param blockNumber block number to fetch events from + * @param logIndex log index in the block to fetch events from + * @param limit limit of events to fetch + */ + fetchEventsByBlockNumberAndLogIndex( + chainId: bigint, + blockNumber: bigint, + logIndex: number, + limit?: number, + ): Promise; +} diff --git a/packages/data-flow/src/interfaces/index.ts b/packages/data-flow/src/interfaces/index.ts index a02c04e..883b4bb 100644 --- a/packages/data-flow/src/interfaces/index.ts +++ b/packages/data-flow/src/interfaces/index.ts @@ -1,20 +1,2 @@ -import { AnyProtocolEvent } from "@grants-stack-indexer/shared"; - -/** - * Interface for the events fetcher - */ -export interface IEventsFetcher { - /** - * Fetch the events by block number and log index for a chain - * @param chainId id of the chain - * @param blockNumber block number to fetch events from - * @param logIndex log index in the block to fetch events from - * @param limit limit of events to fetch - */ - fetchEventsByBlockNumberAndLogIndex( - chainId: bigint, - blockNumber: bigint, - logIndex: number, - limit?: number, - ): Promise; -} +export * from "./eventsFetcher.interface.js"; +export * from "./dataLoader.interface.js"; diff --git a/packages/data-flow/src/internal.ts b/packages/data-flow/src/internal.ts index f1c91ab..b6a22e0 100644 --- a/packages/data-flow/src/internal.ts +++ b/packages/data-flow/src/internal.ts @@ -1 +1,5 @@ +export * from "./types/index.js"; +export * from "./interfaces/index.js"; +export * from "./exceptions/index.js"; +export * from "./data-loader/index.js"; export * from "./eventsFetcher.js"; diff --git a/packages/data-flow/src/types/index.ts b/packages/data-flow/src/types/index.ts new file mode 100644 index 0000000..2bd755b --- /dev/null +++ b/packages/data-flow/src/types/index.ts @@ -0,0 +1,9 @@ +import { Changeset } from "@grants-stack-indexer/repository"; + +export type ExecutionResult = { + changesets: Changeset["type"][]; + numExecuted: number; + numSuccessful: number; + numFailed: number; + errors: string[]; +}; diff --git a/packages/data-flow/test/data-loader/dataLoader.spec.ts b/packages/data-flow/test/data-loader/dataLoader.spec.ts new file mode 100644 index 0000000..85e16b5 --- /dev/null +++ b/packages/data-flow/test/data-loader/dataLoader.spec.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + Changeset, + IApplicationRepository, + IProjectRepository, + IRoundRepository, +} from "@grants-stack-indexer/repository"; + +import { DataLoader } from "../../src/data-loader/dataLoader.js"; +import { InvalidChangeset } from "../../src/internal.js"; + +describe("DataLoader", () => { + const mockProjectRepository = { + insertProject: vi.fn(), + updateProject: vi.fn(), + } as unknown as IProjectRepository; + + const mockRoundRepository = { + insertRound: vi.fn(), + updateRound: vi.fn(), + } as unknown as IRoundRepository; + + const mockApplicationRepository = { + insertApplication: vi.fn(), + updateApplication: vi.fn(), + } as unknown as IApplicationRepository; + + const createDataLoader = (): DataLoader => + new DataLoader({ + project: mockProjectRepository, + round: mockRoundRepository, + application: mockApplicationRepository, + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("applyChanges", () => { + it("successfully process multiple changesets", async () => { + const dataLoader = createDataLoader(); + const changesets = [ + { + type: "InsertProject", + args: { project: { id: "1", name: "Test Project" } }, + } as unknown as Changeset, + { + type: "InsertRound", + args: { round: { id: "1", name: "Test Round" } }, + } as unknown as Changeset, + ]; + + const result = await dataLoader.applyChanges(changesets); + + expect(result.numExecuted).toBe(2); + expect(result.numSuccessful).toBe(2); + expect(result.numFailed).toBe(0); + expect(result.errors).toHaveLength(0); + expect(mockProjectRepository.insertProject).toHaveBeenCalledTimes(1); + expect(mockRoundRepository.insertRound).toHaveBeenCalledTimes(1); + }); + + it("throw InvalidChangeset when encountering unknown changeset type", async () => { + const dataLoader = createDataLoader(); + const changesets = [ + { + type: "UnknownType", + args: {}, + } as unknown as Changeset, + ]; + await expect(() => dataLoader.applyChanges(changesets)).rejects.toThrow( + InvalidChangeset, + ); + }); + + it("stops processing changesets on first error", async () => { + const dataLoader = createDataLoader(); + const error = new Error("Database error"); + vi.spyOn(mockProjectRepository, "insertProject").mockRejectedValueOnce(error); + + const changesets = [ + { + type: "InsertProject", + args: { project: { id: "1" } }, + } as unknown as Changeset, + { + type: "InsertRound" as const, + args: { round: { id: "1" } }, + } as unknown as Changeset, + ]; + + const result = await dataLoader.applyChanges(changesets); + + expect(result.numExecuted).toBe(1); + expect(result.numSuccessful).toBe(0); + expect(result.numFailed).toBe(1); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain("Database error"); + expect(mockRoundRepository.insertRound).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/data-flow/test/data-loader/handlers/application.handlers.spec.ts b/packages/data-flow/test/data-loader/handlers/application.handlers.spec.ts new file mode 100644 index 0000000..86dad31 --- /dev/null +++ b/packages/data-flow/test/data-loader/handlers/application.handlers.spec.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { IApplicationRepository, NewApplication } from "@grants-stack-indexer/repository"; +import { ChainId } from "@grants-stack-indexer/shared"; + +import { createApplicationHandlers } from "../../../src/data-loader/handlers/application.handlers.js"; + +describe("Application Handlers", () => { + const mockRepository = { + insertApplication: vi.fn(), + updateApplication: vi.fn(), + } as unknown as IApplicationRepository; + + const handlers = createApplicationHandlers(mockRepository); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("handle InsertApplication changeset", async () => { + const application = { id: "1", name: "Test Application" } as unknown as NewApplication; + await handlers.InsertApplication({ + type: "InsertApplication", + args: application, + }); + + expect(mockRepository.insertApplication).toHaveBeenCalledWith(application); + }); + + it("handle UpdateApplication changeset", async () => { + const update = { + type: "UpdateApplication", + args: { + chainId: 1 as ChainId, + roundId: "round1", + applicationId: "app1", + application: { status: "APPROVED" }, + }, + } as const; + + await handlers.UpdateApplication(update); + + expect(mockRepository.updateApplication).toHaveBeenCalledWith( + { chainId: 1, roundId: "round1", id: "app1" }, + { status: "APPROVED" }, + ); + }); +}); diff --git a/packages/data-flow/test/data-loader/handlers/project.handlers.spec.ts b/packages/data-flow/test/data-loader/handlers/project.handlers.spec.ts new file mode 100644 index 0000000..5ef0f45 --- /dev/null +++ b/packages/data-flow/test/data-loader/handlers/project.handlers.spec.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { IProjectRepository, NewProject } from "@grants-stack-indexer/repository"; +import { Address, ChainId } from "@grants-stack-indexer/shared"; + +import { createProjectHandlers } from "../../../src/data-loader/handlers/project.handlers.js"; + +describe("Project Handlers", () => { + const mockRepository = { + insertProject: vi.fn(), + updateProject: vi.fn(), + insertPendingProjectRole: vi.fn(), + deleteManyPendingProjectRoles: vi.fn(), + insertProjectRole: vi.fn(), + deleteManyProjectRoles: vi.fn(), + } as unknown as IProjectRepository; + + const handlers = createProjectHandlers(mockRepository); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("handle InsertProject changeset", async () => { + const project = { + id: "project-1", + name: "Test Project", + nonce: null, + anchorAddress: null, + chainId: 1 as ChainId, + projectNumber: null, + registryAddress: "0x123" as Address, + metadataCid: "cid", + metadata: null, + createdByAddress: "0x456" as Address, + createdAtBlock: 100n, + updatedAtBlock: 100n, + tags: [], + projectType: "canonical", + } as NewProject; + + await handlers.InsertProject({ + type: "InsertProject", + args: { project }, + }); + + expect(mockRepository.insertProject).toHaveBeenCalledWith(project); + }); + + it("handle UpdateProject changeset", async () => { + const update = { + type: "UpdateProject", + args: { + chainId: 1 as ChainId, + projectId: "project-1", + project: { + name: "Updated Project", + updatedAtBlock: 200n, + }, + }, + } as const; + + await handlers.UpdateProject(update); + + expect(mockRepository.updateProject).toHaveBeenCalledWith( + { id: "project-1", chainId: 1 }, + { name: "Updated Project", updatedAtBlock: 200n }, + ); + }); + + it("handle InsertPendingProjectRole changeset", async () => { + const pendingRole = { + chainId: 1 as ChainId, + role: "owner", + address: "0x123" as Address, + createdAtBlock: 100n, + }; + + await handlers.InsertPendingProjectRole({ + type: "InsertPendingProjectRole", + args: { pendingProjectRole: pendingRole }, + }); + + expect(mockRepository.insertPendingProjectRole).toHaveBeenCalledWith(pendingRole); + }); + + it("handle DeletePendingProjectRoles changeset", async () => { + const changeset = { + type: "DeletePendingProjectRoles" as const, + args: { + ids: [1, 2, 3], + }, + }; + + await handlers.DeletePendingProjectRoles(changeset); + + expect(mockRepository.deleteManyPendingProjectRoles).toHaveBeenCalledWith([1, 2, 3]); + }); + + it("handle InsertProjectRole changeset", async () => { + const projectRole = { + chainId: 1 as ChainId, + projectId: "project-1", + address: "0x123" as Address, + role: "owner", + createdAtBlock: 100n, + } as const; + + await handlers.InsertProjectRole({ + type: "InsertProjectRole", + args: { projectRole }, + }); + + expect(mockRepository.insertProjectRole).toHaveBeenCalledWith(projectRole); + }); + + it("handle DeleteAllProjectRolesByRole changeset", async () => { + const changeset = { + type: "DeleteAllProjectRolesByRole", + args: { + projectRole: { + chainId: 1 as ChainId, + projectId: "project-1", + role: "owner", + }, + }, + } as const; + + await handlers.DeleteAllProjectRolesByRole(changeset); + + expect(mockRepository.deleteManyProjectRoles).toHaveBeenCalledWith( + changeset.args.projectRole.chainId, + changeset.args.projectRole.projectId, + changeset.args.projectRole.role, + ); + }); + + it("handle DeleteAllProjectRolesByRoleAndAddress changeset", async () => { + const changeset = { + type: "DeleteAllProjectRolesByRoleAndAddress", + args: { + projectRole: { + chainId: 1 as ChainId, + projectId: "project-1", + role: "owner", + address: "0x123" as Address, + }, + }, + } as const; + + await handlers.DeleteAllProjectRolesByRoleAndAddress(changeset); + + expect(mockRepository.deleteManyProjectRoles).toHaveBeenCalledWith( + changeset.args.projectRole.chainId, + changeset.args.projectRole.projectId, + changeset.args.projectRole.role, + changeset.args.projectRole.address, + ); + }); +}); diff --git a/packages/data-flow/test/data-loader/handlers/round.handlers.spec.ts b/packages/data-flow/test/data-loader/handlers/round.handlers.spec.ts new file mode 100644 index 0000000..6ddb625 --- /dev/null +++ b/packages/data-flow/test/data-loader/handlers/round.handlers.spec.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { IRoundRepository, NewRound } from "@grants-stack-indexer/repository"; +import { Address, ChainId } from "@grants-stack-indexer/shared"; + +import { createRoundHandlers } from "../../../src/data-loader/handlers/round.handlers.js"; + +describe("Round Handlers", () => { + const mockRepository = { + insertRound: vi.fn(), + updateRound: vi.fn(), + incrementRoundFunds: vi.fn(), + incrementRoundTotalDistributed: vi.fn(), + insertPendingRoundRole: vi.fn(), + deleteManyPendingRoundRoles: vi.fn(), + insertRoundRole: vi.fn(), + deleteManyRoundRolesByRoleAndAddress: vi.fn(), + } as unknown as IRoundRepository; + + const handlers = createRoundHandlers(mockRepository); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("handle InsertRound changeset", async () => { + const round = { + id: "round-1", + chainId: 1 as ChainId, + matchAmount: 1000n, + } as NewRound; + + await handlers.InsertRound({ + type: "InsertRound" as const, + args: { round }, + }); + + expect(mockRepository.insertRound).toHaveBeenCalledWith(round); + }); + + it("handle UpdateRound changeset", async () => { + const update = { + type: "UpdateRound", + args: { + chainId: 1 as ChainId, + roundId: "round-1", + round: { + matchAmount: 2000n, + matchAmountInUsd: "2000", + }, + }, + } as const; + + await handlers.UpdateRound(update); + + expect(mockRepository.updateRound).toHaveBeenCalledWith( + { id: "round-1", chainId: 1 as ChainId }, + { matchAmount: 2000n, matchAmountInUsd: "2000" }, + ); + }); + + it("handle UpdateRoundByStrategyAddress changeset", async () => { + const update = { + type: "UpdateRoundByStrategyAddress", + args: { + chainId: 1 as ChainId, + strategyAddress: "0x123" as Address, + round: { + matchAmount: 2000n, + matchAmountInUsd: "2000", + }, + }, + } as const; + + await handlers.UpdateRoundByStrategyAddress(update); + + expect(mockRepository.updateRound).toHaveBeenCalledWith( + { chainId: 1 as ChainId, strategyAddress: "0x123" as Address }, + { matchAmount: 2000n, matchAmountInUsd: "2000" }, + ); + }); + + it("handle IncrementRoundFundedAmount changeset", async () => { + const changeset = { + type: "IncrementRoundFundedAmount", + args: { + chainId: 1 as ChainId, + roundId: "round-1", + fundedAmount: 1000n, + fundedAmountInUsd: "1000", + }, + } as const; + + await handlers.IncrementRoundFundedAmount(changeset); + + expect(mockRepository.incrementRoundFunds).toHaveBeenCalledWith( + { chainId: 1 as ChainId, roundId: "round-1" }, + 1000n, + "1000", + ); + }); + + it("handle IncrementRoundTotalDistributed changeset", async () => { + const changeset = { + type: "IncrementRoundTotalDistributed", + args: { + chainId: 1 as ChainId, + roundId: "round-1", + amount: 1000n, + }, + } as const; + + await handlers.IncrementRoundTotalDistributed(changeset); + + expect(mockRepository.incrementRoundTotalDistributed).toHaveBeenCalledWith( + { chainId: 1 as ChainId, roundId: "round-1" }, + 1000n, + ); + }); + + it("handle InsertPendingRoundRole changeset", async () => { + const changeset = { + type: "InsertPendingRoundRole", + args: { + pendingRoundRole: { + chainId: 1 as ChainId, + role: "admin", + address: "0x123" as Address, + createdAtBlock: 100n, + }, + }, + } as const; + + await handlers.InsertPendingRoundRole(changeset); + + expect(mockRepository.insertPendingRoundRole).toHaveBeenCalledWith( + changeset.args.pendingRoundRole, + ); + }); + + it("handle DeletePendingRoundRoles changeset", async () => { + const changeset = { + type: "DeletePendingRoundRoles" as const, + args: { + ids: [1, 2, 3], + }, + }; + + await handlers.DeletePendingRoundRoles(changeset); + + expect(mockRepository.deleteManyPendingRoundRoles).toHaveBeenCalledWith([1, 2, 3]); + }); + + it("handle InsertRoundRole changeset", async () => { + const changeset = { + type: "InsertRoundRole", + args: { + roundRole: { + chainId: 1 as ChainId, + roundId: "round-1", + address: "0x123" as Address, + role: "admin", + createdAtBlock: 100n, + }, + }, + } as const; + + await handlers.InsertRoundRole(changeset); + + expect(mockRepository.insertRoundRole).toHaveBeenCalledWith(changeset.args.roundRole); + }); + + it("handle DeleteAllRoundRolesByRoleAndAddress changeset", async () => { + const changeset = { + type: "DeleteAllRoundRolesByRoleAndAddress", + args: { + roundRole: { + chainId: 1 as ChainId, + roundId: "round-1", + role: "admin", + address: "0x123" as Address, + }, + }, + } as const; + + await handlers.DeleteAllRoundRolesByRoleAndAddress(changeset); + + expect(mockRepository.deleteManyRoundRolesByRoleAndAddress).toHaveBeenCalledWith( + changeset.args.roundRole.chainId, + changeset.args.roundRole.roundId, + changeset.args.roundRole.role, + changeset.args.roundRole.address, + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cac965..903cdfa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,6 +113,9 @@ importers: "@grants-stack-indexer/indexer-client": specifier: workspace:* version: link:../indexer-client + "@grants-stack-indexer/repository": + specifier: workspace:* + version: link:../repository "@grants-stack-indexer/shared": specifier: workspace:* version: link:../shared