@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +517 -0
  2. package/LICENSE-APACHE +190 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +666 -0
  5. package/dist/async-queue.d.ts +26 -0
  6. package/dist/async-queue.js +62 -0
  7. package/dist/credential.d.ts +70 -0
  8. package/dist/credential.js +95 -0
  9. package/dist/errors.d.ts +400 -0
  10. package/dist/errors.js +726 -0
  11. package/dist/events.d.ts +232 -0
  12. package/dist/events.js +298 -0
  13. package/dist/identity.d.ts +40 -0
  14. package/dist/identity.js +78 -0
  15. package/dist/index.bundle.js +2 -0
  16. package/dist/index.d.ts +38 -0
  17. package/dist/index.js +46 -0
  18. package/dist/leader/events.d.ts +111 -0
  19. package/dist/leader/events.js +116 -0
  20. package/dist/leader/index.d.ts +14 -0
  21. package/dist/leader/index.js +11 -0
  22. package/dist/leader/session.d.ts +539 -0
  23. package/dist/leader/session.js +770 -0
  24. package/dist/leader/wasm.d.ts +123 -0
  25. package/dist/leader/wasm.js +39 -0
  26. package/dist/lobby.d.ts +183 -0
  27. package/dist/lobby.js +504 -0
  28. package/dist/local.d.ts +80 -0
  29. package/dist/local.js +160 -0
  30. package/dist/net_leaf.d.ts +1494 -0
  31. package/dist/net_leaf.js +3753 -0
  32. package/dist/net_leaf_bg.wasm +0 -0
  33. package/dist/net_leaf_bg.wasm.d.ts +137 -0
  34. package/dist/netcode/client.d.ts +102 -0
  35. package/dist/netcode/client.js +296 -0
  36. package/dist/netcode/clock.d.ts +45 -0
  37. package/dist/netcode/clock.js +54 -0
  38. package/dist/netcode/host.d.ts +118 -0
  39. package/dist/netcode/host.js +276 -0
  40. package/dist/netcode/index.d.ts +19 -0
  41. package/dist/netcode/index.js +14 -0
  42. package/dist/netcode/interpolate.d.ts +50 -0
  43. package/dist/netcode/interpolate.js +100 -0
  44. package/dist/netcode/wire.d.ts +125 -0
  45. package/dist/netcode/wire.js +117 -0
  46. package/dist/node.d.ts +908 -0
  47. package/dist/node.js +1085 -0
  48. package/dist/org.d.ts +412 -0
  49. package/dist/org.js +567 -0
  50. package/dist/peer-driver.d.ts +117 -0
  51. package/dist/peer-driver.js +224 -0
  52. package/dist/store/assembly.d.ts +176 -0
  53. package/dist/store/assembly.js +325 -0
  54. package/dist/store/chunker.d.ts +83 -0
  55. package/dist/store/chunker.js +190 -0
  56. package/dist/store/core.d.ts +136 -0
  57. package/dist/store/core.js +477 -0
  58. package/dist/store/definition.d.ts +23 -0
  59. package/dist/store/definition.js +83 -0
  60. package/dist/store/errors.d.ts +77 -0
  61. package/dist/store/errors.js +94 -0
  62. package/dist/store/host.d.ts +247 -0
  63. package/dist/store/host.js +675 -0
  64. package/dist/store/index.d.ts +50 -0
  65. package/dist/store/index.js +40 -0
  66. package/dist/store/interest.d.ts +40 -0
  67. package/dist/store/interest.js +77 -0
  68. package/dist/store/inventory.d.ts +81 -0
  69. package/dist/store/inventory.js +140 -0
  70. package/dist/store/join.d.ts +118 -0
  71. package/dist/store/join.js +724 -0
  72. package/dist/store/json.d.ts +50 -0
  73. package/dist/store/json.js +283 -0
  74. package/dist/store/ledger.d.ts +187 -0
  75. package/dist/store/ledger.js +292 -0
  76. package/dist/store/owner.d.ts +616 -0
  77. package/dist/store/owner.js +1711 -0
  78. package/dist/store/patch.d.ts +70 -0
  79. package/dist/store/patch.js +166 -0
  80. package/dist/store/player.d.ts +65 -0
  81. package/dist/store/player.js +261 -0
  82. package/dist/store/replica.d.ts +187 -0
  83. package/dist/store/replica.js +740 -0
  84. package/dist/store/state.d.ts +42 -0
  85. package/dist/store/state.js +165 -0
  86. package/dist/store/types.d.ts +200 -0
  87. package/dist/store/types.js +25 -0
  88. package/dist/store/visibility.d.ts +118 -0
  89. package/dist/store/visibility.js +282 -0
  90. package/dist/store/wire.d.ts +309 -0
  91. package/dist/store/wire.js +834 -0
  92. package/dist/stream.d.ts +174 -0
  93. package/dist/stream.js +238 -0
  94. package/dist/three/index.d.ts +109 -0
  95. package/dist/three/index.js +130 -0
  96. package/dist/udp-probe.d.ts +159 -0
  97. package/dist/udp-probe.js +245 -0
  98. package/dist/wasm.d.ts +642 -0
  99. package/dist/wasm.js +98 -0
  100. package/dist/world/border.d.ts +84 -0
  101. package/dist/world/border.js +99 -0
  102. package/dist/world/handoff.d.ts +187 -0
  103. package/dist/world/handoff.js +222 -0
  104. package/dist/world/index.d.ts +13 -0
  105. package/dist/world/index.js +9 -0
  106. package/dist/world/link.d.ts +179 -0
  107. package/dist/world/link.js +476 -0
  108. package/dist/world/regions.d.ts +154 -0
  109. package/dist/world/regions.js +314 -0
  110. 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 };