Last active
January 7, 2022 04:23
-
-
Save J-Cake/b17b15305b61d9359a1f9f77712b525f to your computer and use it in GitHub Desktop.
Very simple table system which can use files on disk
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import fs from 'fs'; | |
import cp from 'child_process'; | |
import readline from 'readline'; | |
export enum Status { | |
Success = 0, | |
Unauthorised, | |
Nonexistent, | |
Invalid | |
} | |
export enum Type { | |
Text, | |
DateTime, | |
TimeSpan, | |
Int, | |
Float, | |
Path, | |
} | |
type Primitive<T> = { | |
serialise(obj: T): string, | |
parse(str: string): T | |
}; | |
type Value<T extends Type> = typeof parsers[T] extends Primitive<infer K> ? K : never; | |
type RowValue<Row extends TableRow> = { [K in keyof Row]: Value<Row[K]> }; | |
export class TimeSpan { | |
constructor(public readonly days: number, | |
public readonly hours: number, | |
public readonly minutes: number, | |
public readonly seconds: number, | |
public readonly ms: number) { | |
} | |
} | |
const parsers: Record<Type, Primitive<any & { readonly _type: Type }>> = { | |
[Type.Text]: { | |
serialise: (obj: string) => `"${encodeURIComponent(obj)}"`, | |
parse: (str: string) => decodeURIComponent(str.slice(1, -1)) | |
}, | |
[Type.DateTime]: { | |
serialise: (obj: Date) => `{${obj.toISOString()}}`, | |
parse: (str: string) => new Date(str.slice(1, -1)) | |
}, | |
[Type.TimeSpan]: { | |
serialise: (obj: TimeSpan) => `[${obj.days}-${obj.hours}-${obj.minutes}-${obj.seconds}-${obj.ms}]`, | |
parse: (str: string) => new TimeSpan(...str.slice(1, -1).split('-').map(i => Number(i)) as [number, number, number, number, number]) | |
}, | |
[Type.Int]: { | |
serialise: (obj: bigint) => `${obj.toString()}n`, | |
parse: (str: string) => BigInt(str.slice(0, -1)) | |
}, | |
[Type.Float]: { | |
serialise: (obj: number) => obj.toString(), | |
parse: (str: string) => Number(str) | |
}, | |
[Type.Path]: { | |
serialise: (obj: fs.promises.FileHandle) => `//${cp.execSync(`lsof -a -p ${process.pid} -d ${obj} -Fn | tail -n +3`).slice(1)}`, | |
parse: (str: string) => fs.promises.open(str.slice(2), 'r') | |
}, | |
} | |
export type TableRow = { [column: string]: Type }; | |
export type TableHeader<T> = { [column in keyof T]: [index: number, type: Type] }; | |
export type Matcher<Row extends TableRow> = (row: Row) => boolean; | |
export default class DBTable<Row extends TableRow> { | |
public static readonly DELIMITER = ','; | |
private file: fs.promises.FileHandle; | |
private table: RowValue<Row>[] = []; | |
private readonly: boolean = false; | |
private constructor(private header: TableHeader<Row>) { | |
} | |
static open<Row extends TableRow>(file: fs.promises.FileHandle, readonly: boolean = false): Promise<DBTable<Row>> { | |
return new Promise(function (resolve, reject) { | |
let db: DBTable<Row>; | |
const lines = readline.createInterface({input: file.createReadStream({start: 0, autoClose: false})}) | |
lines.once('line', line => { | |
db = new DBTable<Row>(this.parseHeader(line)); | |
lines.on('line', line => db.table.push(db.parse(line))); | |
}).on('close', function() { | |
if (!db) | |
reject(Status.Nonexistent); | |
db.file = file; | |
db.readonly = readonly; | |
resolve(db); | |
}.bind(this)); | |
}.bind(this)); | |
} | |
private static parseHeader<T extends TableRow>(row: string): TableHeader<T> { | |
const header: Partial<TableHeader<T>> = {}; | |
for (const [a, column] of row.split(DBTable.DELIMITER).entries()) { | |
const [name, type] = column.split(':'); | |
header[name.trim() as keyof TableHeader<T>] = [a, Type[type.trim()]]; | |
} | |
return header as TableHeader<T>; | |
} | |
public async close() { | |
const keysWithFiles = Object.keys(this.header).filter(i => this.header[i][1] === Type.Path) | |
const isFileHandle: (x: any) => x is fs.promises.FileHandle = (x): x is fs.promises.FileHandle => typeof x === 'object' && 'fd' in x && 'close' in x; | |
console.log(new Date().toISOString(), '\tClosing Database'); | |
for (const i of this.table) | |
for (const key of keysWithFiles) | |
if (isFileHandle(i[key])) | |
await (i[key] as fs.promises.FileHandle).close(); | |
await this.file.close(); | |
} | |
public async push(value: RowValue<Row>): Promise<Status.Success> { | |
if (this.readonly) | |
throw Status.Unauthorised; | |
this.table.push(value); | |
return Status.Success | |
} | |
public async* select(match: Matcher<RowValue<TableRow>>): AsyncGenerator<RowValue<TableRow>> { | |
let _line; | |
for await (const line of readline.createInterface({ | |
input: this.file.createReadStream({ | |
start: 0, | |
autoClose: false | |
}) | |
})) | |
if (!_line) | |
_line = line; | |
else { | |
const row = this.parse(line); | |
if (match(row)) | |
yield row | |
} | |
} | |
public commit(): this { | |
const stream = this.file.createWriteStream({start: 0, autoClose: false}); | |
stream.write(Object.keys(this.header).map(i => `${i}:${Type[this.header[i][1]]}`).join(DBTable.DELIMITER) + "\n"); | |
for (const i of this.table) | |
stream.write(this.serialise(i) + "\r\n"); | |
return this; | |
} | |
private parseValue(i: string): Row[string] { | |
if (i.startsWith('//')) | |
return parsers[Type.Path].parse(i); | |
else if (i.endsWith('n') && !isNaN(Number(i.slice(0, -1)))) | |
return parsers[Type.Int].parse(i); | |
else if (!i.endsWith('n') && !isNaN(Number(i))) | |
return parsers[Type.Float].parse(i); | |
else if (i.startsWith('{') && i.endsWith('}')) | |
return parsers[Type.DateTime].parse(i); | |
else if (i.startsWith('[') && i.endsWith(']')) | |
return parsers[Type.TimeSpan].parse(i); | |
else if (i.startsWith('"') && i.endsWith('"')) | |
return parsers[Type.Text].parse(i); | |
else throw `Unrecognised value '${i}'`; | |
} | |
private parse(line: string): RowValue<Row> { | |
const pieces = line.split(DBTable.DELIMITER); | |
const obj: Partial<Row> = {}; | |
for (const [a, i] of pieces.entries()) | |
obj[Object.keys(this.header)[a] as keyof Row] = this.parseValue(i.trim()); | |
return obj as RowValue<Row>; | |
} | |
private serialise(value: RowValue<Row>): string { | |
const strings: { [key: string]: string } = {}; | |
for (const i in value) | |
if (value[i] as any instanceof TimeSpan) | |
strings[i] = parsers[Type.TimeSpan].serialise(value[i]); | |
else if (value[i] as any instanceof Date) | |
strings[i] = parsers[Type.DateTime].serialise(value[i]); | |
else if (typeof value[i] === 'number') | |
strings[i] = parsers[Type.Float].serialise(value[i]); | |
else if (typeof value[i] === 'string') | |
strings[i] = parsers[Type.Text].serialise(value[i]); | |
else if (typeof value[i] === 'bigint') | |
strings[i] = parsers[Type.Int].serialise(value[i]); | |
const str: Set<string> = new Set(); // ensure the order is preserved | |
for (const key of Object.keys(this.header)) | |
str.add(strings[key]); | |
return Array.from(str).join(DBTable.DELIMITER) + '\n'; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment