Skip to content

Commit 68df5c2

Browse files
committed
Preserve metadata on deleted doc "tombstones", to match sharedb-mongo behavior
1 parent d0acff4 commit 68df5c2

File tree

3 files changed

+222
-0
lines changed

3 files changed

+222
-0
lines changed

index.js

+15
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
var Mingo = require('mingo');
22
var cloneDeep = require('lodash.clonedeep');
33
var isObject = require('lodash.isobject');
4+
var sharedbMongoUtils = require('./sharedb-mongo-utils');
45

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

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

32+
ShareDBMingo.prototype._writeSnapshotSync = function(collection, id, snapshot) {
33+
var collectionDocs = this.docs[collection] || (this.docs[collection] = {});
34+
// The base MemoryDB deletes the `collectionDocs` entry when `snapshot.type == null`. However,
35+
// sharedb-mongo leaves behind stub Mongo docs that preserve `snapshot.m` metadata. To match
36+
// that behavior, just set the new snapshot instead of deleting the entry.
37+
//
38+
// For queries, the "tombstones" left over from deleted docs get filtered out by makeQuerySafe.
39+
collectionDocs[id] = cloneDeep(snapshot);
40+
};
41+
3142
ShareDBMingo.prototype.query = function(collection, query, fields, options, callback) {
3243
var includeMetadata = options && options.metadata;
3344
var db = this;
@@ -132,6 +143,10 @@ function extendMemoryDB(MemoryDB) {
132143
var count = query.$count;
133144
delete query.$count;
134145

146+
// If needed, modify query to exclude "tombstones" left after deleting docs, using the same
147+
// approach that sharedb-mongo uses.
148+
sharedbMongoUtils.makeQuerySafe(query);
149+
135150
return {
136151
query: query,
137152
sort: sort,

sharedb-mongo-utils.js

+172
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// These functions are taken straight from sharedb-mongo.
2+
3+
exports.makeQuerySafe = makeQuerySafe;
4+
5+
// Call on a query after it gets parsed to make it safe against
6+
// matching deleted documents.
7+
function makeQuerySafe(query) {
8+
// Don't modify the query if the user explicitly sets _type already
9+
if (query.hasOwnProperty('_type')) return;
10+
// Deleted documents are kept around so that we can start their version from
11+
// the last version if they get recreated. When docs are deleted, their data
12+
// properties are cleared and _type is set to null. Filter out deleted docs
13+
// by requiring that _type is a string if the query does not naturally
14+
// restrict the results with other keys
15+
if (deletedDocCouldSatisfyQuery(query)) {
16+
query._type = {$type: 2};
17+
}
18+
};
19+
20+
// Could a deleted doc (one that contains {_type: null} and no other
21+
// fields) satisfy a query?
22+
//
23+
// Return true if it definitely can, or if we're not sure. (This
24+
// function is used as an optimization to see whether we can avoid
25+
// augmenting the query to ignore deleted documents)
26+
function deletedDocCouldSatisfyQuery(query) {
27+
// Any query with `{foo: value}` with non-null `value` will never
28+
// match deleted documents (that are empty other than the `_type`
29+
// field).
30+
//
31+
// This generalizes to additional classes of queries. Here’s a
32+
// recursive description of queries that can't match a deleted doc:
33+
// In general, a query with `{foo: X}` can't match a deleted doc
34+
// if `X` is guaranteed to not match null or undefined. In addition
35+
// to non-null values, the following clauses are guaranteed to not
36+
// match null or undefined:
37+
//
38+
// * `{$in: [A, B, C]}}` where all of A, B, C are non-null.
39+
// * `{$ne: null}`
40+
// * `{$exists: true}`
41+
// * `{$gt: not null}`, `{gte: not null}`, `{$lt: not null}`, `{$lte: not null}`
42+
//
43+
// In addition, some queries that have `$and` or `$or` at the
44+
// top-level can't match deleted docs:
45+
// * `{$and: [A, B, C]}`, where at least one of A, B, C are queries
46+
// guaranteed to not match `{_type: null}`
47+
// * `{$or: [A, B, C]}`, where all of A, B, C are queries guaranteed
48+
// to not match `{_type: null}`
49+
//
50+
// There are more queries that can't match deleted docs but they
51+
// aren’t that common, e.g. ones using `$type` or bit-wise
52+
// operators.
53+
if (query.hasOwnProperty('$and')) {
54+
if (Array.isArray(query.$and)) {
55+
for (var i = 0; i < query.$and.length; i++) {
56+
if (!deletedDocCouldSatisfyQuery(query.$and[i])) {
57+
return false;
58+
}
59+
}
60+
} else {
61+
// Malformed? Play it safe.
62+
return true;
63+
}
64+
}
65+
66+
for (var prop in query) {
67+
// Ignore fields that remain set on deleted docs
68+
if (
69+
prop === '_id' ||
70+
prop === '_v' ||
71+
prop === '_o' ||
72+
prop === '_m' || (
73+
prop[0] === '_' &&
74+
prop[1] === 'm' &&
75+
prop[2] === '.'
76+
)
77+
) {
78+
continue;
79+
}
80+
// Top-level operators with special handling in this function
81+
if (prop === '$and' || prop === '$or') {
82+
continue;
83+
}
84+
// When using top-level operators that we don't understand, play
85+
// it safe
86+
if (prop[0] === '$') {
87+
return true;
88+
}
89+
if (!couldMatchNull(query[prop])) {
90+
return false;
91+
}
92+
}
93+
94+
if (query.hasOwnProperty('$or')) {
95+
if (Array.isArray(query.$or)) {
96+
for (var i = 0; i < query.$or.length; i++) {
97+
if (deletedDocCouldSatisfyQuery(query.$or[i])) {
98+
return true;
99+
}
100+
}
101+
return false;
102+
} else {
103+
// Malformed? Play it safe.
104+
return true;
105+
}
106+
}
107+
108+
return true;
109+
}
110+
111+
function couldMatchNull(clause) {
112+
if (
113+
typeof clause === 'number' ||
114+
typeof clause === 'boolean' ||
115+
typeof clause === 'string'
116+
) {
117+
return false;
118+
} else if (clause === null) {
119+
return true;
120+
} else if (isPlainObject(clause)) {
121+
// Mongo interprets clauses with multiple properties with an
122+
// implied 'and' relationship, e.g. {$gt: 3, $lt: 6}. If every
123+
// part of the clause could match null then the full clause could
124+
// match null.
125+
for (var prop in clause) {
126+
var value = clause[prop];
127+
if (prop === '$in' && Array.isArray(value)) {
128+
var partCouldMatchNull = false;
129+
for (var i = 0; i < value.length; i++) {
130+
if (value[i] === null) {
131+
partCouldMatchNull = true;
132+
break;
133+
}
134+
}
135+
if (!partCouldMatchNull) {
136+
return false;
137+
}
138+
} else if (prop === '$ne') {
139+
if (value === null) {
140+
return false;
141+
}
142+
} else if (prop === '$exists') {
143+
if (value) {
144+
return false;
145+
}
146+
} else if (prop === '$gt' || prop === '$gte' || prop === '$lt' || prop === '$lte') {
147+
if (value !== null) {
148+
return false;
149+
}
150+
} else {
151+
// Not sure what to do with this part of the clause; assume it
152+
// could match null.
153+
}
154+
}
155+
156+
// All parts of the clause could match null.
157+
return true;
158+
} else {
159+
// Not a POJO, string, number, or boolean. Not sure what it is,
160+
// but play it safe.
161+
return true;
162+
}
163+
}
164+
165+
function isPlainObject(value) {
166+
return (
167+
typeof value === 'object' && (
168+
Object.getPrototypeOf(value) === Object.prototype ||
169+
Object.getPrototypeOf(value) === null
170+
)
171+
);
172+
}

test/test.js

+35
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
var expect = require('chai').expect;
2+
var ShareBackend = require('sharedb');
3+
var sinon = require('sinon');
24
var ShareDBMingo = require('../index');
35
var getQuery = require('../get-query');
46

@@ -13,6 +15,9 @@ describe('db', function() {
1315
beforeEach(function() {
1416
this.db = new ShareDBMingo();
1517
});
18+
afterEach(function() {
19+
sinon.restore();
20+
});
1621

1722
describe('query', function() {
1823
require('./query')();
@@ -23,6 +28,36 @@ describe('db', function() {
2328
});
2429
});
2530
});
31+
32+
it('preserves doc metadata after deletion', function(done) {
33+
var clock = sinon.useFakeTimers(1000000);
34+
function expectMeta(property, value) {
35+
var snapshot = db._getSnapshotSync('testcollection', 'test1', true);
36+
expect(snapshot).to.have.property('m');
37+
expect(snapshot.m).to.have.property(property, value);
38+
}
39+
var db = this.db;
40+
var backend = new ShareBackend({db: db});
41+
var connection = backend.connect();
42+
var doc = connection.get('testcollection', 'test1');
43+
doc.create({x: 1, y: 1}, function(err) {
44+
if (err) return done(err);
45+
expect(doc).to.have.property('version', 1);
46+
expectMeta('ctime', 1000000);
47+
expectMeta('mtime', 1000000);
48+
49+
clock.tick(1000);
50+
doc.del(function(err) {
51+
if (err) return done(err);
52+
expect(doc).to.have.property('type', null);
53+
expect(doc).to.have.property('data', undefined);
54+
expect(doc).to.have.property('version', 2);
55+
expectMeta('ctime', 1000000);
56+
expectMeta('mtime', 1001000);
57+
done();
58+
});
59+
});
60+
});
2661
});
2762

2863
describe('getQuery', function() {

0 commit comments

Comments
 (0)