diff --git a/packages/metadata/package.json b/packages/metadata/package.json index b9ca88d..57b0189 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -28,7 +28,7 @@ "test:cov": "vitest run --config vitest.config.ts --coverage" }, "dependencies": { - "@grants-stack-indexer/shared": "workspace:0.0.1", + "@grants-stack-indexer/shared": "workspace:*", "axios": "1.7.7", "zod": "3.23.8" }, diff --git a/packages/pricing/package.json b/packages/pricing/package.json index e4368c9..a6bfd4f 100644 --- a/packages/pricing/package.json +++ b/packages/pricing/package.json @@ -28,7 +28,7 @@ "test:cov": "vitest run --config vitest.config.ts --coverage" }, "dependencies": { - "@grants-stack-indexer/shared": "workspace:0.0.1", + "@grants-stack-indexer/shared": "workspace:*", "axios": "1.7.7" }, "devDependencies": { diff --git a/packages/repository/README.md b/packages/repository/README.md new file mode 100644 index 0000000..663def6 --- /dev/null +++ b/packages/repository/README.md @@ -0,0 +1,59 @@ +# grants-stack-indexer: repository package + +This package provides a data access layer for the grants-stack-indexer project, implementing the Repository pattern to abstract database operations. + +## Setup + +1. Install dependencies by running `pnpm install` in the root directory of the project. + +## Available Scripts + +Available scripts that can be run using `pnpm`: + +| Script | Description | +| ------------- | ------------------------------------------------------- | +| `build` | Build library using tsc | +| `check-types` | Check for type issues using tsc | +| `clean` | Remove `dist` folder | +| `lint` | Run ESLint to check for coding standards | +| `lint:fix` | Run linter and automatically fix code formatting issues | +| `format` | Check code formatting and style using Prettier | +| `format:fix` | Run formatter and automatically fix issues | +| `test` | Run tests using Vitest | +| `test:cov` | Run tests with coverage report | + +## Usage + +This package provides repository interfaces and implementations for projects and rounds. It uses Kysely as the query builder library. + +### Creating a database connection + +```typescript +import { createKyselyPostgresDb, DatabaseConfig } from "@grants-stack-indexer/repository"; + +const dbConfig: DatabaseConfig = { + connectionString: "postgresql://user:password@localhost:5432/mydb", +}; + +const db = createKyselyPostgresDb(dbConfig); + +// Instantiate a repository +const projectRepository = new KyselyProjectRepository(db, "mySchema"); + +const projects = await projectRepository.getProjects(10); +``` + +## API + +### Repositories + +This package provides the following repositories: + +1. **IProjectRepository**: Manages project-related database operations, including project roles and pending roles. + +2. **IRoundRepository**: Manages round-related database operations, including round roles and pending roles. + +## References + +- [Kysely](https://kysely.dev/) +- [PostgreSQL](https://www.postgresql.org/) diff --git a/packages/repository/package.json b/packages/repository/package.json new file mode 100644 index 0000000..7349658 --- /dev/null +++ b/packages/repository/package.json @@ -0,0 +1,38 @@ +{ + "name": "@grants-stack-indexer/repository", + "version": "0.0.1", + "private": true, + "description": "", + "license": "MIT", + "author": "Wonderland", + "type": "module", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "directories": { + "src": "src" + }, + "files": [ + "dist/*", + "package.json", + "!**/*.tsbuildinfo" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "check-types": "tsc --noEmit -p ./tsconfig.json", + "clean": "rm -rf dist/", + "format": "prettier --check \"{src,test}/**/*.{js,ts,json}\"", + "format:fix": "prettier --write \"{src,test}/**/*.{js,ts,json}\"", + "lint": "eslint \"{src,test}/**/*.{js,ts,json}\"", + "lint:fix": "pnpm lint --fix", + "test": "vitest run --config vitest.config.ts --passWithNoTests", + "test:cov": "vitest run --config vitest.config.ts --coverage" + }, + "dependencies": { + "@grants-stack-indexer/shared": "workspace:*", + "kysely": "0.27.4", + "pg": "8.13.0" + }, + "devDependencies": { + "@types/pg": "8.11.10" + } +} diff --git a/packages/repository/src/db/connection.ts b/packages/repository/src/db/connection.ts new file mode 100644 index 0000000..c415edb --- /dev/null +++ b/packages/repository/src/db/connection.ts @@ -0,0 +1,52 @@ +import { CamelCasePlugin, Kysely, PostgresDialect } from "kysely"; +import { Pool, PoolConfig } from "pg"; + +import { + PendingProjectRole as PendingProjectRoleTable, + PendingRoundRole as PendingRoundRoleTable, + ProjectRole as ProjectRoleTable, + Project as ProjectTable, + RoundRole as RoundRoleTable, + Round as RoundTable, +} from "../internal.js"; + +export interface DatabaseConfig extends PoolConfig { + connectionString: string; +} + +export interface Database { + rounds: RoundTable; + pendingRoundRoles: PendingRoundRoleTable; + roundRoles: RoundRoleTable; + projects: ProjectTable; + pendingProjectRoles: PendingProjectRoleTable; + projectRoles: ProjectRoleTable; +} + +/** + * Creates and configures a Kysely database instance for PostgreSQL. + * + * @param config - The database configuration object extending PoolConfig. + * @returns A configured Kysely instance for the Database. + * + * This function sets up a PostgreSQL database connection using Kysely ORM. + * + * @example + * const dbConfig: DatabaseConfig = { + * connectionString: 'postgresql://user:password@localhost:5432/mydb' + * }; + * const db = createKyselyDatabase(dbConfig); + */ +export const createKyselyPostgresDb = (config: DatabaseConfig): Kysely => { + const dialect = new PostgresDialect({ + pool: new Pool({ + max: 15, + idleTimeoutMillis: 30_000, + keepAlive: true, + connectionTimeoutMillis: 5_000, + ...config, + }), + }); + + return new Kysely({ dialect, plugins: [new CamelCasePlugin()] }); +}; diff --git a/packages/repository/src/external.ts b/packages/repository/src/external.ts new file mode 100644 index 0000000..95d48eb --- /dev/null +++ b/packages/repository/src/external.ts @@ -0,0 +1,30 @@ +// Add your external exports here +export type { + IRoundRepository, + IRoundReadRepository, + IProjectRepository, + IProjectReadRepository, + DatabaseConfig, +} from "./internal.js"; + +export type { + Project, + ProjectType, + ProjectRoleNames, + NewProject, + PartialProject, + ProjectRole, + PendingProjectRole, +} from "./types/project.types.js"; + +export type { + Round, + NewRound, + PartialRound, + RoundRole, + PendingRoundRole, +} from "./types/round.types.js"; + +export { KyselyRoundRepository, KyselyProjectRepository } from "./repositories/kysely/index.js"; + +export { createKyselyPostgresDb as createKyselyDatabase } from "./internal.js"; diff --git a/packages/repository/src/index.ts b/packages/repository/src/index.ts new file mode 100644 index 0000000..7cdcc00 --- /dev/null +++ b/packages/repository/src/index.ts @@ -0,0 +1,3 @@ +export * from "./db/connection.js"; +export * from "./repositories/kysely/index.js"; +export * from "./interfaces/index.js"; diff --git a/packages/repository/src/interfaces/index.ts b/packages/repository/src/interfaces/index.ts new file mode 100644 index 0000000..f9900d3 --- /dev/null +++ b/packages/repository/src/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from "./projectRepository.interface.js"; +export * from "./roundRepository.interface.js"; diff --git a/packages/repository/src/interfaces/projectRepository.interface.ts b/packages/repository/src/interfaces/projectRepository.interface.ts new file mode 100644 index 0000000..717bfd3 --- /dev/null +++ b/packages/repository/src/interfaces/projectRepository.interface.ts @@ -0,0 +1,103 @@ +import { Address, ChainId } from "@grants-stack-indexer/shared"; + +import { + NewPendingProjectRole, + NewProject, + NewProjectRole, + PartialProject, + PendingProjectRole, + Project, + ProjectRoleNames, +} from "../types/project.types.js"; + +export interface IProjectReadRepository { + /** + * Retrieves all projects for a given chain ID. + * @param chainId The chain ID to filter projects by. + * @returns A promise that resolves to an array of Project objects. + */ + getProjects(chainId: ChainId): Promise; + + /** + * Retrieves a specific project by its ID and chain ID. + * @param chainId The chain ID of the project. + * @param projectId The unique identifier of the project. + * @returns A promise that resolves to a Project object if found, or undefined if not found. + */ + getProjectById(chainId: ChainId, projectId: string): Promise; + + /** + * Retrieves all pending project roles. + * @returns A promise that resolves to an array of PendingProjectRole objects. + */ + getPendingProjectRoles(): Promise; + + /** + * Retrieves pending project roles for a specific chain ID and role. + * @param chainId The chain ID to filter pending roles by. + * @param role The role to filter pending roles by. + * @returns A promise that resolves to an array of PendingProjectRole objects. + */ + getPendingProjectRolesByRole(chainId: ChainId, role: string): Promise; + + /** + * Retrieves a project by its anchor address and chain ID. + * @param chainId The chain ID of the project. + * @param anchorAddress The anchor address of the project. + * @returns A promise that resolves to a Project object if found, or undefined if not found. + */ + getProjectByAnchor(chainId: ChainId, anchorAddress: Address): Promise; +} + +export interface IProjectRepository extends IProjectReadRepository { + /** + * Inserts a new project into the repository. + * @param project The new project to be inserted. + * @returns A promise that resolves when the insertion is complete. + */ + insertProject(project: NewProject): Promise; + + /** + * Updates an existing project in the repository. + * @param where An object containing the id and chainId to identify the project to update. + * @param project The partial project data to update. + * @returns A promise that resolves when the update is complete. + */ + updateProject(where: { id: string; chainId: ChainId }, project: PartialProject): Promise; + + /** + * Inserts a new project role into the repository. + * @param projectRole The new project role to be inserted. + * @returns A promise that resolves when the insertion is complete. + */ + insertProjectRole(projectRole: NewProjectRole): Promise; + + /** + * Deletes multiple project roles based on the provided criteria. + * @param chainId The chain ID of the project roles to delete. + * @param projectId The project ID of the roles to delete. + * @param role The role type to delete. + * @param address Optional address to further filter the roles to delete. + * @returns A promise that resolves when the deletion is complete. + */ + deleteManyProjectRoles( + chainId: ChainId, + projectId: string, + role: ProjectRoleNames, + address?: Address, + ): Promise; + + /** + * Inserts a new pending project role into the repository. + * @param pendingProjectRole The new pending project role to be inserted. + * @returns A promise that resolves when the insertion is complete. + */ + insertPendingProjectRole(pendingProjectRole: NewPendingProjectRole): Promise; + + /** + * Deletes multiple pending project roles based on their IDs. + * @param ids An array of IDs of the pending project roles to delete. + * @returns A promise that resolves when the deletion is complete. + */ + deleteManyPendingProjectRoles(ids: number[]): Promise; +} diff --git a/packages/repository/src/interfaces/roundRepository.interface.ts b/packages/repository/src/interfaces/roundRepository.interface.ts new file mode 100644 index 0000000..5e7b1b3 --- /dev/null +++ b/packages/repository/src/interfaces/roundRepository.interface.ts @@ -0,0 +1,161 @@ +import { Address, ChainId } from "@grants-stack-indexer/shared"; + +import { + NewPendingRoundRole, + NewRound, + NewRoundRole, + PartialRound, + PendingRoundRole, + Round, + RoundRole, + RoundRoleNames, +} from "../types/round.types.js"; + +export interface IRoundReadRepository { + /** + * Retrieves all rounds for a given chain ID. + * @param chainId The chain ID to fetch rounds for. + * @returns A promise that resolves to an array of Round objects. + */ + getRounds(chainId: ChainId): Promise; + + /** + * Retrieves a specific round by its ID and chain ID. + * @param chainId The chain ID of the round. + * @param roundId The ID of the round to fetch. + * @returns A promise that resolves to a Round object if found, or undefined if not found. + */ + getRoundById(chainId: ChainId, roundId: string): Promise; + + /** + * Retrieves a round by its strategy address and chain ID. + * @param chainId The chain ID of the round. + * @param strategyAddress The strategy address of the round. + * @returns A promise that resolves to a Round object if found, or undefined if not found. + */ + getRoundByStrategyAddress( + chainId: ChainId, + strategyAddress: Address, + ): Promise; + + /** + * Retrieves a round by a specific role and role value. + * @param chainId The chain ID of the round. + * @param roleName The name of the role to filter by. + * @param roleValue The value of the role to filter by. + * @returns A promise that resolves to a Round object if found, or undefined if not found. + */ + getRoundByRole( + chainId: ChainId, + roleName: RoundRoleNames, + roleValue: string, + ): Promise; + + /** + * Retrieves the match token address for a specific round. + * @param chainId The chain ID of the round. + * @param roundId The ID of the round. + * @returns A promise that resolves to the match token address if found, or undefined if not found. + */ + getRoundMatchTokenAddressById( + chainId: ChainId, + roundId: Address | string, + ): Promise
; + + /** + * Retrieves all round roles. + * @returns A promise that resolves to an array of RoundRole objects. + */ + getRoundRoles(): Promise; + + /** + * Retrieves pending round roles for a specific chain and role. + * @param chainId The chain ID to fetch pending roles for. + * @param role The specific role to fetch pending roles for. + * @returns A promise that resolves to an array of PendingRoundRole objects. + */ + getPendingRoundRoles(chainId: ChainId, role: RoundRoleNames): Promise; +} + +export interface IRoundRepository extends IRoundReadRepository { + /** + * Inserts a new round into the repository. + * @param round The new round to insert. + * @returns A promise that resolves when the insertion is complete. + */ + insertRound(round: NewRound): Promise; + + /** + * Updates an existing round in the repository. + * @param where An object containing the id and chainId of the round to update. + * @param round The partial round data to update. + * @returns A promise that resolves when the update is complete. + */ + updateRound(where: { id: string; chainId: ChainId }, round: PartialRound): Promise; + + /** + * Increments the funds for a specific round. + * @param where An object containing the chainId and roundId of the round to update. + * @param amount The amount to increment by. + * @param amountInUsd The amount in USD to increment by. + * @returns A promise that resolves when the increment is complete. + */ + incrementRoundFunds( + where: { + chainId: ChainId; + roundId: string; + }, + amount: bigint, + amountInUsd: number, + ): Promise; + + /** + * Increments the total distributed amount for a specific round. + * @param where An object containing the chainId and roundId of the round to update. + * @param amount The amount to increment by. + * @returns A promise that resolves when the increment is complete. + */ + incrementRoundTotalDistributed( + where: { + chainId: ChainId; + roundId: string; + }, + amount: bigint, + ): Promise; + + /** + * Inserts a new round role into the repository. + * @param roundRole The new round role to insert. + * @returns A promise that resolves when the insertion is complete. + */ + insertRoundRole(roundRole: NewRoundRole): Promise; + + /** + * Deletes multiple round roles based on chain ID, round ID, role, and address. + * @param chainId The chain ID of the roles to delete. + * @param roundId The round ID of the roles to delete. + * @param role The role name of the roles to delete. + * @param address The address associated with the roles to delete. + * @returns A promise that resolves when the deletion is complete. + */ + deleteManyRoundRolesByRoleAndAddress( + chainId: ChainId, + roundId: string, + role: RoundRoleNames, + address: Address, + ): Promise; + + /** + * Inserts a new pending round role into the repository. + * @param pendingRoundRole The new pending round role to insert. + * @returns A promise that resolves when the insertion is complete. + */ + insertPendingRoundRole(pendingRoundRole: NewPendingRoundRole): Promise; + + /** + * Deletes multiple pending round roles by their IDs. + * @param ids An array of IDs of the pending round roles to delete. + * @returns A promise that resolves when the deletion is complete. + */ + deleteManyPendingRoundRoles(ids: number[]): Promise; +} diff --git a/packages/repository/src/internal.ts b/packages/repository/src/internal.ts new file mode 100644 index 0000000..74974b2 --- /dev/null +++ b/packages/repository/src/internal.ts @@ -0,0 +1,4 @@ +export * from "./types/index.js"; +export * from "./interfaces/index.js"; +export * from "./db/connection.js"; +export * from "./repositories/kysely/index.js"; diff --git a/packages/repository/src/repositories/index.ts b/packages/repository/src/repositories/index.ts new file mode 100644 index 0000000..f891e8f --- /dev/null +++ b/packages/repository/src/repositories/index.ts @@ -0,0 +1 @@ +export * from "./kysely/index.js"; diff --git a/packages/repository/src/repositories/kysely/index.ts b/packages/repository/src/repositories/kysely/index.ts new file mode 100644 index 0000000..092ff8d --- /dev/null +++ b/packages/repository/src/repositories/kysely/index.ts @@ -0,0 +1,2 @@ +export * from "./project.repository.js"; +export * from "./round.repository.js"; diff --git a/packages/repository/src/repositories/kysely/project.repository.ts b/packages/repository/src/repositories/kysely/project.repository.ts new file mode 100644 index 0000000..a058331 --- /dev/null +++ b/packages/repository/src/repositories/kysely/project.repository.ts @@ -0,0 +1,153 @@ +import { Kysely } from "kysely"; + +import { Address, ChainId } from "@grants-stack-indexer/shared"; + +import { IProjectRepository } from "../../interfaces/projectRepository.interface.js"; +import { + Database, + NewPendingProjectRole, + NewProject, + NewProjectRole, + PartialProject, + PendingProjectRole, + Project, + ProjectRoleNames, +} from "../../internal.js"; + +export class KyselyProjectRepository implements IProjectRepository { + constructor( + private readonly db: Kysely, + private readonly schemaName: string, + ) {} + + // ============================ PROJECTS ============================ + + /* @inheritdoc */ + async getProjects(chainId: ChainId): Promise { + return this.db + .withSchema(this.schemaName) + .selectFrom("projects") + .where("chainId", "=", chainId) + .selectAll() + .execute(); + } + + /* @inheritdoc */ + async getProjectById(chainId: ChainId, projectId: string): Promise { + return this.db + .withSchema(this.schemaName) + .selectFrom("projects") + .where("chainId", "=", chainId) + .where("id", "=", projectId) + .selectAll() + .executeTakeFirst(); + } + + /* @inheritdoc */ + async getProjectByAnchor( + chainId: ChainId, + anchorAddress: Address, + ): Promise { + return this.db + .withSchema(this.schemaName) + .selectFrom("projects") + .where("chainId", "=", chainId) + .where("anchorAddress", "=", anchorAddress) + .selectAll() + .executeTakeFirst(); + } + + /* @inheritdoc */ + async insertProject(project: NewProject): Promise { + await this.db.withSchema(this.schemaName).insertInto("projects").values(project).execute(); + } + + /* @inheritdoc */ + async updateProject( + where: { id: string; chainId: ChainId }, + project: PartialProject, + ): Promise { + await this.db + .withSchema(this.schemaName) + .updateTable("projects") + .set(project) + .where("id", "=", where.id) + .where("chainId", "=", where.chainId) + .execute(); + } + + // ============================ PROJECT ROLES ============================ + + /* @inheritdoc */ + async insertProjectRole(projectRole: NewProjectRole): Promise { + await this.db + .withSchema(this.schemaName) + .insertInto("projectRoles") + .values(projectRole) + .execute(); + } + + /* @inheritdoc */ + async deleteManyProjectRoles( + chainId: ChainId, + projectId: string, + role: ProjectRoleNames, + address?: Address, + ): Promise { + const query = this.db + .withSchema(this.schemaName) + .deleteFrom("projectRoles") + .where("chainId", "=", chainId) + .where("projectId", "=", projectId) + .where("role", "=", role); + + if (address) { + query.where("address", "=", address); + } + + await query.execute(); + } + + // ============================ PENDING PROJECT ROLES ============================ + + /* @inheritdoc */ + async getPendingProjectRoles(): Promise { + return this.db + .withSchema(this.schemaName) + .selectFrom("pendingProjectRoles") + .selectAll() + .execute(); + } + + /* @inheritdoc */ + async getPendingProjectRolesByRole( + chainId: ChainId, + role: string, + ): Promise { + return this.db + .withSchema(this.schemaName) + .selectFrom("pendingProjectRoles") + .where("chainId", "=", chainId) + .where("role", "=", role) + .selectAll() + .execute(); + } + + /* @inheritdoc */ + async insertPendingProjectRole(pendingProjectRole: NewPendingProjectRole): Promise { + await this.db + .withSchema(this.schemaName) + .insertInto("pendingProjectRoles") + .values(pendingProjectRole) + .execute(); + } + + /* @inheritdoc */ + async deleteManyPendingProjectRoles(ids: number[]): Promise { + await this.db + .withSchema(this.schemaName) + .deleteFrom("pendingProjectRoles") + .where("id", "in", ids) + .execute(); + } +} diff --git a/packages/repository/src/repositories/kysely/round.repository.ts b/packages/repository/src/repositories/kysely/round.repository.ts new file mode 100644 index 0000000..decc0b0 --- /dev/null +++ b/packages/repository/src/repositories/kysely/round.repository.ts @@ -0,0 +1,213 @@ +import { Kysely } from "kysely"; + +import { Address, ChainId } from "@grants-stack-indexer/shared"; + +import { + Database, + IRoundRepository, + NewPendingRoundRole, + NewRound, + NewRoundRole, + PartialRound, + PendingRoundRole, + Round, + RoundRole, + RoundRoleNames, +} from "../../internal.js"; + +export class KyselyRoundRepository implements IRoundRepository { + constructor( + private readonly db: Kysely, + private readonly schemaName: string, + ) {} + + // ============================ ROUNDS ============================ + + /* @inheritdoc */ + async getRounds(chainId: ChainId): Promise { + return this.db + .withSchema(this.schemaName) + .selectFrom("rounds") + .where("chainId", "=", chainId) + .selectAll() + .execute(); + } + + /* @inheritdoc */ + async getRoundById(chainId: ChainId, roundId: string): Promise { + return this.db + .withSchema(this.schemaName) + .selectFrom("rounds") + .where("chainId", "=", chainId) + .where("id", "=", roundId) + .selectAll() + .executeTakeFirst(); + } + + /* @inheritdoc */ + async getRoundByStrategyAddress( + chainId: ChainId, + strategyAddress: Address, + ): Promise { + return this.db + .withSchema(this.schemaName) + .selectFrom("rounds") + .where("chainId", "=", chainId) + .where("strategyAddress", "=", strategyAddress) + .selectAll() + .executeTakeFirst(); + } + + /* @inheritdoc */ + async getRoundByRole( + chainId: ChainId, + roleName: RoundRoleNames, + roleValue: string, + ): Promise { + return this.db + .withSchema(this.schemaName) + .selectFrom("rounds") + .where("chainId", "=", chainId) + .where(`${roleName}Role`, "=", roleValue) + .selectAll() + .executeTakeFirst(); + } + + /* @inheritdoc */ + async getRoundMatchTokenAddressById( + chainId: ChainId, + roundId: Address | string, + ): Promise
{ + const res = await this.db + .withSchema(this.schemaName) + .selectFrom("rounds") + .where("chainId", "=", chainId) + .where("id", "=", roundId) + .select("matchTokenAddress") + .executeTakeFirst(); + + return res?.matchTokenAddress; + } + + /* @inheritdoc */ + async insertRound(round: NewRound): Promise { + await this.db.withSchema(this.schemaName).insertInto("rounds").values(round).execute(); + } + + async updateRound(where: { id: string; chainId: ChainId }, round: PartialRound): Promise { + await this.db + .withSchema(this.schemaName) + .updateTable("rounds") + .set(round) + .where("id", "=", where.id) + .where("chainId", "=", where.chainId) + .execute(); + } + + /* @inheritdoc */ + async incrementRoundFunds( + where: { + chainId: ChainId; + roundId: string; + }, + amount: bigint, + amountInUsd: number, + ): Promise { + await this.db + .withSchema(this.schemaName) + .updateTable("rounds") + .set((eb) => ({ + fundedAmount: eb("fundedAmount", "+", amount), + fundedAmountInUsd: eb("fundedAmountInUsd", "+", amountInUsd), + })) + .where("chainId", "=", where.chainId) + .where("id", "=", where.roundId) + .execute(); + } + + /* @inheritdoc */ + async incrementRoundTotalDistributed( + where: { + chainId: ChainId; + roundId: string; + }, + amount: bigint, + ): Promise { + await this.db + .withSchema(this.schemaName) + .updateTable("rounds") + .set((eb) => ({ + totalDistributed: eb("totalDistributed", "+", amount), + })) + .where("chainId", "=", where.chainId) + .where("id", "=", where.roundId) + .execute(); + } + + // ============================ ROUND ROLES ============================ + + /* @inheritdoc */ + async getRoundRoles(): Promise { + return this.db.withSchema(this.schemaName).selectFrom("roundRoles").selectAll().execute(); + } + + /* @inheritdoc */ + async insertRoundRole(roundRole: NewRoundRole): Promise { + await this.db + .withSchema(this.schemaName) + .insertInto("roundRoles") + .values(roundRole) + .execute(); + } + + /* @inheritdoc */ + async deleteManyRoundRolesByRoleAndAddress( + chainId: ChainId, + roundId: string, + role: RoundRoleNames, + address: Address, + ): Promise { + await this.db + .withSchema(this.schemaName) + .deleteFrom("roundRoles") + .where("chainId", "=", chainId) + .where("roundId", "=", roundId) + .where("role", "=", role) + .where("address", "=", address) + .execute(); + } + + // ============================ PENDING ROUND ROLES ============================ + + /* @inheritdoc */ + async getPendingRoundRoles( + chainId: ChainId, + role: RoundRoleNames, + ): Promise { + return this.db + .withSchema(this.schemaName) + .selectFrom("pendingRoundRoles") + .where("chainId", "=", chainId) + .where("role", "=", role) + .selectAll() + .execute(); + } + + /* @inheritdoc */ + async insertPendingRoundRole(pendingRoundRole: NewPendingRoundRole): Promise { + await this.db + .withSchema(this.schemaName) + .insertInto("pendingRoundRoles") + .values(pendingRoundRole) + .execute(); + } + + /* @inheritdoc */ + async deleteManyPendingRoundRoles(ids: number[]): Promise { + await this.db + .withSchema(this.schemaName) + .deleteFrom("pendingRoundRoles") + .where("id", "in", ids) + .execute(); + } +} diff --git a/packages/repository/src/types/index.ts b/packages/repository/src/types/index.ts new file mode 100644 index 0000000..977b72a --- /dev/null +++ b/packages/repository/src/types/index.ts @@ -0,0 +1,2 @@ +export * from "./project.types.js"; +export * from "./round.types.js"; diff --git a/packages/repository/src/types/project.types.ts b/packages/repository/src/types/project.types.ts new file mode 100644 index 0000000..e4d4d79 --- /dev/null +++ b/packages/repository/src/types/project.types.ts @@ -0,0 +1,51 @@ +import { Address, ChainId } from "@grants-stack-indexer/shared"; + +export type ProjectType = "canonical" | "linked"; + +export type Project = { + id: string; + name: string; + nonce: bigint | null; + anchorAddress: Address | null; + chainId: ChainId; + projectNumber: number | null; + registryAddress: Address; + metadataCid: string | null; + metadata: unknown | null; + createdByAddress: Address; + createdAtBlock: bigint; + updatedAtBlock: bigint; + tags: string[]; + projectType: ProjectType; +}; + +export type NewProject = Project; +export type PartialProject = Partial; + +export type ProjectRoleNames = "owner" | "member"; + +export type ProjectRole = { + chainId: ChainId; + projectId: string; + address: Address; + role: ProjectRoleNames; + createdAtBlock: bigint; +}; + +export type NewProjectRole = ProjectRole; +export type PartialProjectRole = Partial; + +// In Allo V2 profile roles are emitted before a profile exists. +// The role emitted is the profile id. +// Once a profile is created we search for roles with that profile id +// and add real project roles. After that we can remove the pending project roles. +export type PendingProjectRole = { + id?: number; + chainId: ChainId; + role: string; + address: Address; + createdAtBlock: bigint; +}; + +export type NewPendingProjectRole = Omit; +export type PartialPendingProjectRole = Partial; diff --git a/packages/repository/src/types/round.types.ts b/packages/repository/src/types/round.types.ts new file mode 100644 index 0000000..09d8bff --- /dev/null +++ b/packages/repository/src/types/round.types.ts @@ -0,0 +1,77 @@ +import { Address, ChainId } from "@grants-stack-indexer/shared"; + +export type MatchingDistribution = { + applicationId: string; + projectPayoutAddress: string; + projectId: string; + projectName: string; + matchPoolPercentage: number; + contributionsCount: number; + originalMatchAmountInToken: string; + matchAmountInToken: string; +}; + +export type Round = { + id: Address | string; + chainId: ChainId; + matchAmount: bigint; + matchTokenAddress: Address; + matchAmountInUsd: number; + fundedAmount: bigint; + fundedAmountInUsd: number; + applicationMetadataCid: string; + applicationMetadata: unknown | null; + roundMetadataCid: string | null; + roundMetadata: unknown; + applicationsStartTime: Date | null; + applicationsEndTime: Date | null; + donationsStartTime: Date | null; + donationsEndTime: Date | null; + createdByAddress: Address; + createdAtBlock: bigint; + updatedAtBlock: bigint; + totalAmountDonatedInUsd: number; + totalDonationsCount: number; + totalDistributed: bigint; + uniqueDonorsCount: number; + managerRole: string; + adminRole: string; + strategyAddress: Address; + strategyId: string; + strategyName: string; + readyForPayoutTransaction: string | null; + matchingDistribution: MatchingDistribution | null; + projectId: string; + tags: string[]; +}; + +export type NewRound = Round; +export type PartialRound = Partial; + +export type RoundRoleNames = "admin" | "manager"; + +export type RoundRole = { + chainId: ChainId; + roundId: string; + address: Address; + role: RoundRoleNames; + createdAtBlock: bigint; +}; + +export type NewRoundRole = RoundRole; +export type PartialRoundRole = Partial; + +// In Allo V2 rounds roles are emitted before a pool/round exists. +// The role emitted is the bytes32(poolId). +// Once a round is created we search for roles with that pool id +// and add real round roles. After that we can remove the pending round roles. +export type PendingRoundRole = { + id?: number; + chainId: ChainId; + role: string; + address: Address; + createdAtBlock: bigint; +}; + +export type NewPendingRoundRole = Omit; +export type PartialPendingRoundRole = Partial; diff --git a/packages/repository/test/index.spec.ts b/packages/repository/test/index.spec.ts new file mode 100644 index 0000000..e383c0a --- /dev/null +++ b/packages/repository/test/index.spec.ts @@ -0,0 +1,5 @@ +import { describe, it } from "vitest"; + +describe("dummy", () => { + it.skip("dummy", () => {}); +}); diff --git a/packages/repository/tsconfig.build.json b/packages/repository/tsconfig.build.json new file mode 100644 index 0000000..32768e3 --- /dev/null +++ b/packages/repository/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "composite": true, + "declarationMap": true, + "declaration": true, + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/repository/tsconfig.json b/packages/repository/tsconfig.json new file mode 100644 index 0000000..66bb87a --- /dev/null +++ b/packages/repository/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"] +} diff --git a/packages/repository/vitest.config.ts b/packages/repository/vitest.config.ts new file mode 100644 index 0000000..36aeafb --- /dev/null +++ b/packages/repository/vitest.config.ts @@ -0,0 +1,21 @@ +import path from "path"; +import { configDefaults, defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["test/**/*.spec.ts"], + exclude: ["node_modules", "dist"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + exclude: ["node_modules", "dist", "src/index.ts", ...configDefaults.exclude], + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, +}); diff --git a/packages/shared/src/external.ts b/packages/shared/src/external.ts index ca87de6..6663453 100644 --- a/packages/shared/src/external.ts +++ b/packages/shared/src/external.ts @@ -1,2 +1,2 @@ -export type { AnyProtocolEvent, Address, Branded } from "./internal.js"; +export type { AnyProtocolEvent, Address, Branded, ChainId } from "./internal.js"; export { NATIVE_TOKEN_ADDRESS, isNativeToken } from "./constants/index.js"; diff --git a/packages/shared/src/types/common.ts b/packages/shared/src/types/common.ts new file mode 100644 index 0000000..d9128ec --- /dev/null +++ b/packages/shared/src/types/common.ts @@ -0,0 +1,3 @@ +import { Branded } from "../internal.js"; + +export type ChainId = Branded; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 157826b..848dad4 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,2 +1,3 @@ export * from "./events/index.js"; export * from "./brand.js"; +export * from "./common.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01212b0..ad23043 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,7 +120,7 @@ importers: packages/metadata: dependencies: "@grants-stack-indexer/shared": - specifier: workspace:0.0.1 + specifier: workspace:* version: link:../shared axios: specifier: 1.7.7 @@ -136,7 +136,7 @@ importers: packages/pricing: dependencies: "@grants-stack-indexer/shared": - specifier: workspace:0.0.1 + specifier: workspace:* version: link:../shared axios: specifier: 1.7.7 @@ -146,6 +146,22 @@ importers: specifier: 2.0.0 version: 2.0.0(axios@1.7.7) + packages/repository: + dependencies: + "@grants-stack-indexer/shared": + specifier: workspace:* + version: link:../shared + kysely: + specifier: 0.27.4 + version: 0.27.4 + pg: + specifier: 8.13.0 + version: 8.13.0 + devDependencies: + "@types/pg": + specifier: 8.11.10 + version: 8.11.10 + packages/shared: dependencies: viem: @@ -1051,6 +1067,12 @@ packages: integrity: sha512-YRsdVxq6OaLfmR9Hy816IMp33xOBjfyOgUd77ehqg96CFywxAPbDbXvAsuN2KVg2HOT8Eh6uAfU+l4WffwPVrQ==, } + "@types/pg@8.11.10": + resolution: + { + integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==, + } + "@typescript-eslint/eslint-plugin@7.18.0": resolution: { @@ -2632,6 +2654,13 @@ packages: integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, } + kysely@0.27.4: + resolution: + { + integrity: sha512-dyNKv2KRvYOQPLCAOCjjQuCk4YFd33BvGdf/o5bC7FiW+BB6snA81Zt+2wT9QDFzKqxKa5rrOmvlK/anehCcgA==, + } + engines: { node: ">=14.0.0" } + levn@0.4.1: resolution: { @@ -2961,6 +2990,12 @@ packages: } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + obuf@1.1.2: + resolution: + { + integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==, + } + once@1.4.0: resolution: { @@ -3104,6 +3139,78 @@ packages: } engines: { node: ">= 14.16" } + pg-cloudflare@1.1.1: + resolution: + { + integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==, + } + + pg-connection-string@2.7.0: + resolution: + { + integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==, + } + + pg-int8@1.0.1: + resolution: + { + integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==, + } + engines: { node: ">=4.0.0" } + + pg-numeric@1.0.2: + resolution: + { + integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==, + } + engines: { node: ">=4" } + + pg-pool@3.7.0: + resolution: + { + integrity: sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==, + } + peerDependencies: + pg: ">=8.0" + + pg-protocol@1.7.0: + resolution: + { + integrity: sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==, + } + + pg-types@2.2.0: + resolution: + { + integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==, + } + engines: { node: ">=4" } + + pg-types@4.0.2: + resolution: + { + integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==, + } + engines: { node: ">=10" } + + pg@8.13.0: + resolution: + { + integrity: sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==, + } + engines: { node: ">= 8.0.0" } + peerDependencies: + pg-native: ">=3.0.1" + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: + { + integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==, + } + picocolors@1.1.0: resolution: { @@ -3132,6 +3239,68 @@ packages: } engines: { node: ^10 || ^12 || >=14 } + postgres-array@2.0.0: + resolution: + { + integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==, + } + engines: { node: ">=4" } + + postgres-array@3.0.2: + resolution: + { + integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==, + } + engines: { node: ">=12" } + + postgres-bytea@1.0.0: + resolution: + { + integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==, + } + engines: { node: ">=0.10.0" } + + postgres-bytea@3.0.0: + resolution: + { + integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==, + } + engines: { node: ">= 6" } + + postgres-date@1.0.7: + resolution: + { + integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==, + } + engines: { node: ">=0.10.0" } + + postgres-date@2.1.0: + resolution: + { + integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==, + } + engines: { node: ">=12" } + + postgres-interval@1.2.0: + resolution: + { + integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==, + } + engines: { node: ">=0.10.0" } + + postgres-interval@3.0.0: + resolution: + { + integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==, + } + engines: { node: ">=12" } + + postgres-range@1.1.4: + resolution: + { + integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==, + } + prelude-ls@1.2.1: resolution: { @@ -3942,6 +4111,13 @@ packages: utf-8-validate: optional: true + xtend@4.0.2: + resolution: + { + integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==, + } + engines: { node: ">=0.4" } + y18n@5.0.8: resolution: { @@ -4561,6 +4737,12 @@ snapshots: dependencies: undici-types: 5.25.3 + "@types/pg@8.11.10": + dependencies: + "@types/node": 20.8.8 + pg-protocol: 1.7.0 + pg-types: 4.0.2 + "@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.56.0)(typescript@5.5.4))(eslint@8.56.0)(typescript@5.5.4)": dependencies: "@eslint-community/regexpp": 4.11.0 @@ -5502,6 +5684,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kysely@0.27.4: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -5689,6 +5873,8 @@ snapshots: dependencies: path-key: 4.0.0 + obuf@1.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5762,6 +5948,53 @@ snapshots: pathval@2.0.0: {} + pg-cloudflare@1.1.1: + optional: true + + pg-connection-string@2.7.0: {} + + pg-int8@1.0.1: {} + + pg-numeric@1.0.2: {} + + pg-pool@3.7.0(pg@8.13.0): + dependencies: + pg: 8.13.0 + + pg-protocol@1.7.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg-types@4.0.2: + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.2 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + + pg@8.13.0: + dependencies: + pg-connection-string: 2.7.0 + pg-pool: 3.7.0(pg@8.13.0) + pg-protocol: 1.7.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.1.1 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.0: {} picomatch@2.3.1: {} @@ -5774,6 +6007,28 @@ snapshots: picocolors: 1.1.0 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-array@3.0.2: {} + + postgres-bytea@1.0.0: {} + + postgres-bytea@3.0.0: + dependencies: + obuf: 1.1.2 + + postgres-date@1.0.7: {} + + postgres-date@2.1.0: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres-interval@3.0.0: {} + + postgres-range@1.1.4: {} + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.0: @@ -6222,6 +6477,8 @@ snapshots: ws@8.5.0: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {}