UNPKG

badb

Version:

Simple database with caching

782 lines (679 loc) 23.2 kB
const fs = require("fs"); const __TYPES = ["string", "uint32", "int32", "uint16", "int16", "uint8", "int8"]; const __LENGTHS = { "uint32": 4, "int32": 4, "uint16": 2, "int16": 2, "uint8": 1, "int8": 1 }; const __FUNC = { "uint32": ["writeUint32LE", "readUint32LE"], "int32": ["writeInt32LE", "readInt32LE"], "uint16": ["writeUint16LE", "readUint16LE"], "int16": ["writeInt16LE", "readInt16LE"], "uint8": ["writeUint8", "readUint8"], "int8": ["writeInt8", "readInt8"] } function TYPE_OF_ID(id) { return __TYPES[id]; } function ID_OF_TYPE(type) { return __TYPES.indexOf(type.toLowerCase()); } function IS_FIXED_LENGTH(type) { return type.toLowerCase() in __LENGTHS; } function LENGTH_OF_TYPE(type) { return __LENGTHS[type.toLowerCase()]; } function PROVE_VALUE(type, maxLength, value) { if (type == "string") { if (typeof value != "string") return false; return (Buffer.from(value).byteLength <= maxLength - 2); } const val = parseInt(value); if (isNaN(val)) return false; if (type.startsWith("u")) { return 0 <= val && val < 2 ** (8 * maxLength); } else { return - (2 ** (8 * maxLength - 1)) <= val && val < 2 ** (8 * maxLength - 1); } } function WRITE(buffer, type, value, offset) { if (type == "string") { const byteLength = Buffer.from(value).byteLength; buffer.writeUint16LE(byteLength, offset); buffer.utf8Write(value, offset + 2); } else { buffer[__FUNC[type][0]](value, offset); } } function READ(buffer, type, offset) { if (type == "string") { const byteLength = buffer.readUint16LE(offset); return buffer.utf8Slice(offset + 2, offset + 2 + byteLength); } else { return buffer[__FUNC[type][1]](offset); } } function PARSE_VALUE(value, isKey) { if (!("name" in value) || typeof value.name != "string") { throw new Error("The value must have a 'name' which is a string"); } const name = value.name; if (isKey && "default" in value) { throw new Error("The key can't have a default value"); } let type; if (!("type" in value)) { type = "string"; } else { if (typeof value.type != "string" || ID_OF_TYPE(value.type) == -1) { throw new Error("Unsupported type '" + value.type + "' for value '" + name + "'"); } type = value.type.toLowerCase(); } if ("maxLength" in value && IS_FIXED_LENGTH(type)) { throw new Error("Explicit 'maxLength' in value '" + name + "' for a fixed length type '" + type + "'"); } if (!IS_FIXED_LENGTH(type) && !("maxLength" in value) && !("default" in value)) { throw new Error("No 'maxLength' or 'default' in value '" + name + "' for a type without a fixed length"); } if ("maxLength" in value && typeof value.maxLength != "number") { throw new Error("The 'maxLength' of a value must be a number, in value '" + name + "'"); } let defaultValue; if ("default" in value) { const expectedType = type == "string" ? "string" : "number"; if (typeof value.default != expectedType) { throw new Error("The default value '" + value.default + "' does not match the given type '" + type + "', in value '" + name + "'"); } defaultValue = value.default; } else { if (type == "string") defaultValue = ""; else defaultValue = 0; } let maxLength; if (IS_FIXED_LENGTH(type)) { maxLength = LENGTH_OF_TYPE(type); } else { if ("maxLength" in value) { maxLength = value.maxLength; } else { maxLength = Buffer.from(defaultValue).byteLength + 2; } } if (!IS_FIXED_LENGTH(type)) { maxLength += 2; // len of field } if (!PROVE_VALUE(type, maxLength, defaultValue)) { throw new Error("The default value of the value '" + name + "' does not fit in 'maxLength'"); } return { name, type, defaultValue, maxLength }; } class EntryControl { constructor(exists) { this.__exists = exists; this.__removed = false; this.__confirmed = false; } remove() { this.__removed = true; return this.__exists; } removed() { return this.__removed; } confirm() { this.__confirmed = true; return !this.__exists; } confirmed() { return this.__confirmed; } exists() { return this.__exists; } } class BadTable { constructor(path, options) { // options // key: name // values: // name, type, default, max length (string length) // types: // string (requires maxLength), // (u)int32 (maxLength = 4), (u)int16 (maxLength = 2), (u)int8 (maxLength = 1) // // default values: // default is equal to empty string or 0 // type is by default a string (requiring maxLength) // if default is set for type=string, the default maxLength is set to its' length + 2 // // example: /* { "key": "id", "values": [ { "name": "id", "maxLength": 10 }, { "name": "login", "maxLength": 32 }, { "name": "gamesPlayed", "type": "uint32" } ] } */ // Sections of database: // [len] Header, default row, [len] Values // len: Uint32 (little endian) if (options == null) { throw new Error("Options must be passed to the constructor"); } const key = options.key; if (key == null) { throw new Error("'key' must be present in the database"); } if (typeof key != "string") { throw new Error("'key' must be a string"); } const values = options.values; if (values == null) { throw new Error("'values' must be present in the database"); } if (values.constructor != [].constructor) { throw new Error("'values' must be an array"); } const lru_index_max = options.cacheIndex ?? 1024; if (typeof lru_index_max != "number" || lru_index_max < 0) { throw new Error("'cacheIndex' must be a non-negative number"); } const lru_data_max = options.cacheData ?? 64; if (typeof lru_data_max != "number" || lru_data_max < 0) { throw new Error("'cacheData' must be a non-negative number"); } let namesLength = 2; let headerLength = 4; let defaultsLength = 0; let keyLength; const dnames = new Set(); const newValues = []; for (const value of values) { if (value == null || value.constructor != {}.constructor) { throw new Error("The value must be an object"); } const name = value.name; if (dnames.has(name)) { throw new Error("The name '" + name + "' is a duplicate"); } dnames.add(name); namesLength += Buffer.from(name).byteLength + 1; // NULL byte ending const isKey = name == key; const v = PARSE_VALUE(value, isKey); if (isKey) { newValues.unshift({ name, ...v }); keyLength = v.maxLength; } else { newValues.push({ name, ...v }); defaultsLength += v.maxLength; } headerLength += 1 + 2; // type, maxLength } const rowLength = keyLength + defaultsLength; const names = Buffer.alloc(namesLength); let namesOffset = 2; // size of names const header = Buffer.alloc(headerLength); let headerOffset = 4; // size of header const defaults = Buffer.alloc(defaultsLength); let defaultsOffset = 0; const entries = {}; const keyData = { "name": key }; names.writeUint16LE(namesLength - 2, 0); header.writeUint32LE(headerLength - 4, 0); for (const { name, type, defaultValue, maxLength } of newValues) { namesOffset += names.utf8Write(name, namesOffset); names.writeUint8(0, namesOffset); namesOffset += 1; header.writeUint8(ID_OF_TYPE(type), headerOffset); headerOffset += 1; header.writeUint16LE(maxLength, headerOffset); headerOffset += 2; if (name == key) { keyData.type = type; keyData.maxLength = maxLength; } else { WRITE(defaults, type, defaultValue, defaultsOffset); entries[name] = { type, maxLength, defaultValue, "offset": defaultsOffset + keyLength }; defaultsOffset += maxLength; } } const magic = Buffer.from([0xB, 0xA, 0xD, 0xB]); const namesFOffset = magic.byteLength; const headerFOffset = magic.byteLength + namesLength; const defaultsFOffset = magic.byteLength + namesLength + headerLength; const dataFOffset = magic.byteLength + namesLength + headerLength + defaultsLength + 4; // data size let fd; let size = 0; if (fs.existsSync(path)) { fd = fs.openSync(path, "r+"); const magicOld = Buffer.alloc(magic.byteLength); fs.readSync(fd, magicOld, 0, magic.byteLength, 0); if (!magic.equals(magicOld)) { fs.closeSync(fd); throw new Error("The existing file is not a Bad Database"); } const namesOld = Buffer.alloc(namesLength); fs.readSync(fd, namesOld, 0, namesLength, namesFOffset); if (!names.equals(namesOld)) { fs.closeSync(fd); throw new Error("The names do not match"); } const headerOld = Buffer.alloc(headerLength); fs.readSync(fd, headerOld, 0, headerLength, headerFOffset); if (!header.equals(headerOld)) { fs.closeSync(fd); throw new Error("The header does not match"); } const defaultsOld = Buffer.alloc(defaultsLength); fs.readSync(fd, defaultsOld, 0, defaultsLength, defaultsFOffset); if (!defaults.equals(defaultsOld)) { fs.closeSync(fd); throw new Error("The default values do not match"); } const sizeBuffer = Buffer.alloc(4); fs.readSync(fd, sizeBuffer, 0, 4, dataFOffset - 4); size = sizeBuffer.readUint32LE(0); } else { fd = fs.openSync(path, "w+"); fs.writeSync(fd, magic, 0, magic.bytesLength, 0) fs.writeSync(fd, names, 0, namesLength, namesFOffset); fs.writeSync(fd, header, 0, headerLength, headerFOffset); fs.writeSync(fd, defaults, 0, defaultsLength, defaultsFOffset); fs.writeSync(fd, Buffer.alloc(4), 0, 4, dataFOffset - 4); } const lru_index = []; function saveSize() { const sizeBuffer = Buffer.alloc(4); sizeBuffer.writeUint32LE(size); fs.writeSync(fd, sizeBuffer, 0, 4, dataFOffset - 4); } function find(key, create) { for (let i = 0; i < lru_index.length; i ++) { const { "key": lkey, idx } = lru_index[i]; if (lkey == key) { if (i != 0) lru_index.unshift(lru_index.splice(i, 1)[0]); return idx; } } const keyBuffer = Buffer.alloc(keyData.maxLength); WRITE(keyBuffer, keyData.type, key, 0); const compareBuffer = Buffer.alloc(keyData.maxLength); for (let i = 0; i < size; i ++) { fs.readSync(fd, compareBuffer, 0, keyData.maxLength, dataFOffset + i * rowLength); if (keyBuffer.equals(compareBuffer)) { lru_index.unshift({ key, "idx": i }); if (lru_index.length > lru_index_max) lru_index.pop(); return i; } } if (!create) return -1; size += 1; saveSize(); lru_index.unshift({ key, "idx": size - 1 }); if (lru_index.length > lru_index_max) lru_index.pop(); return size - 1; } // key - the key of the element // data - the data row // raw - true if the element does not exist physically const lru_data = []; function load(key) { for (let i = 0; i < lru_data.length; i ++) { const { "key": lkey, raw, data } = lru_data[i]; if (lkey == key) { // bring to front if (i != 0) lru_data.unshift(lru_data.splice(i, 1)[0]); const obj = { ...data }; return { obj, "exists": true, raw }; } } const idx = find(key); if (idx == -1) { const obj = { }; for (const name in entries) { const { defaultValue } = entries[name]; obj[name] = defaultValue; } return { obj, "exists": false, "raw": true }; } const raw = false; const rowBuffer = Buffer.alloc(rowLength); fs.readSync(fd, rowBuffer, 0, rowLength, dataFOffset + idx * rowLength); const obj = { }; for (const name in entries) { const { type, offset } = entries[name]; obj[name] = READ(rowBuffer, type, offset); } lru_data.unshift({ key, raw, "data": { ...obj }}); if (lru_data.length > lru_data_max) { // save dropped element const { "key": lkey, data } = lru_data.pop(); save(lkey, data); } return { obj, raw, "exists": true }; }; function save(key, obj) { const rowBuffer = Buffer.alloc(rowLength); WRITE(rowBuffer, keyData.type, key, 0); for (const name in entries) { const { type, defaultValue, offset, maxLength } = entries[name]; WRITE(rowBuffer, type, obj[name] ?? defaultValue, offset); } const idx = find(key, true); fs.writeSync(fd, rowBuffer, 0, rowLength, dataFOffset + idx * rowLength); } function write(key, obj, raw) { for (let i = 0; i < lru_data.length; i ++) { const { "key": lkey } = lru_data[i]; if (lkey == key) { lru_data.splice(i, 1); lru_data.unshift({ key, raw, "data": { ...obj }}); return; } } lru_data.unshift({ key, raw, "data": { ...obj }}); if (lru_data.length > lru_data_max) { // save dropped element const { "key": lkey, data } = lru_data.pop(); save(lkey, data); } } function remove(key) { for (let i = 0; i < lru_data.length; i ++) { const { "key": lkey } = lru_data[i]; if (lkey == key) { lru_data.splice(i, 1); break; } } // if not physically exist, no need to remove const idx = find(key); if (idx == -1) return; // remove the element from index cache for (let i = 0; i < lru_index.length; i ++) { const { "key": lkey } = lru_index[i]; if (lkey == key) { lru_index.splice(i, 1); break; } } // if only one element, then it is the one we are removing if (size == 1) { size = 0; saveSize(); fs.ftruncateSync(fd, dataFOffset); return; } // when removing an element, swap with the last element // instead of rewriting everything and truncate the file // but don't swap if the removed row is last itself const lastOffset = dataFOffset + (size - 1) * rowLength; if (idx != size - 1) { const lastRow = Buffer.alloc(rowLength); fs.readSync(fd, lastRow, 0, rowLength, lastOffset); fs.writeSync(fd, lastRow, 0, rowLength, dataFOffset + idx * rowLength); // if the last row was in cache, update its' index const movedKey = READ(lastRow, keyData.type, 0); for (let i = 0; i < lru_index.length; i ++) { const { "key": lkey } = lru_index[i]; if (lkey == movedKey) { lru_index[i].idx = idx; break; } } } fs.ftruncateSync(fd, lastOffset); size --; saveSize(); } let closed = false; this.close = () => { if (closed) return; closed = true; for (const { key, data } of lru_data) { save(key, data); } fs.closeSync(fd); } process.once("exit", () => { this.close(); }); let fsLock = null; const keyLocks = {}; async function executeFS(callback) { const lock = fsLock ?? null; const newLock = new Promise(async res => { await lock; res(callback()); }); fsLock = newLock; return newLock; } this.size = () => { let unsaved = 0; for (const { raw } of lru_data) { if (raw) unsaved ++; } return size + unsaved; }; return new Proxy(this, { "get": (target, rkey) => { if (rkey in target) return target[rkey]; if (!PROVE_VALUE(keyData.type, keyData.maxLength, rkey)) { throw new Error("The value '" + rkey + "' does not fit into the key"); } const key = keyData.type == "string" ? rkey.toString() : parseInt(rkey); return async callback => { const lock = keyLocks[key] ?? null; const newLock = new Promise(async (res, rej) => { try { await lock; } catch { } try { const { obj, raw, exists } = await executeFS(() => load(key)); const old = { ...obj }; const control = new EntryControl(exists); const ret = await callback(obj, control); if (control.removed()) { if (exists) await executeFS(() => remove(key)); res(ret); return; } let same = true; const final = { }; for (const name in entries) { const { type, defaultValue, maxLength } = entries[name]; const value = obj[name] ?? defaultValue; if (!PROVE_VALUE(type, maxLength, value)) { throw new Error("The value '" + value + "' does not fit into the field '" + name + "'"); } if (value != old[name]) { same = false; } final[name] = value; } if (!same || (!exists && control.confirmed())) await executeFS(() => write(key, final, raw)); res(ret); } catch (error) { rej(error); } }); keyLocks[key] = newLock; return newLock; }; } }); } } class BadSet { constructor(path, options) { // options // similar to BadTable, but only one single value // type or maxLength is required if (options == null) { throw new Error("Options must be passed to the constructor"); } if (!("type" in options) && !("maxLength" in options)) { throw new Error("At least on of 'type' or 'maxLength' is required for a set"); } const v = { }; if ("type" in options) v.type = options.type; if ("maxLength" in options) v.maxLength = options.maxLength; const table = new BadTable(path, { "key": "$", "values": [{ "name": "$", ...v }], "cacheIndex": options.cacheIndex, "cacheData": options.cacheData }); this.has = async key => { return await table[key]((_, c) => c.exists()); }; this.add = async key => { return await table[key]((_, c) => c.confirm()); }; this.remove = async key => { return await table[key]((_, c) => c.remove()); } this.size = () => { return table.size(); }; this.close = () => table.close(); } } class BadArrayTable { constructor(path, options) { // options // similar to BadTable, but without key // key will be given by the class if (options == null) { throw new Error("Options must be passed to the constructor"); } const key = options.key; if ("key" in options) { throw new Error("'key' can not be chosen in the list"); } if (!("maxKeyLength" in options)) { throw new Error("The max key length must be specified in the list"); } const values = options.values; if (values == null) { throw new Error("'values' must be present in the database"); } if (values.constructor != [].constructor) { throw new Error("'values' must be an array"); } const opt = { "key": "$", "cacheIndex": options.cacheIndex, "cacheData": options.cacheData, "values": [ { "name": "$", "maxLength": options.maxKeyLength + 4 }, ...options.values ] }; const table = new BadTable(path, opt); this.size = () => { return table.size(); }; this.close = () => table.close(); const keyLocks = {}; const base = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!\"#$%&'()*+,-./:;<=>?@[]^_`{|}~"; const maxIndex = base.length ** 4; function num2idx(num) { if (num >= maxIndex) throw new Error("Invalid index: " + num); let idx = ""; while (num) { idx += base[num % base.length]; num = parseInt(num / base.length); } return idx.padStart(4, "0"); } return new Proxy(this, { "get": (target, key) => { if (key in target) return target[key]; return async callback => { const lock = keyLocks[key] ?? null; const newLock = new Promise(async (res, rej) => { try { await lock; } catch { } try { const array = []; let index = 0; while (true) { const a = num2idx(index); const ex = await table[a + key]((e, c) => { if (!c.exists()) return false; array.push(e); return true; }); if (!ex || index == maxIndex) break; index ++; } const aold = array.map(e => ({ ...e })); const ret = await callback(array); // handle updates and removal index = 0; for (const old of aold) { // removal if (index >= array.length) { const a = num2idx(index); await table[a + key]((_, c) => c.remove()); index ++; continue; } let same = true; const elem = array[index]; for (const name in old) { if (old[name] != elem[name]) { same = false; break; } } if (!same) { const a = num2idx(index); await table[a + key](e => { for (const name in elem) { e[name] = elem[name]; } }); } index ++; } // handle appends while (index < array.length && index <= maxIndex) { const elem = array[index]; const a = num2idx(index); await table[a + key](e => { for (const name in elem) { e[name] = elem[name]; } }); index ++; } res(ret); } catch (error) { rej(error); } }); keyLocks[key] = newLock; return newLock; }; } }); } } class BadArray extends Function { constructor(path, options) { super(); const array = new BadArrayTable(path, { ...options, "maxKeyLength": 0 }); this.size = () => { return array.size(); }; return new Proxy(this, { "apply": (target, thisArg, args) => { const callback = args[0]; return new Promise(async res => { res(await array[""](callback)); }); } }); } } function createSingleArray(path, options) { } module.exports = { BadTable, BadSet, BadArrayTable, BadArray };