@runtypelabs/persona 1.41.0 → 1.43.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.
@@ -0,0 +1,182 @@
1
+ import type { SSEEventRecord } from "../types";
2
+
3
+ export class EventStreamStore {
4
+ private db: IDBDatabase | null = null;
5
+ private pendingWrites: SSEEventRecord[] = [];
6
+ private flushScheduled = false;
7
+ private isDestroyed = false;
8
+ private readonly dbName: string;
9
+ private readonly storeName: string;
10
+
11
+ constructor(dbName = "persona-event-stream", storeName = "events") {
12
+ this.dbName = dbName;
13
+ this.storeName = storeName;
14
+ }
15
+
16
+ open(): Promise<void> {
17
+ return new Promise((resolve, reject) => {
18
+ try {
19
+ const request = indexedDB.open(this.dbName, 1);
20
+
21
+ request.onupgradeneeded = () => {
22
+ const db = request.result;
23
+ if (!db.objectStoreNames.contains(this.storeName)) {
24
+ const store = db.createObjectStore(this.storeName, { keyPath: "id" });
25
+ store.createIndex("timestamp", "timestamp", { unique: false });
26
+ }
27
+ };
28
+
29
+ request.onsuccess = () => {
30
+ this.db = request.result;
31
+ resolve();
32
+ };
33
+
34
+ request.onerror = () => {
35
+ reject(request.error);
36
+ };
37
+ } catch (err) {
38
+ reject(err);
39
+ }
40
+ });
41
+ }
42
+
43
+ put(event: SSEEventRecord): void {
44
+ if (!this.db || this.isDestroyed) return;
45
+ this.pendingWrites.push(event);
46
+ if (!this.flushScheduled) {
47
+ this.flushScheduled = true;
48
+ queueMicrotask(() => this.flushWrites());
49
+ }
50
+ }
51
+
52
+ putBatch(events: SSEEventRecord[]): void {
53
+ if (!this.db || this.isDestroyed || events.length === 0) return;
54
+ try {
55
+ const tx = this.db.transaction(this.storeName, "readwrite");
56
+ const store = tx.objectStore(this.storeName);
57
+ for (const event of events) {
58
+ store.put(event);
59
+ }
60
+ } catch {
61
+ // Silently fail - IndexedDB writes are best-effort
62
+ }
63
+ }
64
+
65
+ getAll(): Promise<SSEEventRecord[]> {
66
+ return new Promise((resolve, reject) => {
67
+ if (!this.db) {
68
+ resolve([]);
69
+ return;
70
+ }
71
+ try {
72
+ const tx = this.db.transaction(this.storeName, "readonly");
73
+ const store = tx.objectStore(this.storeName);
74
+ const index = store.index("timestamp");
75
+ const request = index.getAll();
76
+
77
+ request.onsuccess = () => {
78
+ resolve(request.result as SSEEventRecord[]);
79
+ };
80
+
81
+ request.onerror = () => {
82
+ reject(request.error);
83
+ };
84
+ } catch (err) {
85
+ reject(err);
86
+ }
87
+ });
88
+ }
89
+
90
+ getCount(): Promise<number> {
91
+ return new Promise((resolve, reject) => {
92
+ if (!this.db) {
93
+ resolve(0);
94
+ return;
95
+ }
96
+ try {
97
+ const tx = this.db.transaction(this.storeName, "readonly");
98
+ const store = tx.objectStore(this.storeName);
99
+ const request = store.count();
100
+
101
+ request.onsuccess = () => {
102
+ resolve(request.result);
103
+ };
104
+
105
+ request.onerror = () => {
106
+ reject(request.error);
107
+ };
108
+ } catch (err) {
109
+ reject(err);
110
+ }
111
+ });
112
+ }
113
+
114
+ clear(): Promise<void> {
115
+ return new Promise((resolve, reject) => {
116
+ if (!this.db) {
117
+ resolve();
118
+ return;
119
+ }
120
+ this.pendingWrites = [];
121
+ try {
122
+ const tx = this.db.transaction(this.storeName, "readwrite");
123
+ const store = tx.objectStore(this.storeName);
124
+ const request = store.clear();
125
+
126
+ request.onsuccess = () => {
127
+ resolve();
128
+ };
129
+
130
+ request.onerror = () => {
131
+ reject(request.error);
132
+ };
133
+ } catch (err) {
134
+ reject(err);
135
+ }
136
+ });
137
+ }
138
+
139
+ close(): void {
140
+ if (this.db) {
141
+ this.db.close();
142
+ this.db = null;
143
+ }
144
+ }
145
+
146
+ destroy(): Promise<void> {
147
+ this.isDestroyed = true;
148
+ this.pendingWrites = [];
149
+ this.close();
150
+ return new Promise((resolve, reject) => {
151
+ try {
152
+ const request = indexedDB.deleteDatabase(this.dbName);
153
+
154
+ request.onsuccess = () => {
155
+ resolve();
156
+ };
157
+
158
+ request.onerror = () => {
159
+ reject(request.error);
160
+ };
161
+ } catch (err) {
162
+ reject(err);
163
+ }
164
+ });
165
+ }
166
+
167
+ private flushWrites(): void {
168
+ this.flushScheduled = false;
169
+ if (!this.db || this.isDestroyed || this.pendingWrites.length === 0) return;
170
+ const toWrite = this.pendingWrites;
171
+ this.pendingWrites = [];
172
+ try {
173
+ const tx = this.db.transaction(this.storeName, "readwrite");
174
+ const store = tx.objectStore(this.storeName);
175
+ for (const event of toWrite) {
176
+ store.put(event);
177
+ }
178
+ } catch {
179
+ // Silently fail - IndexedDB writes are best-effort
180
+ }
181
+ }
182
+ }
@@ -0,0 +1,449 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { VirtualScroller } from "./virtual-scroller";
3
+
4
+ // Minimal DOM mock for Node environment
5
+ function createMockElement(tag = "div"): any {
6
+ const children: any[] = [];
7
+ const style: Record<string, string> = {};
8
+ const listeners: Record<string, Function[]> = {};
9
+ const el: any = {
10
+ tagName: tag.toUpperCase(),
11
+ style,
12
+ children,
13
+ childNodes: children,
14
+ firstChild: null,
15
+ parentNode: null,
16
+ appendChild(child: any) {
17
+ children.push(child);
18
+ child.parentNode = el;
19
+ el.firstChild = children[0] || null;
20
+ return child;
21
+ },
22
+ remove() {
23
+ if (el.parentNode) {
24
+ const idx = el.parentNode.children.indexOf(el);
25
+ if (idx >= 0) el.parentNode.children.splice(idx, 1);
26
+ el.parentNode = null;
27
+ }
28
+ },
29
+ addEventListener(event: string, handler: Function) {
30
+ if (!listeners[event]) listeners[event] = [];
31
+ listeners[event].push(handler);
32
+ },
33
+ removeEventListener(event: string, handler: Function) {
34
+ if (listeners[event]) {
35
+ listeners[event] = listeners[event].filter((h) => h !== handler);
36
+ }
37
+ },
38
+ setAttribute(name: string, value: string) {
39
+ el[`__attr_${name}`] = value;
40
+ },
41
+ textContent: "",
42
+ innerHTML: "",
43
+ offsetHeight: 0,
44
+ scrollTop: 0,
45
+ scrollHeight: 200,
46
+ clientHeight: 200,
47
+ scrollTo(opts: { top: number; behavior?: string }) {
48
+ el.scrollTop = opts.top;
49
+ },
50
+ };
51
+ return el;
52
+ }
53
+
54
+ // Stub document.createElement for VirtualScroller internals
55
+ const origCreateElement = globalThis.document?.createElement;
56
+ let rafCallbacks: Function[] = [];
57
+
58
+ function _flushRAF() {
59
+ const cbs = rafCallbacks.slice();
60
+ rafCallbacks = [];
61
+ cbs.forEach((cb) => cb());
62
+ }
63
+
64
+ beforeEach(() => {
65
+ rafCallbacks = [];
66
+ // Provide a minimal document.createElement
67
+ if (!globalThis.document) {
68
+ (globalThis as any).document = {};
69
+ }
70
+ (globalThis.document as any).createElement = (tag: string) =>
71
+ createMockElement(tag);
72
+ // Queue RAF callbacks so we can control when they execute
73
+ (globalThis as any).requestAnimationFrame = (cb: Function) => {
74
+ rafCallbacks.push(cb);
75
+ return rafCallbacks.length;
76
+ };
77
+ (globalThis as any).cancelAnimationFrame = (id: number) => {
78
+ if (id > 0 && id <= rafCallbacks.length) {
79
+ rafCallbacks[id - 1] = () => {};
80
+ }
81
+ };
82
+ });
83
+
84
+ afterEach(() => {
85
+ if (origCreateElement) {
86
+ globalThis.document.createElement = origCreateElement;
87
+ }
88
+ });
89
+
90
+ describe("VirtualScroller", () => {
91
+ let container: any;
92
+ let scroller: VirtualScroller;
93
+
94
+ beforeEach(() => {
95
+ container = createMockElement("div");
96
+ container.clientHeight = 200;
97
+ container.scrollHeight = 200;
98
+ container.scrollTop = 0;
99
+ });
100
+
101
+ afterEach(() => {
102
+ scroller?.destroy();
103
+ });
104
+
105
+ function createRenderRow() {
106
+ return (index: number) => {
107
+ const el = createMockElement("div");
108
+ el.textContent = `Row ${index}`;
109
+ el.__dataIndex = index;
110
+ return el;
111
+ };
112
+ }
113
+
114
+ it("should create spacer and viewport elements in container", () => {
115
+ scroller = new VirtualScroller({
116
+ container,
117
+ rowHeight: 40,
118
+ renderRow: createRenderRow(),
119
+ });
120
+
121
+ // Spacer should be in container
122
+ expect(container.children.length).toBe(1);
123
+ const spacer = container.children[0];
124
+ expect(spacer.style.position).toBe("relative");
125
+ expect(spacer.style.width).toBe("100%");
126
+
127
+ // Viewport should be in spacer
128
+ expect(spacer.children.length).toBe(1);
129
+ const viewport = spacer.children[0];
130
+ expect(viewport.style.position).toBe("absolute");
131
+ });
132
+
133
+ it("should update spacer height when totalCount changes", () => {
134
+ scroller = new VirtualScroller({
135
+ container,
136
+ rowHeight: 40,
137
+ renderRow: createRenderRow(),
138
+ });
139
+
140
+ scroller.setTotalCount(100);
141
+ const spacer = container.children[0];
142
+ expect(spacer.style.height).toBe("4000px");
143
+
144
+ scroller.setTotalCount(50);
145
+ expect(spacer.style.height).toBe("2000px");
146
+ });
147
+
148
+ it("should render visible rows on setTotalCount", () => {
149
+ scroller = new VirtualScroller({
150
+ container,
151
+ rowHeight: 40,
152
+ overscan: 0,
153
+ renderRow: createRenderRow(),
154
+ });
155
+
156
+ scroller.setTotalCount(100);
157
+
158
+ // Container height 200 / rowHeight 40 = ceil(5) visible rows
159
+ const viewport = container.children[0].children[0];
160
+ expect(viewport.children.length).toBe(5);
161
+ });
162
+
163
+ it("should render rows with correct positioning", () => {
164
+ scroller = new VirtualScroller({
165
+ container,
166
+ rowHeight: 40,
167
+ overscan: 0,
168
+ renderRow: createRenderRow(),
169
+ });
170
+
171
+ scroller.setTotalCount(100);
172
+
173
+ const viewport = container.children[0].children[0];
174
+ const firstRow = viewport.children[0];
175
+
176
+ expect(firstRow.style.position).toBe("absolute");
177
+ expect(firstRow.style.height).toBe("40px");
178
+ expect(firstRow.style.transform).toBe("translateY(0px)");
179
+ expect(firstRow.textContent).toBe("Row 0");
180
+ });
181
+
182
+ it("should include overscan rows", () => {
183
+ scroller = new VirtualScroller({
184
+ container,
185
+ rowHeight: 40,
186
+ overscan: 3,
187
+ renderRow: createRenderRow(),
188
+ });
189
+
190
+ scroller.setTotalCount(100);
191
+
192
+ // 5 visible + 3 overscan below = 8 (no overscan above at scrollTop=0)
193
+ const viewport = container.children[0].children[0];
194
+ expect(viewport.children.length).toBe(8);
195
+ });
196
+
197
+ it("should clear rows when totalCount is 0", () => {
198
+ scroller = new VirtualScroller({
199
+ container,
200
+ rowHeight: 40,
201
+ overscan: 0,
202
+ renderRow: createRenderRow(),
203
+ });
204
+
205
+ scroller.setTotalCount(10);
206
+ const viewport = container.children[0].children[0];
207
+ expect(viewport.children.length).toBeGreaterThan(0);
208
+
209
+ scroller.setTotalCount(0);
210
+ expect(viewport.children.length).toBe(0);
211
+ });
212
+
213
+ it("should use default rowHeight of 40 and overscan of 5", () => {
214
+ scroller = new VirtualScroller({
215
+ container,
216
+ renderRow: createRenderRow(),
217
+ });
218
+
219
+ scroller.setTotalCount(100);
220
+ const spacer = container.children[0];
221
+ expect(spacer.style.height).toBe("4000px");
222
+ // ceil(200/40) + 5 = 10
223
+ const viewport = spacer.children[0];
224
+ expect(viewport.children.length).toBe(10);
225
+ });
226
+
227
+ it("should report isNearBottom correctly", () => {
228
+ scroller = new VirtualScroller({
229
+ container,
230
+ rowHeight: 40,
231
+ renderRow: createRenderRow(),
232
+ });
233
+
234
+ scroller.setTotalCount(100);
235
+
236
+ // scrollHeight=200, scrollTop=0, clientHeight=200 => distance=0 < 50
237
+ expect(scroller.isNearBottom()).toBe(true);
238
+
239
+ // Simulate scrolling up
240
+ container.scrollHeight = 4000;
241
+ container.scrollTop = 0;
242
+ expect(scroller.isNearBottom()).toBe(false);
243
+ });
244
+
245
+ it("should clean up on destroy", () => {
246
+ scroller = new VirtualScroller({
247
+ container,
248
+ rowHeight: 40,
249
+ overscan: 0,
250
+ renderRow: createRenderRow(),
251
+ });
252
+
253
+ scroller.setTotalCount(10);
254
+ scroller.destroy();
255
+
256
+ // Spacer should be removed from container
257
+ expect(container.children.length).toBe(0);
258
+ });
259
+
260
+ it("should not render more rows than totalCount", () => {
261
+ scroller = new VirtualScroller({
262
+ container,
263
+ rowHeight: 40,
264
+ overscan: 5,
265
+ renderRow: createRenderRow(),
266
+ });
267
+
268
+ // Only 3 items total - should render at most 3
269
+ scroller.setTotalCount(3);
270
+ const viewport = container.children[0].children[0];
271
+ expect(viewport.children.length).toBe(3);
272
+ });
273
+
274
+ it("should handle scrollToBottom", () => {
275
+ scroller = new VirtualScroller({
276
+ container,
277
+ rowHeight: 40,
278
+ renderRow: createRenderRow(),
279
+ });
280
+
281
+ scroller.setTotalCount(100);
282
+
283
+ // Set offsetHeight on spacer for scrollToBottom calculation
284
+ container.children[0].offsetHeight = 4000;
285
+
286
+ scroller.scrollToBottom();
287
+ expect(scroller.getIsAutoScrolling()).toBe(true);
288
+ });
289
+
290
+ it("should not create duplicate rows on repeated setTotalCount", () => {
291
+ scroller = new VirtualScroller({
292
+ container,
293
+ rowHeight: 40,
294
+ overscan: 0,
295
+ renderRow: createRenderRow(),
296
+ });
297
+
298
+ scroller.setTotalCount(10);
299
+ const viewport = container.children[0].children[0];
300
+ const countBefore = viewport.children.length;
301
+
302
+ // Call again with same count - should not duplicate
303
+ scroller.setTotalCount(10);
304
+ expect(viewport.children.length).toBe(countBefore);
305
+ });
306
+
307
+ it("should remove rows outside visible range when scrolled", () => {
308
+ scroller = new VirtualScroller({
309
+ container,
310
+ rowHeight: 40,
311
+ overscan: 0,
312
+ renderRow: createRenderRow(),
313
+ });
314
+
315
+ scroller.setTotalCount(100);
316
+ const viewport = container.children[0].children[0];
317
+
318
+ // Initially: rows 0-4 (5 visible)
319
+ expect(viewport.children.length).toBe(5);
320
+ expect(viewport.children[0].__dataIndex).toBe(0);
321
+
322
+ // Simulate scroll to show rows 50-54
323
+ container.scrollTop = 2000; // 2000/40 = row 50
324
+ scroller.render();
325
+
326
+ // Should only have rows 50-54
327
+ expect(viewport.children.length).toBe(5);
328
+ expect(viewport.children[0].__dataIndex).toBe(50);
329
+ });
330
+
331
+ describe("with 400px container height", () => {
332
+ beforeEach(() => {
333
+ container.clientHeight = 400;
334
+ container.scrollHeight = 400;
335
+ });
336
+
337
+ it("should render ~10 visible rows plus overscan with 100 items", () => {
338
+ scroller = new VirtualScroller({
339
+ container,
340
+ rowHeight: 40,
341
+ overscan: 5,
342
+ renderRow: createRenderRow(),
343
+ });
344
+
345
+ scroller.setTotalCount(100);
346
+
347
+ // 400px / 40px = 10 visible + 5 overscan below = 15 rows
348
+ const viewport = container.children[0].children[0];
349
+ expect(viewport.children.length).toBe(15);
350
+ });
351
+
352
+ it("should render correct rows when scrollTop is 200", () => {
353
+ scroller = new VirtualScroller({
354
+ container,
355
+ rowHeight: 40,
356
+ overscan: 0,
357
+ renderRow: createRenderRow(),
358
+ });
359
+
360
+ scroller.setTotalCount(100);
361
+ const viewport = container.children[0].children[0];
362
+
363
+ // Initially at scrollTop=0: rows 0-9 (400/40 = 10 visible)
364
+ expect(viewport.children.length).toBe(10);
365
+
366
+ // Scroll to 200px -> first visible row = floor(200/40) = 5
367
+ // Last visible row = ceil((200+400)/40) = 15
368
+ container.scrollTop = 200;
369
+ scroller.render();
370
+
371
+ expect(viewport.children.length).toBe(10);
372
+ expect(viewport.children[0].__dataIndex).toBe(5);
373
+ expect(viewport.children[viewport.children.length - 1].__dataIndex).toBe(14);
374
+ });
375
+
376
+ it("should render correct range with overscan when scrollTop is 200", () => {
377
+ scroller = new VirtualScroller({
378
+ container,
379
+ rowHeight: 40,
380
+ overscan: 3,
381
+ renderRow: createRenderRow(),
382
+ });
383
+
384
+ scroller.setTotalCount(100);
385
+ const viewport = container.children[0].children[0];
386
+
387
+ container.scrollTop = 200;
388
+ scroller.render();
389
+
390
+ // startIndex = max(0, floor(200/40) - 3) = max(0, 5-3) = 2
391
+ // endIndex = min(100, ceil((200+400)/40) + 3) = min(100, 15+3) = 18
392
+ // 18 - 2 = 16 rows
393
+ expect(viewport.children.length).toBe(16);
394
+ expect(viewport.children[0].__dataIndex).toBe(2);
395
+ expect(viewport.children[viewport.children.length - 1].__dataIndex).toBe(17);
396
+ });
397
+ });
398
+
399
+ it("should remove excess rows when totalCount decreases", () => {
400
+ scroller = new VirtualScroller({
401
+ container,
402
+ rowHeight: 40,
403
+ overscan: 0,
404
+ renderRow: createRenderRow(),
405
+ });
406
+
407
+ // Start with many items
408
+ scroller.setTotalCount(100);
409
+ const viewport = container.children[0].children[0];
410
+ const spacer = container.children[0];
411
+ expect(viewport.children.length).toBe(5); // 200/40 = 5
412
+
413
+ // Decrease to 2 items
414
+ scroller.setTotalCount(2);
415
+ expect(spacer.style.height).toBe("80px"); // 2 * 40
416
+ expect(viewport.children.length).toBe(2); // Only 2 rows exist
417
+ expect(viewport.children[0].__dataIndex).toBe(0);
418
+ expect(viewport.children[1].__dataIndex).toBe(1);
419
+ });
420
+
421
+ it("isNearBottom should return true when at bottom", () => {
422
+ scroller = new VirtualScroller({
423
+ container,
424
+ rowHeight: 40,
425
+ renderRow: createRenderRow(),
426
+ });
427
+
428
+ scroller.setTotalCount(100);
429
+
430
+ // At bottom: scrollHeight - scrollTop - clientHeight < threshold
431
+ container.scrollHeight = 4000;
432
+ container.scrollTop = 3800; // 4000 - 3800 - 200 = 0 < 50
433
+ expect(scroller.isNearBottom()).toBe(true);
434
+ });
435
+
436
+ it("isNearBottom should return false when scrolled up", () => {
437
+ scroller = new VirtualScroller({
438
+ container,
439
+ rowHeight: 40,
440
+ renderRow: createRenderRow(),
441
+ });
442
+
443
+ scroller.setTotalCount(100);
444
+
445
+ container.scrollHeight = 4000;
446
+ container.scrollTop = 1000; // 4000 - 1000 - 200 = 2800 > 50
447
+ expect(scroller.isNearBottom()).toBe(false);
448
+ });
449
+ });