@ultimat3/realtime 29.3.0 → 29.3.2

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
@@ -253,7 +253,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
253
253
  - Anything a component reads is a getter or accessor. `MutatorLike.local` uses method syntax.
254
254
  `useQuery`'s input is read once, at call time.
255
255
  - **`useRecords(type, selection)` holds the TYPE** (`retainType`): `ready` at once, recomputed per
256
- change to that type, no row of it evicted while held; the last type hold evicts what it spared.
256
+ change to that type, no row of it released away while held; the last type hold evicts what it spared.
257
+ - **Bounded store**: README "One record per `type:key`"; `channel-retention.test.ts`.
257
258
  - **Read hooks answer the SAME object until what they show moved** (`keptAnswer`); a `where`
258
259
  reader hears only keys it shows or keeps (history, 2026-10-29).
259
260
  - Every subscription handle is `Disposable`; `[Symbol.dispose]` IS the release. Type claims go in
package/README.md CHANGED
@@ -436,6 +436,8 @@ over it, and an HTTP answer's records, a socket patch and an optimistic write al
436
436
  | A value is **replaced, never mutated**; a write **merges** columns | a mutated row is a render that never happens; a narrower projection must not blank a wider one |
437
437
  | A structurally bad row is `X_RECORD_REJECTED`, dropped and reported | never partially merged — a keyless row would overwrite another record |
438
438
  | The last holder leaving evicts the record | an infinite scroll must not retain every row it ever saw |
439
+ | A joined channel keeps the rows its frames and catch-up reads adopted — dropped (the last `useChannel` released) it evicts each it alone kept; a row the server removes leaves its keep at once | a `records` frame's rows carry no reader hold: a run page's channel left its events behind after the reader navigated away |
440
+ | Each type is capped — `new RecordStore({ maxRowsPerType, maxRowsByType })`: `maxRowsPerType` for every type (default `DEFAULT_MAX_ROWS_PER_TYPE` = 10 000), `maxRowsByType: { run_logs: 50_000 }` for the types it names; `Infinity` turns a cap off, anything not a whole number ≥ 1 is `X_INVARIANT`. Past it, at the close of a batch, the least recently written row goes — never one a key reader holds (`useRecord`, `useRecords(type, keys)`, a `useQuery` list, a live window), never one a pending write touched, never one that arrived in that batch | a backstop against a fleet feed joined for hours, which adopts a row per run forever — not a view limit: the default sits far above what a page shows of one type. Neither a channel's keep nor a whole-type reader (`useRecords(type, selection)`, which shows at most the cap) exempts a type, so a type a page shows more of than the default names its own cap |
439
441
  | A principal change clears the store | nothing of the previous principal survives in memory |
440
442
 
441
443
  ## Reconnect is the hard part
@@ -866,8 +868,11 @@ principal.
866
868
  table not on FULL, on every boot. `ReplicationStreamStats.partialBefore` still counts changes off
867
869
  a non-FULL relation — a volume figure, not a correctness one.
868
870
  - The record store is **per page**, in memory, and it is not a query cache: it answers "what is
869
- record X now", never "have I run this query before". Nothing evicts by time or size — a record
870
- lives as long as something holds it. Persisting it (IndexedDB) is plan 101 slice 12.
871
+ record X now", never "have I run this query before". Nothing evicts by time; size is bounded —
872
+ a record lives while a reader, a joined channel or a pending write holds it, and an unheld row
873
+ (an HTTP answer's before its reader holds it, a channel's) is also bounded by the per-type cap
874
+ (`maxRowsPerType` / `maxRowsByType`, least recently written first). An eviction is a change like any other: the
875
+ persister deletes the row from disk with it. Persisting it (IndexedDB) is plan 101 slice 12.
871
876
 
872
877
  ## Errors
873
878
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "29.3.0",
3
+ "version": "29.3.2",
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.3.0",
44
- "@ultimat3/entity": "29.3.0",
45
- "@ultimat3/policy": "29.3.0",
46
- "@ultimat3/query": "29.3.0",
43
+ "@ultimat3/core": "29.3.2",
44
+ "@ultimat3/entity": "29.3.2",
45
+ "@ultimat3/policy": "29.3.2",
46
+ "@ultimat3/query": "29.3.2",
47
47
  "nats": "2.29.3"
48
48
  }
49
49
  }
@@ -17,6 +17,14 @@ import type {
17
17
  } from './sync-protocol';
18
18
  import { PROTOCOL_VERSION } from './wire-version';
19
19
 
20
+ /**
21
+ * Frames held behind a catch-up read, and applied seqs above a hole, at most: the node's own ring
22
+ * (`DEFAULT_CHANNEL_RING`, held equal by a test — not imported, that module is the server's). Past
23
+ * it the node could not replay them either, so the read answers instead: held, a read failing
24
+ * for an hour held every frame of the hour, and one lost frame grew `above` for the session.
25
+ */
26
+ export const CHANNEL_HELD_FRAMES = 256;
27
+
20
28
  /** What a browser needs of a `channel()` declaration: its name, its topic rule, its catch-up read. */
21
29
  export interface ChannelRef<K extends string = string> {
22
30
  readonly name: string;
@@ -144,6 +152,8 @@ export class ChannelBook {
144
152
  this.#byTopic.delete(topic);
145
153
  held.disarm?.();
146
154
  held.disarm = null;
155
+ // The rows this channel alone kept go with it; a row a reader still holds stays.
156
+ this.#deps.store.disown(sidOf(held));
147
157
  this.#deps.send(this.#frame(held, 'drop', false));
148
158
  };
149
159
  return {
@@ -210,7 +220,7 @@ export class ChannelBook {
210
220
  const entry = this.#byTopic.get(frame.channel);
211
221
  if (entry === undefined) return;
212
222
  if (entry.buffered !== null) {
213
- entry.buffered.push(frame);
223
+ this.#hold(entry, entry.buffered, [frame]);
214
224
  // The echo of a write the answered read waits on: it lands with the read, settling it there.
215
225
  if (frame.write !== undefined) entry.landing?.recheck();
216
226
  return;
@@ -267,7 +277,8 @@ export class ChannelBook {
267
277
  }
268
278
  const store = this.#deps.store;
269
279
  store.batch(() => {
270
- for (const [type, rows] of Object.entries(frame.adopt ?? {})) store.adopt(type, rows);
280
+ const owner = sidOf(entry);
281
+ for (const [type, rows] of Object.entries(frame.adopt ?? {})) store.adopt(type, rows, owner);
271
282
  for (const [type, keys] of Object.entries(frame.remove ?? {})) store.remove(type, keys);
272
283
  // This page's own write, echoed: settled in the same batch as its rows, so its twin is
273
284
  // never replayed over the truth that already holds it.
@@ -281,6 +292,12 @@ export class ChannelBook {
281
292
  let next = contiguous;
282
293
  while (entry.above.delete(next + 1)) next += 1;
283
294
  entry.contiguous = next;
295
+ // A hole the ring has moved past (a frame lost with no `replay-gap`): no replay can fill it, so
296
+ // the read does — and `above` stops growing for the rest of the session.
297
+ if (entry.above.size > CHANNEL_HELD_FRAMES) {
298
+ this.#catchUp(entry, []);
299
+ return;
300
+ }
284
301
  if (entry.state !== 'live') this.#set(entry, 'live');
285
302
  }
286
303
 
@@ -293,7 +310,7 @@ export class ChannelBook {
293
310
  */
294
311
  #catchUp(entry: Entry, pending: ChannelRecordsFrame[], epoch?: string): void {
295
312
  if (entry.buffered !== null) {
296
- entry.buffered.push(...pending);
313
+ this.#hold(entry, entry.buffered, pending);
297
314
  if (pending.length === 0) entry.again = { epoch: epoch ?? entry.again?.epoch };
298
315
  // No read in flight — the last one failed — so this gap is the retry, now.
299
316
  if (entry.waiting) this.#read(entry);
@@ -304,6 +321,18 @@ export class ChannelBook {
304
321
  this.#read(entry);
305
322
  }
306
323
 
324
+ /**
325
+ * Frames held behind the read, bounded by the ring: past it they are dropped and one more read
326
+ * follows, in the newest frame's epoch — the read answers what they carried.
327
+ */
328
+ #hold(entry: Entry, held: ChannelRecordsFrame[], frames: readonly ChannelRecordsFrame[]): void {
329
+ held.push(...frames);
330
+ if (held.length > CHANNEL_HELD_FRAMES) {
331
+ entry.again = { epoch: held.at(-1)?.epoch };
332
+ entry.buffered = [];
333
+ }
334
+ }
335
+
307
336
  /** The cursor, reset into `epoch`: every seq before the read is the read's to answer. */
308
337
  #restart(entry: Entry, epoch: string | null): void {
309
338
  entry.epoch = epoch;
@@ -380,7 +409,7 @@ export class ChannelBook {
380
409
  entry.again = null;
381
410
  // Its rows land now, alone: they are newer than the store's, and a re-read that fails would
382
411
  // otherwise leave the older ones for the whole retry curve. The frames wait for the re-read.
383
- this.#adopt(answered);
412
+ this.#adopt(entry, answered);
384
413
  entry.buffered = held.filter((frame) => frame.epoch === epoch);
385
414
  this.#restart(entry, epoch);
386
415
  this.#read(entry);
@@ -393,7 +422,7 @@ export class ChannelBook {
393
422
  // `#adopt` and `#apply` open batches of their own inside this one: `RecordStore.batch` nests,
394
423
  // so the read's rows and every held frame are still ONE notification.
395
424
  store.batch(() => {
396
- this.#adopt(answered);
425
+ this.#adopt(entry, answered);
397
426
  for (const frame of current) {
398
427
  if (entry.contiguous === null) entry.contiguous = frame.seq - 1;
399
428
  this.#apply(entry, frame);
@@ -403,11 +432,13 @@ export class ChannelBook {
403
432
  }
404
433
 
405
434
  /** A catch-up read's rows, unless the principal it was read for has left (`CatchUpRecords`). */
406
- #adopt(answered: CatchUpRecords | undefined): void {
435
+ #adopt(entry: Entry, answered: CatchUpRecords | undefined): void {
407
436
  if (answered === undefined || answered.current?.() === false) return;
408
437
  const store = this.#deps.store;
438
+ const owner = sidOf(entry);
409
439
  store.batch(() => {
410
- for (const [type, rows] of Object.entries(answered.records ?? {})) store.adopt(type, rows);
440
+ for (const [type, rows] of Object.entries(answered.records ?? {}))
441
+ store.adopt(type, rows, owner);
411
442
  for (const [type, keys] of Object.entries(answered.removed ?? {})) store.remove(type, keys);
412
443
  });
413
444
  }
package/src/index.ts CHANGED
@@ -139,6 +139,7 @@ export { hasPageSocket, type SyncTarget } from './page-store';
139
139
  export { installRealtime, type RealtimeInstall } from './reactivity';
140
140
  export { persistedTypes, type RecordPersister, recordPersister } from './record-persister';
141
141
  export {
142
+ DEFAULT_MAX_ROWS_PER_TYPE,
142
143
  type RecordKey,
143
144
  type RecordListener,
144
145
  RecordStore,
@@ -103,10 +103,14 @@ const LIVE_READER_ID = 'live-window';
103
103
  *
104
104
  * `tenant === null` runs under the node's own actor, which has no org: a tenant-scoped read is
105
105
  * then `X_TENANCY_ACTOR_ORG_REQUIRED`, the verdict the same anonymous read gets over HTTP.
106
+ *
107
+ * EVERY read runs in a fresh child context, the tenant-less one included. The node's `ctx` lives
108
+ * as long as the process, and per-request state is keyed off the context object (`@ultimat3/cache`'s
109
+ * request memo is a `WeakMap` on it): run straight in it, every tenant-less read memoised into one
110
+ * store that never died — stale answers served to every later read, and a heap that only grew.
106
111
  */
107
112
  function readFor<T>(ctx: Ctx, tenant: string | null, read: () => Promise<T>): Promise<T> {
108
- if (tenant === null) return runWithContext(ctx, read);
109
- const actor = serviceActor({ id: LIVE_READER_ID, orgId: tenant });
113
+ const actor = tenant === null ? undefined : serviceActor({ id: LIVE_READER_ID, orgId: tenant });
110
114
  return runWithContext(ctx, () => withChildContext({ actor }, read));
111
115
  }
112
116
 
@@ -4,7 +4,7 @@
4
4
  // core's `RecordSink`, so `clientTransport` adopts into it without importing this package.
5
5
 
6
6
  import type { ConflictPolicy, RecordRows, RecordSink, Row } from '@ultimat3/core/page';
7
- import { resolveConflict } from '@ultimat3/core/page';
7
+ import { finiteCount, resolveConflict } from '@ultimat3/core/page';
8
8
  import { RebaseConflictError, RecordRejectedError } from './page-errors';
9
9
  import { ServerWait } from './record-await';
10
10
  import { isRow, type RecordKey, recordKey } from './record-key';
@@ -28,8 +28,26 @@ export interface RecordStoreOptions {
28
28
  readonly schedule?: Scheduler;
29
29
  /** How long an overlay waits for the server's row before the synced layer stands. */
30
30
  readonly awaitMs?: number;
31
+ /**
32
+ * The per-type cap: rows of one type the store keeps at most. Past it the least recently written
33
+ * row no key reader holds is evicted (`SyncedLayer.trim`) — a backstop for a channel joined for
34
+ * hours, not a view limit. Default `DEFAULT_MAX_ROWS_PER_TYPE`; `Infinity` turns it off.
35
+ */
36
+ readonly maxRowsPerType?: number;
37
+ /** The cap for the types named here, over `maxRowsPerType` — a log type that runs long, say. */
38
+ readonly maxRowsByType?: Readonly<Record<string, number>>;
31
39
  }
32
40
 
41
+ /**
42
+ * The per-type cap's default. A backstop against hours of a fleet feed: well above what a page
43
+ * shows of one type (a run's 2 000 log lines): a whole-type reader loses no row it shows while its
44
+ * type stays under its cap (past it the least recently written unheld rows go — name a bigger
45
+ * cap for that type in `maxRowsByType`).
46
+ */
47
+ export const DEFAULT_MAX_ROWS_PER_TYPE = 10_000;
48
+
49
+ const MAX = Number.MAX_SAFE_INTEGER;
50
+
33
51
  export class RecordStore implements RecordSink {
34
52
  readonly #synced = new SyncedLayer();
35
53
  /** The overlay's answer per key: a row, or `null` for a record the overlay deleted. */
@@ -48,9 +66,21 @@ export class RecordStore implements RecordSink {
48
66
  readonly #names = new WriteNames();
49
67
  /** Server truth held behind this page's writes in flight (`afterWrites`). */
50
68
  readonly #landings: Landings;
69
+ /** The per-type cap: every type's, then each type `maxRowsByType` names. */
70
+ readonly #cap: number;
71
+ readonly #caps = new Map<string, number>();
51
72
 
52
73
  constructor(options: RecordStoreOptions = {}) {
53
74
  this.#report = options.report ?? ((error) => console.error(error));
75
+ // `Infinity` is "no cap": the largest count there is. `NaN` stays `NaN`, and is refused.
76
+ const maxRowsPerType = options.maxRowsPerType ?? DEFAULT_MAX_ROWS_PER_TYPE;
77
+ this.#cap = finiteCount('RecordStore', 'maxRowsPerType', Math.min(maxRowsPerType, MAX), 1);
78
+ for (const [type, cap] of Object.entries(options.maxRowsByType ?? {})) {
79
+ this.#caps.set(
80
+ type,
81
+ finiteCount('RecordStore', `maxRowsByType.${type}`, Math.min(cap, MAX), 1),
82
+ );
83
+ }
54
84
  this.#wait = new ServerWait(options.schedule, options.awaitMs);
55
85
  this.#landings = new Landings(
56
86
  (fire) => this.#wait.bound(fire),
@@ -90,8 +120,9 @@ export class RecordStore implements RecordSink {
90
120
  /**
91
121
  * `RecordSink.adopt`: server truth, keyed by the server (it holds the entity's primary key; the
92
122
  * browser does not). Merged column by column. A structurally bad row is dropped, never merged.
123
+ * `owner` is the joined channel whose frame carried them: it keeps them until `disown`.
93
124
  */
94
- adopt(type: string, rows: RecordRows): void {
125
+ adopt(type: string, rows: RecordRows, owner?: string): void {
95
126
  this.batch(() => {
96
127
  for (const [key, row] of Object.entries(rows)) {
97
128
  if (!isRow(row) || key === '') {
@@ -104,10 +135,18 @@ export class RecordStore implements RecordSink {
104
135
  continue;
105
136
  }
106
137
  this.merge(type, key, row);
138
+ if (owner) this.#synced.own(recordKey(type, key), owner);
107
139
  }
108
140
  });
109
141
  }
110
142
 
143
+ /** The channel `owner` was dropped: the rows only it kept are evicted (`SyncedLayer.disown`). */
144
+ disown(owner: string): void {
145
+ this.batch(() => {
146
+ for (const rk of this.#synced.disown(owner)) this.#touchSynced(rk);
147
+ });
148
+ }
149
+
111
150
  /** `RecordSink.remove`: the server says these records are gone, for every holder at once. */
112
151
  remove(type: string, keys: readonly string[]): void {
113
152
  this.batch(() => {
@@ -159,7 +198,7 @@ export class RecordStore implements RecordSink {
159
198
  this.#synced.retain(recordKey(type, key));
160
199
  }
161
200
 
162
- /** The last holder leaving evicts the synced record: an infinite scroll must not keep every row. */
201
+ /** The last holder leaving evicts the synced record — unless a joined channel still keeps it. */
163
202
  release(type: string, key: string): void {
164
203
  const rk = recordKey(type, key);
165
204
  if (this.#synced.release(rk)) this.#touchSynced(rk);
@@ -321,6 +360,7 @@ export class RecordStore implements RecordSink {
321
360
  try {
322
361
  return fn();
323
362
  } finally {
363
+ this.#trim();
324
364
  // Synced truth moved under pending writes: replay them once, at the end, over the new truth.
325
365
  if (this.#syncedMoved) {
326
366
  this.#syncedMoved = false;
@@ -405,6 +445,15 @@ export class RecordStore implements RecordSink {
405
445
  return this.#overlays.delete(key);
406
446
  }
407
447
 
448
+ /** The per-type cap, at the close of each batch — never over a row a pending write touched. */
449
+ #trim(): void {
450
+ const evicted = this.#synced.trim(
451
+ (type) => this.#caps.get(type) ?? this.#cap,
452
+ (rk) => this.#view.has(rk) || [...this.#touched.values()].some((keys) => keys.has(rk)),
453
+ );
454
+ for (const rk of evicted) this.#touchSynced(rk);
455
+ }
456
+
408
457
  #touchSynced(rk: RecordKey): void {
409
458
  this.#syncedMoved = true;
410
459
  this.#moved.add(rk);
@@ -1,6 +1,7 @@
1
1
  // The store's SYNCED layer: server truth per `type:key`, merged column by column, with the holds
2
- // that evict a record nobody renders and the provisional mark on rows restored from disk. Pure
3
- // state — it answers what moved, and the store decides who hears about it.
2
+ // that evict a record nobody renders, the channels that keep what they adopted, the per-type cap,
3
+ // and the provisional mark on rows restored from disk. Pure state — it answers what moved, and the
4
+ // store decides who hears about it.
4
5
 
5
6
  import type { Row } from '@ultimat3/core/page';
6
7
  import { sameJson } from './json';
@@ -25,6 +26,14 @@ export class SyncedLayer {
25
26
  * a colon (`?query:<name>`, an unnamed live query's rows), so `type:key` splits two ways.
26
27
  */
27
28
  readonly #typeOfKey = new Map<RecordKey, string>();
29
+ /**
30
+ * Per key, the owners (a joined channel's sid) whose frames adopted it: each keeps the row like a
31
+ * key holder until it leaves, or the server removes the row. Only rows that are here: a removed
32
+ * row leaves it, so a feed joined for hours holds no key it no longer has.
33
+ */
34
+ readonly #owners = new Map<RecordKey, Set<string>>();
35
+ /** Per type, the writes since the last `trim` — its newest rows, which no reader could hold yet. */
36
+ readonly #written = new Map<string, Set<RecordKey>>();
28
37
 
29
38
  get(rk: RecordKey): Row | undefined {
30
39
  return this.#rows.get(rk);
@@ -101,6 +110,7 @@ export class SyncedLayer {
101
110
  /** Answers whether a row was there to remove. */
102
111
  remove(rk: RecordKey): boolean {
103
112
  this.#provisional.delete(rk);
113
+ this.#owners.delete(rk);
104
114
  // Gone is no longer spared: a row the server sends again later is a new arrival, not this one.
105
115
  const type = this.#typeOfKey.get(rk) ?? typeOf(rk);
106
116
  this.#typeOfKey.delete(rk);
@@ -127,14 +137,59 @@ export class SyncedLayer {
127
137
  return false;
128
138
  }
129
139
  this.#holds.delete(rk);
130
- const type = this.#typeOfKey.get(rk) ?? typeOf(rk);
131
- if (this.#typeHolds.has(type)) {
132
- const spared = this.#spared.get(type) ?? new Set<RecordKey>();
133
- spared.add(rk);
134
- this.#spared.set(type, spared);
135
- return false;
140
+ // A channel that adopted it still keeps it: it goes when that channel leaves.
141
+ return !this.#owners.has(rk) && this.#evict(rk);
142
+ }
143
+
144
+ /** `owner` (a joined channel) adopted this row, just merged: it stays until the owner leaves. */
145
+ own(rk: RecordKey, owner: string): void {
146
+ setIn(this.#owners, rk).add(owner);
147
+ }
148
+
149
+ /**
150
+ * The owner left (its channel was dropped): every row it alone kept goes — exactly as a last key
151
+ * holder's release would, so a reader of the whole type still spares it. Answers the rows that went.
152
+ */
153
+ disown(owner: string): readonly RecordKey[] {
154
+ const evicted: RecordKey[] = [];
155
+ // A walk of the kept rows, not an index per owner: a drop is rare, and an index is bytes in
156
+ // every page's boot. Deleting the entry being visited is safe while iterating a Map.
157
+ for (const [rk, owners] of this.#owners) {
158
+ if (!owners.delete(owner) || owners.size > 0) continue;
159
+ this.#owners.delete(rk);
160
+ if (!this.#holds.has(rk) && this.#evict(rk)) evicted.push(rk);
136
161
  }
137
- return this.remove(rk);
162
+ return evicted;
163
+ }
164
+
165
+ /**
166
+ * The per-type cap: each type that grew since the last trim is cut back to its cap, least
167
+ * recently written first. Never a row a key reader holds, never one `pinned` (a pending write's),
168
+ * never one written since the last trim (an HTTP answer's rows, before its reader holds them).
169
+ * A channel's ownership and a whole-type hold do NOT protect a row here: a fleet feed
170
+ * joined for hours, read by `useRecords(type)`, is exactly what the cap exists to bound.
171
+ */
172
+ trim(capOf: (type: string) => number, pinned: (rk: RecordKey) => boolean): readonly RecordKey[] {
173
+ const evicted: RecordKey[] = [];
174
+ for (const [type, written] of this.#written) {
175
+ const keys = this.#byType.get(type) ?? new Set<RecordKey>();
176
+ let over = keys.size - capOf(type);
177
+ // Oldest first: `#put` moves a row to the end of its type on every write, so the DISTINCT
178
+ // rows written since the last trim that are still there are the last ones — never reached
179
+ // (a row written twice counts once; one removed since is no longer at the end). Deleting the
180
+ // entry being visited is safe while iterating a Set.
181
+ let fresh = 0;
182
+ for (const rk of written) if (keys.has(rk)) fresh += 1;
183
+ let older = keys.size - fresh;
184
+ for (const rk of keys) {
185
+ if (over <= 0 || older-- <= 0) break;
186
+ if (this.#holds.has(rk) || pinned(rk) || !this.remove(rk)) continue;
187
+ evicted.push(rk);
188
+ over -= 1;
189
+ }
190
+ }
191
+ this.#written.clear();
192
+ return evicted;
138
193
  }
139
194
 
140
195
  retainType(type: string): void {
@@ -157,12 +212,14 @@ export class SyncedLayer {
157
212
  this.#spared.delete(type);
158
213
  const evicted: RecordKey[] = [];
159
214
  for (const rk of spared ?? []) {
160
- if (!this.#holds.has(rk) && this.remove(rk)) evicted.push(rk);
215
+ if (!this.#holds.has(rk) && !this.#owners.has(rk) && this.remove(rk)) evicted.push(rk);
161
216
  }
162
217
  return evicted;
163
218
  }
164
219
 
165
220
  clear(): void {
221
+ this.#owners.clear();
222
+ this.#written.clear();
166
223
  this.#rows.clear();
167
224
  this.#byType.clear();
168
225
  this.#typeOfKey.clear();
@@ -170,16 +227,37 @@ export class SyncedLayer {
170
227
  this.#spared.clear();
171
228
  }
172
229
 
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);
230
+ /** A row nobody holds goes — unless a reader holds its whole type, which spares it till it leaves. */
231
+ #evict(rk: RecordKey): boolean {
232
+ const type = this.#typeOfKey.get(rk) ?? typeOf(rk);
233
+ if (this.#typeHolds.has(type)) {
234
+ setIn(this.#spared, type).add(rk);
235
+ return false;
179
236
  }
237
+ return this.remove(rk);
238
+ }
239
+
240
+ #put(rk: RecordKey, row: Row, named: string): void {
241
+ const type = this.#typeOfKey.get(rk) ?? named;
242
+ this.#typeOfKey.set(rk, type);
243
+ // Re-inserted on every write: a type's index is also its recency order, oldest first.
244
+ const keys = setIn(this.#byType, type);
245
+ keys.delete(rk);
246
+ keys.add(rk);
247
+ setIn(this.#written, type).add(rk);
180
248
  this.#rows.set(rk, row);
181
249
  }
182
250
  }
183
251
 
184
252
  /** A key's type when no writer named it (a hold on a row not yet there): before its first `:`. */
185
253
  const typeOf = (rk: RecordKey): string => rk.slice(0, rk.indexOf(':'));
254
+
255
+ /** The set `map` holds under `key`, made on first use. */
256
+ function setIn<K, V>(map: Map<K, Set<V>>, key: K): Set<V> {
257
+ let set = map.get(key);
258
+ if (set === undefined) {
259
+ set = new Set();
260
+ map.set(key, set);
261
+ }
262
+ return set;
263
+ }