dbd.db
Version:
A Lightweight Schema-Free Object-Oriented LocalDatabase for Development and Production Purpose
1,061 lines (728 loc) • 22.1 kB
JavaScript
const fs = require('fs')
const fsp = fs.promises
const clonedeep = require('rfdc')()
const Util = require('../util')
const DBDError = require('./dbdError')
const Writer = require('./writer')
const DB = require('./db')
const Bison = require('../bison')
const { EventEmitter } = require('events')
const MAX_SIZE = 1018220
/**
* The hub to interact with the db Collections
* @extends {EventEmitter}
*/
class Collection extends EventEmitter {
/**
* @param {!DB} db The database
* @param {!String} name The collection name
* @param {!Number} [ttl] Make the collection support ttl or not
*/
constructor(db, opts) {
super()
this._waitForReady = []
this._waitForTTL = []
this.ready = false
this.closed = false
this.db = db
this.name = `${opts.name}.bison`
this.ttl = opts.ttl || 0
this.ttlCheck = null
this.ttlChecking = false
this.indexes = {}
this.ttls = {}
this.writer = new Writer(db, this)
this.once('ready', () => {
this.ready = true
let waitReady = 0
while (waitReady < this._waitForReady.length) {
const resolve = this._waitForReady.shift()
resolve()
waitReady++
}
if (this.ttl >= 5) {
this.on('ttl', async check => {
if (!check) {
let waitTTL = 0
while (waitTTL < this._waitForTTL.length) {
const resolve = this._waitForTTL.shift()
resolve()
waitTTL++
}
}
})
this.checkTTL = async () => {
const entries = Object.entries(this.ttls)
const expired = []
let i = 0
while (i < entries.length) {
if (entries[i][1] <= Date.now()) expired.push(entries[i][0])
i++
}
if (!expired.length) return
const indexes = expired.map(key => this.indexes[key])
const indexs = Object.entries(this.indexes)
const newIndexes = {}
const re = []
let id = 0
while (id < indexs.length) {
const index = expired.indexOf(indexs[id][0])
if (index >= 0) {
delete this.ttls[indexs[id][0]]
let idx
const reid = re.findIndex(ind => ind <= indexs[id][1])
if (reid < 0) idx = indexs[id][1]
else idx = indexs[id][1] - (re.length - reid)
re.push(idx)
this.emit('expired', this.db.cache.get(this.displayName)[idx])
this.db.cache.get(this.displayName).splice(idx, 1)
re.sort((a, b) => a - b)
id++
continue
}
const reid = re.findIndex(ind => ind <= indexs[id][1])
if (reid < 0) {
newIndexes[indexs[id][0]] = indexs[id][1]
id++
continue
}
newIndexes[indexs[id][0]] = indexs[id][1] - (re.length - reid)
id++
}
this.indexes = newIndexes
this.writer.emit('exec')
return
}
this.ttlCheck = setInterval(async () => {
if (this.ttlChecking) await new Promise(async resolve => {
this._waitForTTL.push(resolve)
setImmediate(() => {
if (!this.ttlChecking) resolve()
})
if (!this.ttlChecking) resolve()
})
this.emit('ttl', true)
await this.checkTTL()
this.emit('ttl', false)
}, this.ttl * 1000)
}
})
;(async () => {
if (!this.db.ready) await new Promise(res => {
this.db._waitForReady.push(res)
setImmediate(() => { if (this.db.ready) res() })
if (this.db.ready) res()
})
const path = `${this.db.name}/${this.displayName}`
const main = await fsp.open(`${path}.bison`, 'r+').catch(err => null)
const temp = await fsp.open(`${path}.tmp`, 'r+').catch(err => null)
if (!(main || temp)) {
this.db.cache.set(this.displayName, [])
return
}
const sz = await fsp.readFile(`${path}.sz`, 'utf8').catch(err => null)
let fh = main || temp
if (main && temp) {
if (sz && sz.length) {
const tmpStat = await temp.stat()
if (tmpStat.size == sz.split(' ')[0]) {
fh = temp
await main.close()
} else {
fh = main
await temp.close()
}
} else {
fh = main
await temp.close()
}
}
await fh.sync()
const stream = fs.createReadStream(null, { fd: fh.fd, encoding: 'utf8', autoClose: false })
await new Promise((res, rej) => {
let data = ''
stream.once('end', async () => {
stream.removeAllListeners()
await fh.close()
if (!data.length) {
this.db.cache.set(this.displayName, [])
return res()
}
data = Bison.decode(data)
let json
try {
if (sz && sz.length && sz.split(' ')[1] <= MAX_SIZE) {
json = JSON.parse(`[${data}]`)
} else {
json = await this.db.json.parse(`[${data}]`)
}
} catch(err) {
rej(new DBDError('Failed to deserialize data', 3))
}
if (!json) return
this.db.cache.set(this.displayName, json)
res()
})
stream.on('data', chunk => {
data += chunk
})
})
const col = this.db.cache.get(this.displayName)
for (let i = 0; i < col.length; ++i) {
const val = col[i]
this.indexes[val._index] = i
if (!isNaN(val._ttl) && this.ttl >= 5) {
this.ttls[val._index] = val._ttl
}
}
})().then(() => this.emit('ready'))
}
/**
* The collection name
* @returns {String}
*/
get displayName() {
return this.name.substr(0, this.name.length - 6)
}
/**
* The collection size
* @returns {Number}
*/
get size() {
if (this.closed) throw new DBDError('Collection is closed!', 10)
return this.db.cache.get(this.displayName).length
}
/**
* The condition if the Collection can be used
* @returns {Boolean}
*/
get isOpen() {
return (this.ready && !this.closed)
}
/**
* Destroy and delete the Collection
* @returns {Promise<Boolean>}
*/
async destroy() {
if (!this.ready) await new Promise(async resolve => {
this._waitForReady.push(resolve)
setImmediate(() => {
if (this.ready) resolve()
})
if (this.ready) resolve()
})
if (this.closed) throw new DBDError('Collection is closed!', 10)
this.closed = true
super.removeAllListeners()
if (this.ttlCheck) clearInterval(this.ttlCheck)
if (this.ttlChecking) await new Promise(async resolve => {
this._waitForTTL.push(resolve)
setImmediate(() => {
if (!this.ttlChecking) resolve()
})
if (!this.ttlChecking) resolve()
})
if (this.writer.run) await new Promise(async resolve => {
this.writer._waitForReady.push(resolve)
setImmediate(() => {
if (!this.writer.run) resolve()
})
if (!this.writer.run) resolve()
})
for (const promise of this.writer.promises) {
promise[1](new DBDError('Collection is closed!', 10))
}
await fs.promises.unlink(`${this.db.name}/${this.name}`).catch(err => { })
this.db.cache.delete(this.displayName)
this.db.collections.delete(this.displayName)
delete this
return true
}
/**
* Close the Collection, the data will be saved and remain in the Collection
* @returns {Promise<Boolean>}
*/
async close() {
if (!this.ready) await new Promise(async resolve => {
this._waitForReady.push(resolve)
setImmediate(() => {
if (this.ready) resolve()
})
if (this.ready) resolve()
})
if (this.closed) throw new DBDError('Collection is closed!', 10)
this.closed = true
super.removeAllListeners()
if (this.ttlCheck) clearInterval(this.ttlCheck)
if (this.ttlChecking) await new Promise(async resolve => {
this._waitForTTL.push(resolve)
setImmediate(() => {
if (!this.ttlChecking) resolve()
})
if (!this.ttlChecking) resolve()
})
if (this.writer.run) await new Promise(async resolve => {
this.writer._waitForReady.push(resolve)
setImmediate(() => {
if (!this.writer.run) resolve()
})
if (!this.writer.run) resolve()
})
for (const promise of this.writer.promises) {
promise[1](new DBDError('Collection is closed!', 10))
}
this.db.cache.delete(this.displayName)
this.db.collections.delete(this.displayName)
delete this
return true
}
/**
* Finds data in the Collection
* @param {!Object} filter The Filter to find the data
* @param {!Number} [max] Limit for the filter
* @return {Promise<Array>}
*/
async find(filter, max = Infinity) {
if (!this._isObject(filter)) throw new DBDError(`Data must be instanceof Object`, 9)
if (this.closed) throw new DBDError('Collection is closed!', 10)
await this._check(filter)
if (!this.ready) await new Promise(async resolve => {
this._waitForReady.push(resolve)
setImmediate(() => {
if (this.ready) resolve()
})
if (this.ready) resolve()
})
if (this.closed) throw new DBDError('Collection is closed!', 10)
if (filter._index !== undefined) {
const index = this.indexes[String(filter._index)]
const item = this.db.cache.get(this.displayName)[index]
return item ? [clonedeep(item)] : []
}
const find = await Util.find(this.db.cache.get(this.displayName), filter, max, this.db.json)
return find
}
/**
* Find one data in the Collection
* @param {!Object} filter The Filter to find the data
* @returns {Promise}
*/
async findOne(filter) {
if (!this._isObject(filter)) throw new DBDError(`Data must be instanceof Object`, 9)
if (this.closed) throw new DBDError('Collection is closed!', 10)
await this._check(filter)
if (!this.ready) await new Promise(async resolve => {
this._waitForReady.push(resolve)
setImmediate(() => {
if (this.ready) resolve()
})
if (this.ready) resolve()
})
if (this.closed) throw new DBDError('Collection is closed!', 10)
if (filter._index !== undefined) {
const index = this.indexes[String(filter._index)]
const item = this.db.cache.get(this.displayName)[index]
return item ? clonedeep(item) : item
}
const { item }= await Util.findOne(this.db.cache.get(this.displayName), filter)
return item ? clonedeep(item) : undefined
}
/**
* Method to set a data into collection
* @param {!Object} data The data to set
* @param {!Object} [filter] A filter to replace a data
* @returns {Promise<Boolean>}
*/
async set(data, filter) {
if (!this._isObject(data)) throw new DBDError('Data must be instanceof Object!', 9)
if (this.closed) throw new DBDError('Collection is closed!', 10)
await this._check(data)
if (!this.ready) await new Promise(async resolve => {
this._waitForReady.push(resolve)
setImmediate(() => {
if (this.ready) resolve()
})
if (this.ready) resolve()
})
if (this.ttlChecking) await new Promise(async resolve => {
this._waitForTTL.push(resolve)
setImmediate(() => {
if (!this.ttlChecking) resolve()
})
if (!this.ttlChecking) resolve()
})
if (this.closed) throw new DBDError('Collection is closed!', 10)
if (!this._isObject(filter)) {
if (data._index === undefined) {
let i = this.db.cache.get(this.displayName).length
while (this.indexes[i]) i++
data._index = String(i)
}
data._index = String(data._index)
if (typeof this.indexes[data._index] === 'number') throw new DBDError('Duplicated data found!', 11)
if (typeof data._ttl === 'number' && this.ttl >= 15) {
this.ttls[data._index] = String(data._ttl)
}
this.db.cache.get(this.displayName).push(data)
this.indexes[data._index] = this.db.cache.get(this.displayName).length - 1
await new Promise((res, rej) => {
this.writer.promises.push([res, rej])
this.writer.emit('exec')
})
return true
}
await this._check(filter)
if ('_index' in filter) {
const index = this.indexes[String(filter._index)]
if (!('_index' in data)) {
data._index = filter._index
}
data._index = String(data._index)
if (data._index !== String(filter._index) && typeof this.indexes[data._index] === 'number') throw new DBDError('Duplicated data found!', 11)
if (typeof data._ttl === 'number' && this.ttl >= 5) {
this.ttls[data._index] = String(data._ttl)
}
if (data._index !== filter._index) delete this.indexes[filter._index]
if (typeof index === 'number') {
this.db.cache.get(this.displayName)[index] = data
this.indexes[data._index] = index
} else {
this.db.cache.get(this.displayName).push(data)
this.indexes[data._index] = this.db.cache.get(this.displayName).length - 1
}
await new Promise((res, rej) => {
this.writer.promises.push([res, rej])
this.writer.emit('exec')
})
return true
}
const obj = await Util.findOne(this.db.cache.get(this.displayName), filter)
if (!('_index' in data)) {
if (obj.item) {
data._index = obj.item._index
} else {
let i = this.db.cache.get(this.displayName).length
while (!isNaN(this.indexes[i])) i++
data._index = String(i)
}
}
data._index = String(data._index)
if (!obj.item && typeof this.indexes[data._index] === 'number') throw new DBDError('Duplicated data found!', 11)
if (typeof data._ttl === 'number' && this.ttl >= 5) {
this.ttls[data._index] = String(data._ttl)
}
if (obj.item && obj.item._index !== data._index) delete this.indexes[obj.item._index]
if (typeof obj.index === 'number') {
this.db.cache.get(this.displayName)[obj.index] = data
this.indexes[data._index] = obj.index
} else {
this.db.cache.get(this.displayName).push(data)
this.indexes[data._index] = this.db.cache.get(this.displayName).length - 1
}
await new Promise((res, rej) => {
this.writer.promises.push([res, rej])
this.writer.emit('exec')
})
return true
}
/**
* Method to delete a data from the collection
* @param {!Object} filter The filter of the data that will be deleted
* @param {!Number} [max] The maximum data to delete
* @returns {Promise<Boolean>}
*/
async delete(filter, max = Infinity) {
const result = await this.find(filter, max)
if (this.ttlChecking) await new Promise(async resolve => {
this._waitForTTL.push(resolve)
setImmediatet(() => {
if (!this.ttlChecking) resolve()
})
if (!this.ttlChecking) resolve()
})
if (!result.length) return false
if (result.length === 1 && this.db.cache.get(this.displayName)[this.indexes[result[0]._index]] === this.db.cache.get(this.displayName).length - 1) {
this.db.cache.get(this.displayName).pop()
delete this.indexes[result[0]._index]
delete this.ttls[result[0]._index]
await new Promise((res, rej) => {
this.writer.promises.push([res, rej])
this.writer.emit('exec')
})
return true
}
const indexes = []
const ids = []
let id = 0
while (id < result.length) {
ids.push(result[id]._index)
indexes.push(this.indexes[result[id]._index])
id++
}
const indexs = Object.entries(this.indexes)
const newIndexes = {}
const re = []
let i = 0
while (i < indexs.length) {
const index = ids.indexOf(indexs[i][0])
if (index >= 0) {
let idx
const reid = re.findIndex(ind => ind <= indexs[i][1])
if (reid < 0) idx = indexs[i][1]
else idx = indexs[i][1] - (re.length - reid)
re.push(idx)
delete this.ttls[indexs[i][0]]
this.db.cache.get(this.displayName).splice(idx, 1)
re.sort((a, b) => a - b)
i++
continue
}
const reid = re.findIndex(ind => ind <= indexs[i][1])
if (reid < 0) {
newIndexes[indexs[i][0]] = indexs[i][1]
i++
continue
}
newIndexes[indexs[i][0]] = indexs[i][1] - (re.length - reid)
i++
}
this.indexes = newIndexes
await new Promise((res, rej) => {
this.writer.promises.push([res, rej])
this.writer.emit('exec')
})
return true
}
/**
* Update filtered data property value
* @param {!Object} filter The filter to the data
* @param {string[]} property The property to access
* @param {any} value The value to give
* @returns {Boolean}
*/
async update(filter, property, value, object = false) {
if (!this._isObject(filter)) throw new DBDError('Data must be instanceof Object!', 9)
if (this.closed) throw new DBDError('Collection is closed!', 10)
if (!Array.isArray(property)) property = [property]
await this._check(filter)
if (typeof value === 'function')
throw new DBDError('Function is not allowed!', 7)
else if (value instanceof Object)
await this._check(value)
if (!this.ready) await new Promise(async resolve => {
this._waitForReady.push(resolve)
setImmediate(() => {
if (this.ready) resolve()
})
if (this.ready) resolve()
})
if (this.ttlChecking) await new Promise(async resolve => {
this._waitForTTL.push(resolve)
setImmediate(() => {
if (!this.ttlChecking) resolve()
})
if (!this.ttlChecking) resolve()
})
let data = {}
if (filter._index !== undefined) {
const index = this.indexes[String(filter._index)]
if (isNaN(index)) return false
data = this.db.cache.get(this.displayName)[index]
} else {
const item = await Util.findOne(this.db.cache.get(this.displayName), filter)
if (!item.item) return false
data = item.item
}
if (property[0] === '_index' && property.length === 1 && data._index !== String(value)) throw new DBDError('Duplicated data found!', 11)
let dataCheck = data
let i = 0
while (i < property.length) {
if (!(dataCheck instanceof Object)) {
if (!object)
throw new DBDError('Data must be instanceof Object!', 9)
else {
eval(`data[\`${property.slice(0, i).join('`][`')}\`] = {}`)
dataCheck = {}
}
}
dataCheck = dataCheck[String(property[i])]
i++
}
if (i === 1 && property[0] === '_index') {
value = String(value)
}
eval(`data[\`${property.join('`][`')}\`] = value`)
if (!isNaN(data._ttl) && this.ttl >= 5)
this.ttls[data._index] = String(data._ttl)
await new Promise((res, rej) => {
this.writer.promises.push([res, rej])
this.writer.emit('exec')
})
return true
}
/**
* Delete some data in the collection
* @param {...Object} filters The filter of data to delete
* @returns {Promise<Boolean}
*/
async deleteMany(...filters) {
if (this.closed) throw new DBDError('Collection is closed!', 10)
if (!this.ready) await new Promise(async resolve => {
this._waitForReady.push(resolve)
setImmediate(() => {
if (this.ready) resolve()
})
if (this.ready) resolve()
})
if (this.ttlChecking) await new Promise(async resolve => {
this._waitForTTL.push(resolve)
setImmediate(() => {
if (!this.ttlChecking) resolve()
})
if (!this.ttlChecking) resolve()
})
const keys = []
const deleted = []
const indexed = []
const length = filters.length
filters = [...filters]
let c = 0
while (c < length) {
if (!this._isObject(filters[c])) throw new DBDError('Data must be instanceof Object!', 9)
await this._check(filters[c])
if (filters[c]._index !== undefined) {
const index = this.indexes[filters[c]._index]
indexed.push(c)
if (isNaN(index)) {
c++
continue
}
deleted.push(this.db.cache.get(this.displayName)[index])
} else {
keys.push(Object.keys(filters[c]))
}
c++
}
const indexsLength = indexed.length
let ind = 0
while (ind < indexsLength) {
filters.splice(indexed[ind], 1)
ind++
}
const datas = this.db.cache.get(this.displayName)
if (deleted.length !== length && filters.length) {
const isDeleted = {}
if (datas.length >= filters.length) {
let i = 0
while (i < datas.length) {
let a = 0
while (a < filters.length) {
if (isDeleted[a]) {
a++
continue
}
const b = await Util.isEqual(filters[a], keys[a], datas[i])
a++
if (!b) continue
isDeleted[a] = true
deleted.push(datas[i])
break
}
i++
}
} else {
let i = 0
while (i < filters.length) {
if (deleted.length === length) break
let a = 0
while (a < datas.length) {
if (isDeleted[a]) {
a++
continue
}
const b = await Util.isEqual(filters[i], keys[i], datas[a])
a++
if (!b) continue
isDeleted[a] = true
deleted.push(datas[a])
break
}
i++
}
}
}
if (!deleted.length) return false
if (deleted.length === 1 && datas[this.indexes[deleted[0]._index]] === datas.length - 1) {
this.db.cache.get(this.displayName).pop()
delete this.indexes[deleted[0]._index]
delete this.ttls[deleted[0]._index]
await new Promise((res, rej) => {
this.writer.promises.push([res, rej])
this.writer.emit('exec')
})
return true
}
const indexes = []
const ids = []
let id = 0
while (id < deleted.length) {
ids.push(deleted[id]._index)
indexes.push(this.indexes[deleted[id]._index])
id++
}
const indexs = Object.entries(this.indexes)
const newIndexes = {}
const re = []
let i = 0
while (i < indexs.length) {
const index = ids.indexOf(indexs[i][0])
if (index >= 0) {
let idx
const reid = re.findIndex(ind => ind <= indexs[i][1])
if (reid < 0) idx = indexs[i][1]
else idx = indexs[i][1] - (re.length - reid)
re.push(idx)
delete this.ttls[indexs[i][0]]
this.db.cache.get(this.displayName).splice(idx, 1)
re.sort((a, b) => a - b)
i++
continue
}
const reid = re.findIndex(ind => ind <= indexs[i][1])
if (reid < 0) {
newIndexes[indexs[i][0]] = indexs[i][1]
i++
continue
}
newIndexes[indexs[i][0]] = indexs[i][1] - (re.length - reid)
i++
}
this.indexes = newIndexes
await new Promise((res, rej) => {
this.writer.promises.push([res, rej])
this.writer.emit('exec')
})
return true
}
/**
* Check the data if it contains function
* @param {!object} data The data to check
*/
_check(data) {
const keys = Object.keys(data)
let i = 0
while (i < keys.length) {
if (data[keys[i]] instanceof Object) {
this._check(data[keys[i]])
}
if (typeof data[keys[i]] === 'function') {
throw new DBDError('Function is not allowed!', 7)
}
i++
}
}
_isObject(data) {
return data instanceof Object && !Buffer.isBuffer(data) && !Array.isArray(data) && !(data instanceof RegExp)
}
}
module.exports = Collection