entangld 2.2.0 → 2.3.1

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.
@@ -0,0 +1,855 @@
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
+ const Entangld_Message = require("./Message.js");
6
+ const Subscription = require("./Subscription.js");
7
+ const { EntangldError, RPCError } = require("./error.js");
8
+ const {
9
+ is_beneath,
10
+ extract_from_path,
11
+ partial_copy,
12
+ dereferenced_copy,
13
+ parameter_parser
14
+ } = require("./utils.js");
15
+
16
+ /**
17
+ * Synchronized Event Store
18
+ *
19
+ * @extends EventEmitter
20
+ */
21
+ class Entangld extends EventEmitter {
22
+
23
+ constructor() {
24
+ super();
25
+
26
+ this._stores = new Map();
27
+ this._namespaces = new Map();
28
+
29
+ this._transmit = () => { };
30
+ this._local_data = {};
31
+ this._requests = {};
32
+ this._subscriptions = [];
33
+
34
+ // If this is turned on, when someone .get()s an object we will query
35
+ // its children to see if they are functions. We will then call those
36
+ // functions and return their values. This should be the default
37
+ // behavior, but it is rather expensive. TODO
38
+ this._deref_mode = false;
39
+ }
40
+
41
+ /**
42
+ * Get namespaces
43
+ *
44
+ * @readonly
45
+ * @return {array} namespaces - an array of attached namespaces
46
+ */
47
+ get namespaces() {
48
+
49
+ return Array.from(this._stores.keys());
50
+ }
51
+
52
+ /**
53
+ * Get list of subscriptions associated with this object
54
+ *
55
+ * Note, this will include `head`, `terminal` and `pass through` subscriptions,
56
+ * which can be checked using getter methods of the subscription object.
57
+ *
58
+ * @readonly
59
+ * @return {Subscription[]} array of Subscriptions associated with this object
60
+ */
61
+ get subscriptions() {
62
+ return this._subscriptions.map(v => v.static_copy());
63
+ }
64
+
65
+ /**
66
+ * Get namespace for a store
67
+ *
68
+ * @readonly
69
+ * @return {string} namespace for the given store
70
+ */
71
+ namespace(store) {
72
+
73
+ // return this._namespaces(store);
74
+ return this._namespaces.get(store);
75
+ }
76
+
77
+ /**
78
+ * Attach a namespace and a store
79
+ *
80
+ * @param {string} namespace a namespace for this store.
81
+ * @param {object} obj an object that will be sent along with "transmit"
82
+ * callbacks when we need something from this store.
83
+ * @throws {TypeError} if namespace/obj is null or empty.
84
+ * @throws {EntangldError} if you try to attach to the same namespace twice.
85
+ */
86
+ attach(namespace, obj) {
87
+
88
+ // Sanity checks
89
+ if (!namespace)
90
+ throw TypeError("You cannot attach() a null or empty namespace");
91
+
92
+ if (!obj)
93
+ throw TypeError("You cannot attach() a null or empty object");
94
+
95
+ if (this._stores.has(namespace))
96
+ throw new EntangldError("You already attach()ed that namespace");
97
+
98
+ // Attach the store and namespace
99
+ this._stores.set(namespace, obj);
100
+ this._namespaces.set(obj, namespace);
101
+
102
+ // Create an empty local node so that this attach point is visible
103
+ this._set_local(namespace, {});
104
+
105
+ // Find and update any subscriptions that fall beneath the new namespace
106
+ const subscriptions = this._subscriptions.filter(
107
+ s => s.is_beneath(namespace)
108
+ );
109
+
110
+ // Clean up the old entries
111
+ this._unsubscribe(subscriptions);
112
+
113
+ // Re-subscribe
114
+ subscriptions.forEach(s => {
115
+ this._subscribe(s.path, s.callback, s.upstream, s.uuid, s.every);
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Detach a namespace / obj pair.
121
+ *
122
+ * If you only pass a namespace or a store, it will find the missing item
123
+ * before detaching.
124
+ *
125
+ * @param {string} [namespace] the namespace.
126
+ * @param {object} [obj] the store object.
127
+ * @return {boolean} true if the element existed and was removed.
128
+ * @throws {EntangldError} Error will be thrown if you don't pass at least
129
+ * one parameter.
130
+ */
131
+ detach(namespace, obj) {
132
+
133
+ if (!namespace && !obj) {
134
+ const msg = "You must specify at least one of either namespace or "
135
+ + "object when calling detach()";
136
+ throw new EntangldError(msg);
137
+ }
138
+
139
+ if (!obj) obj = this._stores.get(namespace);
140
+ if (!namespace) namespace = this._namespaces.get(obj);
141
+
142
+ // Detach this namespace placeholder from the local store
143
+ this._set_local(namespace, undefined);
144
+
145
+ return this._stores.delete(namespace)
146
+ && this._namespaces.delete(obj);
147
+ }
148
+
149
+ /**
150
+ * Transmit
151
+ *
152
+ * Specify a callback to be used so we can transmit data to another store.
153
+ * Callback will be passed (message, obj) where 'message' is an
154
+ * Entangld_Message object and obj is the object provided by attach().
155
+ *
156
+ * @param {function} func the callback function.
157
+ * @throws {TypeError} if func is not a function.
158
+ */
159
+ transmit(func) {
160
+ if (typeof (func) != "function")
161
+ throw TypeError("func must be a function");
162
+
163
+ this._transmit = func;
164
+ }
165
+
166
+ /**
167
+ * Receive
168
+ *
169
+ * Call this function with the data that was sent via the transmit()
170
+ * callback.
171
+ *
172
+ * @param {Entangld_Message} msg the message to process.
173
+ * @param {object} obj the attach() object where the message originted.
174
+ *
175
+ * @throws {ReferenceError} if event object was not provided.
176
+ * @throws {EntangldError} if an unknown message type was received.
177
+ */
178
+ receive(msg, obj) {
179
+
180
+ if (msg.type == "set" || msg.type == "push") {
181
+ // Remote "set" request
182
+ this.set(msg.path, msg.value, msg.type, msg.params);
183
+
184
+ } else if (msg.type == "get") {
185
+ // Remote "get" request
186
+ this.get(msg.path, msg.value).then((val) => {
187
+
188
+ const response = Entangld_Message.value(msg,val);
189
+
190
+ this._transmit(response, obj);
191
+ });
192
+
193
+ } else if (msg.type == "value") {
194
+ // Incoming value reply
195
+ let resolve = this._requests[msg.uuid];
196
+ resolve(msg.value);
197
+
198
+ //Clean up request
199
+ delete this._requests[msg.uuid];
200
+
201
+ } else if (msg.type == "event") {
202
+ // Incoming event
203
+ if (obj === undefined)
204
+ throw new ReferenceError("receive() called without object");
205
+
206
+ // From our perspective the path is now prepended with the namespace
207
+ msg.path = this._namespaces.get(obj) + "." + msg.path;
208
+
209
+ // Find and dispatch any subscriptions
210
+ let count = 0;
211
+ for (let s of this._subscriptions) {
212
+
213
+ if (s.matches_event_message(msg)) {
214
+
215
+ // Call the callback
216
+ s.call(msg.path, msg.value);
217
+ count++;
218
+ }
219
+ }
220
+
221
+ // No one is listening. This may happen if an event triggers while
222
+ // we are still unsubscribing, or if a downstream subscription gets
223
+ // orphaned
224
+ if (count === 0) {
225
+
226
+ // Reply with unsubscribe request
227
+ const response = Entangld_Message.unsubscribe(msg.uuid);
228
+ this._transmit(response, obj);
229
+ }
230
+
231
+ } else if (msg.type == "subscribe") {
232
+ // Incoming remote subscription request
233
+
234
+ // Create a new subscription that simply transmits when triggered
235
+ // the "tree" from the subscribe message is scoped to be a "path"
236
+ // here in this datastore
237
+ this._subscribe(msg.path, (path, val) => {
238
+ // The "path" here is relative to this datastore, and can
239
+ // potentially be beneath msg.params.tree
240
+ const response = Entangld_Message.event(path,val,msg.uuid);
241
+ this._transmit(response, obj);
242
+ }, obj, msg.uuid, msg.every);
243
+
244
+ } else if (msg.type == "unsubscribe") {
245
+ // Incoming remote unsubscribe request
246
+
247
+ // Unsubscribe to any matching subscriptions.
248
+ this._unsubscribe(this._subscriptions.filter(
249
+ s => s.matches_unsubscribe_message(msg))
250
+ );
251
+
252
+ } else {
253
+ // Default
254
+ throw new EntangldError(
255
+ "Received unknown message: " + JSON.stringify(msg));
256
+ }
257
+ }
258
+
259
+
260
+ /**
261
+ * Push an object into an array in the store.
262
+ *
263
+ * Convenience method for set(path, o, "push").
264
+ *
265
+ * @param {string} path the path to set (like "system.fan.voltage").
266
+ * @param {object} data the object or function you want to store at path.
267
+ * @param {number} [limit] maximum size of the array. Older entries will be
268
+ * removed until the array size is less than or equal to limit.
269
+ *
270
+ * @throws {TypeError} if path is not a string.
271
+ */
272
+ push(path, data, limit=null) {
273
+
274
+ this.set(path, data, "push", { limit });
275
+
276
+ }
277
+
278
+
279
+ /**
280
+ * Set an object into the store
281
+ *
282
+ * @param {string} path the path to set (like "system.fan.voltage").
283
+ * @param {object} data the object or function you want to store at path.
284
+ * @param {string} [operation_type="set"] whether to set or push the new
285
+ * data (push only works if the data item exists and is an array).
286
+ * @param {object} [params] additional parameters.
287
+ *
288
+ * @throws {TypeError} if path is not a string.
289
+ */
290
+ set(path, data, operation_type="set", params={}) {
291
+
292
+ // Sanity check
293
+ if (typeof (path) != "string")
294
+ throw TypeError("path must be a string");
295
+
296
+ let [obj, , tree] = this._get_remote_object(path);
297
+
298
+ if (obj === undefined) {
299
+ // Set local or remote item
300
+
301
+ // Is this going to mess with an attached store?
302
+ for (let [namespace] of this._stores) {
303
+
304
+ if (is_beneath(namespace, path)) {
305
+
306
+ const msg = `Cannot set ${path} - doing so would overwrite `
307
+ + `remote store attached at ${path}. `
308
+ + "Please detach first";
309
+ throw new EntangldError(msg);
310
+ }
311
+ }
312
+
313
+ this._set_local(path, data, operation_type, params);
314
+
315
+ // Check subscriptions to see if we need to run an event
316
+ for (let s of this._subscriptions) {
317
+
318
+ // is this broken? if path="data.element" and s.path="data",
319
+ // then the callback will get triggered, but with the sub path,
320
+ // i.e. cb("data.element",data), except, the callbacks often
321
+ // don't check the path, so it will think it is getting a 'data'
322
+ // object, not a "data.path" object . . .
323
+ if (s.is_above(path)) s.call(path, data);
324
+ }
325
+
326
+ } else {
327
+
328
+ const msg = Entangld_Message.setpush({
329
+ type : operation_type,
330
+ tree : tree,
331
+ value : data,
332
+ params : params
333
+ });
334
+
335
+ this._transmit(msg, obj);
336
+ }
337
+
338
+ }
339
+
340
+
341
+ /**
342
+ * Set a function as an RPC in the datastore
343
+ *
344
+ * @param {string} path the path to set (like "system.fan.voltage").
345
+ * @param {function} func the function you want to store at path.
346
+ * @param {object} opts
347
+ * @param {?string} [opts.description=null] description for the function
348
+ * @param {?string} [opts.function_string=null] optional string of function source code to pass to parameter_parser
349
+ *
350
+ * @throws {TypeError} if path is not a string.
351
+ */
352
+ set_rpc(path, func, {description, function_string}={}) {
353
+ // Sanity check
354
+ if (typeof (path) != "string")
355
+ throw TypeError("path must be a string");
356
+ if (typeof (func) != "function")
357
+ throw TypeError("func must be a function");
358
+
359
+ const all_params = parameter_parser(function_string == null ? func.toString() : function_string);
360
+ // const req_params = all_params.filter((p)=>p.includes("="))
361
+
362
+ if (!all_params.length) all_params.push("value");
363
+
364
+ const usage = all_params.map((p)=>`<${p}>`).join(" ");
365
+
366
+ return this.set(path, async (params)=>{
367
+ if (!params) return "Usage: " + usage + " : " + (description == null ? "No description provided" : description);
368
+
369
+ try {
370
+ // Uncomment for requiring all params
371
+ // if (params.length < req_params.length) throw new Error(`Invalid Parameters. (needed: ${req_params.length}, got: ${params.length})`);
372
+ return [true, await func(...params)];
373
+ } catch(e) {
374
+ return [false, RPCError.serialize_local_error(e)];
375
+ }
376
+
377
+ });
378
+ }
379
+
380
+ /**
381
+ * Set a class instance as an RPC object in the datastore
382
+ *
383
+ * @param {*} object The object to attach
384
+ * @param {Array} key_description_pairs An array of key, description pairs
385
+ * @param {object} opts
386
+ * @param {?string} [opts.description=null] description for the function
387
+ * @param {?string} [opts.function_string=null] optional string of function source code to pass to parameter_parser
388
+ *
389
+ * @throws {TypeError} if path is not a string.
390
+ */
391
+ set_rpc_class(obj, key_description_pairs=[], { namespace="" }={}) {
392
+ for ( const [ key, description ] of (key_description_pairs) ) {
393
+ this.set_rpc(
394
+ (namespace?(namespace+"."):"")+key,
395
+ obj[key].bind(obj),
396
+ { description, function_string:obj[key].toString() }
397
+ );
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Call an RPC procedure in the datastore
403
+ *
404
+ * @param {string} path the path to query (like "system.voltage").
405
+ * @param {Array} [args=[]] the arguments to pass to the RPC.
406
+ * @throws {RPCError} if the rpc call experienced an error
407
+ * @return {Promise} promise resolving to return value of the rpc
408
+ */
409
+ call_rpc(path, args=[]) {
410
+ return this.get(path,args).then((args) => {
411
+ if (args[0] === false) throw RPCError.from_remote_error(args[1]);
412
+ if ( typeof(args[0]) == "string" ) return args[0];
413
+ return args[1];
414
+ });
415
+ }
416
+
417
+ /**
418
+ * Get the remote store in a path
419
+ *
420
+ * If path contains a remote store, return the object as well as the
421
+ * relative path to the remote store
422
+ *
423
+ * Example: let A,B be stores. If A.attach("some.path.to.B", B) then
424
+ * _get_remote_object("some.path.to.B.data") will return
425
+ * [B, "some.path.to.B", "data"] and _get_remote_object("nonexistent.path")
426
+ * will return [undefined, undefined, path]
427
+ *
428
+ * @private
429
+ * @param {string} path the path to investigate.
430
+ *
431
+ * @return {array<object, string, string>} array whose elements are
432
+ * [obj, namespace, relative path below store]. If no store is found,
433
+ * return value is [undefined, undefined, path].
434
+ */
435
+ _get_remote_object(path) {
436
+
437
+ // We don't know what part of path might be a store key. So we need to
438
+ // get all store keys and search (vs .get)
439
+ for (let [namespace, obj] of this._stores) {
440
+
441
+ if (is_beneath(path, namespace)) {
442
+
443
+ // Exact match means path is the root of the attached store
444
+ if (path.length == namespace.length) {
445
+
446
+ return [obj, namespace, ""];
447
+ }
448
+
449
+ // Path is longer than namespace. Make sure we match to a period
450
+ if (path.substr(namespace.length, 1) == ".") {
451
+
452
+ path = path.substr(namespace.length + 1);
453
+ return [obj, namespace, path];
454
+ }
455
+ }
456
+ }
457
+
458
+ // Default: we did not find a store
459
+ return [undefined, undefined, path];
460
+ }
461
+
462
+ /**
463
+ * Get an object from the store.
464
+ *
465
+ * Note: using max_depth, especially large max_depth, involves a lot of
466
+ * recursion and may be expensive.
467
+ *
468
+ * @param {string} path the path to query (like "system.voltage").
469
+ * @param {object} [params] the parameters to be passed to the remote
470
+ * function (RPC) or the maximum depth of the returned object (normal mode).
471
+ *
472
+ * @throws {TypeError} if path is not a string.
473
+ * @return {Promise} promise resolving to the object at that path.
474
+ */
475
+ get(path, params) {
476
+ // Sanity check
477
+ if (typeof (path) != "string")
478
+ throw TypeError("path must be a string");
479
+
480
+ // If an attach()ed store path masks (matches but is shorter than) the
481
+ // path, we are returning that store
482
+ let [obj, , tree] = this._get_remote_object(path);
483
+
484
+ // If object is undefined, we are getting a local item
485
+ if (obj === undefined) {
486
+
487
+ // Get a reference to the object
488
+ let [data, remaining_path] = extract_from_path(
489
+ this._local_data, path);
490
+
491
+ // If it is a function, call it
492
+ if (typeof (data) == "function") {
493
+
494
+ let result = data(params);
495
+
496
+ // Get a promise resolving to the result
497
+ let p = (result && result.constructor && result.constructor.name
498
+ && result.constructor.name == "Promise") ?
499
+ result : new Promise((res) => res(result));
500
+
501
+ return p.then((val) => {
502
+
503
+ // If we have a remaining path, try to navigate it before
504
+ // returning
505
+ [data, remaining_path] = extract_from_path(
506
+ val, remaining_path);
507
+
508
+ return Promise.resolve(
509
+ (remaining_path === "")? data : undefined);
510
+ });
511
+ }
512
+
513
+ // Path does not point to function. So use params as a max depth
514
+ // and return a Promise resolving to a dereferenced partial copy
515
+ if (typeof (params) == "number") {
516
+
517
+ if (this._deref_mode) {
518
+
519
+ return dereferenced_copy(partial_copy(data, params));
520
+ } else {
521
+
522
+ return new Promise(
523
+ (res) => res(partial_copy(data, params)));
524
+ }
525
+ }
526
+
527
+ // Default: return a promise resolving to the entire object as-is
528
+ if (this._deref_mode) {
529
+
530
+ return dereferenced_copy(data);
531
+
532
+ } else {
533
+
534
+ return new Promise((res) => res(data));
535
+ }
536
+ }
537
+
538
+ // Request the data from the remote store
539
+ const msg = Entangld_Message.get(tree, params);
540
+
541
+ return new Promise((res) => {
542
+ this._requests[msg.uuid] = res;
543
+ this._transmit(msg, obj);
544
+ });
545
+
546
+ }
547
+
548
+ /**
549
+ * Subscribe to change events for a path
550
+ *
551
+ * If objects at or below this path change, you will get a callback
552
+ *
553
+ * Subscriptions to keys within attach()ed stores are remote subscriptions.
554
+ * If several stores are attached in some kind of arrangement, a given key
555
+ * may actually traverse multiple stores! Since each store only knows its
556
+ * immediate neighbors - and has no introspection into those neighbors - each
557
+ * store is only able to keeps track of the neighbor on each side with
558
+ * respect to a particular path and has no knowledge of the eventual
559
+ * endpoints. This means that subscribing across several datstores is accomplished
560
+ * by daisy-chaining 2-way subscriptions across each datastore interface.
561
+ *
562
+ * For example, let's suppose capital letters represent Entangld stores and
563
+ * lowercase letters are actual objects. Then the path "A.B.c.d.E.F.g.h"
564
+ * will represent a subscription that traverses four Entangld stores.
565
+ * From the point of view of a store in the middle - say, E - the "upstream"
566
+ * is B and the "downstream" is F.
567
+ *
568
+ * Each store involved keeps track of any subscriptions with which it is
569
+ * involved. It tracks the upstream and downstream, and the uuid of the
570
+ * subscription. The uuid is the same across all stores for a given
571
+ * subscription. For a particular store, the upstream is null if it is the
572
+ * original link in the chain (called the `head`), and the downstream is
573
+ * null if this store owns the endpoint value (called the `tail`). Any
574
+ * subscription which is not the head of a chain is called a `pass through`
575
+ * subscription, because it exist only to pass `event` messages back up the
576
+ * chain to the head (where the user-provided callback function exists).
577
+ * subscriptions can be checked to see if they are `pass through` type via
578
+ * the getter `sub.is_pass_through`.
579
+ *
580
+ * @param {string} path the path to watch. Use of '*' is allowed as a wildcard (e.g. "system.*")
581
+ * @param {function} func the callback - will be of the form (path, value).
582
+ * @param {number|null} [every=null] the number of `set` messages to wait before calling callback
583
+ *
584
+ * @throws {TypeError} if path is not a string.
585
+ * @return {uuidv4} - the uuid of the subscription
586
+ */
587
+ subscribe(path, func, every = null) {
588
+
589
+ return this._subscribe(path, func, null, null, every);
590
+ }
591
+
592
+ /**
593
+ * Create a subscription
594
+ *
595
+ * This is an internal function that allows us to specify the new
596
+ * subscription's upstream and UUID.
597
+ *
598
+ * @private
599
+ * @param {string} path the path to watch.
600
+ * @param {function} func the callback - will be of the form (path, value).
601
+ * @param {(Entangld|null)} [upstream=null] the Engangld next upstream in the path.
602
+ * @param {(uuidv4|null)} [uuid=null] the UUID to use for this subscription.
603
+ *
604
+ * @emits {str} subscription - when this datastore is the terminal datastore of a
605
+ * subscription request, this datastore emits the path
606
+ * and uuid.
607
+ * @throws {TypeError} if path is not a string.
608
+ * @return {uuidv4} - the uuid of the subscription
609
+ */
610
+ _subscribe(path, func, upstream = null, uuid = null, every = null) {
611
+
612
+
613
+ // Supply a UUID if none provided
614
+ uuid = uuid || uuidv4();
615
+
616
+
617
+ // Sanity check
618
+ if (typeof (path) != "string")
619
+ throw TypeError("path must be a string");
620
+
621
+ let [obj, /*namespace*/, tree] = this._get_remote_object(path);
622
+
623
+ // Create the subscription object
624
+ let new_sub = new Subscription({
625
+ path : path,
626
+ downstream : obj,
627
+ upstream : upstream,
628
+ uuid : uuid,
629
+ callback : func,
630
+ every : every
631
+ });
632
+ // Filter out any residual subscriptions
633
+ // Note, residual subscriptions should only exist if a remote datastore (connected via a
634
+ // socket, or something like that) re-attaches this datastore after the connection between
635
+ // the two was uncerimonally destroyed, otherwise the internal clean up of the attach
636
+ // should have propegated down to prevent this.
637
+ // Note, this should still allow circular references, since "match_subscriptions" requires
638
+ // both the uuid and path to match
639
+ this._subscriptions = this._subscriptions.filter(sub => !new_sub.matches_subscription(sub));
640
+
641
+ // Add the new subscription here
642
+ this._subscriptions.push(new_sub);
643
+
644
+ // Commission any subscribes downstream, if this is a remote subscription
645
+ if (new_sub.has_downstream) {
646
+ const msg = Entangld_Message.subscribe(tree, uuid, every);
647
+ this._transmit(msg, obj);
648
+
649
+ } else { // this is the terminal datastore, so emit subscription received
650
+ this.emit("subscription", path, uuid);
651
+ }
652
+
653
+ return new_sub.uuid;
654
+ }
655
+
656
+ /**
657
+ * Check for subscription
658
+ *
659
+ * Are we subscribed to a particular remote path?
660
+ *
661
+ * @param {String} subscription the subscription to check for.
662
+ *
663
+ * @return {Boolean} true if we are subscribed.
664
+ */
665
+ subscribed_to(path) {
666
+
667
+ for (let s of this._subscriptions) {
668
+
669
+ if (s.matches_path(path)) return true;
670
+ }
671
+
672
+ return false;
673
+ }
674
+
675
+ /**
676
+ * Unubscribe to change events for a given path or uuid.
677
+ *
678
+ * Caution - if a path is provided, _all_ events belonging to you with that
679
+ * path will be deleted, so if you have multiple subscriptions on a single path,
680
+ * and only want one of them to be removed, you must provide the uuid instead.
681
+ *
682
+ * @param {(String|uuidv4)} path_or_uuid - the path (or uuid) to unwatch.
683
+ * @throws {EntangldError} if no subscriptions were found.
684
+ * @return {number} count of subscriptions removed.
685
+ */
686
+ unsubscribe(path_or_uuid) {
687
+
688
+ let matching_subs = [];
689
+
690
+ if (path_or_uuid.match(uuid_regex)) {
691
+ // `path_or_uuid` is a uuid, so find any matching subs with that uuid
692
+ matching_subs = this._subscriptions.filter(
693
+ s => s.matches_uuid(path_or_uuid) && !s.is_pass_through
694
+ );
695
+ } else {
696
+ // the arg is a path
697
+ matching_subs = this._subscriptions.filter(
698
+ // search for subs which both have the correct path AND are not
699
+ // pass through, so that pass through subs don't get orphaned
700
+ s => s.matches_path(path_or_uuid) && !s.is_pass_through
701
+ );
702
+ }
703
+
704
+ // Throw an error if none found
705
+ if (matching_subs.length == 0) {
706
+
707
+ throw new EntangldError(`unsubscribe found no subscriptions matching ${path_or_uuid}`);
708
+ }
709
+
710
+ this._unsubscribe(matching_subs);
711
+
712
+ return matching_subs.length;
713
+ }
714
+
715
+ /**
716
+ * Unsubscribe tree.
717
+ *
718
+ * Remove any subscriptions that are beneath a path.
719
+ *
720
+ * @param {string} path the tree to unwatch.
721
+ *
722
+ * @throws {EntangldError} error if there are stores we cannot detach
723
+ * (i.e. they belong to someone else / upstream != null)
724
+ */
725
+ unsubscribe_tree(path) {
726
+
727
+ let matching_subscriptions = this._subscriptions.filter(
728
+ // Get all the subs beneath this path, which are not pass throughs,
729
+ // so that subscriptions don't get orphaned
730
+ s => s.is_beneath(path) && !s.is_pass_through
731
+ );
732
+
733
+ this._unsubscribe(matching_subscriptions);
734
+
735
+ // Error on any remaining `pass through` subscriptions
736
+ matching_subscriptions = this._subscriptions.filter(
737
+ s => s.is_beneath(path)
738
+ );
739
+
740
+ if (matching_subscriptions.length != 0) {
741
+
742
+ const msg = `Unable to completely unsubscribe the tree '${path}' `
743
+ + "because some subscriptions are remote or passthrough "
744
+ + "(we are not the owner, just a downstream)";
745
+
746
+ throw new EntangldError(msg);
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Unubscribe to change events
752
+ *
753
+ * This is an internal function that does not check for safety. It simply
754
+ * deletes the requested paths and notifies any downstream.
755
+ *
756
+ * @private
757
+ * @param {Subscription[]} subscriptions an array of objects containing at minimum
758
+ * uuid and upstream keys
759
+ */
760
+ _unsubscribe(subscriptions) {
761
+
762
+ // Get a list of uuids to remove
763
+ const uuids = subscriptions.map(s => s.uuid);
764
+
765
+ // Drop all subscriptions with matching uuid
766
+ this._subscriptions = this._subscriptions.filter(
767
+ s => !(uuids.includes(s.uuid))
768
+ );
769
+
770
+ // Notify the downstream for any deleted subscriptions that are remote
771
+ subscriptions.forEach((s) => {
772
+ if (!s.has_downstream) { // emit unsubscription
773
+ this.emit("unsubscription", s.path, s.uuid);
774
+ } else { // Notify downstreams
775
+ const msg = Entangld_Message.unsubscribe(s.uuid);
776
+ this._transmit(msg, s.downstream);
777
+ }
778
+ });
779
+ }
780
+
781
+ /**
782
+ * Set local object
783
+ *
784
+ * Sets object into local data store.
785
+ *
786
+ * @private
787
+ * @param {string} path the path at which to store the object.
788
+ * @param {object} data the object to store. If undefined, it unsets path.
789
+ * @param {string} [op_type] whether to set or push the new
790
+ * data (push only works if the data item exists and is an array).
791
+ * @param {object} [params] additional parameters.
792
+ *
793
+ * @throws {EntangldError} if attempting to set root store to a non-object.
794
+ * @throws {TypeError} if attempting to push to a non-array.
795
+ */
796
+ _set_local(path, data, op_type="set", params={}) {
797
+ // Empty path means set everything
798
+ if (path === "") {
799
+ if(data === undefined)
800
+ data = {};
801
+
802
+ // Sanity check
803
+ if (typeof (data) != "object") {
804
+ const msg = "You are trying to set the root store to something "
805
+ + `(${typeof (data)}) besides an object!`;
806
+
807
+ throw EntangldError(msg);
808
+ }
809
+
810
+ this._local_data = data;
811
+ return;
812
+ }
813
+
814
+ let elements = path.split(".");
815
+ let last = elements.pop();
816
+ let pointer = this._local_data;
817
+
818
+ for (let el of elements) {
819
+
820
+ if (!pointer[el]) pointer[el] = {};
821
+ pointer = pointer[el];
822
+ }
823
+
824
+ // Handle unset operations
825
+ if (data === undefined) {
826
+
827
+ delete (pointer[last]);
828
+ return;
829
+ }
830
+
831
+ if (op_type == "push") {
832
+
833
+ if (pointer[last] && typeof (pointer[last].push) == "function") {
834
+
835
+ pointer[last].push(data);
836
+ if(params.limit) {
837
+ while(pointer[last].length > params.limit)
838
+ pointer[last].shift(data);
839
+ }
840
+
841
+ } else {
842
+
843
+ throw new TypeError("You cannot .push() to that object");
844
+ }
845
+
846
+ } else {
847
+
848
+ // Default to set
849
+ pointer[last] = data;
850
+ }
851
+ }
852
+ }
853
+
854
+
855
+ module.exports = exports = Entangld;