diff --git a/db/migrations/1663664975636-data.js b/db/migrations/1663664975636-data.js new file mode 100644 index 0000000..1da8a62 --- /dev/null +++ b/db/migrations/1663664975636-data.js @@ -0,0 +1,13 @@ +module.exports = class data1663664975636 { + name = 'data1663664975636' + + async up(db) { + await db.query(`CREATE TABLE "spotlight" ("id" character varying NOT NULL, "collections" integer NOT NULL, "unique_collectors" integer NOT NULL, "unique" integer NOT NULL, "sold" integer NOT NULL, "total" integer NOT NULL, "average" numeric, "volume" numeric, CONSTRAINT "PK_bafc41803e508da64ed687ed3b9" PRIMARY KEY ("id"))`) + await db.query(`CREATE INDEX "IDX_258db3cbcb3c172be89fcbf674" ON "spotlight" ("sold") `) + } + + async down(db) { + await db.query(`DROP TABLE "spotlight"`) + await db.query(`DROP INDEX "public"."IDX_258db3cbcb3c172be89fcbf674"`) + } +} diff --git a/schema.graphql b/schema.graphql index a436770..fb6454e 100644 --- a/schema.graphql +++ b/schema.graphql @@ -166,16 +166,16 @@ type Series @entity { image: String } -# type Spotlight @entity { -# id: ID! -# collections: Int! -# uniqueCollectors: Int! -# unique: Int! -# sold: Int! @index -# total: Int! -# average: Float -# volume: BigInt -# } +type Spotlight @entity { + id: ID! + collections: Int! + uniqueCollectors: Int! + unique: Int! + sold: Int! @index + total: Int! + average: Float + volume: BigInt +} type CacheStatus @entity { id: ID! diff --git a/src/mappings/utils/cache.ts b/src/mappings/utils/cache.ts index 89537d3..f4b9fe0 100644 --- a/src/mappings/utils/cache.ts +++ b/src/mappings/utils/cache.ts @@ -1,5 +1,5 @@ import logger, { logError } from './logger' -import { Series, CacheStatus } from '../../model/generated' +import { Series, Spotlight, CacheStatus } from '../../model/generated' // import { Store } from '@subsquid/substrate-processor' import { EntityConstructor } from './types' import { getOrCreate } from './entity' @@ -9,49 +9,58 @@ const DELAY_MIN: number = 60 // every 60 minutes const STATUS_ID: string = '0' enum Query { - series = `SELECT - ce.id, ce.name, ce.issuer, ce.meta_id as metadata, me.image, - COUNT(distinct ne.meta_id) as unique, - COUNT(distinct ne.current_owner) as unique_collectors, - COUNT(distinct ne.current_owner) as sold, - COUNT(ne.*) as total, - AVG(ne.price) as average_price, - MIN(NULLIF(ne.price, 0)) as floor_price, - COALESCE(MAX(e.meta::bigint), 0) as highest_sale, - COALESCE(SUM(e.meta::bigint), 0) as volume, - COUNT(e.*) as buys - FROM collection_entity ce - LEFT JOIN metadata_entity me on ce.meta_id = me.id - LEFT JOIN nft_entity ne on ce.id = ne.collection_id - JOIN event e on ne.id = e.nft_id - WHERE e.interaction = 'BUY' - GROUP BY ce.id, me.image, ce.name` + series = `SELECT + ce.id, ce.name, ce.issuer, ce.meta_id as metadata, me.image, + COUNT(distinct ne.meta_id) as unique, + COUNT(distinct ne.current_owner) as unique_collectors, + COUNT(distinct ne.current_owner) as sold, + COUNT(ne.*) as total, + AVG(ne.price) as average_price, + MIN(NULLIF(ne.price, 0)) as floor_price, + COALESCE(MAX(e.meta::bigint), 0) as highest_sale, + COALESCE(SUM(e.meta::bigint), 0) as volume, + COUNT(e.*) as buys + FROM collection_entity ce + LEFT JOIN metadata_entity me on ce.meta_id = me.id + LEFT JOIN nft_entity ne on ce.id = ne.collection_id + JOIN event e on ne.id = e.nft_id + WHERE e.interaction = 'BUY' + GROUP BY ce.id, me.image, ce.name`, + spotlight = `SELECT + issuer as id, COUNT(distinct collection_id) as collections, + COUNT(distinct meta_id) as unique, AVG(price) as average, + COUNT(*) as total, COUNT(distinct ne.current_owner) as unique_collectors, + SUM(CASE WHEN ne.issuer <> ne.current_owner THEN 1 ELSE 0 END) as sold, + COALESCE(SUM(e.meta::bigint), 0) as volume + FROM nft_entity ne + JOIN event e on e.nft_id = ne.id WHERE e.interaction = 'BUY' + GROUP BY issuer` } export async function updateCache(timestamp: Date, store: any): Promise { - let lastUpdate = await getOrCreate(store, CacheStatus, STATUS_ID, { id: STATUS_ID, lastBlockTimestamp: new Date(0) }) - const passedMins = (timestamp.getTime() - lastUpdate.lastBlockTimestamp.getTime()) / 60000 - logger.info(`[CACHE UPDATE] PASSED TIME - ${passedMins} MINS`) - if (passedMins > DELAY_MIN) { - try { - await updateEntityCache(store, Series, Query.series) - lastUpdate.lastBlockTimestamp = timestamp - await store.save(lastUpdate) - logger.success(`[CACHE UPDATE]`) - } catch (e) { - logError(e, (e) => logger.error(`[CACHE UPDATE] ${e.message}`)) - } - } + let lastUpdate = await getOrCreate(store, CacheStatus, STATUS_ID, { id: STATUS_ID, lastBlockTimestamp: new Date(0) }) + const passedMins = (timestamp.getTime() - lastUpdate.lastBlockTimestamp.getTime()) / 60000 + logger.info(`[CACHE UPDATE] PASSED TIME - ${passedMins} MINS`) + if (passedMins > DELAY_MIN) { + try { + await Promise.all([updateEntityCache(store, Series, Query.series), updateEntityCache(store, Spotlight, Query.spotlight)]) + lastUpdate.lastBlockTimestamp = timestamp + await store.save(lastUpdate) + logger.success(`[CACHE UPDATE]`) + } catch (e) { + logError(e, (e) => logger.error(`[CACHE UPDATE] ${e.message}`)) + } + } } async function updateEntityCache(store: any, entityConstructor: EntityConstructor, query: Query): Promise { - const result: any[] = await store.query(query) - const entities = result.map((el) => { - const entity: T = new entityConstructor() - for (const prop in el) { - entity[camelCase(prop) as keyof T] = el[prop] - } - return entity - }) - return store.save(entities) + const result: any[] = await store.query(query) + const entities = result.map((el) => { + const entity: T = new entityConstructor() + for (const prop in el) { + entity[camelCase(prop) as keyof T] = el[prop] + } + return entity + }) + return store.save(entities) } diff --git a/src/model/generated/index.ts b/src/model/generated/index.ts index 3c35cec..34df508 100644 --- a/src/model/generated/index.ts +++ b/src/model/generated/index.ts @@ -12,4 +12,5 @@ export * from "./offer.model" export * from "./_offerStatus" export * from "./assetEntity.model" export * from "./series.model" +export * from "./spotlight.model" export * from "./cacheStatus.model" diff --git a/src/model/generated/spotlight.model.ts b/src/model/generated/spotlight.model.ts new file mode 100644 index 0000000..d3d6a58 --- /dev/null +++ b/src/model/generated/spotlight.model.ts @@ -0,0 +1,34 @@ +import {Entity as Entity_, Column as Column_, PrimaryColumn as PrimaryColumn_, Index as Index_} from "typeorm" +import * as marshal from "./marshal" + +@Entity_() +export class Spotlight { + constructor(props?: Partial) { + Object.assign(this, props) + } + + @PrimaryColumn_() + id!: string + + @Column_("int4", {nullable: false}) + collections!: number + + @Column_("int4", {nullable: false}) + uniqueCollectors!: number + + @Column_("int4", {nullable: false}) + unique!: number + + @Index_() + @Column_("int4", {nullable: false}) + sold!: number + + @Column_("int4", {nullable: false}) + total!: number + + @Column_("numeric", {nullable: true}) + average!: number | undefined | null + + @Column_("numeric", {transformer: marshal.bigintTransformer, nullable: true}) + volume!: bigint | undefined | null +} diff --git a/src/server-extension/model/spotlight.model.ts b/src/server-extension/model/spotlight.model.ts new file mode 100644 index 0000000..b22b38b --- /dev/null +++ b/src/server-extension/model/spotlight.model.ts @@ -0,0 +1,32 @@ +import { Field, ObjectType } from 'type-graphql' + +@ObjectType() +export class SpotlightEntity { + @Field(() => String, { nullable: false }) + id!: string + + @Field(() => Number, { nullable: false }) + collections!: number + + @Field(() => Number, { nullable: false }) + unique!: number + + @Field(() => BigInt, { nullable: true, defaultValue: 0n }) + average!: bigint + + @Field(() => Number, { nullable: false }) + sold!: number + + @Field(() => Number, { nullable: false, name: 'uniqueCollectors' }) + unique_collectors!: number + + @Field(() => BigInt, { nullable: true, defaultValue: 0n }) + volume!: bigint + + @Field(() => Number, { nullable: false }) + total!: number + + constructor(props: Partial) { + Object.assign(this, props) + } +} diff --git a/src/server-extension/resolvers/index.ts b/src/server-extension/resolvers/index.ts index 4a70bc6..2f8f892 100644 --- a/src/server-extension/resolvers/index.ts +++ b/src/server-extension/resolvers/index.ts @@ -2,5 +2,6 @@ import { WalletResolver } from './walletResolver'; import { OfferStatsResolver } from './offerStatsResolver'; import { EventResolver } from './event'; import { SeriesResolver } from './series'; +import { SpotlightResolver } from './spotlight'; -export { WalletResolver, OfferStatsResolver, EventResolver, SeriesResolver }; +export { WalletResolver, OfferStatsResolver, EventResolver, SeriesResolver, SpotlightResolver }; diff --git a/src/server-extension/resolvers/spotlight.ts b/src/server-extension/resolvers/spotlight.ts new file mode 100644 index 0000000..ed33919 --- /dev/null +++ b/src/server-extension/resolvers/spotlight.ts @@ -0,0 +1,49 @@ +import { Arg, Field, ObjectType, Query, Resolver } from 'type-graphql' +import type { EntityManager } from 'typeorm' +import { NFTEntity } from '../../model/generated' +import { SpotlightEntity } from '../model/spotlight.model' + +enum OrderBy { + sold = 'sold', + total = 'total', + volume = 'volume', + unique = 'unique', + average = 'average', + collections = 'collections', + unique_collectors = 'unique_collectors', +} + +enum OrderDirection { + DESC = 'DESC', + ASC = 'ASC', +} + +@Resolver() +export class SpotlightResolver { + constructor(private tx: () => Promise) {} + + // TODO: calculate score sold * (unique / total) + @Query(() => [SpotlightEntity]) + async spotlightTable( + @Arg('limit', { nullable: true, defaultValue: null }) limit: number, + @Arg('offset', { nullable: true, defaultValue: null }) offset: string, + @Arg('orderBy', { nullable: true, defaultValue: 'total' }) orderBy: OrderBy, + @Arg('orderDirection', { nullable: true, defaultValue: 'DESC' }) orderDirection: OrderDirection, + ): Promise { + const query = `SELECT + issuer as id, COUNT(distinct collection_id) as collections, + COUNT(distinct meta_id) as unique, AVG(price) as average, + COUNT(*) as total, COUNT(distinct ne.current_owner) as unique_collectors, + SUM(CASE WHEN ne.issuer <> ne.current_owner THEN 1 ELSE 0 END) as sold, + COALESCE(SUM(e.meta::bigint), 0) as volume + FROM nft_entity ne + JOIN event e on e.nft_id = ne.id WHERE e.interaction = 'BUY' + GROUP BY issuer + ORDER BY ${orderBy} ${orderDirection} + LIMIT $1 OFFSET $2` + const manager = await this.tx() + const result: SpotlightEntity[] = await manager.getRepository(NFTEntity).query(query, [limit, offset]) + + return result + } +}