@small-tech/jsdb
Version:
A zero-dependency, transparent, in-memory, streaming write-on-update JavaScript database for Small Web applications that persists to a JavaScript transaction log.
240 lines (195 loc) • 8.23 kB
JavaScript
/**
JSDB class.
Copyright ⓒ 2020-2021 Aral Balkan. Licensed under AGPLv3 or later.
Shared with ♥ by the Small Technology Foundation.
To use:
const db = new JSDB(databaseDirectory)
Like this? Fund us!
https://small-tech.org/fund-us
*/
import fs from 'node:fs'
import path from 'node:path'
import { log } from './Util.js'
import asyncForEach from './async-foreach.js'
import JSTable from './JSTable.js'
/**
Returns a proxy object, not an instance of the class.
@class
@returns {Proxy} Data proxy.
*/
export default class JSDB {
//
// Class.
//
static isBeingInstantiatedByFactoryMethod = false
/** @type Object<string, any> */
static openDatabases = {}
//
// Public.
//
/**
@typedef { new (...args: any[]) => any } AnyClass
@typedef {{
deleteIfExists?: boolean,
compactOnLoad?: boolean,
alwaysUseLineByLineLoads?: boolean,
classes?: Array<AnyClass>
}} DatabaseOptions
*/
/**
Returns a reference to the JSDB at the given basepath.
If it’s already open, returns the reference.
@param {string} basePath
@param {DatabaseOptions} [options={deleteIfExists: false, compactOnLoad: true, alwaysUseLineByLineLoads: false}]
*/
static open (basePath, options) {
basePath = path.resolve(basePath)
// If a database doesn’t exist at the given path, create one and open it.
if (this.openDatabases[basePath] == undefined) {
this.isBeingInstantiatedByFactoryMethod = true
this.openDatabases[basePath] = new this(basePath, options)
this.isBeingInstantiatedByFactoryMethod = false
}
return this.openDatabases[basePath]
}
//
// Private.
//
//
// Instance.
//
/** @type Array<any> */
tableDataProxies = []
/** @type {Array<string>} */
tableNames = []
/**
@package
@param {string} basePath
@param {DatabaseOptions} [options={deleteIfExist: false, compactOnLoad: true, alwaysUseLineByLineLoads: false}]
*/
constructor (basePath, options = {
deleteIfExists: false,
// JSTable options that can be overriden.
compactOnLoad: true,
alwaysUseLineByLineLoads: false
}) {
// This class can only be instantiated via the open() factory method.
// This is to ensure that multiple instances of the same database cannot be opened at the
// same time, thereby leading to data corruption.
if (!JSDB.isBeingInstantiatedByFactoryMethod) {
throw new Error('The JSDB class cannot be directly instantiated. Please use the JSDB.open() factory method instead.')
}
this.basePath = basePath
this.options = options
// Make sure all default options are set when options argument is provided.
if (this.options.deleteIfExists === undefined) this.options.deleteIfExists = false
if (this.options.compactOnLoad === undefined) this.options.compactOnLoad = true
if (this.options.alwaysUseLineByLineLoads === undefined) this.options.alwaysUseLineByLineLoads = false
this.dataProxy = new Proxy({}, this.proxyHandler)
if (options.deleteIfExists) {
log(` 💾 ❨JSDB❩ Fresh database requested at ${basePath}; existing database is being deleted.`)
fs.rmSync(basePath, {recursive: true, force: true})
}
if (fs.existsSync(basePath)) {
// Load any existing data there might be.
this.loadTables()
} else {
log(` 💾 ❨JSDB❩ No database found at ${basePath}; creating it.`)
fs.mkdirSync(basePath, {recursive: true})
}
// NB. We are returning the data proxy, not an instance of JSDB. Use accordingly.
// @ts-expect-error – Return type of constructor signature must be assignable to the instance type of the class.
return this.dataProxy
}
onTableDelete (tableDataProxy) {
const nameOfTableToRemove = tableDataProxy.__table__.tableName
log(` 💾 ❨JSDB❩ Removing table ${nameOfTableToRemove} from database…`)
this.tableDataProxies = this.tableDataProxies.filter(dataProxy => dataProxy !== tableDataProxy)
this.tableNames = this.tableNames.filter(tableName => tableName !== nameOfTableToRemove)
tableDataProxy.__table__.removeListener('delete', this.onTableDelete.bind(this))
this.dataProxy[nameOfTableToRemove] = null
log(` 💾 ❨JSDB❩ ╰─ Table in ${nameOfTableToRemove} removed from database.`)
}
loadTables () {
this.loadingTables = true
let tableFiles
tableFiles = fs.readdirSync(this.basePath)
tableFiles.filter(fileName => fileName.endsWith('.js')).forEach(tableFile => {
const tableName = tableFile.replace('.js', '')
const tablePath = path.join(this.basePath, tableFile)
const tableDataProxy = new JSTable(tablePath, /* data = */ null, {
compactOnLoad: this.options.compactOnLoad,
alwaysUseLineByLineLoads: this.options.alwaysUseLineByLineLoads,
classes: this.options.classes
})
tableDataProxy.__table__.addListener('delete', this.onTableDelete.bind(this))
this.dataProxy[tableName] = tableDataProxy
this.tableNames.push(tableName)
this.tableDataProxies.push(tableDataProxy)
})
this.loadingTables = false
}
get proxyHandler () {
return {
set: this.setHandler.bind(this),
get: this.getHandler.bind(this)
}
}
getHandler (target, property, receiver) {
// To close the database, we wait for all the tables to be closed and then remove the
// database’s path from the list of open databases so that it can be opened again in
// the future. Note that this is a trap on the returned dataProxy.
if (property === 'close') {
return async function () {
log(` 💾 ❨JSDB❩ Closing database at ${this.basePath}…`)
await asyncForEach(this.tableDataProxies, async tableDataProxy => {
await tableDataProxy.__table__.close()
})
JSDB.openDatabases[this.basePath] = null
log(` 💾 ❨JSDB❩ ╰─ Closed database at ${this.basePath}.`)
}.bind(this)
}
return Reflect.get(...arguments)
}
setHandler (target, property, value, receiver) {
//
// Only objects (including custom objects) and arrays are allowed at
// the root level. Each object/array in the root is considered a separate table
// (instance of JSTable) and is kept in its own JSON file. For a good reference
// on data types supported by JSON.stringify, see:
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
//
const typeOfValue = typeof value
if (value === undefined || value === null) {
// Setting a table to null or undefined is an internal feature. Do not
// use this directly as it will not properly delete the table. Call
// the async .delete() method on the table instead.
Reflect.set(target, property, value, receiver)
return true
}
['function', 'symbol', 'string', 'number', 'bigint'].forEach(forbiddenType => {
if (typeof value === forbiddenType) {
throw new TypeError(`You cannot create a table by setting a value of type ${forbiddenType} (${value}).`)
}
})
// If we’re initially loading tables, do not attempt to create a new table.
if (!this.loadingTables) {
if (this.tableNames.includes(property)) {
// Table already exists. You cannot replace it by setting a new value
// as this is a synchronous operation that involves closing the current
// writeStream, deleting the table, and creating a new table. To replace a table,
// you must first call the asynchronous db.tableName.__table__delete() method.
// return false
throw new Error(`Table ${property} already exists. To replace it, please first call await <database>.${property}.__table__.delete().`)
}
const tableFileName = `${property}.js`
const tablePath = path.join(this.basePath, tableFileName)
value = new JSTable(tablePath, value)
value.__table__.addListener('delete', this.onTableDelete.bind(this))
this.tableDataProxies.push(value)
this.tableNames.push(property)
}
Reflect.set(target, property, value, receiver)
return true
}
}