@net-mesh/browser 0.37.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/CHANGELOG.md +517 -0
- package/LICENSE-APACHE +190 -0
- package/LICENSE-MIT +21 -0
- package/README.md +666 -0
- package/dist/async-queue.d.ts +26 -0
- package/dist/async-queue.js +62 -0
- package/dist/credential.d.ts +70 -0
- package/dist/credential.js +95 -0
- package/dist/errors.d.ts +400 -0
- package/dist/errors.js +726 -0
- package/dist/events.d.ts +232 -0
- package/dist/events.js +298 -0
- package/dist/identity.d.ts +40 -0
- package/dist/identity.js +78 -0
- package/dist/index.bundle.js +2 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +46 -0
- package/dist/leader/events.d.ts +111 -0
- package/dist/leader/events.js +116 -0
- package/dist/leader/index.d.ts +14 -0
- package/dist/leader/index.js +11 -0
- package/dist/leader/session.d.ts +539 -0
- package/dist/leader/session.js +770 -0
- package/dist/leader/wasm.d.ts +123 -0
- package/dist/leader/wasm.js +39 -0
- package/dist/lobby.d.ts +183 -0
- package/dist/lobby.js +504 -0
- package/dist/local.d.ts +80 -0
- package/dist/local.js +160 -0
- package/dist/net_leaf.d.ts +1494 -0
- package/dist/net_leaf.js +3753 -0
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +137 -0
- package/dist/netcode/client.d.ts +102 -0
- package/dist/netcode/client.js +296 -0
- package/dist/netcode/clock.d.ts +45 -0
- package/dist/netcode/clock.js +54 -0
- package/dist/netcode/host.d.ts +118 -0
- package/dist/netcode/host.js +276 -0
- package/dist/netcode/index.d.ts +19 -0
- package/dist/netcode/index.js +14 -0
- package/dist/netcode/interpolate.d.ts +50 -0
- package/dist/netcode/interpolate.js +100 -0
- package/dist/netcode/wire.d.ts +125 -0
- package/dist/netcode/wire.js +117 -0
- package/dist/node.d.ts +908 -0
- package/dist/node.js +1085 -0
- package/dist/org.d.ts +412 -0
- package/dist/org.js +567 -0
- package/dist/peer-driver.d.ts +117 -0
- package/dist/peer-driver.js +224 -0
- package/dist/store/assembly.d.ts +176 -0
- package/dist/store/assembly.js +325 -0
- package/dist/store/chunker.d.ts +83 -0
- package/dist/store/chunker.js +190 -0
- package/dist/store/core.d.ts +136 -0
- package/dist/store/core.js +477 -0
- package/dist/store/definition.d.ts +23 -0
- package/dist/store/definition.js +83 -0
- package/dist/store/errors.d.ts +77 -0
- package/dist/store/errors.js +94 -0
- package/dist/store/host.d.ts +247 -0
- package/dist/store/host.js +675 -0
- package/dist/store/index.d.ts +50 -0
- package/dist/store/index.js +40 -0
- package/dist/store/interest.d.ts +40 -0
- package/dist/store/interest.js +77 -0
- package/dist/store/inventory.d.ts +81 -0
- package/dist/store/inventory.js +140 -0
- package/dist/store/join.d.ts +118 -0
- package/dist/store/join.js +724 -0
- package/dist/store/json.d.ts +50 -0
- package/dist/store/json.js +283 -0
- package/dist/store/ledger.d.ts +187 -0
- package/dist/store/ledger.js +292 -0
- package/dist/store/owner.d.ts +616 -0
- package/dist/store/owner.js +1711 -0
- package/dist/store/patch.d.ts +70 -0
- package/dist/store/patch.js +166 -0
- package/dist/store/player.d.ts +65 -0
- package/dist/store/player.js +261 -0
- package/dist/store/replica.d.ts +187 -0
- package/dist/store/replica.js +740 -0
- package/dist/store/state.d.ts +42 -0
- package/dist/store/state.js +165 -0
- package/dist/store/types.d.ts +200 -0
- package/dist/store/types.js +25 -0
- package/dist/store/visibility.d.ts +118 -0
- package/dist/store/visibility.js +282 -0
- package/dist/store/wire.d.ts +309 -0
- package/dist/store/wire.js +834 -0
- package/dist/stream.d.ts +174 -0
- package/dist/stream.js +238 -0
- package/dist/three/index.d.ts +109 -0
- package/dist/three/index.js +130 -0
- package/dist/udp-probe.d.ts +159 -0
- package/dist/udp-probe.js +245 -0
- package/dist/wasm.d.ts +642 -0
- package/dist/wasm.js +98 -0
- package/dist/world/border.d.ts +84 -0
- package/dist/world/border.js +99 -0
- package/dist/world/handoff.d.ts +187 -0
- package/dist/world/handoff.js +222 -0
- package/dist/world/index.d.ts +13 -0
- package/dist/world/index.js +9 -0
- package/dist/world/link.d.ts +179 -0
- package/dist/world/link.js +476 -0
- package/dist/world/regions.d.ts +154 -0
- package/dist/world/regions.js +314 -0
- package/package.json +72 -0
|
@@ -0,0 +1,1711 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner dispatch: join, admission, `authorize`, projection, `man`
|
|
3
|
+
* (brief §1.3, §1.5, §1.12, §2) — slice C.
|
|
4
|
+
*
|
|
5
|
+
* ## The authenticated origin is an argument, never a field
|
|
6
|
+
*
|
|
7
|
+
* {@link StoreOwner.receive} takes the **authenticated originating
|
|
8
|
+
* peer** as a parameter, and there is no way to name a caller inside a
|
|
9
|
+
* store frame at all (§2's shapes have no originator field). That is
|
|
10
|
+
* the whole of §1.3's rule expressed as a signature: the identity comes
|
|
11
|
+
* from the session the bytes arrived on, which the transport
|
|
12
|
+
* authenticated — `LeafEvent::StreamData.peer_node` on the Rust side,
|
|
13
|
+
* `LeafStream.peerNode` in the package — and a dispatcher cannot read
|
|
14
|
+
* it off the payload because the payload does not carry one.
|
|
15
|
+
*
|
|
16
|
+
* A caller could still *lie to this function*, so the contract is
|
|
17
|
+
* stated where it is enforceable: whoever calls `receive` must pass the
|
|
18
|
+
* peer the transport proved, and the store's own entry points will
|
|
19
|
+
* (slice G). Nothing here can make a wrong argument right, and
|
|
20
|
+
* pretending otherwise is the mistake this module refuses to make.
|
|
21
|
+
*
|
|
22
|
+
* ## What is here, and what is deliberately not
|
|
23
|
+
*
|
|
24
|
+
* Here: `join` admission, handle binding and re-checking, `authorize`
|
|
25
|
+
* for the read request, the audience projection, and the `man` + `snap`
|
|
26
|
+
* emission that follows. Refusals for the paths that reach those.
|
|
27
|
+
*
|
|
28
|
+
* Not here: `act`/`in`/`res` and the ledger (slice E), `aud`/`resume`
|
|
29
|
+
* transitions (slice F), `delta` emission (slice D). Those kinds are
|
|
30
|
+
* refused `not-ready` or `invalid-data` rather than half-implemented,
|
|
31
|
+
* and the refusal says which layer owns them.
|
|
32
|
+
*/
|
|
33
|
+
import { Assembly, AssemblyTable } from './assembly.js';
|
|
34
|
+
import { assertChunkingFits, chunkSnapshot } from './chunker.js';
|
|
35
|
+
import { StoreCore } from './core.js';
|
|
36
|
+
import { StoreError } from './errors.js';
|
|
37
|
+
import { applyVisibility, compileVisibility } from './visibility.js';
|
|
38
|
+
import { canonicalRequest, digestBinding, inlineBinding, LedgerTable, needsDigest, } from './ledger.js';
|
|
39
|
+
import { decimalValue, decodeMessage, encodeMessage, utf8Length, HANDLE_HEX_LENGTH, INCARNATION_HEX_LENGTH, MAX_PATH_SEGMENT_BYTES, } from './wire.js';
|
|
40
|
+
/** Actions in flight per owner, against the pending bound (§2). */
|
|
41
|
+
export const MAX_PENDING_ACTIONS = 32;
|
|
42
|
+
/** Handles one owner will hold (brief §2). */
|
|
43
|
+
export const MAX_HANDLES = 256;
|
|
44
|
+
/** How long a handle lives without an accepted authenticated message. */
|
|
45
|
+
export const HANDLE_LEASE_MS = 60_000;
|
|
46
|
+
/** How many hook runs one drain may perform before it stops (see `drainEvents`). */
|
|
47
|
+
export const MAX_EVENT_ROUNDS = 256;
|
|
48
|
+
function isRecord(value) {
|
|
49
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Placeholder correlation and handle for sizing a local result as the
|
|
53
|
+
* `res` a replica would receive. Same lengths as real ones, so the
|
|
54
|
+
* budget test measures the same bytes.
|
|
55
|
+
*/
|
|
56
|
+
const LOCAL_Q = '0'.repeat(16);
|
|
57
|
+
const LOCAL_H = '0'.repeat(32);
|
|
58
|
+
/**
|
|
59
|
+
* The owner half of the store protocol.
|
|
60
|
+
*
|
|
61
|
+
* Holds no transport: frames come in as text with an authenticated
|
|
62
|
+
* peer, and go out as text addressed to a peer. That is what lets the
|
|
63
|
+
* whole of slice C be exercised without a network.
|
|
64
|
+
*/
|
|
65
|
+
export class StoreOwner {
|
|
66
|
+
deps;
|
|
67
|
+
handles = new Map();
|
|
68
|
+
counters = {};
|
|
69
|
+
chunkBytes;
|
|
70
|
+
incarnation;
|
|
71
|
+
/**
|
|
72
|
+
* The authoritative document.
|
|
73
|
+
*
|
|
74
|
+
* The owner's state IS a store: one place that validates, reconciles,
|
|
75
|
+
* counts revisions and runs a handler's synchronous transaction.
|
|
76
|
+
* Keeping a second copy beside it would give handler writes and
|
|
77
|
+
* `commit` two truths to disagree about.
|
|
78
|
+
*/
|
|
79
|
+
core;
|
|
80
|
+
/** Digest computations in flight, against the pending bound (§2). */
|
|
81
|
+
pending = 0;
|
|
82
|
+
ledgers = new LedgerTable();
|
|
83
|
+
/**
|
|
84
|
+
* One pending projection per handle (§1.8).
|
|
85
|
+
*
|
|
86
|
+
* A newer control REPLACES the pending one rather than queueing
|
|
87
|
+
* behind it, which is what keeps this a bound rather than a buffer.
|
|
88
|
+
*/
|
|
89
|
+
deferred = new Map();
|
|
90
|
+
/** The revision every installed view is at, before the commit. */
|
|
91
|
+
revisionBeforeCommit = 0;
|
|
92
|
+
constructor(deps) {
|
|
93
|
+
this.deps = deps;
|
|
94
|
+
// Fail fast rather than discover the numbers on the first
|
|
95
|
+
// projection (§1.9).
|
|
96
|
+
this.chunkBytes = assertChunkingFits(deps.maxEventBytes);
|
|
97
|
+
this.incarnation = deps.newIncarnation();
|
|
98
|
+
if (this.incarnation.length !== INCARNATION_HEX_LENGTH) {
|
|
99
|
+
throw new StoreError('invalid-data', 'newIncarnation must return 16 lowercase hex');
|
|
100
|
+
}
|
|
101
|
+
// Exactly one projection. Neither would ship nothing truthful;
|
|
102
|
+
// both would leave which one decides a secret to the reader of
|
|
103
|
+
// this file.
|
|
104
|
+
const projections = (typeof deps.project === 'function' ? 1 : 0) + (typeof deps.projectFor === 'function' ? 1 : 0);
|
|
105
|
+
const declared = deps.definition.visibility;
|
|
106
|
+
if (projections > 1 || (projections === 0 && declared === undefined)) {
|
|
107
|
+
throw new StoreError('invalid-data', 'a host needs one of `project` and `projectFor`, or a definition that declares `visibility`');
|
|
108
|
+
}
|
|
109
|
+
this.visibility = declared === undefined ? null : compileVisibility(declared);
|
|
110
|
+
this.perPlayer = typeof deps.projectFor === 'function' || this.visibility?.perPlayer === true;
|
|
111
|
+
this.core = new StoreCore({ definition: deps.definition, initialState: deps.definition.empty() });
|
|
112
|
+
}
|
|
113
|
+
/** Whether projections depend on the peer (`projectFor`, or an `owner` rule). */
|
|
114
|
+
perPlayer;
|
|
115
|
+
/** The definition's declared rules, compiled; `null` when it declares none. */
|
|
116
|
+
visibility;
|
|
117
|
+
/** Development warnings already given, so each is said once. */
|
|
118
|
+
warned = new Set();
|
|
119
|
+
warnOnce(message) {
|
|
120
|
+
if (this.deps.warn === undefined || this.warned.has(message))
|
|
121
|
+
return;
|
|
122
|
+
this.warned.add(message);
|
|
123
|
+
try {
|
|
124
|
+
this.deps.warn(message);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// A logger's failure is not the store's.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** The key under which two handles are certain to receive the same projection. */
|
|
131
|
+
viewKey(peer, audience) {
|
|
132
|
+
return this.perPlayer ? JSON.stringify([peer, audience]) : JSON.stringify(audience);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Views whose change was projected from the changed entities alone
|
|
136
|
+
* (`sparseInterestDiff`). Not a refusal, so not in {@link snapshotCounters}.
|
|
137
|
+
*/
|
|
138
|
+
get sparseViews() {
|
|
139
|
+
return this.sparseViewCount;
|
|
140
|
+
}
|
|
141
|
+
sparseViewCount = 0;
|
|
142
|
+
/** Counters a host can report. Every refusal moves exactly one. */
|
|
143
|
+
snapshotCounters() {
|
|
144
|
+
return { ...this.counters };
|
|
145
|
+
}
|
|
146
|
+
/** Live handles, for a host's own bounds reporting. */
|
|
147
|
+
get handleCount() {
|
|
148
|
+
return this.handles.size;
|
|
149
|
+
}
|
|
150
|
+
/** The handle record, for a test or a host's introspection. */
|
|
151
|
+
handle(h) {
|
|
152
|
+
return this.handles.get(h);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Replace the authoritative state, as `setState` does.
|
|
156
|
+
*
|
|
157
|
+
* Inside an open transaction the write JOINS it (`applySnapshot`
|
|
158
|
+
* stages) and the delta emission is DEFERRED to that transaction's
|
|
159
|
+
* commit (#5): `decide`/`input` diff the committed before/after at
|
|
160
|
+
* the transaction's end and ship exactly one delta, and a rollback
|
|
161
|
+
* ships nothing at all. Emitting here would put a staged-world
|
|
162
|
+
* delta on the wire that no rollback retracts.
|
|
163
|
+
*/
|
|
164
|
+
commit(next) {
|
|
165
|
+
const inTransaction = this.core.inTransaction;
|
|
166
|
+
const previous = this.core.getState();
|
|
167
|
+
if (!inTransaction)
|
|
168
|
+
this.revisionBeforeCommit = this.core.revision;
|
|
169
|
+
this.core.applySnapshot(next);
|
|
170
|
+
if (inTransaction)
|
|
171
|
+
return this.accept([]);
|
|
172
|
+
const current = this.core.getState();
|
|
173
|
+
if (Object.is(previous, current)) {
|
|
174
|
+
// An equivalent commit is not a change (§4): no revision moved,
|
|
175
|
+
// so there is nothing to tell anyone.
|
|
176
|
+
return this.accept([]);
|
|
177
|
+
}
|
|
178
|
+
return this.accept(this.propagate(previous, current));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Write entities of one declared collection, as `setEntities` does:
|
|
182
|
+
* each changed entity validated by its per-entity parser, no
|
|
183
|
+
* whole-document validation. Same transaction and delta rules as
|
|
184
|
+
* {@link commit}.
|
|
185
|
+
*/
|
|
186
|
+
commitEntities(collection, changes) {
|
|
187
|
+
const inTransaction = this.core.inTransaction;
|
|
188
|
+
const previous = this.core.getState();
|
|
189
|
+
if (!inTransaction)
|
|
190
|
+
this.revisionBeforeCommit = this.core.revision;
|
|
191
|
+
this.core.applyEntities(collection, changes);
|
|
192
|
+
if (inTransaction)
|
|
193
|
+
return this.accept([]);
|
|
194
|
+
const current = this.core.getState();
|
|
195
|
+
if (Object.is(previous, current))
|
|
196
|
+
return this.accept([]);
|
|
197
|
+
return this.accept(this.propagate(previous, current));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Tell every installed handle what changed (§1.9).
|
|
201
|
+
*
|
|
202
|
+
* Per audience, because a delta of the raw state would ship what a
|
|
203
|
+
* projection exists to withhold: the previous and current
|
|
204
|
+
* projections are both taken and the difference between THOSE is
|
|
205
|
+
* what goes out. The previous one is recomputed from the previous
|
|
206
|
+
* state rather than retained per handle — one projection can be a
|
|
207
|
+
* megabyte, and keeping one per handle is 256 of them.
|
|
208
|
+
*
|
|
209
|
+
* When a delta would not fit the message budget the owner allocates
|
|
210
|
+
* a new generation instead and sends a manifest: an owner-initiated
|
|
211
|
+
* replacement, admissible at a `ready` replica (§1.8).
|
|
212
|
+
*/
|
|
213
|
+
propagate(previous, current) {
|
|
214
|
+
const out = [];
|
|
215
|
+
const r = String(this.core.revision);
|
|
216
|
+
const interestCache = new Map();
|
|
217
|
+
// One projection pair and one root diff per DISTINCT audience
|
|
218
|
+
// (per distinct peer and audience under `projectFor`), shared by
|
|
219
|
+
// every handle that carries it. Projecting and diffing
|
|
220
|
+
// per handle made this O(handles × world) JSON work per commit:
|
|
221
|
+
// both projections were freshly built per handle, so `shallowDiff`'s
|
|
222
|
+
// identity test could never fire and every key paid its
|
|
223
|
+
// serialization comparison once per handle.
|
|
224
|
+
const diffs = new Map();
|
|
225
|
+
// The commit's raw entity change, when it qualifies for per-view
|
|
226
|
+
// projection of the changed entities alone (see `sparseInterestDiff`);
|
|
227
|
+
// computed on first need.
|
|
228
|
+
let entityChange;
|
|
229
|
+
for (const handle of [...this.handles.values()]) {
|
|
230
|
+
// No `generation === 0` test: a handle is created and installed
|
|
231
|
+
// in one synchronous `join`, and a join whose emission fails
|
|
232
|
+
// deletes it, so a live handle always has a generation. What
|
|
233
|
+
// does need testing is a handle whose NEXT projection is still
|
|
234
|
+
// pending — a delta against a view it has not installed would
|
|
235
|
+
// name a revision it never had, and it has already cleared the
|
|
236
|
+
// old one locally.
|
|
237
|
+
if (this.deferred.has(handle.h))
|
|
238
|
+
continue;
|
|
239
|
+
// Re-authorize before shipping. Permission does not carry forward
|
|
240
|
+
// across time (the rule the read paths below all hold to), and the
|
|
241
|
+
// delta feed IS the read delivered fresh: a policy revocation has to
|
|
242
|
+
// stop it, not only the next projection. `join`, `aud`/`resume` and
|
|
243
|
+
// `resync` are all gated, and the `resync` refusal deliberately keeps
|
|
244
|
+
// the handle warm — which is why the feed cannot rely on the grant
|
|
245
|
+
// having been checked once at admission. The handle goes with the
|
|
246
|
+
// refusal here because the feed is the grant: leaving it alive would
|
|
247
|
+
// let `alive` renew the lease of a peer the policy now forbids.
|
|
248
|
+
if (!this.permitsRead(handle.peer, handle.audience)) {
|
|
249
|
+
this.forget(handle.h, 'refused');
|
|
250
|
+
// `closed`, the wire's legal unsolicited refusal (§1.12 admits
|
|
251
|
+
// exactly `closed` and `owner-lost` without a `q`): it closes
|
|
252
|
+
// the subscription, the replica rejoins, and the JOIN — a
|
|
253
|
+
// request path that CAN carry a refusal — answers `forbidden`
|
|
254
|
+
// correlated. A q-less `forbidden` is not a frame the codec
|
|
255
|
+
// admits; `encodeMessage` throws it out of `propagate`.
|
|
256
|
+
out.push(this.no(handle.peer, handle.h, 'closed', null));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const audienceKey = this.viewKey(handle.peer, handle.audience);
|
|
260
|
+
let ops;
|
|
261
|
+
if (this.interestFns.length > 0 && handle.interest !== null) {
|
|
262
|
+
// Interest-filtered: the view's diff, shared per view, is split
|
|
263
|
+
// into the rest of the document and per-entity changes, and each
|
|
264
|
+
// handle takes only the entities its interest covers.
|
|
265
|
+
let diff = interestCache.get(audienceKey);
|
|
266
|
+
if (diff === undefined) {
|
|
267
|
+
if (entityChange === undefined)
|
|
268
|
+
entityChange = this.entityDelta(previous, current);
|
|
269
|
+
diff =
|
|
270
|
+
entityChange === null
|
|
271
|
+
? undefined
|
|
272
|
+
: this.sparseInterestDiff(handle.peer, handle.audience, entityChange);
|
|
273
|
+
if (diff === undefined) {
|
|
274
|
+
const before = this.project(handle.peer, handle.audience, previous);
|
|
275
|
+
const after = this.project(handle.peer, handle.audience, current);
|
|
276
|
+
diff = before === null || after === null ? null : this.interestDiff(before, after);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
this.sparseViewCount += 1;
|
|
280
|
+
}
|
|
281
|
+
interestCache.set(audienceKey, diff);
|
|
282
|
+
}
|
|
283
|
+
ops = diff === null ? null : this.interestOps(diff, handle.interest);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
ops = diffs.get(audienceKey);
|
|
287
|
+
if (ops === undefined) {
|
|
288
|
+
const before = this.project(handle.peer, handle.audience, previous);
|
|
289
|
+
const after = this.project(handle.peer, handle.audience, current);
|
|
290
|
+
ops =
|
|
291
|
+
before === null || after === null
|
|
292
|
+
? null
|
|
293
|
+
: shallowDiff(before, after);
|
|
294
|
+
diffs.set(audienceKey, ops);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (ops === null) {
|
|
298
|
+
// `project` and its `empty()` fallback both failed: there is
|
|
299
|
+
// nothing truthful to ship, exactly as when `install` cannot
|
|
300
|
+
// carry a projection. Silence — the previous `continue` — left
|
|
301
|
+
// the replica at its old revision reporting `ready, stale:
|
|
302
|
+
// false` until the next change to that audience happened to
|
|
303
|
+
// resync it. The handle goes with a typed notice: `closed`,
|
|
304
|
+
// the only unsolicited refusal the wire admits (§1.12), which
|
|
305
|
+
// closes the subscription and provokes a rejoin — and the
|
|
306
|
+
// JOIN, a request path that CAN carry a refusal, answers
|
|
307
|
+
// `capacity` correlated if the projection is still broken.
|
|
308
|
+
this.forget(handle.h);
|
|
309
|
+
out.push(this.no(handle.peer, handle.h, 'closed', null));
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (ops.length === 0)
|
|
313
|
+
continue;
|
|
314
|
+
const delta = encodeDeltaWithin({ k: 'delta', h: handle.h, g: String(handle.generation), base: String(handle.revision), r, ops }, this.deps.maxEventBytes);
|
|
315
|
+
if (delta !== null) {
|
|
316
|
+
out.push({ peer: handle.peer, h: handle.h, frame: delta });
|
|
317
|
+
this.handles.set(handle.h, { ...handle, revision: this.core.revision });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
// Too large to carry as a patch: replace the whole view.
|
|
321
|
+
const frames = this.install(handle, null);
|
|
322
|
+
if (frames === null) {
|
|
323
|
+
this.forget(handle.h);
|
|
324
|
+
// `closed`, the legal unsolicited refusal: the replacement
|
|
325
|
+
// could not be carried at all. The rejoin it provokes is
|
|
326
|
+
// refused `capacity` correlated at the join.
|
|
327
|
+
out.push(this.no(handle.peer, handle.h, 'closed', null));
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
out.push(...frames);
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
/** The authoritative document, for a host's own reads. */
|
|
335
|
+
getState() {
|
|
336
|
+
return this.core.getState();
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Subscribe to the authoritative document.
|
|
340
|
+
*
|
|
341
|
+
* The owner's own code watches the state it serves — a host renders
|
|
342
|
+
* from this, and an action's commit is the same revision its
|
|
343
|
+
* subscribers see.
|
|
344
|
+
*/
|
|
345
|
+
subscribe(listener) {
|
|
346
|
+
return this.core.subscribe((state, previous) => {
|
|
347
|
+
listener(state, previous);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/** The current authoritative revision. */
|
|
351
|
+
get currentRevision() {
|
|
352
|
+
return this.core.revision;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The host's own player: whether `peer` may read `audience`.
|
|
356
|
+
*
|
|
357
|
+
* The same `authorize` call a `join` makes. There is no handle, no
|
|
358
|
+
* lease and no ledger — the host's player has no wire to lose a
|
|
359
|
+
* frame on and no request to replay — but there is the policy.
|
|
360
|
+
*/
|
|
361
|
+
localRead(peer, audience) {
|
|
362
|
+
return this.permitsRead(peer, audience);
|
|
363
|
+
}
|
|
364
|
+
/** The host's own player: the projection a replica of `audience` receives. */
|
|
365
|
+
localView(peer, audience, interest = null) {
|
|
366
|
+
const view = this.project(peer, audience);
|
|
367
|
+
return view === null ? null : this.filterInterest(view, interest);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The host's own player: one action, through the ladder a replica's
|
|
371
|
+
* `act` takes after the wire — known name, `authorize`, then one
|
|
372
|
+
* transaction that parses the input, runs the handler and validates
|
|
373
|
+
* the output, and a result that must fit the message budget a
|
|
374
|
+
* replica would have received it in.
|
|
375
|
+
*
|
|
376
|
+
* `input` is what `parseStoreJson` produced from the caller's value,
|
|
377
|
+
* so the handler sees exactly what it would see from a replica.
|
|
378
|
+
*/
|
|
379
|
+
localAct(name, input, peer) {
|
|
380
|
+
if (!Object.prototype.hasOwnProperty.call(this.deps.definition.actions, name)) {
|
|
381
|
+
return {
|
|
382
|
+
outcome: { kind: 'refusal', code: 'invalid-data' },
|
|
383
|
+
dispatched: this.refuse('local-act-unknown-name'),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (!this.permitsAction(peer, name, input)) {
|
|
387
|
+
return {
|
|
388
|
+
outcome: { kind: 'refusal', code: 'forbidden' },
|
|
389
|
+
dispatched: this.refuse('local-act-forbidden'),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
const spec = this.deps.definition.actions[name];
|
|
393
|
+
const before = this.core.getState();
|
|
394
|
+
this.revisionBeforeCommit = this.core.revision;
|
|
395
|
+
let outcome;
|
|
396
|
+
try {
|
|
397
|
+
const handler = this.deps.actions[name];
|
|
398
|
+
const produced = this.core.transact(context => {
|
|
399
|
+
const out = spec.output(handler(spec.input(input), context));
|
|
400
|
+
// The budget a replica's `res` would have had to fit. A result
|
|
401
|
+
// that works for the host and not for its players is a bug the
|
|
402
|
+
// host would never see, so it is refused here the same way.
|
|
403
|
+
const frame = encodeMessage({
|
|
404
|
+
k: 'res',
|
|
405
|
+
q: LOCAL_Q,
|
|
406
|
+
h: LOCAL_H,
|
|
407
|
+
s: '1',
|
|
408
|
+
out,
|
|
409
|
+
});
|
|
410
|
+
if (utf8Length(frame) > this.deps.maxEventBytes) {
|
|
411
|
+
throw new StoreError('capacity', 'the result does not fit the message budget');
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}, peer);
|
|
415
|
+
outcome = { kind: 'result', out: produced };
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
outcome = {
|
|
419
|
+
kind: 'refusal',
|
|
420
|
+
code: error instanceof StoreError && error.code === 'capacity' ? 'capacity' : 'action-rejected',
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
const after = this.core.getState();
|
|
424
|
+
const propagated = Object.is(before, after) ? [] : this.propagate(before, after);
|
|
425
|
+
return {
|
|
426
|
+
outcome,
|
|
427
|
+
dispatched: outcome.kind === 'refusal'
|
|
428
|
+
? this.refuse(`local-act-${outcome.code}`, propagated)
|
|
429
|
+
: this.accept(propagated),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* The host's own player: one latest-value input, through the same
|
|
434
|
+
* ladder as a replica's `in` after the wire. No sequence: a local
|
|
435
|
+
* call cannot arrive out of order.
|
|
436
|
+
*/
|
|
437
|
+
localInput(name, input, peer) {
|
|
438
|
+
if (!Object.prototype.hasOwnProperty.call(this.deps.definition.inputs, name)) {
|
|
439
|
+
return this.refuse('local-in-unknown-name');
|
|
440
|
+
}
|
|
441
|
+
if (!this.permitsInput(peer, name, input))
|
|
442
|
+
return this.refuse('local-in-forbidden');
|
|
443
|
+
const before = this.core.getState();
|
|
444
|
+
this.revisionBeforeCommit = this.core.revision;
|
|
445
|
+
try {
|
|
446
|
+
this.core.transact(context => {
|
|
447
|
+
const parse = this.deps.definition.inputs[name];
|
|
448
|
+
const handler = this.deps.inputs[name];
|
|
449
|
+
handler(parse(input), context);
|
|
450
|
+
}, peer);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return this.refuse('local-in-rejected');
|
|
454
|
+
}
|
|
455
|
+
const after = this.core.getState();
|
|
456
|
+
return this.accept(Object.is(before, after) ? [] : this.propagate(before, after));
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Dispatch one inbound frame.
|
|
460
|
+
*
|
|
461
|
+
* `peer` is the **authenticated** originating caller — see the module
|
|
462
|
+
* note. It is not read from `frame`, and no frame can carry one.
|
|
463
|
+
*/
|
|
464
|
+
receive(frame, peer, now = this.deps.now()) {
|
|
465
|
+
const decoded = decodeMessage(frame, { maxBytes: this.deps.maxEventBytes, as: 'owner' });
|
|
466
|
+
if (!decoded.ok)
|
|
467
|
+
return this.refuse(decoded.reason, []);
|
|
468
|
+
const message = decoded.message;
|
|
469
|
+
if (message.k === 'join')
|
|
470
|
+
return this.join(message, peer, now);
|
|
471
|
+
// Every other kind names a handle. Binding is re-checked on every
|
|
472
|
+
// message, not only at admission (§1.3): a session replacement does
|
|
473
|
+
// not re-authorize a handle.
|
|
474
|
+
const bound = this.bind(message.h, peer, now);
|
|
475
|
+
if (typeof bound === 'string') {
|
|
476
|
+
return this.refuse(bound, [this.no(peer, message.h, 'closed', requestOf(message))]);
|
|
477
|
+
}
|
|
478
|
+
switch (message.k) {
|
|
479
|
+
case 'alive': {
|
|
480
|
+
this.handles.set(bound.h, { ...bound, lastSeen: now });
|
|
481
|
+
return this.accept([{ peer, h: bound.h, frame: encodeMessage({ k: 'ok', q: message.q, h: bound.h }) }]);
|
|
482
|
+
}
|
|
483
|
+
case 'leave': {
|
|
484
|
+
this.forget(bound.h, 'left');
|
|
485
|
+
return this.accept([{ peer, h: bound.h, frame: encodeMessage({ k: 'ok', q: message.q, h: bound.h }) }]);
|
|
486
|
+
}
|
|
487
|
+
case 'int':
|
|
488
|
+
return this.interestChange(message, bound, peer, now);
|
|
489
|
+
case 'act':
|
|
490
|
+
return this.action(message, bound, peer, now);
|
|
491
|
+
case 'in':
|
|
492
|
+
return this.input(message, bound, peer, now);
|
|
493
|
+
case 'resync': {
|
|
494
|
+
// §1.8. `g` and `have` are ADVISORY historical position, so
|
|
495
|
+
// they are deliberately not read: a replica installed at A
|
|
496
|
+
// whose replacement B timed out can only honestly name A, and
|
|
497
|
+
// a current-generation check would refuse exactly the caller
|
|
498
|
+
// that most needs recovering.
|
|
499
|
+
//
|
|
500
|
+
// But a resync takes a FRESH projection at the current
|
|
501
|
+
// revision, which makes it a READ — so the policy rules on it
|
|
502
|
+
// exactly as it ruled on the join. Binding the handle proves
|
|
503
|
+
// WHO is asking; it does not carry a permission decision
|
|
504
|
+
// forward across time, and a read the policy has since revoked
|
|
505
|
+
// must not be served because the handle is still warm. The
|
|
506
|
+
// handle survives the refusal: the request was refused, not
|
|
507
|
+
// the subscription.
|
|
508
|
+
if (!this.permitsRead(peer, bound.audience)) {
|
|
509
|
+
return this.refuse('resync-forbidden', [this.no(peer, bound.h, 'forbidden', message.q)]);
|
|
510
|
+
}
|
|
511
|
+
const renewed = { ...bound, lastSeen: now };
|
|
512
|
+
this.handles.set(bound.h, renewed);
|
|
513
|
+
const frames = this.install(renewed, message.q);
|
|
514
|
+
if (frames === null) {
|
|
515
|
+
this.forget(bound.h);
|
|
516
|
+
return this.refuse('resync-projection-capacity', [this.no(peer, bound.h, 'capacity', message.q)]);
|
|
517
|
+
}
|
|
518
|
+
return this.accept(frames);
|
|
519
|
+
}
|
|
520
|
+
case 'aud':
|
|
521
|
+
case 'resume': {
|
|
522
|
+
// §1.7/§1.6. Both state the caller's DESIRED audience and both
|
|
523
|
+
// are reads, so both are authorized for that audience before
|
|
524
|
+
// any projection is taken — an audience the caller may not
|
|
525
|
+
// read is refused, never narrowed to one it may.
|
|
526
|
+
//
|
|
527
|
+
// `resume` differs from `resync` exactly here: a reconnecting
|
|
528
|
+
// caller must not be re-shown whatever audience the owner
|
|
529
|
+
// happened to hold last, because that audience may be wider
|
|
530
|
+
// than what the caller now wants.
|
|
531
|
+
// No audience-bound check here: the parser refuses
|
|
532
|
+
// `audience-too-many` at rung 6 (wire.ts, witnessed in
|
|
533
|
+
// wire.test.ts), so a frame carrying more than the bound never
|
|
534
|
+
// reaches dispatch. Re-checking it would be a claim.
|
|
535
|
+
if (!this.permitsRead(peer, message.aud)) {
|
|
536
|
+
return this.refuse(`${message.k}-forbidden`, [this.no(peer, bound.h, 'forbidden', message.q)]);
|
|
537
|
+
}
|
|
538
|
+
// The audience is rebound BEFORE the projection, so the
|
|
539
|
+
// projection can only ever be of the audience just authorized.
|
|
540
|
+
const rebound = {
|
|
541
|
+
...bound,
|
|
542
|
+
audience: [...message.aud],
|
|
543
|
+
// A resume states the caller's desired interest too; an `aud`
|
|
544
|
+
// leaves it as it was.
|
|
545
|
+
interest: message.k === 'resume' && message.int !== undefined ? [...message.int] : bound.interest,
|
|
546
|
+
lastSeen: now,
|
|
547
|
+
};
|
|
548
|
+
this.handles.set(rebound.h, rebound);
|
|
549
|
+
// A caller control supersedes an in-flight emission (§1.7b).
|
|
550
|
+
// There is nothing to retire on THIS side: an emission is
|
|
551
|
+
// built complete and handed to the transport in one return, so
|
|
552
|
+
// the owner holds no unsent remainder. What enforces the rule
|
|
553
|
+
// is the replica refusing the superseded generation's chunks,
|
|
554
|
+
// and the new generation this allocates — the superseded one
|
|
555
|
+
// stays consumed either way.
|
|
556
|
+
if (!this.deps.canProject()) {
|
|
557
|
+
// §1.8's one disposition: DEFER, never refuse. A control is
|
|
558
|
+
// never answered `not-ready`, and holding one pending
|
|
559
|
+
// projection per handle is what lets the caller's own
|
|
560
|
+
// deadline be the bound.
|
|
561
|
+
this.deferred.set(rebound.h, { q: message.q, peer });
|
|
562
|
+
return this.accept([]);
|
|
563
|
+
}
|
|
564
|
+
const frames = this.install(rebound, message.q);
|
|
565
|
+
if (frames === null) {
|
|
566
|
+
this.forget(rebound.h);
|
|
567
|
+
return this.refuse(`${message.k}-projection-capacity`, [this.no(peer, rebound.h, 'capacity', message.q)]);
|
|
568
|
+
}
|
|
569
|
+
return this.accept(frames);
|
|
570
|
+
}
|
|
571
|
+
default:
|
|
572
|
+
return this.refuse('unreachable-kind');
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* One action (§1.10).
|
|
577
|
+
*
|
|
578
|
+
* The ladder: gameplay readiness, then the sequence, then the
|
|
579
|
+
* ledger's disposition, then — only for a genuinely new request —
|
|
580
|
+
* `authorize` and one synchronous transaction.
|
|
581
|
+
*
|
|
582
|
+
* The digest path is the one place this module awaits. Everything
|
|
583
|
+
* decided before the await is discarded and decided again after it:
|
|
584
|
+
* the handle may have expired, the policy may have been revoked, and
|
|
585
|
+
* the ledger may have retained this very sequence while the digest
|
|
586
|
+
* was computing.
|
|
587
|
+
*/
|
|
588
|
+
action(message, bound, peer, now) {
|
|
589
|
+
// No gameplay-readiness refusal here, and the absence is the
|
|
590
|
+
// honest answer rather than an oversight. §1.6 makes gameplay
|
|
591
|
+
// admissible once a handle's current generation has been emitted
|
|
592
|
+
// in full, and in this owner every accepted emission is built and
|
|
593
|
+
// handed over synchronously, before the reply that admits the
|
|
594
|
+
// handle — so a live handle is always emitted for its current
|
|
595
|
+
// generation. A `not-ready` branch here could not fire, and a
|
|
596
|
+
// check that cannot fire is a claim.
|
|
597
|
+
//
|
|
598
|
+
// Slice F introduces the state that makes it meaningful: a
|
|
599
|
+
// deferred projection (§1.8) allocates a generation whose chunks
|
|
600
|
+
// are not yet out, and an `act` arriving in that window is the
|
|
601
|
+
// first one that must be refused `not-ready`. It belongs in the
|
|
602
|
+
// slice that can produce it.
|
|
603
|
+
const name = message.name;
|
|
604
|
+
if (!Object.prototype.hasOwnProperty.call(this.deps.definition.actions, name)) {
|
|
605
|
+
return this.refuse('act-unknown-name', [this.no(peer, bound.h, 'invalid-data', message.q)]);
|
|
606
|
+
}
|
|
607
|
+
let canonical;
|
|
608
|
+
try {
|
|
609
|
+
canonical = canonicalRequest(name, message.in);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return this.refuse('act-uncanonical', [this.no(peer, bound.h, 'invalid-data', message.q)]);
|
|
613
|
+
}
|
|
614
|
+
if (!needsDigest(canonical)) {
|
|
615
|
+
return this.decide(message, bound, peer, now, inlineBinding(canonical));
|
|
616
|
+
}
|
|
617
|
+
// The large-input path. The synchronous answer is "nothing yet";
|
|
618
|
+
// the rest arrives when the digest does.
|
|
619
|
+
if (this.pending >= MAX_PENDING_ACTIONS) {
|
|
620
|
+
return this.refuse('act-pending-bound', [this.no(peer, bound.h, 'capacity', message.q)]);
|
|
621
|
+
}
|
|
622
|
+
this.pending += 1;
|
|
623
|
+
const deferred = digestBinding(canonical)
|
|
624
|
+
.then(binding => {
|
|
625
|
+
// REVALIDATE. Nothing decided before the await is reused: the
|
|
626
|
+
// handle is re-bound to this peer and incarnation, readiness is
|
|
627
|
+
// re-checked, and the ledger is re-read.
|
|
628
|
+
const at = this.deps.now();
|
|
629
|
+
const rebound = this.bind(message.h, peer, at);
|
|
630
|
+
if (typeof rebound === 'string') {
|
|
631
|
+
return this.refuse(rebound, [this.no(peer, message.h, 'closed', message.q)]);
|
|
632
|
+
}
|
|
633
|
+
return this.decide(message, rebound, peer, at, binding);
|
|
634
|
+
})
|
|
635
|
+
.catch(() =>
|
|
636
|
+
// A digest that fails leaves the owner unable to say whether
|
|
637
|
+
// this request is the retained one, so it refuses rather than
|
|
638
|
+
// guessing either way.
|
|
639
|
+
this.refuse('act-digest-failed', [this.no(peer, message.h, 'indeterminate', message.q)]))
|
|
640
|
+
.finally(() => {
|
|
641
|
+
this.pending -= 1;
|
|
642
|
+
});
|
|
643
|
+
return { out: [], refused: null, deferred };
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* The ledger's decision, and the execution it may authorize.
|
|
647
|
+
*
|
|
648
|
+
* Synchronous from here on: no `await` may appear below this line,
|
|
649
|
+
* because the transaction it enters is synchronous by construction
|
|
650
|
+
* and a permission checked before a wait can have been revoked
|
|
651
|
+
* during it.
|
|
652
|
+
*/
|
|
653
|
+
decide(message, bound, peer, now, binding) {
|
|
654
|
+
const ledger = this.ledgers.ledger(bound.h);
|
|
655
|
+
const s = decimalValue(message.s);
|
|
656
|
+
const disposition = ledger.disposition(s, binding, now);
|
|
657
|
+
switch (disposition.kind) {
|
|
658
|
+
case 'exhausted':
|
|
659
|
+
// The counter does not wrap: a fresh handle is the only way
|
|
660
|
+
// on, and prior outcomes are then unknown.
|
|
661
|
+
return this.refuse('act-sequence-exhausted', [this.no(peer, bound.h, 'capacity', message.q)]);
|
|
662
|
+
case 'conflict':
|
|
663
|
+
// A sequence identifies one request, not a slot. Not executed,
|
|
664
|
+
// and the retained entry is left exactly as it was.
|
|
665
|
+
return this.refuse('act-binding-mismatch', [this.no(peer, bound.h, 'invalid-data', message.q)]);
|
|
666
|
+
case 'expired':
|
|
667
|
+
// Used, and the outcome is gone. This asserts NOTHING about
|
|
668
|
+
// whether it committed.
|
|
669
|
+
return this.refuse('act-result-expired', [this.no(peer, bound.h, 'result-expired', message.q)]);
|
|
670
|
+
case 'replay': {
|
|
671
|
+
// A replay is re-authorized first: the retained outcome is not
|
|
672
|
+
// a permission that keeps working.
|
|
673
|
+
if (!this.permitsAction(peer, message.name, message.in)) {
|
|
674
|
+
return this.refuse('act-forbidden-replay', [this.no(peer, bound.h, 'forbidden', message.q)]);
|
|
675
|
+
}
|
|
676
|
+
this.renew(bound, now);
|
|
677
|
+
return this.accept([this.reply(bound, peer, message.q, s, disposition.outcome)]);
|
|
678
|
+
}
|
|
679
|
+
case 'execute':
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
if (!this.permitsAction(peer, message.name, message.in)) {
|
|
683
|
+
// A denial is RETAINED. Otherwise a later policy change turns
|
|
684
|
+
// this refusal into an execution of the same sequence.
|
|
685
|
+
ledger.retain(s, binding, { kind: 'refusal', code: 'forbidden' }, now);
|
|
686
|
+
this.renew(bound, now);
|
|
687
|
+
return this.refuse('act-forbidden', [this.no(peer, bound.h, 'forbidden', message.q)]);
|
|
688
|
+
}
|
|
689
|
+
const spec = this.deps.definition.actions[message.name];
|
|
690
|
+
const before = this.core.getState();
|
|
691
|
+
this.revisionBeforeCommit = this.core.revision;
|
|
692
|
+
let outcome;
|
|
693
|
+
try {
|
|
694
|
+
const handler = this.deps.actions[message.name];
|
|
695
|
+
// Everything that can REFUSE this action happens inside the
|
|
696
|
+
// transaction — the input parse INCLUDED, because it is
|
|
697
|
+
// application code and a write it makes through the public
|
|
698
|
+
// `host.setState` must join the transaction like the handler's
|
|
699
|
+
// own: discarded on a throw, shipped once at the commit (#5).
|
|
700
|
+
// A parse run beside the transaction published its write and an
|
|
701
|
+
// emission of its own, and the rollback then answered
|
|
702
|
+
// `action-rejected` for a change that had already shipped.
|
|
703
|
+
//
|
|
704
|
+
// The output is validated here for the same reason: a throw
|
|
705
|
+
// inside the transaction discards the staged writes and a throw
|
|
706
|
+
// after it does not. Validating the output afterwards let a
|
|
707
|
+
// handler whose result the definition refuses commit its writes
|
|
708
|
+
// and ship a delta, answering `action-rejected` for a change
|
|
709
|
+
// that had already happened — and the same for a result too
|
|
710
|
+
// large to send, which left the caller with a document it could
|
|
711
|
+
// not be told about.
|
|
712
|
+
const produced = this.core.transact(context => {
|
|
713
|
+
const input = spec.input(message.in);
|
|
714
|
+
const out = spec.output(handler(input, context));
|
|
715
|
+
const frame = encodeMessage({
|
|
716
|
+
k: 'res',
|
|
717
|
+
q: message.q,
|
|
718
|
+
h: bound.h,
|
|
719
|
+
s: message.s,
|
|
720
|
+
out,
|
|
721
|
+
});
|
|
722
|
+
if (utf8Length(frame) > this.deps.maxEventBytes) {
|
|
723
|
+
throw new StoreError('capacity', 'the result does not fit the message budget');
|
|
724
|
+
}
|
|
725
|
+
return out;
|
|
726
|
+
}, peer);
|
|
727
|
+
outcome = { kind: 'result', out: produced };
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
// A handler that threw rejected the action, and the transaction
|
|
731
|
+
// discarded its writes. So did a result the definition refused
|
|
732
|
+
// or one that does not fit — and a result that cannot be sent
|
|
733
|
+
// is `capacity`, not `action-rejected`: the caller needs to know
|
|
734
|
+
// its request was well-formed and the ANSWER was not.
|
|
735
|
+
outcome = {
|
|
736
|
+
kind: 'refusal',
|
|
737
|
+
code: error instanceof StoreError && error.code === 'capacity' ? 'capacity' : 'action-rejected',
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
ledger.retain(s, binding, outcome, now);
|
|
741
|
+
this.renew(bound, now);
|
|
742
|
+
// The handler's writes are a change like any other: every
|
|
743
|
+
// installed handle is told, including this caller's — the `res`
|
|
744
|
+
// answers the request, the delta moves the view, and they are two
|
|
745
|
+
// different facts.
|
|
746
|
+
const after = this.core.getState();
|
|
747
|
+
const propagated = Object.is(before, after) ? [] : this.propagate(before, after);
|
|
748
|
+
return this.accept([this.reply(bound, peer, message.q, s, outcome), ...propagated]);
|
|
749
|
+
}
|
|
750
|
+
/** One latest-value input (§1.11). Fire and forget: no reply, ever. */
|
|
751
|
+
input(message, bound, peer, now) {
|
|
752
|
+
const name = message.name;
|
|
753
|
+
if (!Object.prototype.hasOwnProperty.call(this.deps.definition.inputs, name)) {
|
|
754
|
+
return this.refuse('in-unknown-name');
|
|
755
|
+
}
|
|
756
|
+
if (!this.ledgers.inputs(bound.h).admit(name, decimalValue(message.s))) {
|
|
757
|
+
// Stale. Loss is not an error here, and a dropped input may be
|
|
758
|
+
// the LAST one — the store promises no successor.
|
|
759
|
+
return this.refuse('in-stale-sequence');
|
|
760
|
+
}
|
|
761
|
+
if (!this.permitsInput(peer, name, message.in))
|
|
762
|
+
return this.refuse('in-forbidden');
|
|
763
|
+
const before = this.core.getState();
|
|
764
|
+
this.revisionBeforeCommit = this.core.revision;
|
|
765
|
+
try {
|
|
766
|
+
// The parse runs INSIDE the transaction, exactly as the action
|
|
767
|
+
// ladder does (#5): it is application code, and a write it makes
|
|
768
|
+
// through the public `host.setState` joins the transaction —
|
|
769
|
+
// discarded with the handler's own writes on a throw, shipped
|
|
770
|
+
// once in the change this path reports at the end.
|
|
771
|
+
this.core.transact(context => {
|
|
772
|
+
const parse = this.deps.definition.inputs[name];
|
|
773
|
+
const handler = this.deps.inputs[name];
|
|
774
|
+
handler(parse(message.in), context);
|
|
775
|
+
}, peer);
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
return this.refuse('in-rejected');
|
|
779
|
+
}
|
|
780
|
+
this.renew(bound, now);
|
|
781
|
+
// An input has no reply of its own, but the change it made is
|
|
782
|
+
// still a change every installed handle is told about.
|
|
783
|
+
const after = this.core.getState();
|
|
784
|
+
return this.accept(Object.is(before, after) ? [] : this.propagate(before, after));
|
|
785
|
+
}
|
|
786
|
+
/** A `res` for a result, a `no` for a retained refusal. */
|
|
787
|
+
reply(bound, peer, q, s, outcome) {
|
|
788
|
+
// A freshly constructed envelope carrying the retained OUTCOME —
|
|
789
|
+
// never a retained reply message, whose `q` belonged to the
|
|
790
|
+
// original request (§1.10).
|
|
791
|
+
const frame = outcome.kind === 'result'
|
|
792
|
+
? encodeMessage({ k: 'res', q, h: bound.h, s: s.toString(), out: outcome.out })
|
|
793
|
+
: encodeMessage({ k: 'no', q, h: bound.h, code: outcome.code, s: s.toString() });
|
|
794
|
+
return { peer, h: bound.h, frame };
|
|
795
|
+
}
|
|
796
|
+
permitsAction(peer, name, input) {
|
|
797
|
+
const request = { type: 'action', peer, name, input };
|
|
798
|
+
try {
|
|
799
|
+
return this.deps.authorize(request) === true;
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
permitsInput(peer, name, input) {
|
|
806
|
+
const request = { type: 'input', peer, name, input };
|
|
807
|
+
try {
|
|
808
|
+
return this.deps.authorize(request) === true;
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/** The lease is renewed by an accepted message, refusals excepted. */
|
|
815
|
+
renew(bound, now) {
|
|
816
|
+
const live = this.handles.get(bound.h);
|
|
817
|
+
if (live === undefined)
|
|
818
|
+
return;
|
|
819
|
+
this.handles.set(bound.h, { ...live, lastSeen: now });
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Everything that belongs to one handle, gone together.
|
|
823
|
+
*
|
|
824
|
+
* §2: the ledger goes with the handle. That is what makes a replay
|
|
825
|
+
* after eviction `closed` rather than `result-expired` — the owner
|
|
826
|
+
* no longer knows the sequence existed — and it is what keeps the
|
|
827
|
+
* ledger table bounded by live handles instead of by the owner's
|
|
828
|
+
* lifetime.
|
|
829
|
+
*/
|
|
830
|
+
forget(h, reason = 'dropped') {
|
|
831
|
+
const gone = this.handles.get(h);
|
|
832
|
+
this.handles.delete(h);
|
|
833
|
+
this.ledgers.forget(h);
|
|
834
|
+
// §1.8: expiry retires any projection already pending, and a dead
|
|
835
|
+
// handle never acquires one.
|
|
836
|
+
this.deferred.delete(h);
|
|
837
|
+
if (gone === undefined)
|
|
838
|
+
return;
|
|
839
|
+
this.presenceChanged(gone.peer, reason);
|
|
840
|
+
this.membershipChanged();
|
|
841
|
+
}
|
|
842
|
+
// ─────────────────────────────── events ───────────────────────────
|
|
843
|
+
/** Peers present as players: a handle installed, or a local player. */
|
|
844
|
+
present = new Set();
|
|
845
|
+
localPlayers = new Set();
|
|
846
|
+
pendingEvents = [];
|
|
847
|
+
areas = new Map();
|
|
848
|
+
areasAt = -1;
|
|
849
|
+
/** Set by `farewell`: a closing store raises no more events. */
|
|
850
|
+
ending = false;
|
|
851
|
+
get wantsEvents() {
|
|
852
|
+
return this.deps.onEvent !== undefined || this.deps.areaOf !== undefined;
|
|
853
|
+
}
|
|
854
|
+
isPresent(peer) {
|
|
855
|
+
if (this.localPlayers.has(peer))
|
|
856
|
+
return true;
|
|
857
|
+
for (const handle of this.handles.values())
|
|
858
|
+
if (handle.peer === peer)
|
|
859
|
+
return true;
|
|
860
|
+
return false;
|
|
861
|
+
}
|
|
862
|
+
/** Queue `join` / `leave` when a peer's presence actually flips. */
|
|
863
|
+
presenceChanged(peer, reason, audience = []) {
|
|
864
|
+
if (!this.wantsEvents || this.ending)
|
|
865
|
+
return;
|
|
866
|
+
const now = this.isPresent(peer);
|
|
867
|
+
const was = this.present.has(peer);
|
|
868
|
+
if (now === was)
|
|
869
|
+
return;
|
|
870
|
+
if (now) {
|
|
871
|
+
this.present.add(peer);
|
|
872
|
+
this.pendingEvents.push(Object.freeze({ type: 'join', peer, audience: Object.freeze([...audience]) }));
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
this.present.delete(peer);
|
|
876
|
+
this.areas.delete(peer);
|
|
877
|
+
this.pendingEvents.push(Object.freeze({ type: 'leave', peer, reason: reason ?? 'dropped' }));
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* The host's own player arriving or going (`hostPlayer`). It has no
|
|
882
|
+
* handle, but it is a player: `onEvent` hears it like any other.
|
|
883
|
+
*/
|
|
884
|
+
localPresence(peer, joined, audience = [], reason = 'left') {
|
|
885
|
+
if (joined)
|
|
886
|
+
this.localPlayers.add(peer);
|
|
887
|
+
else
|
|
888
|
+
this.localPlayers.delete(peer);
|
|
889
|
+
this.presenceChanged(peer, joined ? null : reason, audience);
|
|
890
|
+
}
|
|
891
|
+
/** Queue `area` events for present players whose area moved. */
|
|
892
|
+
detectAreas() {
|
|
893
|
+
const areaOf = this.deps.areaOf;
|
|
894
|
+
if (areaOf === undefined || this.ending)
|
|
895
|
+
return;
|
|
896
|
+
if (this.areasAt === this.core.revision && [...this.present].every(peer => this.areas.has(peer)))
|
|
897
|
+
return;
|
|
898
|
+
this.areasAt = this.core.revision;
|
|
899
|
+
const state = this.core.getState();
|
|
900
|
+
for (const peer of this.present) {
|
|
901
|
+
let area;
|
|
902
|
+
try {
|
|
903
|
+
const value = this.core.runReadOnly(() => areaOf(state, peer));
|
|
904
|
+
area = typeof value === 'string' ? value : null;
|
|
905
|
+
}
|
|
906
|
+
catch {
|
|
907
|
+
area = null;
|
|
908
|
+
}
|
|
909
|
+
const known = this.areas.has(peer);
|
|
910
|
+
const from = known ? (this.areas.get(peer) ?? null) : null;
|
|
911
|
+
this.areas.set(peer, area);
|
|
912
|
+
if (known ? from !== area : area !== null) {
|
|
913
|
+
this.pendingEvents.push(Object.freeze({ type: 'area', peer, from, to: area }));
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Run `onEvent` for everything queued, each as its own transaction,
|
|
919
|
+
* and return what those transactions changed for the replicas.
|
|
920
|
+
*
|
|
921
|
+
* Called by the host AFTER the frame that caused the events has been
|
|
922
|
+
* dispatched — never inside it — so a hook never runs in the middle
|
|
923
|
+
* of a projection pass. A hook that throws discards its writes, as a
|
|
924
|
+
* handler's do, and is counted. A hook whose writes cause more events
|
|
925
|
+
* (a teleport on join) is served in the same drain, up to a bound, so
|
|
926
|
+
* two hooks cannot ping-pong forever.
|
|
927
|
+
*/
|
|
928
|
+
drainEvents() {
|
|
929
|
+
const out = [];
|
|
930
|
+
if (!this.wantsEvents)
|
|
931
|
+
return this.accept(out);
|
|
932
|
+
for (let rounds = 0;; rounds += 1) {
|
|
933
|
+
if (this.pendingEvents.length === 0)
|
|
934
|
+
this.detectAreas();
|
|
935
|
+
const event = this.pendingEvents.shift();
|
|
936
|
+
if (event === undefined)
|
|
937
|
+
break;
|
|
938
|
+
if (rounds >= MAX_EVENT_ROUNDS) {
|
|
939
|
+
this.pendingEvents.length = 0;
|
|
940
|
+
this.counters['event-rounds-bound'] = (this.counters['event-rounds-bound'] ?? 0) + 1;
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
const hook = this.deps.onEvent;
|
|
944
|
+
if (hook === undefined)
|
|
945
|
+
continue;
|
|
946
|
+
const before = this.core.getState();
|
|
947
|
+
this.revisionBeforeCommit = this.core.revision;
|
|
948
|
+
try {
|
|
949
|
+
this.core.transact(context => hook(event, context), event.peer);
|
|
950
|
+
}
|
|
951
|
+
catch {
|
|
952
|
+
this.counters['event-rejected'] = (this.counters['event-rejected'] ?? 0) + 1;
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
const after = this.core.getState();
|
|
956
|
+
if (!Object.is(before, after))
|
|
957
|
+
out.push(...this.propagate(before, after));
|
|
958
|
+
}
|
|
959
|
+
return this.accept(out);
|
|
960
|
+
}
|
|
961
|
+
/** Listeners told when the set of installed handles changes. */
|
|
962
|
+
membership = new Set();
|
|
963
|
+
membershipChanged() {
|
|
964
|
+
for (const listener of [...this.membership]) {
|
|
965
|
+
try {
|
|
966
|
+
listener();
|
|
967
|
+
}
|
|
968
|
+
catch {
|
|
969
|
+
// A listener is application code on the frame path; its
|
|
970
|
+
// failure must not take the dispatch down with it.
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Be told when a handle is installed or forgotten — join, leave,
|
|
976
|
+
* expiry, a policy refusal, closure. Synchronous, inside the frame
|
|
977
|
+
* that caused it: a listener reads {@link peers} and must not call
|
|
978
|
+
* back into the owner.
|
|
979
|
+
*/
|
|
980
|
+
onMembership(listener) {
|
|
981
|
+
this.membership.add(listener);
|
|
982
|
+
return () => {
|
|
983
|
+
this.membership.delete(listener);
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
/** The distinct authenticated peers with an installed handle. */
|
|
987
|
+
peers() {
|
|
988
|
+
return [...new Set([...this.handles.values()].map(handle => handle.peer))];
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Re-ask `authorize` for every installed read, now.
|
|
992
|
+
*
|
|
993
|
+
* The delta feed re-authorizes before shipping, but only when there
|
|
994
|
+
* is a change to ship: a policy that revokes a peer (a kick) would
|
|
995
|
+
* otherwise leave it subscribed until the next commit. A refused
|
|
996
|
+
* handle is forgotten with the same unsolicited `closed` the feed
|
|
997
|
+
* sends, so the replica rejoins and its join is answered
|
|
998
|
+
* `forbidden`.
|
|
999
|
+
*/
|
|
1000
|
+
reauthorize() {
|
|
1001
|
+
const out = [];
|
|
1002
|
+
for (const handle of [...this.handles.values()]) {
|
|
1003
|
+
if (this.permitsRead(handle.peer, handle.audience))
|
|
1004
|
+
continue;
|
|
1005
|
+
this.forget(handle.h, 'refused');
|
|
1006
|
+
out.push(this.no(handle.peer, handle.h, 'closed', null));
|
|
1007
|
+
}
|
|
1008
|
+
return this.accept(out);
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Emit the projections that were deferred, now that one can be
|
|
1012
|
+
* taken.
|
|
1013
|
+
*
|
|
1014
|
+
* Completion **revalidates**: a handle can die between the deferral
|
|
1015
|
+
* and the availability, and an expired one never acquires a pending
|
|
1016
|
+
* projection in the first place (§1.8).
|
|
1017
|
+
*/
|
|
1018
|
+
resumeDeferred(now = this.deps.now()) {
|
|
1019
|
+
if (!this.deps.canProject())
|
|
1020
|
+
return this.accept([]);
|
|
1021
|
+
const out = [];
|
|
1022
|
+
for (const [h, pending] of [...this.deferred]) {
|
|
1023
|
+
this.deferred.delete(h);
|
|
1024
|
+
const bound = this.bind(h, pending.peer, now);
|
|
1025
|
+
if (typeof bound === 'string') {
|
|
1026
|
+
// The handle went while the projection was unavailable. The
|
|
1027
|
+
// caller's own deadline covers this; nothing is emitted to a
|
|
1028
|
+
// handle that no longer exists.
|
|
1029
|
+
this.counters[`deferred-${bound}`] = (this.counters[`deferred-${bound}`] ?? 0) + 1;
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
const frames = this.install(bound, pending.q);
|
|
1033
|
+
if (frames === null) {
|
|
1034
|
+
this.forget(h);
|
|
1035
|
+
out.push(this.no(pending.peer, h, 'capacity', pending.q));
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
out.push(...frames);
|
|
1039
|
+
}
|
|
1040
|
+
return this.accept(out);
|
|
1041
|
+
}
|
|
1042
|
+
/** Projections waiting on availability, for a host's bounds report. */
|
|
1043
|
+
get deferredCount() {
|
|
1044
|
+
return this.deferred.size;
|
|
1045
|
+
}
|
|
1046
|
+
/** Ledgers held, for a host's own bounds reporting (§2). */
|
|
1047
|
+
get ledgerCount() {
|
|
1048
|
+
return this.ledgers.size;
|
|
1049
|
+
}
|
|
1050
|
+
/** An accepted frame's reply. */
|
|
1051
|
+
accept(out, deferred = null) {
|
|
1052
|
+
return { out, refused: null, deferred };
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Expire handles whose lease has run out (§2).
|
|
1056
|
+
*
|
|
1057
|
+
* Reports the PEER with the handle, because §1.6's expiry notice is
|
|
1058
|
+
* addressed to a caller and the handle is gone by the time anyone
|
|
1059
|
+
* could look it up. A host that cannot reach that peer sends
|
|
1060
|
+
* nothing; no tombstone is retained to announce later.
|
|
1061
|
+
*/
|
|
1062
|
+
sweep(now) {
|
|
1063
|
+
const expired = [];
|
|
1064
|
+
for (const [h, handle] of [...this.handles]) {
|
|
1065
|
+
if (now - handle.lastSeen >= HANDLE_LEASE_MS) {
|
|
1066
|
+
this.forget(h, 'expired');
|
|
1067
|
+
expired.push({ h, peer: handle.peer });
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return expired;
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Say goodbye, once, to every replica this owner is serving.
|
|
1074
|
+
*
|
|
1075
|
+
* §1.6's doctrine is that a caller learns why rather than inferring
|
|
1076
|
+
* it from silence, and it was applied to EXPIRY (`sweep`) and not
|
|
1077
|
+
* to closure: a host that closed took its answer with it, so every
|
|
1078
|
+
* bound replica's next write reached a store that no longer
|
|
1079
|
+
* existed and the caller's only signal was a deadline — the
|
|
1080
|
+
* `indeterminate` "the store did not answer before the deadline",
|
|
1081
|
+
* measured over the real transport.
|
|
1082
|
+
*
|
|
1083
|
+
* The code is `owner-lost`, not `closed`, and the difference is
|
|
1084
|
+
* the whole semantics: `closed` is the expiry notice and the
|
|
1085
|
+
* replica REJOINS on it, which after a close would either hang or
|
|
1086
|
+
* silently attach the caller to a SUCCESSOR's different document
|
|
1087
|
+
* under the same handle. `owner-lost` is terminal — this
|
|
1088
|
+
* incarnation held the document and is gone — so the replica's
|
|
1089
|
+
* view is cleared, `ready()` rejects, and rejoining is the
|
|
1090
|
+
* caller's decision to make explicitly.
|
|
1091
|
+
*
|
|
1092
|
+
* Every handle is forgotten here, so the frames are a one-shot:
|
|
1093
|
+
* a second call has nothing to say.
|
|
1094
|
+
*/
|
|
1095
|
+
farewell() {
|
|
1096
|
+
this.ending = true;
|
|
1097
|
+
this.pendingEvents.length = 0;
|
|
1098
|
+
const out = [];
|
|
1099
|
+
for (const [h, handle] of [...this.handles]) {
|
|
1100
|
+
this.forget(h);
|
|
1101
|
+
out.push(this.no(handle.peer, h, 'owner-lost', null));
|
|
1102
|
+
}
|
|
1103
|
+
return out;
|
|
1104
|
+
}
|
|
1105
|
+
/** This owner's incarnation, which every manifest carries. */
|
|
1106
|
+
get incarnationHex() {
|
|
1107
|
+
return this.incarnation;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Admit a join.
|
|
1111
|
+
*
|
|
1112
|
+
* Order matters and is the ladder's: **the store address first**,
|
|
1113
|
+
* because it decides whether this owner may answer at all; then
|
|
1114
|
+
* definition identity and version, which are loud for the store
|
|
1115
|
+
* they ARE addressed to; then capacity, `authorize`, and only then
|
|
1116
|
+
* a handle. Nothing is allocated before the policy has ruled, so a
|
|
1117
|
+
* refused caller costs one counter and no state.
|
|
1118
|
+
*
|
|
1119
|
+
* This summary is written out in full because it was wrong for one
|
|
1120
|
+
* commit — it still listed the definition first, ten lines above
|
|
1121
|
+
* the code that had just been reordered, in the rung whose
|
|
1122
|
+
* position was the point of the change.
|
|
1123
|
+
*/
|
|
1124
|
+
join(message, peer, now) {
|
|
1125
|
+
// THE ADDRESS DECIDES WHETHER THIS OWNER MAY SPEAK AT ALL, so it
|
|
1126
|
+
// is read before anything else about the frame.
|
|
1127
|
+
//
|
|
1128
|
+
// Refused SILENTLY — no `no`, no reply: every host store on a
|
|
1129
|
+
// node sees every join, and an owner that is not the addressee
|
|
1130
|
+
// must neither answer it nor refuse it on the addressee's
|
|
1131
|
+
// behalf. The one it IS addressed to answers.
|
|
1132
|
+
//
|
|
1133
|
+
// The order matters and used to be wrong. The definition and
|
|
1134
|
+
// version checks below answer LOUDLY, with the joiner's own `q`,
|
|
1135
|
+
// so a node hosting `app.chat` and `app.world` had the chat
|
|
1136
|
+
// store reject a perfectly good `app.world` join —
|
|
1137
|
+
// `version-mismatch`, status failed — before the world store's
|
|
1138
|
+
// manifest could land. Same defect as the one this address
|
|
1139
|
+
// exists to fix, two rungs higher, and found by review probe K.
|
|
1140
|
+
if (message.store !== this.deps.store) {
|
|
1141
|
+
return this.refuse('join-other-store', []);
|
|
1142
|
+
}
|
|
1143
|
+
// Addressed to THIS store, and wrong about it: that is worth
|
|
1144
|
+
// saying out loud, and it is how a stale client learns it is
|
|
1145
|
+
// stale.
|
|
1146
|
+
if (message.def !== this.deps.definition.id) {
|
|
1147
|
+
return this.refuse('join-wrong-definition', [this.no(peer, null, 'version-mismatch', message.q)]);
|
|
1148
|
+
}
|
|
1149
|
+
if (message.ver !== this.deps.definition.version) {
|
|
1150
|
+
return this.refuse('join-wrong-version', [this.no(peer, null, 'version-mismatch', message.q)]);
|
|
1151
|
+
}
|
|
1152
|
+
if (this.handles.size >= MAX_HANDLES) {
|
|
1153
|
+
return this.refuse('join-capacity', [this.no(peer, null, 'capacity', message.q)]);
|
|
1154
|
+
}
|
|
1155
|
+
// `authorize` receives the AUTHENTICATED peer and the audience it
|
|
1156
|
+
// asked to read. A refusal is `forbidden` and allocates nothing.
|
|
1157
|
+
if (!this.permitsRead(peer, message.aud)) {
|
|
1158
|
+
return this.refuse('join-forbidden', [this.no(peer, null, 'forbidden', message.q)]);
|
|
1159
|
+
}
|
|
1160
|
+
const h = this.newHandle();
|
|
1161
|
+
if (h === null || h.length !== HANDLE_HEX_LENGTH || this.handles.has(h)) {
|
|
1162
|
+
return this.refuse('join-handle-unusable', [this.no(peer, null, 'owner-lost', message.q)]);
|
|
1163
|
+
}
|
|
1164
|
+
const handle = {
|
|
1165
|
+
h,
|
|
1166
|
+
inc: this.incarnation,
|
|
1167
|
+
peer,
|
|
1168
|
+
audience: [...message.aud],
|
|
1169
|
+
interest: message.int === undefined ? null : [...message.int],
|
|
1170
|
+
generation: 0,
|
|
1171
|
+
revision: this.core.revision,
|
|
1172
|
+
lastSeen: now,
|
|
1173
|
+
};
|
|
1174
|
+
this.handles.set(h, handle);
|
|
1175
|
+
const emitted = this.install(handle, message.q);
|
|
1176
|
+
if (emitted === null) {
|
|
1177
|
+
// The projection could not be encoded. The handle goes with it:
|
|
1178
|
+
// admitting one that can never be served is worse than refusing.
|
|
1179
|
+
this.forget(h);
|
|
1180
|
+
return this.refuse('join-projection-capacity', [this.no(peer, null, 'capacity', message.q)]);
|
|
1181
|
+
}
|
|
1182
|
+
this.presenceChanged(peer, null, message.aud);
|
|
1183
|
+
this.membershipChanged();
|
|
1184
|
+
return this.accept(emitted);
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Allocate the next generation and emit `man` + every `snap`.
|
|
1188
|
+
*
|
|
1189
|
+
* Returns `null` when the projection cannot be carried, so the caller
|
|
1190
|
+
* decides what to do with the handle rather than this function
|
|
1191
|
+
* guessing.
|
|
1192
|
+
*/
|
|
1193
|
+
install(handle, q) {
|
|
1194
|
+
const base = this.project(handle.peer, handle.audience);
|
|
1195
|
+
const projected = base === null ? null : this.filterInterest(base, handle.interest);
|
|
1196
|
+
// `null` means the application could produce neither a projection
|
|
1197
|
+
// nor its own empty value. There is nothing truthful to ship, so
|
|
1198
|
+
// the emission fails and the caller refuses.
|
|
1199
|
+
if (projected === null)
|
|
1200
|
+
return null;
|
|
1201
|
+
let chunked;
|
|
1202
|
+
try {
|
|
1203
|
+
chunked = chunkSnapshot(projected, this.chunkBytes);
|
|
1204
|
+
}
|
|
1205
|
+
catch {
|
|
1206
|
+
return null;
|
|
1207
|
+
}
|
|
1208
|
+
// Owner-allocated, monotone per handle, only on acceptance (§1.7a).
|
|
1209
|
+
const generation = handle.generation + 1;
|
|
1210
|
+
this.handles.set(handle.h, { ...handle, generation, revision: this.core.revision });
|
|
1211
|
+
const g = String(generation);
|
|
1212
|
+
const r = String(this.core.revision);
|
|
1213
|
+
const frames = [
|
|
1214
|
+
{
|
|
1215
|
+
peer: handle.peer,
|
|
1216
|
+
h: handle.h,
|
|
1217
|
+
frame: encodeMessage(q === null
|
|
1218
|
+
? { k: 'man', h: handle.h, inc: handle.inc, g, r, n: chunked.n, bytes: String(chunked.bytes) }
|
|
1219
|
+
: { k: 'man', q, h: handle.h, inc: handle.inc, g, r, n: chunked.n, bytes: String(chunked.bytes) }),
|
|
1220
|
+
},
|
|
1221
|
+
];
|
|
1222
|
+
for (let i = 0; i < chunked.n; i += 1) {
|
|
1223
|
+
frames.push({
|
|
1224
|
+
peer: handle.peer,
|
|
1225
|
+
h: handle.h,
|
|
1226
|
+
frame: encodeMessage({ k: 'snap', h: handle.h, g, r, i, n: chunked.n, d: chunked.pieces[i] }),
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
return frames;
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* One read decision, for the join and for every later read.
|
|
1233
|
+
*
|
|
1234
|
+
* A throwing policy is a refusal, never an admission.
|
|
1235
|
+
*/
|
|
1236
|
+
permitsRead(peer, audience) {
|
|
1237
|
+
const request = { type: 'read', peer, audience: [...audience] };
|
|
1238
|
+
try {
|
|
1239
|
+
return this.deps.authorize(request) === true;
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
return false;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
/** A handle from the application's allocator, or null if it threw. */
|
|
1246
|
+
newHandle() {
|
|
1247
|
+
try {
|
|
1248
|
+
return this.deps.newHandle();
|
|
1249
|
+
}
|
|
1250
|
+
catch {
|
|
1251
|
+
return null;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
// ───────────────────────────── interest ───────────────────────────
|
|
1255
|
+
/** The definition's interest collections and their key functions. */
|
|
1256
|
+
get interestFns() {
|
|
1257
|
+
this.#interestFns ??= Object.entries(this.deps.definition.interest ?? {}).map(([collection, key]) => [collection, key]);
|
|
1258
|
+
return this.#interestFns;
|
|
1259
|
+
}
|
|
1260
|
+
#interestFns;
|
|
1261
|
+
/** An entity's interest key; a key function that throws or returns a non-string means "no key": always delivered. */
|
|
1262
|
+
interestKey(fn, entity, id) {
|
|
1263
|
+
try {
|
|
1264
|
+
const key = this.core.runReadOnly(() => fn(entity, id));
|
|
1265
|
+
return typeof key === 'string' ? key : null;
|
|
1266
|
+
}
|
|
1267
|
+
catch {
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
/** A view narrowed to the entities an interest set covers. */
|
|
1272
|
+
filterInterest(view, interest) {
|
|
1273
|
+
if (interest === null || this.interestFns.length === 0)
|
|
1274
|
+
return view;
|
|
1275
|
+
const wanted = new Set(interest);
|
|
1276
|
+
const out = { ...view };
|
|
1277
|
+
for (const [collection, fn] of this.interestFns) {
|
|
1278
|
+
const entities = out[collection];
|
|
1279
|
+
if (!isRecord(entities))
|
|
1280
|
+
continue;
|
|
1281
|
+
const kept = {};
|
|
1282
|
+
for (const [id, entity] of Object.entries(entities)) {
|
|
1283
|
+
const key = this.interestKey(fn, entity, id);
|
|
1284
|
+
if (key === null || wanted.has(key))
|
|
1285
|
+
kept[id] = entity;
|
|
1286
|
+
}
|
|
1287
|
+
out[collection] = kept;
|
|
1288
|
+
}
|
|
1289
|
+
return out;
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* The commit's raw entity change, or `null` when this commit cannot be
|
|
1293
|
+
* projected entity by entity.
|
|
1294
|
+
*
|
|
1295
|
+
* It can when the host projects only by DECLARED rules (no `project` /
|
|
1296
|
+
* `projectFor`, which see the whole document), and every root key the
|
|
1297
|
+
* commit changed is a collection declared in both `interest` and
|
|
1298
|
+
* `entities`, and is an entity map on both sides. Declared rules are
|
|
1299
|
+
* path patterns, so an entity's visibility is the same in a document
|
|
1300
|
+
* holding only the changed entities; and `entities` is the contract that
|
|
1301
|
+
* an entity is valid on its own, so its projection is validated by the
|
|
1302
|
+
* per-entity parser instead of `state` over the whole view.
|
|
1303
|
+
*/
|
|
1304
|
+
entityDelta(previous, current) {
|
|
1305
|
+
if (this.deps.project !== undefined || this.deps.projectFor !== undefined)
|
|
1306
|
+
return null;
|
|
1307
|
+
const entities = this.deps.definition.entities;
|
|
1308
|
+
if (entities === undefined)
|
|
1309
|
+
return null;
|
|
1310
|
+
const interest = new Set(this.interestFns.map(([collection]) => collection));
|
|
1311
|
+
const was = previous;
|
|
1312
|
+
const now = current;
|
|
1313
|
+
const collections = [];
|
|
1314
|
+
for (const key of new Set([...Object.keys(was), ...Object.keys(now)])) {
|
|
1315
|
+
const b = Object.prototype.hasOwnProperty.call(was, key) ? was[key] : undefined;
|
|
1316
|
+
const a = Object.prototype.hasOwnProperty.call(now, key) ? now[key] : undefined;
|
|
1317
|
+
if (Object.is(b, a))
|
|
1318
|
+
continue;
|
|
1319
|
+
if (!interest.has(key) || !Object.prototype.hasOwnProperty.call(entities, key))
|
|
1320
|
+
return null;
|
|
1321
|
+
if (!isRecord(b) || !isRecord(a))
|
|
1322
|
+
return null;
|
|
1323
|
+
const ids = [];
|
|
1324
|
+
for (const id of new Set([...Object.keys(b), ...Object.keys(a)])) {
|
|
1325
|
+
const before = Object.prototype.hasOwnProperty.call(b, id) ? b[id] : undefined;
|
|
1326
|
+
const after = Object.prototype.hasOwnProperty.call(a, id) ? a[id] : undefined;
|
|
1327
|
+
if (!Object.is(before, after))
|
|
1328
|
+
ids.push(id);
|
|
1329
|
+
}
|
|
1330
|
+
collections.push({ collection: key, ids, before: b, after: a });
|
|
1331
|
+
}
|
|
1332
|
+
return { collections };
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* One view's change from the changed entities alone: O(changed) per
|
|
1336
|
+
* view instead of two whole-world projections, a whole-view validation
|
|
1337
|
+
* and a scan of every id. Returns `undefined` for anything it cannot
|
|
1338
|
+
* answer exactly as `interestDiff` over full projections would (a rule
|
|
1339
|
+
* hiding a whole collection, a projected entity its parser refuses),
|
|
1340
|
+
* and the caller takes the full path.
|
|
1341
|
+
*/
|
|
1342
|
+
sparseInterestDiff(peer, audience, delta) {
|
|
1343
|
+
const viewer = Object.freeze({ peer, audience: Object.freeze([...audience]) });
|
|
1344
|
+
const define = (target, key, value) => {
|
|
1345
|
+
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
|
|
1346
|
+
};
|
|
1347
|
+
const sparseBefore = {};
|
|
1348
|
+
const sparseAfter = {};
|
|
1349
|
+
for (const { collection, ids, before, after } of delta.collections) {
|
|
1350
|
+
const b = {};
|
|
1351
|
+
const a = {};
|
|
1352
|
+
for (const id of ids) {
|
|
1353
|
+
if (Object.prototype.hasOwnProperty.call(before, id))
|
|
1354
|
+
define(b, id, before[id]);
|
|
1355
|
+
if (Object.prototype.hasOwnProperty.call(after, id))
|
|
1356
|
+
define(a, id, after[id]);
|
|
1357
|
+
}
|
|
1358
|
+
define(sparseBefore, collection, b);
|
|
1359
|
+
define(sparseAfter, collection, a);
|
|
1360
|
+
}
|
|
1361
|
+
const projectedBefore = this.visibility === null ? sparseBefore : applyVisibility(this.visibility, sparseBefore, viewer);
|
|
1362
|
+
const projectedAfter = this.visibility === null ? sparseAfter : applyVisibility(this.visibility, sparseAfter, viewer);
|
|
1363
|
+
const keyFns = new Map(this.interestFns);
|
|
1364
|
+
const entities = this.deps.definition.entities;
|
|
1365
|
+
const changes = [];
|
|
1366
|
+
for (const { collection, ids } of delta.collections) {
|
|
1367
|
+
const pb = projectedBefore[collection];
|
|
1368
|
+
const pa = projectedAfter[collection];
|
|
1369
|
+
if (!isRecord(pb) || !isRecord(pa))
|
|
1370
|
+
return undefined;
|
|
1371
|
+
const parse = entities[collection];
|
|
1372
|
+
const fn = keyFns.get(collection);
|
|
1373
|
+
// Untouched by the rules: these ARE committed entities, validated
|
|
1374
|
+
// when committed, exactly as `project` returns a committed state.
|
|
1375
|
+
const check = (projected, rebuilt) => {
|
|
1376
|
+
if (projected === undefined || !rebuilt)
|
|
1377
|
+
return { ok: true, value: projected };
|
|
1378
|
+
try {
|
|
1379
|
+
return { ok: true, value: this.core.runReadOnly(() => parse(projected)) };
|
|
1380
|
+
}
|
|
1381
|
+
catch {
|
|
1382
|
+
return { ok: false, value: undefined };
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
for (const id of ids) {
|
|
1386
|
+
const was = check(Object.prototype.hasOwnProperty.call(pb, id) ? pb[id] : undefined, projectedBefore !== sparseBefore);
|
|
1387
|
+
const now = check(Object.prototype.hasOwnProperty.call(pa, id) ? pa[id] : undefined, projectedAfter !== sparseAfter);
|
|
1388
|
+
if (!was.ok || !now.ok)
|
|
1389
|
+
return undefined;
|
|
1390
|
+
if (Object.is(was.value, now.value) ||
|
|
1391
|
+
(was.value !== undefined &&
|
|
1392
|
+
now.value !== undefined &&
|
|
1393
|
+
JSON.stringify(was.value) === JSON.stringify(now.value))) {
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
changes.push({
|
|
1397
|
+
collection,
|
|
1398
|
+
id,
|
|
1399
|
+
before: was.value,
|
|
1400
|
+
after: now.value,
|
|
1401
|
+
beforeKey: was.value === undefined ? null : this.interestKey(fn, was.value, id),
|
|
1402
|
+
afterKey: now.value === undefined ? null : this.interestKey(fn, now.value, id),
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
return { rest: [], entities: changes, whole: [] };
|
|
1407
|
+
}
|
|
1408
|
+
/** One view's change, split for interest: the rest of the document, and per-entity changes with their keys. */
|
|
1409
|
+
interestDiff(before, after) {
|
|
1410
|
+
const collections = new Set(this.interestFns.map(([collection]) => collection));
|
|
1411
|
+
const strip = (view) => {
|
|
1412
|
+
const rest = {};
|
|
1413
|
+
for (const [key, value] of Object.entries(view))
|
|
1414
|
+
if (!collections.has(key))
|
|
1415
|
+
rest[key] = value;
|
|
1416
|
+
return rest;
|
|
1417
|
+
};
|
|
1418
|
+
const rest = shallowDiff(strip(before), strip(after));
|
|
1419
|
+
const entities = [];
|
|
1420
|
+
const whole = [];
|
|
1421
|
+
for (const [collection, fn] of this.interestFns) {
|
|
1422
|
+
const b = before[collection];
|
|
1423
|
+
const a = after[collection];
|
|
1424
|
+
if (!isRecord(b) || !isRecord(a)) {
|
|
1425
|
+
// Not an entity map on one side: replaced whole, filtered per handle.
|
|
1426
|
+
if (JSON.stringify(b) !== JSON.stringify(a))
|
|
1427
|
+
whole.push({ collection, after: a });
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
for (const id of new Set([...Object.keys(b), ...Object.keys(a)])) {
|
|
1431
|
+
const was = Object.prototype.hasOwnProperty.call(b, id) ? b[id] : undefined;
|
|
1432
|
+
const now = Object.prototype.hasOwnProperty.call(a, id) ? a[id] : undefined;
|
|
1433
|
+
if (Object.is(was, now) || (was !== undefined && now !== undefined && JSON.stringify(was) === JSON.stringify(now))) {
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
entities.push({
|
|
1437
|
+
collection,
|
|
1438
|
+
id,
|
|
1439
|
+
before: was,
|
|
1440
|
+
after: now,
|
|
1441
|
+
beforeKey: was === undefined ? null : this.interestKey(fn, was, id),
|
|
1442
|
+
afterKey: now === undefined ? null : this.interestKey(fn, now, id),
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
return { rest, entities, whole };
|
|
1447
|
+
}
|
|
1448
|
+
/** The ops one handle receives for a split diff. */
|
|
1449
|
+
interestOps(diff, interest) {
|
|
1450
|
+
const wanted = new Set(interest);
|
|
1451
|
+
const covers = (key) => key === null || wanted.has(key);
|
|
1452
|
+
const ops = [...diff.rest];
|
|
1453
|
+
for (const change of diff.entities) {
|
|
1454
|
+
const wasIn = change.before !== undefined && covers(change.beforeKey);
|
|
1455
|
+
const isIn = change.after !== undefined && covers(change.afterKey);
|
|
1456
|
+
if (isIn)
|
|
1457
|
+
ops.push({ o: 'r', p: [change.collection, change.id], val: change.after });
|
|
1458
|
+
else if (wasIn)
|
|
1459
|
+
ops.push({ o: 'x', p: [change.collection, change.id] });
|
|
1460
|
+
}
|
|
1461
|
+
for (const entry of diff.whole) {
|
|
1462
|
+
const filtered = this.filterInterest({ [entry.collection]: entry.after }, interest);
|
|
1463
|
+
ops.push({ o: 'r', p: [entry.collection], val: filtered[entry.collection] });
|
|
1464
|
+
}
|
|
1465
|
+
return ops;
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* A new interest set: the entities entering it arrive, the ones leaving
|
|
1469
|
+
* go, in one delta — the view never blanks — and then `ok`.
|
|
1470
|
+
*
|
|
1471
|
+
* Interest is a filter, not a permission: nothing here asks `authorize`,
|
|
1472
|
+
* because the audience (which is) is unchanged and every entity sent
|
|
1473
|
+
* was already readable under it.
|
|
1474
|
+
*/
|
|
1475
|
+
interestChange(message, bound, peer, now) {
|
|
1476
|
+
const next = { ...bound, interest: [...message.int], lastSeen: now };
|
|
1477
|
+
this.handles.set(next.h, next);
|
|
1478
|
+
const ok = { peer, h: bound.h, frame: encodeMessage({ k: 'ok', q: message.q, h: bound.h }) };
|
|
1479
|
+
if (this.interestFns.length === 0 || this.deferred.has(bound.h)) {
|
|
1480
|
+
// Nothing to filter, or an installation is pending that will
|
|
1481
|
+
// project with the new set.
|
|
1482
|
+
return this.accept([ok]);
|
|
1483
|
+
}
|
|
1484
|
+
const view = this.project(peer, bound.audience);
|
|
1485
|
+
if (view === null) {
|
|
1486
|
+
this.forget(bound.h);
|
|
1487
|
+
return this.refuse('int-projection-capacity', [this.no(peer, bound.h, 'capacity', message.q)]);
|
|
1488
|
+
}
|
|
1489
|
+
const before = this.filterInterest(view, bound.interest);
|
|
1490
|
+
const after = this.filterInterest(view, next.interest);
|
|
1491
|
+
const ops = [];
|
|
1492
|
+
for (const [collection] of this.interestFns) {
|
|
1493
|
+
const b = before[collection];
|
|
1494
|
+
const a = after[collection];
|
|
1495
|
+
if (!isRecord(b) || !isRecord(a))
|
|
1496
|
+
continue;
|
|
1497
|
+
for (const id of Object.keys(a))
|
|
1498
|
+
if (!Object.prototype.hasOwnProperty.call(b, id))
|
|
1499
|
+
ops.push({ o: 'r', p: [collection, id], val: a[id] });
|
|
1500
|
+
for (const id of Object.keys(b))
|
|
1501
|
+
if (!Object.prototype.hasOwnProperty.call(a, id))
|
|
1502
|
+
ops.push({ o: 'x', p: [collection, id] });
|
|
1503
|
+
}
|
|
1504
|
+
if (ops.length === 0)
|
|
1505
|
+
return this.accept([ok]);
|
|
1506
|
+
const r = String(this.core.revision);
|
|
1507
|
+
const delta = bound.revision === this.core.revision
|
|
1508
|
+
? encodeDeltaWithin({ k: 'delta', h: bound.h, g: String(bound.generation), base: r, r, ops }, this.deps.maxEventBytes)
|
|
1509
|
+
: null;
|
|
1510
|
+
if (delta !== null)
|
|
1511
|
+
return this.accept([{ peer, h: bound.h, frame: delta }, ok]);
|
|
1512
|
+
// Too large for one delta, or this replica is behind the current
|
|
1513
|
+
// revision: replace the view whole — an owner-initiated
|
|
1514
|
+
// installation, admissible at a ready replica — and then `ok`,
|
|
1515
|
+
// which arrives after the last chunk.
|
|
1516
|
+
const frames = this.install(next, null);
|
|
1517
|
+
if (frames === null) {
|
|
1518
|
+
this.forget(bound.h);
|
|
1519
|
+
return this.refuse('int-projection-capacity', [this.no(peer, bound.h, 'capacity', message.q)]);
|
|
1520
|
+
}
|
|
1521
|
+
return this.accept([...frames, ok]);
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* The audience projection, validated by the definition.
|
|
1525
|
+
*
|
|
1526
|
+
* A projection that does not satisfy `state()` is the owner's bug and
|
|
1527
|
+
* must not be shipped as a snapshot, so it becomes `empty()` — the
|
|
1528
|
+
* value that represents absence — rather than the full state.
|
|
1529
|
+
*/
|
|
1530
|
+
project(peer, audience, state = this.core.getState()) {
|
|
1531
|
+
try {
|
|
1532
|
+
const viewer = Object.freeze({ peer, audience: Object.freeze([...audience]) });
|
|
1533
|
+
const base = this.deps.projectFor !== undefined
|
|
1534
|
+
? this.deps.projectFor(state, viewer)
|
|
1535
|
+
: this.deps.project !== undefined
|
|
1536
|
+
? this.deps.project(state, audience)
|
|
1537
|
+
: state;
|
|
1538
|
+
if (this.visibility === null && base === state) {
|
|
1539
|
+
this.warnOnce(`store '${this.deps.definition.id}': every player receives the full state. ` +
|
|
1540
|
+
"Declare `visibility: 'open'` on the definition if that is intended, or rules for what is secret.");
|
|
1541
|
+
}
|
|
1542
|
+
// Declared rules apply AFTER the hand-written projection: code can
|
|
1543
|
+
// narrow what a rule allows, never widen it.
|
|
1544
|
+
const projected = this.visibility === null ? base : applyVisibility(this.visibility, base, viewer);
|
|
1545
|
+
// Nothing withheld and nothing rebuilt: this IS a committed state,
|
|
1546
|
+
// which the core validated when it was committed. Validating again
|
|
1547
|
+
// builds fresh objects, and fresh objects defeat every identity
|
|
1548
|
+
// check downstream — the diff then serializes every entity to find
|
|
1549
|
+
// the few that moved (measured, scripts/bench-world.mjs).
|
|
1550
|
+
if (projected === state)
|
|
1551
|
+
return state;
|
|
1552
|
+
try {
|
|
1553
|
+
return this.deps.definition.state(projected);
|
|
1554
|
+
}
|
|
1555
|
+
catch (error) {
|
|
1556
|
+
this.warnOnce(`store '${this.deps.definition.id}': a projection failed the state validator, so the player got empty() — ` +
|
|
1557
|
+
`${error instanceof Error ? error.message : String(error)}` +
|
|
1558
|
+
(this.visibility === null ? '' : ' (a hidden field is the HIDDEN marker: wrap its parser with hiddenOr)'));
|
|
1559
|
+
throw error;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
catch {
|
|
1563
|
+
// `empty()` is application code as well, and it is reached
|
|
1564
|
+
// precisely when the application has already thrown once. A
|
|
1565
|
+
// second failure has no value left to fall back to, so it
|
|
1566
|
+
// becomes a refusal rather than an exception escaping the frame
|
|
1567
|
+
// handler — which would take down the transport loop for every
|
|
1568
|
+
// other handle.
|
|
1569
|
+
try {
|
|
1570
|
+
return this.deps.definition.empty();
|
|
1571
|
+
}
|
|
1572
|
+
catch {
|
|
1573
|
+
return null;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Re-check the binding for every message (§1.3).
|
|
1579
|
+
*
|
|
1580
|
+
* Returns a countable reason instead of a handle when the handle is
|
|
1581
|
+
* unknown, expired, or bound to a different authenticated peer — and
|
|
1582
|
+
* the three are **one** refusal on the wire, so a refusal cannot
|
|
1583
|
+
* disclose that a handle exists (§2).
|
|
1584
|
+
*/
|
|
1585
|
+
bind(h, peer, now) {
|
|
1586
|
+
const handle = this.handles.get(h);
|
|
1587
|
+
if (handle === undefined)
|
|
1588
|
+
return 'handle-unknown';
|
|
1589
|
+
if (now - handle.lastSeen >= HANDLE_LEASE_MS) {
|
|
1590
|
+
this.forget(h, 'expired');
|
|
1591
|
+
return 'handle-expired';
|
|
1592
|
+
}
|
|
1593
|
+
if (handle.peer !== peer)
|
|
1594
|
+
return 'handle-foreign-peer';
|
|
1595
|
+
// No incarnation check here: a handle is created with this owner's
|
|
1596
|
+
// incarnation and does not outlive the owner, so a stale one cannot
|
|
1597
|
+
// reach this table. The incarnation rides the manifest as
|
|
1598
|
+
// provenance for the replica, which is a different job. A check
|
|
1599
|
+
// that cannot fire is not a defence — it is a claim.
|
|
1600
|
+
return handle;
|
|
1601
|
+
}
|
|
1602
|
+
no(peer, h, code, q) {
|
|
1603
|
+
return {
|
|
1604
|
+
peer,
|
|
1605
|
+
h,
|
|
1606
|
+
frame: encodeMessage(q === null
|
|
1607
|
+
? { k: 'no', h: h, code }
|
|
1608
|
+
: h === null
|
|
1609
|
+
? { k: 'no', q, code }
|
|
1610
|
+
: { k: 'no', q, h, code }),
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
refuse(reason, out = []) {
|
|
1614
|
+
this.counters[reason] = (this.counters[reason] ?? 0) + 1;
|
|
1615
|
+
return { out, refused: reason, deferred: null };
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* The root-level difference between two projections.
|
|
1620
|
+
*
|
|
1621
|
+
* Root level only, deliberately: §1.13's patch semantics replace a
|
|
1622
|
+
* whole subtree at a path, and a deeper diff would buy smaller frames
|
|
1623
|
+
* at the cost of a second traversal per handle per commit — the thing
|
|
1624
|
+
* a 60 Hz loop cannot afford. A subtree that did not change is not
|
|
1625
|
+
* emitted at all, which is where the saving actually is.
|
|
1626
|
+
*/
|
|
1627
|
+
function shallowDiff(before, after) {
|
|
1628
|
+
const ops = [];
|
|
1629
|
+
for (const key of Object.keys(after)) {
|
|
1630
|
+
const nextValue = after[key];
|
|
1631
|
+
if (Object.prototype.hasOwnProperty.call(before, key) && Object.is(before[key], nextValue))
|
|
1632
|
+
continue;
|
|
1633
|
+
const previousValue = Object.prototype.hasOwnProperty.call(before, key) ? before[key] : undefined;
|
|
1634
|
+
// One level deeper for a map on both sides: the entries that
|
|
1635
|
+
// changed, not the whole map. Measured (scripts/bench-world.mjs):
|
|
1636
|
+
// moving 5% of 500 ships resent all 62 KB of `ships` every tick,
|
|
1637
|
+
// because any change inside a root key replaced it whole.
|
|
1638
|
+
const entries = previousValue === undefined ? null : entryDiff(key, previousValue, nextValue);
|
|
1639
|
+
if (entries !== null) {
|
|
1640
|
+
ops.push(...entries);
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
// Not `Object.is` alone: reconciliation shares unchanged subtrees,
|
|
1644
|
+
// so identity IS the comparison for anything it touched, and a
|
|
1645
|
+
// value it could not share is compared by its serialization.
|
|
1646
|
+
if (previousValue !== undefined && JSON.stringify(previousValue) === JSON.stringify(nextValue)) {
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
ops.push({ o: 'r', p: [key], val: nextValue });
|
|
1650
|
+
}
|
|
1651
|
+
for (const key of Object.keys(before)) {
|
|
1652
|
+
if (!Object.prototype.hasOwnProperty.call(after, key))
|
|
1653
|
+
ops.push({ o: 'x', p: [key] });
|
|
1654
|
+
}
|
|
1655
|
+
return ops;
|
|
1656
|
+
}
|
|
1657
|
+
/** Entry ops worth sending instead of replacing a whole map: at most this many. */
|
|
1658
|
+
const MAX_ENTRY_OPS = 128;
|
|
1659
|
+
/**
|
|
1660
|
+
* The per-entry ops turning map `before` into map `after` under root
|
|
1661
|
+
* `key`, or `null` when replacing the whole value is the better (or
|
|
1662
|
+
* only) choice: not maps on both sides, an entry name the patch path
|
|
1663
|
+
* cannot carry, or more changed entries than {@link MAX_ENTRY_OPS} or
|
|
1664
|
+
* than half the map.
|
|
1665
|
+
*/
|
|
1666
|
+
function entryDiff(key, before, after) {
|
|
1667
|
+
const isMap = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1668
|
+
if (!isMap(before) || !isMap(after))
|
|
1669
|
+
return null;
|
|
1670
|
+
const ops = [];
|
|
1671
|
+
const limit = Math.min(MAX_ENTRY_OPS, Math.max(1, Math.floor(Object.keys(after).length / 2)));
|
|
1672
|
+
const carriable = (entry) => utf8Length(entry) <= MAX_PATH_SEGMENT_BYTES && !['__proto__', 'constructor', 'prototype'].includes(entry);
|
|
1673
|
+
for (const entry of Object.keys(after)) {
|
|
1674
|
+
const next = after[entry];
|
|
1675
|
+
const had = Object.prototype.hasOwnProperty.call(before, entry);
|
|
1676
|
+
if (had && (Object.is(before[entry], next) || JSON.stringify(before[entry]) === JSON.stringify(next)))
|
|
1677
|
+
continue;
|
|
1678
|
+
if (!carriable(entry))
|
|
1679
|
+
return null;
|
|
1680
|
+
ops.push({ o: 'r', p: [key, entry], val: next });
|
|
1681
|
+
if (ops.length > limit)
|
|
1682
|
+
return null;
|
|
1683
|
+
}
|
|
1684
|
+
for (const entry of Object.keys(before)) {
|
|
1685
|
+
if (Object.prototype.hasOwnProperty.call(after, entry))
|
|
1686
|
+
continue;
|
|
1687
|
+
if (!carriable(entry))
|
|
1688
|
+
return null;
|
|
1689
|
+
ops.push({ o: 'x', p: [key, entry] });
|
|
1690
|
+
if (ops.length > limit)
|
|
1691
|
+
return null;
|
|
1692
|
+
}
|
|
1693
|
+
return ops;
|
|
1694
|
+
}
|
|
1695
|
+
/** The delta frame, or null when it will not fit the budget (§1.9). */
|
|
1696
|
+
function encodeDeltaWithin(message, maxEventBytes) {
|
|
1697
|
+
let frame;
|
|
1698
|
+
try {
|
|
1699
|
+
frame = encodeMessage(message);
|
|
1700
|
+
}
|
|
1701
|
+
catch {
|
|
1702
|
+
return null;
|
|
1703
|
+
}
|
|
1704
|
+
return utf8Length(frame) <= maxEventBytes ? frame : null;
|
|
1705
|
+
}
|
|
1706
|
+
/** The `q` a refusal for this message should carry, if any. */
|
|
1707
|
+
function requestOf(message) {
|
|
1708
|
+
return 'q' in message && typeof message.q === 'string' ? message.q : null;
|
|
1709
|
+
}
|
|
1710
|
+
/** Re-exported so a host can hold assemblies without another import. */
|
|
1711
|
+
export { Assembly, AssemblyTable };
|