abyjs.db
Version:
abyjs.db - A Database with Speed and Optimization.
661 lines (503 loc) • 22.8 kB
JavaScript
const TableOptions = require("../utils/TableOptions")
const DatabaseError = require("./DatabaseError")
const Data = require("./Data")
const QueueManager = require("../utils/QueueManager")
const fs = require("fs")
/**
* The tables used for the database.
* @type {DatabaseTable}
*/
module.exports = class DatabaseTable {
constructor(options = TableOptions, db) {
/**
* The database this table belongs to.
* @type {Database}
* @property
*/
this.db = db
this._resolve(options)
/**
* The files this table is currently managing.
* @type {Map<string, number>}
* @property
*/
this.files = new Map()
/**
* The cache manager of this table.
* @type {Map<string, string>}
* @property
*/
this.cache = new Map()
/**
* The queue manager for this table, see `QueueManager` for more information.
* @type {object}
* @property
*/
this.queue = {...QueueManager}
}
/**
* Resolves the options that were passed to this table.
* @type {function}
* @param options {object} the options to assign to this table.
* @return {?DatabaseError} The error that occurred while setting options, if any.
*/
_resolve(opts) {
for (const [name, value] of Object.entries(TableOptions)) {
this[name] = opts[name]
if (!this[name]) this[name] = value
}
if (!this.name) throw new DatabaseError(this.db.Errors.TABLE_NAME)
}
/**
* Resolves a path to a table file by using the file name.
* @type {function}
* @param file {string} the name of the file to resolve
* @return {string} the full path to this file.
*/
_path(file) {
return this.db._path(this.name, file.includes(this.db.path) ? file.slice(this.dir.length).split(".")[0] : file.split(".")[0])
}
/**
* Returns a list of files that are currently available to the table to write in.
* @property
* @return {array<string>} the file names
*/
get _availableFiles() {
const files = []
for (const file of [...this.files.entries()]) {
if (file[1] < this.db.maxFileData) {
files.push(file[0])
}
}
if (!files.length) {
const path = this._assert()
files.push(path)
}
return files
}
/**
* Caches all the files and their respective size, will also cache routes if `cacheRouters` is set to true.
* @type {function}
*/
_cacheFiles() {
let anyAvailable = false
const start = Date.now()
this.db._debug("Starting to cache files for table", this.name, "...")
for (const file of fs.readdirSync(this.dir)) {
this.db._debug(`Loading file ${file} for table ${this.name}...`)
const start = Date.now()
const fileData = this.db._marshal(fs.readFileSync(this._path(file), this.db.Adapters))
if (this.db.cacheRouters) {
for (const key of Object.keys(fileData)) {
this.db.routers.set(`${this.name}/${key}`, this._path(file))
}
}
const length = Object.keys(fileData).length
if (length < this.db.maxFileData) {
this.anyAvailable = true
}
this.files.set(file, length)
this.db._debug(`Finished file ${file} for table ${this.name} in ${Date.now() - start}ms.`)
}
if (!this.anyAvailable) {
this._assert()
}
this.db._debug(`Finished loading table ${this.name} in ${Date.now() - start}ms.`)
}
/**
* Returns the current directory path for this table.
* @return {string} the path to the table.
* @property
* @type {function}
*/
get dir() {
return `${this.db.path}${this.name}/`
}
/**
* Checks whether this table has a unique folder.
* @type {function}
*/
_create() {
if (!fs.existsSync(this.dir)) {
fs.mkdirSync(this.dir)
}
}
/**
* Creates a new file in this table.
* @type {function}
*/
_assert() {
const next = this.files.size + 1
const path = this._path(this.schemeName(next))
fs.writeFileSync(path, "{}", this.db.Adapters)
this.files.set(this.schemeName(next), 0)
this.db._debug(`Created file to path ${path}`)
return this.schemeName(next)
}
/**
* Returns the next file name for this table.
* @type {function}
* @return {string} the name for the next file in this table.
*/
schemeName(l) {
return `${this.name}_scheme_${l}`
}
/**
* Connects the table to the database.
* Will emit a table ready event upon successful connection.
* @type {function}
*/
_start() {
this.db._debug(`Starting table ${this.name}...`)
this._create()
this._cacheFiles()
this.db._debug(`Table ${this.name} ready`)
this.db.emit(this.db.Events.TABLE_READY, this)
}
/**
* Sends a request to save data into the table database.
* @type {function}
* @param data {<Data|object>} body for the data to save, this requires a resolve parameter that will be called upon successful data.
* @return {Promise<boolean>} Whether the data was successfully saved.
*/
async _set(data) {
if (data) {
data.resolve(true)
this.queue.set.push(data)
this._update(data.toJSON())
}
if (this.queue.status.set) return undefined
else this.queue.status.set = true
await new Promise(e => setTimeout(e, this.db.saveTime))
const start = Date.now()
const items = this.queue.set.length
this.db._debug(`Starting to save ${items} items into database to table ${this.name}...`)
const fileCache = new Map()
const fileReaderCache = new Map()
let availableFiles = this._availableFiles
for (const _ of this.queue.set) {
const d = this.queue.set.shift()
//for router cache
if (this.db.cacheRouters) {
const route = `${this.name}/${d.key}`
if (this.db.routers.has(route)) {
const path = this.db.routers.get(route)
const file = fileCache.get(path) || this.db._marshal(fs.readFileSync(path, this.db.Adapters))
if (!fileCache.has(path)) fileCache.set(path, file)
fileCache.get(path)[d.key] = d.toJSON()
if (this.db.forceSave) {
d.resolve(true)
}
} else {
let val = this.files.get(availableFiles[0])
const file = fileCache.get(availableFiles[0]) || this.db._marshal(fs.readFileSync(this._path(availableFiles[0]), this.db.Adapters))
if (!fileCache.has(availableFiles[0])) fileCache.set(availableFiles[0], file)
if (typeof val !== "number") throw new DatabaseError(`Fatal error: ${val} did not match ${availableFiles[0]}.`)
fileCache.get(availableFiles[0])[d.key] = d.toJSON()
val++
this.db.routers.set(`${this.name}/${d.key}`, this._path(availableFiles[0]))
this.files.set(availableFiles[0], val)
if (val >= this.db.maxFileData) {
availableFiles.shift()
if (!availableFiles.length) {
availableFiles = this._availableFiles
}
}
}
} else {
//routers not cached
let pass = false
for (const fileName of fs.readdirSync(this.dir)) {
const reader = fileReaderCache.get(fileName) || fs.readFileSync(this._path(fileName), this.db.Adapters)
if (!fileReaderCache.has(fileName)) fileReaderCache.set(fileName, reader)
if (this.db._readFrom(reader, d.key)) {
const json = fileCache. get(fileName) || this.db._marshal(reader)
if (!fileCache.has(fileName)) fileCache.set(fileName, json)
json[d.key] = d.toJSON()
fileCache.set(fileName, json)
pass = true
break
} else {
continue
}
}
//could not find key in any file.
if (pass) continue
if (!pass) {
const fileName = availableFiles[0]
const json = fileCache.get(fileName) || this.db._marshal(fs.readFileSync(this._path(fileName), this.db.Adapters))
if (!fileCache.has(fileName)) fileCache.set(fileName, json)
json[d.key] = d.toJSON()
let val = this.files.get(fileName)
val++
this.files.set(fileName, val)
if (val >= this.db.maxFileData) {
availableFiles.shift()
if (!availableFiles.length) {
availableFiles = this._availableFiles
}
}
}
}
}
for (const [file, data] of [...fileCache.entries()]) {
fs.writeFileSync(this._path(file), this.db._unmarshal(data))
}
this.db._debug(`Saved ${items} items in ${Date.now() - start}ms to table ${this.name}.`)
this.db._debug(`There are now ${this.queue.set.length} items to set into table ${this.name}.`)
this.queue.status.set = false
if (this.queue.set.length) return this._set()
}
/**
* Requests the table to retrieve information on given key.
* @type {function}
* @param data {object} the object body to get the data from, this requires a valid key and a resolve parameter that'll be called upon data retrieved.
* @return {Promise<?Data>} the data that was found in the database, if any.
*/
async _get(data) {
if (data && this.db.cacheRouters) {
if (!this.db.routers.has(`${this.name}/${data.key}`)) {
return data.resolve(undefined)
}
}
if (data && this.cache.has(data.key)) {
const d = this.cache.get(data.key)
d.resolve = data.resolve
return this._send(d)
} else {
if (data) {
this.queue.get.push(data)
}
}
if (this.queue.status.get) return undefined
else this.queue.status.get = true
await new Promise(e => setTimeout(e, this.db.getTime))
const start = Date.now()
const items = this.queue.get.length
this.db._debug(`Getting ${items} items from table ${this.name}...`)
const fileCache = new Map()
const fileReaderCache = new Map()
for (const _ of this.queue.get) {
const d = this.queue.get.shift()
if (this.db.cacheRouters) {
const route = this.db.routers.get(`${this.name}/${d.key}`)
if (!route) {
d.resolve(undefined)
continue
}
const file = fileCache.get(route) || this.db._marshal(fs.readFileSync(this._path(route), this.db.Adapters))
if (!fileCache.has(route)) fileCache.set(route, file)
const raw = file[d.key]
if (!raw) {
d.resolve(undefined)
continue
}
this._update(raw, "set")
raw.resolve = d.resolve
this._send(raw)
} else {
//uncached routers
//loop over all files
let found = false
for (const fileName of fs.readdirSync(this.dir)) {
const file = fileReaderCache.get(fileName) || fs.readFileSync(this._path(fileName), this.db.Adapters)
if (!fileReaderCache.has(fileName)) fileReaderCache.set(fileName, file)
if (!this.db._readFrom(file, d.key)) {
continue
} else {
found = true
const json = fileCache.get(fileName) || this.db._marshal(file)
if (!fileCache.has(fileName)) fileCache.set(fileName, json)
const data = json[d.key]
if (!data) {
d.resolve(undefined)
break
}
data.resolve = d.resolve
this._update(data)
this._send(data)
break
}
}
if (!found) {
d.resolve(undefined)
continue
}
}
}
this.db._debug(`Finished getting ${items} items in ${Date.now() - start}ms.`)
this.queue.status.get = false
if (this.queue.get.length) this._get()
}
/**
* Sends the request back to the client with the requested key.
* @type {function}
* @param data {object} the data for this key.
* @param onlyCheck {?boolean} Whether the client should just check if the data is ready to expire.
* @return {<boolean|?Data>} The requested data, if any.
*/
_send(data, onlyCheck = false) {
if (onlyCheck) {
return data.ttl && data.ttl - Date.now() < 1
}
const d = new Data(data, this.db, "get")
if (d.ttl !== undefined && d.ttl - Date.now() < 1) {
new Promise((resolve, reject) => {
this._delete({
key: d.key,
resolve,
reject
})
})
return data.resolve(undefined)
}
data.resolve(d)
}
/**
* Requests the table to pull all the data from this table, will also cache results if `cacheRouters` is set to true.
* @type {function}
* @param data {object} the data body for this request, it can optionally include a filter.
* @return {Promise<array<Data>>} all the data of this table.
*/
async _all(data) {
if (data) {
if (!data.filter) data.filter = () => true
this.queue.all.push(data)
}
if (this.queue.status.all) return
else this.queue.status.all = true
await new Promise(e => setTimeout(e, this.db.allTime))
let all = {}
for (const path of [...this.files.keys()]) {
const raw = this.db._marshal(fs.readFileSync(this._path(path), this.db.Adapters))
all = {...all, ...raw}
}
all = Object.entries(all).map(array => {
const [key, data] = array
if (this._send(data, true)) {
new Promise((resolve, reject) => {
this._delete({
resolve,
reject,
key
})
})
return undefined
} else {
this._update(data, "set")
return {
key,
data
}
}
}).filter(a => a)
for (const _ of this.queue.all) {
const d = this.queue.all.shift()
if (typeof d.options.filter === "function") {
all = all.filter(d.options.filter)
}
d.resolve(all)
}
this.queue.status.all = false
if (this.queue.all.length) this._all()
}
/**
* Request the table to elete a key from the table database.
* @param data {object} object body for the delete call.
* @type {function}
* @return {Promise<boolean>} Whether the key was successfully deleted.
*/
async _delete(data) {
if (data) {
if (this.db.cacheRouters && !this.db.routers.has(`${this.name}/${data.key}`)) {
data.resolve(false)
return
} else if (this.db.cacheRouters) {
this._update(data.key, "delete")
if (!this.db.force) data.resolve(true)
data.route = this.db.routers.get(`${this.name}/${data.key}`)
data.route = data.route.split("/")
data.route = data.route[data.route.length - 1]
this.db.routers.delete(`${this.name}/${data.key}`)
this.queue.delete.push(data)
} else if (!this.db.cacheRouters) {
this._update(data.key, "delete")
this.queue.delete.push(data)
if (!this.db.force) data.resolve(true)
} else throw new Error("Unknown")
}
if (this.queue.status.delete) return
else this.queue.status.delete = true
await new Promise(e => setTimeout(e, this.db.deleteTime))
const start = Date.now()
const items = this.queue.delete.length
this.db._debug(`Starting to delete ${items} items from the table ${this.name}...`)
const fileCache = new Map()
const fileReaderCache = new Map()
for (const _ of this.queue.delete) {
const d = this.queue.delete.shift()
if (this.db.cacheRouters) {
if (!d.route) {
continue
}
console.log(d.route)
const file = fileCache.get(d.route) || this.db._marshal(fs.readFileSync(this._path(d.route), this.db.Adapters))
let val = this.files.get(d.route)
val--
this.files.set(d.route, val)
if (!fileCache.has(d.route)) fileCache.set(d.route, file)
delete file[d.key]
if (this.db.force) {
d.resolve(true)
}
fileCache.set(d.route, file)
} else {
//uncached routers
let pass = false
for (const fileName of fs.readdirSync(this.dir)) {
const file = fileReaderCache.get(fileName) || fs.readFileSync(this._path(fileName), this.db.Adapters)
if (!fileReaderCache.has(fileName)) fileReaderCache.set(fileName, file)
if (this.db._readFrom(file, d.key)) {
const json = fileCache.get(fileName) || this.db._marshal(file)
if (!fileCache.has(fileName)) fileCache.set(fileName, json)
delete json[d.key]
if (this.db.force) {
d.resolve(true)
}
let val = this.files.get(fileName)
val--
this.files.set(fileName, val)
fileCache.set(fileName, json)
pass = true
break
} else {
continue
}
}
if (!pass && this.db.force) {
d.resolve(false)
}
}
}
if (fileCache.size) {
for (const [path, data] of [...fileCache.entries()]) {
fs.writeFileSync(this._path(path), this.db._unmarshal(data))
}
}
this.db._debug(`Finished deleting ${items} items from table ${this.name} in ${Date.now() - start}ms`)
this.queue.status.delete = false
if (this.queue.delete.length) this._delete()
}
/**
* Controls cache updates in this table.
* @type {function}
* @param data {<Data|object>} the data to update or add to cache.
* @param method {?string} the method calling this function.
*/
_update(data, method = "set") {
this.cache[method](data.key || data, data)
if (this.cache.size >= this.db.cacheMaxSize) {
this.cache.delete(this.cache.values().next().value.key)
}
}
}