@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,552 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { SyncQueryStore, MemoryPersistentStore, refIdsKeyFor, refCountKeyFor } from '../QueryStore.js';
3
+ import { QueryClient } from '../QueryClient.js';
4
+ import { entity, t } from '../typeDefs.js';
5
+ import { query, ExtractType } from '../query.js';
6
+ import { parseObjectEntities, parseArrayEntities, parseEntities } from '../parseEntities.js';
7
+ import { createMockFetch, getClientEntityMap, getEntityMapSize, testWithClient } from './utils.js';
8
+ import { hashValue } from 'signalium/utils';
9
+
10
+ /**
11
+ * Entity System Tests
12
+ *
13
+ * Tests entity parsing, deduplication, caching, proxy behavior, and reactivity.
14
+ */
15
+
16
+ describe('Entity System', () => {
17
+ let client: QueryClient;
18
+ let mockFetch: ReturnType<typeof createMockFetch>;
19
+ let kv: any;
20
+ let store: any;
21
+
22
+ beforeEach(() => {
23
+ kv = new MemoryPersistentStore();
24
+ store = new SyncQueryStore(kv);
25
+ mockFetch = createMockFetch();
26
+ client = new QueryClient(store, { fetch: mockFetch as any });
27
+ });
28
+
29
+ describe('Entity Proxies', () => {
30
+ it('should create reactive entity proxies', async () => {
31
+ const User = entity(() => ({
32
+ __typename: t.typename('User'),
33
+ id: t.id,
34
+ name: t.string,
35
+ email: t.string,
36
+ }));
37
+
38
+ mockFetch.get('/users/[id]', {
39
+ user: {
40
+ __typename: 'User',
41
+ id: 1,
42
+ name: 'Alice',
43
+ email: 'alice@example.com',
44
+ },
45
+ });
46
+
47
+ await testWithClient(client, async () => {
48
+ const getUser = query(t => ({
49
+ path: '/users/[id]',
50
+ response: { user: User },
51
+ }));
52
+
53
+ const relay = getUser({ id: '1' });
54
+ const result = await relay;
55
+
56
+ // Verify proxy provides reactive access
57
+ expect(result.user.name).toBe('Alice');
58
+ expect(result.user.email).toBe('alice@example.com');
59
+
60
+ // Verify entity is in the entity map
61
+ const entityMap = getClientEntityMap(client);
62
+ const userKey = hashValue('User:1');
63
+ const entityRecord = entityMap.getEntity(userKey);
64
+
65
+ expect(entityRecord).toBeDefined();
66
+ expect(entityRecord!.proxy).toBe(result.user);
67
+ });
68
+ });
69
+
70
+ it('should cache property access in entity proxies', async () => {
71
+ const User = entity(() => ({
72
+ __typename: t.typename('User'),
73
+ id: t.id,
74
+ name: t.string,
75
+ }));
76
+
77
+ mockFetch.get('/users/[id]', {
78
+ user: { __typename: 'User', id: 1, name: 'Alice' },
79
+ });
80
+
81
+ await testWithClient(client, async () => {
82
+ const getUser = query(t => ({
83
+ path: '/users/[id]',
84
+ response: { user: User },
85
+ }));
86
+
87
+ const relay = getUser({ id: '1' });
88
+ const result = await relay;
89
+
90
+ const user = result.user;
91
+
92
+ // Access same property multiple times
93
+ const name1 = user.name;
94
+ const name2 = user.name;
95
+ const name3 = user.name;
96
+
97
+ // All should return the same value
98
+ expect(name1).toBe('Alice');
99
+ expect(name2).toBe('Alice');
100
+ expect(name3).toBe('Alice');
101
+
102
+ // Verify caching by checking the entity's cache
103
+ const entityMap = getClientEntityMap(client);
104
+ const userKey = hashValue('User:1');
105
+ const entityRecord = entityMap.getEntity(userKey);
106
+
107
+ expect(entityRecord!.cache.has('name')).toBe(true);
108
+ expect(entityRecord!.cache.get('name')).toBe('Alice');
109
+ });
110
+ });
111
+
112
+ it('should return updated entity data when refetched', async () => {
113
+ const User = entity(() => ({
114
+ __typename: t.typename('User'),
115
+ id: t.id,
116
+ name: t.string,
117
+ }));
118
+
119
+ mockFetch.get('/users/[id]', {
120
+ user: { __typename: 'User', id: 1, name: 'Alice' },
121
+ });
122
+
123
+ await testWithClient(client, async () => {
124
+ const getUser = query(t => ({
125
+ path: '/users/[id]',
126
+ response: { user: User },
127
+ }));
128
+
129
+ const relay = getUser({ id: '1' });
130
+ const initialResult = await relay;
131
+ expect(initialResult.user.name).toBe('Alice');
132
+
133
+ // Set up updated response after initial fetch
134
+ mockFetch.get('/users/[id]', {
135
+ user: { __typename: 'User', id: 1, name: 'Alice Updated' },
136
+ });
137
+
138
+ // Refetch to get updated data
139
+ const refetchedResult = await relay.refetch();
140
+
141
+ // Refetch should return the new data
142
+ expect(refetchedResult.user.name).toBe('Alice Updated');
143
+
144
+ // The relay value should also be updated
145
+ expect(relay.value!.user.name).toBe('Alice Updated');
146
+ });
147
+ });
148
+ });
149
+
150
+ describe('Entity Deduplication', () => {
151
+ it('should deduplicate entities within same response', async () => {
152
+ const User = entity(() => ({
153
+ __typename: t.typename('User'),
154
+ id: t.id,
155
+ name: t.string,
156
+ }));
157
+
158
+ mockFetch.get('/users', {
159
+ users: [
160
+ { __typename: 'User', id: 1, name: 'Alice' },
161
+ { __typename: 'User', id: 2, name: 'Bob' },
162
+ { __typename: 'User', id: 1, name: 'Alice' }, // Duplicate
163
+ { __typename: 'User', id: 3, name: 'Charlie' },
164
+ { __typename: 'User', id: 2, name: 'Bob' }, // Another duplicate
165
+ ],
166
+ });
167
+
168
+ await testWithClient(client, async () => {
169
+ const getUsers = query(t => ({
170
+ path: '/users',
171
+ response: {
172
+ users: t.array(User),
173
+ },
174
+ }));
175
+
176
+ const relay = getUsers();
177
+ const result = await relay;
178
+
179
+ // Verify array length
180
+ expect(result.users).toHaveLength(5);
181
+
182
+ // Should only have 3 unique entities in array (deduplication works)
183
+ expect(new Set(result.users).size).toBe(3);
184
+ });
185
+ });
186
+
187
+ it('should share entities across multiple queries', async () => {
188
+ const User = entity(() => ({
189
+ __typename: t.typename('User'),
190
+ id: t.id,
191
+ name: t.string,
192
+ }));
193
+
194
+ mockFetch.get('/users/[id]', {
195
+ user: { __typename: 'User', id: 1, name: 'Alice' },
196
+ });
197
+
198
+ mockFetch.get('/users', {
199
+ users: [
200
+ { __typename: 'User', id: 1, name: 'Alice' },
201
+ { __typename: 'User', id: 2, name: 'Bob' },
202
+ ],
203
+ });
204
+
205
+ mockFetch.get('/author', {
206
+ author: { __typename: 'User', id: 1, name: 'Alice' },
207
+ });
208
+
209
+ await testWithClient(client, async () => {
210
+ const getUser = query(t => ({
211
+ path: '/users/[id]',
212
+ response: { user: User },
213
+ }));
214
+
215
+ const listUsers = query(t => ({
216
+ path: '/users',
217
+ response: { users: t.array(User) },
218
+ }));
219
+
220
+ const getAuthor = query(t => ({
221
+ path: '/author',
222
+ response: { author: User },
223
+ }));
224
+
225
+ const relay1 = getUser({ id: '1' });
226
+ const result1 = await relay1;
227
+
228
+ const relay2 = listUsers();
229
+ const result2 = await relay2;
230
+
231
+ const relay3 = getAuthor();
232
+ const result3 = await relay3;
233
+
234
+ // All three should reference the same User entity (id: 1)
235
+ expect(result1.user).toBe(result2.users[0]);
236
+ expect(result1.user).toBe(result3.author);
237
+ });
238
+ });
239
+ });
240
+
241
+ describe('Nested Entities', () => {
242
+ it('should parse deeply nested entities', async () => {
243
+ const Address = entity(() => ({
244
+ __typename: t.typename('Address'),
245
+ id: t.id,
246
+ city: t.string,
247
+ country: t.string,
248
+ }));
249
+
250
+ const Company = entity(() => ({
251
+ __typename: t.typename('Company'),
252
+ id: t.id,
253
+ name: t.string,
254
+ address: Address,
255
+ }));
256
+
257
+ const User = entity(() => ({
258
+ __typename: t.typename('User'),
259
+ id: t.id,
260
+ name: t.string,
261
+ company: Company,
262
+ }));
263
+
264
+ mockFetch.get('/users/[id]', {
265
+ user: {
266
+ __typename: 'User',
267
+ id: 1,
268
+ name: 'Alice',
269
+ company: {
270
+ __typename: 'Company',
271
+ id: 1,
272
+ name: 'Tech Corp',
273
+ address: {
274
+ __typename: 'Address',
275
+ id: 1,
276
+ city: 'San Francisco',
277
+ country: 'USA',
278
+ },
279
+ },
280
+ },
281
+ });
282
+
283
+ await testWithClient(client, async () => {
284
+ const getUser = query(t => ({
285
+ path: '/users/[id]',
286
+ response: { user: User },
287
+ }));
288
+
289
+ const relay = getUser({ id: '1' });
290
+ const result = await relay;
291
+
292
+ // Access deeply nested property
293
+ expect(result.user.company.address.city).toBe('San Francisco');
294
+
295
+ // All three entities should be in the map
296
+ expect(getEntityMapSize(client)).toBe(3);
297
+ });
298
+ });
299
+
300
+ it('should handle entities with multiple nested arrays', async () => {
301
+ const Post = entity(() => ({
302
+ __typename: t.typename('Post'),
303
+ id: t.id,
304
+ title: t.string,
305
+ }));
306
+
307
+ const User = entity(() => ({
308
+ __typename: t.typename('User'),
309
+ id: t.id,
310
+ name: t.string,
311
+ posts: t.array(Post),
312
+ }));
313
+
314
+ mockFetch.get('/users/[id]', {
315
+ user: {
316
+ __typename: 'User',
317
+ id: 1,
318
+ name: 'Alice',
319
+ posts: [
320
+ {
321
+ __typename: 'Post',
322
+ id: 1,
323
+ title: 'First Post',
324
+ },
325
+ {
326
+ __typename: 'Post',
327
+ id: 2,
328
+ title: 'Second Post',
329
+ },
330
+ ],
331
+ },
332
+ });
333
+
334
+ await testWithClient(client, async () => {
335
+ const getUser = query(t => ({
336
+ path: '/users/[id]',
337
+ response: { user: User },
338
+ }));
339
+
340
+ const relay = getUser({ id: '1' });
341
+ const result = await relay;
342
+
343
+ // Verify user entity
344
+ expect(result.user.name).toBe('Alice');
345
+
346
+ // Verify posts array
347
+ expect(result.user.posts).toHaveLength(2);
348
+ expect(result.user.posts[0].title).toBe('First Post');
349
+ expect(result.user.posts[1].title).toBe('Second Post');
350
+ });
351
+ });
352
+ });
353
+
354
+ describe('Entity Parsing Functions', () => {
355
+ it('should parse object entities correctly', async () => {
356
+ const User = entity(() => ({
357
+ __typename: t.typename('User'),
358
+ id: t.id,
359
+ name: t.string,
360
+ }));
361
+
362
+ const entityRefs = new Set<number>();
363
+ const data = {
364
+ __typename: 'User',
365
+ id: 1,
366
+ name: 'Test',
367
+ };
368
+
369
+ const result = await parseObjectEntities(data, User, client, entityRefs);
370
+
371
+ // Should return proxy
372
+ expect(getEntityMapSize(client)).toBe(1);
373
+ expect(entityRefs.size).toBe(1);
374
+
375
+ // Result should be a proxy
376
+ const userKey = hashValue('User:1');
377
+ expect(entityRefs.has(userKey)).toBe(true);
378
+ });
379
+
380
+ it('should parse array entities correctly', async () => {
381
+ const User = entity(() => ({
382
+ __typename: t.typename('User'),
383
+ id: t.id,
384
+ name: t.string,
385
+ }));
386
+
387
+ const entityRefs = new Set<number>();
388
+ const data = [
389
+ { __typename: 'User', id: 1, name: 'Alice' },
390
+ { __typename: 'User', id: 2, name: 'Bob' },
391
+ ];
392
+
393
+ const result = await parseArrayEntities(data, User, client, entityRefs);
394
+
395
+ // Should have parsed 2 entities
396
+ expect(getEntityMapSize(client)).toBe(2);
397
+ expect(entityRefs.size).toBe(2);
398
+ });
399
+
400
+ it('should parse nested structures with mixed entities', async () => {
401
+ const User = entity(() => ({
402
+ __typename: t.typename('User'),
403
+ id: t.id,
404
+ name: t.string,
405
+ }));
406
+
407
+ const shape = t.object({
408
+ users: t.array(User),
409
+ admin: User,
410
+ });
411
+
412
+ const entityRefs = new Set<number>();
413
+ const data = {
414
+ users: [
415
+ { __typename: 'User', id: 1, name: 'Alice' },
416
+ { __typename: 'User', id: 2, name: 'Bob' },
417
+ ],
418
+ admin: { __typename: 'User', id: 1, name: 'Alice' },
419
+ };
420
+
421
+ const result = await parseEntities(data, shape, client, entityRefs);
422
+
423
+ // Should deduplicate the admin (same as users[0])
424
+ expect(getEntityMapSize(client)).toBe(2);
425
+ });
426
+
427
+ it('should handle entities in records/dictionaries', async () => {
428
+ const User = entity(() => ({
429
+ __typename: t.typename('User'),
430
+ id: t.id,
431
+ name: t.string,
432
+ }));
433
+
434
+ mockFetch.get('/users/map', {
435
+ userMap: {
436
+ alice: { __typename: 'User', id: 1, name: 'Alice' },
437
+ bob: { __typename: 'User', id: 2, name: 'Bob' },
438
+ },
439
+ });
440
+
441
+ await testWithClient(client, async () => {
442
+ const getUserMap = query(t => ({
443
+ path: '/users/map',
444
+ response: {
445
+ userMap: t.record(User),
446
+ },
447
+ }));
448
+
449
+ const relay = getUserMap();
450
+ const result = await relay;
451
+
452
+ expect(result.userMap.alice.name).toBe('Alice');
453
+ expect(result.userMap.bob.name).toBe('Bob');
454
+
455
+ expect(getEntityMapSize(client)).toBe(2);
456
+ });
457
+ });
458
+
459
+ it('should handle union types with entities', async () => {
460
+ type TextPost = ExtractType<typeof TextPost>;
461
+ const TextPost = entity(() => ({
462
+ __typename: t.typename('TextPost'),
463
+ id: t.id,
464
+ content: t.string,
465
+ }));
466
+
467
+ type ImagePost = ExtractType<typeof ImagePost>;
468
+ const ImagePost = entity(() => ({
469
+ __typename: t.typename('ImagePost'),
470
+ id: t.id,
471
+ url: t.string,
472
+ }));
473
+
474
+ const PostUnion = t.union(TextPost, ImagePost);
475
+
476
+ mockFetch.get('/posts', {
477
+ posts: [
478
+ { __typename: 'TextPost', type: 'text', id: '1', content: 'Hello' },
479
+ { __typename: 'ImagePost', type: 'image', id: '2', url: '/img.jpg' },
480
+ { __typename: 'TextPost', type: 'text', id: '3', content: 'World' },
481
+ ],
482
+ });
483
+
484
+ await testWithClient(client, async () => {
485
+ const getPosts = query(t => ({
486
+ path: '/posts',
487
+ response: {
488
+ posts: t.array(PostUnion),
489
+ },
490
+ }));
491
+
492
+ const relay = getPosts();
493
+ const result = await relay;
494
+
495
+ expect(result.posts).toHaveLength(3);
496
+
497
+ const post1 = result.posts[0] as TextPost;
498
+ const post2 = result.posts[1] as ImagePost;
499
+
500
+ expect(post1.__typename).toBe('TextPost');
501
+ expect(post1.content).toBe('Hello');
502
+
503
+ expect(post2.__typename).toBe('ImagePost');
504
+ expect(post2.url).toBe('/img.jpg');
505
+
506
+ expect(getEntityMapSize(client)).toBe(3);
507
+ });
508
+ });
509
+ });
510
+
511
+ describe('Entity Map Management', () => {
512
+ it('should maintain entity map across queries', async () => {
513
+ const User = entity(() => ({
514
+ __typename: t.typename('User'),
515
+ id: t.id,
516
+ name: t.string,
517
+ }));
518
+
519
+ mockFetch.get('/users', {
520
+ users: [
521
+ { __typename: 'User', id: 1, name: 'Alice' },
522
+ { __typename: 'User', id: 2, name: 'Bob' },
523
+ ],
524
+ });
525
+
526
+ await testWithClient(client, async () => {
527
+ const listUsers = query(t => ({
528
+ path: '/users',
529
+ response: { users: t.array(User) },
530
+ }));
531
+
532
+ // First query
533
+ const relay = listUsers();
534
+ await relay;
535
+
536
+ expect(getEntityMapSize(client)).toBe(2);
537
+
538
+ mockFetch.get('/users', {
539
+ users: [
540
+ { __typename: 'User', id: 3, name: 'Charlie' },
541
+ { __typename: 'User', id: 4, name: 'David' },
542
+ ],
543
+ });
544
+
545
+ const result = await relay.refetch();
546
+
547
+ // The entity map should now have 4 entities
548
+ expect(getEntityMapSize(client)).toBe(4);
549
+ });
550
+ });
551
+ });
552
+ });