Skip to content

Commit cbd99b4

Browse files
committed
feat: sync cached data to network assets
1 parent a82b99a commit cbd99b4

File tree

21 files changed

+357
-76
lines changed

21 files changed

+357
-76
lines changed

api-schema.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@ type Mutation {
491491
adminAddCommunityMember(communityId: String!, input: AdminAddCommunityMemberInput!): CommunityMember
492492
adminCacheConfigSet(key: CacheConfigKey!, value: String!): Boolean
493493
adminCacheResolve(cacheId: String!, cluster: NetworkCluster!): JSON
494+
adminCacheSync(cacheId: String!, cluster: NetworkCluster!): JSON
494495
adminCleanupNetworkAssets(cluster: NetworkCluster!): Boolean
495496
adminCreateBackup: Boolean!
496497
adminCreateBot(input: AdminCreateBotInput!): Bot

libs/api/cache/data-access/src/lib/api-cache.data-access.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Module } from '@nestjs/common'
22
import { ApiCacheConfigDataAccessModule } from '@pubkey-link/api-cache-config-data-access'
33
import { ApiCoreDataAccessModule } from '@pubkey-link/api-core-data-access'
4+
import { ApiNetworkAssetDataAccessModule } from '@pubkey-link/api-network-asset-data-access'
45
import { ApiNetworkDataAccessModule } from '@pubkey-link/api-network-data-access'
56
import { ApiNetworkTokenDataAccessModule } from '@pubkey-link/api-network-token-data-access'
67
import { ApiCacheService } from './api-cache.service'
@@ -10,6 +11,7 @@ import { ApiCacheService } from './api-cache.service'
1011
ApiCacheConfigDataAccessModule,
1112
ApiCoreDataAccessModule,
1213
ApiNetworkDataAccessModule,
14+
ApiNetworkAssetDataAccessModule,
1315
ApiNetworkTokenDataAccessModule,
1416
],
1517
providers: [ApiCacheService],

libs/api/cache/data-access/src/lib/api-cache.service.ts

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injectable, Logger } from '@nestjs/common'
22
import { OnEvent } from '@nestjs/event-emitter'
3-
import { NetworkCluster, NetworkToken, NetworkTokenType } from '@prisma/client'
3+
import { NetworkCluster, NetworkToken, NetworkTokenType, Prisma } from '@prisma/client'
44
import {
55
createResolver,
66
getResolverOwner,
@@ -11,13 +11,15 @@ import {
1111
} from '@pubkey-cache/resolver'
1212
import { ApiCacheConfigService, EVENT_CACHE_CONFIG_LOADED } from '@pubkey-link/api-cache-config-data-access'
1313
import { ApiCoreService } from '@pubkey-link/api-core-data-access'
14+
import { ApiNetworkAssetService } from '@pubkey-link/api-network-asset-data-access'
1415
import { ApiNetworkTokenService } from '@pubkey-link/api-network-token-data-access'
1516
import { DAS, Helius } from 'helius-sdk'
1617
import { Storage } from 'unstorage'
1718
import { ApiCache } from './api-cache'
1819
import { createTraitCountMap, sortAssetsByName, TraitCountMap } from './create-trait-count-map'
1920
import { CacheStatus } from './entity/cache-status'
2021
import { getStorage } from './get-storage'
22+
import { formatSnapshot } from './helpers/format-snapshot'
2123

2224
export interface CachedResult {
2325
cluster: NetworkCluster
@@ -57,6 +59,7 @@ export class ApiCacheService {
5759
constructor(
5860
private readonly core: ApiCoreService,
5961
private readonly cacheConfig: ApiCacheConfigService,
62+
private readonly networkAsset: ApiNetworkAssetService,
6063
private readonly networkToken: ApiNetworkTokenService,
6164
) {
6265
this.storage = getStorage({ redisUrl: this.core.config.redisUrl })
@@ -188,9 +191,10 @@ export class ApiCacheService {
188191
result,
189192
storage: cache.storage,
190193
})
194+
await this.syncCacheNetworkAssets({ cluster: cache.cluster, id: resolver.id })
195+
191196
const endTimeResolver = new Date().getTime()
192197
const durationResolver = endTimeResolver - startTimeResolver
193-
194198
results.push(
195199
`Synced resolver ${resolver.id}, wrote ${writeCount} items to storage (${durationResolver / 1000} seconds)`,
196200
)
@@ -219,22 +223,27 @@ export class ApiCacheService {
219223
}
220224

221225
async assetsSnapshot(param: { cluster: NetworkCluster; id: string }) {
226+
try {
227+
return await this.getSnapshot(param)
228+
} catch (e) {
229+
this.logger.error(`Error getting snapshot for ${param.id}`, e)
230+
throw e
231+
}
232+
}
233+
234+
private async getSnapshot(param: { cluster: NetworkCluster; id: string }): Promise<CachedResult> {
222235
const { cache, resolver } = await this.getResolver(param)
223236
const result = await getResult({ resolver, storage: cache.storage })
237+
if (!result) {
238+
throw new Error('No result found')
239+
}
224240

225241
const cachedAt = result?.cachedAt ? new Date(result.cachedAt) : new Date()
226-
const cacheAge = new Date().getTime() - cachedAt.getTime()
227-
return result
228-
? {
229-
id: result.id,
230-
cachedAt,
231-
cacheAge,
232-
type: result.type,
233-
total: result.total,
234-
traits: result.traits,
235-
items: result.items,
236-
}
237-
: { error: 'No snapshot found' }
242+
return {
243+
...result,
244+
cluster: param.cluster,
245+
cachedAt,
246+
}
238247
}
239248

240249
private async getResolver(param: { cluster: NetworkCluster; id: string }) {
@@ -283,4 +292,34 @@ export class ApiCacheService {
283292
throw new Error(`Unknown resolver type: ${resolver.type}`)
284293
}
285294
}
295+
296+
async syncCacheNetworkAssets(param: { cluster: NetworkCluster; id: string }) {
297+
const snapshot = await this.getSnapshot({ cluster: param.cluster, id: param.id })
298+
if (!snapshot) {
299+
throw new Error(`Snapshot not found for ${param.id}`)
300+
}
301+
const networkToken = await this.getSnapshotNetworkToken(snapshot)
302+
const assets: Prisma.NetworkAssetCreateInput[] = formatSnapshot({
303+
items: snapshot.items,
304+
networkToken,
305+
type: snapshot.type,
306+
})
307+
if (assets.length) {
308+
this.logger.verbose(`syncCache: Upserting ${assets.length} assets`)
309+
await this.networkAsset.sync.upsertAssets({ cluster: snapshot.cluster, assets, linkIdentity: false })
310+
}
311+
return {
312+
total: snapshot.total,
313+
items: snapshot.items,
314+
}
315+
}
316+
317+
private async getSnapshotNetworkToken(snapshot: CachedResult): Promise<NetworkToken> {
318+
const account = snapshot.id?.split(':')[1]
319+
const found = await this.core.data.networkToken.findFirst({ where: { account, cluster: snapshot.cluster } })
320+
if (!found) {
321+
throw new Error(`getSnapshotToken: Token ${account} not found on cluster ${snapshot.cluster}`)
322+
}
323+
return found
324+
}
286325
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { Logger } from '@nestjs/common'
2+
import { NetworkResolver, NetworkToken, NetworkTokenType, Prisma } from '@prisma/client'
3+
import { ResolverType } from '@pubkey-cache/resolver'
4+
import { NetworkAssetInput } from '@pubkey-link/api-network-util'
5+
import { DAS } from 'helius-sdk'
6+
7+
export function formatSnapshot({
8+
items,
9+
networkToken,
10+
type,
11+
}: {
12+
items: (DAS.GetAssetResponse | DAS.TokenAccounts)[]
13+
networkToken: NetworkToken
14+
type: ResolverType
15+
}): Prisma.NetworkAssetCreateInput[] {
16+
switch (type) {
17+
case ResolverType['helius-collection-assets']:
18+
return (items as DAS.GetAssetResponse[]).map((asset) => convertHeliusAssetResponse({ asset, networkToken }))
19+
case ResolverType['helius-token-accounts']:
20+
return (items as DAS.TokenAccounts[]).map((item) => convertHeliusTokenAccounts({ item, networkToken }))
21+
default:
22+
return []
23+
}
24+
}
25+
26+
function convertHeliusTokenAccounts({
27+
item,
28+
networkToken,
29+
}: {
30+
item: DAS.TokenAccounts
31+
networkToken: NetworkToken
32+
}): NetworkAssetInput {
33+
return {
34+
network: { connect: { cluster: networkToken.cluster } },
35+
resolver: NetworkResolver.SolanaFungible,
36+
type: NetworkTokenType.Fungible,
37+
account: item.address?.toString() ?? '',
38+
name: networkToken.name ?? '',
39+
symbol: networkToken.symbol ?? '',
40+
owner: item.owner ?? '',
41+
imageUrl: networkToken.imageUrl ?? '',
42+
balance: item.amount?.toString() ?? '0',
43+
group: item.mint ?? '',
44+
decimals: 0,
45+
mint: item.mint ?? '',
46+
program: networkToken.program ?? '',
47+
}
48+
}
49+
50+
function convertHeliusAssetResponse({
51+
asset,
52+
networkToken,
53+
}: {
54+
asset: DAS.GetAssetResponse
55+
networkToken: NetworkToken
56+
}): NetworkAssetInput {
57+
return {
58+
network: { connect: { cluster: networkToken.cluster } },
59+
resolver: NetworkResolver.SolanaNonFungible,
60+
type: NetworkTokenType.NonFungible,
61+
account: asset.id,
62+
name: asset.content?.metadata.name ?? '',
63+
symbol: asset.content?.metadata.symbol,
64+
owner: asset.ownership.owner,
65+
group: networkToken.account,
66+
decimals: 0,
67+
balance: '1',
68+
burnt: asset.burnt,
69+
mint: asset.id,
70+
program: asset.token_info?.token_program,
71+
imageUrl: asset.content?.files?.length ? asset.content.files[0].uri : '',
72+
metadata: (asset.content?.metadata ?? {}) as Prisma.InputJsonValue,
73+
attributes: convertHeliusAssetResponseAttributes(asset),
74+
}
75+
}
76+
77+
export function convertHeliusAssetResponseAttributes(asset: DAS.GetAssetResponse): [string, string][] {
78+
const attributes = asset.content?.metadata.attributes?.length ? asset.content?.metadata.attributes : []
79+
80+
if (typeof attributes?.filter !== 'function') {
81+
Logger.error(
82+
`Invalid attributes for asset ${asset.id}: ${JSON.stringify(asset.content?.metadata)}`,
83+
'getDasApiAssetAttributes',
84+
)
85+
return []
86+
}
87+
88+
const main = attributes.length
89+
? (attributes
90+
.filter((s) => !!s)
91+
.filter((s) => s.trait_type?.length && s.value?.length)
92+
.map((s) => [s.trait_type?.toString(), s.value?.toString()]) as [string, string][])
93+
: []
94+
95+
const attributesExt = getAdditionalMetadata(asset)
96+
const ext = attributesExt.length ? attributesExt : []
97+
98+
return [...main, ...ext]
99+
}
100+
101+
// Get the Token2022 additional metadata from the mint extensions
102+
function getAdditionalMetadata(asset: DAS.GetAssetResponse): [string, string][] {
103+
if (
104+
asset.mint_extensions &&
105+
asset.mint_extensions['metadata'] &&
106+
asset.mint_extensions['metadata']['additionalMetadata']
107+
) {
108+
const pairs = asset.mint_extensions['metadata']['additionalMetadata'] as unknown as [string, string][]
109+
// Only return any items if they have a value with a length
110+
return pairs.filter((pair) => pair[1]?.length)
111+
}
112+
return []
113+
}

libs/api/cache/feature/src/lib/api-cache-admin.resolver.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,7 @@ export class ApiCacheAdminResolver {
1717

1818
@Query(() => GraphQLJSON, { nullable: true })
1919
adminCacheDetail(
20-
@Args({
21-
name: 'cluster',
22-
type: () => NetworkCluster,
23-
})
20+
@Args({ name: 'cluster', type: () => NetworkCluster })
2421
cluster: NetworkCluster,
2522
@Args('cacheId') cacheId: string,
2623
) {
@@ -29,13 +26,19 @@ export class ApiCacheAdminResolver {
2926

3027
@Mutation(() => GraphQLJSON, { nullable: true })
3128
adminCacheResolve(
32-
@Args({
33-
name: 'cluster',
34-
type: () => NetworkCluster,
35-
})
29+
@Args({ name: 'cluster', type: () => NetworkCluster })
3630
cluster: NetworkCluster,
3731
@Args('cacheId') cacheId: string,
3832
) {
3933
return this.service.resolveCache({ cluster, id: cacheId })
4034
}
35+
36+
@Mutation(() => GraphQLJSON, { nullable: true })
37+
adminCacheSync(
38+
@Args({ name: 'cluster', type: () => NetworkCluster })
39+
cluster: NetworkCluster,
40+
@Args('cacheId') cacheId: string,
41+
) {
42+
return this.service.syncCacheNetworkAssets({ cluster, id: cacheId })
43+
}
4144
}

libs/api/collection/data-access/src/lib/api-collection.service.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,27 @@ export class ApiCollectionService {
3737
throw new Error(`Resolver ${collection.token.account} not found`)
3838
}
3939

40-
const snapshot = await this.cache.assetsSnapshot({ cluster: collection.token.cluster, id: resolver.id })
41-
const items: DAS.GetAssetResponse[] = (snapshot.items ?? []) as DAS.GetAssetResponse[]
40+
try {
41+
const snapshot = await this.cache.assetsSnapshot({ cluster: collection.token.cluster, id: resolver.id })
42+
const items: DAS.GetAssetResponse[] = (snapshot.items ?? []) as DAS.GetAssetResponse[]
4243

43-
const assets = items.map((asset) => ({
44-
id: asset.id,
45-
name: asset.content?.metadata?.name ?? '',
46-
description: asset.content?.metadata?.description ?? '',
47-
imageUrl: asset.content?.files?.[0]?.uri ?? '',
48-
owner: asset.ownership.owner,
49-
attributes: renameAttributes(asset.content?.metadata?.attributes ?? []),
50-
}))
44+
const assets = items.map((asset) => ({
45+
id: asset.id,
46+
name: asset.content?.metadata?.name ?? '',
47+
description: asset.content?.metadata?.description ?? '',
48+
imageUrl: asset.content?.files?.[0]?.uri ?? '',
49+
owner: asset.ownership.owner,
50+
attributes: renameAttributes(asset.content?.metadata?.attributes ?? []),
51+
}))
5152

52-
return {
53-
...collection,
54-
attributes: accumulateAttributes(assets),
55-
assets,
53+
return {
54+
...collection,
55+
attributes: accumulateAttributes(assets),
56+
assets,
57+
}
58+
} catch (e) {
59+
console.log('error', e)
60+
throw e
5661
}
5762
}
5863

0 commit comments

Comments
 (0)