Skip to content

Continuing with the Back End

Because the Searches entity keeps an index of all current entities, we need to update it whenever we change the data. Let's build a function that updates it accordingly:

async fn update_searches(client: &GolemBaseClient, item: &MediaItem) -> Result<(), Box<dyn std::error::Error>> {
    println!("Fetching search entity for update...");
    let mut searches: Searches = get_search_entity(&client)
        .await
        .unwrap_or(None)        // unwrap the Result, get Option<Searches>
        .unwrap_or_default();   // unwrap the Option, get Searches

    let old_entity_key = searches.entity_key.clone();

    // The rest of the updateSearchesFromItem logic from the TS file
    if let Some((person_key, genre_key, source_person_field)) = MEDIA_MAP.get(match item {
        MediaItem::Book(_) => "book",
        MediaItem::Movie(_) => "movie",
        MediaItem::Music(_) => "music",
    }) {
        let (person_value, genre_value) = match item {
            MediaItem::Book(book) => (book.author.clone(), book.genre.clone()),
            MediaItem::Movie(movie) => (movie.director.clone(), movie.genre.clone()),
            MediaItem::Music(music) => (music.artist.clone(), music.genre.clone()),
        };

        match *person_key {
            "authors" => if !searches.authors.iter().any(|s| s.eq_ignore_ascii_case(&person_value)) { searches.authors.push(person_value.clone()); },
            "directors" => if !searches.directors.iter().any(|s| s.eq_ignore_ascii_case(&person_value)) { searches.directors.push(person_value.clone()); },
            "artists" => if !searches.artists.iter().any(|s| s.eq_ignore_ascii_case(&person_value)) { searches.artists.push(person_value.clone()); },
            _ => {}
        }

        match *genre_key {
            "book_genres" => if !searches.book_genres.iter().any(|s| s.eq_ignore_ascii_case(&genre_value)) { searches.book_genres.push(genre_value.clone()); },
            "movie_genres" => if !searches.movie_genres.iter().any(|s| s.eq_ignore_ascii_case(&genre_value)) { searches.movie_genres.push(genre_value.clone()); },
            "music_genres" => if !searches.music_genres.iter().any(|s| s.eq_ignore_ascii_case(&genre_value)) { searches.music_genres.push(genre_value.clone()); },
            _ => {}
        }
    }

    // Convert the updated searches object back to annotations
    let string_annotations = vec![
        Annotation::new("app".to_string(), GOLEM_BASE_APP_NAME.to_string()),
        Annotation::new("type".to_string(), "searches".to_string()),
        Annotation::new("directors".to_string(), searches.directors.join(",")),
        Annotation::new("artists".to_string(), searches.artists.join(",")),
        Annotation::new("authors".to_string(), searches.authors.join(",")),
        Annotation::new("movie_genres".to_string(), searches.movie_genres.join(",")),
        Annotation::new("music_genres".to_string(), searches.music_genres.join(",")),
        Annotation::new("book_genres".to_string(), searches.book_genres.join(",")),
    ];

    if let Some(key) = old_entity_key {
        println!("Updating existing searches entity...");
        let update = Update {
            entity_key: parse_b256(&key),
            data: "searches_data".into(),
            btl: 0,
            string_annotations,
            numeric_annotations: vec![],
        };
        client.update_entities(vec![update]).await?;
    } else {
        println!("Creating new searches entity...");
        let create = Create {
            btl: 0,
            data: "searches_data".into(),
            string_annotations,
            numeric_annotations: vec![],
        };
        client.create_entities(vec![create]).await?;
    }

    Ok(())
}

And while we're talking about Searches, let's build a function that retrieves it:

async fn get_search_entity(client: &GolemBaseRoClient) -> Result<Option<Searches>, Box<dyn std::error::Error>> {
    let entities = client.query_entities(&format!("app=\"{}\" && type=\"searches\"", GOLEM_BASE_APP_NAME)).await?;
    if let Some(entity_data) = entities.first() {
        let metadata = client.get_entity_metadata(entity_data.key.clone()).await?;
        Ok(Some(transform_annotations_to_searches(&metadata, entity_data.key.to_string())))
    } else {
        Ok(None)
    }
}

Great! Now let's write a function that adds some sample data:

// Pre-load sample data
async fn load_data_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
    println!("Loading sample data...");
    // TODO: A real implementation would parse and create entities from a static file.
    // For this demo, we'll just create a dummy one.
    let sample_item = MediaItem::Movie(Movie {
        title: "Dune".to_string(),
        description: "A movie about sandworms.".to_string(),
        director: "Denis Villeneuve".to_string(),
        genre: "Sci-fi".to_string(),
        rating: 9,
        watched: true,
        year: 2021,
    });

    let (_, string_annotations, numeric_annotations) = transform_item_to_annotations(&sample_item, &["title", "genre", "director"]);

    let data = match serde_json::to_vec(&sample_item) {
        Ok(buf) => Bytes::from(buf),
        Err(e) => {
            eprintln!("Error encoding sample_item as JSON: {}", e);
            return (StatusCode::BAD_REQUEST, "Invalid JSON payload").into_response();
        }
    };

    let create = Create {
        btl: 25,
        data: data,
        string_annotations,
        numeric_annotations,
    };

    if let Err(e) = state.client.create_entities(vec![create]).await {
        eprintln!("Error creating sample entity: {:?}", e);
        return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create sample data.").into_response();
    }

    // Update search entity with new item
    if let Err(e) = update_searches(&state.client, &sample_item).await {
        eprintln!("Error updating searches: {:?}", e);
        return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to update search entity.").into_response();
    }

    (StatusCode::OK, Json(json!({"status": "OK"}))).into_response()
}

And now a function that saves a new media item:

// Save a new media item
async fn save_handler(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,      // 👈 use this (not `request`)
    body: String,            // raw JSON body
) -> impl IntoResponse {
    // Parse JSON payload
    let item: MediaItem = match serde_json::from_str(&body) {
        Ok(item) => item,
        Err(e) => {
            eprintln!("Error parsing JSON body: {}", e);
            return (StatusCode::BAD_REQUEST, "Invalid JSON format.").into_response();
        }
    };

    // Your existing transformation
    let (data_as_string, string_annotations, numeric_annotations) = transform_item_to_annotations(
        &item,
        &["title", "genre", "director", "author", "artist"],
    );

    // Read optional header and branch: update vs create
    let result = if let Some(key_str) = headers
        .get("x-entity-key") // header names are case-insensitive
        .and_then(|h| h.to_str().ok())
    {
        println!("Updating existing entity with key: {}", key_str);

        let entity_key = parse_b256(key_str);

        let update = Update {
            entity_key,
            btl: 25,
            data: data_as_string.into(),
            string_annotations,
            numeric_annotations,
        };

        state.client.update_entities(vec![update]).await
    } else {
        println!("Creating new entity...");

        let create = Create {
            btl: 25,
            data: data_as_string.into(),
            string_annotations,
            numeric_annotations,
        };

        state.client.create_entities(vec![create]).await
    };

    // Optionally update search entity
    if let Err(e) = update_searches(&state.client, &item).await {
        eprintln!("Error updating searches: {:?}", e);
        return (
            StatusCode::INTERNAL_SERVER_ERROR,
            "Failed to update search entity.",
        )
            .into_response();
    }

    // Build response
    match result {
        Ok(receipts) => {
            let key_str = receipts
                .first()
                .map(|r| r.entity_key.to_string()); // adjust if your type differs
            (StatusCode::OK, Json(json!({ "entity_key": key_str }))).into_response()
        }
        Err(e) => {
            eprintln!("Error creating/updating entity: {}", e);
            (StatusCode::INTERNAL_SERVER_ERROR, "Failed to save media item.").into_response()
        }
    }
}

Next we need a function that reads the Searches entity and retrieves the data to be used to fill in the dropdown boxes. This is useful for the querying system; for example, a "directors" dropdown will list all the movie directors currently in the system.

// Get search options for dropdowns
async fn search_options_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
    match get_search_entity(&state.client).await {
        Ok(Some(searches)) => Json(serde_json::to_value(&searches).unwrap()).into_response(),
        Ok(None) => (StatusCode::NOT_FOUND, "Search entity not found.").into_response(),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error fetching search entity: {}", e)).into_response(),
    }
}

And now let's build the handler for when the user performs a query:

// Query for media items
async fn query_handler(State(state): State<Arc<AppState>>, query_params: Query<HashMap<String, String>>) -> impl IntoResponse {
    let mut query_string = format!("app=\"{}\"", GOLEM_BASE_APP_NAME);

    for (key, value) in query_params.iter() {
        if value.parse::<u64>().is_ok() {
            query_string += &format!(" && {}={}", key, value);
        } else {
            query_string += &format!(" && {}\"={}\"", key, value);
        }
    }

    println!("Running query: {}", query_string);

    match state.client.query_entities(&query_string).await {
        Ok(results) => Json(results).into_response(),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Query failed: {}", e)).into_response(),
    }
}

Let's also include a helper handler that lets us empty out the database:

// Purge all entities for the app
async fn purge_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
    println!("Purging all entities...");
    match state.client.get_all_entity_keys().await {
        Ok(keys) => {
            if keys.is_empty() {
                return (StatusCode::OK, "No entities to purge.").into_response();
            }
            let delete_keys: Vec<Hash> = keys;
            match state.client.delete_entities(delete_keys).await {
                Ok(_) => (StatusCode::OK, "Entities purged successfully.").into_response(),
                Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to delete entities: {}", e)).into_response(),
            }
        },
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to get all entity keys: {}", e)).into_response(),
    }
}

And finally, a handler for obtaining an item based on its key:

// Get an item by its entity key
async fn get_by_key_handler(State(state): State<Arc<AppState>>, Path(id): Path<String>) -> impl IntoResponse {
    let entity_key = parse_b256(&id);

    println!("Loading entity by key: {}", entity_key);
    match state.client.get_entity_metadata(entity_key).await {
        Ok(metadata) => Json(json!({
            "entityKey": id,
            "data": metadata.payload.unwrap_or_default(),
            "stringAnnotations": metadata.string_annotations,
            "numericAnnotations": metadata.numeric_annotations,
        })).into_response(),
        Err(e) => {
            eprintln!("Error getting metadata: {}", e);
            (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to get metadata: {}", e)).into_response()
        },
    }
}

And to wrap it up, let's build the main!

// The main entry point for the application.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    dotenvy::dotenv().ok();

    let password = std::env::var("GOLEMDB_PASS")?;

    let keypath = config_dir()
        .ok_or("Failed to get config directory")?
        .join("golembase")
        .join("wallet.json");
    let signer = PrivateKeySigner::decrypt_keystore(keypath, password.trim_end())?;

    let rpc_url = Url::parse("http://localhost:8545").unwrap();

    let shared_state = Arc::new(AppState {
        client: GolemBaseClient::builder().wallet(signer.clone()).rpc_url(rpc_url.clone()).build(),
    });

    println!("Server is running at http://localhost:3000");

    let app = Router::new()
        .route("/", get(hello_handler))
        .route("/load-data", get(load_data_handler))
        .route("/save", post(save_handler))
        .route("/search-options", get(search_options_handler))
        .route("/query", get(query_handler))
        .route("/purge", get(purge_handler))
        .route("/block-number", get(block_number_handler))
        .route("/key/:id", get(get_by_key_handler))
        .with_state(shared_state)
        .layer(CorsLayer::permissive());

    let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();

    Ok(())
}

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.