@spfn/core 0.3.0-beta.26 → 0.3.0-beta.28

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.
@@ -51,9 +51,10 @@ interface StateFrame<V = unknown, D = unknown> {
51
51
  data?: D;
52
52
  }
53
53
  /**
54
- * What the client holds for one `(id, key)`: the latest frame and whether the
55
- * server still serves it. A state the server stopped serving before any value
56
- * arrived is `{ status: 'revoked' }` alone.
54
+ * What the client holds for one `(id, key)`: the frame that last changed
55
+ * `status`, `value` or `data` — its `at` is that read's, not the newest `at`
56
+ * the client has seen — and whether the server still serves it. A state the
57
+ * server stopped serving before any value arrived is `{ status: 'revoked' }` alone.
57
58
  */
58
59
  type StateEntry<V = unknown, D = unknown> = Readonly<StateFrame<V, D> & {
59
60
  status: 'ready' | 'revoked';
@@ -65,7 +66,36 @@ type StateEntry<V = unknown, D = unknown> = Readonly<StateFrame<V, D> & {
65
66
  }>;
66
67
  /** Called after the entry of a subscribed `(id, key)` changed. */
67
68
  type StateListener = () => void;
68
- interface StateClientConfig {
69
+ /**
70
+ * Whether the client's stream is live. `'reconnecting'` until a stream opens and while the SSE
71
+ * client retries a dropped one — `attempt` is its count, `0` before its first retry;
72
+ * `'failed'` when the retries are exhausted (the SSE client's `maxReconnectAttempts`, the
73
+ * client's `maxRetries` for the first snapshot), until `retry()`, a new `subscribe` or `online`.
74
+ */
75
+ type ConnectionStatus = {
76
+ status: 'connected';
77
+ } | {
78
+ status: 'reconnecting';
79
+ attempt: number;
80
+ } | {
81
+ status: 'failed';
82
+ };
83
+ /** Called with the new status after the client's connection status changed. */
84
+ type ConnectionListener = (status: ConnectionStatus) => void;
85
+ /**
86
+ * The snapshot a server render read, for a client to start from (`StateClientConfig.initial`).
87
+ * Pass what `api.getStates` answered: a snapshot whose manifest names a state the server does not
88
+ * serve keeps the stream from opening, as the SSE handler refuses that event. The router type
89
+ * parameter (`StateClientInitial<typeof states>`, `createStateClient<typeof states>`) is what types
90
+ * `snapshot`; without it the client is untyped, as before.
91
+ */
92
+ interface StateClientInitial<TStates extends AnyStateRouter = any> {
93
+ /** What `api.getStates` (or the snapshot route) answered for the render. */
94
+ snapshot: StatesSnapshot<TStates['states']>;
95
+ /** The query that snapshot was read with; keyed entries are stored under its key. @default {} */
96
+ query?: Record<string, string>;
97
+ }
98
+ interface StateClientConfig<TStates extends AnyStateRouter = any> {
69
99
  /**
70
100
  * The SSE client the stream is opened on — `createAuthSSEClient()` for an
71
101
  * authenticated app. Give the state client its own: an `SSEClient` holds
@@ -88,6 +118,21 @@ interface StateClientConfig {
88
118
  * @default 3000
89
119
  */
90
120
  retryDelay?: number;
121
+ /**
122
+ * How many times a failed first snapshot is read again before the client
123
+ * gives up: the status turns `'failed'` and no retry is set, until
124
+ * `retry()`, a new `subscribe` or `online`. The first read is not a retry.
125
+ * `0` retries until the snapshot succeeds.
126
+ * @default 0
127
+ */
128
+ maxRetries?: number;
129
+ /**
130
+ * The snapshot the server rendered with. The client installs its manifest
131
+ * and applies its states at construction, so `current()` answers at once
132
+ * and the first `subscribe` opens the stream without reading the snapshot
133
+ * first. The stream's open still re-reads it.
134
+ */
135
+ initial?: StateClientInitial<TStates>;
91
136
  }
92
137
  /**
93
138
  * The browser side of a state router: one store per `(id, key)`, one SSE
@@ -99,8 +144,8 @@ interface StateClient<TStates extends AnyStateRouter> {
99
144
  /** Read every state the query names a key for, and the manifest; each entry is applied and the snapshot returned. */
100
145
  snapshot(query?: Record<string, string>): Promise<StatesSnapshot<TStates['states']>>;
101
146
  /**
102
- * Watch one `(id, key)`. The first subscriber reads the snapshot, then opens
103
- * the stream. A key whose fields are not the state's goes to `onError` once
147
+ * Watch one `(id, key)`. The first subscriber reads the snapshot — unless
148
+ * the client was built with `initial` — then opens the stream. A key whose fields are not the state's goes to `onError` once
104
149
  * the manifest is known. Returns the unsubscribe.
105
150
  */
106
151
  subscribe<Id extends StateId<TStates>>(id: Id, ...args: [...key: StateKeyArgs<TStates, Id>, listener: StateListener]): () => void;
@@ -108,7 +153,22 @@ interface StateClient<TStates extends AnyStateRouter> {
108
153
  write<Id extends WritableStateId<TStates>>(id: Id, input: StateCatalog<TStates>[Id]['input'], ...key: StateKeyArgs<TStates, Id>): Promise<StateFrameOf<TStates, Id>>;
109
154
  /** The entry held for `(id, key)`, the same object until it changes. */
110
155
  current<Id extends StateId<TStates>>(id: Id, ...key: StateKeyArgs<TStates, Id>): StateEntry<StateCatalog<TStates>[Id]['value'], StateCatalog<TStates>[Id]['data']> | undefined;
111
- /** Close the stream and drop every listener. */
156
+ /** The connection status, a frozen object: the same reference until it changes. */
157
+ connection(): ConnectionStatus;
158
+ /**
159
+ * Call `listener` with the new status on every change of `connection()`.
160
+ * Returns the unsubscribe (idempotent).
161
+ */
162
+ subscribeConnection(listener: ConnectionListener): () => void;
163
+ /**
164
+ * Restart now when `'failed'` or `'reconnecting'`: the first snapshot is
165
+ * read again, its retry count reset, or the stream reopened. A no-op when
166
+ * `'connected'`, while the first snapshot is in flight, or with nothing
167
+ * subscribed — so after `close()`, until a new `subscribe`, which `retry()`
168
+ * then restarts as before. `online` is not listened to again after `close()`.
169
+ */
170
+ retry(): void;
171
+ /** Close the stream, drop every listener and stop listening to `online`. */
112
172
  close(): void;
113
173
  }
114
174
 
@@ -135,10 +195,12 @@ interface StateClient<TStates extends AnyStateRouter> {
135
195
 
136
196
  /**
137
197
  * Build the client for `typeof states`. The first subscription (or call)
138
- * reads the snapshot, whose manifest names every state; the stream then
198
+ * reads the snapshot, whose manifest names every state — or takes it from
199
+ * `initial`, the snapshot the server rendered with; the stream then
139
200
  * requests every state event — the server's `grant` can only activate an
140
- * event the connection requested.
201
+ * event the connection requested. The router type parameter is what types
202
+ * `initial.snapshot`; without it the client is untyped, as before.
141
203
  */
142
- declare function createStateClient<TStates extends AnyStateRouter>(config: StateClientConfig): StateClient<TStates>;
204
+ declare function createStateClient<TStates extends AnyStateRouter>(config: StateClientConfig<TStates>): StateClient<TStates>;
143
205
 
144
- export { type AnyStateRouter, type InferStateData, type StateCatalog, type StateClient, type StateClientConfig, type StateEntry, type StateFrame, type StateFrameOf, type StateId, type StateKeyArgs, type StateListener, type WritableStateId, createStateClient };
206
+ export { type AnyStateRouter, type ConnectionListener, type ConnectionStatus, type InferStateData, type StateCatalog, type StateClient, type StateClientConfig, type StateClientInitial, type StateEntry, type StateFrame, type StateFrameOf, type StateId, type StateKeyArgs, type StateListener, type WritableStateId, createStateClient };
@@ -20,14 +20,11 @@ function sameJson(a, b) {
20
20
  const bRecord = b;
21
21
  return aKeys.length === Object.keys(b).length && aKeys.every((field) => field in bRecord && sameJson(a[field], bRecord[field]));
22
22
  }
23
- function supersedes(current, arrival) {
24
- if (current?.at === void 0 || arrival.at > current.at) {
25
- return true;
26
- }
27
- if (arrival.at < current.at) {
28
- return false;
29
- }
30
- return !sameJson(current.value, arrival.value) || !sameJson(current.data, arrival.data);
23
+ function isOlder(stamp, arrival) {
24
+ return stamp !== void 0 && arrival.at < stamp;
25
+ }
26
+ function changes(current, arrival) {
27
+ return current?.at === void 0 || !sameJson(current.value, arrival.value) || !sameJson(current.data, arrival.data);
31
28
  }
32
29
  function entryOf(frame, status) {
33
30
  const { at, value, data } = frame;
@@ -35,6 +32,12 @@ function entryOf(frame, status) {
35
32
  }
36
33
  var StateStore = class {
37
34
  entries = /* @__PURE__ */ new Map();
35
+ /**
36
+ * The newest `at` seen per slot, kept apart from the entry: an arrival
37
+ * that changes nothing still orders the ones after it, while the entry's
38
+ * `at` stays the `at` of the read that last changed the value.
39
+ */
40
+ stamps = /* @__PURE__ */ new Map();
38
41
  listeners = /* @__PURE__ */ new Map();
39
42
  /**
40
43
  * The slots the server stopped serving, with the generation of the
@@ -52,12 +55,16 @@ var StateStore = class {
52
55
  }
53
56
  /**
54
57
  * Apply an arrival: the value under the ordering rule, the status from the
55
- * revoked mark. Listeners hear only an applied change.
58
+ * revoked mark. Listeners hear only a changed status, value or data.
56
59
  */
57
60
  apply(slot, frame) {
58
61
  const current = this.entries.get(slot);
59
62
  const status = this.revoked.has(slot) ? "revoked" : "ready";
60
- if (supersedes(current, frame)) {
63
+ const ordered = !isOlder(this.stamps.get(slot), frame);
64
+ if (ordered) {
65
+ this.stamps.set(slot, frame.at);
66
+ }
67
+ if (ordered && changes(current, frame)) {
61
68
  this.replace(slot, entryOf(frame, status));
62
69
  } else if (current.status !== status) {
63
70
  this.replace(slot, entryOf(current, status));
@@ -113,9 +120,21 @@ function notify(slot, listener) {
113
120
  }
114
121
 
115
122
  // src/state/client.ts
123
+ var CONNECTED = Object.freeze({ status: "connected" });
124
+ var FAILED = Object.freeze({ status: "failed" });
125
+ var STARTING = Object.freeze({ status: "reconnecting", attempt: 0 });
126
+ function reconnecting(attempt) {
127
+ return attempt === 0 ? STARTING : Object.freeze({ status: "reconnecting", attempt });
128
+ }
129
+ function sameStatus(a, b) {
130
+ return a.status === b.status && a.attempt === b.attempt;
131
+ }
116
132
  function routeName(method, id) {
117
133
  return `${method}${id[0].toUpperCase()}${id.slice(1)}State`;
118
134
  }
135
+ function stateEventName(id) {
136
+ return `state.${id}.changed`;
137
+ }
119
138
  function carries(query, key) {
120
139
  return Object.entries(key).every(([field, value]) => query[field] === value);
121
140
  }
@@ -130,6 +149,12 @@ var BrowserStateClient = class {
130
149
  this.config = config;
131
150
  this.api = createApi(config.api);
132
151
  this.reportError = config.onError ?? ((error) => console.error("[state] A refresh failed:", error));
152
+ if (config.initial) {
153
+ this.hydrate(config.initial);
154
+ }
155
+ if (typeof globalThis.addEventListener === "function") {
156
+ globalThis.addEventListener("online", this.onOnline);
157
+ }
133
158
  }
134
159
  store = new StateStore();
135
160
  api;
@@ -139,6 +164,11 @@ var BrowserStateClient = class {
139
164
  manifestRead;
140
165
  stream;
141
166
  syncQueued = false;
167
+ status = STARTING;
168
+ connectionListeners = /* @__PURE__ */ new Set();
169
+ onOnline = () => this.retry();
170
+ /** The retries of the first snapshot since its last start, against `maxRetries`. */
171
+ retries = 0;
142
172
  /** The lifetime whose subscriptions wait on the first snapshot — its read in flight, or its retry timer set. */
143
173
  waitingLifetime;
144
174
  retryTimer;
@@ -191,13 +221,70 @@ var BrowserStateClient = class {
191
221
  current(id, key) {
192
222
  return this.store.current(this.slot(id, key));
193
223
  }
224
+ connection() {
225
+ return this.status;
226
+ }
227
+ subscribeConnection(listener) {
228
+ const notify2 = (status) => listener(status);
229
+ this.connectionListeners.add(notify2);
230
+ return () => {
231
+ this.connectionListeners.delete(notify2);
232
+ };
233
+ }
234
+ /**
235
+ * Restart what gave up or is still retrying, now: the first snapshot
236
+ * while the manifest is unknown, else the stream — a new `sse.subscribe`,
237
+ * whose attempt count starts at 0. Nothing to do when connected or with
238
+ * nothing watched — so after `close()`, until a new `subscribe`.
239
+ */
240
+ retry() {
241
+ if (this.status === CONNECTED || this.watched.size === 0) {
242
+ return;
243
+ }
244
+ if (this.manifest) {
245
+ this.openStream(this.streamQuery());
246
+ } else {
247
+ this.rereadManifest();
248
+ }
249
+ }
194
250
  close() {
195
251
  this.lifetime++;
196
252
  clearTimeout(this.retryTimer);
197
253
  this.retryTimer = void 0;
254
+ this.retries = 0;
198
255
  this.closeStream();
199
256
  this.watched.clear();
200
257
  this.store.clearListeners();
258
+ this.connectionListeners.clear();
259
+ this.status = STARTING;
260
+ if (typeof globalThis.removeEventListener === "function") {
261
+ globalThis.removeEventListener("online", this.onOnline);
262
+ }
263
+ }
264
+ /** The status is `next` from now; each connection listener hears it, a throwing one logged. */
265
+ setConnection(next) {
266
+ if (sameStatus(next, this.status)) {
267
+ return;
268
+ }
269
+ this.status = next;
270
+ for (const listener of [...this.connectionListeners]) {
271
+ try {
272
+ listener(next);
273
+ } catch (error) {
274
+ console.warn("[state] A connection listener threw:", error);
275
+ }
276
+ }
277
+ }
278
+ /**
279
+ * Start from the snapshot the server rendered with: its manifest, then
280
+ * each state present, as a read snapshot applies them, under generation 0
281
+ * — no connection yet. Nothing is marked `'revoked'`: nothing is watched,
282
+ * and an omission in the render's snapshot is not a denial by the stream.
283
+ */
284
+ hydrate(initial) {
285
+ const snapshot = initial.snapshot;
286
+ this.installManifest(snapshot.manifest);
287
+ this.applyPresent(snapshot, initial.query ?? {}, this.tag());
201
288
  }
202
289
  slot(id, key) {
203
290
  return StateStore.slot(id, stableKey(key));
@@ -282,16 +369,23 @@ var BrowserStateClient = class {
282
369
  * generation alone — a frame or a write answer of the new connection may
283
370
  * land first with a newer `at` — and the ordering rule decides only the value.
284
371
  */
285
- serve(slot, frame, tag) {
286
- if ((this.store.revokedBy(slot) ?? Infinity) < tag.generation) {
372
+ serve(slot, frame, generation) {
373
+ if ((this.store.revokedBy(slot) ?? Infinity) < generation) {
287
374
  this.store.allow(slot);
288
375
  }
289
376
  this.store.apply(slot, frame);
290
377
  }
291
- /** A state present is served, under the generation rule of `serve`. */
378
+ /**
379
+ * A state present is served, under the generation rule of `serve`. A state
380
+ * the manifest does not describe, or a keyed one whose key fields the query
381
+ * lacks, is skipped: the server never serves either, but a hand-built
382
+ * `initial` might.
383
+ */
292
384
  applyPresent(snapshot, query, tag) {
293
385
  for (const [id, result] of Object.entries(snapshot.states)) {
294
- this.serve(this.slot(id, this.keyIn(id, query)), { at: snapshot.at, ...result }, tag);
386
+ if (this.manifest[id]?.key.every((field) => field in query)) {
387
+ this.serve(this.slot(id, this.keyIn(id, query)), { at: snapshot.at, ...result }, tag.generation);
388
+ }
295
389
  }
296
390
  }
297
391
  /**
@@ -318,7 +412,9 @@ var BrowserStateClient = class {
318
412
  } else {
319
413
  this.watched.delete(slot);
320
414
  }
321
- this.queueSync();
415
+ if (delta === 1 || this.watched.size === 0 || this.status !== FAILED) {
416
+ this.queueSync();
417
+ }
322
418
  }
323
419
  /**
324
420
  * One stream for the client, its query the union of the subscribed keys.
@@ -337,10 +433,15 @@ var BrowserStateClient = class {
337
433
  /**
338
434
  * Until the manifest is known, subscriptions wait for the first snapshot;
339
435
  * the stream opens after it, never before — its `events=` comes from it.
436
+ * A client built with `initial` knows it from the start. With nothing
437
+ * watched the stream closes, the retry count starts over for the next
438
+ * wait, and the status is the start value: no stream, nothing answered.
340
439
  */
341
440
  sync() {
342
441
  if (this.watched.size === 0) {
442
+ this.retries = 0;
343
443
  this.closeStream();
444
+ this.setConnection(STARTING);
344
445
  return;
345
446
  }
346
447
  if (!this.manifest) {
@@ -348,7 +449,7 @@ var BrowserStateClient = class {
348
449
  return;
349
450
  }
350
451
  const query = this.streamQuery();
351
- if (this.stream?.text !== stableKey(query)) {
452
+ if (this.stream?.failed || this.stream?.text !== stableKey(query)) {
352
453
  this.openStream(query);
353
454
  }
354
455
  }
@@ -356,13 +457,18 @@ var BrowserStateClient = class {
356
457
  * One waiter per lifetime on the first snapshot. A failure goes to
357
458
  * `onError`, and the read is retried after `retryDelay` — the stream that
358
459
  * would reconnect on its own does not exist yet. A retry stops on
359
- * `close()` or when nothing is watched (`sync` closes instead).
460
+ * `close()`, when nothing is watched (`sync` closes instead), or after
461
+ * `maxRetries`; a wait started after that last one restarts the count.
360
462
  */
361
463
  awaitManifest() {
362
464
  const lifetime = this.lifetime;
363
465
  if (this.waitingLifetime === lifetime) {
364
466
  return;
365
467
  }
468
+ if (this.status === FAILED) {
469
+ this.retries = 0;
470
+ this.setConnection(STARTING);
471
+ }
366
472
  this.waitingLifetime = lifetime;
367
473
  this.ensureManifest().then(
368
474
  () => lifetime === this.lifetime && this.resync(),
@@ -379,14 +485,40 @@ var BrowserStateClient = class {
379
485
  this.waitingLifetime = void 0;
380
486
  this.sync();
381
487
  }
382
- /** Read the first snapshot again after `retryDelay`, as the SSE client reconnects a dropped stream. */
488
+ /**
489
+ * Read the first snapshot again after `retryDelay`, as the SSE client
490
+ * reconnects a dropped stream — or, with `maxRetries` spent, give up:
491
+ * `'failed'`, no timer, no wait.
492
+ */
383
493
  retryLater() {
494
+ const maxRetries = this.config.maxRetries ?? 0;
495
+ if (maxRetries > 0 && this.retries >= maxRetries) {
496
+ this.waitingLifetime = void 0;
497
+ this.setConnection(FAILED);
498
+ return;
499
+ }
500
+ this.retries++;
384
501
  this.retryTimer = setTimeout(() => {
385
502
  this.retryTimer = void 0;
386
503
  this.resync();
387
504
  }, this.config.retryDelay ?? 3e3);
388
505
  this.retryTimer.unref?.();
389
506
  }
507
+ /**
508
+ * `retry()` before the manifest: the count starts over and the first
509
+ * snapshot is read now, its pending timer cleared. A read in flight is
510
+ * already the retry; it is not doubled.
511
+ */
512
+ rereadManifest() {
513
+ this.retries = 0;
514
+ if (this.waitingLifetime === this.lifetime && this.retryTimer === void 0) {
515
+ return;
516
+ }
517
+ clearTimeout(this.retryTimer);
518
+ this.retryTimer = void 0;
519
+ this.setConnection(STARTING);
520
+ this.resync();
521
+ }
390
522
  /**
391
523
  * Every subscribed key's fields; one connection carries one value per
392
524
  * field, so on a conflict the latest subscribed key wins. The keys it
@@ -399,33 +531,96 @@ var BrowserStateClient = class {
399
531
  * Open (or reopen) the stream. `events` is every state of the manifest, in
400
532
  * its order, never only the subscribed ones: a state subscribed later would
401
533
  * otherwise be unrequested, and the server's `grant` cannot activate it.
534
+ * Unless connected, the status is `'reconnecting'` 0 until it opens: a
535
+ * new subscription counts its attempts from 0. A reopen on a key change
536
+ * while connected stays `'connected'`.
402
537
  */
403
538
  openStream(query) {
404
539
  this.closeStream();
540
+ if (this.status !== CONNECTED) {
541
+ this.setConnection(STARTING);
542
+ }
405
543
  const ids = Object.keys(this.manifest);
406
- const stream = { query, text: stableKey(query), generation: 0, close: () => void 0 };
544
+ const stream = { query, text: stableKey(query), generation: 0, closedByClient: false, failed: false, close: () => void 0 };
407
545
  stream.close = this.config.sse.subscribe({
408
- events: ids.map((id) => `state.${id}.changed`),
409
- handlers: Object.fromEntries(ids.map((id) => [`state.${id}.changed`, (frame) => this.onFrame(id, frame)])),
546
+ events: ids.map(stateEventName),
547
+ handlers: Object.fromEntries(ids.map((id) => [stateEventName(id), (frame) => this.onFrame(id, frame, stream)])),
410
548
  query,
411
- // Every open — the first, a reconnect, a reopen on a key change — re-reads the
412
- // snapshot. A frame sent while no stream was open is lost, whether its key
413
- // changed or not; the snapshot read after the open covers that gap.
414
549
  onOpen: () => {
415
550
  stream.generation = ++this.generation;
416
- this.refresh(query);
551
+ this.streamStatus(stream, CONNECTED);
417
552
  },
553
+ onReconnect: (attempt) => this.streamStatus(stream, reconnecting(attempt)),
554
+ onClose: () => this.onStreamClose(stream),
555
+ onConnected: (connected) => this.onConnected(connected, stream),
418
556
  onSubscriptionChange: (change) => this.onSubscriptionChange(change, stream)
419
557
  });
420
558
  this.stream = stream;
421
559
  }
560
+ /** Flagged first: the SSE client fires `onClose` on this teardown too, and it is no failure. */
422
561
  closeStream() {
423
- this.stream?.close();
424
- this.stream = void 0;
562
+ if (this.stream) {
563
+ this.stream.closedByClient = true;
564
+ this.stream.close();
565
+ this.stream = void 0;
566
+ }
567
+ }
568
+ /** The status of a callback of `stream`, when no other stream replaced it. */
569
+ streamStatus(stream, status) {
570
+ if (stream === this.stream) {
571
+ this.setConnection(status);
572
+ }
573
+ }
574
+ /**
575
+ * The SSE client fires `onClose` on every teardown; one the client did
576
+ * not start is the reconnect attempts exhausted: `'failed'`, and the
577
+ * stream counts as absent for the next `sync`.
578
+ */
579
+ onStreamClose(stream) {
580
+ if (!stream.closedByClient && stream === this.stream) {
581
+ stream.failed = true;
582
+ this.setConnection(FAILED);
583
+ }
584
+ }
585
+ /**
586
+ * Every connection — the first, a reconnect, a reopen on a key change —
587
+ * must cover the frames sent while no stream was open. The server says in
588
+ * `connected` which states it served their current frame right after it;
589
+ * the snapshot is read only when one it did not serve is neither denied
590
+ * nor served — or when the server predates `initial`. A denied state the
591
+ * client watches is `'revoked'`, as an omission from that snapshot would
592
+ * be. A connection already replaced is ignored.
593
+ */
594
+ onConnected(connected, stream) {
595
+ if (stream !== this.stream || stream.generation !== this.generation) {
596
+ return;
597
+ }
598
+ const denied = new Set(connected.deniedEvents);
599
+ const served = new Set(connected.initial ?? []);
600
+ const unserved = Object.keys(this.manifest).map(stateEventName).filter((name) => !denied.has(name) && !served.has(name));
601
+ this.revokeDenied(denied, stream);
602
+ if (connected.initial && unserved.length === 0) {
603
+ this.readShadowedKeys(stream.query);
604
+ } else {
605
+ this.refresh(stream.query);
606
+ }
607
+ }
608
+ /** Each watched state the connection denies, on the key it carries, marked `'revoked'` by it. */
609
+ revokeDenied(denied, stream) {
610
+ for (const id of Object.keys(this.manifest)) {
611
+ const slot = this.slot(id, this.keyIn(id, stream.query));
612
+ if (denied.has(stateEventName(id)) && this.watched.has(slot)) {
613
+ this.store.revoke(slot, stream.generation);
614
+ }
615
+ }
425
616
  }
426
617
  /** The snapshot for the stream's query, and one read for each subscribed key the query shadows. */
427
618
  refresh(query) {
428
619
  this.readSnapshot(query, this.tag()).catch(this.reportError);
620
+ this.readShadowedKeys(query);
621
+ }
622
+ /** One read for each subscribed key the query shadows: the stream serves one key per field. */
623
+ readShadowedKeys(query) {
429
624
  for (const watch of this.watched.values()) {
430
625
  if (watch.key && !carries(query, watch.key)) {
431
626
  this.readShadowed(watch.id, watch.key, this.tag()).catch(this.reportError);
@@ -438,13 +633,18 @@ var BrowserStateClient = class {
438
633
  */
439
634
  async readShadowed(id, key, tag) {
440
635
  const frame = await this.api[routeName("get", id)].call({ query: key });
441
- this.serve(this.slot(id, key), frame, tag);
636
+ this.serve(this.slot(id, key), frame, tag.generation);
442
637
  }
443
- /** A frame for a key nobody here subscribed is dropped — the server's filter is the first guard, this the second. */
444
- onFrame(id, frame) {
638
+ /**
639
+ * A frame for a key nobody here subscribed is dropped — the server's filter
640
+ * is the first guard, this the second. A frame is served under its
641
+ * connection's generation: the server sends one only on a connection that
642
+ * allows the state, so a newer connection's frame lifts an older revoke.
643
+ */
644
+ onFrame(id, frame, stream) {
445
645
  const slot = this.slot(id, frame.key);
446
646
  if (this.watched.has(slot)) {
447
- this.store.apply(slot, frame);
647
+ this.serve(slot, frame, stream.generation);
448
648
  }
449
649
  }
450
650
  /**