Browse Source

Distribution system initial schema and mappings

Leszek Wiesner 3 years ago
parent
commit
f62c7dd1cf

+ 39 - 0
query-node/manifest.yml

@@ -36,6 +36,19 @@ typegen:
     - storage.VoucherChanged
     - storage.StorageBucketDeleted
     - storage.NumberOfStorageBucketsInDynamicBagCreationPolicyUpdated
+    - storage.DistributionBucketFamilyCreated
+    - storage.DistributionBucketFamilyDeleted
+    - storage.DistributionBucketCreated
+    - storage.DistributionBucketStatusUpdated
+    - storage.DistributionBucketDeleted
+    - storage.DistributionBucketsUpdatedForBag
+    - storage.DistributionBucketsPerBagLimitUpdated
+    - storage.DistributionBucketModeUpdated
+    - storage.FamiliesInDynamicBagCreationPolicyUpdated
+    - storage.DistributionBucketOperatorInvited
+    - storage.DistributionBucketInvitationCancelled
+    - storage.DistributionBucketInvitationAccepted
+    - storage.DistributionBucketMetadataSet
 
     # membership
     - members.MemberRegistered
@@ -305,6 +318,32 @@ mappings:
       handler: storage_StorageBucketDeleted
     - event: storage.NumberOfStorageBucketsInDynamicBagCreationPolicyUpdated
       handler: storage_NumberOfStorageBucketsInDynamicBagCreationPolicyUpdated
+    - event: storage.DistributionBucketFamilyCreated
+      handler: storage_DistributionBucketFamilyCreated
+    - event: storage.DistributionBucketFamilyDeleted
+      handler: storage_DistributionBucketFamilyDeleted
+    - event: storage.DistributionBucketCreated
+      handler: storage_DistributionBucketCreated
+    - event: storage.DistributionBucketStatusUpdated
+      handler: storage_DistributionBucketStatusUpdated
+    - event: storage.DistributionBucketDeleted
+      handler: storage_DistributionBucketDeleted
+    - event: storage.DistributionBucketsUpdatedForBag
+      handler: storage_DistributionBucketsUpdatedForBag
+    - event: storage.DistributionBucketsPerBagLimitUpdated
+      handler: storage_DistributionBucketsPerBagLimitUpdated
+    - event: storage.DistributionBucketModeUpdated
+      handler: storage_DistributionBucketModeUpdated
+    - event: storage.FamiliesInDynamicBagCreationPolicyUpdated
+      handler: storage_FamiliesInDynamicBagCreationPolicyUpdated
+    - event: storage.DistributionBucketOperatorInvited
+      handler: storage_DistributionBucketOperatorInvited
+    - event: storage.DistributionBucketInvitationCancelled
+      handler: storage_DistributionBucketInvitationCancelled
+    - event: storage.DistributionBucketInvitationAccepted
+      handler: storage_DistributionBucketInvitationAccepted
+    - event: storage.DistributionBucketMetadataSet
+      handler: storage_DistributionBucketMetadataSet
   extrinsicHandlers:
     # infer defaults here
     #- extrinsic: Balances.Transfer

+ 21 - 4
query-node/mappings/common.ts

@@ -1,20 +1,25 @@
 import { DatabaseManager } from '@joystream/hydra-common'
 import { BaseModel } from '@joystream/warthog'
 import { WorkingGroup } from '@joystream/types/augment/all'
+import { AnyMetadataClass, DecodedMetadataObject } from '@joystream/metadata-protobuf/types'
+import { metaToObject } from '@joystream/metadata-protobuf/utils'
+import { Bytes } from '@polkadot/types'
 
 type EntityClass<T extends BaseModel> = {
   new (): T
   name: string
 }
 
+type RelationsArr<T extends BaseModel> = Exclude<
+  keyof T & string,
+  { [K in keyof T]: T[K] extends BaseModel | undefined ? '' : T[K] extends BaseModel[] | undefined ? '' : K }[keyof T]
+>[]
+
 export async function getById<T extends BaseModel>(
   store: DatabaseManager,
   entityClass: EntityClass<T>,
   id: string,
-  relations?: Exclude<
-    keyof T & string,
-    { [K in keyof T]: T[K] extends BaseModel | undefined ? '' : T[K] extends BaseModel[] | undefined ? '' : K }[keyof T]
-  >[]
+  relations?: RelationsArr<T>
 ): Promise<T> {
   const result = await store.get(entityClass, { where: { id }, relations })
   if (!result) {
@@ -35,3 +40,15 @@ export function getWorkingGroupModuleName(group: WorkingGroup): WorkingGroupModu
 
   throw new Error(`Unsupported working group encountered: ${group.type}`)
 }
+
+export function deserializeMetadata<T>(
+  metadataType: AnyMetadataClass<T>,
+  metadataBytes: Bytes
+): DecodedMetadataObject<T> | null {
+  try {
+    return metaToObject(metadataType, metadataType.decode(metadataBytes.toU8a(true)))
+  } catch (e) {
+    console.error(`Cannot deserialize ${metadataType.name}! Provided bytes: (${metadataBytes.toHex()})`)
+    return null
+  }
+}

+ 202 - 7
query-node/mappings/storage.ts → query-node/mappings/storage/index.ts

@@ -2,8 +2,12 @@
 eslint-disable @typescript-eslint/naming-convention
 */
 import { DatabaseManager, EventContext, StoreContext } from '@joystream/hydra-common'
-import { Storage } from './generated/types/storage'
+import { Storage } from '../generated/types/storage'
 import {
+  DistributionBucket,
+  DistributionBucketFamily,
+  DistributionBucketOperator,
+  DistributionBucketOperatorStatus,
   StorageBag,
   StorageBagOwner,
   StorageBagOwnerChannel,
@@ -18,13 +22,14 @@ import {
   StorageSystemParameters,
 } from 'query-node/dist/model'
 import BN from 'bn.js'
-import { getById, getWorkingGroupModuleName } from './common'
+import { getById, getWorkingGroupModuleName } from '../common'
 import { BTreeSet } from '@polkadot/types'
 import { DataObjectCreationParameters } from '@joystream/types/storage'
 import { registry } from '@joystream/types'
 import { In } from 'typeorm'
 import _ from 'lodash'
 import { DataObjectId, BagId, DynamicBagId, StaticBagId } from '@joystream/types/augment/all'
+import { processDistributionOperatorMetadata, processStorageOperatorMetadata } from './metadata'
 
 async function getDataObjectsInBag(
   store: DatabaseManager,
@@ -101,7 +106,7 @@ function getBagId(bagId: BagId) {
 async function getDynamicBag(
   store: DatabaseManager,
   bagId: DynamicBagId,
-  relations?: ('storedBy' | 'objects')[]
+  relations?: ('storedBy' | 'distributedBy' | 'objects')[]
 ): Promise<StorageBag> {
   return getById(store, StorageBag, getDynamicBagId(bagId), relations)
 }
@@ -109,7 +114,7 @@ async function getDynamicBag(
 async function getStaticBag(
   store: DatabaseManager,
   bagId: StaticBagId,
-  relations?: ('storedBy' | 'objects')[]
+  relations?: ('storedBy' | 'distributedBy' | 'objects')[]
 ): Promise<StorageBag> {
   const id = getStaticBagId(bagId)
   const bag = await store.get(StorageBag, { where: { id }, relations })
@@ -125,12 +130,49 @@ async function getStaticBag(
   return bag
 }
 
-async function getBag(store: DatabaseManager, bagId: BagId, relations?: 'storedBy'[]): Promise<StorageBag> {
+async function getBag(
+  store: DatabaseManager,
+  bagId: BagId,
+  relations?: ('storedBy' | 'distributedBy' | 'objects')[]
+): Promise<StorageBag> {
   return bagId.isStatic
     ? getStaticBag(store, bagId.asStatic, relations)
     : getDynamicBag(store, bagId.asDynamic, relations)
 }
 
+async function getDistributionBucketOperatorWithMetadata(store: DatabaseManager, id: string) {
+  const operator = await store.get(DistributionBucketOperator, {
+    where: { id },
+    relations: ['metadata', 'metadata.nodeLocation', 'metadata.nodeLocation.coordinates'],
+  })
+  if (!operator) {
+    throw new Error(`DistributionBucketOperator not found by id: ${id}`)
+  }
+  return operator
+}
+
+async function getStorageBucketWithOperatorMetadata(store: DatabaseManager, id: string) {
+  const bucket = await store.get(StorageBucket, {
+    where: { id },
+    relations: ['metadata', 'metadata.nodeLocation', 'metadata.nodeLocation.coordinates'],
+  })
+  if (!bucket) {
+    throw new Error(`StorageBucket not found by id: ${id}`)
+  }
+  return bucket
+}
+
+async function getDistributionBucketFamilyWithMetadata(store: DatabaseManager, id: string) {
+  const family = await store.get(DistributionBucketFamily, {
+    where: { id },
+    relations: ['metadata', 'metadata.boundary'],
+  })
+  if (!family) {
+    throw new Error(`DistributionBucketFamily not found by id: ${id}`)
+  }
+  return family
+}
+
 // BUCKETS
 
 export async function storage_StorageBucketCreated({ event, store }: EventContext & StoreContext): Promise<void> {
@@ -160,8 +202,12 @@ export async function storage_StorageBucketCreated({ event, store }: EventContex
 
 export async function storage_StorageOperatorMetadataSet({ event, store }: EventContext & StoreContext): Promise<void> {
   const [bucketId, , metadataBytes] = new Storage.StorageOperatorMetadataSetEvent(event).params
-  const storageBucket = await getById(store, StorageBucket, bucketId.toString())
-  storageBucket.operatorMetadata = Buffer.from(metadataBytes.toU8a(true))
+  const storageBucket = await getStorageBucketWithOperatorMetadata(store, bucketId.toString())
+  storageBucket.operatorMetadata = await processStorageOperatorMetadata(
+    store,
+    storageBucket.operatorMetadata,
+    metadataBytes
+  )
   await store.save<StorageBucket>(storageBucket)
 }
 
@@ -329,6 +375,155 @@ export async function storage_UpdateBlacklist({ event, store }: EventContext & S
   await store.save<StorageSystemParameters>(storageSystem)
 }
 
+export async function storage_DistributionBucketFamilyCreated({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  const [familyId] = new Storage.DistributionBucketFamilyCreatedEvent(event).params
+
+  const family = new DistributionBucketFamily({
+    id: familyId.toString(),
+  })
+
+  await store.save<DistributionBucketFamily>(family)
+}
+
+export async function storage_DistributionBucketFamilyDeleted({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  const [familyId] = new Storage.DistributionBucketFamilyDeletedEvent(event).params
+
+  const family = await getById(store, DistributionBucketFamily, familyId.toString())
+
+  await store.remove(family)
+}
+
+export async function storage_DistributionBucketCreated({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [familyId, acceptingNewBags, bucketId] = new Storage.DistributionBucketCreatedEvent(event).params
+
+  const family = await getById(store, DistributionBucketFamily, familyId.toString())
+  const bucket = new DistributionBucket({
+    id: bucketId.toString(),
+    acceptingNewBags: acceptingNewBags.valueOf(),
+    distributing: true, // Runtime default
+    family,
+  })
+
+  await store.save<DistributionBucket>(bucket)
+}
+
+export async function storage_DistributionBucketStatusUpdated({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  const [, bucketId, acceptingNewBags] = new Storage.DistributionBucketStatusUpdatedEvent(event).params
+
+  const bucket = await getById(store, DistributionBucket, bucketId.toString())
+  bucket.acceptingNewBags = acceptingNewBags.valueOf()
+
+  await store.save<DistributionBucket>(bucket)
+}
+
+export async function storage_DistributionBucketDeleted({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [, bucketId] = new Storage.DistributionBucketDeletedEvent(event).params
+
+  const bucket = await getById(store, DistributionBucket, bucketId.toString())
+
+  await store.remove<DistributionBucket>(bucket)
+}
+
+export async function storage_DistributionBucketsUpdatedForBag({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  const [bagId, , addedBucketsIds, removedBucketsIds] = new Storage.DistributionBucketsUpdatedForBagEvent(event).params
+  const storageBag = await getBag(store, bagId, ['distributedBy'])
+  storageBag.distributedBy = (storageBag.distributedBy || [])
+    .filter((b) => !Array.from(removedBucketsIds).some((id) => id.eq(b.id)))
+    .concat(Array.from(addedBucketsIds).map((id) => new DistributionBucket({ id: id.toString() })))
+
+  await store.save<StorageBag>(storageBag)
+}
+
+export async function storage_DistributionBucketModeUpdated({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  const [, bucketId, distributing] = new Storage.DistributionBucketModeUpdatedEvent(event).params
+
+  const bucket = await getById(store, DistributionBucket, bucketId.toString())
+  bucket.distributing = distributing.valueOf()
+
+  await store.save<DistributionBucket>(bucket)
+}
+
+export async function storage_DistributionBucketOperatorInvited({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  const [, bucketId, workerId] = new Storage.DistributionBucketOperatorInvitedEvent(event).params
+
+  const bucket = await getById(store, DistributionBucket, bucketId.toString())
+  const invitedOperator = new DistributionBucketOperator({
+    id: `${bucketId}-${workerId}`,
+    distributionBucket: bucket,
+    status: DistributionBucketOperatorStatus.INVITED,
+    workerId: workerId.toNumber(),
+  })
+
+  await store.save<DistributionBucketOperator>(invitedOperator)
+}
+
+export async function storage_DistributionBucketInvitationCancelled({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  const [, bucketId, workerId] = new Storage.DistributionBucketOperatorInvitedEvent(event).params
+
+  const invitedOperator = await getById(store, DistributionBucketOperator, `${bucketId}-${workerId}`)
+
+  await store.remove<DistributionBucketOperator>(invitedOperator)
+}
+
+export async function storage_DistributionBucketInvitationAccepted({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  const [workerId, , bucketId] = new Storage.DistributionBucketInvitationAcceptedEvent(event).params
+
+  const invitedOperator = await getById(store, DistributionBucketOperator, `${bucketId}-${workerId}`)
+  invitedOperator.status = DistributionBucketOperatorStatus.ACTIVE
+
+  await store.save<DistributionBucketOperator>(invitedOperator)
+}
+
+export async function storage_DistributionBucketMetadataSet({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  const [workerId, , bucketId, metadataBytes] = new Storage.DistributionBucketMetadataSetEvent(event).params
+
+  const operator = await getDistributionBucketOperatorWithMetadata(store, `${bucketId}-${workerId}`)
+  operator.metadata = await processDistributionOperatorMetadata(store, operator.metadata, metadataBytes)
+
+  await store.save<DistributionBucketOperator>(operator)
+}
+
+export async function storage_DistributionBucketsPerBagLimitUpdated({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  // To be implemented
+}
+
+export async function storage_FamiliesInDynamicBagCreationPolicyUpdated({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  // To be implemented
+}
+
 export async function storage_StorageBucketVoucherLimitsSet({
   event,
   store,

+ 125 - 0
query-node/mappings/storage/metadata.ts

@@ -0,0 +1,125 @@
+import { DatabaseManager } from '@joystream/hydra-common'
+import {
+  DistributionBucketFamilyMetadata,
+  DistributionBucketOperatorMetadata,
+  StorageBucketOperatorMetadata,
+  GeoCoordinates,
+  NodeLocationMetadata,
+} from 'query-node/dist/model'
+import { deserializeMetadata } from '../common'
+import { Bytes } from '@polkadot/types'
+import {
+  DistributionBucketOperatorMetadata as DistributionBucketOperatorMetadataProto,
+  StorageBucketOperatorMetadata as StorageBucketOperatorMetadataProto,
+  DistributionBucketFamilyMetadata as DistributionBucketFamilyMetadataProto,
+  INodeLocationMetadata,
+} from '@joystream/metadata-protobuf'
+import { isSet } from '@joystream/metadata-protobuf/utils'
+
+async function processNodeLocationMetadata(
+  store: DatabaseManager,
+  current: NodeLocationMetadata | undefined,
+  meta: INodeLocationMetadata
+): Promise<NodeLocationMetadata> {
+  const nodeLocation = current || new NodeLocationMetadata()
+  if (isSet(meta.city)) {
+    nodeLocation.city = meta.city
+  }
+  if (isSet(meta.coordinates)) {
+    const coordinates = current?.coordinates || new GeoCoordinates()
+    coordinates.latitude = meta.coordinates.latitude
+    coordinates.longitude = meta.coordinates.longitude
+    await store.save<GeoCoordinates>(coordinates)
+    nodeLocation.coordinates = coordinates
+  }
+  if (isSet(meta.countryCode)) {
+    // TODO: Validate the code
+    nodeLocation.countryCode = meta.countryCode
+  }
+  await store.save<NodeLocationMetadata>(nodeLocation)
+  return nodeLocation
+}
+
+export async function processDistributionOperatorMetadata(
+  store: DatabaseManager,
+  current: DistributionBucketOperatorMetadata | undefined,
+  metadataBytes: Bytes
+): Promise<DistributionBucketOperatorMetadata | undefined> {
+  const meta = deserializeMetadata(DistributionBucketOperatorMetadataProto, metadataBytes)
+  if (!meta) {
+    return current
+  }
+  const metadataEntity = current || new DistributionBucketOperatorMetadata()
+  if (isSet(meta.endpoint)) {
+    metadataEntity.nodeEndpoint = meta.endpoint
+  }
+  if (isSet(meta.location)) {
+    metadataEntity.nodeLocation = await processNodeLocationMetadata(store, metadataEntity.nodeLocation, meta.location)
+  }
+  if (isSet(meta.extra)) {
+    metadataEntity.extra = meta.extra
+  }
+
+  await store.save<DistributionBucketOperatorMetadata>(metadataEntity)
+
+  return metadataEntity
+}
+
+export async function processStorageOperatorMetadata(
+  store: DatabaseManager,
+  current: StorageBucketOperatorMetadata | undefined,
+  metadataBytes: Bytes
+): Promise<StorageBucketOperatorMetadata | undefined> {
+  const meta = deserializeMetadata(StorageBucketOperatorMetadataProto, metadataBytes)
+  if (!meta) {
+    return current
+  }
+  const metadataEntity = current || new StorageBucketOperatorMetadata()
+  if (isSet(meta.endpoint)) {
+    metadataEntity.nodeEndpoint = meta.endpoint
+  }
+  if (isSet(meta.location)) {
+    metadataEntity.nodeLocation = await processNodeLocationMetadata(store, metadataEntity.nodeLocation, meta.location)
+  }
+  if (isSet(meta.extra)) {
+    metadataEntity.extra = meta.extra
+  }
+
+  await store.save<StorageBucketOperatorMetadata>(metadataEntity)
+
+  return metadataEntity
+}
+
+export async function processDistributionBucketFamilyMetadata(
+  store: DatabaseManager,
+  current: DistributionBucketFamilyMetadata | undefined,
+  metadataBytes: Bytes
+): Promise<DistributionBucketFamilyMetadata | undefined> {
+  const meta = deserializeMetadata(DistributionBucketFamilyMetadataProto, metadataBytes)
+  if (!meta) {
+    return current
+  }
+  const metadataEntity = current || new DistributionBucketFamilyMetadata()
+  if (isSet(meta.region)) {
+    metadataEntity.region = meta.region
+  }
+  if (isSet(meta.description)) {
+    metadataEntity.description = meta.description
+  }
+
+  await store.save<DistributionBucketOperatorMetadata>(metadataEntity)
+
+  // Update boundary after metadata is saved (since we need an id to reference)
+  if (isSet(meta.boundary)) {
+    await Promise.all((metadataEntity.boundary || []).map((coords) => store.remove<GeoCoordinates>(coords)))
+    await Promise.all(
+      meta.boundary.map(({ latitude, longitude }) =>
+        store.save<GeoCoordinates>(
+          new GeoCoordinates({ latitude, longitude, boundarySourceBucketFamilyMeta: metadataEntity })
+        )
+      )
+    )
+  }
+
+  return metadataEntity
+}

+ 109 - 1
query-node/schemas/storage.graphql

@@ -25,6 +25,36 @@ type StorageBucketOperatorStatusActive @variant {
 
 union StorageBucketOperatorStatus = StorageBucketOperatorStatusMissing | StorageBucketOperatorStatusInvited | StorageBucketOperatorStatusActive
 
+type GeoCoordinates @entity {
+  latitude: Float!
+  longitude: Float!
+
+  "Optional DistributionBucketFamilyMetadata reference in case the coordinates are part of a region boundary"
+  boundarySourceBucketFamilyMeta: DistributionBucketFamilyMetadata
+}
+
+type NodeLocationMetadata @entity {
+  "ISO 3166-1 alpha-2 country code (2 letters)"
+  countryCode: String
+
+  "City name"
+  city: String
+
+  "Geographic coordinates"
+  coordinates: GeoCoordinates
+}
+
+type StorageBucketOperatorMetadata @entity {
+  "Root node endpoint"
+  nodeEndpoint: String
+
+  "Optional node location metadata"
+  nodeLocation: NodeLocationMetadata
+
+  "Additional information about the node/operator"
+  extra: String
+}
+
 type StorageBucket @entity {
   "Runtime bucket id"
   id: ID!
@@ -33,7 +63,7 @@ type StorageBucket @entity {
   operatorStatus: StorageBucketOperatorStatus!
 
   "Storage bucket operator metadata"
-  operatorMetadata: Bytes
+  operatorMetadata: StorageBucketOperatorMetadata
 
   "Whether the bucket is accepting any new storage bags"
   acceptingNewBags: Boolean!
@@ -88,6 +118,9 @@ type StorageBag @entity {
   "Storage buckets assigned to store the bag"
   storedBy: [StorageBucket!]
 
+  "Distribution buckets assigned to distribute the bag"
+  distributedBy: [DistributionBucket!]
+
   "Owner of the storage bag"
   owner: StorageBagOwner!
 }
@@ -117,3 +150,78 @@ type StorageDataObject @entity {
   "Public key used to authenticate the uploader by the storage provider"
   authenticationKey: String
 }
+
+type DistributionBucketFamilyMetadata @entity {
+  "Name of the geographical region covered by the family (ie.: us-east-1)"
+  region: String
+
+  "Optional, more specific description of the region covered by the family"
+  description: String
+
+  "Optional region boundary as geocoordiantes polygon"
+  boundary: [GeoCoordinates!] @derivedFrom(field: "boundarySourceBucketFamilyMeta")
+}
+
+type DistributionBucketOperatorMetadata @entity {
+  "Root distributor node api endpoint"
+  nodeEndpoint: String
+
+  "Optional node location metadata"
+  nodeLocation: NodeLocationMetadata
+
+  "Additional information about the node/operator"
+  extra: String
+}
+
+enum DistributionBucketOperatorStatus {
+  INVITED,
+  ACTIVE
+}
+
+type DistributionBucketOperator @entity {
+  "{bucketId}-{workerId}"
+  id: ID!
+
+  "Related distirbution bucket"
+  distributionBucket: DistributionBucket!
+
+  "ID of the distribution group worker"
+  workerId: Int!
+
+  "Current operator status"
+  status: DistributionBucketOperatorStatus!
+
+  "Operator metadata"
+  metadata: DistributionBucketOperatorMetadata
+}
+
+type DistributionBucket @entity {
+  "Runtime bucket id"
+  id: ID!
+
+  "Distribution family the bucket is part of"
+  family: DistributionBucketFamily!
+
+  "Distribution bucket operators (either active or invited)"
+  operators: [DistributionBucketOperator!] @derivedFrom(field: "distributionBucket")
+
+  "Whether the bucket is accepting any new bags"
+  acceptingNewBags: Boolean!
+
+  "Whether the bucket is currently distributing content"
+  distributing: Boolean!
+
+  "Bags assigned to be distributed by the bucket"
+  distributedBags: [StorageBag!] @derivedFrom(field: "distributedBy")
+}
+
+type DistributionBucketFamily @entity {
+  "Runtime bucket family id"
+  id: ID!
+
+  "Current bucket family metadata"
+  metadata: DistributionBucketFamilyMetadata
+
+  "Distribution buckets belonging to the family"
+  buckets: [DistributionBucket!] @derivedFrom(field: "family")
+}