2 Writing a database interface
X edited this page 5 months ago

Creating a database interface

Ass supports countless* different database types, but sometimes, you may want to use something else. Maybe you want to store data in something else, something unique. In this example we'll just be implementing a yaml database but you can use anything you'd like.
* may not be countless

Setting up the interface class

Before you can write an interface, you need to change a few values around ass's source code, hopefully in the future, it'll use a plugin system for this instead, but this isn't the future, this is now, lets get to work!

First create the skeleton for interface in /backend/sql

/// FILE: /backend/sql/yaml.ts
import { AssFile, AssUser, UploadToken } from 'ass';

import { Database, DatabaseTable, DatabaseValue } from './database';
import { log } from '../log';

import YAML from 'yaml';
import fs from 'fs';
import path from 'path';

// Database path
const DB_PATH = path.join('.ass-data/db.yaml');

// The class name should be in the format "<Database kind>Database"
export class YAMLDatabase implements Database {
    public open(): Promise<void> {
        return Promise.resolve();
    }

    public close(): Promise<void> {
        return Promise.resolve();
    }
    
    public configure(): Promise<void> {
        return Promise.resolve();
    }
    
    public put(table: DatabaseTable, key: string, data: DatabaseValue): Promise<void> {
        return Promise.resolve();
    }
    
    public get(table: DatabaseTable, key: string): Promise<DatabaseValue> {
        throw new Error("Key does not exist");
    }
    
    public getAll(table: DatabaseTable): Promise<DatabaseValue[]> {
        return Promise.resolve([]);
    }
}

Once your skeleton interface is ready, set it up to work with the rest of the code

/// FILE: /common/types.d.ts
/// TYPE: ass.DatabaseConfiguration
interface DatabaseConfiguration {
    // This should match the filename you chose for the interface
    kind: /* (snip) */ | 'yaml';
}
/// FILE:     /backend/app.ts
/// FUNCTION: main
switch (UserConfig.config.database?.kind) {
    // (snip)
    case 'yaml': // Dont forget to import the skeleton!
        await DBManager.use(new YAMLDatabase());
        break;
}
/// FILE:     /backend/routers/api.ts
/// FUNCTION: Express POST route for /setup
switch (UserConfig.config.database.kind) {
    // (snip)
    case 'yaml': // Again, dont forget to import the skeleton
        await DBManager.use(new YAMLDatabase());
        break;
} 
/// FILE: /backend/data.ts
/// VAR:  DBNAMES
const DBNAMES = {
    // (snip)
    // set this to the display name for your database
    'yaml': 'YAML'
};

Next, add your database to the setup page

//- * FILE: /views/setup.pug
block content
    .flex.flex-col.items-center
        h2.setup-text-section-header.mt-4 Database
        .setup-panel
            sl-tab-group
                //- * YAML (change this)
		sl-tab#yaml-tab(slot='nav' panel='yaml') YAML
 
		//- If you have options for your db, put them here
                sl-tab-panel(name='yaml')
		    | you all good!

Add your database to the setup page code

/// FILE:     /frontend/setup.ts
/// FUNCTION: DOMContentLoaded listener
const Elements = {
    // (snip)
    yamlTab: document.querySelector('#yaml-tab') as SlTab
};

Elements.submitButton.addEventListener('click', async () => {
    // (snip)
    if (Elements.jsonTab.active) {
        ...
    } else if (Elements.yamlTab.active) {
        config.database = {
            kind: 'yaml'
        };
    }
});

You now have a bare minimum database interface. Now you can get to writing the actual database part.

Defining database structure

Before you write the interface code, you're going to want to figure out how you want to lay out all the values in the database. Since we're using yaml, we'll do something like this

/// FILE: /backend/sql/yaml.ts
type YAMLDatabaseSchema = {
    version: 1;
    users:   { [index: string]: AssUser };
    files:   { [index: string]: AssFile };
    tokens:  { [index: string]: UploadToken };
};

Reading and writing

We're going to be saving and loading the database a lot since it's just a file, so we should probably write a couple helper functions to handle that for us.

export class YAMLDatabase implements Database {
    private _readDB(): YAMLDatabaseSchema {
        return YAML.parse(fs.readFileSync(DB_PATH, {
            encoding: 'utf8'
        }));
    }

    private _writeDB(db: YAMLDatabaseSchema) {
        fs.writeFileSync(DB_PATH, YAML.stringify(db), {
            encoding: 'utf8'
        });
    }
}

Database interaction code

Now that you have the interface skeleton set up, you need to make it actually do something, a database interface has 6 functions:

function descriptions blah balh nufadjksf

open()

function open(): Promise<void>;

open connects to the database, thats it. It should only resolve once it's done connecting and it should reject if anything goes wrong.

close()

function close(): Promise<void>;

close disconnects from the database. It should only resolve once it's disconnected and it should reject if anything goes wrong.

configure()

function configure(): Promise<void>;

configure prepares the database for use, it doesn't configure the interface, it configures the database itself. Some things it may do include creating tables, updating tables if the schema changed or repairing corrupted data. As usual, it should reject if anything goes wrong.

put()

function put(table: 'assfiles', key: string, data: AssFile): Promise<void>;
function put(table: 'assusers', key: string, data: AssUser): Promise<void>;
function put(table: 'asstokens', key: string, data: UploadToken): Promise<void>;
function put(table: DatabaseTable, key: string, data: DatabaseValue): Promise<void>;

put inserts a value into the database, table is either assfiles, assusers or asstokens. depending on the table, the data type is AssFile, AssUser or UploadToken respectively. It should resolve onve done and reject if something goes wrong. If the key already exists, it should do nothing and reject.

get()

function get(table: 'assfiles', key: string): Promise<AssFile>
function get(table: 'assusers', key: string): Promise<AssUser
function get(table: 'asstokens', key: string): Promise<UploadToken>
function get(table: DatabaseTable, key: string): Promise<DatabaseValue>

get gets a value from the database. It should throw an error if the key does not exist and should resolve to the value if it does. If something goes wrong it should reject.

getAll()

function getAll(table: 'assfiles', key: string): Promise<AssFile[]>
function getAll(table: 'assusers', key: string): Promise<AssUser[]>
function getAll(table: 'asstokens', key: string): Promise<UploadToken[]>
function getAll(table: DatabaseTable, key: string): Promise<DatabaseValue[]>

getAll should resolve an array of values. It should reject if something goes wrong.

Database configuration

When a database is selected, DBManager calls db.open() to connect to the database, then db.configure() to set up/update/repair database tables. We aren't connecting to anything, so we can just keep YAMLDatabase.open as Promise.resolve(). We should set up YAMLDatabase.configure to create the database file if it does not exist (Bonus: If you want, you can also make it validate the YAML, but we wont be doing that in this guide)

/// FILE:     /backend/sql/yaml.ts
/// FUNCTION: YAMLDatabase.configure
return new Promise(async (resolve, reject) => {
    try {
        if (!fs.existsSync(DB_PATH)) {
            this._writeDB({
                version: 1,
                users:   {},
                files:   {},
                tokens:  {}
            });
        }

        resolve();
    } catch (err) {
        log.error('Failed to verify existence of data files');
        reject(err);
    }
});

Writing to the database

db.put() has 3 different tables that it can store data into, we'll need to make it so it handles each of them correctly. We'll start with the generic blank promise

/// FILE:     /backend/sql/yaml.ts
/// FUNCTION: YAMLDatabase.put
return new Promise(async (resolve, reject) => {
    try {
        resolve();
    } catch (err) {
        log.error('Failed to insert data');
        reject(err);
    }
});

We'll need to have a mapping of table names to database properties, let's add one after DB_PATH

/// FILE: /backend/sql/yaml.ts
const DB_TABLES: { [index: string]: 'users' | 'files' | 'tokens' } = {
    'assusers':  'users',
    'assfiles':  'files',
    'asstokens': 'tokens'
};

Now let's set up put to store the data in its dedicated spot

/// FILE: /backend/sql/yaml.ts
/// FUNCTION: promise in YAMLDatabase.put

// Load the database
let db = this._readDB();

// Store the data
db[DB_TABLES[table]][key] = data;

// Save the database
this._writeDB(db);

resolve();

We're also going to need to make sure that there isn't any conflict

/// FILE: /backend/sql/yaml.ts
/// FUNCTION: promise in YAMLDatabase.put
let db = this._readDB();

// Check for conflict
if (db[DB_TABLES[table]][key] != undefined) {
    return reject(new Error(`Key ${key} in ${table} already exists`));
}

Getting values from the database

db.get() is like db.put(), but backwards. We can just copy the code and make a few modifications.

/// FILE:     /backend/sql/yaml.ts
/// FUNCTION: YAMLDatabase.get
return new Promise(async (resolve, reject) => {
    try {
        // Load the database
        let db = this._readDB();

        // Check for the key
        if (db[DB_TABLES[table]][key] == undefined) throw new Error("Key does not exist");

        // Get the thing
        resolve(db[DB_TABLES[table]][key]);
    } catch (err) {
        log.error('Failed to get data');
        reject(err);
    }
});

Getting all values from the database

db.all() just returns an object with all values in the database, you just do what you did with db.get(), but return the table instead of the value.

/// FILE:     /backend/sql/yaml.ts
/// FUNCTION: YAMLDatabase.getAll
return new Promise(async (resolve, reject) => {
    try {
        // Load the database
        let db = this._readDB();

        // Get the thing
        resolve(Object.values(db[DB_TABLES[table]]));
    } catch (err) {
        log.error('Failed to get data');
        reject(err);
    }
});

Finishing up

There you go, you did it. You have a yaml interface. If you configure ass to use yaml, you should see .ass-data/db.yaml get populated with images and accounts.