import Tracker from './Tracker.js';
import EJSON from 'ejson';
import Data from './Data';
import Random from '../lib/Random';
import call from './Call';
import { hasOwn, isPlainObject } from '../lib/utils.js';
/**
* @private
* @type {object}
*/
const observers = Object.create(null);
/**
* @private
* @type {object}
*/
const observersByComp = Object.create(null);
/**
* Get the list of callbacks for changes on a collection
* @param {string} type - Type of change happening.
* @param {string} collection - Collection it has happened on
* @param {string} newDocument - New value of item in the collection
*/
export function getObservers(type, collection, newDocument) {
let observersRet = [];
if (observers[collection]) {
observers[collection].forEach(({ cursor, callbacks }) => {
if (callbacks[type]) {
if (type === 'removed') {
observersRet.push(callbacks['removed']);
} else if (
Data.db[collection].findOne({
$and: [{ _id: newDocument._id }, cursor._selector],
})
) {
try {
observersRet.push(callbacks[type]);
} catch (e) {
console.error('Error in observe callback old', e);
}
} else {
// TODO what to do here?
}
}
});
}
// Find the observers related to the specific query
if (observersByComp[collection] && !(collection in {})) {
let keys = Object.keys(observersByComp[collection]);
for (let i = 0; i < keys.length; i++) {
observersByComp[collection][keys[i]].callbacks.forEach(
({ cursor, callback }) => {
let findRes = Data.db[collection].findOne({
$and: [{ _id: newDocument?._id }, cursor._selector],
});
if (findRes) {
observersRet.push(callback);
}
}
);
}
}
return observersRet;
}
/** @private */
const _registerObserver = (collection, cursor, callbacks) => {
observers[collection] = observers[collection] || [];
observers[collection].push({ cursor, callbacks });
};
/**
* Represents a Mongo.Cursor, usually returned by Collection.find().
*
* @see https://docs.meteor.com/api/collections.html#mongo_cursor
*/
class Cursor {
/**
* Usually you don't use this directly, unless you know what you are doing.
* @constructor
* @param collection
* @param docs
* @param selector
*/
constructor(collection, docs, selector) {
this._docs = docs || [];
this._collection = collection;
this._selector = selector;
}
/**
* Returns the number of documents that match a query.
* This method is deprecated since MongoDB 4.0 and will soon be replaced by
* Collection.countDocuments and Collection.estimatedDocumentCount.
*
* @deprecated
* @returns {number} size of the collection
*/
count() {
return this._docs.length;
}
/**
* Return all matching documents as an Array.
* @returns {object[]}
*/
fetch() {
return this._transformedDocs();
}
/**
* Call callback once for each matching document, sequentially and synchronously.
* @param callback {function}
* Function to call. It will be called with three arguments: the document, a 0-based index, and cursor itself.
*/
forEach(callback) {
this._transformedDocs().forEach(callback);
}
/**
* Map callback over all matching documents. Returns an Array.
* @param callback {function} Function to call. It will be called with three arguments:
* the document, a 0-based index, and cursor itself.
* @returns {object[]}
*/
map(callback) {
return this._transformedDocs().map(callback);
}
/**
* Applies a transform method on the documents, if given.
* @private
* @private
* @returns {object[]}
*/
_transformedDocs() {
return this._collection._transform
? this._docs.map(this._collection._transform)
: this._docs;
}
/**
* Registers an observer for the given callbacks
* @param callbacks {object}
* @see https://docs.meteor.com/api/collections.html#Mongo-Cursor-observe
*/
observe(callbacks) {
_registerObserver(this._collection._collection.name, this, callbacks);
}
}
/**
* List of all local collections, whose names
* are defined with `null`.
*
*/
export const localCollections = [];
/**
* Reference implementation for a Mongo.Collection.
* Uses Minimongo under the hood.
* We have forked minimongo into our org, see the link below.
*
* @class
* @see https://docs.meteor.com/api/collections.html
* @see https://github.com/meteorrn/minimongo-cache
*/
export class Collection {
/**
* Constructor for a Collection
* @param name {string|null}
* The name of the collection. If null, creates an unmanaged (unsynchronized) local collection.
* @param options {object=}
* @param options.transform {function=}
* An optional transformation function.
* Documents will be passed through this function before being returned from fetch or findOne,
* and before being passed to callbacks of observe, map, forEach, allow, and deny.
* Transforms are not applied for the callbacks of observeChanges or to cursors returned from publish functions.
*/
constructor(name, options = {}) {
if (name === null) {
this.localCollection = true;
name = Random.id();
localCollections.push(name);
}
// XXX: apparently using a name that occurs in Object prototype causes
// Data.db[name] to return the full MemoryDb implementation from Minimongo
// instead of a collection.
// A respective issues has been opened: https://github.com/meteorrn/minimongo-cache
// Additionally, this is subject to prototype pollution.
if (name in {}) {
throw new Error(
`Object-prototype property ${name} is not a supported Collection name`
);
}
if (!Data.db[name]) Data.db.addCollection(name);
this._collection = Data.db[name];
this._name = name;
this._transform = wrapTransform(options.transform);
}
/**
* Find the documents in a collection that match the selector.
* If called in useTracker it automatically invokes a new Tracker.Computation
* // TODO add reactive flag to options to disable reactivity for this call
* // TODO evaluate if hint: { $natural } can be implemented for backward search
*
* @param selector {string|object}
* A query describing the documents to find
* @param options {object=}
* @param options.sort {object=}
* @param options.limit {number=}
* @param options.skip {number=}
* @param options.fields {object=}
* @returns {Cursor}
*/
find(selector, options) {
let result;
let docs;
if (typeof selector == 'string') {
if (options) {
docs = this._collection.findOne({ _id: selector }, options);
} else {
docs = this._collection.get(selector);
}
if (docs) docs = [docs];
} else {
docs = this._collection.find(selector, options);
}
result = new Cursor(
this,
docs,
typeof selector == 'string' ? { _id: selector } : selector
);
// If this is being called within a use tracker
// make the tracker computation to say if this
// collection is changed it needs to be re-run
if (Tracker.active && Tracker.currentComputation) {
let id = Tracker.currentComputation._id;
observersByComp[this._name] =
observersByComp[this._name] || Object.create(null);
if (!observersByComp[this._name][id]) {
let item = {
computation: Tracker.currentComputation,
callbacks: [],
};
observersByComp[this._name][id] = item;
}
let item = observersByComp[this._name][id];
item.callbacks.push({
cursor: result,
callback: (newVal, old) => {
if (old && EJSON.equals(newVal, old)) {
return;
}
item.computation.invalidate();
},
});
Tracker.onInvalidate(() => {
if (observersByComp[this._name][id]) {
delete observersByComp[this._name][id];
}
});
}
return result;
}
/**
*
* @param selector
* @param options
* @returns {Cursor}
*/
findOne(selector, options) {
let result = this.find(selector, options);
if (result) {
result = result.fetch()[0];
}
return result;
}
/**
* Inserts a new document into the collection.
* If this is a collection that exists on the server, then it also
* calls the respective server side method
* /collectionName/insert
* @param item {object} the document to add to the collection
* @param callback {function=} optional callback, called when complete with error or result
*/
insert(item, callback = () => {}) {
let id;
if ('_id' in item) {
if (!item._id || typeof item._id != 'string') {
return callback(
'Meteor requires document _id fields to be non-empty strings'
);
}
id = item._id;
} else {
id = item._id = Random.id();
}
if (this._collection.get(id))
return callback({
error: 409,
reason: `Duplicate key _id with value ${id}`,
});
this._collection.upsert(item);
if (!this.localCollection) {
Data.waitDdpConnected(() => {
call(`/${this._name}/insert`, item, (err) => {
if (err) {
this._collection.del(id);
return callback(err);
}
callback(null, id);
});
});
}
// Notify relevant observers that the item has been updated with its new value
let observers = getObservers('added', this._collection.name, item);
observers.forEach((callback) => {
try {
callback(item, undefined);
} catch (e) {
console.error('Error in observe callback', e);
}
});
return id;
}
/**
* Update **a single** document by given id.
* If this is a collection that exists on the server, then it also
* calls the respective server side method
* /collectionName/update
* @param id {string|MongoID.ObjectID} id or ObjectID of the given document
* @param modifier {object} the modifier, see the minimongo docs for supported modifiers
* @param options {object=}
* @param options.localOnly {boolean=} force update call to server, even if this is a local collection
* @param callback {function=} optional callback, called when complete with error or result
*/
update(id, modifier, options = {}, callback = () => {}) {
if (typeof options == 'function') {
callback = options;
}
let old = this._collection.get(id);
if (!this._collection.get(id))
return callback({
error: 409,
reason: `Item not found in collection ${this._name} with id ${id}`,
});
// change mini mongo for optimize UI changes
// TODO only exec if modifier.$set is an object
this._collection.upsert({ _id: id, ...modifier.$set });
if (!this.localCollection || (options && options.localOnly)) {
Data.waitDdpConnected(() => {
call(`/${this._name}/update`, { _id: id }, modifier, (err) => {
if (err) {
// TODO in such case the _collection's document should be reverted
// unless we remove the auto-update to the server anyways
return callback(err);
}
callback(null, id);
});
});
}
let newItem = this._collection.findOne({ _id: id });
// Notify relevant observers that the item has been updated with its new value
let observers = getObservers('changed', this._collection.name, newItem);
observers.forEach((callback) => {
try {
callback(newItem, old);
} catch (e) {
// TODO make listenable / loggable
console.error('Error in observe callback', e);
}
});
}
/**
* Remove a **single** document by a given id.
* If it's not a local collection then the respective server
* collection method endpoint /collectionName/remove is called.
*
* @param id {string|MongoID.ObjectID} _id of the document to remove
* @param callback {function=} optional callback, called when complete with error or result
*/
remove(id, callback = () => {}) {
const element = this.findOne(id);
if (element) {
this._collection.del(element._id);
if (!this.localCollection) {
Data.waitDdpConnected(() => {
call(`/${this._name}/remove`, { _id: id }, (err, res) => {
if (err) {
this._collection.upsert(element);
return callback(err);
}
callback(null, res);
});
});
}
// Load the observers for removing the element
let observers = getObservers('removed', this._collection.name, element);
observers.forEach((callback) => {
try {
callback(element);
} catch (e) {
// TODO make listenable / loggable
console.error('Error in observe callback', e);
}
});
} else {
// TODO wrap message in new Error
callback(`No document with _id : ${id}`);
}
}
/**
* Define helpers for documents. This is basically an implementation of
* `dburles:mongo-collection-helpers`
* @param helpers {object} dictionary of helper functions that become prototypes of the documents
* @see https://github.com/dburles/meteor-collection-helpers
*/
helpers(helpers) {
let _transform;
if (this._transform && !this._helpers) _transform = this._transform;
if (!this._helpers) {
this._helpers = function Document(doc) {
return Object.assign(this, doc);
};
this._transform = (doc) => {
if (_transform) {
doc = _transform(doc);
}
return new this._helpers(doc);
};
}
Object.entries(helpers).forEach(([key, helper]) => {
this._helpers.prototype[key] = helper;
});
}
}
//From Meteor core
/**
* Wrap a transform function to return objects that have the _id field
* of the untransformed document. This ensures that subsystems such as
* the observe-sequence package that call `observe` can keep track of
* the documents identities.
*
* - Require that it returns objects
* - If the return value has an _id field, verify that it matches the
* original _id field
* - If the return value doesn't have an _id field, add it back.
* @private
*/
function wrapTransform(transform) {
if (!transform) return null;
// No need to doubly-wrap transforms.
if (transform.__wrappedTransform__) return transform;
var wrapped = function (doc) {
if (!hasOwn(doc, '_id')) {
// XXX do we ever have a transform on the oplog's collection? because that
// collection has no _id.
throw new Error('can only transform documents with _id');
}
var id = doc._id;
// XXX consider making tracker a weak dependency and checking Package.tracker here
var transformed = Tracker.nonreactive(function () {
return transform(doc);
});
if (!isPlainObject(transformed)) {
throw new Error('transform must return object');
}
if (hasOwn(transformed, '_id')) {
if (!EJSON.equals(transformed._id, id)) {
throw new Error("transformed document can't have different _id");
}
} else {
transformed._id = id;
}
return transformed;
};
wrapped.__wrappedTransform__ = true;
return wrapped;
}