entangld 2.4.1 → 2.7.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/.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 +144 -20
- package/lib/Message.js +10 -11
- package/lib/Subscription.js +23 -0
- package/lib/error.js +15 -0
- package/package.json +1 -4
- 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/.gitmodules +0 -3
- package/README.hbs +0 -190
- package/tags +0 -244
package/docs/protocol.md
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
# Entangld protocol
|
|
2
|
+
|
|
3
|
+
> **Draft.** This describes what `lib/` does today, and it's incomplete. Where this document and the code disagree, the
|
|
4
|
+
> code wins. The format has no version number yet, and nothing here is a compatibility promise. Open questions are marked
|
|
5
|
+
> **TODO**. The [preferred direction](#proposed-handshakes-and-versioning) and other
|
|
6
|
+
> [changes we're considering](#changes-under-consideration) are at the end.
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Stores talk to each other by passing messages. A store knows only its immediate neighbours, so a path which crosses
|
|
11
|
+
several stores is handled one link at a time.
|
|
12
|
+
|
|
13
|
+
On any link, the store which attached the other is **upstream**, and the attached store is **downstream**. `get`, `set`,
|
|
14
|
+
`push`, `subscribe` and `unsubscribe` messages travel downstream. `value` and `event` messages travel upstream.
|
|
15
|
+
|
|
16
|
+
The library doesn't provide a transport. `transmit()` supplies the function which sends a message, and `receive()` takes
|
|
17
|
+
the messages which arrive.
|
|
18
|
+
|
|
19
|
+
**TODO:** state what a transport must guarantee. Today, a lost `value` leaves a request waiting unless the caller set a
|
|
20
|
+
limit, and nothing says whether messages must arrive in order.
|
|
21
|
+
|
|
22
|
+
## Messages
|
|
23
|
+
|
|
24
|
+
Messages are objects with these fields. A field which is `undefined` is left out when the message is serialized as JSON.
|
|
25
|
+
|
|
26
|
+
| Field | Used by | Meaning |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| `type` | All | One of the types below |
|
|
29
|
+
| `path` | All but `unsubscribe` | A path relative to the downstream store |
|
|
30
|
+
| `value` | `get`, `value`, `set`, `push`, `event` | The payload |
|
|
31
|
+
| `uuid` | `get`, `value`, `subscribe`, `event`, `unsubscribe` | Links messages together, see [uuids](#uuids) |
|
|
32
|
+
| `params` | `set`, `push` | Options. `limit` is the maximum length of the array after a `push` |
|
|
33
|
+
| `every` | `subscribe` | Send one event per this many changes. `null` means every change |
|
|
34
|
+
|
|
35
|
+
### `get`
|
|
36
|
+
|
|
37
|
+
Reads a path or calls a function. `value` is the optional parameter: the argument for a function, or a maximum depth for
|
|
38
|
+
data. It's left out if there is none.
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
{ "type": "get", "path": "system.voltage", "uuid": "fe9921dd-a48a-4fac-9f4e-2fa882b2948d" }
|
|
42
|
+
{ "type": "get", "path": "double.me", "value": [ 4 ], "uuid": "1023077d-683f-4ff1-8900-bb01b298e0ca" }
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### `value`
|
|
46
|
+
|
|
47
|
+
Answers a `get`, with the same `path` and `uuid`. A path with no data has no `value` field.
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
{ "type": "value", "path": "system.voltage", "value": 12, "uuid": "fe9921dd-a48a-4fac-9f4e-2fa882b2948d" }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
RPCs use this message too. `set_rpc` wraps a function so that its reply is `[ true, result ]`, or `[ false, error ]` if
|
|
54
|
+
it failed. `call_rpc` unwraps that, so to the protocol an RPC is an ordinary `get`.
|
|
55
|
+
|
|
56
|
+
### `set` and `push`
|
|
57
|
+
|
|
58
|
+
Write to a path. There is no reply. A `set` with no `value` removes the path. A `push` adds to an array, and fails on the
|
|
59
|
+
receiving store if the path isn't one.
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
{ "type": "set", "path": "system.voltage", "value": 2, "params": {} }
|
|
63
|
+
{ "type": "push", "path": "log", "value": 1, "params": { "limit": 5 } }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### `subscribe`
|
|
67
|
+
|
|
68
|
+
Asks to be told about changes to a path. There is no reply. The path may use `*` as a wildcard.
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
{ "type": "subscribe", "path": "system.*", "uuid": "e6c079db-a6d6-4b5b-9baa-b6bb4f8f3a49", "every": 2 }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### `event`
|
|
75
|
+
|
|
76
|
+
Reports a change for a subscription. `path` is the path which changed, relative to the sender. The receiver adds its
|
|
77
|
+
namespace for that link. `every` is applied by the store which owns the data, so skipped changes never cross a link.
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
{ "type": "event", "path": "system.voltage", "value": 12, "uuid": "e6c079db-a6d6-4b5b-9baa-b6bb4f8f3a49" }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### `unsubscribe`
|
|
84
|
+
|
|
85
|
+
Ends a subscription. It has no `path`. A store also sends one in reply to an `event` which matches none of its
|
|
86
|
+
subscriptions.
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
{ "type": "unsubscribe", "uuid": "e6c079db-a6d6-4b5b-9baa-b6bb4f8f3a49" }
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Behaviours
|
|
93
|
+
|
|
94
|
+
### uuids
|
|
95
|
+
|
|
96
|
+
- **`get` and `value`:** a uuid belongs to one link. A store forwarding a `get` makes a new one, so a chain of three
|
|
97
|
+
stores uses two uuids for one request.
|
|
98
|
+
- **Subscriptions:** one uuid is used along the whole chain, by `subscribe`, `event` and `unsubscribe`.
|
|
99
|
+
|
|
100
|
+
### Chains
|
|
101
|
+
|
|
102
|
+
A store forwarding a request holds its own pending entry, and answers upstream when its downstream answers.
|
|
103
|
+
Subscriptions are daisy-chained in the same way. A store in the middle holds a `pass through` subscription, which only
|
|
104
|
+
sends events upstream.
|
|
105
|
+
|
|
106
|
+
### Reattaching
|
|
107
|
+
|
|
108
|
+
`attach()` removes any subscriptions beneath the new namespace and subscribes them again, using the same uuids. A store
|
|
109
|
+
replaces an existing subscription which has the same uuid and path, so that a client which reconnects doesn't get
|
|
110
|
+
duplicate events.
|
|
111
|
+
|
|
112
|
+
### Failures
|
|
113
|
+
|
|
114
|
+
- **Nothing reports an error across a link.** There is no error message.
|
|
115
|
+
- **Functions:** if a function behind a `get` fails, no `value` is sent. Functions which can fail should use `set_rpc`.
|
|
116
|
+
- **Unknown message types:** `receive()` throws an `EntangldError`.
|
|
117
|
+
- **Late replies:** a `value` with an unknown uuid is ignored, because the request may have timed out.
|
|
118
|
+
- **Failed writes:** if a `set` or `push` fails, the error stays on the receiving store. The sender isn't told.
|
|
119
|
+
|
|
120
|
+
### What stays local
|
|
121
|
+
|
|
122
|
+
`timeout_ms` and `signal` never appear in a message. They stop the wait or end the subscription on the calling store,
|
|
123
|
+
and aborting a subscription sends an ordinary `unsubscribe`.
|
|
124
|
+
|
|
125
|
+
**TODO:** cover `attach`, `detach`, and what happens to pending requests when a link drops. `detach()` leaves them in
|
|
126
|
+
`_requests` today.
|
|
127
|
+
|
|
128
|
+
## Proposed: handshakes and versioning
|
|
129
|
+
|
|
130
|
+
**Not built or adopted.** This is the preferred direction, not a description of what the library does.
|
|
131
|
+
|
|
132
|
+
Today a store can't tell what a peer supports, and a new message type raises an error on any store which hasn't upgraded.
|
|
133
|
+
This proposal lets stores find out, and sets rules so that later changes need as few stores upgraded as possible.
|
|
134
|
+
|
|
135
|
+
### Negotiating a link
|
|
136
|
+
|
|
137
|
+
The application calls `negotiate()` when a link comes up, because only it knows when that happens. `attach()` can't do
|
|
138
|
+
this: some stores call it once at startup before any socket connects, and others call it per connection.
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
// A client which reconnects by itself
|
|
142
|
+
this.cortex.on("connect", () => {
|
|
143
|
+
this.datastore.negotiate(this.cortex, { timeout_ms: 5000 });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// A server, with a new socket per connection
|
|
147
|
+
_serverOnConnect(socket) {
|
|
148
|
+
this.datastore.attach(`chips.${id}`, socket);
|
|
149
|
+
this.datastore.negotiate(socket, { timeout_ms: 5000 });
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Asking lazily, on first need, was rejected. `set` and `subscribe` are synchronous so they can't wait for an answer, and a
|
|
154
|
+
lost reply would stall every caller waiting on it. It would also still need a hook, to forget a peer on disconnect.
|
|
155
|
+
|
|
156
|
+
Negotiation is per link, because messages already work hop by hop. It's an ordinary `get` of `@entangld.protocol` on the
|
|
157
|
+
peer, and the answer is a protocol number and a list of features, both separate from the npm version:
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
{ "protocol": 1, "features": [ "cancel", "error_reply" ] }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- **Old stores:** a `get` of an unknown path returns `undefined` on 2.6.0, with no error, so an old store can be asked
|
|
164
|
+
safely. No new message type is needed.
|
|
165
|
+
- **Unknown means old:** a link counts as protocol 0 until it has an answer. A lost reply leaves it there, so nothing
|
|
166
|
+
new is sent to a store which may not understand it.
|
|
167
|
+
- **Reconnects:** a peer can change across a reconnect, so `negotiate()` runs on every connect. A stale answer's worst
|
|
168
|
+
case should be an ignored message, once unknown messages are ignored.
|
|
169
|
+
|
|
170
|
+
### Features upgrade existing calls
|
|
171
|
+
|
|
172
|
+
Applications don't check feature lists or build messages. Once a link is negotiated, the library takes over. **A feature
|
|
173
|
+
upgrades an existing call, and never needs its own code path**, so the same code works on old and new links.
|
|
174
|
+
|
|
175
|
+
| Application code | With an old peer | With a peer which has the feature |
|
|
176
|
+
| --- | --- | --- |
|
|
177
|
+
| `controller.abort()` | Stops waiting | Also cancels the remote work |
|
|
178
|
+
| `{ timeout_ms: 5000 }` | Local timer | Also sends the deadline along the chain |
|
|
179
|
+
| `subscribe(path, callback)` | Today's events | Compact events |
|
|
180
|
+
| `get` on a function which fails | Waits for its limit | Rejects with the real error |
|
|
181
|
+
| `get(path, undefined, { strict: true })` | Behaves as `get` does today | Rejects if the path isn't found |
|
|
182
|
+
|
|
183
|
+
For the times an application wants to know, there's one checker. It takes a path, so the caller doesn't need to know
|
|
184
|
+
which link that means:
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
store.supports("b.slow_job", "cancel");
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
It can only answer for the immediate neighbour, so `true` doesn't promise that a store further along the chain has the
|
|
191
|
+
feature. Avoid methods per feature, like `can_cancel()`.
|
|
192
|
+
|
|
193
|
+
### Reserved names
|
|
194
|
+
|
|
195
|
+
Any path segment starting with `@` is reserved, at any level. New stores refuse to `set` or `attach` these names, so they
|
|
196
|
+
never appear in the data, in `get("")`, or in wildcards. `$` was rejected because shells expand it, so
|
|
197
|
+
`--get bots.x.$entangld.protocol` silently loses the name.
|
|
198
|
+
|
|
199
|
+
Only one reserved namespace is used to start, `@entangld`, at the root of a store:
|
|
200
|
+
|
|
201
|
+
```js
|
|
202
|
+
store.get("b.@entangld.protocol");
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Suffixes like `a.b.c.@schema` are reserved but not used. Asked this on 2.6.0, a store runs any function at `a.b.c`, fails
|
|
206
|
+
navigating the result, and never replies. Negotiation doesn't make that safe, because it only describes the neighbour,
|
|
207
|
+
and a path may be owned several stores away. They could become a shorthand once old stores are gone.
|
|
208
|
+
|
|
209
|
+
### Queries
|
|
210
|
+
|
|
211
|
+
A root query has to reach the store which owns a path, and callers usually don't know where one store ends and the next
|
|
212
|
+
begins. So the library does it, and each store peels off one hop:
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
store.describe("b.c.motor.start");
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
{ "type": "get", "path": "@entangld.describe", "value": { "path": "c.motor.start" }, "uuid": "..." }
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
B sees that `c` is an attached store, and forwards the query to C with `motor.start`. An old store in the middle answers
|
|
223
|
+
`undefined`, so the query stops there, safely. This first upgrade needs every store on the way to be new. The rules below
|
|
224
|
+
are there so that later ones don't.
|
|
225
|
+
|
|
226
|
+
**A query a store understands never answers `undefined`.** That leaves `undefined` to mean only that a store on the way
|
|
227
|
+
doesn't support the query, so a caller can tell the two apart.
|
|
228
|
+
|
|
229
|
+
`describe` looks at a path without calling it:
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
await store.describe("b.c.motor.start");
|
|
233
|
+
|
|
234
|
+
// { exists: true, kind: "rpc" }
|
|
235
|
+
// { exists: false }
|
|
236
|
+
// { exists: null, kind: "function" } Beneath a function, so it can't tell without calling it
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`store.exists(path)` is a thin wrapper around this, for the common case. It returns `null` when it can't tell, including
|
|
240
|
+
for a store which doesn't support the query.
|
|
241
|
+
|
|
242
|
+
### Strict reads
|
|
243
|
+
|
|
244
|
+
`get` returns `undefined` for a missing path, a removed path, and a function which returned `undefined`, and the replies
|
|
245
|
+
are identical. A `value` reply gains a field saying whether the path was found:
|
|
246
|
+
|
|
247
|
+
```js
|
|
248
|
+
{ "type": "value", "path": "nope", "found": false, "uuid": "..." }
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
```js
|
|
252
|
+
await store.get("b.nope"); // undefined, as it is today
|
|
253
|
+
await store.get("b.nope", undefined, { strict: true }); // Rejects with an EntangldNotFoundError
|
|
254
|
+
await store.get("b.empty_fn", undefined, { strict: true }); // undefined, because it was found
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
This takes one round trip and can't go stale, unlike calling `exists` before `get`, so `exists` is for asking about a
|
|
258
|
+
path and `strict` is for reading one. An old store sends no `found`, so `strict` can't tell, and behaves as `get` does
|
|
259
|
+
today.
|
|
260
|
+
|
|
261
|
+
Wrinkle: a store forwarding a `get` rebuilds the reply from the value alone, because `_requests` only passes on
|
|
262
|
+
`msg.value`, so `found` would be lost at each hop. Stores need to carry the whole reply, which is also what keeping
|
|
263
|
+
unrecognised fields needs.
|
|
264
|
+
|
|
265
|
+
`subscribe` doesn't take `strict`. Subscribing to a path before it exists is normal, and a subscription whose path
|
|
266
|
+
appears later still works. It's also synchronous with no reply, so it has nothing to reject. To check first, call
|
|
267
|
+
`describe`. Knowing that a subscription arrived is a different question, see
|
|
268
|
+
[acknowledgements](#acknowledgements).
|
|
269
|
+
|
|
270
|
+
### Forward compatibility
|
|
271
|
+
|
|
272
|
+
**Only the stores which care about a change should need upgrading.** This release has to touch every store, so it should
|
|
273
|
+
make that the last time where it can. Each store follows these rules from protocol 1 on:
|
|
274
|
+
|
|
275
|
+
- **Route without interpreting.** A store forwards an `@entangld` query by its `path`, whether or not it recognises the
|
|
276
|
+
name. Only the store which answers needs to understand it.
|
|
277
|
+
- **Don't answer what you don't know.** The store which owns the path answers `undefined` for an unknown name. Queries
|
|
278
|
+
it does know never answer `undefined`, so a caller can tell that it isn't supported.
|
|
279
|
+
- **Keep what you don't recognise.** A store forwarding a message or a reply passes on fields it doesn't know.
|
|
280
|
+
`receive()` drops them today, by calling `get(msg.path, msg.value)`, and `_requests` only passes on `msg.value`.
|
|
281
|
+
- **Ignore unknown message types.** `receive()` throws today. Ignoring them makes a stale negotiation harmless, at the
|
|
282
|
+
cost of hiding bugs which are loud today, so it may be worth emitting an event.
|
|
283
|
+
- **Degrade, don't fail.** A feature a store lacks should make a call weaker, not break it.
|
|
284
|
+
|
|
285
|
+
For example, a later `who_owns` query, where a store says whether it owns an endpoint or only passes it through, would
|
|
286
|
+
need only the answering store to upgrade. Every store between would forward it without knowing what it means.
|
|
287
|
+
|
|
288
|
+
**The limit:** this covers anything a store only needs to pass along. A feature which needs a store to act still needs
|
|
289
|
+
that store upgraded. Cancelling and deadlines have to clear each store's pending request, and compact events change the
|
|
290
|
+
encoding of one link, so those stop at the first old store, or only apply where both ends support them.
|
|
291
|
+
|
|
292
|
+
**TODO:** decide what the protocol number means once features exist. It may only need to change for a breaking change.
|
|
293
|
+
|
|
294
|
+
## Changes under consideration
|
|
295
|
+
|
|
296
|
+
Nothing here is decided or built. A change to the protocol is a good moment to commit to this document, and to make
|
|
297
|
+
several of these at once. Most depend on the [proposal above](#proposed-handshakes-and-versioning).
|
|
298
|
+
|
|
299
|
+
### Error replies
|
|
300
|
+
|
|
301
|
+
A `value` can't carry an error, which is why `call_rpc` puts success or failure inside the value. An error reply would
|
|
302
|
+
end the hang behind a raw `get`, and would let a store report that it gave up. A raw `get` can't be fixed without this.
|
|
303
|
+
|
|
304
|
+
Wrinkle: `set_rpc` already has its own error format, so we'd need to decide whether the two merge.
|
|
305
|
+
|
|
306
|
+
### Cancelling remote work
|
|
307
|
+
|
|
308
|
+
Applications would use this through `abort()`, not a new method, as [proposed](#features-upgrade-existing-calls).
|
|
309
|
+
|
|
310
|
+
Today an RPC which timed out or was aborted may still finish, so a retry can run it twice. A new message could carry the
|
|
311
|
+
abort down the chain, and let the function at the far end stop:
|
|
312
|
+
|
|
313
|
+
```js
|
|
314
|
+
{ type: "cancel", uuid }
|
|
315
|
+
|
|
316
|
+
store.set("slow.job", async (params, { signal }) => { /* can stop early */ });
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Each store on the way clears its pending request, which fixes the leak described under deadlines when the link works.
|
|
320
|
+
|
|
321
|
+
Wrinkles:
|
|
322
|
+
|
|
323
|
+
- **Middle stores need a new map.** A `get` has a different uuid on each link, so `cancel` can't simply be forwarded. A
|
|
324
|
+
store has to remember which incoming request (by uuid and sender) became which outgoing request, and clean that up on
|
|
325
|
+
every outcome.
|
|
326
|
+
- **Function signatures.** Functions are called as `data(params)`, so a second argument is possible, but any function
|
|
327
|
+
which already has an optional second parameter would start receiving the context by accident. It may need to be
|
|
328
|
+
opt-in. `_deref_mode` also calls functions, with no request at all.
|
|
329
|
+
- **`set_rpc` has no obvious place for it.** It calls `func(...params)`, so every position belongs to the caller's
|
|
330
|
+
arguments. Appending the context would collide with optional parameters, and `set_rpc` prints usage from the
|
|
331
|
+
function's parameters, so those would need filtering. This probably needs an opt-in on `set_rpc` itself.
|
|
332
|
+
- **Functions have to cooperate.** A signal only asks a function to stop. One which ignores it keeps running, so a
|
|
333
|
+
timeout still can't promise the work stopped.
|
|
334
|
+
- **It can't cross a dead link.** If the link is down, `cancel` is lost just as the reply was.
|
|
335
|
+
|
|
336
|
+
### Deadlines which travel
|
|
337
|
+
|
|
338
|
+
In a chain A → B → C, if a reply is lost after B, A gives up but B keeps its pending entry forever. Sending the remaining
|
|
339
|
+
time with the request would let each store enforce it, even if the link dies. Relative time avoids clock skew between
|
|
340
|
+
stores. This is how gRPC deadlines work.
|
|
341
|
+
|
|
342
|
+
```js
|
|
343
|
+
{ type: "get", path, value, uuid, timeout_ms: 8500 }
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Wrinkles:
|
|
347
|
+
|
|
348
|
+
- **Coverage stops at old stores, silently.** `receive()` only reads the fields it knows, so an old peer should ignore
|
|
349
|
+
the extra field, but it won't enforce it or pass it on. Confirm this before relying on it.
|
|
350
|
+
- **A store can't report that it gave up,** without error replies. It can only clean up and stay quiet, and the caller
|
|
351
|
+
gets the timeout from its own timer.
|
|
352
|
+
- **Should the far end get it?** Passing the deadline to a function is the same signature problem as cancelling.
|
|
353
|
+
- **Stores need a little margin.** If every store uses the same remaining time, they all expire together and race. It
|
|
354
|
+
may be worth subtracting a small allowance per link.
|
|
355
|
+
|
|
356
|
+
### Acknowledgements
|
|
357
|
+
|
|
358
|
+
`set`, `push` and `subscribe` have no reply, so a lost message goes unnoticed. A lost `subscribe` is the worst case,
|
|
359
|
+
because the events simply never arrive. Acks would need an answer for what to do when one doesn't come, and they would
|
|
360
|
+
add traffic to what is often a high-rate path, so they should probably be opt-in.
|
|
361
|
+
|
|
362
|
+
For `subscribe`, what a caller wants to know is that the subscription arrived, not that the path exists yet. That's a
|
|
363
|
+
better fit than a strict mode, and [checking a subscription](#checking-a-subscription) may answer it without acks at all.
|
|
364
|
+
|
|
365
|
+
### Checking a subscription
|
|
366
|
+
|
|
367
|
+
Because a subscription keeps one uuid along its whole chain, the store which owns the path can confirm that it has it.
|
|
368
|
+
This would be another `@entangld` query:
|
|
369
|
+
|
|
370
|
+
```js
|
|
371
|
+
const uuid = store.subscribe("b.c.motor.speed", callback);
|
|
372
|
+
|
|
373
|
+
await store.subscription_active(uuid);
|
|
374
|
+
// true The owning store has it
|
|
375
|
+
// false It doesn't, so it was lost or hasn't arrived yet
|
|
376
|
+
// null A store on the way doesn't support the check
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Unlike a per-message ack, this costs nothing unless it's called, it works at any time, such as after a reconnect, and only
|
|
380
|
+
the owning store needs to understand it. The library already knows the path from the uuid.
|
|
381
|
+
|
|
382
|
+
Wrinkle: `false` can mean a subscription is still on its way, so checking straight after subscribing isn't useful.
|
|
383
|
+
|
|
384
|
+
### Describing endpoints
|
|
385
|
+
|
|
386
|
+
`set_rpc` already returns usage text, which is a small version of this. `describe` would also return what a store has been
|
|
387
|
+
told about a path:
|
|
388
|
+
|
|
389
|
+
```js
|
|
390
|
+
store.set_description("motor.speed", {
|
|
391
|
+
description: "Wheel speed",
|
|
392
|
+
encoding: speed_encoding.to_json(),
|
|
393
|
+
user_data: { unit: "m/s", precision: 0.001 }
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
store.set_rpc("motor.stop", stop, { description: "Stop the motor", user_data: { safe_to_retry: true } });
|
|
397
|
+
|
|
398
|
+
await store.describe("b.motor.speed");
|
|
399
|
+
// { exists: true, kind: "data", description: "Wheel speed", encoding: { ... }, user_data: { ... } }
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
- **`description` is for people.** It's a short summary of the path.
|
|
403
|
+
- **`encoding` is for the library.** It says how a value is packed, and only matters if the path uses a codec.
|
|
404
|
+
- **`user_data` is for the application.** The library stores it and returns it unchanged, and never reads it. It can hold
|
|
405
|
+
anything which fits in JSON, like a unit, the precision of a sensor, or a JSON Schema. The library doesn't define any of
|
|
406
|
+
those, so an application can use whatever suits it.
|
|
407
|
+
|
|
408
|
+
All three are optional, and nothing is enforced, because a store accepts any JSON. They live beside the data, not in it, so
|
|
409
|
+
they don't appear in `get("")`, and removing a value doesn't lose them. Only the owning store has them, for exact paths at
|
|
410
|
+
first.
|
|
411
|
+
|
|
412
|
+
Wrinkles:
|
|
413
|
+
|
|
414
|
+
- **Size.** `describe` returns `user_data` every time, so large values cost traffic on slow links. It may be worth a way
|
|
415
|
+
to ask for it only when needed.
|
|
416
|
+
- **Drift.** Nothing checks `user_data` against the real value, or against `encoding`. A `Float32` might not hold a
|
|
417
|
+
claimed precision, for example. That's accepted, because the library doesn't read it.
|
|
418
|
+
|
|
419
|
+
### Compact events
|
|
420
|
+
|
|
421
|
+
Today's events repeat the `path` and `uuid` in every message. For high-rate paths this can be replaced in stages, each
|
|
422
|
+
useful by itself. Sizes are the message alone, before framing, and were worked out rather than measured on a link:
|
|
423
|
+
|
|
424
|
+
| Encoding | One number | 8-field pose |
|
|
425
|
+
| --- | --- | --- |
|
|
426
|
+
| Today's JSON | 115 | 209 |
|
|
427
|
+
| Short subscription id, in JSON | 15 | 114 |
|
|
428
|
+
| Id, with an `encoding` for the keys, in JSON | 15 | 69 |
|
|
429
|
+
| Id, with binary values as base64 in JSON | about 18 | about 54 |
|
|
430
|
+
| Id, with raw binary values | about 7 | about 35 |
|
|
431
|
+
|
|
432
|
+
The owner agrees the id when the subscription starts, and sends the `encoding` then, so both ends build the same codec
|
|
433
|
+
and can't disagree. This reply also confirms that the subscription arrived:
|
|
434
|
+
|
|
435
|
+
```js
|
|
436
|
+
{ type: "subscribed", uuid, id: 12, encoding: { /* ... */ } }
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Wrinkles:
|
|
440
|
+
|
|
441
|
+
- **It's per link.** Each link agrees its own ids, so a store in the middle decodes and encodes again, and a link with an
|
|
442
|
+
old store stays on today's events.
|
|
443
|
+
- **Values which don't fit.** A value which doesn't match its `encoding` goes as a normal event.
|
|
444
|
+
- **Lossless by default.** Dropping the `path` and `uuid` loses nothing, so that can happen automatically. An `encoding`
|
|
445
|
+
which narrows a value, like `Float32`, is lossy, so the owner chooses it for a path.
|
|
446
|
+
- **Rate often matters more.** The existing `every` option can cut a high-rate path by more than any encoding, with no
|
|
447
|
+
protocol change.
|
|
448
|
+
|
|
449
|
+
### Codecs
|
|
450
|
+
|
|
451
|
+
The library keeps no runtime dependencies, so packing is pluggable. A codec only provides ways to load an encoding, and
|
|
452
|
+
to encode and decode a value with it, and links opt in:
|
|
453
|
+
|
|
454
|
+
```js
|
|
455
|
+
store.negotiate(link, { codec: entangld_obj2buf });
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
[obj2buf](https://github.com/Nova-Dynamics/obj2buf-node) is the intended reference. It's pure JavaScript with no
|
|
459
|
+
dependencies. `Schema.to_json()` and `Schema.from_json()` give us an `encoding` which can be sent over a link, it can tell
|
|
460
|
+
`Float32` from `Float64`, and `schema.validate()` gives us the fallback for values which don't fit. Fields which can be
|
|
461
|
+
`null` need `OptionalType`. It hasn't been tried with this library yet.
|
|
462
|
+
|
|
463
|
+
The two ends need to use the same codec, so the codec's name should be part of negotiation.
|
|
464
|
+
|
|
465
|
+
### Binary frames
|
|
466
|
+
|
|
467
|
+
The aim is raw binary on the wire, but `sockhop` can't carry it yet. It splits messages on a terminator and parses each one
|
|
468
|
+
as JSON, so binary data needs a change there, like length-prefixed frames. That would also remove most of the framing
|
|
469
|
+
around each message.
|
|
470
|
+
|
|
471
|
+
Until then, the library supports both, and negotiation decides which a link uses:
|
|
472
|
+
|
|
473
|
+
1. **Base64 inside JSON.** This works on any JSON transport today, for about a third more bytes than raw binary.
|
|
474
|
+
2. **Raw binary frames.** This needs a transport which can carry them, so a transport has to be able to say that it can.
|
|
475
|
+
|
|
476
|
+
The library doesn't own the transport, so the change itself belongs in `sockhop`.
|
|
477
|
+
|
|
478
|
+
### Smaller improvements
|
|
479
|
+
|
|
480
|
+
- **`value` size:** `value` repeats the `path` the requester already knows, and `set` sends an empty `params`.
|
|
481
|
+
- **`every: null`:** `subscribe` sends this explicitly, where other optional fields are left out.
|
|
482
|
+
- **Transport guidance:** a recommended framing and example transport, so that a new port doesn't start from `lib/`. What
|
|
483
|
+
a transport must guarantee is an open question [above](#overview).
|
|
484
|
+
- **Conformance cases:** a shared set of message examples which any port can run, so that ports notice when they drift.
|
package/generate_docs.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const fs=require("fs");
|
|
2
2
|
const jsdoc2md=require("jsdoc-to-markdown");
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const template="# API reference\n\n{{>main}}\n";
|
|
5
5
|
|
|
6
|
-
jsdoc2md.render({files: "lib/*.js", template
|
|
7
|
-
.then(output=> fs.writeFileSync("
|
|
6
|
+
jsdoc2md.render({files: "lib/*.js", template})
|
|
7
|
+
.then(output=> fs.writeFileSync("API.md", output));
|