@ultimat3/realtime 29.1.0 → 29.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -164,8 +164,6 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
164
164
  `upgrade()`, as Bun does.
165
165
  - **A grant expires; a socket does not**: expired grants are re-decided on an interval
166
166
  (`hub.onActorChange`, `registry.reauthorize`). No `refresh` = close `1008`; one that raises retries.
167
- - **Every `socket.send` reads its answer**: a dropped subscribe reply marks it desynced; a dropped
168
- roster logs `sync.presence_roster_dropped`; `drain()` returns `DrainedSocket[]` (`notified`).
169
167
  - **Inbound frames run in a lane, never the socket**: `subscribe` is `sub:<sid>` or `topic:<name>`,
170
168
  everything else unlaned (`frame-lanes.ts`); a lane exists only while work is queued.
171
169
  - **A dropped `records` frame is counted AND repaired**: per-node `seq`/`epoch` per channel; a refused
@@ -256,6 +254,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
256
254
  `useQuery`'s input is read once, at call time.
257
255
  - **`useRecords(type, selection)` holds the TYPE** (`retainType`): `ready` at once, recomputed per
258
256
  change to that type, no row of it evicted while held; the last type hold evicts what it spared.
257
+ - **Read hooks answer the SAME object until what they show moved** (`keptAnswer`); a `where`
258
+ reader hears only keys it shows or keeps (history, 2026-10-29).
259
259
  - Every subscription handle is `Disposable`; `[Symbol.dispose]` IS the release. Type claims go in
260
260
  `type-pins.ts`.
261
261
  - **The outbox**: only `pending` is sendable; `drain()` is one chained pass at a time; `#epoch` is
@@ -282,9 +282,6 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
282
282
  per socket; the engine reaps ports on the longer of its own and the node's.
283
283
  - **There is ONE `backoffDelay`, `@ultimat3/core`'s, counted from 1** — `policyDelay()` maps a
284
284
  `BackoffPolicy` onto it; `bun run flight-copies` refuses a second curve.
285
-
286
- ## Rules for code here
287
-
288
285
  - Never a bare `Error`. Never `any`. Never `Date.now()` — take a `Clock` (`monotonic()` for durations).
289
286
 
290
287
  ## The page boot
@@ -312,7 +309,6 @@ bun test packages/realtime/src # from the REPO ROOT
312
309
  bun run typecheck
313
310
  ```
314
311
 
315
- `bunfig.toml`'s preload (matchers) is read from the cwd.
316
312
 
317
313
  Changing a frame shape means a fixture in `sync-protocol.test.ts` (the round-trip test fails
318
314
  without one) and bumping `PROTOCOL_VERSION` **when an old frame becomes unreadable in either
package/README.md CHANGED
@@ -251,6 +251,7 @@ const pair = useRecords('posts', [postId]); // those key
251
251
  const runs = useRecords<Run>('runs', { // EVERY `runs` record on the page
252
252
  where: (run) => run.state !== 'done',
253
253
  order: (a, b) => (a.startedAt < b.startedAt ? 1 : -1),
254
+ throttleMs: 1_000, // optional: at most once a second
254
255
  });
255
256
  const like = useMutation(LIKE_POST); // await like(input); like.pending
256
257
  const connection = useConnection(); // .offline .online .reconnectAt .updateAvailable
@@ -279,6 +280,8 @@ same type `@ultimat3/ui` renders.
279
280
  | A query ref is `{ name, live?, entity? }`, never the query VALUE | importing a `query()` drags its read path into the island (698,801 B measured) |
280
281
  | Lists hold keys; rows are the store's | a record updated by any answer or frame re-renders every list showing it, with no refetch |
281
282
  | A list that must GROW with the store is `useRecords(type, { where?, order? })` | a `useQuery` list holds its own answer's keys and `useRecords(type, keys)` the keys it was given, so a record a channel's `records` frame adopts reaches neither. The whole-type form is the channel-records counterpart of a live window: `ready` from its first read, every record of the type the store holds (optimistic ones included), default order by key, and none evicted while it is held — a row it alone kept goes when the last reader of the type lets go (#680) |
283
+ | An answer is the SAME object until what it shows moved | `useQuery`, `useRecord` and both `useRecords` forms hand back the previous answer — and its `data` array — while the status and every row object match: a refetch or poll that found the same rows, or a re-read after an unrelated change, re-runs nothing derived from it (Solid's `===`). A whole-type reader with `where` is not even TOLD of a record it neither shows nor would show: on a run page the viewer's alerts list stopped hearing the fleet's runs (180 of 200 frames, 0 its own) |
284
+ | A busy feed is throttled by the reader, `useRecords(type, { throttleMs })` | the first change is told at once, a burst as ONE notification at the end of the period, the last always lands; `release()` drops what is owed. A live board on a phone took ten records a second, each a whole-board redraw: `throttleMs: 1000` tells it 21 times in 20 s instead of 200. Absent or `0`: every change, as before. A non-finite or fractional value is `X_INVARIANT`; `schedule` injects the timer in a test |
282
285
  | Knowing THAT records moved is `useRecordChanges(types, listener)` | one call per store update, the moved keys per type, and no hold on any row or type, so a page that re-renders on a channel's `records` pins nothing. `useChannel(...).error()` is the node's refusal of a `failed` membership |
283
286
  | Every write is HTTP through core's `clientTransport` | one write path: the action's authz, idempotency and contract; the socket carries none |
284
287
  | The answer's records are adopted before the overlay goes | a convergent twin never flickers back to the pre-write value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "29.1.0",
3
+ "version": "29.3.0",
4
4
  "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,10 +40,10 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/core": "29.1.0",
44
- "@ultimat3/entity": "29.1.0",
45
- "@ultimat3/policy": "29.1.0",
46
- "@ultimat3/query": "29.1.0",
43
+ "@ultimat3/core": "29.3.0",
44
+ "@ultimat3/entity": "29.3.0",
45
+ "@ultimat3/policy": "29.3.0",
46
+ "@ultimat3/query": "29.3.0",
47
47
  "nats": "2.29.3"
48
48
  }
49
49
  }
@@ -0,0 +1,99 @@
1
+ // What a read hook hands back when nothing it shows moved: the SAME answer object. Solid compares a
2
+ // memo's value with `===`, so a fresh array of the same rows re-ran every derivation below it — a
3
+ // panel had to diff rows by hand (`stableRuns`, `sameData`) to keep a board still. And how often a
4
+ // hook may tell its readers at all: `throttleMs`, the first change at once, a burst as one.
5
+
6
+ import type { AsyncState } from '@ultimat3/core/page';
7
+ import { finiteCount } from '@ultimat3/core/page';
8
+ import { type Scheduler, timeoutScheduler } from './thundering-herd';
9
+
10
+ /** Same length, same row OBJECTS in the same order. The store keeps an unmoved row's object. */
11
+ export function sameRows<R>(a: readonly R[], b: readonly R[]): boolean {
12
+ if (a === b) return true;
13
+ if (a.length !== b.length) return false;
14
+ for (let index = 0; index < a.length; index++) if (a[index] !== b[index]) return false;
15
+ return true;
16
+ }
17
+
18
+ /**
19
+ * The rows to answer with: the previous answer's own array when it holds exactly these rows, so a
20
+ * re-read that found nothing new (a poll, a refetch, a change to a row the list does not show)
21
+ * hands every reader the value it already holds.
22
+ */
23
+ export function keptRows<R>(previous: AsyncState<readonly R[]> | null, rows: readonly R[]) {
24
+ const held = previous !== null && 'data' in previous ? previous.data : undefined;
25
+ return held !== undefined && sameRows(held, rows) ? held : rows;
26
+ }
27
+
28
+ /** `{ status, data }`, reusing `previous` when both match — the identity a renderer stops on. */
29
+ export function keptAnswer<R>(
30
+ previous: AsyncState<readonly R[]> | null,
31
+ status: 'ready' | 'refreshing',
32
+ rows: readonly R[],
33
+ ): AsyncState<readonly R[]> {
34
+ const data = keptRows(previous, rows);
35
+ if (
36
+ previous !== null &&
37
+ previous.status === status &&
38
+ 'data' in previous &&
39
+ previous.data === data
40
+ )
41
+ return previous;
42
+ return { status, data };
43
+ }
44
+
45
+ export interface Notifier {
46
+ /** A change readers should hear: now, or at the end of the open period. */
47
+ readonly call: () => void;
48
+ /** The hook let go: an owed notification is dropped with it. */
49
+ readonly cancel: () => void;
50
+ }
51
+
52
+ /**
53
+ * `notify` at most once per `ms`: a change with no period open is told AT ONCE (a lone update is
54
+ * never delayed) and opens one; changes inside it are told ONCE at its end, which opens the next.
55
+ * The last change always lands. No clock: the scheduler's timer is the period. `0` or absent: every
56
+ * change, synchronously — what a hook did before the option existed.
57
+ */
58
+ export function throttledNotify(
59
+ notify: () => void,
60
+ ms: number | undefined,
61
+ schedule: Scheduler = timeoutScheduler,
62
+ ): Notifier {
63
+ if (ms === undefined || finiteCount('useRecords', 'throttleMs', ms) === 0) {
64
+ return { call: notify, cancel: () => undefined };
65
+ }
66
+ let cancelTimer: (() => void) | null = null;
67
+ let owed = false;
68
+ /** Released: nothing is armed or told again, whatever a notification's reader did meanwhile. */
69
+ let closed = false;
70
+ // The period is armed BEFORE the readers hear: a reader that releases the hook while it is told
71
+ // cancels this timer, rather than the hook arming one after its own release.
72
+ const open = (): void => {
73
+ if (closed) return;
74
+ cancelTimer = schedule(() => {
75
+ cancelTimer = null;
76
+ if (!owed || closed) return;
77
+ owed = false;
78
+ open();
79
+ notify();
80
+ }, ms);
81
+ };
82
+ return {
83
+ call: () => {
84
+ if (closed) return;
85
+ if (cancelTimer !== null) {
86
+ owed = true;
87
+ return;
88
+ }
89
+ open();
90
+ notify();
91
+ },
92
+ cancel: () => {
93
+ closed = true;
94
+ cancelTimer?.();
95
+ cancelTimer = null;
96
+ owed = false;
97
+ },
98
+ };
99
+ }
@@ -81,8 +81,8 @@ export class RecordStore implements RecordSink {
81
81
  seen.add(rk);
82
82
  if (row !== null) out.push([rk.slice(prefix.length), row]);
83
83
  }
84
- for (const [rk, row] of this.#synced.entries()) {
85
- if (rk.startsWith(prefix) && !seen.has(rk)) out.push([rk.slice(prefix.length), row]);
84
+ for (const [rk, row] of this.#synced.ofType(type)) {
85
+ if (!seen.has(rk)) out.push([rk.slice(prefix.length), row]);
86
86
  }
87
87
  return out;
88
88
  }
@@ -133,7 +133,7 @@ export class RecordStore implements RecordSink {
133
133
  for (const [key, row] of Object.entries(rows)) {
134
134
  const rk = recordKey(type, key);
135
135
  if (!isRow(row) || key === '') continue;
136
- if (this.#synced.restore(rk, row)) this.#touchSynced(rk);
136
+ if (this.#synced.restore(rk, row, type)) this.#touchSynced(rk);
137
137
  }
138
138
  });
139
139
  }
@@ -141,7 +141,7 @@ export class RecordStore implements RecordSink {
141
141
  /** One server write, merged column by column (`SyncedLayer.merge`). */
142
142
  merge(type: string, key: string, columns: Row): Row {
143
143
  const rk = recordKey(type, key);
144
- const { row, changed } = this.#synced.merge(rk, columns);
144
+ const { row, changed } = this.#synced.merge(rk, columns, type);
145
145
  if (changed) {
146
146
  this.#touchSynced(rk);
147
147
  } else if (this.#overlays.size > 0) {
@@ -15,6 +15,16 @@ export class SyncedLayer {
15
15
  readonly #spared = new Map<string, Set<RecordKey>>();
16
16
  /** Keys restored from disk and not yet confirmed: the first server row REPLACES, never merges. */
17
17
  readonly #provisional = new Set<RecordKey>();
18
+ /**
19
+ * Per type, its keys: a whole-type read walks its own rows, never every row of every type. A
20
+ * run page holds the fleet's runs beside one run's events, and each event reader scanned both.
21
+ */
22
+ readonly #byType = new Map<string, Set<RecordKey>>();
23
+ /**
24
+ * Each indexed key's type, as its writer NAMED it. Never recovered from the key: a type may hold
25
+ * a colon (`?query:<name>`, an unnamed live query's rows), so `type:key` splits two ways.
26
+ */
27
+ readonly #typeOfKey = new Map<RecordKey, string>();
18
28
 
19
29
  get(rk: RecordKey): Row | undefined {
20
30
  return this.#rows.get(rk);
@@ -34,8 +44,12 @@ export class SyncedLayer {
34
44
 
35
45
  /** Every row of one type — what a replay's `tx.<type>.all()` reads under the overlay. */
36
46
  ofType(type: string): readonly [RecordKey, Row][] {
37
- const prefix = `${type}:`;
38
- return [...this.#rows].filter(([rk]) => rk.startsWith(prefix));
47
+ const out: [RecordKey, Row][] = [];
48
+ for (const rk of this.#byType.get(type) ?? []) {
49
+ const row = this.#rows.get(rk);
50
+ if (row !== undefined) out.push([rk, row]);
51
+ }
52
+ return out;
39
53
  }
40
54
 
41
55
  isProvisional(rk: RecordKey): boolean {
@@ -49,7 +63,11 @@ export class SyncedLayer {
49
63
  * a JSON column is a fresh array or object on every read, and by identity every catch-up, poll
50
64
  * and reconnect moved every row. An unmoved column keeps its object, so its readers stay put.
51
65
  */
52
- merge(rk: RecordKey, columns: Row): { readonly row: Row; readonly changed: boolean } {
66
+ merge(
67
+ rk: RecordKey,
68
+ columns: Row,
69
+ type: string,
70
+ ): { readonly row: Row; readonly changed: boolean } {
53
71
  // A restored row is a guess from disk: the server's first answer is the whole truth.
54
72
  const current = this.#provisional.delete(rk) ? undefined : this.#rows.get(rk);
55
73
  const next: Record<string, unknown> = { ...current };
@@ -62,19 +80,20 @@ export class SyncedLayer {
62
80
  }
63
81
  if (!changed && current !== undefined) return { row: current, changed: false };
64
82
  const row = Object.freeze(next);
65
- this.#rows.set(rk, row);
83
+ this.#put(rk, row, type);
66
84
  return { row, changed: true };
67
85
  }
68
86
 
69
87
  /** A conflict policy's verdict replacing the server row outright. */
70
88
  set(rk: RecordKey, row: Row): void {
71
- this.#rows.set(rk, Object.freeze({ ...row }));
89
+ // Only ever over a row the server already holds: its type was named when it arrived.
90
+ this.#put(rk, Object.freeze({ ...row }), this.#typeOfKey.get(rk) ?? typeOf(rk));
72
91
  }
73
92
 
74
93
  /** A row from disk, only where the server has not answered. Answers whether it went in. */
75
- restore(rk: RecordKey, row: Row): boolean {
94
+ restore(rk: RecordKey, row: Row, type: string): boolean {
76
95
  if (this.#rows.has(rk)) return false;
77
- this.#rows.set(rk, Object.freeze({ ...row }));
96
+ this.#put(rk, Object.freeze({ ...row }), type);
78
97
  this.#provisional.add(rk);
79
98
  return true;
80
99
  }
@@ -83,7 +102,12 @@ export class SyncedLayer {
83
102
  remove(rk: RecordKey): boolean {
84
103
  this.#provisional.delete(rk);
85
104
  // Gone is no longer spared: a row the server sends again later is a new arrival, not this one.
86
- this.#spared.get(rk.slice(0, rk.indexOf(':')))?.delete(rk);
105
+ const type = this.#typeOfKey.get(rk) ?? typeOf(rk);
106
+ this.#typeOfKey.delete(rk);
107
+ this.#spared.get(type)?.delete(rk);
108
+ const keys = this.#byType.get(type);
109
+ keys?.delete(rk);
110
+ if (keys?.size === 0) this.#byType.delete(type);
87
111
  return this.#rows.delete(rk);
88
112
  }
89
113
 
@@ -103,7 +127,7 @@ export class SyncedLayer {
103
127
  return false;
104
128
  }
105
129
  this.#holds.delete(rk);
106
- const type = rk.slice(0, rk.indexOf(':'));
130
+ const type = this.#typeOfKey.get(rk) ?? typeOf(rk);
107
131
  if (this.#typeHolds.has(type)) {
108
132
  const spared = this.#spared.get(type) ?? new Set<RecordKey>();
109
133
  spared.add(rk);
@@ -140,7 +164,22 @@ export class SyncedLayer {
140
164
 
141
165
  clear(): void {
142
166
  this.#rows.clear();
167
+ this.#byType.clear();
168
+ this.#typeOfKey.clear();
143
169
  this.#provisional.clear();
144
170
  this.#spared.clear();
145
171
  }
172
+
173
+ #put(rk: RecordKey, row: Row, type: string): void {
174
+ if (!this.#typeOfKey.has(rk)) {
175
+ this.#typeOfKey.set(rk, type);
176
+ const keys = this.#byType.get(type);
177
+ if (keys === undefined) this.#byType.set(type, new Set([rk]));
178
+ else keys.add(rk);
179
+ }
180
+ this.#rows.set(rk, row);
181
+ }
146
182
  }
183
+
184
+ /** A key's type when no writer named it (a hold on a row not yet there): before its first `:`. */
185
+ const typeOf = (rk: RecordKey): string => rk.slice(0, rk.indexOf(':'));
package/src/use-query.ts CHANGED
@@ -8,6 +8,7 @@ import type { LiveHandle } from './client-contract';
8
8
  import type { JsonValue } from './json';
9
9
  import { installedPage, type PageServices } from './page-store';
10
10
  import { isServerRender, signalFor } from './reactivity';
11
+ import { keptAnswer } from './record-answer';
11
12
  import { type RecordStore, recordKey } from './record-store';
12
13
 
13
14
  /**
@@ -88,16 +89,17 @@ function liveAccessor<R extends object>(
88
89
  if (handle.state() === 'live') seen = true;
89
90
  bump();
90
91
  });
92
+ /** The last answer handed out: the same rows back keep its `data` array (`keptAnswer`). */
93
+ let answer: AsyncState<readonly R[]> | null = null;
91
94
  const read = (): AsyncState<readonly R[]> => {
92
95
  version();
93
96
  const state = handle.state();
94
97
  if (state === 'failed') return { status: 'failed', error: handle.error() };
95
- if (state === 'live') {
96
- seen = true;
97
- return { status: 'ready', data: handle.rows() };
98
- }
98
+ if (state === 'live') seen = true;
99
99
  // Loading, stale or offline: what was shown stays shown, marked busy — never torn down.
100
- return seen ? { status: 'refreshing', data: handle.rows() } : PENDING;
100
+ else if (!seen) return PENDING;
101
+ answer = keptAnswer(answer, state === 'live' ? 'ready' : 'refreshing', handle.rows());
102
+ return answer;
101
103
  };
102
104
  const release = (): void => {
103
105
  off();
@@ -226,14 +228,17 @@ function readAccessor<R extends object>(
226
228
  };
227
229
 
228
230
  load();
231
+ /**
232
+ * The last answer handed out: a re-read that answered the same rows (a poll, a refetch, a return
233
+ * to the tab) hands back its `data` array, so nothing derived from it runs again.
234
+ */
235
+ let answer: AsyncState<readonly R[]> | null = null;
229
236
  const read = (): AsyncState<readonly R[]> => {
230
237
  version();
231
238
  if (state.status === 'pending' || state.status === 'failed') return state;
232
239
  // Rows are typed by the caller's query; the store holds them as JSON rows.
233
- const data = rows() as readonly R[];
234
- return state.status === 'refreshing'
235
- ? { status: 'refreshing', data }
236
- : { status: 'ready', data };
240
+ answer = keptAnswer(answer, state.status, rows() as readonly R[]);
241
+ return answer;
237
242
  };
238
243
  const release = (): void => {
239
244
  if (released) return;
package/src/use-record.ts CHANGED
@@ -6,7 +6,9 @@
6
6
  import type { AsyncState, Row } from '@ultimat3/core/page';
7
7
  import { pageStore } from './page-store';
8
8
  import { isServerRender, signalFor } from './reactivity';
9
- import { type RecordStore, recordKey } from './record-store';
9
+ import { keptAnswer, throttledNotify } from './record-answer';
10
+ import { type RecordKey, type RecordStore, recordKey } from './record-store';
11
+ import type { Scheduler } from './thundering-herd';
10
12
 
11
13
  /**
12
14
  * A callable `AsyncState`: `pending` until the record first arrives, `ready` from then on —
@@ -43,12 +45,16 @@ export function useRecord<R extends object = Row>(type: string, key: string): Re
43
45
  if (store.peek(type, key) !== undefined) seen = true;
44
46
  setVersion(version() + 1);
45
47
  });
48
+ let answer: AsyncState<R | undefined> = PENDING;
46
49
  const read = (): AsyncState<R | undefined> => {
47
50
  version();
48
51
  // The store holds JSON rows; the caller names the entity row type it reads them as.
49
52
  const row = store.peek(type, key) as R | undefined;
50
53
  if (row !== undefined) seen = true;
51
- return row === undefined && !seen ? PENDING : { status: 'ready', data: row };
54
+ if (row === undefined && !seen) return PENDING;
55
+ // The same row object back is the same answer: nothing reading it runs again.
56
+ if (answer.status !== 'ready' || answer.data !== row) answer = { status: 'ready', data: row };
57
+ return answer;
52
58
  };
53
59
  let held = true;
54
60
  const release = (): void => {
@@ -75,6 +81,14 @@ export interface RecordSelection<R extends object = Row> {
75
81
  readonly where?: (record: R) => boolean;
76
82
  /** Absent: by record key, ascending — creation order for a uuid v7 key. */
77
83
  readonly order?: (a: R, b: R) => number;
84
+ /**
85
+ * Tell readers at most once per this many ms: the first change at once, a burst as ONE change at
86
+ * the end of the period, the last always landing. For a projection a busy feed moves many times
87
+ * a second (a live board on a phone). Absent or `0`: every change, as it lands.
88
+ */
89
+ readonly throttleMs?: number;
90
+ /** The period's timer. Defaults to `setTimeout`; a test injects its own. */
91
+ readonly schedule?: Scheduler;
78
92
  }
79
93
 
80
94
  /**
@@ -131,16 +145,22 @@ function byKeys<R extends object>(
131
145
  for (const key of keys) store.retain(type, key);
132
146
  });
133
147
  let seen = present().length > 0;
148
+ let answer: AsyncState<readonly R[]> | null = null;
134
149
  const unsubscribe = store.subscribe((changed) => {
135
- if (![...changed].some((key) => targets.has(key))) return;
136
- if (present().length > 0) seen = true;
137
- bump();
150
+ for (const rk of changed) {
151
+ if (!targets.has(rk)) continue;
152
+ if (present().length > 0) seen = true;
153
+ bump();
154
+ return;
155
+ }
138
156
  });
139
157
  const read = (): AsyncState<readonly R[]> => {
140
158
  version();
141
159
  const rows = present();
142
160
  if (rows.length > 0) seen = true;
143
- return !seen ? PENDING : { status: 'ready', data: rows };
161
+ if (!seen) return PENDING;
162
+ answer = keptAnswer(answer, 'ready', rows);
163
+ return answer;
144
164
  };
145
165
  let held = true;
146
166
  const release = (): void => {
@@ -163,15 +183,36 @@ function ofType<R extends object>(
163
183
  ): RecordsAccessor<R> {
164
184
  const prefix = `${type}:`;
165
185
  const { where, order } = selection;
186
+ const notify = throttledNotify(bump, selection.throttleMs, selection.schedule);
166
187
  store.retainType(type);
167
188
  // Recomputed once per change to this type, never per read: the same answer back is what tells
168
189
  // a fine-grained renderer nothing moved.
169
190
  let answer: AsyncState<readonly R[]> | null = null;
170
191
  let answered = -1;
192
+ /** The keys the current answer shows: a change to one of them moves it, whatever `where` says. */
193
+ let shown: ReadonlySet<RecordKey> = new Set();
194
+ /**
195
+ * Whether `rk` can move the answer readers hold: shown in it, or kept by `where` now. A record of
196
+ * the type the list neither shows nor would show — the fleet's run beside one viewer's — is
197
+ * nobody's change here, and told to nobody (a stale answer is told regardless: it is re-read).
198
+ */
199
+ const moves = (rk: RecordKey): boolean => {
200
+ if (where === undefined || answer === null || answered !== version() || shown.has(rk)) {
201
+ return true;
202
+ }
203
+ // The store holds JSON rows; the caller names the entity row type it reads them as.
204
+ const row = store.peek(type, rk.slice(prefix.length)) as R | undefined;
205
+ try {
206
+ return row !== undefined && where(row);
207
+ } catch {
208
+ // The read raises it to the caller; a listener that threw would silence the page's others.
209
+ return true;
210
+ }
211
+ };
171
212
  const unsubscribe = store.subscribe((changed) => {
172
213
  for (const rk of changed) {
173
- if (!rk.startsWith(prefix)) continue;
174
- bump();
214
+ if (!rk.startsWith(prefix) || !moves(rk)) continue;
215
+ notify.call();
175
216
  return;
176
217
  }
177
218
  });
@@ -181,11 +222,15 @@ function ofType<R extends object>(
181
222
  // The store holds JSON rows; the caller names the entity row type it reads them as.
182
223
  const keyed = store.keyed(type) as readonly (readonly [string, R])[];
183
224
  const kept = where === undefined ? [...keyed] : keyed.filter(([, row]) => where(row));
184
- const rows =
185
- order === undefined
186
- ? kept.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, row]) => row)
187
- : kept.map(([, row]) => row).sort(order);
188
- answer = { status: 'ready', data: rows };
225
+ if (order === undefined) kept.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
226
+ else kept.sort(([, a], [, b]) => order(a, b));
227
+ // Only a `where` asks what is shown: with none, every record of the type moves the answer.
228
+ if (where !== undefined) shown = new Set(kept.map(([key]) => `${prefix}${key}`));
229
+ answer = keptAnswer(
230
+ answer,
231
+ 'ready',
232
+ kept.map(([, row]) => row),
233
+ );
189
234
  answered = at;
190
235
  return answer;
191
236
  };
@@ -193,6 +238,7 @@ function ofType<R extends object>(
193
238
  const release = (): void => {
194
239
  if (!held) return;
195
240
  held = false;
241
+ notify.cancel();
196
242
  unsubscribe();
197
243
  store.releaseType(type);
198
244
  };