@memsdb/core
Version:
A simple embedded document based database with advanced querying, advanced population/tree creation, and multiple storage and backup providers.
432 lines • 17.8 kB
JavaScript
import { v4 } from 'uuid';
import { cloneDeep, merge } from 'smoldash';
import { updateDocIndex } from './utils/Indexed';
import { updateReactiveIndex } from './utils/ReactiveIndex';
import { nestedKey } from './utils/NestedKey';
import { debounce } from './utils/Debounce';
import { populate } from './Populate';
/**
* Class for creating structured documents
* @category Core
*/
export class DBDoc {
/**
* Construct a new Document with the collections schema and any provided data
* @param data Data to be assigned to the document schema
* @param collection Reference to the parent collection
*/
constructor(data, collection, id = v4(), isCloned = false, _createdAt = Date.now(), _updatedAt = Date.now()) {
this.isCloned = false;
/** Reference to indexed data for repeated deep data matching */
this.indexed = new Map();
/** Object for any plugin related data */
this._pluginData = new Map();
this.dataCache = new Map();
this.updatePathsCache = new Map();
this.updateIndexes = debounce((path) => {
const _ = this.doc_.extend(`updateIndexes`);
/* DEBUG */ _('Document was modified at path %s', path);
this._updatedAt = Date.now();
if (this.indexed.size > 0) {
/* DEBUG */ _('Indexes in collection, updating each collection index');
for (const key in this.indexed) {
updateDocIndex(this, key);
_('Updated index "%s" for document %s', key, this.id);
}
/* DEBUG */ _('Finished updating %d indexes', this.indexed.size);
}
if (this.collection.reactiveIndexed.size > 0) {
/* DEBUG */ _('Updating reactive indexes');
for (const key of this.collection.reactiveIndexed.keys()) {
updateReactiveIndex(this.collection, key);
/* DEBUG */ _('Updated collection reactive index for key %j', key);
}
/* DEBUG */ _('Finished updating %d reactive indexes', this.collection.reactiveIndexed.size);
}
/* DEBUG */ _('Emitting event "EventCollectionDocumentUpdated"');
this.collection.emitEvent({
event: 'EventCollectionDocumentUpdated',
doc: this,
collection: this.collection,
paths: this.updatePathsCache,
});
this.updatePathsCache.clear();
}, 300);
/**
* Object with functions for handling plugin data
*/
this.pluginData = {
/**
* Get the data object from a specific plugin
* @param plugin Plugin name to get data of
* @returns Data from the plugin
*/
get: (plugin) => {
const _ = this.doc_.extend('pluginData:get');
const data = this._pluginData.get(plugin);
/* DEBUG */ _('Data retrieved for plugin `%s`', plugin);
return data;
},
/**
* Set/replace the data object for a plugin
* @param plugin Plugin name to set data to
* @param data Data to replace the plugin data with
*/
set: (plugin, data) => {
const _ = this.doc_.extend('pluginData:set');
this._pluginData.set(plugin, data);
/* DEBUG */ _('Data set for plugin `%s`', plugin);
},
/**
* Delete the data object of a specific plugin
* @param plugin Plugin name to delete data of
*/
delete: (plugin) => {
const _ = this.doc_.extend('pluginData:delete');
this._pluginData.delete(plugin);
/* DEBUG */ _('Deleted plugin data for plugin `%s`', plugin);
},
};
this.collection = collection;
// Ensure the document has a valid and unique ID
this.id = id;
const _ = (this.doc_ = collection.col_.extend(`<doc>${this.id}`));
this.isCloned = isCloned;
// Ensure this.data is a replica of the schema before assigning the new data
this.setData(merge(cloneDeep(this.collection.schema), cloneDeep(data)), true);
this.pluginData.set('internal:subscriptions', new Map());
this._createdAt = _createdAt;
this._updatedAt = _updatedAt;
// Assign the data to the new document
/* DEBUG */ _('Document %s constructed', this.id);
}
/**
* Listen to changes on a specific key
* @param key Key to listen to changes on
* @param func Function to run when changes occur
*/
subscribe(key, func) {
const subscriptionMap = this.pluginData.get('internal:subscriptions');
const subscribedKey = subscriptionMap.get(key);
if (subscribedKey) {
subscribedKey.push(func);
}
else {
subscriptionMap.set(key, [func]);
}
}
/**
* Remove all subscribed functions for a specified key
* @param key Key to stop listening to
*/
unsubscribe(key) {
const subscriptionMap = this.pluginData.get('internal:subscriptions');
subscriptionMap.delete(key);
}
/**
* The data of the document as provided by the storage provider
*/
get data() {
const _ = this.doc_.extend('data:get');
let data;
const details = {
_createdAt: this._createdAt,
_updatedAt: this._updatedAt,
id: this.id,
};
const cached = this.dataCache.get('root');
if (cached) {
/* DEBUG */ _('dataCache present, returning cache');
return { ...cached, ...details };
}
if (this.isCloned) {
/* DEBUG */ _('Document is cloned, retrieving internal cloned data');
data = this.pluginData.get('internal:cloned');
/* DEBUG */ _('Retreived internal cloned data');
}
else {
/* DEBUG */ _('Retrieving data from storage engine');
data = this.collection.db.storageEngine.load(this);
/* DEBUG */ _('Data retrieved from storage engine');
}
/* DEBUG */ _('Setting dataCache');
this.dataCache.set('root', data);
/* DEBUG */ _('Initialising dataCache auto-delete countdown');
setTimeout(() => {
/* DEBUG */ _('Clearing dataCache');
this.dataCache.delete('root');
}, 500);
/* DEBUG */ _('Returning data');
return { ...data, ...details };
}
/**
* Set the value of a key in the doc to a specified value.
*
* **This should only be done on shallow key values**, lest you want keys like
* 'key1.key2.key3' as object keys in your data
* @param key Key to set the value of
* @param data Data to set to the afformentioned key
* @returns Returns nothing
*/
set(key, data) {
this.updatePathsCache.set(key, data);
const _ = this.doc_.extend('data:set');
const docData = this.data;
const subscriptionMap = this.pluginData.get('internal:subscriptions');
const keySubscriptions = subscriptionMap.get(key);
const rootSubscriptions = subscriptionMap.get('root');
/* DEBUG */ _('Setting key `%s`');
docData[key] = data;
if (this.isCloned) {
/* DEBUG */ _('Document is a clone, not saving or updating document or indexes');
this.pluginData.set('internal:cloned', docData);
}
else {
/* DEBUG */ _('Saving document');
this.collection.db.storageEngine.save(this, docData);
/* DEBUG */ _('Finished saving document, Updating indexes');
this.updateIndexes(key);
/* DEBUG */ _('Finished updating indexes. Applying key and data to key and root subscriptions');
if (keySubscriptions)
keySubscriptions.forEach(func => func(key, data));
if (rootSubscriptions)
rootSubscriptions.forEach(func => func(key, data));
/* DEBUG */ _('Finished running subscriptions');
}
this.dataCache.delete('root');
}
/**
* Set the root of the data object.
*
* This will completely replace the data object
* @param data Data to set
*/
setData(data, initial = false) {
const _ = this.doc_.extend('data:setData');
this.updatePathsCache.set('root', data);
if (this.isCloned) {
/* DEBUG */ _('Document is a clone, setting internal data instead of to storage engine');
this.pluginData.set('internal:cloned', data);
/* DEBUG */ _('Finished setting document plugin data');
}
else {
/* DEBUG */ _('Saving document');
this.collection.db.storageEngine.save(this, data);
if (!initial) {
/* DEBUG */ _('Finished saving document, Updating indexes');
this.updateIndexes('root');
/* DEBUG */ _('Finished updating indexes');
const subscriptionMap = this.pluginData.get('internal:subscriptions');
const keySubscriptions = subscriptionMap.get('root');
if (keySubscriptions)
keySubscriptions.forEach(func => func('root', data));
}
}
this.dataCache.delete('root');
}
/**
* Delete this document from the db
*/
delete() {
const _ = this.doc_.extend('delete');
try {
/* DEBUG */ _('Emitting event "EventDocumentDelete"');
this.emitEvent({
event: 'EventDocumentDelete',
doc: this,
});
/* DEBUG */ _('Splicing document from collection');
this.collection.docs.splice(this.collection.docs.findIndex((val) => val === this), 1);
this.collection.idMap.delete(this.id);
/* DEBUG */ _('Deleting document data');
if (this.isCloned) {
/* DEBUG */ _('Document is cloned, removing plugin data');
this.pluginData.delete('internal:cloned');
}
else {
/* DEBUG */ _('Deleting document data from storage engine');
this.collection.db.storageEngine.delete(this);
/* DEBUG */ _('Deleted document data from storage engine');
}
/* DEBUG */ _('Updating reactive indexes');
for (const key of this.collection.reactiveIndexed.keys()) {
/* DEBUG */ _('Updating reactive index');
updateReactiveIndex(this.collection, key);
}
/* DEBUG */ _('Updated reactive indexes');
/* DEBUG */ _('Emitting event "EventDocumentDeleteComplete"');
this.emitEvent({
event: 'EventDocumentDeleteComplete',
id: this.id,
success: true,
});
}
catch (err) {
/* DEBUG */ _('Failed to delete this document `%s`, %J', err.message, err);
/* DEBUG */ _('Emitting event "EventDocumentDeleteComplete" with error');
this.emitEvent({
event: 'EventDocumentDeleteComplete',
id: this.id,
success: false,
error: err,
});
}
}
/**
* Populate down a tree of documents based on the provided MemsPL populateQuery
* @param populateQuery MemsPL population query
* @param filter Filter unspecified keys from the populated documents
* @returns Cloned version of this document
*/
populate(populateQuery, filter = false) {
const _ = this.doc_.extend('populate');
/* DEBUG */ _('Running populate on document');
const [populated] = populate(this.collection, [this], populateQuery, filter);
/* DEBUG */ _('Finished populating document');
return populated;
}
/**
* Populate the document with another document that matches the query.
* This will return a copy of the document and not a reference to the
* original.
*
* It's recommended you use the provided
* populate (`doc.populate(...)`) function instead.
* @param opts Options for the populate. Things like the target field and query don't have to be set
*/
customPopulate(opts) {
// Debugger variable
const _ = this.doc_.extend('customPopulate');
// Construct a new document based on the original so as to not perform a mutation
/* DEBUG */ _('Creating identical document so as to avoid mutations');
const resultDoc = this.clone();
/* DEBUG */ this.doc_('Emitting event "EventDocumentCustomPopulate"');
this.emitEvent({
event: 'EventDocumentCustomPopulate',
doc: resultDoc,
opts,
});
// Destructure out variables
const { srcField, targetField = 'id', targetCol, query = [
{
key: targetField,
operation: '===',
comparison: srcField === 'id' ? this.id : this.data[srcField],
inverse: false,
},
], destinationField = 'children', unwind = false, } = opts;
/* DEBUG */ _('Populating document `%s` field with results from `%s.%s`', destinationField, targetCol, targetField);
/* DEBUG */ _('Finding child documents');
const queriedDocuments = targetCol.find({ queries: query });
// Set a specific field to the results of the query, unwinding if necessary
/* DEBUG */ _('Setting field on document to contain children. Unwind: %s', unwind ? 'true' : 'false');
resultDoc.set(destinationField, unwind && queriedDocuments.length < 2
? queriedDocuments[0]
: queriedDocuments);
/* DEBUG */ this.doc_('Emitting event "EventDocumentCustomPopulateComplete"');
this.emitEvent({
event: 'EventDocumentCustomPopulateComplete',
doc: resultDoc,
opts,
});
// Return the copied document and not the original
/* DEBUG */ _('Finished populating field, returning ghost document');
return resultDoc;
}
/**
* Populate a tree of documents. It's recommended you use the provided
* populate (`doc.populate(...)`) function instead.
* @param opts Options for making a tree from the provided document
* @returns A cloned version of this doc that has the data field formatted into a tree
*/
tree(opts = {}) {
opts = {
populations: [],
maxDepth: 0,
currentDepth: 1,
...opts,
};
// Debugger variable
const _ = this.doc_.extend('tree');
const doc = this.clone();
/* DEBUG */ this.doc_('Emitting event "EventDocumentTree"');
this.emitEvent({
event: 'EventDocumentTree',
doc,
opts,
});
if (!opts)
return doc;
/* DEBUG */ _('Number of populations: %d', opts.populations?.length);
// Map over populations array to run individual populations
opts.populations?.map((q, i) => {
if (this.collection.name === q.collection.name) {
/* DEBUG */ _('Running population number %d', i);
const children = q.collection.find({
queries: [
{
key: q.targetField,
operation: '===',
comparison: q.srcField === 'id'
? this.id
: nestedKey(this.data, q.srcField),
inverse: false,
},
],
});
if (opts.maxDepth && opts.currentDepth <= opts.maxDepth)
doc.set(q.destinationField, children.map(child => child.tree({
...opts,
currentDepth: opts.currentDepth + 1,
})));
}
});
/* DEBUG */ _('Emitting event "EventDocumentTreeComplete"');
this.emitEvent({
event: 'EventDocumentTreeComplete',
doc,
opts,
});
/* DEBUG */ _('Finished running %d populations, returning result', opts.populations?.length);
return doc;
}
/**
* Duplicate this document, making mutations to it not affect the original
*/
clone() {
/* DEBUG */ this.doc_('Emitting event "EventDocumentClone"');
this.emitEvent({
event: 'EventDocumentClone',
doc: this,
});
const cloned = new DBDoc({}, this.collection, this.id, true);
cloned.setData(cloneDeep(this.data));
cloned._createdAt = this._createdAt;
cloned._updatedAt = this._updatedAt;
/* DEBUG */ this.doc_('Emitting event "EventDocumentClone"');
this.emitEvent({
event: 'EventDocumentCloneComplete',
doc: cloned,
});
return cloned;
}
/**
* Emit an event to the attached database
* @param event Event to emit
*/
emitEvent(event) {
this.collection.emitEvent(event);
}
/**
* Returns a simplified view
*/
toJSON() {
return {
...this.data,
id: this.id,
_type: `(DBCollection<${this.collection.name}<DBDoc>>)`,
_indexes: Object.keys(this.indexed),
};
}
}
//# sourceMappingURL=DBDoc.js.map