@signalium/query 0.0.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,954 @@
1
+ /* eslint-disable @typescript-eslint/no-unused-expressions */
2
+ import { describe, it, expect, beforeEach } from 'vitest';
3
+ import {
4
+ SyncQueryStore,
5
+ MemoryPersistentStore,
6
+ valueKeyFor,
7
+ refCountKeyFor,
8
+ refIdsKeyFor,
9
+ updatedAtKeyFor,
10
+ } from '../QueryStore.js';
11
+ import { QueryClient } from '../QueryClient.js';
12
+ import { entity, t } from '../typeDefs.js';
13
+ import { query } from '../query.js';
14
+ import { hashValue } from 'signalium/utils';
15
+ import { createMockFetch, testWithClient, createTestWatcher, getClientEntityMap, sleep } from './utils.js';
16
+
17
+ /**
18
+ * Caching and Persistence Tests
19
+ *
20
+ * Tests query caching, document store persistence, reference counting,
21
+ * cascade deletion, and LRU cache management.
22
+ */
23
+
24
+ // Helper to simulate old store.set() behavior for testing
25
+ function setDocument(kv: any, key: number, value: unknown, refIds?: Set<number>) {
26
+ kv.setString(valueKeyFor(key), JSON.stringify(value));
27
+
28
+ const prevRefIds = kv.getBuffer(refIdsKeyFor(key));
29
+
30
+ if (refIds === undefined || refIds.size === 0) {
31
+ kv.delete(refIdsKeyFor(key));
32
+
33
+ // Decrement all previous refs
34
+ if (prevRefIds) {
35
+ for (const refId of prevRefIds) {
36
+ if (refId === 0) continue;
37
+ const refCountKey = refCountKeyFor(refId);
38
+ const currentCount = kv.getNumber(refCountKey);
39
+ if (currentCount === undefined) continue;
40
+
41
+ const newCount = currentCount - 1;
42
+ if (newCount === 0) {
43
+ kv.delete(refCountKey);
44
+ } else {
45
+ kv.setNumber(refCountKey, newCount);
46
+ }
47
+ }
48
+ }
49
+ } else {
50
+ // Convert to array for storage
51
+ const newRefArray = new Uint32Array(refIds);
52
+ kv.setBuffer(refIdsKeyFor(key), newRefArray);
53
+
54
+ // Build sets for comparison
55
+ const prevRefSet = new Set(prevRefIds || []);
56
+ const newRefSet = new Set(refIds);
57
+
58
+ // Decrement refs that are no longer present
59
+ if (prevRefIds) {
60
+ for (const refId of prevRefIds) {
61
+ if (refId === 0) continue;
62
+ if (!newRefSet.has(refId)) {
63
+ const refCountKey = refCountKeyFor(refId);
64
+ const currentCount = kv.getNumber(refCountKey);
65
+ if (currentCount === undefined) continue;
66
+
67
+ const newCount = currentCount - 1;
68
+ if (newCount === 0) {
69
+ kv.delete(refCountKey);
70
+ } else {
71
+ kv.setNumber(refCountKey, newCount);
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ // Increment refs that are new
78
+ for (const refId of refIds) {
79
+ if (!prevRefSet.has(refId)) {
80
+ const refCountKey = refCountKeyFor(refId);
81
+ const currentCount = kv.getNumber(refCountKey) ?? 0;
82
+ kv.setNumber(refCountKey, currentCount + 1);
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ // Helper to simulate old store.get() behavior
89
+ function getDocument(kv: any, key: number): unknown | undefined {
90
+ const value = kv.getString(valueKeyFor(key));
91
+ return value ? JSON.parse(value) : undefined;
92
+ }
93
+
94
+ // Helper to simulate old store.delete() behavior
95
+ function deleteDocument(kv: any, key: number) {
96
+ const refIds = kv.getBuffer(refIdsKeyFor(key));
97
+
98
+ kv.delete(valueKeyFor(key));
99
+ kv.delete(refIdsKeyFor(key));
100
+ kv.delete(refCountKeyFor(key));
101
+
102
+ // Decrement ref counts and cascade delete if needed
103
+ if (refIds) {
104
+ for (const refId of refIds) {
105
+ if (refId === 0) continue;
106
+
107
+ const refCountKey = refCountKeyFor(refId);
108
+ const currentCount = kv.getNumber(refCountKey);
109
+
110
+ if (currentCount === undefined) continue;
111
+
112
+ const newCount = currentCount - 1;
113
+
114
+ if (newCount === 0) {
115
+ // Cascade delete
116
+ deleteDocument(kv, refId);
117
+ } else {
118
+ kv.setNumber(refCountKey, newCount);
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // Helper to set up a query result in the store
125
+ function setQuery(kv: any, [queryDefId, params]: [string, any], result: unknown, refIds?: Set<number>) {
126
+ if (typeof params === 'object' && params !== null && Object.keys(params).length === 0) {
127
+ params = undefined;
128
+ }
129
+
130
+ const queryKey = hashValue([queryDefId, params]);
131
+ setDocument(kv, queryKey, result, refIds);
132
+ kv.setNumber(updatedAtKeyFor(queryKey), Date.now());
133
+ }
134
+
135
+ describe('Caching and Persistence', () => {
136
+ let client: QueryClient;
137
+ let mockFetch: ReturnType<typeof createMockFetch>;
138
+ let kv: any;
139
+ let store: any;
140
+
141
+ beforeEach(() => {
142
+ kv = new MemoryPersistentStore();
143
+ const queryStore = new SyncQueryStore(kv);
144
+ mockFetch = createMockFetch();
145
+ client = new QueryClient(queryStore, { fetch: mockFetch as any });
146
+ store = queryStore;
147
+ });
148
+
149
+ describe('Query Result Caching', () => {
150
+ it('should cache query results in document store', async () => {
151
+ mockFetch.get('/items/[id]', { id: 1, name: 'Test' });
152
+
153
+ await testWithClient(client, async () => {
154
+ const getItem = query(t => ({
155
+ path: '/items/[id]',
156
+ response: { id: t.number, name: t.string },
157
+ }));
158
+
159
+ const relay = getItem({ id: '1' });
160
+ // Watcher is automatically managed
161
+ await relay;
162
+
163
+ // Verify data is in document store
164
+ const queryKey = hashValue(['GET:/items/[id]', { id: '1' }]);
165
+ const cached = getDocument(kv, queryKey);
166
+
167
+ expect(cached).toEqual({ id: 1, name: 'Test' });
168
+ });
169
+ });
170
+
171
+ it('should load query results from cache', async () => {
172
+ const queryKey = hashValue(['GET:/items/[id]', { id: '1' }]);
173
+ const cachedData = { id: 1, name: 'Cached Data' };
174
+
175
+ // Pre-populate cache
176
+ setDocument(kv, queryKey, cachedData);
177
+ kv.setNumber(updatedAtKeyFor(queryKey), Date.now());
178
+
179
+ mockFetch.get(
180
+ '/items/[id]',
181
+ { id: 1, name: 'Fresh Data' },
182
+ {
183
+ delay: 10,
184
+ },
185
+ );
186
+
187
+ const getItem = query(t => ({
188
+ path: '/items/[id]',
189
+ response: { id: t.number, name: t.string },
190
+ }));
191
+
192
+ await testWithClient(client, async () => {
193
+ const relay = getItem({ id: '1' });
194
+ // Force a pull
195
+ relay.value;
196
+ await sleep();
197
+
198
+ expect(relay.value).toEqual({ id: 1, name: 'Cached Data' });
199
+
200
+ const result = await relay;
201
+
202
+ expect(result).toEqual({ id: 1, name: 'Fresh Data' });
203
+ expect(relay.value).toEqual({ id: 1, name: 'Fresh Data' });
204
+ });
205
+ });
206
+
207
+ it('should persist across QueryClient instances', async () => {
208
+ mockFetch.get('/item', { id: 1, value: 'Persistent' });
209
+
210
+ const getItem = query(t => ({
211
+ path: '/item',
212
+ response: { id: t.number, value: t.string },
213
+ }));
214
+
215
+ await testWithClient(client, async () => {
216
+ const relay = getItem();
217
+ await relay;
218
+ });
219
+
220
+ expect(mockFetch.calls).toHaveLength(1);
221
+ mockFetch.reset();
222
+
223
+ // Create new client with same stores
224
+ mockFetch.get('/item', { id: 1, value: 'New Data' }, { delay: 10 });
225
+ const client2 = new QueryClient(store, { fetch: mockFetch as any });
226
+
227
+ await testWithClient(client2, async () => {
228
+ const relay = getItem();
229
+ // Force a pull
230
+ relay.value;
231
+ await sleep();
232
+
233
+ expect(relay.value).toEqual({ id: 1, value: 'Persistent' });
234
+
235
+ const result = await relay;
236
+
237
+ expect(result).toEqual({ id: 1, value: 'New Data' });
238
+ expect(relay.value).toEqual({ id: 1, value: 'New Data' });
239
+ });
240
+ });
241
+ });
242
+
243
+ describe('Entity Persistence', () => {
244
+ it('should persist entities to document store', async () => {
245
+ const User = entity(() => ({
246
+ __typename: t.typename('User'),
247
+ id: t.id,
248
+ name: t.string,
249
+ }));
250
+
251
+ mockFetch.get('/users/[id]', {
252
+ user: { __typename: 'User', id: 1, name: 'Alice' },
253
+ });
254
+
255
+ await testWithClient(client, async () => {
256
+ const getUser = query(t => ({
257
+ path: '/users/[id]',
258
+ response: { user: User },
259
+ }));
260
+
261
+ const relay = getUser({ id: '1' });
262
+ await relay;
263
+
264
+ // Verify entity is persisted
265
+ const userKey = hashValue('User:1');
266
+ const entityData = getDocument(kv, userKey);
267
+
268
+ expect(entityData).toBeDefined();
269
+ expect(entityData).toEqual({
270
+ __typename: 'User',
271
+ id: 1,
272
+ name: 'Alice',
273
+ });
274
+ });
275
+ });
276
+
277
+ it('should load entities from persistence', async () => {
278
+ const User = entity(() => ({
279
+ __typename: t.typename('User'),
280
+ id: t.id,
281
+ name: t.string,
282
+ }));
283
+
284
+ const getDocument = query(t => ({
285
+ path: '/document',
286
+ response: { user: User },
287
+ }));
288
+
289
+ // Pre-populate entity
290
+ const userKey = hashValue('User:1');
291
+ const userData = {
292
+ __typename: 'User',
293
+ id: 1,
294
+ name: 'Persisted User',
295
+ };
296
+
297
+ setDocument(kv, userKey, userData);
298
+
299
+ // Set up the query result with reference to the user
300
+ const queryResult = {
301
+ user: { __entityRef: userKey },
302
+ };
303
+ setQuery(kv, ['GET:/document', {}], queryResult, new Set([userKey]));
304
+
305
+ // Query returns entity reference
306
+ mockFetch.get(
307
+ '/document',
308
+ {
309
+ user: { __typename: 'User', id: 1, name: 'Fresh User' },
310
+ },
311
+ { delay: 10 },
312
+ );
313
+
314
+ await testWithClient(client, async () => {
315
+ const relay = getDocument();
316
+ // Force a pull
317
+ relay.value;
318
+ await sleep();
319
+
320
+ expect(relay.value).toEqual({ user: { __typename: 'User', id: 1, name: 'Persisted User' } });
321
+
322
+ const result = await relay;
323
+
324
+ expect(result).toEqual({ user: { __typename: 'User', id: 1, name: 'Fresh User' } });
325
+ expect(relay.value).toEqual({ user: { __typename: 'User', id: 1, name: 'Fresh User' } });
326
+ });
327
+ });
328
+ });
329
+
330
+ describe('Reference Counting', () => {
331
+ it('should increment ref count when entity is referenced', async () => {
332
+ const Post = entity(() => ({
333
+ __typename: t.typename('Post'),
334
+ id: t.id,
335
+ title: t.string,
336
+ }));
337
+
338
+ const User = entity(() => ({
339
+ __typename: t.typename('User'),
340
+ id: t.id,
341
+ name: t.string,
342
+ favoritePost: Post,
343
+ }));
344
+
345
+ const getUser = query(t => ({
346
+ path: '/users/[id]',
347
+ response: { user: User },
348
+ }));
349
+
350
+ mockFetch.get('/users/[id]', {
351
+ user: {
352
+ __typename: 'User',
353
+ id: 1,
354
+ name: 'Alice',
355
+ favoritePost: {
356
+ __typename: 'Post',
357
+ id: 1,
358
+ title: 'Favorite Post',
359
+ },
360
+ },
361
+ });
362
+
363
+ await testWithClient(client, async () => {
364
+ const relay = getUser({ id: '1' });
365
+ await relay;
366
+
367
+ // Check reference count
368
+ const postKey = hashValue('Post:1');
369
+ const refCount = await kv.getNumber(refCountKeyFor(postKey));
370
+
371
+ expect(refCount).toBe(1);
372
+ });
373
+ });
374
+
375
+ it('should handle multiple references to same entity', async () => {
376
+ const User = entity(() => ({
377
+ __typename: t.typename('User'),
378
+ id: t.id,
379
+ name: t.string,
380
+ }));
381
+
382
+ mockFetch.get('/user/profile', {
383
+ user: { __typename: 'User', id: 1, name: 'Alice' },
384
+ });
385
+ mockFetch.get('/user/details', {
386
+ user: { __typename: 'User', id: 1, name: 'Alice' },
387
+ });
388
+
389
+ await testWithClient(client, async () => {
390
+ const getUser1 = query(t => ({
391
+ path: '/user/profile',
392
+ response: { user: User },
393
+ }));
394
+
395
+ const getUser2 = query(t => ({
396
+ path: '/user/details',
397
+ response: { user: User },
398
+ }));
399
+
400
+ const relay1 = getUser1();
401
+ await relay1;
402
+
403
+ const relay2 = getUser2();
404
+ await relay2;
405
+
406
+ // Entity should have references from queries
407
+ const userKey = hashValue('User:1');
408
+ const refCount = await kv.getNumber(refCountKeyFor(userKey));
409
+
410
+ expect(refCount).toBe(2);
411
+ });
412
+ });
413
+ });
414
+
415
+ describe('Document Store Operations', () => {
416
+ it('should store query results with entity references', async () => {
417
+ const User = entity(() => ({
418
+ __typename: t.typename('User'),
419
+ id: t.id,
420
+ name: t.string,
421
+ }));
422
+
423
+ mockFetch.get('/users/[id]', {
424
+ user: { __typename: 'User', id: 1, name: 'Alice' },
425
+ });
426
+
427
+ await testWithClient(client, async () => {
428
+ const getUser = query(t => ({
429
+ path: '/users/[id]',
430
+ response: { user: User },
431
+ }));
432
+
433
+ const relay = getUser({ id: '1' });
434
+ await relay;
435
+
436
+ // Check that query and entity are stored
437
+ const queryKey = hashValue(['GET:/users/[id]', { id: '1' }]);
438
+ const userKey = hashValue('User:1');
439
+
440
+ const queryValue = getDocument(kv, queryKey);
441
+ expect(queryValue).toBeDefined();
442
+
443
+ const entityValue = getDocument(kv, userKey);
444
+ expect(entityValue).toEqual({
445
+ __typename: 'User',
446
+ id: 1,
447
+ name: 'Alice',
448
+ });
449
+
450
+ // Check that query references the entity
451
+ const refs = await kv.getBuffer(refIdsKeyFor(queryKey));
452
+ expect(refs).toBeDefined();
453
+ expect(Array.from(refs!)).toContain(userKey);
454
+ });
455
+ });
456
+
457
+ it('should store nested entity references correctly', async () => {
458
+ const Post = entity(() => ({
459
+ __typename: t.typename('Post'),
460
+ id: t.id,
461
+ title: t.string,
462
+ }));
463
+
464
+ const User = entity(() => ({
465
+ __typename: t.typename('User'),
466
+ id: t.id,
467
+ name: t.string,
468
+ favoritePost: Post,
469
+ }));
470
+
471
+ const getUser = query(t => ({
472
+ path: '/users/[id]',
473
+ response: { user: User },
474
+ }));
475
+
476
+ mockFetch.get('/users/[id]', {
477
+ user: {
478
+ __typename: 'User',
479
+ id: 1,
480
+ name: 'Alice',
481
+ favoritePost: {
482
+ __typename: 'Post',
483
+ id: 42,
484
+ title: 'My Post',
485
+ },
486
+ },
487
+ });
488
+
489
+ await testWithClient(client, async () => {
490
+ const relay = getUser({ id: '1' });
491
+ await relay;
492
+
493
+ const userKey = hashValue('User:1');
494
+ const postKey = hashValue('Post:42');
495
+
496
+ // User should reference Post
497
+ const userRefs = await kv.getBuffer(refIdsKeyFor(userKey));
498
+ expect(userRefs).toBeDefined();
499
+ expect(Array.from(userRefs!)).toContain(postKey);
500
+
501
+ // Post should have a reference count of 1
502
+ expect(await kv.getNumber(refCountKeyFor(postKey))).toBe(1);
503
+ });
504
+ });
505
+ });
506
+
507
+ describe('Cascade Deletion', () => {
508
+ it('should cascade delete entities when query is evicted from LRU', async () => {
509
+ const User = entity(() => ({
510
+ __typename: t.typename('User'),
511
+ id: t.id,
512
+ name: t.string,
513
+ }));
514
+
515
+ // Set up a query cache with maxCount of 2
516
+ const getUser = query(t => ({
517
+ path: '/users/[id]',
518
+ response: { user: User },
519
+ cache: { maxCount: 2 },
520
+ }));
521
+
522
+ mockFetch.get('/users/1', { user: { __typename: 'User', id: 1, name: 'User 1' } });
523
+ mockFetch.get('/users/2', { user: { __typename: 'User', id: 2, name: 'User 2' } });
524
+ mockFetch.get('/users/3', { user: { __typename: 'User', id: 3, name: 'User 3' } });
525
+
526
+ await testWithClient(client, async () => {
527
+ // Fetch 3 users, the third should evict the first
528
+ const relay1 = getUser({ id: '1' });
529
+ await relay1;
530
+
531
+ const relay2 = getUser({ id: '2' });
532
+ await relay2;
533
+
534
+ const query1Key = hashValue(['GET:/users/[id]', { id: '1' }]);
535
+ const query2Key = hashValue(['GET:/users/[id]', { id: '2' }]);
536
+ const query3Key = hashValue(['GET:/users/[id]', { id: '3' }]);
537
+
538
+ const user1Key = hashValue('User:1');
539
+ const user2Key = hashValue('User:2');
540
+ const user3Key = hashValue('User:3');
541
+
542
+ // Query 1 and 2 should exist
543
+ expect(getDocument(kv, query1Key)).toBeDefined();
544
+ expect(getDocument(kv, query2Key)).toBeDefined();
545
+
546
+ // User 1 and 2 should exist
547
+ expect(getDocument(kv, user1Key)).toBeDefined();
548
+ expect(getDocument(kv, user2Key)).toBeDefined();
549
+
550
+ // Fetch user 3, should evict user 1's query
551
+ const relay3 = getUser({ id: '3' });
552
+ await relay3;
553
+
554
+ // Query 1 should be evicted
555
+ expect(getDocument(kv, query1Key)).toBeUndefined();
556
+ expect(getDocument(kv, query2Key)).toBeDefined();
557
+ expect(getDocument(kv, query3Key)).toBeDefined();
558
+
559
+ // User 1 should be cascade deleted
560
+ expect(getDocument(kv, user1Key)).toBeUndefined();
561
+ expect(getDocument(kv, user2Key)).toBeDefined();
562
+ expect(getDocument(kv, user3Key)).toBeDefined();
563
+ });
564
+ });
565
+
566
+ it('should NOT delete entity if still referenced by another query', async () => {
567
+ const User = entity(() => ({
568
+ __typename: t.typename('User'),
569
+ id: t.id,
570
+ name: t.string,
571
+ }));
572
+
573
+ const getProfile = query(t => ({
574
+ path: '/user/profile/[id]',
575
+ response: { user: User },
576
+ cache: { maxCount: 1 },
577
+ }));
578
+
579
+ const getDetails = query(t => ({
580
+ path: '/user/details/[id]',
581
+ response: { user: User },
582
+ }));
583
+
584
+ mockFetch.get('/user/profile/1', {
585
+ user: { __typename: 'User', id: 1, name: 'Alice' },
586
+ });
587
+ mockFetch.get('/user/details/1', {
588
+ user: { __typename: 'User', id: 1, name: 'Alice' },
589
+ });
590
+
591
+ await testWithClient(client, async () => {
592
+ // Both queries reference the same user
593
+ const relay1 = getProfile({ id: '1' });
594
+ await relay1;
595
+
596
+ const relay2 = getDetails({ id: '1' });
597
+ await relay2;
598
+
599
+ const userKey = hashValue('User:1');
600
+
601
+ // User should have ref count of 2
602
+ expect(await kv.getNumber(refCountKeyFor(userKey))).toBe(2);
603
+
604
+ // Force eviction of first query by making another profile request
605
+ mockFetch.get('/user/profile/2', {
606
+ user: { __typename: 'User', id: 2, name: 'Bob' },
607
+ });
608
+
609
+ const relay3 = getProfile({ id: '2' });
610
+ await relay3;
611
+
612
+ // Original user should still exist (referenced by details query)
613
+ expect(getDocument(kv, userKey)).toBeDefined();
614
+ expect(await kv.getNumber(refCountKeyFor(userKey))).toBe(1);
615
+ });
616
+ });
617
+
618
+ it('should handle deep cascade deletion through nested entities', async () => {
619
+ const Tag = entity(() => ({
620
+ __typename: t.typename('Tag'),
621
+ id: t.id,
622
+ name: t.string,
623
+ }));
624
+
625
+ const Post = entity(() => ({
626
+ __typename: t.typename('Post'),
627
+ id: t.id,
628
+ title: t.string,
629
+ tag: Tag,
630
+ }));
631
+
632
+ const User = entity(() => ({
633
+ __typename: t.typename('User'),
634
+ id: t.id,
635
+ name: t.string,
636
+ post: Post,
637
+ }));
638
+
639
+ mockFetch.get('/users/[id]', {
640
+ user: {
641
+ __typename: 'User',
642
+ id: 1,
643
+ name: 'Alice',
644
+ post: {
645
+ __typename: 'Post',
646
+ id: 10,
647
+ title: 'My Post',
648
+ tag: {
649
+ __typename: 'Tag',
650
+ id: 100,
651
+ name: 'Tech',
652
+ },
653
+ },
654
+ },
655
+ });
656
+
657
+ const getUser = query(t => ({
658
+ path: '/users/[id]',
659
+ response: { user: User },
660
+ cache: { maxCount: 1 },
661
+ }));
662
+
663
+ await testWithClient(client, async () => {
664
+ const relay1 = getUser({ id: '1' });
665
+ await relay1;
666
+
667
+ const userKey = hashValue('User:1');
668
+ const postKey = hashValue('Post:10');
669
+ const tagKey = hashValue('Tag:100');
670
+
671
+ // All entities should exist
672
+ expect(getDocument(kv, userKey)).toBeDefined();
673
+ expect(getDocument(kv, postKey)).toBeDefined();
674
+ expect(getDocument(kv, tagKey)).toBeDefined();
675
+
676
+ // Fetch a different user to evict the first
677
+ mockFetch.get('/users/[id]', {
678
+ user: {
679
+ __typename: 'User',
680
+ id: 2,
681
+ name: 'Bob',
682
+ post: {
683
+ __typename: 'Post',
684
+ id: 20,
685
+ title: 'Other Post',
686
+ tag: {
687
+ __typename: 'Tag',
688
+ id: 200,
689
+ name: 'Other',
690
+ },
691
+ },
692
+ },
693
+ });
694
+
695
+ const relay2 = getUser({ id: '2' });
696
+ await relay2;
697
+
698
+ // All original entities should be cascade deleted
699
+ expect(getDocument(kv, userKey)).toBeUndefined();
700
+ expect(getDocument(kv, postKey)).toBeUndefined();
701
+ expect(getDocument(kv, tagKey)).toBeUndefined();
702
+ });
703
+ });
704
+ });
705
+
706
+ describe('Reference Updates', () => {
707
+ it('should update references when query result changes', async () => {
708
+ const Post = entity(() => ({
709
+ __typename: t.typename('Post'),
710
+ id: t.id,
711
+ title: t.string,
712
+ }));
713
+
714
+ const User = entity(() => ({
715
+ __typename: t.typename('User'),
716
+ id: t.id,
717
+ name: t.string,
718
+ favoritePost: Post,
719
+ }));
720
+
721
+ // First response with post 1
722
+ mockFetch.get('/users/[id]', {
723
+ user: {
724
+ __typename: 'User',
725
+ id: 1,
726
+ name: 'Alice',
727
+ favoritePost: {
728
+ __typename: 'Post',
729
+ id: 10,
730
+ title: 'Post 10',
731
+ },
732
+ },
733
+ });
734
+
735
+ await testWithClient(client, async () => {
736
+ const getUser = query(t => ({
737
+ path: '/users/[id]',
738
+ response: { user: User },
739
+ }));
740
+
741
+ const relay = getUser({ id: '1' });
742
+ await relay;
743
+
744
+ const post10Key = hashValue('Post:10');
745
+
746
+ // Post 10 should have 1 reference
747
+ expect(await kv.getNumber(refCountKeyFor(post10Key))).toBe(1);
748
+
749
+ // Update response to reference a different post
750
+ mockFetch.get('/users/[id]', {
751
+ user: {
752
+ __typename: 'User',
753
+ id: 1,
754
+ name: 'Alice',
755
+ favoritePost: {
756
+ __typename: 'Post',
757
+ id: 20,
758
+ title: 'Post 20',
759
+ },
760
+ },
761
+ });
762
+
763
+ const result = await relay.refetch();
764
+
765
+ const post20Key = hashValue('Post:20');
766
+
767
+ expect(result).toEqual({
768
+ user: {
769
+ __typename: 'User',
770
+ id: 1,
771
+ name: 'Alice',
772
+ favoritePost: { __typename: 'Post', id: 20, title: 'Post 20' },
773
+ },
774
+ });
775
+
776
+ // Post 10 should have no refs (and be deleted)
777
+ expect(await kv.getNumber(refCountKeyFor(post10Key))).toBeUndefined();
778
+ expect(getDocument(kv, post10Key)).toBeUndefined();
779
+
780
+ // Post 20 should have 1 reference
781
+ expect(await kv.getNumber(refCountKeyFor(post20Key))).toBe(1);
782
+ });
783
+ });
784
+
785
+ it('should deduplicate entity references in arrays', async () => {
786
+ const Post = entity(() => ({
787
+ __typename: t.typename('Post'),
788
+ id: t.id,
789
+ title: t.string,
790
+ }));
791
+
792
+ // Response with same post referenced multiple times
793
+ mockFetch.get('/posts', {
794
+ posts: [
795
+ { __typename: 'Post', id: 1, title: 'Post 1' },
796
+ { __typename: 'Post', id: 1, title: 'Post 1' }, // Same post again
797
+ { __typename: 'Post', id: 1, title: 'Post 1' }, // And again
798
+ ],
799
+ });
800
+
801
+ await testWithClient(client, async () => {
802
+ const getPosts = query(t => ({
803
+ path: '/posts',
804
+ response: { posts: t.array(Post) },
805
+ }));
806
+
807
+ const relay = getPosts();
808
+ const result = await relay;
809
+
810
+ expect(result.posts.length).toEqual(3);
811
+
812
+ const postKey = hashValue('Post:1');
813
+ const queryKey = hashValue(['GET:/posts', undefined]);
814
+
815
+ // Query should reference post 1
816
+ const refs = await kv.getBuffer(refIdsKeyFor(queryKey));
817
+ expect(refs).toBeDefined();
818
+ expect(Array.from(refs!).filter(id => id === postKey).length).toBe(1);
819
+
820
+ // Post should have ref count of 1 (deduplicated)
821
+ expect(await kv.getNumber(refCountKeyFor(postKey))).toBe(1);
822
+ });
823
+ });
824
+ });
825
+
826
+ describe('Storage Cleanup', () => {
827
+ it('should clean up all query storage keys when evicted from LRU', async () => {
828
+ const Post = entity(() => ({
829
+ __typename: t.typename('Post'),
830
+ id: t.id,
831
+ title: t.string,
832
+ }));
833
+
834
+ const User = entity(() => ({
835
+ __typename: t.typename('User'),
836
+ id: t.id,
837
+ name: t.string,
838
+ post: Post,
839
+ }));
840
+
841
+ mockFetch.get('/users/[id]', {
842
+ user: {
843
+ __typename: 'User',
844
+ id: 1,
845
+ name: 'Alice',
846
+ post: { __typename: 'Post', id: 10, title: 'Post' },
847
+ },
848
+ });
849
+
850
+ const getUser = query(t => ({
851
+ path: '/users/[id]',
852
+ response: { user: User },
853
+ cache: { maxCount: 1 },
854
+ }));
855
+
856
+ await testWithClient(client, async () => {
857
+ const relay1 = getUser({ id: '1' });
858
+ await relay1;
859
+
860
+ const queryKey = hashValue(['GET:/users/[id]', { id: '1' }]);
861
+
862
+ // Verify all keys exist for the query
863
+ expect(await kv.getString(valueKeyFor(queryKey))).toBeDefined();
864
+ expect(await kv.getNumber(updatedAtKeyFor(queryKey))).toBeDefined();
865
+ expect(await kv.getBuffer(refIdsKeyFor(queryKey))).toBeDefined();
866
+
867
+ // Fetch different user to evict first query
868
+ mockFetch.get('/users/[id]', {
869
+ user: {
870
+ __typename: 'User',
871
+ id: 2,
872
+ name: 'Bob',
873
+ post: { __typename: 'Post', id: 20, title: 'Other' },
874
+ },
875
+ });
876
+
877
+ const relay2 = getUser({ id: '2' });
878
+ await relay2;
879
+
880
+ // All query keys should be cleaned up
881
+ expect(await kv.getString(valueKeyFor(queryKey))).toBeUndefined();
882
+ expect(await kv.getNumber(updatedAtKeyFor(queryKey))).toBeUndefined();
883
+ expect(await kv.getBuffer(refIdsKeyFor(queryKey))).toBeUndefined();
884
+ expect(await kv.getNumber(refCountKeyFor(queryKey))).toBeUndefined();
885
+ });
886
+ });
887
+
888
+ it('should clean up all entity storage keys when cascade deleted', async () => {
889
+ const Post = entity(() => ({
890
+ __typename: t.typename('Post'),
891
+ id: t.id,
892
+ title: t.string,
893
+ }));
894
+
895
+ const User = entity(() => ({
896
+ __typename: t.typename('User'),
897
+ id: t.id,
898
+ name: t.string,
899
+ post: Post,
900
+ }));
901
+
902
+ mockFetch.get('/users/[id]', {
903
+ user: {
904
+ __typename: 'User',
905
+ id: 1,
906
+ name: 'Alice',
907
+ post: { __typename: 'Post', id: 10, title: 'Post' },
908
+ },
909
+ });
910
+
911
+ const getUser = query(t => ({
912
+ path: '/users/[id]',
913
+ response: { user: User },
914
+ cache: { maxCount: 1 },
915
+ }));
916
+
917
+ await testWithClient(client, async () => {
918
+ const relay1 = getUser({ id: '1' });
919
+ await relay1;
920
+
921
+ const userKey = hashValue('User:1');
922
+ const postKey = hashValue('Post:10');
923
+
924
+ // Verify all keys exist for entities
925
+ expect(await kv.getString(valueKeyFor(userKey))).toBeDefined();
926
+ expect(await kv.getString(valueKeyFor(postKey))).toBeDefined();
927
+ expect(await kv.getBuffer(refIdsKeyFor(userKey))).toBeDefined();
928
+ expect(await kv.getNumber(refCountKeyFor(postKey))).toBe(1);
929
+
930
+ // Fetch different user to evict first query and cascade delete entities
931
+ mockFetch.get('/users/[id]', {
932
+ user: {
933
+ __typename: 'User',
934
+ id: 2,
935
+ name: 'Bob',
936
+ post: { __typename: 'Post', id: 20, title: 'Other' },
937
+ },
938
+ });
939
+
940
+ const relay2 = getUser({ id: '2' });
941
+ await relay2;
942
+
943
+ // All entity keys should be cleaned up
944
+ expect(await kv.getString(valueKeyFor(userKey))).toBeUndefined();
945
+ expect(await kv.getNumber(refCountKeyFor(userKey))).toBeUndefined();
946
+ expect(await kv.getBuffer(refIdsKeyFor(userKey))).toBeUndefined();
947
+
948
+ expect(await kv.getString(valueKeyFor(postKey))).toBeUndefined();
949
+ expect(await kv.getNumber(refCountKeyFor(postKey))).toBeUndefined();
950
+ expect(await kv.getBuffer(refIdsKeyFor(postKey))).toBeUndefined();
951
+ });
952
+ });
953
+ });
954
+ });