@ultimat3/realtime 29.3.1 → 29.4.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 +2 -1
- package/README.md +7 -2
- package/package.json +5 -5
- package/src/client-channels.ts +38 -7
- package/src/index.ts +1 -0
- package/src/live-definition.ts +6 -2
- package/src/record-store.ts +52 -3
- package/src/record-synced.ts +94 -16
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
|
|
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
|
|
870
|
-
lives
|
|
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
|
+
"version": "29.4.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.
|
|
44
|
-
"@ultimat3/entity": "29.
|
|
45
|
-
"@ultimat3/policy": "29.
|
|
46
|
-
"@ultimat3/query": "29.
|
|
43
|
+
"@ultimat3/core": "29.4.0",
|
|
44
|
+
"@ultimat3/entity": "29.4.0",
|
|
45
|
+
"@ultimat3/policy": "29.4.0",
|
|
46
|
+
"@ultimat3/query": "29.4.0",
|
|
47
47
|
"nats": "2.29.3"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/src/client-channels.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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 ?? {}))
|
|
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,
|
package/src/live-definition.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/record-store.ts
CHANGED
|
@@ -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
|
|
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);
|
package/src/record-synced.ts
CHANGED
|
@@ -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
|
|
3
|
-
// state — it answers what moved, and the
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
}
|