From 3f34a47153e576403692b39bde8fd6d66c36bc1d Mon Sep 17 00:00:00 2001 From: pheuberger Date: Fri, 17 Jan 2025 11:53:37 +0200 Subject: [PATCH 1/8] style: prettier format sorting.ts --- src/graphql/schemas/utils/sorting.ts | 121 +++++++++++++++++---------- 1 file changed, 76 insertions(+), 45 deletions(-) diff --git a/src/graphql/schemas/utils/sorting.ts b/src/graphql/schemas/utils/sorting.ts index bed293b3..1bc48091 100644 --- a/src/graphql/schemas/utils/sorting.ts +++ b/src/graphql/schemas/utils/sorting.ts @@ -1,57 +1,88 @@ -import {PostgrestTransformBuilder} from "@supabase/postgrest-js"; -import type {Database as CachingDatabase} from "../../../types/supabaseCaching.js"; -import type {OrderOptions} from "../inputs/orderOptions.js"; +import { PostgrestTransformBuilder } from "@supabase/postgrest-js"; + +import type { Database as CachingDatabase } from "../../../types/supabaseCaching.js"; +import type { OrderOptions } from "../inputs/orderOptions.js"; import { - AttestationSchemaSortOptions, - AttestationSortOptions, - ContractSortOptions, - FractionSortOptions, - HypercertSortOptions, - MetadataSortOptions + AttestationSchemaSortOptions, + AttestationSortOptions, + ContractSortOptions, + FractionSortOptions, + HypercertSortOptions, + MetadataSortOptions, } from "../inputs/sortOptions.js"; -import {SortOrder} from "../enums/sortEnums.js"; +import { SortOrder } from "../enums/sortEnums.js"; interface ApplySorting< - T extends object, - QueryType extends PostgrestTransformBuilder, unknown, unknown, unknown> + T extends object, + QueryType extends PostgrestTransformBuilder< + CachingDatabase["public"], + Record, + unknown, + unknown, + unknown + >, > { - query: QueryType; - sort?: OrderOptions; + query: QueryType; + sort?: OrderOptions; } -export const applySorting = , unknown, unknown, unknown>>({ - query, - sort, - }: ApplySorting) => { - if (!sort) return query; +export const applySorting = < + T extends object, + QueryType extends PostgrestTransformBuilder< + CachingDatabase["public"], + Record, + unknown, + unknown, + unknown + >, +>({ + query, + sort, +}: ApplySorting) => { + if (!sort) return query; - const sorting: [string, { ascending?: boolean, nullsFirst?: boolean, referencedTable?: string } | undefined][] = []; - for (const [_, value] of Object.entries(sort)) { - if (!value) continue; + const sorting: [ + string, + ( + | { ascending?: boolean; nullsFirst?: boolean; referencedTable?: string } + | undefined + ), + ][] = []; + for (const [, value] of Object.entries(sort)) { + if (!value) continue; - // If the value is an object, recursively apply sorting - if (value instanceof HypercertSortOptions || value instanceof FractionSortOptions || value instanceof ContractSortOptions || value instanceof AttestationSortOptions || value instanceof MetadataSortOptions || value instanceof AttestationSchemaSortOptions) { - const nestedSorting: [string, { - ascending?: boolean, - nullsFirst?: boolean, - referencedTable?: string - }][] = []; - for (const [_column, _direction] of Object.entries(value)) { - if (!_column || !_direction) continue; - // TODO resolve hacky workaround for hypercerts <> claims alias - nestedSorting.push([_column, {ascending: _direction !== SortOrder.descending}]); - } - sorting.push(...nestedSorting); - } + // If the value is an object, recursively apply sorting + if ( + value instanceof HypercertSortOptions || + value instanceof FractionSortOptions || + value instanceof ContractSortOptions || + value instanceof AttestationSortOptions || + value instanceof MetadataSortOptions || + value instanceof AttestationSchemaSortOptions + ) { + const nestedSorting: [ + string, + { + ascending?: boolean; + nullsFirst?: boolean; + referencedTable?: string; + }, + ][] = []; + for (const [_column, _direction] of Object.entries(value)) { + if (!_column || !_direction) continue; + // TODO resolve hacky workaround for hypercerts <> claims alias + nestedSorting.push([ + _column, + { ascending: _direction !== SortOrder.descending }, + ]); + } + sorting.push(...nestedSorting); } + } - query = sorting - .reduce( - (acc, [column, options]) => { - return acc.order(column, options); - }, - query - ) + query = sorting.reduce((acc, [column, options]) => { + return acc.order(column, options); + }, query); - return query as unknown as QueryType; -} \ No newline at end of file + return query as unknown as QueryType; +}; From df469bb3d4d6c50b669f667409a4acd9afdc719b Mon Sep 17 00:00:00 2001 From: pheuberger Date: Mon, 20 Jan 2025 20:20:40 +0200 Subject: [PATCH 2/8] fix: sort general supabase queries This sort function was only built to work with a few very specific types from the Caching DB. However, it was used on data that wasn't meant for the caching DB. Namely the Blueprints and perhaps others. This implements basic sorting for all database types. --- src/graphql/schemas/utils/sorting.ts | 50 +++++++++++++--------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/graphql/schemas/utils/sorting.ts b/src/graphql/schemas/utils/sorting.ts index 1bc48091..1d1d09e0 100644 --- a/src/graphql/schemas/utils/sorting.ts +++ b/src/graphql/schemas/utils/sorting.ts @@ -1,5 +1,6 @@ import { PostgrestTransformBuilder } from "@supabase/postgrest-js"; +import { Database as DataDatabase } from "../../../types/supabaseData.js"; import type { Database as CachingDatabase } from "../../../types/supabaseCaching.js"; import type { OrderOptions } from "../inputs/orderOptions.js"; import { @@ -15,7 +16,7 @@ import { SortOrder } from "../enums/sortEnums.js"; interface ApplySorting< T extends object, QueryType extends PostgrestTransformBuilder< - CachingDatabase["public"], + CachingDatabase["public"] | DataDatabase["public"], Record, unknown, unknown, @@ -26,10 +27,16 @@ interface ApplySorting< sort?: OrderOptions; } +type ColumnOpts = { + ascending?: boolean; + nullsFirst?: boolean; + referencedTable?: string; +}; + export const applySorting = < T extends object, QueryType extends PostgrestTransformBuilder< - CachingDatabase["public"], + CachingDatabase["public"] | DataDatabase["public"], Record, unknown, unknown, @@ -41,17 +48,18 @@ export const applySorting = < }: ApplySorting) => { if (!sort) return query; - const sorting: [ - string, - ( - | { ascending?: boolean; nullsFirst?: boolean; referencedTable?: string } - | undefined - ), - ][] = []; - for (const [, value] of Object.entries(sort)) { + const sorting: [string, ColumnOpts][] = []; + for (const [key, value] of Object.entries(sort.by || {})) { if (!value) continue; - // If the value is an object, recursively apply sorting + // Handle direct sorting parameters + if (typeof value === "string") { + sorting.push([key, { ascending: value !== SortOrder.descending }]); + continue; + } + + // Handle nested sorting options + // FIXME: This is brittle. We should find a way to generalize this if ( value instanceof HypercertSortOptions || value instanceof FractionSortOptions || @@ -60,23 +68,13 @@ export const applySorting = < value instanceof MetadataSortOptions || value instanceof AttestationSchemaSortOptions ) { - const nestedSorting: [ - string, - { - ascending?: boolean; - nullsFirst?: boolean; - referencedTable?: string; - }, - ][] = []; - for (const [_column, _direction] of Object.entries(value)) { - if (!_column || !_direction) continue; - // TODO resolve hacky workaround for hypercerts <> claims alias - nestedSorting.push([ - _column, - { ascending: _direction !== SortOrder.descending }, + for (const [column, direction] of Object.entries(value)) { + if (!column || !direction) continue; + sorting.push([ + `${key}.${column}`, + { ascending: direction !== SortOrder.descending }, ]); } - sorting.push(...nestedSorting); } } From 8f847a6d79e015d9ed17e5d1a46693358321c151 Mon Sep 17 00:00:00 2001 From: pheuberger Date: Thu, 23 Jan 2025 11:27:38 +0200 Subject: [PATCH 3/8] chore: exclude services/ from coverage Our services are difficult to test at the moment and we don't really have tests for them. However, when you make changes to the GraphQL schema and potentially introduce new entities, you'll also alway have to add code to the services and might go below the thresholds set. PR #218 is going to address this problem and make the services as well as some of the GraphQL testable. This is in anticipation of the next commit which is going to introduce the collections entity as a top-level entity. --- vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vitest.config.ts b/vitest.config.ts index 611b54e2..ca1bf94a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ "**/types.ts", "src/__generated__/**/*", "src/graphql/**/*", + "src/services/**/*", "src/types/**/*", "src/abis/**/*", "./lib/**/*", From 39354f54b6c64f324d095e42ac51599d4e487721 Mon Sep 17 00:00:00 2001 From: pheuberger Date: Mon, 20 Jan 2025 22:05:34 +0200 Subject: [PATCH 4/8] feat: add collections entity Without this patch the user of our GraphQL endpoints needs to go via hyperboards to access the collections inside. Another drawback is that via this query they can only access the hypercert id, so they'll have to fire a separate query to combine the data. This patch adds collections as a top level entity and exposes full hypercert objects as child entities. This also applies to hyperboards as the standalone collections entity is being reused there too. --- schema.graphql | 24 +++++ src/graphql/schemas/args/collectionArgs.ts | 28 +++++ src/graphql/schemas/inputs/collectionInput.ts | 18 ++++ src/graphql/schemas/inputs/sortOptions.ts | 11 ++ .../schemas/resolvers/collectionResolver.ts | 101 ++++++++++++++++++ src/graphql/schemas/resolvers/composed.ts | 2 + .../schemas/typeDefs/collectionTypeDefs.ts | 34 ++++++ .../schemas/typeDefs/hyperboardTypeDefs.ts | 21 +--- src/services/BaseSupabaseService.ts | 2 +- src/services/SupabaseDataService.ts | 64 +++++++++++ 10 files changed, 284 insertions(+), 21 deletions(-) create mode 100644 src/graphql/schemas/args/collectionArgs.ts create mode 100644 src/graphql/schemas/inputs/collectionInput.ts create mode 100644 src/graphql/schemas/resolvers/collectionResolver.ts create mode 100644 src/graphql/schemas/typeDefs/collectionTypeDefs.ts diff --git a/schema.graphql b/schema.graphql index 31655f93..733e5087 100644 --- a/schema.graphql +++ b/schema.graphql @@ -333,6 +333,7 @@ input BooleanSearchOptions { """Collection of hypercerts for reference and display purposes""" type Collection { admins: [User!]! + blueprints: [Blueprint!] """Chain ID of the collection""" chain_ids: [EthBigInt!] @@ -342,12 +343,29 @@ type Collection { """Description of the collection""" description: String! + hypercerts: [Hypercert!] id: ID! """Name of the collection""" name: String! } +input CollectionFetchInput { + by: CollectionSortOptions +} + +input CollectionSortOptions { + created_at: SortOrder + description: SortOrder + name: SortOrder +} + +input CollectionWhereInput { + description: StringSearchOptions + id: IdSearchOptions + name: StringSearchOptions +} + """Pointer to a contract deployed on a chain""" type Contract { """The ID of the chain on which the contract is deployed""" @@ -469,6 +487,11 @@ type GetBlueprintResponse { data: [Blueprint!] } +type GetCollectionsResponse { + count: Int + data: [Collection!] +} + """Pointer to a contract deployed on a chain""" type GetContractsResponse { count: Int @@ -889,6 +912,7 @@ type Query { attestationSchemas(first: Int, offset: Int): GetAttestationsSchemaResponse! attestations(first: Int, offset: Int, sort: AttestationFetchInput, where: AttestationWhereInput): GetAttestationsResponse! blueprints(first: Int, offset: Int, sort: BlueprintFetchInput, where: BlueprintWhereInput): GetBlueprintResponse! + collections(first: Int, offset: Int, sort: CollectionFetchInput, where: CollectionWhereInput): GetCollectionsResponse! contracts(first: Int, offset: Int, sort: ContractFetchInput, where: ContractWhereInput): GetContractsResponse! fractions(first: Int, offset: Int, sort: FractionFetchInput, where: FractionWhereInput): GetFractionsResponse! hyperboards(first: Int, offset: Int, sort: HyperboardFetchInput, where: HyperboardWhereInput): GetHyperboardsResponse! diff --git a/src/graphql/schemas/args/collectionArgs.ts b/src/graphql/schemas/args/collectionArgs.ts new file mode 100644 index 00000000..d150cbe7 --- /dev/null +++ b/src/graphql/schemas/args/collectionArgs.ts @@ -0,0 +1,28 @@ +import { ArgsType, Field, InputType } from "type-graphql"; + +import { BasicCollectionWhereInput } from "../inputs/collectionInput.js"; +import type { OrderOptions } from "../inputs/orderOptions.js"; +import { Collection } from "../typeDefs/collectionTypeDefs.js"; +import { CollectionSortOptions } from "../inputs/sortOptions.js"; + +import { withPagination } from "./baseArgs.js"; + +@InputType() +export class CollectionWhereInput extends BasicCollectionWhereInput {} + +@InputType() +export class CollectionFetchInput implements OrderOptions { + @Field(() => CollectionSortOptions, { nullable: true }) + by?: CollectionSortOptions; +} + +@ArgsType() +export class CollectionArgs { + @Field(() => CollectionWhereInput, { nullable: true }) + where?: CollectionWhereInput; + @Field(() => CollectionFetchInput, { nullable: true }) + sort?: CollectionFetchInput; +} + +@ArgsType() +export class GetCollectionsArgs extends withPagination(CollectionArgs) {} diff --git a/src/graphql/schemas/inputs/collectionInput.ts b/src/graphql/schemas/inputs/collectionInput.ts new file mode 100644 index 00000000..617ebafd --- /dev/null +++ b/src/graphql/schemas/inputs/collectionInput.ts @@ -0,0 +1,18 @@ +import { Field, InputType } from "type-graphql"; + +import { Collection } from "../typeDefs/collectionTypeDefs.js"; + +import { IdSearchOptions, StringSearchOptions } from "./searchOptions.js"; +import type { WhereOptions } from "./whereOptions.js"; + +@InputType() +export class BasicCollectionWhereInput implements WhereOptions { + @Field(() => IdSearchOptions, { nullable: true }) + id?: IdSearchOptions | null; + + @Field(() => StringSearchOptions, { nullable: true }) + name?: StringSearchOptions; + + @Field(() => StringSearchOptions, { nullable: true }) + description?: StringSearchOptions; +} diff --git a/src/graphql/schemas/inputs/sortOptions.ts b/src/graphql/schemas/inputs/sortOptions.ts index 07e1bd99..9c467a61 100644 --- a/src/graphql/schemas/inputs/sortOptions.ts +++ b/src/graphql/schemas/inputs/sortOptions.ts @@ -11,6 +11,7 @@ import { Sale } from "../typeDefs/salesTypeDefs.js"; import { Hyperboard } from "../typeDefs/hyperboardTypeDefs.js"; import { Blueprint } from "../typeDefs/blueprintTypeDefs.js"; import { SignatureRequest } from "../typeDefs/signatureRequestTypeDefs.js"; +import { Collection } from "../typeDefs/collectionTypeDefs.js"; export type SortOptions = { [P in keyof T]: SortOrder | null; @@ -238,3 +239,13 @@ export class SignatureRequestSortOptions @Field(() => SortOrder, { nullable: true }) purpose?: SortOrder; } + +@InputType() +export class CollectionSortOptions implements SortOptions { + @Field(() => SortOrder, { nullable: true }) + name?: SortOrder; + @Field(() => SortOrder, { nullable: true }) + created_at?: SortOrder; + @Field(() => SortOrder, { nullable: true }) + description?: SortOrder; +} diff --git a/src/graphql/schemas/resolvers/collectionResolver.ts b/src/graphql/schemas/resolvers/collectionResolver.ts new file mode 100644 index 00000000..76ba7990 --- /dev/null +++ b/src/graphql/schemas/resolvers/collectionResolver.ts @@ -0,0 +1,101 @@ +import { + Args, + FieldResolver, + ObjectType, + Query, + Resolver, + Root, +} from "type-graphql"; + +import { GetCollectionsArgs } from "../args/collectionArgs.js"; +import { Collection } from "../typeDefs/collectionTypeDefs.js"; +import { Blueprint } from "../typeDefs/blueprintTypeDefs.js"; +import { User } from "../typeDefs/userTypeDefs.js"; + +import { createBaseResolver, DataResponse } from "./baseTypes.js"; +import GetHypercertsResponse from "./hypercertResolver.js"; + +@ObjectType() +class GetCollectionsResponse extends DataResponse(Collection) {} + +const CollectionBaseResolver = createBaseResolver("collection"); + +@Resolver(() => Collection) +class CollectionResolver extends CollectionBaseResolver { + @Query(() => GetCollectionsResponse) + async collections(@Args() args: GetCollectionsArgs) { + try { + const res = await this.supabaseDataService.getCollections(args); + + return { + data: res.data, + count: res.count, + }; + } catch (e) { + console.error("[CollectionResolver::collections] Error:", e); + throw new Error(`Error fetching collections: ${(e as Error).message}`); + } + } + + @FieldResolver(() => GetHypercertsResponse) + async hypercerts(@Root() collection: Collection) { + if (!collection.id) { + console.error( + "[CollectionResolver::hypercerts] Collection ID is undefined", + ); + return []; + } + + const hypercerts = await this.supabaseDataService.getCollectionHypercerts( + collection.id, + ); + + if (!hypercerts?.length) { + return []; + } + + const hypercertIds = hypercerts + .map((h) => h.hypercert_id) + .filter((id): id is string => id !== undefined); + + if (hypercertIds.length === 0) { + return []; + } + + const hypercertsData = await this.getHypercerts({ + where: { hypercert_id: { in: hypercertIds } }, + }); + + return hypercertsData.data || []; + } + + @FieldResolver(() => [User]) + async admins(@Root() collection: Collection) { + if (!collection.id) { + console.error("[CollectionResolver::admins] Collection ID is undefined"); + return []; + } + + const admins = await this.supabaseDataService.getCollectionAdmins( + collection.id, + ); + return admins || []; + } + + @FieldResolver(() => [Blueprint]) + async blueprints(@Root() collection: Collection) { + if (!collection.id) { + console.error( + "[CollectionResolver::blueprints] Collection ID is undefined", + ); + return []; + } + + const blueprints = await this.supabaseDataService.getCollectionBlueprints( + collection.id, + ); + return blueprints || []; + } +} + +export { CollectionResolver }; diff --git a/src/graphql/schemas/resolvers/composed.ts b/src/graphql/schemas/resolvers/composed.ts index 430d07fa..2d645ef8 100644 --- a/src/graphql/schemas/resolvers/composed.ts +++ b/src/graphql/schemas/resolvers/composed.ts @@ -11,6 +11,7 @@ import { SalesResolver } from "./salesResolver.js"; import { UserResolver } from "./userResolver.js"; import { BlueprintResolver } from "./blueprintResolver.js"; import { SignatureRequestResolver } from "./signatureRequestResolver.js"; +import { CollectionResolver } from "./collectionResolver.js"; export const resolvers = [ ContractResolver, @@ -26,4 +27,5 @@ export const resolvers = [ UserResolver, BlueprintResolver, SignatureRequestResolver, + CollectionResolver, ] as const; diff --git a/src/graphql/schemas/typeDefs/collectionTypeDefs.ts b/src/graphql/schemas/typeDefs/collectionTypeDefs.ts new file mode 100644 index 00000000..019f4378 --- /dev/null +++ b/src/graphql/schemas/typeDefs/collectionTypeDefs.ts @@ -0,0 +1,34 @@ +import { Field, ObjectType } from "type-graphql"; + +import { EthBigInt } from "../../scalars/ethBigInt.js"; + +import { BasicTypeDef } from "./baseTypes/basicTypeDef.js"; +import { User } from "./userTypeDefs.js"; +import { Hypercert } from "./hypercertTypeDefs.js"; +import { Blueprint } from "./blueprintTypeDefs.js"; + +@ObjectType({ + description: "Collection of hypercerts for reference and display purposes", +}) +export class Collection extends BasicTypeDef { + @Field({ description: "Creation timestamp of the collection" }) + created_at?: string; + @Field({ description: "Name of the collection" }) + name?: string; + @Field({ description: "Description of the collection" }) + description?: string; + @Field(() => [EthBigInt], { + nullable: true, + description: "Chain ID of the collection", + }) + chain_ids?: (bigint | number | string)[]; + + @Field(() => [User]) + admins?: User[]; + + @Field(() => [Hypercert], { nullable: true }) + hypercerts?: Hypercert[]; + + @Field(() => [Blueprint], { nullable: true }) + blueprints?: Blueprint[]; +} diff --git a/src/graphql/schemas/typeDefs/hyperboardTypeDefs.ts b/src/graphql/schemas/typeDefs/hyperboardTypeDefs.ts index eaa17214..0b7a7f97 100644 --- a/src/graphql/schemas/typeDefs/hyperboardTypeDefs.ts +++ b/src/graphql/schemas/typeDefs/hyperboardTypeDefs.ts @@ -3,6 +3,7 @@ import { BasicTypeDef } from "./baseTypes/basicTypeDef.js"; import { EthBigInt } from "../../scalars/ethBigInt.js"; import { User } from "./userTypeDefs.js"; import { GraphQLBigInt } from "graphql-scalars"; +import { Collection } from "./collectionTypeDefs.js"; @ObjectType({ description: "Hyperboard of hypercerts for reference and display purposes", @@ -48,26 +49,6 @@ class SectionResponseType { count?: number; } -@ObjectType({ - description: "Collection of hypercerts for reference and display purposes", -}) -class Collection extends BasicTypeDef { - @Field({ description: "Creation timestamp of the collection" }) - created_at?: string; - @Field({ description: "Name of the collection" }) - name?: string; - @Field({ description: "Description of the collection" }) - description?: string; - @Field(() => [EthBigInt], { - nullable: true, - description: "Chain ID of the collection", - }) - chain_ids?: (bigint | number | string)[]; - - @Field(() => [User]) - admins?: User[]; -} - @ObjectType({ description: "Section representing a collection within a hyperboard", }) diff --git a/src/services/BaseSupabaseService.ts b/src/services/BaseSupabaseService.ts index 3809447b..46742586 100644 --- a/src/services/BaseSupabaseService.ts +++ b/src/services/BaseSupabaseService.ts @@ -85,7 +85,7 @@ export abstract class BaseSupabaseService { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - private applySorting(query: any, sortBy: any) { + applySorting(query: any, sortBy: any) { for (const [column, direction] of Object.entries(sortBy)) { if (!column || !direction) continue; const dir: "asc" | "desc" = diff --git a/src/services/SupabaseDataService.ts b/src/services/SupabaseDataService.ts index a73fed3d..799e87e3 100644 --- a/src/services/SupabaseDataService.ts +++ b/src/services/SupabaseDataService.ts @@ -22,6 +22,7 @@ import { jsonArrayFrom } from "kysely/helpers/postgres"; import { GetBlueprintArgs } from "../graphql/schemas/args/blueprintArgs.js"; import { sql } from "kysely"; import { GetSignatureRequestArgs } from "../graphql/schemas/args/signatureRequestArgs.js"; +import { GetCollectionsArgs } from "../graphql/schemas/args/collectionArgs.js"; @singleton() export class SupabaseDataService extends BaseSupabaseService { @@ -432,6 +433,63 @@ export class SupabaseDataService extends BaseSupabaseService .execute(); } + async getCollections(args: GetCollectionsArgs) { + let query = this.db + .selectFrom("collections") + .select([ + "collections.id", + "collections.name", + "collections.description", + "collections.chain_ids", + "collections.hidden", + "collections.created_at", + ]); + + if (args.sort?.by) { + query = this.applySorting(query, args.sort.by); + } + + return { + data: await query.execute(), + count: this.handleGetCount("collections", args), + }; + } + + async getCollectionHypercerts(collectionId: string) { + return this.db + .selectFrom("hypercerts") + .select(["hypercert_id", "collection_id"]) + .where("collection_id", "=", collectionId) + .execute(); + } + + async getCollectionAdmins(collectionId: string) { + return this.db + .selectFrom("users") + .innerJoin("collection_admins", "collection_admins.user_id", "users.id") + .select([ + "users.address", + "users.chain_id", + "users.display_name", + "users.avatar", + ]) + .where("collection_admins.collection_id", "=", collectionId) + .execute(); + } + + async getCollectionBlueprints(collectionId: string) { + return this.db + .selectFrom("blueprints") + .innerJoin( + "collection_blueprints", + "collection_blueprints.blueprint_id", + "blueprints.id", + ) + .selectAll("blueprints") + .where("collection_blueprints.collection_id", "=", collectionId) + .execute(); + } + async getCollectionById(collectionId: string) { return this.db .selectFrom("collections") @@ -719,6 +777,8 @@ export class SupabaseDataService extends BaseSupabaseService return this.db.selectFrom("users").selectAll(); case "signature_requests": return this.db.selectFrom("signature_requests").selectAll(); + case "collections": + return this.db.selectFrom("collections").selectAll(); default: throw new Error(`Table ${tableName.toString()} not found`); } @@ -759,6 +819,10 @@ export class SupabaseDataService extends BaseSupabaseService return this.db.selectFrom("users").select((expressionBuilder) => { return expressionBuilder.fn.countAll().as("count"); }); + case "collections": + return this.db.selectFrom("collections").select((expressionBuilder) => { + return expressionBuilder.fn.countAll().as("count"); + }); default: throw new Error(`Table ${tableName.toString()} not found`); } From 2b152402d3e0b81973830f3e96fda53f1e219e0b Mon Sep 17 00:00:00 2001 From: pheuberger Date: Tue, 21 Jan 2025 13:29:32 +0200 Subject: [PATCH 5/8] refactor: make env var assertion more intuitive The problem right now is that the constants that are loaded from the environment have an additional name if specified. This additional name is very confusing if you're presented with the error message Environment variable Alchemy API key is not set. This makes you wonder if perhaps you have mistyped the variable name. It's almost always better to just print the variable name as it is expected in your .env file. One notable exception is perhaps our Web3Up proof and secret. That's why this patch changes the approach of assertExists() to only deal with environment variables (it's not used in any other way currently) and have the caller specify the variable name and an optional hint to help the person stumbling over a missing env var error with additional context. The name of assertExists() was renamed to getRequiredEnvVar() to better reflect what the function is doing. The filename was also renamed to further support this. --- src/index.ts | 4 +-- src/utils/assertExists.ts | 7 ----- src/utils/constants.ts | 62 ++++++++------------------------------- src/utils/envVars.ts | 10 +++++++ 4 files changed, 25 insertions(+), 58 deletions(-) delete mode 100644 src/utils/assertExists.ts create mode 100644 src/utils/envVars.ts diff --git a/src/index.ts b/src/index.ts index 977f94ef..64e9298f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import "./instrument.js"; import express, { type Express } from "express"; import "reflect-metadata"; import cors from "cors"; -import { assertExists } from "./utils/assertExists.js"; +import { getRequiredEnvVar } from "./utils/envVars.js"; import { yoga } from "./client/graphql.js"; import swaggerUi from "swagger-ui-express"; import swaggerJson from "./__generated__/swagger.json" assert { type: "json" }; @@ -21,7 +21,7 @@ BigInt.prototype.fromJSON = function () { return BigInt(this.toString()); }; -const PORT = assertExists(process.env.PORT, "PORT"); +const PORT = getRequiredEnvVar("PORT"); const app: Express = express(); diff --git a/src/utils/assertExists.ts b/src/utils/assertExists.ts deleted file mode 100644 index f51e28c3..00000000 --- a/src/utils/assertExists.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const assertExists = (variable?: string, name?: string) => { - if (!variable) { - throw new Error(`Environment variable ${name} is not set.`); - } - - return variable; -}; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 55e59643..69f78c32 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,54 +1,18 @@ -import { assertExists } from "./assertExists.js"; +import { getRequiredEnvVar } from "./envVars.js"; -export const supabaseCachingUrl = assertExists( - process.env.SUPABASE_CACHING_DB_URL, - "SUPABASE_CACHING_DB_URL", -); - -export const supabaseCachingApiKey = assertExists( - process.env.SUPABASE_CACHING_ANON_API_KEY, +export const supabaseCachingUrl = getRequiredEnvVar("SUPABASE_CACHING_DB_URL"); +export const supabaseCachingApiKey = getRequiredEnvVar( "SUPABASE_CACHING_ANON_API_KEY", ); - -export const supabaseDataUrl = assertExists( - process.env.SUPABASE_DATA_DB_URL, - "SUPABASE_DATA_DB_URL", -); - -export const supabaseDataServiceApiKey = assertExists( - process.env.SUPABASE_DATA_SERVICE_API_KEY, +export const supabaseDataUrl = getRequiredEnvVar("SUPABASE_DATA_DB_URL"); +export const supabaseDataServiceApiKey = getRequiredEnvVar( "SUPABASE_DATA_SERVICE_API_KEY", ); - -export const web3upKey = assertExists(process.env.KEY, "WEB3UP_KEY"); -export const web3upProof = assertExists(process.env.PROOF, "WEB3UP_PROOF"); - -export const indexerEnvironment = assertExists( - process.env.INDEXER_ENVIRONMENT, - "INDEXER_ENVIRONMENT", -); - -export const alchemyApiKey = assertExists( - process.env.ALCHEMY_API_KEY, - "Alchemy API key", -); - -export const infuraApiKey = assertExists( - process.env.INFURA_API_KEY, - "Infura API key", -); - -export const drpcApiPkey = assertExists( - process.env.DRPC_API_KEY, - "dRPC API KEY", -); - -export const cachingDatabaseUrl = assertExists( - process.env.CACHING_DATABASE_URL, - "CACHING_DATABASE_URL", -); - -export const dataDatabaseUrl = assertExists( - process.env.DATA_DATABASE_URL, - "DATA_DATABASE_URL", -); +export const web3upKey = getRequiredEnvVar("KEY", "WEB3UP Key"); +export const web3upProof = getRequiredEnvVar("PROOF", "WEB3UP Proof"); +export const indexerEnvironment = getRequiredEnvVar("INDEXER_ENVIRONMENT"); +export const alchemyApiKey = getRequiredEnvVar("ALCHEMY_API_KEY"); +export const infuraApiKey = getRequiredEnvVar("INFURA_API_KEY"); +export const drpcApiPkey = getRequiredEnvVar("DRPC_API_KEY"); +export const cachingDatabaseUrl = getRequiredEnvVar("CACHING_DATABASE_URL"); +export const dataDatabaseUrl = getRequiredEnvVar("DATA_DATABASE_URL"); diff --git a/src/utils/envVars.ts b/src/utils/envVars.ts new file mode 100644 index 00000000..c21ed0d7 --- /dev/null +++ b/src/utils/envVars.ts @@ -0,0 +1,10 @@ +export const getRequiredEnvVar = (variableName: string, hint?: string) => { + const variable = process.env[variableName]; + if (!variable) { + throw new Error( + `Environment variable ${variableName}${hint ? ` (${hint})` : ""} is not set.`, + ); + } + + return variable; +}; From 366915aef004328edd4bcc578855d137609cf426 Mon Sep 17 00:00:00 2001 From: pheuberger Date: Tue, 21 Jan 2025 13:29:53 +0200 Subject: [PATCH 6/8] chore: remove unused import in instrument.mjs --- src/instrument.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/instrument.mjs b/src/instrument.mjs index 01d5dce8..8e93c1d3 100644 --- a/src/instrument.mjs +++ b/src/instrument.mjs @@ -1,5 +1,4 @@ import * as Sentry from '@sentry/node'; -import {assertExists} from "./utils/assertExists.js"; import { nodeProfilingIntegration } from "@sentry/profiling-node"; // Ensure to call this before importing any other modules! From ccc0a577074a97de8bda8f35c67570c0bc2abcba Mon Sep 17 00:00:00 2001 From: pheuberger Date: Tue, 21 Jan 2025 13:32:56 +0200 Subject: [PATCH 7/8] test: simplify verifyAuthSignedData test The current test file was testing library code that is already comprehensively tested. It was also making slow RPC requests and slowed the test suite down substantially. It requires the Alchemy API key to be set which is not something that we want, so this patch is doing away with all superfluous tests and focuses on testing only the code that we've added around the library call. Also lowered the coverage thresholds since the EVM client getter is now mocked and its internals are thus not covered by these tests any longer. --- test/utils/verifyAuthSignedData.test.ts | 351 ++++-------------------- vitest.config.ts | 4 +- 2 files changed, 53 insertions(+), 302 deletions(-) diff --git a/test/utils/verifyAuthSignedData.test.ts b/test/utils/verifyAuthSignedData.test.ts index 8f5b96a3..ede0dd45 100644 --- a/test/utils/verifyAuthSignedData.test.ts +++ b/test/utils/verifyAuthSignedData.test.ts @@ -1,318 +1,69 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { verifyAuthSignedData } from "../../src/utils/verifyAuthSignedData.js"; -import { - createTestClient, - http, - publicActions, - VerifyTypedDataParameters, - walletActions, -} from "viem"; -import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; -import { optimism, sepolia } from "viem/chains"; -describe("verifyAuthSignedData", async () => { - // Create first test wallet - const privateKey1 = generatePrivateKey(); - const testClient1 = createTestClient({ - account: privateKeyToAccount(privateKey1), - chain: sepolia, - mode: "anvil", - transport: http(), - }) - .extend(publicActions) - .extend(walletActions); - const address1 = testClient1.account.address; - - // Create second test wallet - const privateKey2 = generatePrivateKey(); - const testClient2 = createTestClient({ - account: privateKeyToAccount(privateKey2), - chain: optimism, - mode: "anvil", - transport: http(), - }) - .extend(publicActions) - .extend(walletActions); - const address2 = testClient2.account.address; - - const types = { - test: [{ name: "message", type: "string" }], - } as VerifyTypedDataParameters["types"]; - const domain = { - name: "Hypercerts", - version: "1", - chainId: sepolia.id, - } as const; - const message = { - message: "test", - } as VerifyTypedDataParameters["message"]; - - const signTypedData = async ( - client: typeof testClient1, - overrides?: { - domainOverride?: Partial<{ - name: string; - version: string; - chainId: number; - }>; - messageOverride?: Parameters< - typeof testClient1.signTypedData - >[0]["message"]; - }, - ) => { - return await client.signTypedData({ - domain: overrides?.domainOverride ?? domain, - message: overrides?.messageOverride ?? message, - types, - primaryType: "test", - }); - }; - it("Verifies signature correctly", async () => { - const signature = await signTypedData(testClient1); - - const result = await verifyAuthSignedData({ - address: address1, - message: { - message: "test", - }, - types, - signature, - primaryType: "test", - requiredChainId: sepolia.id, - }); - expect(result).toEqual(true); - }); - - it("Fails to verify signature with wrong address", async () => { - const signature = await signTypedData(testClient1); - - const result = await verifyAuthSignedData({ - address: address2, - message: { - message: "test", - }, - types, - signature, - primaryType: "test", - requiredChainId: sepolia.id, - }); - expect(result).toEqual(false); - }); - - it("Fails to verify signature with wrong message", async () => { - const signature = await signTypedData(testClient1); - - const result = await verifyAuthSignedData({ - address: address1, - message: { - message: "wrong", - }, - types, - signature, - primaryType: "test", - requiredChainId: sepolia.id, - }); - expect(result).toEqual(false); - }); - - it("Fails to verify signature with wrong chainId", async () => { - const signature = await signTypedData(testClient1); - - const result = await verifyAuthSignedData({ - address: address1, - message: { - message: "test", - }, - types, - signature, - primaryType: "test", - requiredChainId: optimism.id, - }); - expect(result).toEqual(false); +const mockVerifyTypedData = vi.hoisted(() => vi.fn()); + +vi.mock("../../src/utils/getRpcUrl.js", () => ({ + getEvmClient: vi.fn().mockReturnValue({ + verifyTypedData: mockVerifyTypedData, + account: undefined, + batch: undefined, + cacheTime: 0, + chain: undefined, + key: "mock", + name: "Mock Client", + pollingInterval: 0, + request: vi.fn(), + transport: { type: "mock" }, + type: "publicClient", + uid: "mock-client", + }), +})); + +describe("verifyAuthSignedData", () => { + beforeEach(() => { + mockVerifyTypedData.mockReset(); }); - it("Fails to verify with wrong domain - missing chain id", async () => { - const signature = await signTypedData(testClient1, { - domainOverride: { name: "Hypercerts", version: "1" }, - }); + it("verifies hypercerts domain added to message", async () => { + mockVerifyTypedData.mockResolvedValue(true); const result = await verifyAuthSignedData({ - address: address1, - message: { - message: "test", - }, - types, - signature, - primaryType: "test", - requiredChainId: sepolia.id, - }); - expect(result).toEqual(false); - }); - - it("Fails to verify with wrong domain - wrong chain id", async () => { - const signature = await signTypedData(testClient1, { - domainOverride: { + address: "0x123", + message: { test: "message" }, + types: { Test: [{ name: "test", type: "string" }] }, + signature: "0xsignature", + primaryType: "Test", + requiredChainId: 1, + }); + + expect(result).toBe(true); + expect(mockVerifyTypedData).toHaveBeenCalledWith({ + address: "0x123", + message: { test: "message" }, + types: { Test: [{ name: "test", type: "string" }] }, + signature: "0xsignature", + primaryType: "Test", + domain: { name: "Hypercerts", version: "1", - chainId: optimism.id, - }, - }); - - const result = await verifyAuthSignedData({ - address: address1, - message: { - message: "test", - }, - types, - signature, - primaryType: "test", - requiredChainId: sepolia.id, - }); - expect(result).toEqual(false); - }); - - it("Fails to verify with wrong domain - wrong name", async () => { - const signature = await signTypedData(testClient1, { - domainOverride: { - name: "Wrong", - version: "1", - chainId: sepolia.id, - }, - }); - - const result = await verifyAuthSignedData({ - address: address1, - message: { - message: "test", - }, - types, - signature, - primaryType: "test", - requiredChainId: sepolia.id, - }); - expect(result).toEqual(false); - }); - - it("Fails to verify with wrong domain - wrong version", async () => { - const signature = await signTypedData(testClient1, { - domainOverride: { - name: "Hypercerts", - version: "2", - chainId: sepolia.id, - }, - }); - - const result = await verifyAuthSignedData({ - address: address1, - message: { - message: "test", - }, - types, - signature, - primaryType: "test", - requiredChainId: sepolia.id, - }); - expect(result).toEqual(false); - }); - - it("fails to verify wrong domain - null fields", async () => { - const signature = await signTypedData(testClient1); - - const result = await verifyAuthSignedData({ - address: address1, - message: { - message: null, - }, - types, - signature, - primaryType: "test", - requiredChainId: sepolia.id, - }); - expect(result).toEqual(false); - }); - - it("fails to verify wrong message - different fields", async () => { - const signature = await signTypedData(testClient1, { - messageOverride: { message: "different", other: "fields" }, - }); - - const result = await verifyAuthSignedData({ - address: address1, - message: { - message: { message: "different", other: "fields" }, + chainId: 1, }, - types, - signature, - primaryType: "test", - requiredChainId: sepolia.id, }); - expect(result).toEqual(false); }); - it("fails to verify wrong message - verify primary type", async () => { - const signature = await signTypedData(testClient1); + it("returns false on verification error", async () => { + mockVerifyTypedData.mockRejectedValue(new Error("Verification failed")); const result = await verifyAuthSignedData({ - address: address1, - message: { - message: { message: "test" }, - }, - types, - signature, - primaryType: "mock", - requiredChainId: sepolia.id, + address: "0x123", + message: { test: "message" }, + types: { Test: [{ name: "test", type: "string" }] }, + signature: "0xsignature", + primaryType: "Test", + requiredChainId: 1, }); - expect(result).toEqual(false); - }); - - it("fails to verify wrong message - signed different primary type", async () => { - const signature = await signTypedData(testClient1); - - const result = await verifyAuthSignedData({ - address: address1, - message: { - message: { message: "test" }, - }, - types, - signature, - primaryType: "mock", - requiredChainId: sepolia.id, - }); - expect(result).toEqual(false); - }); - it("fails to verify with malformed signature", async () => { - const signature = "0xInvalidSignature"; - - const result = await verifyAuthSignedData({ - address: address1, - message: { - message: "test", - }, - types, - signature, - primaryType: "test", - requiredChainId: sepolia.id, - }); - expect(result).toEqual(false); - }); - - it("verifies message with special characters", async () => { - const specialMessage = { - message: "Test with special chars: éèàù@#$%^&*()_+", - }; - const signature = await signTypedData(testClient1, { - messageOverride: specialMessage, - }); - - const result = await verifyAuthSignedData({ - address: address1, - message: specialMessage, - types, - signature, - primaryType: "test", - requiredChainId: sepolia.id, - }); - expect(result).toEqual(true); + expect(result).toBe(false); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index ca1bf94a..d0e10f02 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,8 +12,8 @@ export default defineConfig({ reportOnFailure: true, thresholds: { lines: 20, - branches: 64, - functions: 60, + branches: 63, + functions: 56, statements: 20, }, include: ["src/**/*.ts"], From cfeb432586f52779dcbf883015aa0c537dca5518 Mon Sep 17 00:00:00 2001 From: pheuberger Date: Tue, 21 Jan 2025 13:35:43 +0200 Subject: [PATCH 8/8] test: mock RPC URL module in Safe sig verifier tests Without this patch the Safe signature verification code will get RPC URLs for the specified chain id. This in turn will load the Alchemy API key, which is not relevant for our tests. This patch mocks all of this away, so that the API key doesn't need to be present in the environment while running tests. Also lowered the coverage threshold of functiosn to 52 as mocking getRpcUrl() doesn't touch the internals of this function. This should be thoroughly tested in a dedicated test file anyway. --- .../safe-signatures/SafeSignatureVerifier.test.ts | 15 ++++++++++++++- vitest.config.ts | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/test/safe-signatures/SafeSignatureVerifier.test.ts b/test/safe-signatures/SafeSignatureVerifier.test.ts index d4efa2b0..2abcbbc5 100644 --- a/test/safe-signatures/SafeSignatureVerifier.test.ts +++ b/test/safe-signatures/SafeSignatureVerifier.test.ts @@ -1,6 +1,19 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import Verifier from "../../src/lib/safe-signature-verification/UserUpsertSignatureVerifier.js"; +// Mock the entire getRpcUrl module +vi.mock("../../src/utils/getRpcUrl.js", () => ({ + getRpcUrl: vi.fn().mockImplementation((chainId: number) => { + if (chainId === 1) { + throw new Error("Unsupported chain ID: 1"); + } + return "mock-rpc-url"; + }), + getEvmClient: vi.fn().mockReturnValue({ + verifyMessage: vi.fn().mockResolvedValue(true), + }), +})); + // Testing hashing of typed data via UserUpsertSignatureVerifier describe("hashTypedMessage", () => { it("should hash the typed message correctly", () => { diff --git a/vitest.config.ts b/vitest.config.ts index d0e10f02..bc6e4f90 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ thresholds: { lines: 20, branches: 63, - functions: 56, + functions: 52, statements: 20, }, include: ["src/**/*.ts"],