diff --git a/apps/processing/src/services/sharedDependencies.service.ts b/apps/processing/src/services/sharedDependencies.service.ts index dde0e77..0c493e1 100644 --- a/apps/processing/src/services/sharedDependencies.service.ts +++ b/apps/processing/src/services/sharedDependencies.service.ts @@ -1,7 +1,7 @@ import { CoreDependencies } from "@grants-stack-indexer/data-flow"; import { EnvioIndexerClient } from "@grants-stack-indexer/indexer-client"; -import { IpfsProvider } from "@grants-stack-indexer/metadata"; -import { PricingProviderFactory } from "@grants-stack-indexer/pricing"; +import { CachingMetadataProvider, IpfsProvider } from "@grants-stack-indexer/metadata"; +import { CachingPricingProvider, PricingProviderFactory } from "@grants-stack-indexer/pricing"; import { createKyselyDatabase, IEventRegistryRepository, @@ -11,6 +11,8 @@ import { KyselyApplicationRepository, KyselyDonationRepository, KyselyEventRegistryRepository, + KyselyMetadataCache, + KyselyPricingCache, KyselyProjectRepository, KyselyRoundRepository, KyselyStrategyProcessingCheckpointRepository, @@ -68,11 +70,23 @@ export class SharedDependenciesService { kyselyDatabase, env.DATABASE_SCHEMA, ); + const pricingRepository = new KyselyPricingCache(kyselyDatabase, env.DATABASE_SCHEMA); const pricingProvider = PricingProviderFactory.create(env, { logger, }); + const cachedPricingProvider = new CachingPricingProvider( + pricingProvider, + pricingRepository, + logger, + ); + const metadataRepository = new KyselyMetadataCache(kyselyDatabase, env.DATABASE_SCHEMA); const metadataProvider = new IpfsProvider(env.IPFS_GATEWAYS_URL, logger); + const cachedMetadataProvider = new CachingMetadataProvider( + metadataProvider, + metadataRepository, + logger, + ); const eventRegistryRepository = new KyselyEventRegistryRepository( kyselyDatabase, @@ -104,9 +118,9 @@ export class SharedDependenciesService { projectRepository, roundRepository, applicationRepository, - pricingProvider, + pricingProvider: cachedPricingProvider, donationRepository, - metadataProvider, + metadataProvider: cachedMetadataProvider, applicationPayoutRepository, transactionManager, }, diff --git a/apps/processing/test/unit/sharedDependencies.service.spec.ts b/apps/processing/test/unit/sharedDependencies.service.spec.ts index ed84920..c832ce9 100644 --- a/apps/processing/test/unit/sharedDependencies.service.spec.ts +++ b/apps/processing/test/unit/sharedDependencies.service.spec.ts @@ -45,16 +45,20 @@ vi.mock("@grants-stack-indexer/repository", () => ({ KyselyEventRegistryRepository: vi.fn(), KyselyStrategyProcessingCheckpointRepository: vi.fn(), KyselyTransactionManager: vi.fn(), + KyselyPricingCache: vi.fn(), + KyselyMetadataCache: vi.fn(), })); vi.mock("@grants-stack-indexer/pricing", () => ({ PricingProviderFactory: { create: vi.fn(), }, + CachingPricingProvider: vi.fn(), })); vi.mock("@grants-stack-indexer/metadata", () => ({ IpfsProvider: vi.fn(), + CachingMetadataProvider: vi.fn(), })); vi.mock("@grants-stack-indexer/indexer-client", () => ({ diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 57b0189..b5156e3 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -28,6 +28,7 @@ "test:cov": "vitest run --config vitest.config.ts --coverage" }, "dependencies": { + "@grants-stack-indexer/repository": "workspace:*", "@grants-stack-indexer/shared": "workspace:*", "axios": "1.7.7", "zod": "3.23.8" diff --git a/packages/metadata/src/external.ts b/packages/metadata/src/external.ts index 78fd970..6a7bccb 100644 --- a/packages/metadata/src/external.ts +++ b/packages/metadata/src/external.ts @@ -1,4 +1,4 @@ -export { IpfsProvider } from "./internal.js"; +export { IpfsProvider, CachingMetadataProvider } from "./internal.js"; export { EmptyGatewaysUrlsException, diff --git a/packages/metadata/src/providers/cachingProxy.provider.ts b/packages/metadata/src/providers/cachingProxy.provider.ts new file mode 100644 index 0000000..0824103 --- /dev/null +++ b/packages/metadata/src/providers/cachingProxy.provider.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +import { ICache } from "@grants-stack-indexer/repository"; +import { ILogger } from "@grants-stack-indexer/shared"; + +import { IMetadataProvider } from "../internal.js"; + +/** + * A metadata provider that caches metadata lookups from the underlying provider. + * When a metadata is requested, it first checks the cache. If found, returns the cached metadata. + * If not found in cache, fetches from the underlying provider and caches the result before returning. + * Cache failures (both reads and writes) are logged but do not prevent the provider from functioning. + */ +export class CachingMetadataProvider implements IMetadataProvider { + constructor( + private readonly provider: IMetadataProvider, + private readonly cache: ICache, + private readonly logger: ILogger, + ) {} + + /** @inheritdoc */ + async getMetadata( + ipfsCid: string, + validateContent?: z.ZodSchema, + ): Promise { + let cachedMetadata: T | undefined = undefined; + try { + cachedMetadata = (await this.cache.get(ipfsCid)) as T | undefined; + } catch (error) { + this.logger.debug(`Failed to get cached metadata for IPFS CID ${ipfsCid}`, { + error, + }); + } + + if (cachedMetadata) { + return cachedMetadata; + } + + const metadata = await this.provider.getMetadata(ipfsCid, validateContent); + + if (metadata) { + try { + await this.cache.set(ipfsCid, metadata); + } catch (error) { + this.logger.debug(`Failed to cache metadata for IPFS CID ${ipfsCid}`, { + error, + }); + } + } + + return metadata; + } +} diff --git a/packages/metadata/src/providers/index.ts b/packages/metadata/src/providers/index.ts index f575273..8831827 100644 --- a/packages/metadata/src/providers/index.ts +++ b/packages/metadata/src/providers/index.ts @@ -1 +1,2 @@ export * from "./ipfs.provider.js"; +export * from "./cachingProxy.provider.js"; diff --git a/packages/metadata/test/providers/cachingProxy.provider.spec.ts b/packages/metadata/test/providers/cachingProxy.provider.spec.ts new file mode 100644 index 0000000..cee7619 --- /dev/null +++ b/packages/metadata/test/providers/cachingProxy.provider.spec.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { ICache } from "@grants-stack-indexer/repository"; +import { ILogger } from "@grants-stack-indexer/shared"; + +import { IMetadataProvider } from "../../src/internal.js"; +import { CachingMetadataProvider } from "../../src/providers/cachingProxy.provider.js"; + +describe("CachingMetadataProvider", () => { + const mockProvider = { + getMetadata: vi.fn(), + } as unknown as IMetadataProvider; + + const mockCache = { + get: vi.fn(), + set: vi.fn(), + } as unknown as ICache; + + const mockLogger = { + debug: vi.fn(), + } as unknown as ILogger; + + let provider: CachingMetadataProvider; + + beforeEach(() => { + vi.clearAllMocks(); + provider = new CachingMetadataProvider(mockProvider, mockCache, mockLogger); + }); + + describe("getMetadata", () => { + const testCid = "QmTest123"; + const testData = { foo: "bar" }; + const testSchema = z.object({ foo: z.string() }); + + it("returns cached metadata when available", async () => { + vi.spyOn(mockCache, "get").mockResolvedValue(testData); + + const result = await provider.getMetadata(testCid, testSchema); + + expect(result).toEqual(testData); + expect(mockCache.get).toHaveBeenCalledWith(testCid); + expect(mockProvider.getMetadata).not.toHaveBeenCalled(); + }); + + it("fetches and caches metadata when cache misses", async () => { + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); + vi.spyOn(mockProvider, "getMetadata").mockResolvedValue(testData); + + const result = await provider.getMetadata(testCid, testSchema); + + expect(result).toEqual(testData); + expect(mockCache.get).toHaveBeenCalledWith(testCid); + expect(mockProvider.getMetadata).toHaveBeenCalledWith(testCid, testSchema); + expect(mockCache.set).toHaveBeenCalledWith(testCid, testData); + }); + + it("handles cache read failures gracefully", async () => { + vi.spyOn(mockCache, "get").mockRejectedValue(new Error("Cache read error")); + vi.spyOn(mockProvider, "getMetadata").mockResolvedValue(testData); + + const result = await provider.getMetadata(testCid, testSchema); + + expect(result).toEqual(testData); + expect(mockLogger.debug).toHaveBeenCalledWith( + `Failed to get cached metadata for IPFS CID ${testCid}`, + expect.any(Object), + ); + expect(mockProvider.getMetadata).toHaveBeenCalledWith(testCid, testSchema); + }); + + it("handles cache write failures gracefully", async () => { + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); + vi.spyOn(mockCache, "set").mockRejectedValue(new Error("Cache write error")); + vi.spyOn(mockProvider, "getMetadata").mockResolvedValue(testData); + + const result = await provider.getMetadata(testCid, testSchema); + + expect(result).toEqual(testData); + expect(mockLogger.debug).toHaveBeenCalledWith( + `Failed to cache metadata for IPFS CID ${testCid}`, + expect.any(Object), + ); + }); + + it("returns undefined when metadata is not found", async () => { + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); + vi.spyOn(mockProvider, "getMetadata").mockResolvedValue(undefined); + + const result = await provider.getMetadata(testCid, testSchema); + + expect(result).toBeUndefined(); + expect(mockCache.set).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/pricing/package.json b/packages/pricing/package.json index 69e12af..8a581d5 100644 --- a/packages/pricing/package.json +++ b/packages/pricing/package.json @@ -28,6 +28,7 @@ "test:cov": "vitest run --config vitest.config.ts --coverage" }, "dependencies": { + "@grants-stack-indexer/repository": "workspace:*", "@grants-stack-indexer/shared": "workspace:*", "axios": "1.7.7" }, diff --git a/packages/pricing/src/external.ts b/packages/pricing/src/external.ts index 2762894..f1ee939 100644 --- a/packages/pricing/src/external.ts +++ b/packages/pricing/src/external.ts @@ -1,6 +1,6 @@ export type { TokenPrice, IPricingProvider } from "./internal.js"; -export { CoingeckoProvider, DummyPricingProvider } from "./internal.js"; +export { CoingeckoProvider, DummyPricingProvider, CachingPricingProvider } from "./internal.js"; export { PricingProviderFactory } from "./internal.js"; export type { diff --git a/packages/pricing/src/providers/cachingProxy.provider.ts b/packages/pricing/src/providers/cachingProxy.provider.ts new file mode 100644 index 0000000..6aa4234 --- /dev/null +++ b/packages/pricing/src/providers/cachingProxy.provider.ts @@ -0,0 +1,67 @@ +import { ICache, PriceCacheKey } from "@grants-stack-indexer/repository"; +import { ILogger, TokenCode } from "@grants-stack-indexer/shared"; + +import { IPricingProvider, TokenPrice } from "../internal.js"; + +/** + * A pricing provider that caches token price lookups from the underlying provider. + * When a price is requested, it first checks the cache. If found, returns the cached price. + * If not found in cache, fetches from the underlying provider and caches the result before returning. + * Cache failures (both reads and writes) are logged but do not prevent the provider from functioning. + */ +export class CachingPricingProvider implements IPricingProvider { + constructor( + private readonly provider: IPricingProvider, + private readonly cache: ICache, + private readonly logger: ILogger, + ) {} + + /** @inheritdoc */ + async getTokenPrice( + tokenCode: TokenCode, + startTimestampMs: number, + endTimestampMs?: number, + ): Promise { + let cachedPrice: TokenPrice | undefined = undefined; + try { + cachedPrice = await this.cache.get({ + tokenCode, + timestampMs: startTimestampMs, + }); + } catch (error) { + this.logger.debug( + `Failed to get cached price for token ${tokenCode} at ${startTimestampMs}`, + { error }, + ); + } + + if (cachedPrice) { + return cachedPrice; + } + + const price = await this.provider.getTokenPrice( + tokenCode, + startTimestampMs, + endTimestampMs, + ); + + if (price) { + try { + await this.cache.set( + { + tokenCode, + timestampMs: startTimestampMs, + }, + price, + ); + } catch (error) { + this.logger.debug( + `Failed to cache price for token ${tokenCode} at ${startTimestampMs}`, + { error }, + ); + } + } + + return price; + } +} diff --git a/packages/pricing/src/providers/index.ts b/packages/pricing/src/providers/index.ts index 9c05289..2829e17 100644 --- a/packages/pricing/src/providers/index.ts +++ b/packages/pricing/src/providers/index.ts @@ -1,2 +1,3 @@ export * from "./coingecko.provider.js"; export * from "./dummy.provider.js"; +export * from "./cachingProxy.provider.js"; diff --git a/packages/pricing/test/providers/cachingProxy.provider.spec.ts b/packages/pricing/test/providers/cachingProxy.provider.spec.ts new file mode 100644 index 0000000..1e80581 --- /dev/null +++ b/packages/pricing/test/providers/cachingProxy.provider.spec.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ICache, PriceCacheKey } from "@grants-stack-indexer/repository"; +import { ILogger, TokenCode } from "@grants-stack-indexer/shared"; + +import { IPricingProvider, TokenPrice } from "../../src/internal.js"; +import { CachingPricingProvider } from "../../src/providers/cachingProxy.provider.js"; + +describe("CachingPricingProvider", () => { + const mockProvider = { + getTokenPrice: vi.fn(), + } as unknown as IPricingProvider; + + const mockCache = { + get: vi.fn(), + set: vi.fn(), + } as unknown as ICache; + + const mockLogger = { + debug: vi.fn(), + } as unknown as ILogger; + + let provider: CachingPricingProvider; + + beforeEach(() => { + vi.clearAllMocks(); + provider = new CachingPricingProvider(mockProvider, mockCache, mockLogger); + }); + + describe("getTokenPrice", () => { + const testToken = { + code: "USDC" as TokenCode, + priceSourceCode: "USDC" as TokenCode, + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + decimals: 6, + }; + const testStartTime = 1234567890000; + const testEndTime = 1234567899999; + const testPrice: TokenPrice = { + priceUsd: 0.99, + timestampMs: testStartTime, + }; + + it("returns cached price when available", async () => { + vi.spyOn(mockCache, "get").mockResolvedValue(testPrice); + + const result = await provider.getTokenPrice(testToken.code, testStartTime); + + expect(result).toEqual(testPrice); + expect(mockCache.get).toHaveBeenCalledWith({ + tokenCode: testToken.code, + timestampMs: testStartTime, + }); + expect(mockProvider.getTokenPrice).not.toHaveBeenCalled(); + }); + + it("fetches and caches price when cache misses", async () => { + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); + vi.spyOn(mockProvider, "getTokenPrice").mockResolvedValue(testPrice); + + const result = await provider.getTokenPrice(testToken.code, testStartTime, testEndTime); + + expect(result).toEqual(testPrice); + expect(mockProvider.getTokenPrice).toHaveBeenCalledWith( + testToken.code, + testStartTime, + testEndTime, + ); + expect(mockCache.set).toHaveBeenCalledWith( + { + tokenCode: testToken.code, + timestampMs: testStartTime, + }, + testPrice, + ); + }); + + it("handles cache read failures gracefully", async () => { + vi.spyOn(mockCache, "get").mockRejectedValue(new Error("Cache read error")); + vi.spyOn(mockProvider, "getTokenPrice").mockResolvedValue(testPrice); + + const result = await provider.getTokenPrice(testToken.code, testStartTime, testEndTime); + + expect(result).toEqual(testPrice); + expect(mockLogger.debug).toHaveBeenCalledWith( + `Failed to get cached price for token ${testToken.code} at ${testStartTime}`, + expect.any(Object), + ); + expect(mockProvider.getTokenPrice).toHaveBeenCalled(); + }); + + it("handles cache write failures gracefully", async () => { + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); + vi.spyOn(mockCache, "set").mockRejectedValue(new Error("Cache write error")); + vi.spyOn(mockProvider, "getTokenPrice").mockResolvedValue(testPrice); + + const result = await provider.getTokenPrice(testToken.code, testStartTime, testEndTime); + + expect(result).toEqual(testPrice); + expect(mockLogger.debug).toHaveBeenCalledWith( + `Failed to cache price for token ${testToken.code} at ${testStartTime}`, + expect.any(Object), + ); + }); + + it("returns undefined when price is not found", async () => { + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); + vi.spyOn(mockProvider, "getTokenPrice").mockResolvedValue(undefined); + + const result = await provider.getTokenPrice(testToken.code, testStartTime, testEndTime); + + expect(result).toBeUndefined(); + expect(mockCache.set).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/repository/src/db/connection.ts b/packages/repository/src/db/connection.ts index 01b29e9..9770e71 100644 --- a/packages/repository/src/db/connection.ts +++ b/packages/repository/src/db/connection.ts @@ -16,8 +16,10 @@ import { Donation as DonationTable, ProcessedEvent as EventRegistryTable, MatchingDistribution, + Metadata as MetadataCacheTable, PendingProjectRole as PendingProjectRoleTable, PendingRoundRole as PendingRoundRoleTable, + Price as PriceCacheTable, ProjectRole as ProjectRoleTable, Project as ProjectTable, Round, @@ -67,6 +69,8 @@ export interface Database { strategiesRegistry: StrategyRegistryTable; eventsRegistry: EventRegistryTable; strategyProcessingCheckpoints: StrategyProcessingCheckpointTable; + metadataCache: MetadataCacheTable; + priceCache: PriceCacheTable; } /** diff --git a/packages/repository/src/external.ts b/packages/repository/src/external.ts index ed021da..fd9428b 100644 --- a/packages/repository/src/external.ts +++ b/packages/repository/src/external.ts @@ -73,4 +73,9 @@ export type { StrategyProcessingCheckpoint, NewStrategyProcessingCheckpoint } fr export type { ITransactionManager, TransactionConnection } from "./internal.js"; export { KyselyTransactionManager } from "./internal.js"; +export type { ICache } from "./internal.js"; +export type { Metadata, NewMetadata, PartialMetadata } from "./internal.js"; +export type { Price, NewPrice, PartialPrice, PriceCacheKey } from "./internal.js"; +export { KyselyMetadataCache, KyselyPricingCache } from "./internal.js"; + export { createKyselyPostgresDb as createKyselyDatabase } from "./internal.js"; diff --git a/packages/repository/src/interfaces/cache.interface.ts b/packages/repository/src/interfaces/cache.interface.ts new file mode 100644 index 0000000..a026730 --- /dev/null +++ b/packages/repository/src/interfaces/cache.interface.ts @@ -0,0 +1,22 @@ +/** + * Interface for a cache. + * @template Key - The type of the key. + * @template Value - The type of the value. + */ +export interface ICache { + /** + * Get the value for a given key. + * @param key - The key to get the value for. + * @returns The value for the given key, or undefined if the key is not found. + * @throws If there is an error getting the value. + */ + get(key: Key): Promise; + + /** + * Set the value for a given key. + * @param key - The key to set the value for. + * @param value - The value to set for the given key. + * @throws If there is an error setting the value. + */ + set(key: Key, value: Value): Promise; +} diff --git a/packages/repository/src/interfaces/index.ts b/packages/repository/src/interfaces/index.ts index e94da7c..761d20b 100644 --- a/packages/repository/src/interfaces/index.ts +++ b/packages/repository/src/interfaces/index.ts @@ -8,3 +8,4 @@ export * from "./eventsRepository.interface.js"; export * from "./strategyProcessingCheckpointRepository.interface.js"; export * from "./transactionManager.interface.js"; export * from "./pgError.interface.js"; +export * from "./cache.interface.js"; diff --git a/packages/repository/src/repositories/kysely/index.ts b/packages/repository/src/repositories/kysely/index.ts index 40ed093..ea1e5ea 100644 --- a/packages/repository/src/repositories/kysely/index.ts +++ b/packages/repository/src/repositories/kysely/index.ts @@ -7,3 +7,5 @@ export * from "./strategyRegistry.repository.js"; export * from "./eventRegistry.repository.js"; export * from "./strategyProcessingCheckpoint.repository.js"; export * from "./transactionManager.js"; +export * from "./prices.repository.js"; +export * from "./metadata.repository.js"; diff --git a/packages/repository/src/repositories/kysely/metadata.repository.ts b/packages/repository/src/repositories/kysely/metadata.repository.ts new file mode 100644 index 0000000..66fd5ea --- /dev/null +++ b/packages/repository/src/repositories/kysely/metadata.repository.ts @@ -0,0 +1,66 @@ +import { Kysely } from "kysely"; + +import { Database, handlePostgresError, ICache } from "../../internal.js"; + +export class KyselyMetadataCache implements ICache { + constructor( + private readonly db: Kysely, + private readonly schema: string, + ) {} + + /** @inheritdoc */ + async get(id: string): Promise { + try { + const result = await this.db + .withSchema(this.schema) + .selectFrom("metadataCache") + .select("metadata") + .where("id", "=", id) + .executeTakeFirst(); + + if (!result) { + return undefined; + } + + return result.metadata as T; + } catch (error) { + throw handlePostgresError(error, { + className: KyselyMetadataCache.name, + methodName: "get", + additionalData: { + id, + }, + }); + } + } + + /** @inheritdoc */ + async set(id: string, metadata: T): Promise { + try { + await this.db + .withSchema(this.schema) + .insertInto("metadataCache") + .values({ + id: id, + metadata: metadata as unknown, + createdAt: new Date(), + }) + .onConflict((oc) => + oc.column("id").doUpdateSet({ + metadata: metadata as unknown, + createdAt: new Date(), + }), + ) + .execute(); + } catch (error) { + throw handlePostgresError(error, { + className: KyselyMetadataCache.name, + methodName: "set", + additionalData: { + id, + metadata, + }, + }); + } + } +} diff --git a/packages/repository/src/repositories/kysely/prices.repository.ts b/packages/repository/src/repositories/kysely/prices.repository.ts new file mode 100644 index 0000000..5d1ca10 --- /dev/null +++ b/packages/repository/src/repositories/kysely/prices.repository.ts @@ -0,0 +1,89 @@ +import { Kysely } from "kysely"; + +import { TokenCode, TokenPrice } from "@grants-stack-indexer/shared"; + +import { Database, handlePostgresError, ICache } from "../../internal.js"; + +export type PriceCacheKey = { tokenCode: TokenCode; timestampMs: number }; + +/** + * A cache for token prices using Kysely. + * This cache is used to store and retrieve token prices for a given token and timestamp. + * It uses the `priceCache` table in the database to store the prices. + * Note: no eviction strategy is implemented since is not needed for the current use case. + */ +export class KyselyPricingCache implements ICache { + constructor( + private readonly db: Kysely, + private readonly schema: string, + ) {} + + /** @inheritdoc */ + async get(key: { tokenCode: TokenCode; timestampMs: number }): Promise { + const { tokenCode, timestampMs } = key; + + try { + const result = await this.db + .withSchema(this.schema) + .selectFrom("priceCache") + .select(["timestampMs", "priceUsd"]) + .where("tokenCode", "=", tokenCode) + .where("timestampMs", "=", timestampMs) + .executeTakeFirst(); + + if (!result) { + return undefined; + } + + return { + timestampMs: result.timestampMs, + priceUsd: result.priceUsd, + }; + } catch (error) { + throw handlePostgresError(error, { + className: KyselyPricingCache.name, + methodName: "get", + additionalData: { + key, + }, + }); + } + } + + /** @inheritdoc */ + async set( + key: { tokenCode: TokenCode; timestampMs: number }, + value: TokenPrice, + ): Promise { + const { tokenCode, timestampMs } = key; + const { priceUsd } = value; + + try { + await this.db + .withSchema(this.schema) + .insertInto("priceCache") + .values({ + tokenCode: tokenCode, + timestampMs: timestampMs, + priceUsd: priceUsd, + createdAt: new Date(), + }) + .onConflict((oc) => + oc.columns(["tokenCode", "timestampMs"]).doUpdateSet({ + priceUsd: priceUsd, + createdAt: new Date(), + }), + ) + .execute(); + } catch (error) { + throw handlePostgresError(error, { + className: KyselyPricingCache.name, + methodName: "set", + additionalData: { + key, + value, + }, + }); + } + } +} diff --git a/packages/repository/src/types/index.ts b/packages/repository/src/types/index.ts index 0a7e401..d7e5272 100644 --- a/packages/repository/src/types/index.ts +++ b/packages/repository/src/types/index.ts @@ -8,3 +8,5 @@ export * from "./strategy.types.js"; export * from "./event.types.js"; export * from "./strategyProcessingCheckpoint.types.js"; export * from "./transaction.types.js"; +export * from "./metadata.types.js"; +export * from "./price.types.js"; diff --git a/packages/repository/src/types/metadata.types.ts b/packages/repository/src/types/metadata.types.ts new file mode 100644 index 0000000..7ac4d67 --- /dev/null +++ b/packages/repository/src/types/metadata.types.ts @@ -0,0 +1,8 @@ +export type Metadata = { + id: string; + metadata: unknown; + createdAt: Date; +}; + +export type NewMetadata = Omit; +export type PartialMetadata = Partial; diff --git a/packages/repository/src/types/price.types.ts b/packages/repository/src/types/price.types.ts new file mode 100644 index 0000000..644f05e --- /dev/null +++ b/packages/repository/src/types/price.types.ts @@ -0,0 +1,11 @@ +import { TokenCode } from "@grants-stack-indexer/shared"; + +export type Price = { + tokenCode: TokenCode; + timestampMs: number; + priceUsd: number; + createdAt: Date; +}; + +export type NewPrice = Omit; +export type PartialPrice = Partial; diff --git a/packages/shared/src/external.ts b/packages/shared/src/external.ts index 4c87e41..dbd0d85 100644 --- a/packages/shared/src/external.ts +++ b/packages/shared/src/external.ts @@ -16,7 +16,7 @@ export { Logger } from "./logger/logger.js"; export { BigNumber } from "./internal.js"; export type { BigNumberType } from "./internal.js"; -export type { TokenCode, Token } from "./internal.js"; +export type { TokenCode, Token, TokenPrice } from "./internal.js"; export { TOKENS, getToken, getTokenOrThrow, UnknownToken } from "./internal.js"; export { isAlloEvent, isRegistryEvent, isStrategyEvent } from "./internal.js"; diff --git a/packages/shared/src/tokens/tokens.ts b/packages/shared/src/tokens/tokens.ts index dcb3961..29a8434 100644 --- a/packages/shared/src/tokens/tokens.ts +++ b/packages/shared/src/tokens/tokens.ts @@ -12,6 +12,11 @@ export type Token = { voteAmountCap?: bigint; }; +export type TokenPrice = { + timestampMs: number; + priceUsd: number; +}; + export const TOKENS: { [chainId: number]: { [tokenAddress: Address]: Token; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74a274f..5b7d7ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,6 +186,9 @@ importers: packages/metadata: dependencies: + "@grants-stack-indexer/repository": + specifier: workspace:* + version: link:../repository "@grants-stack-indexer/shared": specifier: workspace:* version: link:../shared @@ -202,6 +205,9 @@ importers: packages/pricing: dependencies: + "@grants-stack-indexer/repository": + specifier: workspace:* + version: link:../repository "@grants-stack-indexer/shared": specifier: workspace:* version: link:../shared diff --git a/scripts/migrations/src/migrations/20250127T000000_add_cache_tables.ts b/scripts/migrations/src/migrations/20250127T000000_add_cache_tables.ts new file mode 100644 index 0000000..ec78723 --- /dev/null +++ b/scripts/migrations/src/migrations/20250127T000000_add_cache_tables.ts @@ -0,0 +1,27 @@ +import { Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + // Create pricing cache table + await db.schema + .createTable("priceCache") + .addColumn("tokenCode", "text", (col) => col.notNull()) + .addColumn("timestampMs", "integer", (col) => col.notNull()) + .addColumn("priceUsd", "decimal(36, 18)", (col) => col.notNull()) + .addColumn("createdAt", "timestamptz", (col) => col.defaultTo(sql`now()`)) + .addPrimaryKeyConstraint("pricing_cache_pkey", ["tokenCode", "timestampMs"]) + .execute(); + + // Create metadata cache table + await db.schema + .createTable("metadataCache") + .addColumn("id", "text", (col) => col.notNull()) + .addColumn("metadata", "jsonb", (col) => col.notNull()) + .addColumn("createdAt", "timestamptz", (col) => col.defaultTo(sql`now()`)) + .addPrimaryKeyConstraint("metadata_cache_pkey", ["id"]) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("priceCache").execute(); + await db.schema.dropTable("metadataCache").execute(); +}