@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,740 @@
1
+ /**
2
+ * Slice D — the replica: snapshot install, delta apply, resync,
3
+ * generations.
4
+ *
5
+ * ## What this module is
6
+ *
7
+ * The other half of slice C. The owner emits `man` + `snap` chunks and
8
+ * later `delta`s; this decides what a caller is allowed to SEE as a
9
+ * result. It is the production form of the state machine
10
+ * `test/store/lifecycle-model.ts` models abstractly (§1.7a), with the
11
+ * real codec, the real assembler (slice B1) and the real document.
12
+ *
13
+ * ## The three monotone numbers (§1.7a), which are different things
14
+ *
15
+ * - `installed` — the generation whose snapshot is published.
16
+ * - `assembling` — the generation of the open assembly.
17
+ * - `retired` — the highest generation ever **admitted**, installed or
18
+ * not. This is the watermark that makes an abandoned generation
19
+ * inadmissible for ever: `g > installed` would let a delayed manifest
20
+ * reopen a generation that was admitted, abandoned, and never
21
+ * published. Admission tests `g > retired`.
22
+ *
23
+ * Duplicate active manifests fall out of the same rule rather than
24
+ * needing their own: admitting the first set `retired := g`, so the
25
+ * second fails `g > retired` and the open assembly is untouched.
26
+ *
27
+ * ## What an empty slot is not
28
+ *
29
+ * `fenced` exists because an empty correlation slot is not consent. A
30
+ * refused request fences the subscription's view; only `ready` accepts
31
+ * unsolicited owner refreshes, and a `fenced` replica records no skip,
32
+ * so an owner emission cannot lift a fence the caller placed.
33
+ *
34
+ * ## Scope
35
+ *
36
+ * Audience transitions and `resume` are slice F, actions and the ledger
37
+ * are slice E. Both are refused here rather than half-served: this
38
+ * module issues `join` and `resync` and nothing else.
39
+ *
40
+ * Its acceptance gate is reliable transfer (§4 D), exactly as B2′ — a
41
+ * replica that applies deltas in order is not evidence that the
42
+ * transport delivers them in order.
43
+ */
44
+ import { ASSEMBLY_DEADLINE_MS, AssemblyTable, } from './assembly.js';
45
+ import { StoreError } from './errors.js';
46
+ import { decimalValue, decodeMessage, encodeMessage } from './wire.js';
47
+ /**
48
+ * How many times an unanswered transition re-asks before the replica
49
+ * gives the caller a terminal answer.
50
+ *
51
+ * Three: enough that a join survives independent losses, few enough
52
+ * that a store nobody is serving stops asking. Each re-ask abandons
53
+ * the subscription the owner may have opened for the previous one;
54
+ * nothing can release it by name, because a replica whose MANIFEST
55
+ * was lost never learned a handle to release — the owner's own idle
56
+ * lease (`owner.ts`'s `HANDLE_LEASE_MS`) is what reclaims it, which
57
+ * is why the ladder is bounded rather than endless.
58
+ */
59
+ export const MAX_JOIN_REASKS = 3;
60
+ /**
61
+ * A replica of one owner's store.
62
+ *
63
+ * Every method returns the requests it wants sent; nothing here touches
64
+ * a transport, so the state machine is testable without one.
65
+ */
66
+ export class StoreReplica {
67
+ deps;
68
+ #state = 'joining';
69
+ /** The handle, once LEARNED from a manifest. Only `join` creates one. */
70
+ #handle = null;
71
+ #incarnation = null;
72
+ #installed = null;
73
+ /** The owner's revision for the installed view — not `core.revision`. */
74
+ #revision = null;
75
+ #assembling = null;
76
+ #retired = 0n;
77
+ #skipped = null;
78
+ #behind = false;
79
+ /** The desired-transition slot: one `q` per shared transition. */
80
+ #slot = null;
81
+ /**
82
+ * When the outstanding transition was ASKED for, and how many times
83
+ * the deadline has re-asked it.
84
+ *
85
+ * The assembly deadline covers an assembly that stalls half-built.
86
+ * It cannot cover a transition that never produced one: a `join`
87
+ * whose MANIFEST is lost opens nothing, so there is nothing to
88
+ * expire, and before this the caller's `ready()` never settled and
89
+ * nothing was ever sent again — the failure was pure silence. A
90
+ * real browser produced exactly that, and the lost datagram was
91
+ * 130-200 bytes: the manifest, far too small to be one of the
92
+ * multi-kilobyte chunks.
93
+ *
94
+ * Never cleared: `tick` reads it only for a state with no open
95
+ * assembly, and every path into `joining` or `installing` stamps it
96
+ * on entry, so a stale value cannot be read.
97
+ */
98
+ #askedAt = null;
99
+ #reasks = 0;
100
+ /**
101
+ * Bumped by every caller-initiated transition.
102
+ *
103
+ * The replica calls APPLICATION code in two places inside an
104
+ * installation — the definition's validator, from the assembler, and
105
+ * a subscriber, from the publication — and either may cancel or
106
+ * change audience re-entrantly. Comparing the epoch across those
107
+ * calls is what stops an installation that was cancelled halfway
108
+ * from finishing and reporting `ready`: without it, a cancellation
109
+ * from a validator was simply overwritten, and one from a
110
+ * subscriber left the caller `ready` and not stale over the empty
111
+ * view `cancel` had just published.
112
+ */
113
+ #epoch = 0;
114
+ #assembly = null;
115
+ /** The caller's latest desired audience. Survives every transition. */
116
+ #desired;
117
+ /** Local waiters on the slot's transition, independently cancellable. */
118
+ #waiters = 0;
119
+ #assemblies = new AssemblyTable();
120
+ #dropped = {};
121
+ constructor(deps) {
122
+ this.deps = deps;
123
+ this.#desired = [...deps.audience];
124
+ this.#interest = deps.interest === undefined ? null : [...deps.interest];
125
+ }
126
+ #interest;
127
+ /**
128
+ * The caller's desired interest set, stated by every later `join` and
129
+ * `resume` so a rejoin never falls back to an older one. `null`: none
130
+ * declared, every entity delivered.
131
+ */
132
+ get interest() {
133
+ return this.#interest === null ? null : [...this.#interest];
134
+ }
135
+ set interest(keys) {
136
+ this.#interest = keys === null ? null : [...keys];
137
+ }
138
+ /** The caller's latest desired audience. */
139
+ get desired() {
140
+ return [...this.#desired];
141
+ }
142
+ /** Local waiters on the transition in flight. */
143
+ get waiters() {
144
+ return this.#waiters;
145
+ }
146
+ get state() {
147
+ return this.#state;
148
+ }
149
+ /** The learned handle, or null while none has been learned. */
150
+ get handle() {
151
+ return this.#handle;
152
+ }
153
+ /** The installed generation, as a canonical decimal, or null. */
154
+ get installed() {
155
+ return this.#installed === null ? null : this.#installed.toString();
156
+ }
157
+ /** The owner's revision for the installed view, or null. */
158
+ get revision() {
159
+ return this.#revision === null ? null : this.#revision.toString();
160
+ }
161
+ /** The watermark: the highest generation ever admitted. */
162
+ get retired() {
163
+ return this.#retired.toString();
164
+ }
165
+ /** The highest refresh dropped mid-transition, or null. */
166
+ get skipped() {
167
+ return this.#skipped === null ? null : this.#skipped.toString();
168
+ }
169
+ /** Arrivals not taken, by reason. */
170
+ get dropped() {
171
+ return { ...this.#dropped };
172
+ }
173
+ /**
174
+ * The first request: a `join`.
175
+ *
176
+ * Only `join` creates a handle, so this is the only entry point from
177
+ * a state with none — including after `no {closed}`, which discards
178
+ * the handle and all of its generation state.
179
+ */
180
+ join() {
181
+ if (this.#state === 'closed') {
182
+ throw new StoreError('closed', 'a closed replica cannot join again');
183
+ }
184
+ this.#epoch += 1;
185
+ this.#waiters = 1;
186
+ // A join is a FRESH SUBSCRIPTION with a new handle, and §1.7a
187
+ // restarts that handle's generations at 1 — so every value scoped
188
+ // to the old handle goes, `retired` included. Keeping the old
189
+ // watermark wedges the rejoin permanently: the new handle's first
190
+ // manifest names generation 1, fails `g > retired` against the
191
+ // watermark of a handle that no longer exists, and nothing can
192
+ // ever install again. Recovery knowledge goes for the same reason
193
+ // — a skip recorded before a cancellation must not make the next
194
+ // installation ask for work the caller cancelled.
195
+ this.#reset();
196
+ const q = this.deps.newQ();
197
+ this.#slot = q;
198
+ this.#askedAt = this.deps.now();
199
+ this.#reasks = 0;
200
+ this.#state = 'joining';
201
+ this.deps.core.setStatus({ phase: 'connecting', stale: this.#published() });
202
+ return {
203
+ kind: 'join',
204
+ q,
205
+ frame: encodeMessage({
206
+ k: 'join',
207
+ q,
208
+ def: this.deps.definition.id,
209
+ store: this.deps.store,
210
+ ver: this.deps.definition.version,
211
+ key: this.deps.key,
212
+ aud: [...this.#desired],
213
+ ...(this.#interest === null ? {} : { int: [...this.#interest] }),
214
+ }),
215
+ };
216
+ }
217
+ /**
218
+ * Change what this caller wants to see (§1.7).
219
+ *
220
+ * Three moments, deliberately separate: local intent is IMMEDIATE —
221
+ * the view is cleared to `empty()` and the status is `syncing`,
222
+ * before the owner has agreed to anything, because continuing to
223
+ * render the old audience is the disclosure this exists to stop.
224
+ * Owner acceptance allocates the generation, and installation
225
+ * publishes it.
226
+ *
227
+ * Equal pending requests share one wire request and one `q`, with a
228
+ * local waiter each: `q` is the transition's identity on the wire,
229
+ * not a waiter's.
230
+ */
231
+ setAudience(names) {
232
+ if (this.#state === 'closed') {
233
+ throw new StoreError('closed', 'a closed replica cannot change audience');
234
+ }
235
+ this.#epoch += 1;
236
+ const next = [...names];
237
+ if (this.#state === 'installing' && sameAudience(next, this.#desired)) {
238
+ // The same transition, already in flight. One wire request, one
239
+ // slot, one more waiter.
240
+ this.#waiters += 1;
241
+ return [];
242
+ }
243
+ this.#desired = next;
244
+ const h = this.#handle;
245
+ // Immediately, whatever the owner later says.
246
+ this.#retireAssembly();
247
+ this.#installed = null;
248
+ this.#revision = null;
249
+ this.#skipped = null;
250
+ this.#behind = false;
251
+ this.deps.core.applySnapshot(this.deps.definition.empty());
252
+ this.deps.core.setStatus({ phase: 'syncing', stale: false, error: null });
253
+ if (h === null) {
254
+ // Nothing to change the audience OF: only `join` creates a
255
+ // handle, so the desired audience rides the join instead.
256
+ this.#waiters = 1;
257
+ return [this.join()];
258
+ }
259
+ // A newer transition rejects the previous waiters and takes the
260
+ // slot with a NEW `q`.
261
+ const q = this.deps.newQ();
262
+ this.#slot = q;
263
+ this.#askedAt = this.deps.now();
264
+ this.#waiters = 1;
265
+ this.#state = 'installing';
266
+ return [{ kind: 'aud', q, frame: encodeMessage({ k: 'aud', q, h, aud: next }) }];
267
+ }
268
+ /**
269
+ * The session was replaced (§1.6).
270
+ *
271
+ * A reconnect is not an audience change: the last snapshot is
272
+ * RETAINED and marked stale, because what the caller may see has not
273
+ * changed — only whether it is current. The request states the
274
+ * caller's latest desired audience, never the owner's older one.
275
+ */
276
+ reconnect() {
277
+ if (this.#state === 'closed') {
278
+ throw new StoreError('closed', 'a closed replica cannot reconnect');
279
+ }
280
+ this.#epoch += 1;
281
+ const h = this.#handle;
282
+ // The lost session's assembly cannot be completed on the new one.
283
+ this.#retireAssembly();
284
+ if (h === null) {
285
+ // No handle learned, so there is nothing to resume: a forged `h`
286
+ // is exactly what `resume` must never be allowed to mint, so the
287
+ // only honest request is a fresh `join`.
288
+ return [this.join()];
289
+ }
290
+ const q = this.deps.newQ();
291
+ this.#slot = q;
292
+ this.#state = 'installing';
293
+ this.deps.core.setStatus({ phase: 'reconnecting', stale: this.#published() });
294
+ return [
295
+ {
296
+ kind: 'resume',
297
+ q,
298
+ frame: encodeMessage({
299
+ k: 'resume',
300
+ q,
301
+ h,
302
+ aud: [...this.#desired],
303
+ ...(this.#interest === null ? {} : { int: [...this.#interest] }),
304
+ }),
305
+ },
306
+ ];
307
+ }
308
+ /**
309
+ * One local waiter gives up.
310
+ *
311
+ * Cancellation is per waiter; the wire request is not. Cancelling the
312
+ * last one fences the subscription — the view was invalidated at
313
+ * request time and is NOT restored, because the caller asked for a
314
+ * different one.
315
+ */
316
+ cancelWaiter() {
317
+ if (this.#waiters > 0)
318
+ this.#waiters -= 1;
319
+ if (this.#waiters > 0)
320
+ return;
321
+ if (this.#state !== 'installing')
322
+ return;
323
+ this.cancel();
324
+ }
325
+ /** One decoded arrival from the owner. */
326
+ receive(frame) {
327
+ if (this.#state === 'closed')
328
+ return this.#drop('closed');
329
+ const decoded = decodeMessage(frame, { maxBytes: this.deps.maxEventBytes, as: 'replica' });
330
+ if (!decoded.ok)
331
+ return this.#drop(decoded.reason);
332
+ const message = decoded.message;
333
+ // A message naming a handle other than the learned one is dropped
334
+ // and counted, whatever else it says. This is checked before the
335
+ // kind, so a `delta` for a foreign handle cannot reach the patch
336
+ // path at all.
337
+ if ('h' in message && message.h !== undefined && this.#handle !== null && message.h !== this.#handle) {
338
+ return this.#drop('foreign-handle');
339
+ }
340
+ switch (message.k) {
341
+ case 'man':
342
+ return this.#manifest(message);
343
+ case 'snap':
344
+ return this.#chunk(message);
345
+ case 'delta':
346
+ return this.#delta(message);
347
+ case 'no':
348
+ return this.#refused(message.code, message.q);
349
+ case 'ok':
350
+ case 'res':
351
+ // Slice E's correlated replies. Counted, never half-applied.
352
+ return this.#drop('unimplemented-kind');
353
+ }
354
+ }
355
+ /**
356
+ * The assembly deadline, and the lease the caller keeps by asking.
357
+ *
358
+ * Returns the requests the elapsed time produced — at most one
359
+ * `resync`, because recovery is coalesced.
360
+ */
361
+ tick() {
362
+ const now = this.deps.now();
363
+ if (this.#state === 'installing' && this.#assembly !== null) {
364
+ if (!this.#assembly.expired(now))
365
+ return [];
366
+ return this.#abandon('assembly-deadline');
367
+ }
368
+ // A transition that produced NO assembly, past its deadline: the
369
+ // manifest that would have opened one never arrived.
370
+ if (this.#askedAt === null || now - this.#askedAt < ASSEMBLY_DEADLINE_MS)
371
+ return [];
372
+ if (this.#state !== 'joining' && this.#state !== 'installing')
373
+ return [];
374
+ if (this.#reasks >= MAX_JOIN_REASKS) {
375
+ // Asked and re-asked, and nothing answered. The caller gets a
376
+ // terminal answer rather than more silence: `#clearView` is
377
+ // what carries the error to `getStatus()`, and the fence is
378
+ // what makes `ready()` settle.
379
+ this.#askedAt = null;
380
+ this.#state = 'fenced';
381
+ this.#slot = null;
382
+ this.#retireAssembly();
383
+ this.#clearView('failed', new StoreError('timeout',
384
+ // NAMES THE STORE. A mistyped address settles typed, but
385
+ // after the full ladder — and "the store never answered"
386
+ // sends the reader looking at the network when the only
387
+ // evidence of the real cause is a `join-other-store`
388
+ // counter on a host they may not be able to read.
389
+ `no store named ${this.deps.store} answered the join`));
390
+ return [];
391
+ }
392
+ // `join` resets the ladder, because a join is normally the CALLER
393
+ // asking afresh. This one is the deadline's, so the count is
394
+ // carried across it deliberately.
395
+ const spent = this.#reasks;
396
+ const again = this.join();
397
+ this.#reasks = spent + 1;
398
+ return [again];
399
+ }
400
+ /** The caller gives up this subscription. Fences it; no view. */
401
+ cancel() {
402
+ if (this.#state === 'closed')
403
+ return;
404
+ this.#epoch += 1;
405
+ this.#retireAssembly();
406
+ this.#slot = null;
407
+ this.#state = 'fenced';
408
+ // Recovery knowledge from a cancelled epoch must never make the
409
+ // next installation ask for work the caller cancelled. `join`
410
+ // clears it through `#reset`; `setAudience` clears it itself,
411
+ // because it keeps the handle and so does not reset; and `resume`
412
+ // deliberately does NOT, because a reconnect resumes the same
413
+ // subscription and a skip recorded before the session was lost is
414
+ // still news the owner has moved on.
415
+ this.#skipped = null;
416
+ this.#behind = false;
417
+ this.#clearView('failed');
418
+ }
419
+ #manifest(message) {
420
+ const g = decimalValue(message.g);
421
+ // Solicited or not is decided by the slot, and the answer changes
422
+ // which states may admit it.
423
+ const solicited = message.q !== undefined && message.q === this.#slot;
424
+ if (message.q !== undefined && !solicited)
425
+ return this.#drop('stale-correlation');
426
+ // The watermark goes FIRST, before any state-dependent branch. A
427
+ // generation at or below it is spent — a duplicate of the
428
+ // manifest being assembled, or one admitted and then abandoned —
429
+ // and it is neither a skipped refresh nor news of anything. Asking
430
+ // "am I mid-transition?" first reported a duplicate as a skip,
431
+ // which would have provoked a resynchronization for a generation
432
+ // the replica had already been told about.
433
+ if (g <= this.#retired)
434
+ return this.#drop('stale-generation');
435
+ if (!solicited) {
436
+ // An unsolicited refresh. `ready` accepts one; a replica
437
+ // mid-transition records the skip so the owner's newer state is
438
+ // recoverable rather than stranded; a fenced one records
439
+ // nothing, because the fence is the caller's to lift.
440
+ if (this.#state === 'fenced')
441
+ return this.#drop('fenced');
442
+ if (this.#state !== 'ready') {
443
+ this.#skipped = this.#skipped === null ? g : max(this.#skipped, g);
444
+ return this.#drop('mid-transition');
445
+ }
446
+ }
447
+ else if (this.#state !== 'joining' && this.#state !== 'installing' && this.#state !== 'fenced') {
448
+ return this.#drop('unexpected-manifest');
449
+ }
450
+ if (this.#handle === null) {
451
+ // The handle is LEARNED here, from the manifest that answered the
452
+ // join — the one moment a replica acquires one.
453
+ this.#handle = message.h;
454
+ this.#incarnation = message.inc;
455
+ }
456
+ else if (this.#incarnation !== null && message.inc !== this.#incarnation) {
457
+ // A different incarnation is a different document, not a
458
+ // refresh of this one.
459
+ return this.#drop('foreign-incarnation');
460
+ }
461
+ // The previous assembly goes first: opening the replacement and
462
+ // THEN retiring by handle reclaims the one just opened, and the
463
+ // next chunk arrives at a closed assembly.
464
+ this.#retireAssembly();
465
+ // Admitted, so the watermark moves whatever happens next. A
466
+ // generation this replica could not take must not be reopened by a
467
+ // delayed manifest either.
468
+ this.#retired = g;
469
+ const manifest = {
470
+ h: message.h,
471
+ g: message.g,
472
+ r: message.r,
473
+ n: message.n,
474
+ bytes: message.bytes,
475
+ };
476
+ const opened = this.#assemblies.open(manifest, this.deps.now());
477
+ if (!('accept' in opened)) {
478
+ // An admissible manifest this replica cannot hold — over the
479
+ // byte bound, or the table is full. The generation is consumed,
480
+ // so recovery is a new one, not a retry of this.
481
+ return { out: this.#abandon(opened.reason), dropped: opened.reason };
482
+ }
483
+ this.#assembling = g;
484
+ this.#assembly = opened;
485
+ this.#state = 'installing';
486
+ this.deps.core.setStatus({ phase: 'syncing', stale: this.#published() });
487
+ return { out: [], dropped: null };
488
+ }
489
+ #chunk(message) {
490
+ const assembly = this.#assembly;
491
+ // No open assembly, no chunk: every supersession path — a newer
492
+ // manifest, cancellation, the deadline, a refusal — retires the
493
+ // assembly, so a chunk for a superseded installation arrives here
494
+ // with nothing to join and cannot publish.
495
+ if (assembly === null)
496
+ return this.#drop('no-open-assembly');
497
+ // The chunk's generation is NOT re-checked here. `Assembly.accept`
498
+ // admits a chunk only when its whole `(h, g, r, n)` matches the
499
+ // manifest it was opened with, and that manifest's `g` is what
500
+ // `assembling` holds — so identity is the assembler's property
501
+ // (slice B1) and a second comparison here would be a claim that
502
+ // cannot fail rather than a check that can.
503
+ const epoch = this.#epoch;
504
+ const outcome = assembly.accept({ h: message.h, g: message.g, r: message.r, n: message.n, i: message.i, d: message.d }, raw => this.deps.definition.state(raw), this.deps.now());
505
+ // A cancellation from the VALIDATOR, noticed before anything is
506
+ // published. `#install`'s own epoch check runs only AFTER
507
+ // `applySnapshot` has notified subscribers, so leaving this to it
508
+ // handed every subscriber the cancelled world and then an empty
509
+ // one. That went unobserved for as long as the owner's projection
510
+ // happened to call the shared validator first and spent the
511
+ // cancellation there; once an identity projection stopped
512
+ // re-validating, "stays fenced when the definition cancels while
513
+ // validating" saw `[5, 0]`.
514
+ if (this.#epoch !== epoch)
515
+ return this.#drop('superseded-installation');
516
+ if (!outcome.ok) {
517
+ // A fatal refusal has destroyed the assembly; a non-fatal one
518
+ // (a duplicate) leaves it open and is only counted.
519
+ if (!outcome.fatal)
520
+ return this.#drop(outcome.reason);
521
+ return { out: this.#abandon(outcome.reason), dropped: outcome.reason };
522
+ }
523
+ if (!outcome.done)
524
+ return { out: [], dropped: null };
525
+ return this.#install(outcome.document, message.g, message.r, epoch);
526
+ }
527
+ #install(document, g, r, epoch) {
528
+ // Reached only from a completed assembly, which the assembler
529
+ // opened for this generation and which every supersession path
530
+ // retires — so `g` is `assembling` by construction and there is
531
+ // nothing left here to fence against. The fence is the retirement,
532
+ // witnessed on each path that supersedes ("publishes nothing after
533
+ // cancellation mid-assembly"), not a comparison at the end.
534
+ this.deps.core.applySnapshot(document);
535
+ this.#assembling = null;
536
+ this.#assembly = null;
537
+ this.#assemblies.reclaim(this.#handle, g);
538
+ if (this.#epoch !== epoch) {
539
+ // A SUBSCRIBER cancelled while this document was being
540
+ // published. Its `cancel` already cleared the view; saying
541
+ // `ready` over that is the worst of both — an empty world
542
+ // reported as current.
543
+ this.#installed = null;
544
+ this.#revision = null;
545
+ this.#clearView('failed');
546
+ return this.#drop('superseded-installation');
547
+ }
548
+ this.#installed = decimalValue(g);
549
+ this.#revision = decimalValue(r);
550
+ this.#slot = null;
551
+ this.#state = 'ready';
552
+ this.deps.core.setStatus({ phase: 'ready', stale: false, error: null });
553
+ // Then §1.8's skipped-refresh recovery: a refresh dropped while
554
+ // mid-transition would otherwise make every later delta
555
+ // inadmissible, and owner and replica would never converge. One
556
+ // coalesced request, however many were dropped.
557
+ return { out: this.#recoverIfBehind(), dropped: null };
558
+ }
559
+ #delta(message) {
560
+ if (this.#state !== 'ready') {
561
+ // Mid-transition the owner has moved past the revision being
562
+ // assembled. One flag, coalesced into the same recovery.
563
+ this.#behind = true;
564
+ return this.#drop('not-ready');
565
+ }
566
+ const g = decimalValue(message.g);
567
+ const installed = this.#installed;
568
+ if (installed === null || g !== installed) {
569
+ // A delta for a generation this replica never installed. BELOW the
570
+ // installed one is a genuinely stale frame and is dropped. ABOVE it
571
+ // means the owner-initiated manifest that would have installed that
572
+ // generation never arrived — the lost-datagram case §1.8 names, and
573
+ // the owner's own overflow fallback emits exactly such a manifest —
574
+ // and every later delta carries the same new `g`. Dropping without
575
+ // asking strands this replica at the old generation for good while
576
+ // `getStatus()` keeps reporting `ready`, because `tick`'s ladder runs
577
+ // only in `joining`/`installing` and this check fires before the
578
+ // `base` gap check below that would otherwise have asked.
579
+ if (installed !== null && g > installed) {
580
+ return { out: this.#resync('generation-ahead'), dropped: 'generation-ahead' };
581
+ }
582
+ return this.#drop('stale-generation');
583
+ }
584
+ if (this.#revision === null || decimalValue(message.base) !== this.#revision) {
585
+ // A gap. The delta is not applied to the wrong base — that would
586
+ // publish a document neither side believes in.
587
+ return { out: this.#resync('gap'), dropped: 'gap' };
588
+ }
589
+ const outcome = this.deps.core.applyDelta(message.ops);
590
+ if (!outcome.ok) {
591
+ // A patch that does not apply or does not validate is a
592
+ // divergence, so the view is refetched rather than guessed at.
593
+ return { out: this.#resync(outcome.reason), dropped: outcome.reason };
594
+ }
595
+ this.#revision = decimalValue(message.r);
596
+ return { out: [], dropped: null };
597
+ }
598
+ #refused(code, q) {
599
+ if (code === 'owner-lost') {
600
+ // Terminal: the incarnation that held the document is gone, so
601
+ // there is nothing to rejoin.
602
+ this.#retireAssembly();
603
+ this.#state = 'closed';
604
+ this.#handle = null;
605
+ this.#clearView('closed');
606
+ return { out: [], dropped: 'owner-lost' };
607
+ }
608
+ if (code === 'closed') {
609
+ // The handle is dead, and this is handle news however it was
610
+ // provoked. `errors.ts` defines `closed` as terminal for the handle
611
+ // AND for any action in flight on it, and `receive`'s foreign-handle
612
+ // gate has already discarded any `closed` naming a different handle —
613
+ // so one reaching here is always about THIS handle. The
614
+ // `q`-of-a-dead-question guard below protects refusals that answer a
615
+ // request; it cannot apply here, because every owner-side handle-death
616
+ // answer carries the provoking request's `q` (`owner.ts` answers an
617
+ // `alive` or `act` on a swept handle with `no {q: <that request>, …,
618
+ // closed}`) and a 20 s keepalive's `q` is never the live slot.
619
+ // Dropping that as `stale-correlation` holds a dead handle forever:
620
+ // every later `alive` is answered the same way and counted the same
621
+ // way, the view stays published as `ready`, and no rejoin ever runs.
622
+ const wasFenced = this.#state === 'fenced';
623
+ this.#reset();
624
+ if (wasFenced) {
625
+ // An expiry notice is no more the caller's consent than an
626
+ // owner refresh was: the fence stays.
627
+ this.#clearView('failed');
628
+ return { out: [], dropped: 'closed-while-fenced' };
629
+ }
630
+ this.#clearView('connecting');
631
+ return { out: [this.join()], dropped: 'closed' };
632
+ }
633
+ if (q === undefined || q !== this.#slot)
634
+ return this.#drop('stale-correlation');
635
+ // The REQUEST was refused, not the subscription: the handle
636
+ // survives, and the caller's next request lifts the fence.
637
+ this.#retireAssembly();
638
+ this.#slot = null;
639
+ this.#state = 'fenced';
640
+ this.#clearView('failed', new StoreError(code, `the request was refused: ${code}`));
641
+ return { out: [], dropped: `refused-${code}` };
642
+ }
643
+ /** Abandon the open assembly and ask for the view again. */
644
+ #abandon(reason) {
645
+ this.#retireAssembly();
646
+ // `retired` KEEPS the generation: the abandoned one must not be
647
+ // reopened by a delayed manifest.
648
+ this.#assembling = null;
649
+ this.#state = 'installing';
650
+ this.deps.core.setStatus({ phase: 'syncing', stale: this.#published() });
651
+ return this.#resync(reason);
652
+ }
653
+ #resync(_reason) {
654
+ const h = this.#handle;
655
+ if (h === null)
656
+ return [];
657
+ const q = this.deps.newQ();
658
+ this.#slot = q;
659
+ this.#askedAt = this.deps.now();
660
+ this.#state = 'installing';
661
+ this.#skipped = null;
662
+ this.#behind = false;
663
+ // The published view is known to be behind — that is why this is
664
+ // being sent. Retained, because the audience has not changed, but
665
+ // NOT current, and the application is told so: reporting `ready`
666
+ // here means every consumer believes a stale world is live.
667
+ this.deps.core.setStatus({
668
+ phase: this.#published() ? 'reconnecting' : 'syncing',
669
+ stale: this.#published(),
670
+ });
671
+ // `(g, have)` are ADVISORY historical position — what this replica
672
+ // actually has, never a claim about the owner's current
673
+ // generation. A replica installed at A whose replacement B timed
674
+ // out can only honestly name A, and that is exactly the replica
675
+ // that most needs recovering (§1.8).
676
+ return [
677
+ {
678
+ kind: 'resync',
679
+ q,
680
+ frame: encodeMessage({
681
+ k: 'resync',
682
+ q,
683
+ h,
684
+ g: this.#installed === null ? '0' : this.#installed.toString(),
685
+ have: this.#revision === null ? '0' : this.#revision.toString(),
686
+ }),
687
+ },
688
+ ];
689
+ }
690
+ #recoverIfBehind() {
691
+ const skipped = this.#skipped;
692
+ const behind = this.#behind;
693
+ if (!behind && (skipped === null || this.#installed === null || skipped <= this.#installed)) {
694
+ this.#skipped = null;
695
+ this.#behind = false;
696
+ return [];
697
+ }
698
+ return this.#resync(behind ? 'behind' : 'skipped-refresh');
699
+ }
700
+ /** Discard everything scoped to one handle's subscription. */
701
+ #reset() {
702
+ this.#retireAssembly();
703
+ this.#handle = null;
704
+ this.#incarnation = null;
705
+ this.#installed = null;
706
+ this.#revision = null;
707
+ this.#retired = 0n;
708
+ this.#skipped = null;
709
+ this.#behind = false;
710
+ this.#slot = null;
711
+ }
712
+ #retireAssembly() {
713
+ if (this.#assembly !== null) {
714
+ this.#assembly.reclaim();
715
+ this.#assembly = null;
716
+ }
717
+ if (this.#handle !== null)
718
+ this.#assemblies.reclaimHandle(this.#handle);
719
+ this.#assembling = null;
720
+ }
721
+ #published() {
722
+ return this.#installed !== null;
723
+ }
724
+ #clearView(phase, error = null) {
725
+ this.#installed = null;
726
+ this.#revision = null;
727
+ this.deps.core.applySnapshot(this.deps.definition.empty());
728
+ this.deps.core.setStatus({ phase, stale: false, error });
729
+ }
730
+ #drop(reason) {
731
+ this.#dropped[reason] = (this.#dropped[reason] ?? 0) + 1;
732
+ return { out: [], dropped: reason };
733
+ }
734
+ }
735
+ function max(a, b) {
736
+ return a > b ? a : b;
737
+ }
738
+ function sameAudience(a, b) {
739
+ return a.length === b.length && a.every((label, index) => label === b[index]);
740
+ }