entangld 2.6.0 → 2.7.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.
- package/.github/workflows/mocha-tests.yml +1 -1
- package/API.md +754 -0
- package/README.md +212 -799
- package/docs/protocol.md +484 -0
- package/generate_docs.js +3 -3
- package/lib/Datastore.js +143 -20
- package/lib/Message.js +10 -11
- package/lib/Subscription.js +23 -0
- package/lib/error.js +15 -0
- package/lib/uuid.js +51 -0
- package/package.json +4 -1
- package/test/signals.js +590 -0
- package/test/sockets.js +7 -2
- package/test/tests.js +10 -0
- package/test/timeouts.js +173 -0
- package/test/uuid.js +37 -0
- package/.gitmodules +0 -3
- package/README.hbs +0 -190
- package/tags +0 -244
package/test/timeouts.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
const assert = require("assert");
|
|
2
|
+
const Entangld = require("../lib/Datastore.js");
|
|
3
|
+
const { EntangldError, EntangldTimeoutError } = require("../lib/error.js");
|
|
4
|
+
|
|
5
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
|
|
7
|
+
// Resolve to the error a promise rejects with, or fail if it resolves
|
|
8
|
+
function rejection(promise) {
|
|
9
|
+
return promise.then(
|
|
10
|
+
(val) => assert.fail(`Expected a rejection, got ${val}`),
|
|
11
|
+
(err) => err
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Attach store b to store a. Messages from a are delivered only if allow(msg)
|
|
16
|
+
function linked_stores(allow = () => true) {
|
|
17
|
+
const a = new Entangld();
|
|
18
|
+
const b = new Entangld();
|
|
19
|
+
|
|
20
|
+
a.transmit((msg, store) => {
|
|
21
|
+
if (allow(msg)) store.receive(msg, a);
|
|
22
|
+
});
|
|
23
|
+
b.transmit((msg, store) => store.receive(msg, b));
|
|
24
|
+
|
|
25
|
+
a.attach("b", b);
|
|
26
|
+
b.attach("a", a);
|
|
27
|
+
|
|
28
|
+
return { a, b };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("Timeouts", () => {
|
|
32
|
+
|
|
33
|
+
describe("get", () => {
|
|
34
|
+
|
|
35
|
+
it("rejects if a remote store never replies", async () => {
|
|
36
|
+
const { a } = linked_stores(() => false);
|
|
37
|
+
|
|
38
|
+
const err = await rejection(a.get("b.value", undefined, { timeout_ms: 10 }));
|
|
39
|
+
|
|
40
|
+
assert(err instanceof EntangldTimeoutError);
|
|
41
|
+
assert(err instanceof EntangldError);
|
|
42
|
+
assert.strictEqual(err.name, "EntangldTimeoutError");
|
|
43
|
+
assert.strictEqual(err.path, "b.value");
|
|
44
|
+
assert.strictEqual(err.timeout_ms, 10);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("removes the request after timing out", async () => {
|
|
48
|
+
const { a } = linked_stores(() => false);
|
|
49
|
+
|
|
50
|
+
await rejection(a.get("b.value", undefined, { timeout_ms: 10 }));
|
|
51
|
+
|
|
52
|
+
assert.strictEqual(Object.keys(a._requests).length, 0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("resolves if the reply beats the timeout", async () => {
|
|
56
|
+
const { a, b } = linked_stores();
|
|
57
|
+
b.set("value", 7);
|
|
58
|
+
|
|
59
|
+
const value = await a.get("b.value", undefined, { timeout_ms: 1000 });
|
|
60
|
+
|
|
61
|
+
assert.strictEqual(value, 7);
|
|
62
|
+
assert.strictEqual(Object.keys(a._requests).length, 0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("ignores a reply which arrives after the timeout", async () => {
|
|
66
|
+
const { a, b } = linked_stores(() => false);
|
|
67
|
+
const sent = [];
|
|
68
|
+
a.transmit((msg) => sent.push(msg));
|
|
69
|
+
|
|
70
|
+
await rejection(a.get("b.value", undefined, { timeout_ms: 10 }));
|
|
71
|
+
|
|
72
|
+
assert.doesNotThrow(() => {
|
|
73
|
+
a.receive({ type: "value", uuid: sent[0].uuid, value: 1 }, b);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("passes params through and keeps options off the wire", async () => {
|
|
78
|
+
const sent = [];
|
|
79
|
+
const { a, b } = linked_stores((msg) => {
|
|
80
|
+
sent.push(JSON.stringify(msg));
|
|
81
|
+
return true;
|
|
82
|
+
});
|
|
83
|
+
b.set("echo", (params) => params);
|
|
84
|
+
|
|
85
|
+
const value = await a.get("b.echo", ["x"], { timeout_ms: 1000 });
|
|
86
|
+
|
|
87
|
+
assert.deepStrictEqual(value, ["x"]);
|
|
88
|
+
assert(!sent.join("").includes("timeout_ms"));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects if a local function never settles", async () => {
|
|
92
|
+
const a = new Entangld();
|
|
93
|
+
a.set("hang", () => new Promise(() => { }));
|
|
94
|
+
|
|
95
|
+
const err = await rejection(a.get("hang", undefined, { timeout_ms: 10 }));
|
|
96
|
+
|
|
97
|
+
assert(err instanceof EntangldTimeoutError);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("resolves if a local function beats the timeout", async () => {
|
|
101
|
+
const a = new Entangld();
|
|
102
|
+
a.set("slow", () => wait(1).then(() => 8));
|
|
103
|
+
|
|
104
|
+
assert.strictEqual(await a.get("slow", undefined, { timeout_ms: 1000 }), 8);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("keeps the original error if a local function rejects", async () => {
|
|
108
|
+
const a = new Entangld();
|
|
109
|
+
a.set("bad", () => Promise.reject(new Error("boom")));
|
|
110
|
+
|
|
111
|
+
const err = await rejection(a.get("bad", undefined, { timeout_ms: 1000 }));
|
|
112
|
+
|
|
113
|
+
assert.strictEqual(err.message, "boom");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns local data", async () => {
|
|
117
|
+
const a = new Entangld();
|
|
118
|
+
a.set("value", 3);
|
|
119
|
+
|
|
120
|
+
assert.strictEqual(await a.get("value", undefined, { timeout_ms: 10 }), 3);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("never settles a lost reply without timeout_ms", async () => {
|
|
124
|
+
const { a } = linked_stores(() => false);
|
|
125
|
+
let settled = false;
|
|
126
|
+
|
|
127
|
+
a.get("b.value").finally(() => {
|
|
128
|
+
settled = true;
|
|
129
|
+
});
|
|
130
|
+
await wait(20);
|
|
131
|
+
|
|
132
|
+
assert.strictEqual(settled, false);
|
|
133
|
+
assert.strictEqual(Object.keys(a._requests).length, 1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("throws on an invalid timeout_ms", () => {
|
|
137
|
+
const a = new Entangld();
|
|
138
|
+
|
|
139
|
+
for (const timeout_ms of [0, -1, NaN, Infinity, 2147483648, "5", null]) {
|
|
140
|
+
assert.throws(() => a.get("value", undefined, { timeout_ms }), TypeError);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("call_rpc", () => {
|
|
146
|
+
|
|
147
|
+
it("rejects if a remote store never replies", async () => {
|
|
148
|
+
const { a } = linked_stores(() => false);
|
|
149
|
+
|
|
150
|
+
const err = await rejection(a.call_rpc("b.rpc", [1], { timeout_ms: 10 }));
|
|
151
|
+
|
|
152
|
+
assert(err instanceof EntangldTimeoutError);
|
|
153
|
+
assert.strictEqual(err.path, "b.rpc");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns the result if the reply beats the timeout", async () => {
|
|
157
|
+
const { a, b } = linked_stores();
|
|
158
|
+
b.set_rpc("double", (value) => value * 2);
|
|
159
|
+
|
|
160
|
+
const value = await a.call_rpc("b.double", [4], { timeout_ms: 1000 });
|
|
161
|
+
|
|
162
|
+
assert.strictEqual(value, 8);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rejects on an invalid timeout_ms", async () => {
|
|
166
|
+
const a = new Entangld();
|
|
167
|
+
|
|
168
|
+
const err = await rejection(a.call_rpc("rpc", [], { timeout_ms: 0 }));
|
|
169
|
+
|
|
170
|
+
assert(err instanceof TypeError);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
package/test/uuid.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const assert = require("assert");
|
|
2
|
+
const uuidv4 = require("../lib/uuid.js");
|
|
3
|
+
const { make_uuid } = uuidv4;
|
|
4
|
+
|
|
5
|
+
const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
6
|
+
const node_crypto = require("crypto");
|
|
7
|
+
|
|
8
|
+
describe("uuid", () => {
|
|
9
|
+
it("makes distinct v4 uuids", () => {
|
|
10
|
+
const ids = new Set(Array.from({ length: 1000 }, () => uuidv4()));
|
|
11
|
+
assert.strictEqual(ids.size, 1000);
|
|
12
|
+
for ( const id of ids ) assert.match(id, V4);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("uses globalThis.crypto.randomUUID when there is one (browsers, Node 19+)", () => {
|
|
16
|
+
const uuid = make_uuid({ randomUUID: () => "from-web-crypto" }, () => assert.fail("Node's crypto was loaded"));
|
|
17
|
+
assert.strictEqual(uuid(), "from-web-crypto");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("falls back to Node's crypto module (Node 18)", () => {
|
|
21
|
+
const uuid = make_uuid(null, () => ({ randomUUID: () => "from-node-crypto" }));
|
|
22
|
+
assert.strictEqual(uuid(), "from-node-crypto");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("builds one from getRandomValues on a page that isn't secure", () => {
|
|
26
|
+
// In a browser bundle, the "browser" field turns require("crypto") into {}
|
|
27
|
+
const page = { getRandomValues: (a) => node_crypto.getRandomValues(a) };
|
|
28
|
+
const uuid = make_uuid(page, () => ({}));
|
|
29
|
+
const ids = new Set(Array.from({ length: 1000 }, () => uuid()));
|
|
30
|
+
assert.strictEqual(ids.size, 1000);
|
|
31
|
+
for ( const id of ids ) assert.match(id, V4);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("says what's missing when there's no crypto at all", () => {
|
|
35
|
+
assert.throws(() => make_uuid(null, () => { throw new Error("no module"); }), /crypto.randomUUID or crypto.getRandomValues/);
|
|
36
|
+
});
|
|
37
|
+
});
|
package/.gitmodules
DELETED
package/README.hbs
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
# Entangld
|
|
2
|
-
|
|
3
|
-
Synchronized key-value stores with RPCs and pub/sub events. Works over sockets (try it with [Sockhop](https://www.npmjs.com/package/sockhop "Sockhop on NPM")!)
|
|
4
|
-
|
|
5
|
-
## Examples
|
|
6
|
-
Basic use:
|
|
7
|
-
```js
|
|
8
|
-
let e=new Entangld();
|
|
9
|
-
|
|
10
|
-
// Simple set/get
|
|
11
|
-
e.set("number.six",6);
|
|
12
|
-
e.get("number.six").then((val)=>{}); // val==6
|
|
13
|
-
|
|
14
|
-
// Functions as values
|
|
15
|
-
e._deref_mode=true; // Or pass to constructor: new Entangld({ deref_mode: true });
|
|
16
|
-
e.set("number.seven",()=>{ return 7;});
|
|
17
|
-
e.get("number").then((val)=>{}); // val => { six:6, seven, 7}
|
|
18
|
-
|
|
19
|
-
// Promises from functions
|
|
20
|
-
e.set("number.eight",()=>{ return new Promise((resolve)=>{ resolve(8); }); });
|
|
21
|
-
e.get("number.eight").then((val)=>{}); // val==8
|
|
22
|
-
|
|
23
|
-
// Even dereference beneath functions
|
|
24
|
-
e.set("eenie.meenie",()=>{ return {"miney": "moe"}; });
|
|
25
|
-
e.get("eenie.meenie.miney").then((val)=>{}); // val=="moe"
|
|
26
|
-
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
Pairing two data stores together:
|
|
30
|
-
```js
|
|
31
|
-
let parent=new Entangld();
|
|
32
|
-
let child=new Entangld();
|
|
33
|
-
|
|
34
|
-
// Attach child namespace
|
|
35
|
-
parent.attach("child",child);
|
|
36
|
-
|
|
37
|
-
// Configure communications
|
|
38
|
-
parent.transmit((msg, store) => store.receive(msg,parent)); // store will always be child
|
|
39
|
-
child.transmit((msg, store) => store.receive(msg, child)); // store will always be parent
|
|
40
|
-
|
|
41
|
-
// Set something in the child...
|
|
42
|
-
child.set("system.voltage",33);
|
|
43
|
-
|
|
44
|
-
// Get it back in the parent
|
|
45
|
-
parent.get("child.system.voltage"); // == 33
|
|
46
|
-
```
|
|
47
|
-
Using getter functions as RPC:
|
|
48
|
-
```js
|
|
49
|
-
// Assign a function to a child key
|
|
50
|
-
child.set("double.me",(param=0)=>param*2); // Or we could return a Promise instead of a value, if we wanted to!
|
|
51
|
-
|
|
52
|
-
// Call the RPC from the parent
|
|
53
|
-
parent.get("child.double.me", 2).then((val)=>{
|
|
54
|
-
|
|
55
|
-
// val == 4
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
```
|
|
59
|
-
Note in this example how we set a default value for this getter function (0). This is because when _deref_mode is ```true``` this getter will be called without any arguments.
|
|
60
|
-
|
|
61
|
-
Pub/sub (remote events):
|
|
62
|
-
```js
|
|
63
|
-
// Assign an event callback
|
|
64
|
-
parent.subscribe("child.system.voltage",(path, val)=>{
|
|
65
|
-
|
|
66
|
-
// path=="child.system.voltage"
|
|
67
|
-
// val==21
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// Trigger a callback on the parent
|
|
71
|
-
child.set("system.voltage",21);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// Listen on the child for when the parent subscribes
|
|
75
|
-
child.on("subscription", ( path, uuid ) => console.log("Parent subscribed to :" + path));
|
|
76
|
-
parent.subscribe("child.system.voltage"); // Child prints: "Parent subscribed to : system.voltage"
|
|
77
|
-
|
|
78
|
-
// Throttle the subscription callbacks, so that the callback is only called every 2 sets
|
|
79
|
-
let counter = 0;
|
|
80
|
-
parent.subscribe("child.rapid.data", () => counter += 1, 2);
|
|
81
|
-
child.set("rapid.data", 1) // triggers callback
|
|
82
|
-
child.set("rapid.data", 1) // doesn't trigger callback
|
|
83
|
-
child.set("rapid.data", 1) // triggers callback
|
|
84
|
-
child.set("rapid.data", 1) // doesn't trigger callback
|
|
85
|
-
console.log( counter ); // === 2
|
|
86
|
-
|
|
87
|
-
```
|
|
88
|
-
Over sockets:
|
|
89
|
-
```js
|
|
90
|
-
const Sockhop=require("sockhop");
|
|
91
|
-
const Entangld=require("entangld");
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Parent / server setup
|
|
96
|
-
*/
|
|
97
|
-
|
|
98
|
-
let parent=new Entangld();
|
|
99
|
-
let server=new Sockhop.server();
|
|
100
|
-
|
|
101
|
-
// Connect server to parent store
|
|
102
|
-
parent.transmit((msg, store)=>server.send(store, msg));
|
|
103
|
-
server
|
|
104
|
-
.on("receive",(data, meta)=>parent.receive(data, meta.sock)) // Use the socket as the data store handle
|
|
105
|
-
.on('connect',(sock)=>{
|
|
106
|
-
|
|
107
|
-
parent.attach("client", sock); // "client" works for one client. Normally use uuid() or something
|
|
108
|
-
|
|
109
|
-
parent.get("client.my.name")
|
|
110
|
-
.then((val)=>{
|
|
111
|
-
|
|
112
|
-
console.log("Client's name is "+val);
|
|
113
|
-
server.close();
|
|
114
|
-
});
|
|
115
|
-
})
|
|
116
|
-
.on('disconnect', (sock)=>parent.detach(null, sock))
|
|
117
|
-
.on('error', (e)=>console.log("Sockhop error: "+e))
|
|
118
|
-
.listen();
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Child / client setup
|
|
123
|
-
*/
|
|
124
|
-
|
|
125
|
-
let child=new Entangld();
|
|
126
|
-
let client=new Sockhop.client();
|
|
127
|
-
|
|
128
|
-
// Connect client to child store
|
|
129
|
-
child.transmit((msg)=>client.send(msg));
|
|
130
|
-
client
|
|
131
|
-
.on("receive", (data, meta)=>child.receive(data))
|
|
132
|
-
.on("connect", ()=>{
|
|
133
|
-
// attach() to parent is optional, if we plan to get() parent items
|
|
134
|
-
})
|
|
135
|
-
.on("error", (e)=>console.log("Sockhop error: "+e))
|
|
136
|
-
.connect();
|
|
137
|
-
|
|
138
|
-
child.set("my.name", "Entangld");
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
## Raison d'etre
|
|
143
|
-
Any object can store values. And a Map can store values keyed to objects. But what if you want to....
|
|
144
|
-
|
|
145
|
-
- Have your store synchronized with multiple data sources (other stores)?
|
|
146
|
-
- Over a network?
|
|
147
|
-
- Subscribe to events within the datastore?
|
|
148
|
-
- Support subscription wildcards?
|
|
149
|
-
|
|
150
|
-
## Notes
|
|
151
|
-
|
|
152
|
-
- As of version 2.0.0, subscriptions all have uuids (which are the same across a chain of subscription objects, in the case of subscriptions to remote datastores), which supports multiple (remote or local) subscriptions to the same endpoint, but with different callback functions. The uuid of a subscription is available in the subscription object (a list of which is exposed from the datastore via `store.owned_subscriptions()`), and is also returned from the method call `store.subscribe("path.to.data")`. Having access to this uuid also allows the user to cancel specific subscriptions. The method call `store.unsubscribe(...)` will now either accept a path or a uuid. For paths, the method will terminate _all_ subscriptions matching that path, while for uuids, only the specific subscription matching that uuid will be canceled.
|
|
153
|
-
- As of version 1.5.0, subscriptions may be created across multiple chained datastores. Subscription removal now acts on all subscriptions matching (but not beneath) a given path. To unsubscribe a tree, use ```unsubscribe_tree()```
|
|
154
|
-
- As of version 1.2.0, attached datastores may be located at arbitrary paths, not just in the root:
|
|
155
|
-
```parent.attach("child.goes.here", child);```
|
|
156
|
-
- Also as of 1.2.0, attach()ed child stores will appear in the parent as placeholders (empty objects) when .get() is called on paths within the parent. For example:
|
|
157
|
-
```
|
|
158
|
-
child.set("",{"child" : "data" });
|
|
159
|
-
parent.set("",{"parent" : "data"});
|
|
160
|
-
parent.attach("child", child);
|
|
161
|
-
|
|
162
|
-
parent.get(""); // Returns { "parent" : "data", "child" : {} }
|
|
163
|
-
parent.get("child"); // Returns {"child" : "data"}
|
|
164
|
-
```
|
|
165
|
-
This is because we would have to perform recursive child queries to show you a complete tree. This is left for a future version.
|
|
166
|
-
- If you ```.set()``` a function, that function may return a value or a Promise. If it returns a promise, that promise will be returned directly to you when you call ```.get()```
|
|
167
|
-
- As of version 1.4.0, you may subscribe() to a local event. This should probably be eventually replaced with native events. In other words, instead of ```.subscribe("a.b.c", callback)``` we should use ```.on("path.a.b.c", callback)```
|
|
168
|
-
|
|
169
|
-
## _deref_mode
|
|
170
|
-
If you attach a key to a getter function instead of a value, that function would never be called until you request that key directly (i.e. querying the parent of that key would not reveal that that key exists). This changed in 1.2.1, when _deref_mode was introduced. If you set _deref_mode to true, it will iterate all leaves and try to call all functions. Those that return Promise will have their Promise resolved before the result is actually returned.
|
|
171
|
-
|
|
172
|
-
This is pretty cool, and after consideration it is probably the way this thing should work all the time. However it also introduces two problems which are not yet resolved (//TODO):
|
|
173
|
-
|
|
174
|
-
First, in an effort to not accidentally mutate the original data set, a copy is made. This is somewhat inefficient.
|
|
175
|
-
Second, when the copy is made, JSON.parse/JSON.stringify are used. This means that leaves consisting of Map() or the like are just erased.
|
|
176
|
-
|
|
177
|
-
If these two issues can be resolved at some point, _deref_mode will probably be turned on permanently. Honestly, for remote stores operating over sockets it's probably not a huge issue. More to the point are local stores where the user might be storing non JSON-compatible items.
|
|
178
|
-
|
|
179
|
-
{{>main}}
|
|
180
|
-
|
|
181
|
-
## TODO
|
|
182
|
-
- Make sure incoming values request store doesn't build up
|
|
183
|
-
- When querying a parent, perhaps there should be an option to also dump child stores located below that level (potentially resource intensive)
|
|
184
|
-
- Fix _deref_mode so it doesn't "strip" the returned object by turning everything to JSON and back (inefficient and it's basically mutating the result silently)
|
|
185
|
-
- Fix unit tests so they are completely independent (tests often depend on prior test ending with a particular state)
|
|
186
|
-
- Detaching a store does not unsubscribe to any subscriptions from or through that store and may therefore leave things in a dirty or unstable state. We could unsubscribe_tree(store_name) but that would not take care of passthrough subscriptions (those that don't belong to us)
|
|
187
|
-
- unsubscribe_tree() needs to have a test ensuring errors are thrown if not all subs can be removed
|
|
188
|
-
|
|
189
|
-
## License
|
|
190
|
-
MIT
|