entangld 2.2.0 → 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/.eslintrc.json +29 -0
- package/README.md +481 -414
- package/generate_docs.js +3 -3
- package/index.js +8 -1357
- package/lib/Datastore.js +855 -0
- package/lib/Message.js +148 -0
- package/lib/Subscription.js +216 -0
- package/lib/error.js +43 -0
- package/lib/utils.js +263 -0
- package/package.json +8 -9
- package/tags +244 -0
- package/test/circular.js +3 -7
- package/test/events.js +1 -1
- package/test/sockets.js +19 -19
- package/test/subscriptions.js +51 -51
- package/test/tests.js +382 -383
- package/test/single.js +0 -48
package/lib/Datastore.js
ADDED
|
@@ -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;
|