/**
*
* ## Offline database wrapper with Nedb
*
* @namespace
*
*/
kiss.db.offline = {
mode: "offline",
// Store the various offline collections
collections: {},
// Convert id to _id
toId(record) {
record.id = record._id
delete record._id
return record
},
toIds(records) {
records.forEach(record => {
record.id = record._id
delete record._id
})
return records
},
toMongoId(record) {
record._id = record.id
delete record.id
return record
},
toMongoIds(records) {
records.forEach(record => {
record._id = record.id
delete record.id
})
return records
},
/**
* Creates a new collection
*
* Does not do anything if the collection already exists.
* This method should not be called directly: collections are created automatically by requesting them.
*
* @ignore
* @async
* @param {string} modelId
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {promise} Promise to have a collection created if it doesn't exist yet
*/
async createCollection(modelId, dbMode = "offline") {
log(`kiss.db.offline - createCollection <${modelId}> in mode <${(dbMode == "memory") ? "memory" : "offline"}>`)
const newCollection = new Nedb({
filename: modelId,
autoload: true,
timestampData: true,
inMemoryOnly: (dbMode === "memory")
})
const collection = this.collections[modelId] = this.nedbWrapper.fromInstance(newCollection)
await collection.loadDatabase()
return collection
},
/**
* Get a collection
*
* This method should not be called directly: NeDb collections are retrieved automatically when requesting them.
*
* @ignore
* @async
* @param {string} modelId
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {promise} Promise to have a collection
*/
async getCollection(modelId, dbMode = "offline") {
if (!this.collections[modelId]) await this.createCollection(modelId, dbMode)
return this.collections[modelId]
},
/**
* Delete the local NeDb collection (memory or offline)
*
* @async
* @param {string} modelId
* @returns {promise} Promise to have a the collection deleted
*/
async deleteCollection(modelId) {
const collection = this.collections[modelId]
if (!collection) return
collection.remove({}, {
multi: true
}, function (err, numRemoved) {
if (err) {
log("kiss.db.offline - deleteCollection - Error:", 4, err)
} else {
log("kiss.db.offline - deleteCollection - Num removed: " + numRemoved, 2)
}
})
this.collections[modelId] = null
},
/**
* Create an index for a collection
*
* According to NeDb, this can give a significant performance boost when reading data.
*
* @async
* @param {string} modelId
* @param {string} fieldName
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {promise} Promise to have a collection index created
*
* @example
* await db.offline.createCollectionIndex("users", {fieldName: "firstName"})
*/
async createCollectionIndex(modelId, fieldName, dbMode = "offline") {
const collection = await this.getCollection(modelId, dbMode)
collection.ensureIndex({
fieldName: fieldName
})
},
/**
* Insert one record in a collection. See [db.insertOne](kiss.db.html#.insertOne)
*
* @async
* @param {string} modelId
* @param {object} record - A single record
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {object} The inserted record data
*/
async insertOne(modelId, record, dbMode = "offline") {
log("kiss.db - " + dbMode + " - insertOne - Model " + modelId, 0, record)
const collection = await this.getCollection(modelId, dbMode)
record.createdBy = kiss.session.getUserId()
this.toMongoId(record)
const insertedRecord = await collection.insert(record)
this.toId(record)
// Broadcast
const channel = "EVT_DB_INSERT:" + modelId.toUpperCase()
kiss.pubsub.publish(channel, {
channel,
dbMode,
accountId: kiss.session.getCurrentAccountId(),
userId: kiss.session.getUserId(),
modelId,
id: record.id,
data: record
})
return insertedRecord
},
/**
* Insert many records in a collection. See [db.insertMany](kiss.db.html#.insertMany)
*
* @async
* @param {string} modelId
* @param {object[]} records - An array of records [{...}, {...}] for bulk insert
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {object[]} The array of inserted records data
*/
async insertMany(modelId, records, dbMode = "offline") {
log("kiss.db - " + dbMode + " - insertMany - Model" + modelId + " / " + records.length + " record(s)", 0, records)
const collection = await this.getCollection(modelId, dbMode)
const createdBy = kiss.session.getUserId()
records.forEach(record => record.createdBy = createdBy)
this.toMongoIds(records)
const insertedRecords = await collection.insert(records)
this.toIds(records)
// Broadcast
const channel = "EVT_DB_INSERT_MANY:" + modelId.toUpperCase()
kiss.pubsub.publish(channel, {
channel,
dbMode,
accountId: kiss.session.getCurrentAccountId(),
userId: kiss.session.getUserId(),
modelId,
data: records
})
return insertedRecords
},
/**
* Update a record then propagate the mutation to foreign records.
* Note: it will generate a transaction processed with kiss.db.updateBulk
*
* @async
* @param {string} modelId
* @param {string} recordId
* @param {string} [update] - If not specified, re-compute all the computed fields
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {boolean} true if the update is successful
*
* @example
* await kiss.db.updateOneDeep("company", "f07xF008d", {"name": "pickaform"})
*/
async updateOneDeep(modelId, recordId, update, dbMode = "offline") {
log("kiss.db - " + dbMode + " - updateOneDeep - Model " + modelId + " / Record " + recordId, 0, update)
const model = kiss.app.models[modelId]
const record = await model.collection.findOne(recordId)
return await kiss.data.relations.updateOneDeep(model, record, update)
},
/**
* Update the 2 records connected by a link
*
* @param {object} linkRecord
* @returns The transaction result
*/
async updateLink(linkRecord) {
log("kiss.db - offline - updateLink: ", 0, linkRecord)
return await kiss.data.relations.updateLink(linkRecord)
},
/**
* Update a single record in a collection. See [db.updateOne](kiss.db.html#.update)
*
* @async
* @param {string} modelId
* @param {string} recordId
* @param {string} update - Specifies how the record should be updated
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {object} The updated record
*/
async updateOne(modelId, recordId, update, dbMode = "offline") {
log("kiss.db - " + dbMode + " - updateOne - Model " + modelId + " / Record " + recordId, 0, update)
const collection = await this.getCollection(modelId, dbMode)
update.updatedBy = kiss.session.getUserId()
const response = await collection.update({
_id: recordId
}, {
$set: update
}, {
upsert: false
})
// Broadcast
const channel = "EVT_DB_UPDATE:" + modelId.toUpperCase()
kiss.pubsub.publish(channel, {
channel,
dbMode,
accountId: kiss.session.getCurrentAccountId(),
userId: kiss.session.getUserId(),
modelId,
id: recordId,
data: update
})
return response
},
/**
* Update many records in a single collection. See [db.updateMany](kiss.db.html#.updateMany)
*
* @async
* @param {string} modelId
* @param {object} query
* @param {object} update
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
*
* TODO: NOT USED / NOT TESTED YET
*/
async updateMany(modelId, query, update, dbMode = "offline") {
log("kiss.db - " + dbMode + " - updateMany - Model " + modelId, 0, update)
const collection = await this.getCollection(modelId, dbMode)
update.updatedBy = kiss.session.getUserId()
const response = await collection.update(query, {
$set: update
}, {
multi: true
})
// Broadcast
const channel = "EVT_DB_UPDATE_MANY:" + modelId.toUpperCase()
kiss.pubsub.publish(channel, {
channel,
dbMode,
accountId: kiss.session.getCurrentAccountId(),
userId: kiss.session.getUserId(),
modelId,
data: update
})
return response
},
/**
* Update many records in multiple collections. See [db.updateBulk](kiss.db.html#.updateBulk)
* TODO: group operations by collection to avoid reseting the collection in the loop
* TODO: use PromiseAll to parallelize update operations
*
* @async
* @param {object[]} operations - The list of updates to perform
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {object} response - Empty object for offline/memory database
*/
async updateBulk(operations, dbMode = "offline") {
log("kiss.db - " + dbMode + " - updateBulk", 0, operations)
const updatedBy = kiss.session.getUserId()
for (let operation of operations) {
const collection = await this.getCollection(operation.modelId, dbMode)
operation.updates.updatedBy = updatedBy
await collection.update({
_id: operation.recordId
}, {
$set: operation.updates
}, {
upsert: false
})
}
// Broadcast
const channel = "EVT_DB_UPDATE_BULK"
kiss.pubsub.publish(channel, {
channel,
dbMode,
accountId: kiss.session.getCurrentAccountId(),
userId: kiss.session.getUserId(),
data: operations
})
return {}
},
/**
* Find a single record in a collection. See [db.findOne](kiss.db.html#.findOne)
*
* @async
* @param {string} modelId
* @param {string} recordId
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {object} The found record
*/
async findOne(modelId, recordId, dbMode = "offline") {
log("kiss.db - " + dbMode + " - findOne - Model " + modelId + " / Record " + recordId)
const collection = await this.getCollection(modelId, dbMode)
const record = await collection.findOne({
_id: recordId
})
if (record) this.toId(record)
return record
},
/**
* Find multiple records by id
*
* @async
* @param {string} modelId
* @param {string[]} ids - ids of the records to retrieve
* @param {object[]|object} [sort] - Sort options, as a normalized array or a Mongo object. Normalized example: [{fieldA: "asc"}, {fieldB: "desc"}]. Mongo example: {fieldA: 1, fieldB: -1}
* @param {string} [sortSyntax] - Sort syntax: "nomalized" | "mongo". Default is normalized
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {object[]} The found records
*/
async findById(modelId, ids, sort = [], sortSyntax = "normalized", dbMode = "offline") {
log("kiss.db - " + dbMode + " - findById - Model " + modelId + " / Records: " + ids.join())
const collection = await this.getCollection(modelId, dbMode)
let records
if (sortSyntax.length == 0) {
records = await collection.find({
_id: {
"$in": ids
}
})
}
else {
const sortObject = (sortSyntax == "normalized") ? kiss.db.mongo.convertSort(sort) : sort
records = await collection.cfind({
_id: {
"$in": ids
}
}).sort(sortObject).exec()
}
return this.toIds(records)
},
/**
* Find documents in a collection. See [db.find](kiss.db.html#.find)
*
* @async
* @param {string} modelId
* @param {object} [query] - Query object
* @param {*} [query.filter] - The query
* @param {string} [query.filterSyntax] - The query syntax. By default, passed as a normalized object
* @param {*} [query.sort] - Sort fields
* @param {string} [query.sortSyntax] - The sort syntax. By default, passed as a normalized array
* @param {string[]} [query.group] - Array of fields to group by: ["country", "city"]
* @param {boolean} [query.groupUnwind] - true to unwind the fields for records that belongs to multiple groups
* @param {object} [query.projection] - {firstName: 1, lastName: 1, password: 0}
* @param {object} [query.skip] - Number of records to skip
* @param {object} [query.limit] - Number of records to return
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {object[]} An array containing the records data
*/
async find(modelId, query, dbMode = "offline") {
log("kiss.db - " + dbMode + " - find - Model " + modelId + " / Query:", 0, query)
let records
const collection = await this.getCollection(modelId, dbMode)
if (!query) {
records = await collection.find({})
return this.toIds(records)
}
// Sanitize the query
const search = {
operation: "search",
filter: query.filter || {},
filterSyntax: query.filterSyntax || "normalized",
sort: query.sort || {},
sortSyntax: query.sortSyntax || "normalized",
group: query.group || [],
projection: query.projection || {},
skip: query.skip,
limit: query.limit,
groupUnwind: query.groupUnwind
}
// If sort or filter are normalized, we convert them to mongo syntax beforing sending request to database
if (search.filterSyntax == "normalized") {
search.filter = kiss.db.mongo.convertFilterGroup(search.filter)
}
if (search.sortSyntax == "normalized") {
search.sort = kiss.db.mongo.convertSort(search.sort)
}
if (search.limit && search.limit > 0) {
records = await collection.cfind(search.filter, search.projection).limit(search.limit).sort(search.sort).exec()
}
else {
records = await collection.cfind(search.filter, search.projection).sort(search.sort).exec()
}
return this.toIds(records)
},
/**
* Delete an element from a collection. See [db.delete](kiss.db.html#.delete)
*
* @async
* @param {string} modelId
* @param {string} recordId
* @param {boolean} [sendToTrash] - If true, keeps the original record in a "trash" collection
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {boolean}
*/
async deleteOne(modelId, recordId, sendToTrash, dbMode = "offline") {
log("kiss.db - " + dbMode + " - deleteOne - Model " + modelId + " / Record " + recordId)
const collection = await this.getCollection(modelId, dbMode)
// Copying record to trash collection prior to deletion
if (sendToTrash) {
await this.copyOneToTrash(modelId, recordId, dbMode)
}
await collection.remove({
_id: recordId
})
// Broadcast
const channel = "EVT_DB_DELETE:" + modelId.toUpperCase()
kiss.pubsub.publish(channel, {
channel,
dbMode,
accountId: kiss.session.getCurrentAccountId(),
userId: kiss.session.getUserId(),
modelId,
id: recordId
})
// Deleting a dynamic record can trigger updates on its relations
if (kiss.tools.isUid(modelId)) {
const operations = await kiss.data.relations.updateForeignRecords(modelId, recordId)
if (operations.length > 0) {
const channel = "EVT_DB_UPDATE_BULK"
kiss.pubsub.publish(channel, {
channel,
dbMode,
accountId: kiss.session.getAccountId(),
userId: kiss.session.getUserId(),
data: operations
})
}
}
else if (modelId.startsWith("file")) {
// TODO: remove file from local drive or Amazon S3
}
return true
},
/**
* Delete many records from a collection
*
* @param {string} modelId
* @param {object} query
* @param {boolean} [sendToTrash] - If true, keeps the original records in a "trash" collection
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
*
* TODO: NOT TESTED YET
*/
async deleteMany(modelId, query, sendToTrash, dbMode = "offline") {
log("kiss.db - " + dbMode + " - deleteMany - Model " + modelId, 0, query)
const collection = await this.getCollection(modelId, dbMode)
// Copying records to trash collection prior to deletion
if (sendToTrash) {
await this.copyManyToTrash(modelId, query, dbMode)
}
const response = await collection.remove(
query, {
multi: true
}
)
// Broadcast
const channel = "EVT_DB_DELETE_MANY:" + modelId.toUpperCase()
kiss.pubsub.publish(channel, {
channel,
dbMode,
accountId: kiss.session.getCurrentAccountId(),
userId: kiss.session.getUserId(),
modelId,
data: query
})
return response
},
/**
* Count the number of records that match a query.
*
* @async
* @param {string} modelId
* @param {object} query - Use same query format as for find() method
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {number} The number of records
*/
async count(modelId, query, dbMode) {
log("kiss.db - " + dbMode + " - count - Model " + modelId, 0, query)
const collection = await this.getCollection(modelId, dbMode)
return await collection.count(query)
},
/**
* Copy a record to the "trash" collection (recycle bin)
*
* This is useful if you want to implement soft deletion
*
* @async
* @param {string} modelId
* @param {string} recordId
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {object} The record copied to the trash with extra informations
*/
async copyOneToTrash(modelId, recordId, dbMode = "offline") {
// Get the record to move
const record = await this.findOne(modelId, recordId, dbMode)
// The record is associated to the account
record.accountId = "anonymous"
// Set the source collection to be able to restore at the right place later
record.sourceModelId = modelId
// Timestamp the deletion
record.deletedAt = new Date().toISOString()
record.deletedBy = "anonymous"
await this.insertOne("trash", record, dbMode)
return record
},
/**
* Copy many records to the "trash" collection (recycle bin)
*
* This is useful if you want to implement soft deletion
*
* @async
* @param {string} modelId
* @param {string} query
* @param {string} [dbMode] - Use "memory" to work with an in-memory collection
* @returns {object} The records copied to the trash with extra informations
*/
async copyManyToTrash(modelId, query, dbMode = "offline") {
// Get the records to move
const records = await this.find(modelId, query, dbMode)
const data = []
for (record of records) {
// The record is associated to the account
record.accountId = "anonymous"
// Set the source collection to be able to restore at the right place later
record.sourceModelId = modelId
// Timestamp the deletion
record.deletedAt = new Date().toISOString()
record.deletedBy = "anonymous"
data.push(record)
}
await this.insertMany("trash", data, dbMode)
return records
},
/**
*
* Node.js Embedded Database (Nedb) wrapper
*
* Nedb is a local / offline database developped by Louis Chatriot, both in-memory and persistent with IndexedDb or WebSQL or localStorage (or file-based storage for Node).
* Nedb is the only accepted dependency for KissJS, because Nedb code is absolutely brilliant.
*
* Nedb has the fastest and easiest api compared to:
* - PouchDb: problems => can't update fields, but have to update full records + versioning high complexity
* - Dixie: problems => boring static schema and schema versionning system
* - LokiJS: problems => the localCollection must be integraly serialized to persist (ouch!)
*
* The only small inconvenient of Nedb is that it only uses callbacks, hence, this wrapper to use it with Promises :)
*
* @ignore
*/
nedbWrapper: {
/**
* Transform a Nedb instance into a promisified (aka "thenified") version
*
* @param {*} nedbInstance
*/
fromInstance(nedbInstance) {
const newCollection = {
nedb: nedbInstance // Keep the original Nedb in case we still want callbacks in some situations
}
// Convert main methods
const methods = ['loadDatabase', 'insert', 'find', 'findOne', 'count', 'update', 'remove', 'ensureIndex', 'removeIndex']
for (let i = 0; i < methods.length; ++i) {
const method = methods[i]
newCollection[method] = kiss.db.offline.nedbWrapper.thenify(nedbInstance[method].bind(nedbInstance))
}
// Convert cursor find
newCollection.cfind = function (query, projections) {
const cursor = nedbInstance.find(query, projections)
cursor.exec = kiss.db.offline.nedbWrapper.thenify(cursor.exec.bind(cursor))
return cursor
}
// Convert cursor findOne
newCollection.cfindOne = function (query, projections) {
const cursor = nedbInstance.findOne(query, projections)
cursor.exec = kiss.db.offline.nedbWrapper.thenify(cursor.exec.bind(cursor))
return cursor
}
// Convert cursor count
newCollection.ccount = function (query) {
const cursor = nedbInstance.count(query)
cursor.exec = kiss.db.offline.nedbWrapper.thenify(cursor.exec.bind(cursor))
return cursor
}
return newCollection
},
/**
* Create the wrapper to transform a function with callbaks into a Promise.
*
* @param {string} name - Method name
* @param {object} options - {withCallback: true|false, multiArgs: true|false}
* @returns {string} - The string representation of the "thenified" function, ready to be evaluated
*/
create: function (name, options) {
name = (name || '').replace(/\s|bound(?!$)/g, '')
options = options || {}
var multiArgs = options.multiArgs !== undefined ? options.multiArgs : true
multiArgs = 'var multiArgs = ' + JSON.stringify(multiArgs) + '\n'
var withCallback = options.withCallback ?
'var lastType = typeof arguments[len - 1]\n' +
'if (lastType === "function") return $$__fn__$$.apply(self, arguments)\n' :
''
return '(function ' + name + '() {\n' +
'var self = this\n' +
'var len = arguments.length\n' +
multiArgs +
withCallback +
'var args = new Array(len + 1)\n' +
'for (var i = 0; i < len; ++i) args[i] = arguments[i]\n' +
'var lastIndex = i\n' +
'return new Promise(function (resolve, reject) {\n' +
'args[lastIndex] = kiss.db.offline.nedbWrapper.createCallback(resolve, reject, multiArgs)\n' +
'$$__fn__$$.apply(self, args)\n' +
'})\n' +
'})'
},
/**
* Turn async functions into promises
*
* @param {function} $$__fn__$$ - Function to thenify
* @param {object} options - {withCallback: true|false, multiArgs: true|false}
* @returns {function} - The wrapped function
*/
thenify: function ($$__fn__$$, options) {
return eval(this.create($$__fn__$$.name, options))
},
/**
* Generates the callback for the "thenified" function
*
* @param {function} resolve
* @param {function} reject
* @param {boolean} multiArgs
*/
createCallback: function (resolve, reject, multiArgs) {
return function (err, value) {
if (err) return reject(err)
const length = arguments.length
if (length <= 2 || !multiArgs) return resolve(value)
if (Array.isArray(multiArgs)) {
let values = {}
for (let i = 1; i < length; i++) values[multiArgs[i - 1]] = arguments[i]
return resolve(values)
}
let values = new Array(length - 1)
for (let i = 1; i < length; ++i) values[i - 1] = arguments[i]
resolve(values)
}
}
}
};
/**
* Turn async functions into promises and backward compatible with callback
*
* @param {function} $$__fn__$$ - Function to thenify
* @param {object} options - {withCallback: true|false, multiArgs: true|false}
* @returns {function} - The wrapped function
*/
kiss.db.offline.nedbWrapper.thenify.withCallback = function ($$__fn__$$, options) {
options = options || {}
options.withCallback = true
if (options.multiArgs === undefined) options.multiArgs = true
return eval(kiss.db.offline.nedbWrapper.create($$__fn__$$.name, options))
}
;
Source