@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.
- package/ENTITY_STORE_DESIGN.md +386 -0
- package/package.json +71 -0
- package/src/EntityMap.ts +63 -0
- package/src/QueryClient.ts +266 -0
- package/src/QueryStore.ts +314 -0
- package/src/__tests__/caching-persistence.test.ts +954 -0
- package/src/__tests__/entity-system.test.ts +552 -0
- package/src/__tests__/mock-fetch.test.ts +182 -0
- package/src/__tests__/parse-entities.test.ts +421 -0
- package/src/__tests__/path-interpolation.test.ts +225 -0
- package/src/__tests__/reactivity.test.ts +420 -0
- package/src/__tests__/rest-query-api.test.ts +564 -0
- package/src/__tests__/type-to-string.test.ts +129 -0
- package/src/__tests__/utils.ts +242 -0
- package/src/__tests__/validation-edge-cases.test.ts +820 -0
- package/src/errors.ts +124 -0
- package/src/index.ts +7 -0
- package/src/parseEntities.ts +213 -0
- package/src/pathInterpolator.ts +74 -0
- package/src/proxy.ts +257 -0
- package/src/query.ts +163 -0
- package/src/react/__tests__/basic.test.tsx +921 -0
- package/src/react/__tests__/component.test.tsx +977 -0
- package/src/react/__tests__/utils.tsx +71 -0
- package/src/typeDefs.ts +351 -0
- package/src/types.ts +121 -0
- package/src/utils.ts +66 -0
- package/tsconfig.cjs.json +14 -0
- package/tsconfig.esm.json +13 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +71 -0
|
@@ -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
|
+
});
|