Commit a802a95814e6
tarball8 files changed+405-35
@@ -2,6 +2,16 @@ import type { TimelineItem } from './types';
22
33
export const FEDERATION_LAG_MS = 60 * 1000;44
5
+const decorated = new WeakMap<TimelineItem, { lag: number; item: TimelineItem }>();6
+7
+function withLag(item: TimelineItem & { kind: 'event' }, lag: number): TimelineItem {8
+ const hit = decorated.get(item);9
+ if (hit && hit.lag === lag) return hit.item;10
+ const next: TimelineItem = { ...item, event: { ...item.event, delivery_lag_ms: lag } };11
+ decorated.set(item, { lag, item: next });12
+ return next;13
+}14
+515
/**616
* Carries delivery lag forward from the previous list and measures it for events appended717
* after the last one already known. `since` is when the room last became able to receive@@ -16,15 +26,16 @@ export function observeArrivals(previous: TimelineItem[], items: TimelineItem[],
1626
if (item.kind === 'event' && lags.has(item.event.item_id)) lastKnown = index;1727
});1828
if (lastKnown === -1) return null;19
- return items.map((item, index) => {29
+ const next = items.map((item, index) => {2030
if (item.kind !== 'event') return item;2131
const event = item.event;2232
if (lags.has(event.item_id)) {2333
const carried = lags.get(event.item_id);24
- return carried === undefined ? item : { ...item, event: { ...event, delivery_lag_ms: carried } };34
+ return carried === undefined ? item : withLag(item, carried);2535
}2636
if (index < lastKnown || event.own) return item;2737
const lag = now - Math.max(event.timestamp_ms, since);28
- return lag < FEDERATION_LAG_MS ? item : { ...item, event: { ...event, delivery_lag_ms: lag } };38
+ return lag < FEDERATION_LAG_MS ? item : withLag(item, lag);2939
});40
+ return previous.length === next.length && next.every((item, index) => item === previous[index]) ? previous : next;3041
}@@ -0,0 +1,63 @@
1
+interface Entry<T> {2
+ signature: string;3
+ item: T;4
+}5
+6
+export interface ShareCache<T> {7
+ byKey: Map<string, Entry<T>>;8
+ list: T[];9
+}10
+11
+export function newShareCache<T>(): ShareCache<T> {12
+ return { byKey: new Map(), list: [] };13
+}14
+15
+function signature(text: string): string {16
+ let a = 0x811c9dc5;17
+ let b = 0x9747b28c;18
+ for (let i = 0; i < text.length; i += 1) {19
+ const c = text.charCodeAt(i);20
+ a = Math.imul(a ^ c, 0x01000193);21
+ b = Math.imul(b + c, 0x85ebca6b) ^ (b >>> 13);22
+ }23
+ return `${text.length}.${(a >>> 0).toString(36)}.${(b >>> 0).toString(36)}`;24
+}25
+26
+/**27
+ * Parses `raw` while reusing the objects from the previous call whose serialised form is unchanged.28
+ * Entries are matched by `identify` and compared by a hash of their JSON, so only the latest list is retained.29
+ * Returns the previous array itself when every element was reused and the length matches.30
+ */31
+export function parseShared<T>(32
+ cache: ShareCache<T>,33
+ raw: unknown,34
+ parse: (entry: unknown) => T | null,35
+ identify: (entry: unknown) => string | null = () => null36
+): T[] {37
+ if (!Array.isArray(raw)) {38
+ cache.byKey = new Map();39
+ cache.list = cache.list.length ? [] : cache.list;40
+ return cache.list;41
+ }42
+ const byKey = new Map<string, Entry<T>>();43
+ const out: T[] = [];44
+ for (const entry of raw) {45
+ const sig = signature(JSON.stringify(entry));46
+ const base = identify(entry) ?? `~${sig}`;47
+ let key = base;48
+ for (let n = 1; byKey.has(key); n += 1) key = `${base}#${n}`;49
+ const previous = cache.byKey.get(key);50
+ const item = previous && previous.signature === sig ? previous.item : parse(entry);51
+ if (item === null) continue;52
+ byKey.set(key, { signature: sig, item });53
+ out.push(item);54
+ }55
+ cache.byKey = byKey;56
+ if (out.length === cache.list.length && out.every((item, index) => item === cache.list[index])) return cache.list;57
+ cache.list = out;58
+ return out;59
+}60
+61
+export function sameList<T>(a: readonly T[], b: readonly T[]): boolean {62
+ return a === b || (a.length === b.length && a.every((item, index) => item === b[index]));63
+}@@ -1,10 +1,13 @@
11
import { get, writable } from 'svelte/store';2
+import type { Writable } from 'svelte/store';23
import { parseMentionsPage, parseMentionsUpdate } from './mentions-model';34
import type { MentionsPage } from './mentions-model';45
import { getSession, subscribeEvents } from './api';56
import type { SoftLogout } from './api';67
import { isRecord } from './guards';78
import { observeArrivals } from './arrivals';9
+import { newShareCache, parseShared, sameList } from './sharing';10
+import type { ShareCache } from './sharing';811
import { loadKeywords, resetKeywords } from './keywords';912
import { resetLinkPreviews } from './link-previews';1013
import { resetQuickReactions } from './quick-reactions';@@ -17,8 +20,9 @@ import {
1720
parseRecentHistoryMap,1821
parseThreadListEntries,1922
parseRecentHistoryState,20
- parseRoomList,21
- parseTimelineList,23
+ parseRoomSummary,24
+ identifyTimelineItem,25
+ parseTimelineItem,2226
parseVerificationState2327
} from './types';2428
import type {@@ -103,7 +107,28 @@ export function dismissBanner(id: number): void {
103107
banners.update((list) => list.filter((b) => b.id !== id));104108
}105109
110
+const timelineCaches = new Map<string, ShareCache<TimelineItem>>();111
+const pinnedCaches = new Map<string, ShareCache<TimelineItem>>();112
+const threadCaches = new Map<string, ShareCache<TimelineItem>>();113
+const roomsCache = newShareCache<RoomSummary>();114
+115
+function parseCached(caches: Map<string, ShareCache<TimelineItem>>, key: string, raw: unknown): TimelineItem[] {116
+ let cache = caches.get(key);117
+ if (!cache) caches.set(key, (cache = newShareCache()));118
+ return parseShared(cache, raw, parseTimelineItem, identifyTimelineItem);119
+}120
+121
+function setKeyed<T extends unknown[]>(store: Writable<Record<string, T>>, key: string, value: T): void {122
+ const all = get(store);123
+ if (all[key] !== value) store.set({ ...all, [key]: value });124
+}125
+106126
export function resetClientState(): void {127
+ timelineCaches.clear();128
+ pinnedCaches.clear();129
+ threadCaches.clear();130
+ roomsCache.byKey = new Map();131
+ roomsCache.list = [];107132
mentionsSnapshot.set(null);108133
rooms.set([]);109134
timelines.set({});@@ -168,12 +193,17 @@ function withArrivals(roomId: string, previous: TimelineItem[], items: TimelineI
168193
169194
function applyTimelinePayload(roomId: string, payload: Record<string, unknown>): void {170195
if (!Array.isArray(payload['items'])) return;171
- const items = parseTimelineList(payload['items']);172
- timelines.update((all) => ({ ...all, [roomId]: withArrivals(roomId, all[roomId] ?? [], items) }));196
+ const items = parseCached(timelineCaches, roomId, payload['items']);197
+ setKeyed(timelines, roomId, withArrivals(roomId, get(timelines)[roomId] ?? [], items));198
+}199
+200
+function identifyRoom(raw: unknown): string | null {201
+ return isRecord(raw) && typeof raw['room_id'] === 'string' ? raw['room_id'] : null;173202
}174203
175204
function setRooms(raw: unknown): void {176
- rooms.set(parseRoomList(raw));205
+ const next = parseShared(roomsCache, raw, parseRoomSummary, identifyRoom);206
+ if (!sameList(get(rooms), next)) rooms.set(next);177207
}178208
179209
function handlePayload(text: string): void {@@ -196,7 +226,7 @@ function handlePayload(text: string): void {
196226
timelines.update((all) => {197227
const next = { ...all };198228
for (const [roomId, list] of Object.entries(rawTimelines)) {199
- next[roomId] = withArrivals(roomId, all[roomId] ?? [], parseTimelineList(list));229
+ next[roomId] = withArrivals(roomId, all[roomId] ?? [], parseCached(timelineCaches, roomId, list));200230
}201231
return next;202232
});@@ -205,7 +235,8 @@ function handlePayload(text: string): void {
205235
const next: Record<string, TimelineItem[]> = {};206236
for (const entry of data['threads']) {207237
if (!isRecord(entry) || typeof entry['room_id'] !== 'string' || typeof entry['root_event_id'] !== 'string') continue;208
- next[threadKey(entry['room_id'], entry['root_event_id'])] = parseTimelineList(entry['items']);238
+ const key = threadKey(entry['room_id'], entry['root_event_id']);239
+ next[key] = parseCached(threadCaches, key, entry['items']);209240
}210241
threadTimelines.set(next);211242
}@@ -213,7 +244,7 @@ function handlePayload(text: string): void {
213244
const rawPinned = data['pinned'];214245
if (isRecord(rawPinned)) {215246
const next: Record<string, TimelineItem[]> = {};216
- for (const [roomId, list] of Object.entries(rawPinned)) next[roomId] = parseTimelineList(list);247
+ for (const [roomId, list] of Object.entries(rawPinned)) next[roomId] = parseCached(pinnedCaches, roomId, list);217248
pinnedTimelines.set(next);218249
}219250
const snapshotEncryption = parseEncryptionState(data['encryption']);@@ -251,11 +282,20 @@ function handlePayload(text: string): void {
251282
if (typeof roomId === 'string') applyTimelinePayload(roomId, data);252283
return;253284
}285
+ if (kind === 'timeline_closed') {286
+ const roomId = data['room_id'];287
+ if (typeof roomId !== 'string') return;288
+ timelineCaches.delete(roomId);289
+ timelines.update((all) => {290
+ const { [roomId]: _closed, ...rest } = all;291
+ return rest;292
+ });293
+ return;294
+ }254295
if (kind === 'pinned') {255296
const roomId = data['room_id'];256297
if (typeof roomId === 'string' && Array.isArray(data['items'])) {257
- const items = parseTimelineList(data['items']);258
- pinnedTimelines.update((all) => ({ ...all, [roomId]: items }));298
+ setKeyed(pinnedTimelines, roomId, parseCached(pinnedCaches, roomId, data['items']));259299
}260300
return;261301
}@@ -278,9 +318,9 @@ function handlePayload(text: string): void {
278318
if (typeof roomId !== 'string' || typeof root !== 'string') return;279319
const key = threadKey(roomId, root);280320
if (kind === 'thread') {281
- const items = parseTimelineList(data['items']);282
- threadTimelines.update((all) => ({ ...all, [key]: items }));321
+ setKeyed(threadTimelines, key, parseCached(threadCaches, key, data['items']));283322
} else {323
+ threadCaches.delete(key);284324
threadTimelines.update((all) => {285325
const { [key]: _closed, ...rest } = all;286326
return rest;@@ -291,6 +331,7 @@ function handlePayload(text: string): void {
291331
if (kind === 'pinned_closed') {292332
const roomId = data['room_id'];293333
if (typeof roomId === 'string') {334
+ pinnedCaches.delete(roomId);294335
pinnedTimelineVersions.update((all) => ({ ...all, [roomId]: (all[roomId] ?? 0) + 1 }));295336
pinnedTimelines.update((all) => {296337
const { [roomId]: _closed, ...rest } = all;@@ -1,8 +1,9 @@
1
-import { derived, get, writable } from 'svelte/store';1
+import { derived, get, readable, writable } from 'svelte/store';22
import { closeThreadList, getAccountData, openThreadList, paginateThreadList, runCommand, setAccountData } from './api';33
import { pushBanner, rooms, sessionUser, threadLists, timelines } from './store';44
import { roomThreads } from './thread-index';55
import type { ThreadEntry } from './thread-index';6
+import type { ThreadListEntry, ThreadUnread, TimelineItem } from './types';67
78
const PINNED_TYPE = 'dev.malice.pinned_threads';89
@@ -15,16 +16,56 @@ export interface OpenThread {
1516
export const openThread = writable<OpenThread | null>(null);1617
export const pinnedThreads = writable<Record<string, string[]>>({});1718
18
-export const threadsByRoom = derived([timelines, pinnedThreads, threadLists, rooms], ([$timelines, $pinned, $lists, $rooms]) => {19
- const unreads = new Map($rooms.map((room) => [room.room_id, room.thread_unreads]));20
- const out: Record<string, ThreadEntry[]> = {};21
- const ids = [...Object.keys($timelines), ...Object.keys($pinned), ...Object.keys($lists), ...unreads.keys()];22
- for (const roomId of new Set(ids)) {23
- const list = roomThreads(roomId, $timelines[roomId] ?? [], $pinned[roomId] ?? [], $lists[roomId]?.entries ?? [], unreads.get(roomId) ?? {});24
- if (list.length) out[roomId] = list;25
- }26
- return out;27
-});19
+const NO_TIMELINE: TimelineItem[] = [];20
+const NO_PINS: string[] = [];21
+const NO_ENTRIES: ThreadListEntry[] = [];22
+const NO_UNREADS: Record<string, ThreadUnread> = {};23
+24
+interface RoomThreadsCache {25
+ timeline: TimelineItem[];26
+ pins: string[];27
+ entries: ThreadListEntry[];28
+ unreads: Record<string, ThreadUnread>;29
+ list: ThreadEntry[];30
+}31
+32
+function createThreadsByRoom() {33
+ let cache = new Map<string, RoomThreadsCache>();34
+ let last: Record<string, ThreadEntry[]> = {};35
+ const computed = derived([timelines, pinnedThreads, threadLists, rooms], ([$timelines, $pinned, $lists, $rooms]) => {36
+ const unreads = new Map($rooms.map((room) => [room.room_id, room.thread_unreads]));37
+ const next = new Map<string, RoomThreadsCache>();38
+ const out: Record<string, ThreadEntry[]> = {};39
+ const ids = [...Object.keys($timelines), ...Object.keys($pinned), ...Object.keys($lists), ...unreads.keys()];40
+ for (const roomId of new Set(ids)) {41
+ const timeline = $timelines[roomId] ?? NO_TIMELINE;42
+ const pins = $pinned[roomId] ?? NO_PINS;43
+ const entries = $lists[roomId]?.entries ?? NO_ENTRIES;44
+ const roomUnreads = unreads.get(roomId) ?? NO_UNREADS;45
+ const hit = cache.get(roomId);46
+ const reuse = hit && hit.timeline === timeline && hit.pins === pins && hit.entries === entries && hit.unreads === roomUnreads;47
+ const entry = reuse ? hit : { timeline, pins, entries, unreads: roomUnreads, list: roomThreads(roomId, timeline, pins, entries, roomUnreads) };48
+ next.set(roomId, entry);49
+ if (entry.list.length) out[roomId] = entry.list;50
+ }51
+ const keys = Object.keys(out);52
+ const unchanged = keys.length === Object.keys(last).length && keys.every((roomId) => out[roomId] === last[roomId]);53
+ cache = next;54
+ if (unchanged) return last;55
+ last = out;56
+ return out;57
+ });58
+ return readable(last, (set) => {59
+ let sent: Record<string, ThreadEntry[]> | null = null;60
+ return computed.subscribe((value) => {61
+ if (value === sent) return;62
+ sent = value;63
+ set(value);64
+ });65
+ });66
+}67
+68
+export const threadsByRoom = createThreadsByRoom();2869
2970
const THREAD_PAGE = 20;3071
const openLists = new Set<string>();@@ -0,0 +1,52 @@
1
+const STRING = '"(?:[^"\\\\]|\\\\.)*"';2
+const REPLACEMENT = new RegExp(`^\\{"type":"(rooms|timeline|pinned|thread|thread_list)"(?:,"room_id":(${STRING}))?(?:,"root_event_id":(${STRING}))?`);3
+4
+/** Serialised key for events that replace the full state of one room, thread or list, or null for anything else. */5
+export function replacementKey(event: string): string | null {6
+ const match = REPLACEMENT.exec(event);7
+ if (!match) return null;8
+ const [, type, room, root] = match;9
+ if (type === 'rooms') return type;10
+ if (room === undefined) return null;11
+ if (type === 'thread') return root === undefined ? null : `${type}:${room}:${root}`;12
+ return `${type}:${room}`;13
+}14
+15
+/**16
+ * Buffers full-state events so a burst delivers only the latest per key. Anything else flushes17
+ * the buffer first, so deltas and lifecycle events keep their order relative to the buffered ones.18
+ */19
+export class EventCoalescer {20
+ private pending = new Map<string, { account: string; event: string }>();21
+ private scheduled = false;22
+23
+ constructor(24
+ private deliver: (account: string, event: string) => void,25
+ private schedule: (flush: () => void) => void = (flush) => void setTimeout(flush, 0)26
+ ) {}27
+28
+ push(account: string, event: string): void {29
+ const key = replacementKey(event);30
+ if (key === null) {31
+ this.flush();32
+ this.deliver(account, event);33
+ return;34
+ }35
+ const slot = `${account}\n${key}`;36
+ this.pending.delete(slot);37
+ this.pending.set(slot, { account, event });38
+ if (this.scheduled) return;39
+ this.scheduled = true;40
+ this.schedule(() => {41
+ this.scheduled = false;42
+ this.flush();43
+ });44
+ }45
+46
+ flush(): void {47
+ if (this.pending.size === 0) return;48
+ const batch = [...this.pending.values()];49
+ this.pending.clear();50
+ for (const { account, event } of batch) this.deliver(account, event);51
+ }52
+}@@ -1,5 +1,6 @@
11
import init, { Host } from './pkg/malice_worker.js';22
import { isRecord } from '../lib/guards';3
+import { EventCoalescer } from './coalesce';34
import {45
createAccount,56
deleteAccount,@@ -48,6 +49,8 @@ function broadcastEvent(account: string, event: string): void {
4849
for (const port of subscriptions.get(account) ?? []) postTo(port, { kind: 'event', account, event });4950
}5051
52
+const coalescer = new EventCoalescer(broadcastEvent);53
+5154
function toWireError(error: unknown): WireError {5255
if (isRecord(error) && typeof error['kind'] === 'string') {5356
return { kind: error['kind'], message: typeof error['message'] === 'string' ? error['message'] : 'unknown error' };@@ -76,21 +79,24 @@ function onSessionEnded(account: string, retain: unknown): void {
7679
} else if (identity) {7780
softAccounts.set(account, identity);7881
}82
+ coalescer.flush();7983
broadcastEvent(account, JSON.stringify({ type: 'session_ended', retain }));8084
}8185
86
+const SESSION_ENDED_PREFIX = '{"type":"session_ended"';87
+8288
function onHostEvent(account: string, eventJson: string): void {83
- let parsed: unknown;84
- try {85
- parsed = JSON.parse(eventJson);86
- } catch {87
- return;88
- }89
- if (isRecord(parsed) && parsed['type'] === 'session_ended') {90
- onSessionEnded(account, parsed['retain']);89
+ if (eventJson.startsWith(SESSION_ENDED_PREFIX)) {90
+ let retain: unknown;91
+ try {92
+ retain = (JSON.parse(eventJson) as { retain?: unknown }).retain;93
+ } catch {94
+ return;95
+ }96
+ onSessionEnded(account, retain);9197
return;9298
}93
- broadcastEvent(account, eventJson);99
+ coalescer.push(account, eventJson);94100
}95101
96102
function onHostFatal(message: string): void {@@ -183,6 +189,7 @@ async function handleCall(account: string, request: unknown, bytes?: ArrayBuffer
183189
}184190
185191
function handleSubscribe(port: PortLike, account: string): void {192
+ coalescer.flush();186193
let set = subscriptions.get(account);187194
if (!set) {188195
set = new Set();@@ -196,10 +203,12 @@ function handleSubscribe(port: PortLike, account: string): void {
196203
}197204
198205
function handleUnsubscribe(port: PortLike, account: string): void {206
+ coalescer.flush();199207
subscriptions.get(account)?.delete(port);200208
}201209
202210
function dropPort(port: PortLike): void {211
+ coalescer.flush();203212
ports.delete(port);204213
for (const set of subscriptions.values()) set.delete(port);205214
}@@ -210,9 +219,11 @@ async function dispatch(port: PortLike, data: unknown): Promise<void> {
210219
const kind = data['kind'];211220
const id = data['id'];212221
const reply = (ok: unknown) => {222
+ coalescer.flush();213223
if (typeof id === 'number') postTo(port, { kind: 'reply', id, ok });214224
};215225
const fail = (error: unknown) => {226
+ coalescer.flush();216227
if (typeof id === 'number') postTo(port, { kind: 'reply', id, error: toWireError(error) });217228
};218229
@@ -0,0 +1,71 @@
1
+import assert from 'node:assert/strict';2
+import { test } from 'node:test';3
+import { EventCoalescer, replacementKey } from '../src/worker/coalesce.ts';4
+5
+function setup() {6
+ const delivered: string[] = [];7
+ const queue: Array<() => void> = [];8
+ const coalescer = new EventCoalescer(9
+ (account, event) => delivered.push(`${account}|${event}`),10
+ (flush) => queue.push(flush)11
+ );12
+ return { delivered, coalescer, run: () => queue.splice(0).forEach((flush) => flush()), queue };13
+}14
+15
+const timeline = (room: string, n: number) => `{"type":"timeline","room_id":"${room}","items":[${n}]}`;16
+17
+test('keys cover full-state events only', () => {18
+ assert.equal(replacementKey(timeline('!a', 1)), 'timeline:"!a"');19
+ assert.equal(replacementKey('{"type":"rooms","rooms":[]}'), 'rooms');20
+ assert.equal(replacementKey('{"type":"thread","room_id":"!a","root_event_id":"$r","items":[]}'), 'thread:"!a":"$r"');21
+ assert.equal(replacementKey('{"type":"thread_list","room_id":"!a","entries":[]}'), 'thread_list:"!a"');22
+ assert.equal(replacementKey('{"type":"pinned","room_id":"!a","items":[]}'), 'pinned:"!a"');23
+ assert.equal(replacementKey('{"type":"thread_closed","room_id":"!a","root_event_id":"$r"}'), null);24
+ assert.equal(replacementKey('{"type":"thread_list_history","room_id":"!a","state":"Idle"}'), null);25
+ assert.equal(replacementKey('{"type":"timeline_closed","room_id":"!a"}'), null);26
+ assert.equal(replacementKey('{"type":"snapshot"}'), null);27
+ assert.equal(replacementKey('{"type":"connection","state":"online"}'), null);28
+});29
+30
+test('a burst for one key delivers only the latest, once, on the scheduled flush', () => {31
+ const { delivered, coalescer, run, queue } = setup();32
+ coalescer.push('acc', timeline('!a', 1));33
+ coalescer.push('acc', timeline('!a', 2));34
+ coalescer.push('acc', timeline('!a', 3));35
+ assert.equal(queue.length, 1);36
+ assert.deepEqual(delivered, []);37
+ run();38
+ assert.deepEqual(delivered, [`acc|${timeline('!a', 3)}`]);39
+});40
+41
+test('other events flush pending ones first and keep their order', () => {42
+ const { delivered, coalescer, run } = setup();43
+ coalescer.push('acc', timeline('!a', 1));44
+ coalescer.push('acc', timeline('!b', 1));45
+ coalescer.push('acc', '{"type":"timeline_closed","room_id":"!a"}');46
+ coalescer.push('acc', timeline('!a', 2));47
+ run();48
+ assert.deepEqual(delivered, [49
+ `acc|${timeline('!a', 1)}`,50
+ `acc|${timeline('!b', 1)}`,51
+ 'acc|{"type":"timeline_closed","room_id":"!a"}',52
+ `acc|${timeline('!a', 2)}`53
+ ]);54
+});55
+56
+test('different keys and accounts are kept apart', () => {57
+ const { delivered, coalescer, run } = setup();58
+ coalescer.push('x', timeline('!a', 1));59
+ coalescer.push('y', timeline('!a', 2));60
+ coalescer.push('x', timeline('!a', 3));61
+ run();62
+ assert.deepEqual(delivered, [`y|${timeline('!a', 2)}`, `x|${timeline('!a', 3)}`]);63
+});64
+65
+test('a flush with nothing pending after an explicit flush delivers nothing twice', () => {66
+ const { delivered, coalescer, run } = setup();67
+ coalescer.push('acc', timeline('!a', 1));68
+ coalescer.flush();69
+ run();70
+ assert.equal(delivered.length, 1);71
+});@@ -0,0 +1,80 @@
1
+import assert from 'node:assert/strict';2
+import { test } from 'node:test';3
+import { newShareCache, parseShared } from '../src/lib/sharing.ts';4
+import { observeArrivals } from '../src/lib/arrivals.ts';5
+import type { TimelineEvent, TimelineItem } from '../src/lib/types.ts';6
+7
+interface Row {8
+ id: string;9
+ count: number;10
+}11
+12
+function parse(entry: unknown): Row | null {13
+ const raw = entry as Record<string, unknown>;14
+ return typeof raw['id'] === 'string' ? { id: raw['id'], count: Number(raw['count']) } : null;15
+}16
+17
+test('unchanged entries keep their object and unchanged lists keep their array', () => {18
+ const cache = newShareCache<Row>();19
+ const first = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse);20
+ const second = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse);21
+ assert.equal(second, first);22
+});23
+24
+test('a changed entry gets a new object while its neighbours keep theirs', () => {25
+ const cache = newShareCache<Row>();26
+ const first = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse);27
+ const second = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 2 }], parse);28
+ assert.notEqual(second, first);29
+ assert.equal(second[0], first[0]);30
+ assert.notEqual(second[1], first[1]);31
+ assert.equal(second[1].count, 2);32
+});33
+34
+test('appended and removed entries change the array', () => {35
+ const cache = newShareCache<Row>();36
+ const first = parseShared(cache, [{ id: 'a', count: 1 }], parse);37
+ const grown = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse);38
+ assert.notEqual(grown, first);39
+ assert.equal(grown[0], first[0]);40
+ const shrunk = parseShared(cache, [{ id: 'b', count: 1 }], parse);41
+ assert.equal(shrunk[0], grown[1]);42
+});43
+44
+test('unparsable entries are skipped and non-arrays yield an empty list', () => {45
+ const cache = newShareCache<Row>();46
+ assert.equal(parseShared(cache, [{ id: 'a', count: 1 }, { nope: true }], parse).length, 1);47
+ assert.deepEqual(parseShared(cache, 'x', parse), []);48
+});49
+50
+function event(id: string, timestamp_ms: number): TimelineItem {51
+ return { kind: 'event', event: { item_id: id, timestamp_ms, own: false } as TimelineEvent };52
+}53
+54
+test('arrivals keeps array and lagged item identity across identical updates', () => {55
+ const MINUTE = 60 * 1000;56
+ const known = event('a', 90 * MINUTE);57
+ const late = event('late', 80 * MINUTE);58
+ const first = observeArrivals([known], [known, late], 0, 100 * MINUTE);59
+ assert.ok(first);60
+ const second = observeArrivals(first, [known, late], 0, 101 * MINUTE);61
+ assert.equal(second, first);62
+});63
+64
+test('identified entries are matched by identity and evicted when absent', () => {65
+ const cache = newShareCache<Row>();66
+ const identify = (entry: unknown) => (entry as Row).id;67
+ const first = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse, identify);68
+ const second = parseShared(cache, [{ id: 'b', count: 1 }, { id: 'c', count: 1 }], parse, identify);69
+ assert.equal(second[0], first[1]);70
+ assert.deepEqual([...cache.byKey.keys()], ['b', 'c']);71
+});72
+73
+test('a changed identified entry is reparsed under the same key', () => {74
+ const cache = newShareCache<Row>();75
+ const identify = (entry: unknown) => (entry as Row).id;76
+ const first = parseShared(cache, [{ id: 'a', count: 1 }], parse, identify);77
+ const second = parseShared(cache, [{ id: 'a', count: 2 }], parse, identify);78
+ assert.notEqual(second[0], first[0]);79
+ assert.equal(second[0].count, 2);80
+});