-
-
Notifications
You must be signed in to change notification settings - Fork 53
Writing 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
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.
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 };
};
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'
});
}
}
Now that you have the interface skeleton set up, you need to make it actually do something, a database interface has 6 functions:
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.
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.
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.
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.
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.
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.
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);
}
});
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`));
}
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);
}
});
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);
}
});
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.