entangld 2.1.6 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,1335 +1,10 @@
1
- const { v4:uuidv4 } = require("uuid");
2
- const EventEmitter = require("events");
3
- const uuid_regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
-
5
- /**
6
- * Message class for Entangld.
7
- *
8
- * These messages are used for executing datastore operations between
9
- * remote datastores. In these relationships, there is always an upstream
10
- * and downstream pair, so that `get`, `set`, `push` and `subscribe` messages
11
- * are created upstream and passed downstream while `event` and `value` messages
12
- * are created downstream and are passed back up. `unsubscribe` events can travel
13
- * in both directions. To maintain consistency, the internal `.path` attribute
14
- * will always refer a path relative to the downstream datastore, since the
15
- * downstream datastores do not necessarilly have access to the upstream store
16
- * structure, and so cannot generally construct upstream paths. This means that
17
- * the `.path` attribute is a `tree` relative to the upstream datastore, and
18
- * the upstream path can be reconstructed as:
19
- * ```javascript
20
- * > upstream._namespaces.get(downstream) + "." + msg.path;
21
- * ```
22
- * Since `unsubscribe` messages can pass either upstream or downstream, the notion
23
- * of a path is ill-defined, and so unsubscribe messages should have their `.path`
24
- * attributes set to undefined or null.
25
- *
26
- * Most messages will also have a `.uuid` attribute. For `get`/`value` messages,
27
- * this allows for the value to be properly linked back up with the original `get`
28
- * message. For the `subscribe`/`unsubscribe`/`event` messages, this allows for
29
- * callback functions to be trigger properly, and for unsubscribe messages to
30
- * propogate both directions. `set`/`push` messages do not use the `.uuid` attribute
31
- * since they require no response.
32
- */
33
- class Entangld_Message {
34
-
35
- constructor({type, path, value, uuid, params, every}) {
36
-
37
- this.type = type;
38
- this.path = path;
39
- this.value = value;
40
- this.uuid = uuid;
41
- this.params = params;
42
- this.every = every;
43
- }
44
-
45
- // ----------------------
46
- // Class Methods
47
- // ----------------------
48
- /**
49
- * Create a `get` message for remote datastores
50
- *
51
- * @param {string} tree - the path relative to the remote datastore
52
- * @param {*} [get_params=undefined] - any parameters to be passed to the
53
- * remote datastore's local get function
54
- * @return {Entangld_Message} - The get message to pass to the remote datastore
55
- */
56
- static get(tree, get_params) {
57
- return new this({
58
- type: "get",
59
- path : tree, // path needs to be relative to downstream store
60
- value : get_params,
61
- uuid : uuidv4() // Gets generate a new uuid
62
- });
63
- }
64
-
65
- /**
66
- * Create a `value` message in response to a `get` message
67
- *
68
- * @param {Entangld_Message} get_msg - the `get` message which this is in response to
69
- * @param value - the value of the `get`
70
- * @return {Entangld_Message} - The `value` message to pass back
71
- */
72
- static value(get_msg, value) {
73
- return new this({
74
- type: "value",
75
- path : get_msg.path, // Resond with the get's path, which is relative to this store
76
- value : value,
77
- uuid : get_msg.uuid // Respond with the get's uuid
78
- });
79
- }
80
-
81
- /**
82
- * Create a `set`/`push` message for a remote datastore
83
- *
84
- * @param {Object} obj - The parameter object for the set/push
85
- * @param {string} obj.type - either "set" or "push"
86
- * @param {string} obj.tree - the path (relative to the downstream datastore)
87
- * @param {*} obj.value - the value to insert into the datastore
88
- * @param {*} obj.params - any additional parameters
89
- * @return {Entangld_Message} - the "set" or "push" message
90
- */
91
- static setpush({type = "set", tree, value, params}) {
92
- if (!["set", "push"].includes(type)) { throw new Error(`Invalid type (${type}) for setpush`); }
93
- return new this({
94
- type: type,
95
- path : tree,
96
- value : value,
97
- params : params
98
- });
99
- }
100
-
101
- /**
102
- * Construct subscribe message
103
- *
104
- * @param {string} tree - the path (relative to the downstream datastore)
105
- * @param {uuidv4} uuid - the subscription uuid
106
- * @return {Entangld_Message} the `subscribe` message
107
- */
108
- static subscribe(tree, uuid, every = null) {
109
- return new this({
110
- type : "subscribe",
111
- path : tree,
112
- uuid : uuid,
113
- every : every
114
- });
115
- }
116
-
117
- /**
118
- * Create an `event` message to return data to subscribe callbacks
119
- *
120
- * @param {string} path - the path (relative to the downstream store)
121
- * @param {*} value - the updated datastore value at the path
122
- * @param {uuidv4} uuid - the uuid of the subscribe being triggered
123
- * @return {Entangld_Message} the `event` message
124
- */
125
- static event(path, value, uuid) {
126
- return new this({
127
- type : "event",
128
- path : path,
129
- value : value,
130
- uuid : uuid
131
- });
132
- }
133
-
134
-
135
- /**
136
- * Create an unsubscribe message for a subscription uuid
137
- *
138
- * @param {String} uuid - the subscription uuid
139
- */
140
- static unsubscribe(uuid) {
141
- return new this({
142
- type: "unsubscribe",
143
- // path is ommitted from unsubscribe messages
144
- uuid : uuid
145
- });
146
- }
147
-
148
- }
149
-
150
- /**
151
- * Error class for Entangld.
152
- */
153
- class EntangldError extends Error {
154
- constructor(message) {
155
- super(message);
156
-
157
- this.name = this.constructor.name;
158
- Error.captureStackTrace(this, this.constructor);
159
- }
160
- }
161
-
162
-
163
- /**
164
- * A datastore subscription object
165
- */
166
- class Subscription {
167
- /**
168
- * Constructor
169
- *
170
- * @param {Object} obj - the configuration object
171
- * @param {string} obj.path - the datastore path (relative to this datastore)
172
- * of the subscription
173
- * @param {uuidv4} obj.uuid - the uuid of the subscription chain
174
- * @param {function} obj.callback - the callback function, with signature (path, value),
175
- * where path is relative to this datastore
176
- * @param {(Entangld|null)} obj.downstream - the downstream datastore (if any)
177
- * associated with this subscription
178
- * @param {(Entangld|null)} obj.upstream - the upstream datastore (if any)
179
- * associated with this subscription
180
- * @param {(number|null)} obj.every - how many `set` messages to wait before calling callback
181
- * @return {Subscription} - the subscription object
182
- */
183
- constructor({path, uuid, callback, downstream, upstream, every}) {
184
- this.path = path;
185
- this.downstream = downstream || null;
186
- this.upstream = upstream || null;
187
- this.uuid = uuid;
188
- this.callback = callback;
189
- this._has_cb = (typeof(callback) === "function");
190
- this._is_counting = (this.downstream === null);
191
- every = parseInt(every);
192
- if ( isNaN(every) || every < 1 ) {
193
- this.every = 1;
194
- } else {
195
- this.every = every;
196
- }
197
- this.index = -1; // guarentee that the first `set` triggers
198
- }
199
-
200
- /**
201
- * Apply this callback function
202
- *
203
- * Note, this method also tracks the number of times that a callback
204
- * function is called (if this subscription is terminal), so that if
205
- * the subscriptions are throttled by specifying an `this.every`,
206
- * this method will only call the callback function every `this.every`
207
- * times it receives a `set` message. If this subscription is not
208
- * terminal, then the callback function is called every time.
209
- *
210
- * This method also is safed when a callback function is not give (i.e.
211
- * by the `this.static_copy()` method).
212
- */
213
- call(...args) {
214
- if ( !this._has_cb ) return;
215
- if ( this._is_counting ) {
216
- this.index = (this.index + 1) % this.every;
217
- if ( this.index !== 0 ) return;
218
- }
219
- this.callback(...args);
220
- }
221
-
222
- /**
223
- * Check if subscription is a `pass through` type
224
- *
225
- * Pass throughs are as the links in a chain of subscriptions to allows
226
- * subscriptions to remote datastores. One store acts as the `head`, where
227
- * the callback function is registered, an all others are `path through` datastores
228
- * which simply pass event messages back up to the head subscription.
229
- *
230
- * @return {Boolean}
231
- */
232
- get is_pass_through() {
233
- return this.has_upstream;
234
- }
235
-
236
- /**
237
- * Check if this subscription will be directly given data by a datastore
238
- *
239
- * @return {Boolean}
240
- */
241
- get is_terminal() {
242
- return !this.has_downstream;
243
- }
244
-
245
- /**
246
- * Check if this subscription will apply a user-supplied callback to data
247
- *
248
- * @return {Boolean}
249
- */
250
- get is_head() {
251
- return !this.has_upstream;
252
- }
253
-
254
- /**
255
- * Check if subscription has any downstream subscriptions
256
- *
257
- * It the subscription refers to a remote datastore (the downstream), this
258
- * getter will return a true. Note that !this.has_downstream will check if
259
- * the subscription is the `tail` subscription object in a subscription chain.
260
- *
261
- * @return {Boolean}
262
- */
263
- get has_downstream() {
264
- return this.downstream !== null;
265
- }
266
-
267
- /**
268
- * Check if subscription has any upstream subscriptions
269
- *
270
- * It the subscription passes data back to a remote datastore (the upstream), this
271
- * getter will return a true.
272
- *
273
- * @return {Boolean}
274
- */
275
- get has_upstream() {
276
- return this.upstream !== null;
277
- }
278
-
279
-
280
- /**
281
- * Check if a different `Subscription` object matches this subscription
282
- *
283
- * @param {Subscription} sub - a different subscription
284
- * @return {Boolean} - True if the subscriptions match
285
- */
286
- matches_subscription(sub) {
287
- return this.matches_uuid(sub.uuid) && this.matches_path(sub.path);
288
- }
289
-
290
- /**
291
- * Check if an `event` message matches this subscription
292
- *
293
- * @param {Entangld_Message} msg - a received message from a downstream datastore
294
- * @return {Boolean} - True if the message is associated with the subscription
295
- */
296
- matches_event_message(msg) {
297
- // should be above, otherwise events below a subscription won't
298
- // trigger the callback. I.e. subscribing to `flower` won't
299
- // trigger when someone sets `flower.color`
300
- return this.matches_uuid(msg.uuid) && this.is_above(msg.path);
301
- }
302
-
303
- /**
304
- * Check if an `unsubscribe` message matches this subscription
305
- *
306
- * @param {Entangld_Message} msg - a received message from a downstream datastore
307
- * @return {Boolean} - True if the message is associated with the subscription
308
- */
309
- matches_unsubscribe_message(msg) {
310
- return this.matches_uuid(msg.uuid);
311
- }
312
-
313
- /**
314
- * Check if a provided path matches this path
315
- *
316
- * @param {String} path - a path string to check against
317
- * @return {Boolean} - true if the path matches
318
- */
319
- matches_path(path) {
320
- return this.path === path;
321
- }
322
-
323
- /**
324
- * Check if a provided uuid matches this uuid
325
- *
326
- * @param {uuidv4} uuid - a uuid string to check against
327
- * @return {Boolean} - true if the path matches
328
- */
329
- matches_uuid(uuid) {
330
- return this.uuid === uuid;
331
- }
332
-
333
- /**
334
- * Check if subscription path is beneath a provided path
335
- *
336
- * @param {String} path - a path string to check against
337
- * @return {Boolean} - true if the subscription is beneath the path
338
- */
339
- is_beneath(path) {
340
- return is_beneath(this.path, path)
341
- }
342
-
343
- /**
344
- * Check if subscription path is above a provided path
345
- *
346
- * @param {String} path - a path string to check against
347
- * @return {Boolean} - true if the subscription is beneath the path
348
- */
349
- is_above(path) {
350
- return is_beneath(path, this.path);
351
- }
352
-
353
- /**
354
- * Get a copy of this subscription without external references
355
- *
356
- * This creates a copy, except the upstream/downstream references
357
- * are set to true (if they exist) or null (if they don't. Addtionally,
358
- * the callback function is excluded.
359
- *
360
- * @return {Subscription} a copy of this subscription object
361
- */
362
- static_copy() {
363
- return new Subscription({
364
- path : this.path,
365
- uuid : this.uuid,
366
- downstream : (this.downstream === null ? null : true),
367
- upstream : (this.upstream === null ? null : true),
368
- every : this.every
369
- })
370
- }
371
- }
372
-
373
-
374
- /**
375
- * Deep copy an object
376
- *
377
- * Uses JSON parse methods so things that don't work in JSON will disappear,
378
- * with the special exception of functions which are replaced by their return
379
- * values (or if the function returns a promise, the value that it resolves to).
380
- *
381
- * If you pass undefined, it will return a promise resolving to undefined.
382
- *
383
- * @private
384
- * @param {object} original the object to copy.
385
- * @return {Promise} a promise resolving to a completely new, de-referenced
386
- * object containing only objects and values.
387
- */
388
- function dereferenced_copy(original) {
389
-
390
- // Undefined passes through
391
- if (original === undefined)
392
- return new Promise((res) => { res(undefined); });
393
-
394
- // Make a copy of o. It will be missing any functions
395
- let copy = JSON.parse(JSON.stringify(original));
396
-
397
- // A container to hold the copy (so we can have a recursive function
398
- // replace it using a key)
399
- let container = { "copy": copy };
400
-
401
- // We will store our promises here
402
- let promises = [];
403
-
404
- // Recursively call all functions in o, placing their values in copy.
405
- // Promises are tracked also.
406
- function recurse(o, c, parent, key) {
407
-
408
- // If o is a function, get its value and return a promise
409
- if (typeof (o) == "function") {
410
-
411
- // Call the function
412
- let result = o();
413
-
414
- // If the function itself returns a promise, save it and make it
415
- // replace the object with a result
416
- if (result && result.constructor && result.constructor.name
417
- && result.constructor.name == "Promise") {
418
-
419
- promises.push(promises, result);
420
- result.then((val) => {
421
-
422
- parent[key] = val;
423
- });
424
- } else {
425
-
426
- // Replace the object directly
427
- parent[key] = result;
428
- }
429
-
430
- return;
431
- }
432
-
433
-
434
- // If o is not an object, return
435
- if (typeof (o) != "object") return;
436
-
437
- // Otherwise, iterate keys and call ourselves recursively
438
- for (let key in o) {
439
-
440
- recurse(o[key], c[key], c, key);
441
- }
442
- }
443
-
444
- recurse(original, copy, container, "copy");
445
-
446
- // Wait for all promises to fulfil, then return the copy
447
- return Promise.all(promises).then(() => Promise.resolve(copy));
448
- }
449
-
450
-
451
- /**
452
- * Extract from path.
453
- *
454
- * Given an object, extracts a child object using a string path.
455
- *
456
- * @private
457
- * @param {object} obj the object we are extracting from.
458
- * @param {string} path the path to find beneath obj.
459
- * @return {Array<object, string>} array containing [result, remaining_path].
460
- * result will be undefined if path can't be found. If we encounter a function
461
- * in our search, result will be the function and remaining_path will be the
462
- * remaining part of path.
463
- */
464
- function extract_from_path(obj, path) {
465
-
466
- // Empty path means get everything
467
- if (path === "") return [obj, ""];
468
-
469
-
470
- let keys = path.split(".");
471
- let key;
472
- let o = obj;
473
-
474
- while ((key = keys.shift())) {
475
-
476
- if (key in o) {
477
-
478
- o = o[key];
479
-
480
- // If o is a function, we can't go any farther
481
- if (typeof (o) == "function") {
482
-
483
- return [o, keys.join(".")];
484
- }
485
-
486
- } else {
487
-
488
- return [undefined, ""];
489
- }
490
-
491
- }
492
-
493
- return [o, ""];
494
- }
495
-
496
- /**
497
- * Partial copy.
498
- *
499
- * Return a partial copy of an object (depth limited). DOES NOT DE-REFERENCE!
500
- *
501
- * @private
502
- * @param {object} object the object to copy
503
- * @param {number} max_depth the maximum depth to copy (0 returns object keys)
504
- * @return {object} partial copy of an object limited by max_depth.
505
- */
506
- function partial_copy(o, max_depth) {
507
-
508
- // Trivial case, return object untouched if no max_depth
509
- if (typeof (max_depth) != "number") return o;
510
-
511
- // If o is not an object, return it
512
- if (typeof (o) != "object") return o;
513
-
514
- let c = (Array.isArray(o)) ? [] : {};
515
-
516
- // If max_depth has been exceeded, return the empty object or array
517
- if (max_depth < 0) return c;
518
-
519
- // Otherwise, iterate keys and call ourselves recursively
520
- for (let key in o) {
521
-
522
- if (typeof (o[key]) != "object") {
523
-
524
- c[key] = o[key];
525
-
526
- } else {
527
-
528
- c[key] = partial_copy(o[key], max_depth - 1);
529
- }
530
- }
531
-
532
- return c;
533
- }
534
-
535
- /**
536
- * Is beneath
537
- *
538
- * Is `a` under `b`? E.g. is "system.bus.voltage" equal to or beneath "system.bus"?
539
- *
540
- * @private
541
- * @param {string} a the string tested for "insideness"
542
- * @param {string} b the string tested for "outsideness"
543
- * @return boolean
544
- */
545
- function is_beneath(a, b) {
546
-
547
- // Everything is beneath the top ("")
548
- if(b==="") return true;
549
-
550
- // If paths are both blank, they are equal
551
- if(b==="" && a==="") return true;
552
-
553
- let A=a.split(".");
554
- let B=b.split(".");
555
-
556
- // A is not beneath B if any part is not the same
557
- while(A.length && B.length){
558
-
559
- if(A.shift()!=B.shift()) return false;
560
- }
561
-
562
- // A is not beneath B if B is longer
563
- if(B.length) return false;
564
-
565
- return true;
566
- }
567
- /**
568
- * Synchronized Event Store
569
- *
570
- * @extends EventEmitter
571
- */
572
- class Entangld extends EventEmitter {
573
-
574
- constructor() {
575
- super();
576
-
577
- this._stores = new Map();
578
- this._namespaces = new Map();
579
-
580
- this._transmit = () => { };
581
- this._local_data = {};
582
- this._requests = {};
583
- this._subscriptions = [];
584
-
585
- // These are global but need to be accessible for various reasons
586
- // (i.e. testing)
587
- this._partial_copy = partial_copy;
588
- this._dereferenced_copy = dereferenced_copy;
589
-
590
- // If this is turned on, when someone .get()s an object we will query
591
- // its children to see if they are functions. We will then call those
592
- // functions and return their values. This should be the default
593
- // behavior, but it is rather expensive. TODO
594
- this._deref_mode = false;
595
- }
596
-
597
- /**
598
- * Get namespaces
599
- *
600
- * @readonly
601
- * @return {array} namespaces - an array of attached namespaces
602
- */
603
- get namespaces() {
604
-
605
- return Array.from(this._stores.keys());
606
- }
607
-
608
- /**
609
- * Get list of subscriptions associated with this object
610
- *
611
- * Note, this will include `head`, `terminal` and `pass through` subscriptions,
612
- * which can be checked using getter methods of the subscription object.
613
- *
614
- * @readonly
615
- * @return {Subscription[]} array of Subscriptions associated with this object
616
- */
617
- get subscriptions() {
618
- return this._subscriptions.map(v => v.static_copy());
619
- }
620
-
621
- /**
622
- * Get namespace for a store
623
- *
624
- * @readonly
625
- * @return {string} namespace for the given store
626
- */
627
- namespace(store) {
628
-
629
- // return this._namespaces(store);
630
- return this._namespaces.get(store);
631
- }
632
-
633
- /**
634
- * Attach a namespace and a store
635
- *
636
- * @param {string} namespace a namespace for this store.
637
- * @param {object} obj an object that will be sent along with "transmit"
638
- * callbacks when we need something from this store.
639
- * @throws {TypeError} if namespace/obj is null or empty.
640
- * @throws {EntangldError} if you try to attach to the same namespace twice.
641
- */
642
- attach(namespace, obj) {
643
-
644
- // Sanity checks
645
- if (!namespace)
646
- throw TypeError("You cannot attach() a null or empty namespace");
647
-
648
- if (!obj)
649
- throw TypeError("You cannot attach() a null or empty object");
650
-
651
- if (this._stores.has(namespace))
652
- throw new EntangldError("You already attach()ed that namespace");
653
-
654
- // Attach the store and namespace
655
- this._stores.set(namespace, obj);
656
- this._namespaces.set(obj, namespace);
657
-
658
- // Create an empty local node so that this attach point is visible
659
- this._set_local(namespace, {});
660
-
661
- // Find and update any subscriptions that fall beneath the new namespace
662
- const subscriptions = this._subscriptions.filter(
663
- s => s.is_beneath(namespace)
664
- );
665
-
666
- // Clean up the old entries
667
- this._unsubscribe(subscriptions);
668
-
669
- // Re-subscribe
670
- subscriptions.forEach(s => {
671
- this._subscribe(s.path, s.callback, s.upstream, s.uuid, s.every);
672
- });
673
- }
674
-
675
- /**
676
- * Detach a namespace / obj pair.
677
- *
678
- * If you only pass a namespace or a store, it will find the missing item
679
- * before detaching.
680
- *
681
- * @param {string} [namespace] the namespace.
682
- * @param {object} [obj] the store object.
683
- * @return {boolean} true if the element existed and was removed.
684
- * @throws {EntangldError} Error will be thrown if you don't pass at least
685
- * one parameter.
686
- */
687
- detach(namespace, obj) {
688
-
689
- if (!namespace && !obj) {
690
- const msg = "You must specify at least one of either namespace or "
691
- + "object when calling detach()"
692
- throw new EntangldError(msg);
693
- }
694
-
695
- if (!obj) obj = this._stores.get(namespace);
696
- if (!namespace) namespace = this._namespaces.get(obj);
697
-
698
- // Detach this namespace placeholder from the local store
699
- this._set_local(namespace, undefined);
700
-
701
- return this._stores.delete(namespace)
702
- && this._namespaces.delete(obj);
703
- }
704
-
705
- /**
706
- * Transmit
707
- *
708
- * Specify a callback to be used so we can transmit data to another store.
709
- * Callback will be passed (message, obj) where 'message' is an
710
- * Entangld_Message object and obj is the object provided by attach().
711
- *
712
- * @param {function} func the callback function.
713
- * @throws {TypeError} if func is not a function.
714
- */
715
- transmit(func) {
716
- if (typeof (func) != "function")
717
- throw TypeError("func must be a function")
718
-
719
- this._transmit = func;
720
- }
721
-
722
- /**
723
- * Receive
724
- *
725
- * Call this function with the data that was sent via the transmit()
726
- * callback.
727
- *
728
- * @param {Entangld_Message} msg the message to process.
729
- * @param {object} obj the attach() object where the message originted.
730
- *
731
- * @throws {ReferenceError} if event object was not provided.
732
- * @throws {EntangldError} if an unknown message type was received.
733
- */
734
- receive(msg, obj) {
735
-
736
- if (msg.type == "set" || msg.type == "push") {
737
- // Remote "set" request
738
- this.set(msg.path, msg.value, msg.type, msg.params);
739
-
740
- } else if (msg.type == "get") {
741
- // Remote "get" request
742
- this.get(msg.path, msg.value).then((val) => {
743
-
744
- const response = Entangld_Message.value(msg,val)
745
-
746
- this._transmit(response, obj);
747
- });
748
-
749
- } else if (msg.type == "value") {
750
- // Incoming value reply
751
- let resolve = this._requests[msg.uuid];
752
- resolve(msg.value);
753
-
754
- //Clean up request
755
- delete this._requests[msg.uuid];
756
-
757
- } else if (msg.type == "event") {
758
- // Incoming event
759
- if (obj === undefined)
760
- throw new ReferenceError("receive() called without object");
761
-
762
- // From our perspective the path is now prepended with the namespace
763
- msg.path = this._namespaces.get(obj) + "." + msg.path;
764
-
765
- // Find and dispatch any subscriptions
766
- let count = 0;
767
- for (let s of this._subscriptions) {
768
-
769
- if (s.matches_event_message(msg)) {
770
-
771
- // Call the callback
772
- s.call(msg.path, msg.value);
773
- count++;
774
- }
775
- }
776
-
777
- // No one is listening. This may happen if an event triggers while
778
- // we are still unsubscribing, or if a downstream subscription gets
779
- // orphaned
780
- if (count === 0) {
781
-
782
- // Reply with unsubscribe request
783
- const response = Entangld_Message.unsubscribe(msg.uuid);
784
- this._transmit(response, obj);
785
- }
786
-
787
- } else if (msg.type == "subscribe") {
788
- // Incoming remote subscription request
789
-
790
- // Create a new subscription that simply transmits when triggered
791
- // the "tree" from the subscribe message is scoped to be a "path"
792
- // here in this datastore
793
- this._subscribe(msg.path, (path, val) => {
794
- // The "path" here is relative to this datastore, and can
795
- // potentially be beneath msg.params.tree
796
- const response = Entangld_Message.event(path,val,msg.uuid);
797
- this._transmit(response, obj);
798
- }, obj, msg.uuid, msg.every);
799
-
800
- } else if (msg.type == "unsubscribe") {
801
- // Incoming remote unsubscribe request
802
-
803
- // Unsubscribe to any matching subscriptions.
804
- this._unsubscribe(this._subscriptions.filter(
805
- s => s.matches_unsubscribe_message(msg))
806
- );
807
-
808
- } else {
809
- // Default
810
- throw new EntangldError(
811
- "Received unknown message: " + JSON.stringify(msg));
812
- }
813
- }
814
-
815
-
816
- /**
817
- * Push an object into an array in the store.
818
- *
819
- * Convenience method for set(path, o, "push").
820
- *
821
- * @param {string} path the path to set (like "system.fan.voltage").
822
- * @param {object} data the object or function you want to store at path.
823
- * @param {number} [limit] maximum size of the array. Older entries will be
824
- * removed until the array size is less than or equal to limit.
825
- *
826
- * @throws {TypeError} if path is not a string.
827
- */
828
- push(path, data, limit=null) {
829
-
830
- this.set(path, data, "push", { limit });
831
-
832
- }
833
-
834
-
835
- /**
836
- * Set an object into the store
837
- *
838
- * @param {string} path the path to set (like "system.fan.voltage").
839
- * @param {object} data the object or function you want to store at path.
840
- * @param {string} [operation_type="set"] whether to set or push the new
841
- * data (push only works if the data item exists and is an array).
842
- * @param {object} [params] additional parameters.
843
- *
844
- * @throws {TypeError} if path is not a string.
845
- */
846
- set(path, data, operation_type="set", params={}) {
847
-
848
- // Sanity check
849
- if (typeof (path) != "string")
850
- throw TypeError("path must be a string");
851
-
852
- let [obj, , tree] = this._get_remote_object(path);
853
-
854
- if (obj === undefined) {
855
- // Set local or remote item
856
-
857
- // Is this going to mess with an attached store?
858
- for (let [namespace] of this._stores) {
859
-
860
- if (is_beneath(namespace, path)) {
861
-
862
- const msg = `Cannot set ${path} - doing so would overwrite `
863
- + `remote store attached at ${path}. `
864
- + "Please detach first"
865
- throw new EntangldError(msg);
866
- }
867
- }
868
-
869
- this._set_local(path, data, operation_type, params);
870
-
871
- // Check subscriptions to see if we need to run an event
872
- for (let s of this._subscriptions) {
873
-
874
- // is this broken? if path="data.element" and s.path="data",
875
- // then the callback will get triggered, but with the sub path,
876
- // i.e. cb("data.element",data), except, the callbacks often
877
- // don't check the path, so it will think it is getting a 'data'
878
- // object, not a "data.path" object . . .
879
- if (s.is_above(path)) s.call(path, data)
880
- }
881
-
882
- } else {
883
-
884
- const msg = Entangld_Message.setpush({
885
- type : operation_type,
886
- tree : tree,
887
- value : data,
888
- params : params
889
- })
890
-
891
- this._transmit(msg, obj);
892
- }
893
-
894
- }
895
-
896
-
897
- /**
898
- * Get the remote store in a path
899
- *
900
- * If path contains a remote store, return the object as well as the
901
- * relative path to the remote store
902
- *
903
- * Example: let A,B be stores. If A.attach("some.path.to.B", B) then
904
- * _get_remote_object("some.path.to.B.data") will return
905
- * [B, "some.path.to.B", "data"] and _get_remote_object("nonexistent.path")
906
- * will return [undefined, undefined, path]
907
- *
908
- * @private
909
- * @param {string} path the path to investigate.
910
- *
911
- * @return {array<object, string, string>} array whose elements are
912
- * [obj, namespace, relative path below store]. If no store is found,
913
- * return value is [undefined, undefined, path].
914
- */
915
- _get_remote_object(path) {
916
-
917
- // We don't know what part of path might be a store key. So we need to
918
- // get all store keys and search (vs .get)
919
- for (let [namespace, obj] of this._stores) {
920
-
921
- if (is_beneath(path, namespace)) {
922
-
923
- // Exact match means path is the root of the attached store
924
- if (path.length == namespace.length) {
925
-
926
- return [obj, namespace, ""];
927
- }
928
-
929
- // Path is longer than namespace. Make sure we match to a period
930
- if (path.substr(namespace.length, 1) == ".") {
931
-
932
- path = path.substr(namespace.length + 1)
933
- return [obj, namespace, path];
934
- }
935
- }
936
- }
937
-
938
- // Default: we did not find a store
939
- return [undefined, undefined, path];
940
- }
941
-
942
- /**
943
- * Get an object from the store.
944
- *
945
- * Note: using max_depth, especially large max_depth, involves a lot of
946
- * recursion and may be expensive.
947
- *
948
- * @param {string} path the path to query (like "system.voltage").
949
- * @param {object} [params] the parameters to be passed to the remote
950
- * function (RPC) or the maximum depth of the returned object (normal mode).
951
- *
952
- * @throws {TypeError} if path is not a string.
953
- * @return {Promise} promise resolving to the object at that path.
954
- */
955
- get(path, params) {
956
- // Sanity check
957
- if (typeof (path) != "string")
958
- throw TypeError("path must be a string");
959
-
960
- // If an attach()ed store path masks (matches but is shorter than) the
961
- // path, we are returning that store
962
- let [obj, , tree] = this._get_remote_object(path);
963
-
964
- // If object is undefined, we are getting a local item
965
- if (obj === undefined) {
966
-
967
- // Get a reference to the object
968
- let [data, remaining_path] = extract_from_path(
969
- this._local_data, path);
970
-
971
- // If it is a function, call it
972
- if (typeof (data) == "function") {
973
-
974
- let result = data(params);
975
-
976
- // Get a promise resolving to the result
977
- let p = (result && result.constructor && result.constructor.name
978
- && result.constructor.name == "Promise") ?
979
- result : new Promise((res) => res(result));
980
-
981
- return p.then((val) => {
982
-
983
- // If we have a remaining path, try to navigate it before
984
- // returning
985
- [data, remaining_path] = extract_from_path(
986
- val, remaining_path);
987
-
988
- return Promise.resolve(
989
- (remaining_path === "")? data : undefined);
990
- });
991
- }
992
-
993
- // Path does not point to function. So use params as a max depth
994
- // and return a Promise resolving to a dereferenced partial copy
995
- if (typeof (params) == "number") {
996
-
997
- if (this._deref_mode) {
998
-
999
- return dereferenced_copy(partial_copy(data, params));
1000
- } else {
1001
-
1002
- return new Promise(
1003
- (res) => res(partial_copy(data, params)));
1004
- }
1005
- }
1006
-
1007
- // Default: return a promise resolving to the entire object as-is
1008
- if (this._deref_mode) {
1009
-
1010
- return dereferenced_copy(data);
1011
-
1012
- } else {
1013
-
1014
- return new Promise((res) => res(data));
1015
- }
1016
- }
1017
-
1018
- // Request the data from the remote store
1019
- const msg = Entangld_Message.get(tree, params);
1020
-
1021
- return new Promise((res) => {
1022
- this._requests[msg.uuid] = res;
1023
- this._transmit(msg, obj);
1024
- });
1025
-
1026
- }
1027
-
1028
- /**
1029
- * Subscribe to change events for a path
1030
- *
1031
- * If objects at or below this path change, you will get a callback
1032
- *
1033
- * Subscriptions to keys within attach()ed stores are remote subscriptions.
1034
- * If several stores are attached in some kind of arrangement, a given key
1035
- * may actually traverse multiple stores! Since each store only knows its
1036
- * immediate neighbors - and has no introspection into those neighbors - each
1037
- * store is only able to keeps track of the neighbor on each side with
1038
- * respect to a particular path and has no knowledge of the eventual
1039
- * endpoints. This means that subscribing across several datstores is accomplished
1040
- * by daisy-chaining 2-way subscriptions across each datastore interface.
1041
- *
1042
- * For example, let's suppose capital letters represent Entangld stores and
1043
- * lowercase letters are actual objects. Then the path "A.B.c.d.E.F.g.h"
1044
- * will represent a subscription that traverses four Entangld stores.
1045
- * From the point of view of a store in the middle - say, E - the "upstream"
1046
- * is B and the "downstream" is F.
1047
- *
1048
- * Each store involved keeps track of any subscriptions with which it is
1049
- * involved. It tracks the upstream and downstream, and the uuid of the
1050
- * subscription. The uuid is the same across all stores for a given
1051
- * subscription. For a particular store, the upstream is null if it is the
1052
- * original link in the chain (called the `head`), and the downstream is
1053
- * null if this store owns the endpoint value (called the `tail`). Any
1054
- * subscription which is not the head of a chain is called a `pass through`
1055
- * subscription, because it exist only to pass `event` messages back up the
1056
- * chain to the head (where the user-provided callback function exists).
1057
- * subscriptions can be checked to see if they are `pass through` type via
1058
- * the getter `sub.is_pass_through`.
1059
- *
1060
- * @param {string} path the path to watch.
1061
- * @param {function} func the callback - will be of the form (path, value).
1062
- * @param {number|null} [every=null] the number of `set` messages to wait before calling callback
1063
- *
1064
- * @throws {TypeError} if path is not a string.
1065
- * @return {uuidv4} - the uuid of the subscription
1066
- */
1067
- subscribe(path, func, every = null) {
1068
-
1069
- return this._subscribe(path, func, null, null, every);
1070
- }
1071
-
1072
- /**
1073
- * Create a subscription
1074
- *
1075
- * This is an internal function that allows us to specify the new
1076
- * subscription's upstream and UUID.
1077
- *
1078
- * @private
1079
- * @param {string} path the path to watch.
1080
- * @param {function} func the callback - will be of the form (path, value).
1081
- * @param {(Entangld|null)} [upstream=null] the Engangld next upstream in the path.
1082
- * @param {(uuidv4|null)} [uuid=null] the UUID to use for this subscription.
1083
- *
1084
- * @emits {str} subscription - when this datastore is the terminal datastore of a
1085
- * subscription request, this datastore emits the path
1086
- * and uuid.
1087
- * @throws {TypeError} if path is not a string.
1088
- * @return {uuidv4} - the uuid of the subscription
1089
- */
1090
- _subscribe(path, func, upstream = null, uuid = null, every = null) {
1091
-
1092
-
1093
- // Supply a UUID if none provided
1094
- uuid = uuid || uuidv4();
1095
-
1096
-
1097
- // Sanity check
1098
- if (typeof (path) != "string")
1099
- throw TypeError("path must be a string");
1100
-
1101
- let [obj, /*namespace*/, tree] = this._get_remote_object(path);
1102
-
1103
- // Create the subscription object
1104
- let new_sub = new Subscription({
1105
- path : path,
1106
- downstream : obj,
1107
- upstream : upstream,
1108
- uuid : uuid,
1109
- callback : func,
1110
- every : every
1111
- })
1112
- // Filter out any residual subscriptions
1113
- // Note, residual subscriptions should only exist if a remote datastore (connected via a
1114
- // socket, or something like that) re-attaches this datastore after the connection between
1115
- // the two was uncerimonally destroyed, otherwise the internal clean up of the attach
1116
- // should have propegated down to prevent this.
1117
- // Note, this should still allow circular references, since "match_subscriptions" requires
1118
- // both the uuid and path to match
1119
- this._subscriptions = this._subscriptions.filter(sub => !new_sub.matches_subscription(sub));
1120
-
1121
- // Add the new subscription here
1122
- this._subscriptions.push(new_sub);
1123
-
1124
- // Commission any subscribes downstream, if this is a remote subscription
1125
- if (new_sub.has_downstream) {
1126
- const msg = Entangld_Message.subscribe(tree, uuid, every);
1127
- this._transmit(msg, obj);
1128
-
1129
- } else { // this is the terminal datastore, so emit subscription received
1130
- this.emit("subscription", path, uuid)
1131
- }
1132
-
1133
- return new_sub.uuid
1134
- }
1135
-
1136
- /**
1137
- * Check for subscription
1138
- *
1139
- * Are we subscribed to a particular remote path?
1140
- *
1141
- * @param {String} subscription the subscription to check for.
1142
- *
1143
- * @return {Boolean} true if we are subscribed.
1144
- */
1145
- subscribed_to(path) {
1146
-
1147
- for (let s of this._subscriptions) {
1148
-
1149
- if (s.matches_path(path)) return true;
1150
- }
1151
-
1152
- return false;
1153
- }
1154
-
1155
- /**
1156
- * Unubscribe to change events for a given path or uuid.
1157
- *
1158
- * Caution - if a path is provided, _all_ events belonging to you with that
1159
- * path will be deleted, so if you have multiple subscriptions on a single path,
1160
- * and only want one of them to be removed, you must provide the uuid instead.
1161
- *
1162
- * @param {(String|uuidv4)} path_or_uuid - the path (or uuid) to unwatch.
1163
- * @throws {EntangldError} if no subscriptions were found.
1164
- * @return {number} count of subscriptions removed.
1165
- */
1166
- unsubscribe(path_or_uuid) {
1167
-
1168
- let matching_subs = [];
1169
-
1170
- if (path_or_uuid.match(uuid_regex)) {
1171
- // `path_or_uuid` is a uuid, so find any matching subs with that uuid
1172
- matching_subs = this._subscriptions.filter(
1173
- s => s.matches_uuid(path_or_uuid) && !s.is_pass_through
1174
- );
1175
- } else {
1176
- // the arg is a path
1177
- matching_subs = this._subscriptions.filter(
1178
- // search for subs which both have the correct path AND are not
1179
- // pass through, so that pass through subs don't get orphaned
1180
- s => s.matches_path(path_or_uuid) && !s.is_pass_through
1181
- );
1182
- }
1183
-
1184
- // Throw an error if none found
1185
- if (matching_subs.length == 0) {
1186
-
1187
- throw new EntangldError(`unsubscribe found no subscriptions matching ${path_or_uuid}`);
1188
- }
1189
-
1190
- this._unsubscribe(matching_subs);
1191
-
1192
- return matching_subs.length;
1193
- }
1194
-
1195
- /**
1196
- * Unsubscribe tree.
1197
- *
1198
- * Remove any subscriptions that are beneath a path.
1199
- *
1200
- * @param {string} path the tree to unwatch.
1201
- *
1202
- * @throws {EntangldError} error if there are stores we cannot detach
1203
- * (i.e. they belong to someone else / upstream != null)
1204
- */
1205
- unsubscribe_tree(path) {
1206
-
1207
- let matching_subscriptions = this._subscriptions.filter(
1208
- // Get all the subs beneath this path, which are not pass throughs,
1209
- // so that subscriptions don't get orphaned
1210
- s => s.is_beneath(path) && !s.is_pass_through
1211
- );
1212
-
1213
- this._unsubscribe(matching_subscriptions);
1214
-
1215
- // Error on any remaining `pass through` subscriptions
1216
- matching_subscriptions = this._subscriptions.filter(
1217
- s => s.is_beneath(path)
1218
- )
1219
-
1220
- if (matching_subscriptions.length != 0) {
1221
-
1222
- const msg = `Unable to completely unsubscribe the tree '${path}' `
1223
- + "because some subscriptions are remote or passthrough "
1224
- + "(we are not the owner, just a downstream)"
1225
-
1226
- throw new EntangldError(msg);
1227
- }
1228
- }
1229
-
1230
- /**
1231
- * Unubscribe to change events
1232
- *
1233
- * This is an internal function that does not check for safety. It simply
1234
- * deletes the requested paths and notifies any downstream.
1235
- *
1236
- * @private
1237
- * @param {Subscription[]} subscriptions an array of objects containing at minimum
1238
- * uuid and upstream keys
1239
- */
1240
- _unsubscribe(subscriptions) {
1241
-
1242
- // Get a list of uuids to remove
1243
- const uuids = subscriptions.map(s => s.uuid);
1244
-
1245
- // Drop all subscriptions with matching uuid
1246
- this._subscriptions = this._subscriptions.filter(
1247
- s => !(uuids.includes(s.uuid))
1248
- );
1249
-
1250
- // Notify the downstream for any deleted subscriptions that are remote
1251
- subscriptions.forEach((s) => {
1252
- if (!s.has_downstream) { // emit unsubscription
1253
- this.emit("unsubscription", s.path, s.uuid);
1254
- } else { // Notify downstreams
1255
- const msg = Entangld_Message.unsubscribe(s.uuid);
1256
- this._transmit(msg, s.downstream);
1257
- }
1258
- });
1259
- }
1260
-
1261
- /**
1262
- * Set local object
1263
- *
1264
- * Sets object into local data store.
1265
- *
1266
- * @private
1267
- * @param {string} path the path at which to store the object.
1268
- * @param {object} data the object to store. If undefined, it unsets path.
1269
- * @param {string} [op_type] whether to set or push the new
1270
- * data (push only works if the data item exists and is an array).
1271
- * @param {object} [params] additional parameters.
1272
- *
1273
- * @throws {EntangldError} if attempting to set root store to a non-object.
1274
- * @throws {TypeError} if attempting to push to a non-array.
1275
- */
1276
- _set_local(path, data, op_type="set", params={}) {
1277
- // Empty path means set everything
1278
- if (path === "") {
1279
- if(data === undefined)
1280
- data = {}
1281
-
1282
- // Sanity check
1283
- if (typeof (data) != "object") {
1284
- const msg = "You are trying to set the root store to something "
1285
- + `(${typeof (data)}) besides an object!`
1286
-
1287
- throw EntangldError(msg);
1288
- }
1289
-
1290
- this._local_data = data;
1291
- return;
1292
- }
1293
-
1294
- let elements = path.split(".");
1295
- let last = elements.pop();
1296
- let pointer = this._local_data;
1297
-
1298
- for (let el of elements) {
1299
-
1300
- if (!pointer[el]) pointer[el] = {};
1301
- pointer = pointer[el];
1302
- }
1303
-
1304
- // Handle unset operations
1305
- if (data === undefined) {
1306
-
1307
- delete (pointer[last]);
1308
- return;
1309
- }
1310
-
1311
- if (op_type == "push") {
1312
-
1313
- if (pointer[last] && typeof (pointer[last].push) == "function") {
1314
-
1315
- pointer[last].push(data);
1316
- if(params.limit) {
1317
- while(pointer[last].length > params.limit)
1318
- pointer[last].shift(data);
1319
- }
1320
-
1321
- } else {
1322
-
1323
- throw new TypeError("You cannot .push() to that object");
1324
- }
1325
-
1326
- } else {
1327
-
1328
- // Default to set
1329
- pointer[last] = data;
1330
- }
1331
- }
1332
- }
1333
-
1
+ const Entangld = require("./lib/Datastore.js");
2
+
3
+ // Attach objects for destructuring requires
4
+ Entangld.Datastore = require("./lib/Datastore.js");
5
+ Entangld.error = require("./lib/error.js");
6
+ Entangld.Message = require("./lib/Message.js");
7
+ Entangld.Subscription = require("./lib/Subscription.js");
8
+ Entangld.utils = require("./lib/utils.js");
1334
9
 
1335
10
  module.exports = exports = Entangld;