Skip to content

Commit

Permalink
Merge pull request #28 from share/preserve-deleted-metadata
Browse files Browse the repository at this point in the history
Preserve metadata on deleted doc "tombstones", to match sharedb-mongo behavior
  • Loading branch information
ericyhwang committed Jul 25, 2023
2 parents d0acff4 + 68df5c2 commit 157591b
Show file tree
Hide file tree
Showing 3 changed files with 222 additions and 0 deletions.
15 changes: 15 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
var Mingo = require('mingo');
var cloneDeep = require('lodash.clonedeep');
var isObject = require('lodash.isobject');
var sharedbMongoUtils = require('./sharedb-mongo-utils');

// This is designed for use in tests, so load all Mingo query operators
require('mingo/init/system');
Expand Down Expand Up @@ -28,6 +29,16 @@ function extendMemoryDB(MemoryDB) {

ShareDBMingo.prototype = Object.create(MemoryDB.prototype);

ShareDBMingo.prototype._writeSnapshotSync = function(collection, id, snapshot) {
var collectionDocs = this.docs[collection] || (this.docs[collection] = {});
// The base MemoryDB deletes the `collectionDocs` entry when `snapshot.type == null`. However,
// sharedb-mongo leaves behind stub Mongo docs that preserve `snapshot.m` metadata. To match
// that behavior, just set the new snapshot instead of deleting the entry.
//
// For queries, the "tombstones" left over from deleted docs get filtered out by makeQuerySafe.
collectionDocs[id] = cloneDeep(snapshot);
};

ShareDBMingo.prototype.query = function(collection, query, fields, options, callback) {
var includeMetadata = options && options.metadata;
var db = this;
Expand Down Expand Up @@ -132,6 +143,10 @@ function extendMemoryDB(MemoryDB) {
var count = query.$count;
delete query.$count;

// If needed, modify query to exclude "tombstones" left after deleting docs, using the same
// approach that sharedb-mongo uses.
sharedbMongoUtils.makeQuerySafe(query);

return {
query: query,
sort: sort,
Expand Down
172 changes: 172 additions & 0 deletions sharedb-mongo-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// These functions are taken straight from sharedb-mongo.

exports.makeQuerySafe = makeQuerySafe;

// Call on a query after it gets parsed to make it safe against
// matching deleted documents.
function makeQuerySafe(query) {
// Don't modify the query if the user explicitly sets _type already
if (query.hasOwnProperty('_type')) return;
// Deleted documents are kept around so that we can start their version from
// the last version if they get recreated. When docs are deleted, their data
// properties are cleared and _type is set to null. Filter out deleted docs
// by requiring that _type is a string if the query does not naturally
// restrict the results with other keys
if (deletedDocCouldSatisfyQuery(query)) {
query._type = {$type: 2};
}
};

// Could a deleted doc (one that contains {_type: null} and no other
// fields) satisfy a query?
//
// Return true if it definitely can, or if we're not sure. (This
// function is used as an optimization to see whether we can avoid
// augmenting the query to ignore deleted documents)
function deletedDocCouldSatisfyQuery(query) {
// Any query with `{foo: value}` with non-null `value` will never
// match deleted documents (that are empty other than the `_type`
// field).
//
// This generalizes to additional classes of queries. Here’s a
// recursive description of queries that can't match a deleted doc:
// In general, a query with `{foo: X}` can't match a deleted doc
// if `X` is guaranteed to not match null or undefined. In addition
// to non-null values, the following clauses are guaranteed to not
// match null or undefined:
//
// * `{$in: [A, B, C]}}` where all of A, B, C are non-null.
// * `{$ne: null}`
// * `{$exists: true}`
// * `{$gt: not null}`, `{gte: not null}`, `{$lt: not null}`, `{$lte: not null}`
//
// In addition, some queries that have `$and` or `$or` at the
// top-level can't match deleted docs:
// * `{$and: [A, B, C]}`, where at least one of A, B, C are queries
// guaranteed to not match `{_type: null}`
// * `{$or: [A, B, C]}`, where all of A, B, C are queries guaranteed
// to not match `{_type: null}`
//
// There are more queries that can't match deleted docs but they
// aren’t that common, e.g. ones using `$type` or bit-wise
// operators.
if (query.hasOwnProperty('$and')) {
if (Array.isArray(query.$and)) {
for (var i = 0; i < query.$and.length; i++) {
if (!deletedDocCouldSatisfyQuery(query.$and[i])) {
return false;
}
}
} else {
// Malformed? Play it safe.
return true;
}
}

for (var prop in query) {
// Ignore fields that remain set on deleted docs
if (
prop === '_id' ||
prop === '_v' ||
prop === '_o' ||
prop === '_m' || (
prop[0] === '_' &&
prop[1] === 'm' &&
prop[2] === '.'
)
) {
continue;
}
// Top-level operators with special handling in this function
if (prop === '$and' || prop === '$or') {
continue;
}
// When using top-level operators that we don't understand, play
// it safe
if (prop[0] === '$') {
return true;
}
if (!couldMatchNull(query[prop])) {
return false;
}
}

if (query.hasOwnProperty('$or')) {
if (Array.isArray(query.$or)) {
for (var i = 0; i < query.$or.length; i++) {
if (deletedDocCouldSatisfyQuery(query.$or[i])) {
return true;
}
}
return false;
} else {
// Malformed? Play it safe.
return true;
}
}

return true;
}

function couldMatchNull(clause) {
if (
typeof clause === 'number' ||
typeof clause === 'boolean' ||
typeof clause === 'string'
) {
return false;
} else if (clause === null) {
return true;
} else if (isPlainObject(clause)) {
// Mongo interprets clauses with multiple properties with an
// implied 'and' relationship, e.g. {$gt: 3, $lt: 6}. If every
// part of the clause could match null then the full clause could
// match null.
for (var prop in clause) {
var value = clause[prop];
if (prop === '$in' && Array.isArray(value)) {
var partCouldMatchNull = false;
for (var i = 0; i < value.length; i++) {
if (value[i] === null) {
partCouldMatchNull = true;
break;
}
}
if (!partCouldMatchNull) {
return false;
}
} else if (prop === '$ne') {
if (value === null) {
return false;
}
} else if (prop === '$exists') {
if (value) {
return false;
}
} else if (prop === '$gt' || prop === '$gte' || prop === '$lt' || prop === '$lte') {
if (value !== null) {
return false;
}
} else {
// Not sure what to do with this part of the clause; assume it
// could match null.
}
}

// All parts of the clause could match null.
return true;
} else {
// Not a POJO, string, number, or boolean. Not sure what it is,
// but play it safe.
return true;
}
}

function isPlainObject(value) {
return (
typeof value === 'object' && (
Object.getPrototypeOf(value) === Object.prototype ||
Object.getPrototypeOf(value) === null
)
);
}
35 changes: 35 additions & 0 deletions test/test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
var expect = require('chai').expect;
var ShareBackend = require('sharedb');
var sinon = require('sinon');
var ShareDBMingo = require('../index');
var getQuery = require('../get-query');

Expand All @@ -13,6 +15,9 @@ describe('db', function() {
beforeEach(function() {
this.db = new ShareDBMingo();
});
afterEach(function() {
sinon.restore();
});

describe('query', function() {
require('./query')();
Expand All @@ -23,6 +28,36 @@ describe('db', function() {
});
});
});

it('preserves doc metadata after deletion', function(done) {
var clock = sinon.useFakeTimers(1000000);
function expectMeta(property, value) {
var snapshot = db._getSnapshotSync('testcollection', 'test1', true);
expect(snapshot).to.have.property('m');
expect(snapshot.m).to.have.property(property, value);
}
var db = this.db;
var backend = new ShareBackend({db: db});
var connection = backend.connect();
var doc = connection.get('testcollection', 'test1');
doc.create({x: 1, y: 1}, function(err) {
if (err) return done(err);
expect(doc).to.have.property('version', 1);
expectMeta('ctime', 1000000);
expectMeta('mtime', 1000000);

clock.tick(1000);
doc.del(function(err) {
if (err) return done(err);
expect(doc).to.have.property('type', null);
expect(doc).to.have.property('data', undefined);
expect(doc).to.have.property('version', 2);
expectMeta('ctime', 1000000);
expectMeta('mtime', 1001000);
done();
});
});
});
});

describe('getQuery', function() {
Expand Down

0 comments on commit 157591b

Please sign in to comment.