-
Notifications
You must be signed in to change notification settings - Fork 4
/
Copy pathFirebaseIndex.js
512 lines (465 loc) · 17.7 KB
/
FirebaseIndex.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
/*! FirebaseIndex
*
*************************************/
var FirebaseIndex;
(function ($, exports) { // jQuery isn't required, but it helps with async ops
"use strict";
var undefined;
function FirebaseIndex(indexRef, dataRef) {
this.indexRef = indexRef;
this.dataRef = typeof(dataRef) === 'function'? dataRef : function(key) { return dataRef.child(key); };
this._initMemberVars();
}
/**
* Add a key to a FirebaseIndex path and include that data record in our results. A priority may optionally be
* included to create sorted indices.
*
* Note that if an index exists which does not exist in the data path, this won't hurt anything. The child_added
* callback only gets invoked if data actually exists at that path. If, later, the data reappears, then child_added
* will be called at that time.
*
* @param {String} key
* @param {String|Number} [priority]
* @param {Function} [onComplete]
* @returns {*}
*/
FirebaseIndex.prototype.add = function(key, priority, onComplete) {
this.addValue(key, 1, priority, onComplete);
return this;
};
FirebaseIndex.prototype.addValue = function(key, value, priority, onComplete) {
var ref = this.indexRef.child(key);
if( priority && typeof(priority) === 'function' ) {
onComplete = priority;
priority = undefined;
}
if( priority !== undefined ) {
ref.setWithPriority(value, priority, onComplete);
}
else {
ref.set(value, onComplete);
}
return this;
};
/**
* Removes a key from the index. This does not remove the actual data record, but simply prevents it from being
* included in our results.
*
* @param {String} key
* @param {Function} [onComplete]
* @returns {*}
*/
FirebaseIndex.prototype.drop = function(key, onComplete) {
this.indexRef.child(key).remove(onComplete);
return this;
};
/**
* Creates an event listener on the data path. However, only records in this index are included in
* the results.
*
* When the callback is fired, the snapshot will contain the full data object from the data path.
*
* @param {String} eventType one of child_added, child_changed, or child_removed
* @param {Function} [callback]
* @param {Object} [context]
* @returns {*}
*/
FirebaseIndex.prototype.on = function(eventType, callback, context) {
var fn;
this._initChildListeners();
// handle optional arguments
if( arguments.length === 2 && typeof(callback) === 'object' ) {
context = callback;
callback = null;
}
// determine the event type
switch(eventType) {
case 'child_added':
fn = addEventListener(this.eventListeners[eventType], callback, context);
// mimic Firebase behavior by sending any pre-existing records when on('child_added') is invoked
notifyExistingRecs(this.dataRef, this.childRefs, fn);
break;
case 'child_changed':
case 'child_removed':
case 'child_moved':
case 'index_value':
fn = addEventListener(this.eventListeners[eventType], callback, context);
break;
default:
throw new Error('I cannot process this event type: '+eventType);
}
return fn;
};
/**
* Stop listening to a data record which was initialized from this index
*
* @param {String} eventType one of child_added, child_changed, or child_removed
* @param {Function} [callback]
* @param {Object} [context]
* @returns {*}
*/
FirebaseIndex.prototype.off = function(eventType, callback, context) {
// handle optional arguments
if( arguments.length === 2 && typeof(callback) === 'object' ) {
context = callback;
callback = null;
}
// determine the event type
switch(eventType) {
case 'child_added':
case 'child_changed':
case 'child_moved':
case 'child_removed':
case 'index_value':
var events = this.eventListeners[eventType];
// This tricky little construct just removes all matches.
// Since we're going to remove elements from `events` each
// time there is a match, we start over each time and avoid
// all the index craziness that would occur. We're assuming the
// list of listeners is less than a few hundred and that
// this cost of additional iterations is insignificant
while(
events.length && events.some(function(o, i) {
if(o.cb === callback && o.ctx === context) {
events.splice(i, 1);
return true;
}
return false;
})
);
break;
default:
throw new Error('I cannot process this event type: '+eventType);
}
return this;
};
/**
* @param {number} [priority]
* @param {string} [name]
* @return {FirebaseIndexQuery} a read-only version of this index
*/
FirebaseIndex.prototype.startAt = function(priority, name) {
return new FirebaseIndexQuery(this.indexRef.startAt(priority, name), this.dataRef);
};
/**
* @param {number} [priority]
* @param {string} [name]
* @return {FirebaseIndexQuery} a read-only version of this index
*/
FirebaseIndex.prototype.endAt = function(priority, name) {
return new FirebaseIndexQuery(this.indexRef.endAt(priority, name), this.dataRef);
};
/**
* @param {number} limit
* @return {FirebaseIndexQuery} a read-only version of this index
*/
FirebaseIndex.prototype.limit = function(limit) {
return new FirebaseIndexQuery(this.indexRef.limit(limit), this.dataRef);
};
/**
* Remove all listeners and clear all memory resources consumed by this object. A new instance must
* be created to perform any further ops.
*/
FirebaseIndex.prototype.dispose = function() {
this.childRefs.forEach(function(o) {
o.dispose();
});
this.indexRef.off('child_added', this._indexAdded);
this.indexRef.off('child_removed', this._indexRemoved);
this.indexRef.off('child_moved', this._indexMoved);
this.indexRef.off('child_changed', this._indexValue);
this.childRefs = this.eventListeners = this.indexRef = this.dataRef = null;
};
FirebaseIndex.prototype.name = function() {
return this.indexRef.name();
};
FirebaseIndex.prototype.parent = function() {
return this.indexRef.parent();
};
/** @private */
FirebaseIndex.prototype._initMemberVars = function() {
bindAll(this, '_indexAdded', '_indexRemoved', '_indexMoved', '_childChanged', '_indexValue');
this.initialized = false;
this.eventListeners = { 'child_added': [], 'child_moved': [], 'child_removed': [], 'child_changed': [], 'index_value': [] };
this.childRefs = {};
};
/** @private */
FirebaseIndex.prototype._initChildListeners = function() {
if( !this.initialized ) { // lazy initialize so that limit/startAt/endAt don't generate superfluous listeners
this.initialized = true;
this.indexRef.on('child_added', this._indexAdded);
this.indexRef.on('child_removed', this._indexRemoved);
this.indexRef.on('child_moved', this._indexMoved);
this.indexRef.on('child_changed', this._indexValue);
}
};
/** @private */
FirebaseIndex.prototype._indexAdded = function(ss, prevId) {
storeChildRef(this.childRefs, this._childChanged, ss, prevId);
// monitor the record for changes and defer the handling to this._childChanged
var ref = this.dataRef(ss.name());
var fn = ref.on('value', this._childChanged.bind(this, ss.name()));
this.childRefs[ss.name()].dataSub = {
dispose: function() { ref.off('value', fn); }
}
};
/** @private */
FirebaseIndex.prototype._indexRemoved = function(ss) {
var indexData = this.childRefs[ss.name()];
if( indexData ) {
indexData.dispose();
notifyListeners(this.eventListeners['child_removed'], ss, indexData);
}
};
/** @private */
FirebaseIndex.prototype._indexMoved = function(ss, prevId) {
var indexData = this.childRefs[ss.name()];
if(indexData ) {
indexData.prevId = prevId;
notifyListeners(this.eventListeners['child_moved'], ss, indexData);
}
};
/** @private */
FirebaseIndex.prototype._indexValue = function(ss) {
var indexData = this.childRefs[ss.name()];
if(indexData ) {
indexData.idxValue = ss.val();
notifyListeners(this.eventListeners['index_value'], ss, indexData);
}
};
/** @private */
FirebaseIndex.prototype._childChanged = function(key, ss) {
// The index and the actual data set may vary slightly; this could be intentional since
// we could monitor things that come and go frequently. So what we do here is look at the
// actual data, compare it to the index, and send notifications that jive with our findings
var v = ss.val(), eventType = null, prevId = undefined, ref = this.childRefs[key];
if( v === null ) {
// null means data doesn't exist; if it's in our list, it was deleted
// if it's not in our list, it never existed in the first place
// we just ignore it until some data shows up or it's removed from the index
// since it's okay to have things in the list that may show up in the data later
//todo add an option to FirebaseIndex to auto-clean the index when data changes?
if( ref ) {
// since we could have records waiting on which doesn't exist before they load in, we need to
// deal with that case here by resolving any waiting methods
if( ref.def ) {
if( ref.def.state() === 'pending' ) {
reassignPrevId(this.childRefs, ref);
ref.def.resolve();
}
}
else if( ref.loaded === false ) {
reassignPrevId(this.childRefs, ref);
ref.loaded = true;
}
// notify listeners record was removed
eventType = 'child_removed';
}
else {
warn('Invalid key in index (no data exists)', key);
}
}
else if( ref ) {
if( !ref.loaded ) {
// this is the first time we've seen this data, we'll mark it as added
// we make sure the prevId has already been marked "loaded" before triggering
// this event, that way they arrive at the client in the same order they came
// out of the index list, which prevents prevId from not existing
//eventType = 'child_added';
prevId = ref.prevId;
waitFor(this.childRefs, prevId, function() {
notifyListeners(this.eventListeners['child_added'], ss, ref);
ref.loaded = true;
ref.def && ref.def.resolve();
}.bind(this));
}
else {
// the value has been changed
eventType = 'child_changed';
}
}
else {
// this can happen and be legitimate; sometimes when records are removed from the index and modified
// at the same time (say I change a boolean that then removes the index entry) this condition happens
// however, it is also a good indicator of bugs, so we print it out to console for record keeping
warn('Received an unkeyed record; this is okay if it was modified just as the key was deleted', key);
}
eventType && notifyListeners(this.eventListeners[eventType], ss, ref);
return this;
};
function FirebaseIndexQuery(indexRef, dataRef) {
this.indexRef = indexRef;
this.dataRef = dataRef;
this._initMemberVars();
}
inheritsPrototype(FirebaseIndexQuery, FirebaseIndex, {
add: function() { throw new Error('cannot add to index on read-only FirebaseIndexQueue instance (after calling limit, endAt, or startAt)'); },
drop: function() { throw new Error('cannot drop from index on read-only FirebaseIndexQueue instance (after calling limit, endAt, or startAt)'); },
child: function() { throw new Error('cannot access child on read-only FirebaseIndexQueue instance (after calling limit, endAt, or startAt)'); }
});
function notifyListeners(list, ss, ref) {
list.forEach(function(o) {
// make the calls async so they match client expectations
// Firebase can call them synchonously if the data is already local
// which messes up Promise.progress() and any async callbacks
defer(function() { o.fn(wrapSnap(ss, ref.key), ref.prevId, ref.idxValue) });
});
}
function addEventListener(list, callback, context) {
var fn = context? callback.bind(context) : callback;
list.push({
fn: fn,
cb: callback,
ctx: context
});
return fn;
}
function notifyExistingRecs(dataPathFn, refs, callback) {
var key;
for (key in refs) {
if (refs.hasOwnProperty(key) && refs[key].loaded) {
// must be external because key is mutable and we use it in a closure
getValAndNotify(dataPathFn(key), refs[key], key, callback);
}
}
}
function getValAndNotify(dataRef, idx, key, callback) {
dataRef.once('value', function(ss) {
if( ss.val() !== null ) { defer(function() { callback(wrapSnap(ss, key), idx.prevId, idx.idxValue); }); }
});
}
function storeChildRef(list, cb, ss, prevId) {
var key = ss.name();
var childRef = ss.ref();
list[key] = {
prevId: prevId,
loaded: false,
def: $? $.Deferred() : null,
ref: childRef,
dataSub: null,
key: key,
idxValue: ss.val(),
dispose: function() {
childRef.off('value', cb);
childRef.dataSub && childRef.dataSub.dispose();
delete list[key];
}
};
return childRef;
}
function reassignPrevId(refs, missingRef) {
var newPrevId = missingRef.prevId, oldPrevId = missingRef.key;
_.find(refs, function(r, k) {
if(r.key === oldPrevId) {
r.prevId = newPrevId;
return true;
}
return false;
});
}
function waitFor(refs, id, callback) {
var ref = id? refs[id] : null;
if( !id || !ref || ref.loaded ) {
callback();
}
else if( ref.def ) {
// use jQuery deferred if it exists (fast and efficient)
ref.def.done(callback);
}
else {
// do it the old fashioned way :(
setTimeout(function() {
waitFor(refs, id, callback);
}, 10);
}
}
function bindAll(o) {
var args = Array.prototype.slice.call(arguments, 1);
args.forEach(function(m) {
o[m] = o[m].bind(o);
});
}
function inheritsPrototype(to, from, fns) {
var key;
for (key in from.prototype) {
if (from.prototype.hasOwnProperty(key)) {
to.prototype[key] = from.prototype[key];
}
}
for (key in fns) {
if (fns.hasOwnProperty(key)) {
to.prototype[key] = fns[key];
}
}
}
var defer;
if( typeof(_) === 'object' && _ && typeof(_.defer) === 'function' ) {
// if underscore is available, use it
defer = _.defer;
}
else {
// otherwise, hope setTimeout hasn't been tinkered with
defer = function(fn) {
return setTimeout(fn, 0);
}
}
function wrapSnap(ss, key) {
ss.name = function() { return key; };
return ss;
}
function warn(txt, val) {
if( typeof(console) !== 'undefined' && console && console.warn ) {
console.warn(txt, ' ', val);
}
}
if (!Function.prototype.bind) {
// credits: https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind
Function.prototype.bind = function (oThis) {
if (typeof this !== "function") {
// closest thing possible to the ECMAScript 5 internal IsCallable function
throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
}
var aArgs = Array.prototype.slice.call(arguments, 1),
fToBind = this,
fNOP = function () {},
fBound = function () {
return fToBind.apply(this instanceof fNOP && oThis
? this
: oThis,
aArgs.concat(Array.prototype.slice.call(arguments)));
};
fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();
return fBound;
};
}
if (!Array.prototype.some) {
// credits: https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/some
Array.prototype.some = function(fun /*, thisp */)
{
"use strict";
if (this == null)
throw new TypeError();
var t = Object(this);
var len = t.length >>> 0;
if (typeof fun != "function")
throw new TypeError();
var thisp = arguments[1];
for (var i = 0; i < len; i++)
{
if (i in t && fun.call(thisp, t[i], i, t))
return true;
}
return false;
};
}
if ( !Array.prototype.forEach ) {
// credits: https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/forEach
Array.prototype.forEach = function(fn, scope) {
for(var i = 0, len = this.length; i < len; ++i) {
fn.call(scope, this[i], i, this);
}
}
}
exports.FirebaseIndex = FirebaseIndex;
})(jQuery, typeof(exports) === 'object' && exports? exports : window);