Step 3

Next, create a file called dataService.ts and add the following. Note that this is a lot of code, and we discuss it in detail in the next step.

Tip: This is where you'll find most of the code demonstrating how to use the Golem-base TypeScript SDK, and this is where we'll be focusing most of our lesson.

Also, notice there are two lines for creating the client instance, and one is commented out. The first is for connecting to a locally running op-geth instance; the second is for connecting to our Kaolin test site.

import {
    createClient,
    AccountData,
    Tagged,
    type GolemBaseCreate,
    type GolemBaseUpdate,
    Annotation,
    Hex
} from "golem-base-sdk"
import { readFileSync } from "fs";
import jsonData from './data.json' with { type: 'json' };
import { GOLEM_BASE_APP_NAME, MediaItem, MediaType, Searches } from "./media.js";
import { getSearchEntity, transformSearchesToKeyValuePairs, updateSearchesFromItem } from "./searches.js";

interface QueryResult {
    key: string;
    auto_generated: string;
    type: string;
    title: string;
    description: string;
    // This next line means we can have any additional properties we want, provided their values are string, or number
    [key: string]: string | number;
}

// Read in key file and create client.
// Tip: We're including both local version and kaolin testnet. Comment/Uncomment to choose.
const keyBytes = readFileSync('./private.key');
const key: AccountData = new Tagged("privatekey", keyBytes);
export const client = await createClient(1337, key, 'http://localhost:8545', 'ws://localhost:8545');
//export const client = await createClient(600606, key, 'https://rpc.kaolin.holesky.golem-base.io', 'wss://ws.rpc.kaolin.holesky.golem-base.io');

const encoder = new TextEncoder();
const decoder = new TextDecoder();

export const getBlockNumber = async() => {
    return await client.getRawClient().httpClient.getBlockNumber()
}

export const sendSampleData = async () => {

    let creates:GolemBaseCreate[] = [];

    for (let i = 0; i < jsonData.length; i++) {
        creates.push(convertToCreateOrUpdate(jsonData[i]));
    }

    // Gather up authors, directors, artists, book-genres, movie-genres, music-genres so we can provide some search dropdowns
    // This will be built into a single entity that we'll also send over.

    let searchesTest:Searches = {
        directors: [],
        artists: [],
        authors: [],
        movie_genres: [],
        music_genres: [],
        book_genres: []
    }

    for (let i = 0; i < jsonData.length; i++) {
        updateSearchesFromItem(searchesTest, jsonData[i] as MediaItem);
    }

    let searches:GolemBaseCreate = {
        data: encoder.encode("searches"),
        btl: 25,
        stringAnnotations: transformSearchesToKeyValuePairs(searchesTest),
        numericAnnotations: []
    };

    searches.stringAnnotations.push(new Annotation("app", GOLEM_BASE_APP_NAME));
    searches.stringAnnotations.push(new Annotation("type", "searches"));

    creates.push(searches)

    const receipts = await client.createEntities(creates);

    console.log(receipts);

    return 10;
}

export const purge = async() => {
    // First query all with the current golem app id

    let queryString = `app="${GOLEM_BASE_APP_NAME}"`;
    console.log(queryString);
    const result:any = await client.queryEntities(queryString);
    const keys = result.map((item: any) => {
        return item.entityKey;
    })

    await client.deleteEntities(keys);
    return result;

}

export const createOrUpdateMediaItem = async (mediaItem: MediaItem, updateKey?: Hex) => {
    // Convert to a CreateEntity item
    let creates:GolemBaseCreate[] = [];
    let updates:GolemBaseUpdate[] = [];

    // TODO: Verify schema
    if (updateKey) {
        updates.push(convertToCreateOrUpdate(mediaItem, updateKey) as GolemBaseUpdate);
    }
    else {
        creates.push(convertToCreateOrUpdate(mediaItem));
    }

    // Grab the current Searches entity

    let searches:Searches = await getSearchEntity();

    // Add in the people and genres

    updateSearchesFromItem(searches, mediaItem);

    // Create an Update with the Searches entity

    const entityKey = searches.entityKey;
    delete searches.entityKey;

    let searchesUpdate:GolemBaseUpdate = {
        entityKey: entityKey as Hex,
        data: encoder.encode('searches'),
        btl: 25,
        stringAnnotations: transformSearchesToKeyValuePairs(searches),
        numericAnnotations: []
    }
    searchesUpdate.stringAnnotations.push(new Annotation("app", GOLEM_BASE_APP_NAME));
    searchesUpdate.stringAnnotations.push(new Annotation("type", "searches"));
    updates.push(searchesUpdate);

    // Send both the Create and the Update as a single transaction
    const receipt = await client.sendTransaction(creates, updates, [], []);
    console.log(receipt);
    return receipt; // For now we'll just return the receipt; probably need to clean it up into a more user-friendly struct

}

// This converts an incoming "plain old" JSON into a GolemBaseCreate or GolemBaseUpdate structure.
export const convertToCreateOrUpdate = (mediaItem: any, updateKey?: Hex) => {

    // Construct the data value from the type, name, and description

    // TODO: Add in the auto_generated part... Or remove it completely?

    const data_value:any = `${mediaItem?.type?.toUpperCase()}: ${mediaItem?.title} - ${mediaItem?.description}`;
    console.log(data_value);

    let result:GolemBaseCreate|GolemBaseUpdate;

    if (updateKey) {
        result = {
            entityKey: updateKey,
            data: data_value,
            btl: 25,
            stringAnnotations: [new Annotation("app", GOLEM_BASE_APP_NAME)],
            numericAnnotations: []
        }
    }
    else {
        result = {
            data: data_value,
            btl: 25,
            stringAnnotations: [new Annotation("app", GOLEM_BASE_APP_NAME)],
            numericAnnotations: []
        }
    }

    for (const key of Object.keys(mediaItem)) {
        const value = (mediaItem as any)[key];
        if (typeof(value) == 'number') {
            result.numericAnnotations.push(new Annotation(key, value));

        }
        else if (typeof(value) == 'string') {
            result.stringAnnotations.push(new Annotation(key, value));
        }
        else {
            let newValue = String(value);
            if (String(value).toLowerCase() == 'true') {
                newValue = 'true';
            }
            else if (String(value).toLowerCase() == 'false') {
                newValue = 'false';
            }
            result.stringAnnotations.push(new Annotation(key, newValue));

        }
    }

    return result;
}

export const getItemByEntityKey = async (hash: Hex) => {

    // This function builds a "regular" flat JSON object
    // based on the data stored in the annotations.
    // We simply loop through the annotations, grab the key
    // and save the value into the object with that key as
    // as a member.
    const metadata: any = await client.getEntityMetaData(hash);

    let result:any = {
        key: hash
    };

    for (let i=0; i<metadata.stringAnnotations.length; i++) {
        const key = metadata.stringAnnotations[i].key;
        const value = metadata.stringAnnotations[i].value;
        result[key] = value;
    }

    for (let i=0; i<metadata.numericAnnotations.length; i++) {
        const key = metadata.numericAnnotations[i].key;
        const value = metadata.numericAnnotations[i].value;
        result[key] = value;
    }

    return result;
}

export const query = async (queryString: string) => {
    console.log('Querying...');
    console.log(queryString);
    const rawResult: any = await client.queryEntities(queryString);

    // This part is annoying; we have to decode every payload.
    let result:QueryResult[] = [];

    for (let i=0; i<rawResult.length; i++) {
        console.log(i);
        const metadata: any = await getItemByEntityKey(rawResult[i].entityKey);
        console.log(metadata);
        let item:QueryResult = {
            key: rawResult[i].entityKey,
            auto_generated: decoder.decode(rawResult[i].storageValue),
            type: metadata.type,
            title: metadata.title,
            description: metadata.description
        }
        // Loop through members of metadata, skipping type, title, description TODO: Do we really want the interface?
        for (const key of Object.keys(metadata)) {
            if (key != "type" && key != "title" && key != "description" && key != "app") {
                const value = (metadata as any)[key];
                item[key] = value;
            }
        }
        console.log(item);
        result.push(item);
    }

    console.log(result);

    return result;
}

For the final TypeScript file in the backend, create a file called searches.ts and add the following to it:

// The "searches" object holds index information on the existing entities.

import { Annotation, Hex } from "golem-base-sdk";
import { GOLEM_BASE_APP_NAME, MediaItem, MediaType, Searches } from "./media.js";
import { client } from "./dataService.js";

// Mapping from media type to the person + genre keys. This way we can add additional media types later on without having to make major rewrites
const MEDIA_MAP: Record<MediaType, { personKey: keyof Searches; genreKey: keyof Searches; sourcePersonField: string }> = {
  book: { personKey: "authors", genreKey: "book_genres", sourcePersonField: "author" },
  movie: { personKey: "directors", genreKey: "movie_genres", sourcePersonField: "director" },
  music: { personKey: "artists", genreKey: "music_genres", sourcePersonField: "artist" },
};



// This takes an existing Searches (a set of lists), and adds in additional items, but checks for existence first, and only adds if they aren't already there
// We also use the above so we're not hardcoding "director" "movie_genre" etc. in case we want to add additional media types later.
export const updateSearchesFromItem = (searches: Searches, item: MediaItem): void => {

    if (!item || !item.type) {
        return; // invalid data, skip
    }

    const map = MEDIA_MAP[item.type];

    if (!map) {
        return; // invalid data, skip
    }

    const personValue: string = (item as any)[map.sourcePersonField];
    const genreValue: string = item.genre;

    const personList = searches[map.personKey] as string[];
    const genreList = searches[map.genreKey] as string[];

    // Normalize for comparison
    const personValueLower = personValue.toLowerCase();
    const genreValueLower = genreValue.toLowerCase();

    // Case-insensitive check for person
    if (!personList.some(p => p.toLowerCase() === personValueLower)) {
        personList.push(personValue);
    }

    // Case-insensitive check for genre
    if (!genreList.some(g => g.toLowerCase() === genreValueLower)) {
        genreList.push(genreValue);
    }
}

export const transformSearchesToKeyValuePairs = (searches: Omit<Searches, 'entityKey'>): Annotation<string>[] => {
    return Object.entries(searches).map(([key, value]) => {
        const finalKey = key; //.replace(/_/g, '-'); // turn underscores into dashes
        const sortedValues = [...value].sort((a, b) => a.localeCompare(b));
        return new Annotation(finalKey, sortedValues.join(','));
    });
}

export const getSearchEntity = async(): Promise<Searches> => {
    // This is an example where for the "full" app we would also include userid or username in the query
    const entities = await client.queryEntities(`app="${GOLEM_BASE_APP_NAME}" && type="searches"`);
    if (entities.length > 0) {

        // There should always be exactly one, but just in case...
        let search_hash: Hex = entities[0].entityKey;

        // Grab the metadata
        const metadata = await client.getEntityMetaData(search_hash);

        console.log(metadata);

        // Build the search options as a single object
        // Let's use the built in reduce function to transform this into an object
        // (Instead of harcoding "director", "author" etc. That way if we add 
        // Additional media types later on, we won't have to change this code.)
        const output:Searches = metadata.stringAnnotations.reduce(
            (acc, {key, value}) => {
                // Skip the app and type annotations but include all the rest
                if (key == "app" || key == "type") {
                    return acc;
                }
                acc[key] = value.split(',');
                return acc;
            },
            {} as Record<string, string[]>
        ) as unknown as Searches; // Those are just to get the TS compiler to shut up ;-)

        output.entityKey = search_hash;

        console.log(output);
        return output;

    }
    return {} as Searches; // Again, to get TS to quiet down
}

And now we'll build the file with the sample starter data. Create a file in the same src folder called data.json and put the following in it:

[

{
  "type": "book",
  "title": "A Game of Thrones",
  "description": "A sprawling fantasy of politics and dragons",
  "author": "George R. R. Martin",
  "genre": "fantasy",
  "rating": 4,
  "owned": false,
  "year": 1996
},

{
  "type": "movie",
  "title": "Inception",
  "description": "A mind-bending dream within a dream",
  "director": "Christopher Nolan",
  "genre": "sci-fi",
  "rating": 5,
  "watched": true,
  "year": 2010
},

{
  "type": "movie",
  "title": "Arrival",
  "description": "Aliens land in Montana and language nerds save the world",
  "director": "Denis Villeneuve",
  "genre": "sci-fi",
  "rating": 5,
  "watched": false,
  "year": 2016
},

{
  "type": "music",
  "title": "Blade Runner Blues",
  "description": "Spacey synth with prog undertones",
  "artist": "Vangelis",
  "genre": "ambient",
  "rating": 5,
  "favorite": true,
  "year": 1982
},

{
  "type": "music",
  "title": "Mothership Connection",
  "description": "A funky jam from an intergalactic mothership",
  "artist": "Parliament",
  "genre": "funk",
  "rating": 4,
  "favorite": false,
  "year": 1975
},

{
  "type": "book",
  "title": "Snow Crash",
  "description": "A hacker discovers the world is a simulation",
  "author": "Neal Stephenson",
  "genre": "cyberpunk",
  "rating": 4,
  "owned": true,
  "year": 1992
},

{
  "type": "movie",
  "title": "Back to the Future",
  "description": "Time-traveling teen in a DeLorean with wild hair mentor",
  "director": "Robert Zemeckis",
  "genre": "sci-fi",
  "rating": 5,
  "watched": true,
  "year": 1985
},

{
  "type": "movie",
  "title": "Brazil",
  "description": "A dystopian future where style meets surveillance",
  "director": "Terry Gilliam",
  "genre": "dystopian",
  "rating": 4,
  "watched": false,
  "year": 1985
},

{
  "type": "music",
  "title": "Boys of Summer (Cover)",
  "description": "Melancholy synthwave soundtrack from a virtual world",
  "artist": "The Midnight",
  "genre": "synthwave",
  "rating": 4,
  "favorite": true,
  "year": 2017
},

{
  "type": "music",
  "title": "Close to the Edge",
  "description": "A prog rock odyssey through time and space",
  "artist": "Yes",
  "genre": "prog rock",
  "rating": 5,
  "favorite": true,
  "year": 1972
},

{
  "type": "book",
  "title": "A Clash of Kings",
  "description": "A war-torn sequel with direwolves and betrayal",
  "author": "George R. R. Martin",
  "genre": "fantasy",
  "rating": 4,
  "owned": true,
  "year": 1998
},

{
  "type": "book",
  "title": "A Storm of Swords",
  "description": "The dragons rise, and war rages across Westeros",
  "author": "George R. R. Martin",
  "genre": "fantasy",
  "rating": 5,
  "owned": true,
  "year": 2000
},

{
  "type": "book",
  "title": "Neuromancer",
  "description": "Corporate espionage meets virtual addiction",
  "author": "William Gibson",
  "genre": "cyberpunk",
  "rating": 5,
  "owned": true,
  "year": 1984
},

{
  "type": "movie",
  "title": "Memento",
  "description": "A man with no short-term memory pieces together a murder",
  "director": "Christopher Nolan",
  "genre": "thriller",
  "rating": 5,
  "watched": true,
  "year": 2000
},

{
  "type": "music",
  "title": "2112",
  "description": "A 21-minute space opera in musical form",
  "artist": "Rush",
  "genre": "prog rock",
  "rating": 5,
  "favorite": true,
  "year": 1976
},

{
  "type": "music",
  "title": "Echoes",
  "description": "The track that launched a thousand concept albums",
  "artist": "Pink Floyd",
  "genre": "prog rock",
  "rating": 5,
  "favorite": true,
  "year": 1971
},

{
  "type": "music",
  "title": "Heart of Gold",
  "description": "Poetic balladry with a touch of mysticism",
  "artist": "Neil Young",
  "genre": "folk",
  "rating": 4,
  "favorite": false,
  "year": 1972
},

{
  "type": "movie",
  "title": "Rogue One: A Star Wars Story",
  "description": "A rebel team steals Death Star plans in gritty Star Wars prequel",
  "director": "Gareth Edwards",
  "genre": "sci-fi",
  "rating": 4,
  "watched": true,
  "year": 2016
}

]

That's it! Now you have the backend files ready. We won't build and test it yet, as we need a running golembase node and a private key file; we'll create the front end files, and then go through the process of creating a private key file.

Head to Step 4.