@signalium/query 0.0.0 → 0.0.2
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/.turbo/turbo-build.log +12 -0
- package/CHANGELOG.md +17 -0
- package/dist/cjs/EntityMap.js +46 -0
- package/dist/cjs/EntityMap.js.map +1 -0
- package/dist/cjs/QueryClient.js +368 -0
- package/dist/cjs/QueryClient.js.map +1 -0
- package/dist/cjs/QueryStore.js +222 -0
- package/dist/cjs/QueryStore.js.map +1 -0
- package/dist/cjs/errors.js +105 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/index.js +24 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/parseEntities.js +127 -0
- package/dist/cjs/parseEntities.js.map +1 -0
- package/dist/cjs/pathInterpolator.js +69 -0
- package/dist/cjs/pathInterpolator.js.map +1 -0
- package/dist/cjs/proxy.js +187 -0
- package/dist/cjs/proxy.js.map +1 -0
- package/dist/cjs/query.js +41 -0
- package/dist/cjs/query.js.map +1 -0
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -0
- package/dist/cjs/typeDefs.js +249 -0
- package/dist/cjs/typeDefs.js.map +1 -0
- package/dist/cjs/types.js +15 -0
- package/dist/cjs/types.js.map +1 -0
- package/dist/cjs/utils.js +65 -0
- package/dist/cjs/utils.js.map +1 -0
- package/dist/esm/EntityMap.d.ts +18 -0
- package/dist/esm/EntityMap.d.ts.map +1 -0
- package/dist/esm/EntityMap.js +42 -0
- package/dist/esm/EntityMap.js.map +1 -0
- package/dist/esm/QueryClient.d.ts +123 -0
- package/dist/esm/QueryClient.d.ts.map +1 -0
- package/dist/esm/QueryClient.js +363 -0
- package/dist/esm/QueryClient.js.map +1 -0
- package/dist/esm/QueryStore.d.ts +77 -0
- package/dist/esm/QueryStore.d.ts.map +1 -0
- package/dist/esm/QueryStore.js +212 -0
- package/dist/esm/QueryStore.js.map +1 -0
- package/dist/esm/errors.d.ts +4 -0
- package/dist/esm/errors.d.ts.map +1 -0
- package/dist/esm/errors.js +101 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/index.d.ts +7 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/parseEntities.d.ts +8 -0
- package/dist/esm/parseEntities.d.ts.map +1 -0
- package/dist/esm/parseEntities.js +120 -0
- package/dist/esm/parseEntities.js.map +1 -0
- package/dist/esm/pathInterpolator.d.ts +29 -0
- package/dist/esm/pathInterpolator.d.ts.map +1 -0
- package/dist/esm/pathInterpolator.js +66 -0
- package/dist/esm/pathInterpolator.js.map +1 -0
- package/dist/esm/proxy.d.ts +8 -0
- package/dist/esm/proxy.d.ts.map +1 -0
- package/dist/esm/proxy.js +180 -0
- package/dist/esm/proxy.js.map +1 -0
- package/dist/esm/query.d.ts +41 -0
- package/dist/esm/query.d.ts.map +1 -0
- package/dist/esm/query.js +38 -0
- package/dist/esm/query.js.map +1 -0
- package/dist/esm/typeDefs.d.ts +25 -0
- package/dist/esm/typeDefs.d.ts.map +1 -0
- package/dist/esm/typeDefs.js +239 -0
- package/dist/esm/typeDefs.js.map +1 -0
- package/dist/esm/types.d.ts +96 -0
- package/dist/esm/types.d.ts.map +1 -0
- package/dist/esm/types.js +12 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/utils.d.ts +6 -0
- package/dist/esm/utils.d.ts.map +1 -0
- package/dist/esm/utils.js +60 -0
- package/dist/esm/utils.js.map +1 -0
- package/dist/tsconfig.esm.tsbuildinfo +1 -0
- package/package.json +3 -3
- package/src/QueryClient.ts +321 -105
- package/src/QueryStore.ts +15 -7
- package/src/__tests__/caching-persistence.test.ts +31 -2
- package/src/__tests__/entity-system.test.ts +5 -1
- package/src/__tests__/gc-time.test.ts +327 -0
- package/src/__tests__/mock-fetch.test.ts +8 -4
- package/src/__tests__/parse-entities.test.ts +5 -1
- package/src/__tests__/reactivity.test.ts +5 -1
- package/src/__tests__/refetch-interval.test.ts +262 -0
- package/src/__tests__/rest-query-api.test.ts +5 -1
- package/src/__tests__/stale-time.test.ts +357 -0
- package/src/__tests__/utils.ts +28 -12
- package/src/__tests__/validation-edge-cases.test.ts +1 -0
- package/src/query.ts +2 -1
- package/src/react/__tests__/basic.test.tsx +9 -4
- package/src/react/__tests__/component.test.tsx +10 -3
- package/src/types.ts +11 -0
- package/vitest.config.ts +4 -10
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { SyncQueryStore, MemoryPersistentStore, valueKeyFor } from '../QueryStore.js';
|
|
4
|
+
import { QueryClient } from '../QueryClient.js';
|
|
5
|
+
import { query } from '../query.js';
|
|
6
|
+
import { t, entity } from '../typeDefs.js';
|
|
7
|
+
import { createMockFetch, testWithClient, sleep } from './utils.js';
|
|
8
|
+
import { hashValue } from 'signalium/utils';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GC Time Tests
|
|
12
|
+
*
|
|
13
|
+
* Tests gcTime-based garbage collection with sorted queue management,
|
|
14
|
+
* LRU interaction, and subscriber-aware eviction
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
describe('GC Time', () => {
|
|
18
|
+
let client: QueryClient;
|
|
19
|
+
let mockFetch: ReturnType<typeof createMockFetch>;
|
|
20
|
+
let kv: any;
|
|
21
|
+
let store: any;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
kv = new MemoryPersistentStore();
|
|
25
|
+
store = new SyncQueryStore(kv);
|
|
26
|
+
mockFetch = createMockFetch();
|
|
27
|
+
client = new QueryClient(store, { fetch: mockFetch as any, evictionMultiplier: 0.001 });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
client?.destroy();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('Basic GC', () => {
|
|
35
|
+
it('should evict queries from disk after gcTime expires', async () => {
|
|
36
|
+
const getItem = query(t => ({
|
|
37
|
+
path: '/item/[id]',
|
|
38
|
+
response: { id: t.number, name: t.string },
|
|
39
|
+
cache: { gcTime: 100, staleTime: 50 }, // 1 second
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
mockFetch.get('/item/1', { id: 1, name: 'Item 1' });
|
|
43
|
+
|
|
44
|
+
await testWithClient(client, async () => {
|
|
45
|
+
const relay = getItem({ id: '1' });
|
|
46
|
+
expect(relay.value).toEqual(undefined);
|
|
47
|
+
await relay;
|
|
48
|
+
expect(relay.value).toEqual({ id: 1, name: 'Item 1' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await sleep(75);
|
|
52
|
+
|
|
53
|
+
mockFetch.get('/item/1', { id: 1, name: 'Item 1 updated' }, { delay: 50 });
|
|
54
|
+
await testWithClient(client, async () => {
|
|
55
|
+
const relay = getItem({ id: '1' });
|
|
56
|
+
expect(relay.value).toEqual({ id: 1, name: 'Item 1' });
|
|
57
|
+
await relay;
|
|
58
|
+
expect(relay.value).toEqual({ id: 1, name: 'Item 1' });
|
|
59
|
+
|
|
60
|
+
await sleep(60);
|
|
61
|
+
expect(relay.value).toEqual({ id: 1, name: 'Item 1 updated' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await sleep(200);
|
|
65
|
+
|
|
66
|
+
await testWithClient(client, async () => {
|
|
67
|
+
const relay = getItem({ id: '1' });
|
|
68
|
+
expect(relay.value).toEqual(undefined);
|
|
69
|
+
await relay;
|
|
70
|
+
expect(relay.value).toEqual({ id: 1, name: 'Item 1 updated' });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should NOT evict queries with active subscribers', async () => {
|
|
75
|
+
const getItem = query(t => ({
|
|
76
|
+
path: '/active',
|
|
77
|
+
response: { data: t.string },
|
|
78
|
+
cache: { gcTime: 50 },
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
mockFetch.get('/active', { data: 'test' });
|
|
82
|
+
|
|
83
|
+
// Keep query active
|
|
84
|
+
await testWithClient(client, async () => {
|
|
85
|
+
const relay = getItem();
|
|
86
|
+
await relay;
|
|
87
|
+
|
|
88
|
+
const queryKey = hashValue(['GET:/active', undefined]);
|
|
89
|
+
|
|
90
|
+
// Wait past GC time
|
|
91
|
+
await sleep(60);
|
|
92
|
+
|
|
93
|
+
// Should still be in memory because it's active (has subscriber)
|
|
94
|
+
expect(client.queryInstances.has(queryKey)).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
}, 3000);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('GC with LRU', () => {
|
|
100
|
+
it('should work alongside LRU cache eviction', async () => {
|
|
101
|
+
const User = entity(() => ({
|
|
102
|
+
__typename: t.typename('User'),
|
|
103
|
+
id: t.id,
|
|
104
|
+
name: t.string,
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
const getUser = query(t => ({
|
|
108
|
+
path: '/users/[id]',
|
|
109
|
+
response: { user: User },
|
|
110
|
+
cache: {
|
|
111
|
+
maxCount: 2, // LRU size
|
|
112
|
+
gcTime: 5000, // 5 seconds
|
|
113
|
+
},
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
mockFetch.get('/users/1', { user: { __typename: 'User', id: 1, name: 'User 1' } });
|
|
117
|
+
mockFetch.get('/users/2', { user: { __typename: 'User', id: 2, name: 'User 2' } });
|
|
118
|
+
mockFetch.get('/users/3', { user: { __typename: 'User', id: 3, name: 'User 3' } });
|
|
119
|
+
|
|
120
|
+
await testWithClient(client, async () => {
|
|
121
|
+
// Fetch 3 users - third should evict first from disk via LRU
|
|
122
|
+
const relay1 = getUser({ id: '1' });
|
|
123
|
+
await relay1;
|
|
124
|
+
|
|
125
|
+
const relay2 = getUser({ id: '2' });
|
|
126
|
+
await relay2;
|
|
127
|
+
|
|
128
|
+
const relay3 = getUser({ id: '3' });
|
|
129
|
+
await relay3;
|
|
130
|
+
|
|
131
|
+
const query1Key = hashValue(['GET:/users/[id]', { id: '1' }]);
|
|
132
|
+
const query2Key = hashValue(['GET:/users/[id]', { id: '2' }]);
|
|
133
|
+
const query3Key = hashValue(['GET:/users/[id]', { id: '3' }]);
|
|
134
|
+
|
|
135
|
+
// All should be in memory initially
|
|
136
|
+
expect(client.queryInstances.has(query1Key)).toBe(true);
|
|
137
|
+
expect(client.queryInstances.has(query2Key)).toBe(true);
|
|
138
|
+
expect(client.queryInstances.has(query3Key)).toBe(true);
|
|
139
|
+
|
|
140
|
+
// First query should be evicted from DISK by LRU (but still in memory)
|
|
141
|
+
expect(kv.getString(valueKeyFor(query1Key))).toBeUndefined();
|
|
142
|
+
expect(kv.getString(valueKeyFor(query2Key))).toBeDefined();
|
|
143
|
+
expect(kv.getString(valueKeyFor(query3Key))).toBeDefined();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('GC Queue Management', () => {
|
|
149
|
+
it('should add queries to GC queue when deactivated', async () => {
|
|
150
|
+
const getItem = query(t => ({
|
|
151
|
+
path: '/item',
|
|
152
|
+
response: { value: t.string },
|
|
153
|
+
cache: { gcTime: 2000 },
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
mockFetch.get('/item', { value: 'test' });
|
|
157
|
+
|
|
158
|
+
const queryKey = hashValue(['GET:/item', undefined]);
|
|
159
|
+
|
|
160
|
+
await testWithClient(client, async () => {
|
|
161
|
+
const relay = getItem();
|
|
162
|
+
await relay;
|
|
163
|
+
|
|
164
|
+
expect(client.queryInstances.has(queryKey)).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// After context ends, query should be scheduled for GC
|
|
168
|
+
// In a real implementation, we'd check the GC queue
|
|
169
|
+
// For now, we verify the query is still in memory
|
|
170
|
+
expect(client.queryInstances.has(queryKey)).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should remove queries from GC queue when reactivated', async () => {
|
|
174
|
+
const getItem = query(t => ({
|
|
175
|
+
path: '/reactivate',
|
|
176
|
+
response: { n: t.number },
|
|
177
|
+
cache: { gcTime: 1000 },
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
mockFetch.get('/reactivate', { n: 1 });
|
|
181
|
+
|
|
182
|
+
const queryKey = hashValue(['GET:/reactivate', undefined]);
|
|
183
|
+
|
|
184
|
+
await testWithClient(client, async () => {
|
|
185
|
+
const relay = getItem();
|
|
186
|
+
await relay;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Query deactivated, scheduled for GC
|
|
190
|
+
await sleep(40);
|
|
191
|
+
|
|
192
|
+
// Reactivate before GC
|
|
193
|
+
mockFetch.get('/reactivate', { n: 2 });
|
|
194
|
+
await testWithClient(client, async () => {
|
|
195
|
+
const relay = getItem();
|
|
196
|
+
relay.value; // Access it
|
|
197
|
+
await sleep(60);
|
|
198
|
+
|
|
199
|
+
// Should still be in memory
|
|
200
|
+
expect(client.queryInstances.has(queryKey)).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Even after original GC time, should not be evicted due to reactivation
|
|
204
|
+
await sleep(40);
|
|
205
|
+
expect(client.queryInstances.has(queryKey)).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('GC with Entities', () => {
|
|
210
|
+
it("should handle entity cleanup when query is GC'd", async () => {
|
|
211
|
+
const Post = entity(() => ({
|
|
212
|
+
__typename: t.typename('Post'),
|
|
213
|
+
id: t.id,
|
|
214
|
+
title: t.string,
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
const User = entity(() => ({
|
|
218
|
+
__typename: t.typename('User'),
|
|
219
|
+
id: t.id,
|
|
220
|
+
name: t.string,
|
|
221
|
+
post: Post,
|
|
222
|
+
}));
|
|
223
|
+
|
|
224
|
+
const getUser = query(t => ({
|
|
225
|
+
path: '/user',
|
|
226
|
+
response: { user: User },
|
|
227
|
+
cache: { gcTime: 1000 },
|
|
228
|
+
}));
|
|
229
|
+
|
|
230
|
+
mockFetch.get('/user', {
|
|
231
|
+
user: {
|
|
232
|
+
__typename: 'User',
|
|
233
|
+
id: 1,
|
|
234
|
+
name: 'Alice',
|
|
235
|
+
post: {
|
|
236
|
+
__typename: 'Post',
|
|
237
|
+
id: 10,
|
|
238
|
+
title: 'Test Post',
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await testWithClient(client, async () => {
|
|
244
|
+
const relay = getUser();
|
|
245
|
+
await relay;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const userKey = hashValue('User:1');
|
|
249
|
+
const postKey = hashValue('Post:10');
|
|
250
|
+
|
|
251
|
+
// Entities should exist in store
|
|
252
|
+
expect(kv.getString(valueKeyFor(userKey))).toBeDefined();
|
|
253
|
+
expect(kv.getString(valueKeyFor(postKey))).toBeDefined();
|
|
254
|
+
|
|
255
|
+
// Note: The actual GC of entities is handled by the LRU system
|
|
256
|
+
// when queries are evicted. The gcTime affects when queries
|
|
257
|
+
// are removed from memory, but disk cleanup is done by LRU.
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('Edge Cases', () => {
|
|
262
|
+
it('should handle queries without gcTime', async () => {
|
|
263
|
+
const getItem = query(t => ({
|
|
264
|
+
path: '/no-gc',
|
|
265
|
+
response: { data: t.string },
|
|
266
|
+
// No gcTime configured
|
|
267
|
+
}));
|
|
268
|
+
|
|
269
|
+
mockFetch.get('/no-gc', { data: 'test' });
|
|
270
|
+
|
|
271
|
+
const queryKey = hashValue(['GET:/no-gc', undefined]);
|
|
272
|
+
|
|
273
|
+
await testWithClient(client, async () => {
|
|
274
|
+
const relay = getItem();
|
|
275
|
+
await relay;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Should remain in memory indefinitely
|
|
279
|
+
await sleep(100);
|
|
280
|
+
expect(client.queryInstances.has(queryKey)).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should handle very short gcTime', async () => {
|
|
284
|
+
const getItem = query(t => ({
|
|
285
|
+
path: '/short-gc',
|
|
286
|
+
response: { value: t.number },
|
|
287
|
+
cache: { gcTime: 100 }, // Very short
|
|
288
|
+
}));
|
|
289
|
+
|
|
290
|
+
mockFetch.get('/short-gc', { value: 42 });
|
|
291
|
+
|
|
292
|
+
const queryKey = hashValue(['GET:/short-gc', undefined]);
|
|
293
|
+
|
|
294
|
+
await testWithClient(client, async () => {
|
|
295
|
+
const relay = getItem();
|
|
296
|
+
await relay;
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Should be scheduled for GC quickly
|
|
300
|
+
// Note: Actual eviction timing depends on GC interval
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should handle very long gcTime', async () => {
|
|
304
|
+
const getItem = query(t => ({
|
|
305
|
+
path: '/long-gc',
|
|
306
|
+
response: { data: t.string },
|
|
307
|
+
cache: { gcTime: 1000 * 60 * 60 }, // 1 hour
|
|
308
|
+
}));
|
|
309
|
+
|
|
310
|
+
mockFetch.get('/long-gc', { data: 'persisted' });
|
|
311
|
+
|
|
312
|
+
const queryKey = hashValue(['GET:/long-gc', undefined]);
|
|
313
|
+
|
|
314
|
+
await testWithClient(client, async () => {
|
|
315
|
+
const relay = getItem();
|
|
316
|
+
await relay;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Should remain in memory for a while, then be evicted
|
|
320
|
+
await sleep(40);
|
|
321
|
+
expect(client.queryInstances.has(queryKey)).toBe(true);
|
|
322
|
+
|
|
323
|
+
await sleep(100);
|
|
324
|
+
expect(client.queryInstances.has(queryKey)).toBe(false);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -155,15 +155,19 @@ describe('createMockFetch', () => {
|
|
|
155
155
|
expect(data).toEqual({ users: [] });
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
-
it('should
|
|
158
|
+
it('should reuse the last match when no unused mocks remain', async () => {
|
|
159
159
|
const mockFetch = createMockFetch();
|
|
160
160
|
mockFetch.get('/users/123', { id: 123, name: 'Alice' });
|
|
161
161
|
|
|
162
162
|
// First call should succeed
|
|
163
|
-
await mockFetch('/users/123', { method: 'GET' });
|
|
163
|
+
const response1 = await mockFetch('/users/123', { method: 'GET' });
|
|
164
|
+
const data1 = await response1.json();
|
|
165
|
+
expect(data1).toEqual({ id: 123, name: 'Alice' });
|
|
164
166
|
|
|
165
|
-
// Second call should
|
|
166
|
-
await
|
|
167
|
+
// Second call should reuse the same mock since there are no unused ones
|
|
168
|
+
const response2 = await mockFetch('/users/123', { method: 'GET' });
|
|
169
|
+
const data2 = await response2.json();
|
|
170
|
+
expect(data2).toEqual({ id: 123, name: 'Alice' });
|
|
167
171
|
});
|
|
168
172
|
|
|
169
173
|
it('should allow multiple setups for repeated calls', async () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { SyncQueryStore, MemoryPersistentStore, valueKeyFor, refIdsKeyFor, refCountKeyFor } from '../QueryStore.js';
|
|
3
3
|
import { QueryClient } from '../QueryClient.js';
|
|
4
4
|
import { entity, t } from '../typeDefs.js';
|
|
@@ -23,6 +23,10 @@ describe('Parse Entities', () => {
|
|
|
23
23
|
store = queryStore;
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
client?.destroy();
|
|
28
|
+
});
|
|
29
|
+
|
|
26
30
|
describe('nested entities', () => {
|
|
27
31
|
it('should track refs for deeply nested entities (A->B->C)', async () => {
|
|
28
32
|
const EntityC = entity(() => ({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { SyncQueryStore, MemoryPersistentStore } from '../QueryStore.js';
|
|
3
3
|
import { QueryClient, QueryClientContext } from '../QueryClient.js';
|
|
4
4
|
import { entity, t } from '../typeDefs.js';
|
|
@@ -26,6 +26,10 @@ describe('Signalium Reactivity', () => {
|
|
|
26
26
|
client = new QueryClient(store, { fetch: mockFetch as any });
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
client?.destroy();
|
|
31
|
+
});
|
|
32
|
+
|
|
29
33
|
describe('Relay Lifecycle', () => {
|
|
30
34
|
it('should start relay in pending state', async () => {
|
|
31
35
|
mockFetch.get('/item', { data: 'test' }, { delay: 100 });
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
|
2
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
3
|
+
import { SyncQueryStore, MemoryPersistentStore } from '../QueryStore.js';
|
|
4
|
+
import { QueryClient } from '../QueryClient.js';
|
|
5
|
+
import { query } from '../query.js';
|
|
6
|
+
import { RefetchInterval } from '../types.js';
|
|
7
|
+
import { createMockFetch, testWithClient, sleep } from './utils.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* RefetchInterval Tests
|
|
11
|
+
*
|
|
12
|
+
* Tests refetchInterval with dynamic GCD-based timer management,
|
|
13
|
+
* subscriber tracking, exponential backoff, and no overlapping fetches
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
describe('RefetchInterval', () => {
|
|
17
|
+
let client: QueryClient;
|
|
18
|
+
let mockFetch: ReturnType<typeof createMockFetch>;
|
|
19
|
+
let kv: any;
|
|
20
|
+
let store: any;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
client?.destroy();
|
|
24
|
+
kv = new MemoryPersistentStore();
|
|
25
|
+
store = new SyncQueryStore(kv);
|
|
26
|
+
mockFetch = createMockFetch();
|
|
27
|
+
client = new QueryClient(store, { fetch: mockFetch as any, refetchMultiplier: 0.1 });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
client?.destroy();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('Basic Refetch Interval', () => {
|
|
35
|
+
it('should refetch at specified interval', async () => {
|
|
36
|
+
let callCount = 0;
|
|
37
|
+
mockFetch.get('/counter', () => ({ count: ++callCount }));
|
|
38
|
+
|
|
39
|
+
const getCounter = query(t => ({
|
|
40
|
+
path: '/counter',
|
|
41
|
+
response: { count: t.number },
|
|
42
|
+
cache: { refetchInterval: RefetchInterval.Every1Second },
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
await testWithClient(client, async () => {
|
|
46
|
+
const relay = getCounter();
|
|
47
|
+
await relay;
|
|
48
|
+
expect(relay.value).toEqual({ count: 1 });
|
|
49
|
+
|
|
50
|
+
// Wait for interval to trigger (100ms with 0.1 multiplier + buffer)
|
|
51
|
+
await sleep(120);
|
|
52
|
+
expect(relay.value?.count).toBeGreaterThan(1);
|
|
53
|
+
|
|
54
|
+
// Wait for another interval
|
|
55
|
+
await sleep(110);
|
|
56
|
+
expect(relay.value?.count).toBeGreaterThan(2);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should stop refetching when query is no longer accessed', async () => {
|
|
61
|
+
let callCount = 0;
|
|
62
|
+
mockFetch.get('/item', () => ({ n: ++callCount }));
|
|
63
|
+
|
|
64
|
+
const getItem = query(t => ({
|
|
65
|
+
path: '/item',
|
|
66
|
+
response: { n: t.number },
|
|
67
|
+
cache: { refetchInterval: RefetchInterval.Every1Second },
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
await testWithClient(client, async () => {
|
|
71
|
+
const relay = getItem();
|
|
72
|
+
await relay;
|
|
73
|
+
const initialCount = relay.value!.n;
|
|
74
|
+
|
|
75
|
+
// Wait a bit (250ms with 0.1 multiplier = ~2.5 intervals)
|
|
76
|
+
await sleep(250);
|
|
77
|
+
const afterCount = relay.value!.n;
|
|
78
|
+
|
|
79
|
+
expect(afterCount).toBeGreaterThan(initialCount);
|
|
80
|
+
|
|
81
|
+
await sleep(250);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// After context ends, wait and check that no more calls happen
|
|
85
|
+
const countBeforeWait = callCount;
|
|
86
|
+
await sleep(200);
|
|
87
|
+
|
|
88
|
+
// Note: This test is simplified - in a real implementation,
|
|
89
|
+
// subscriber tracking would need proper cleanup
|
|
90
|
+
// For now we just verify the basic interval works
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('Multiple Intervals with GCD', () => {
|
|
95
|
+
it('should handle multiple queries with different intervals efficiently', async () => {
|
|
96
|
+
let count1 = 0;
|
|
97
|
+
let count5 = 0;
|
|
98
|
+
|
|
99
|
+
mockFetch.get('/every1s', () => ({ count: ++count1 }));
|
|
100
|
+
mockFetch.get('/every5s', () => ({ count: ++count5 }));
|
|
101
|
+
|
|
102
|
+
const getEvery1s = query(t => ({
|
|
103
|
+
path: '/every1s',
|
|
104
|
+
response: { count: t.number },
|
|
105
|
+
cache: { refetchInterval: RefetchInterval.Every1Second },
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
const getEvery5s = query(t => ({
|
|
109
|
+
path: '/every5s',
|
|
110
|
+
response: { count: t.number },
|
|
111
|
+
cache: { refetchInterval: RefetchInterval.Every5Seconds },
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
await testWithClient(client, async () => {
|
|
115
|
+
const relay1s = getEvery1s();
|
|
116
|
+
const relay5s = getEvery5s();
|
|
117
|
+
|
|
118
|
+
await relay1s;
|
|
119
|
+
await relay5s;
|
|
120
|
+
|
|
121
|
+
// Wait and verify different refetch rates (350ms = 3.5 intervals of 1s)
|
|
122
|
+
await sleep(350);
|
|
123
|
+
|
|
124
|
+
// 1s query should have refetched ~3 times
|
|
125
|
+
expect(count1).toBeGreaterThanOrEqual(3);
|
|
126
|
+
expect(count1).toBeLessThanOrEqual(5);
|
|
127
|
+
|
|
128
|
+
// 5s query should have refetched 0-1 times
|
|
129
|
+
expect(count5).toBeGreaterThanOrEqual(1);
|
|
130
|
+
expect(count5).toBeLessThanOrEqual(2);
|
|
131
|
+
|
|
132
|
+
// Wait for 5s interval (200ms more)
|
|
133
|
+
await sleep(200);
|
|
134
|
+
|
|
135
|
+
// 5s query should now have refetched
|
|
136
|
+
expect(count5).toBeGreaterThanOrEqual(2);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should use GCD for multiple queries with compatible intervals', async () => {
|
|
141
|
+
// Every5Seconds and Every10Seconds should use GCD of 5s
|
|
142
|
+
let count5 = 0;
|
|
143
|
+
let count10 = 0;
|
|
144
|
+
|
|
145
|
+
mockFetch.get('/5s', () => ({ n: ++count5 }));
|
|
146
|
+
mockFetch.get('/10s', () => ({ n: ++count10 }));
|
|
147
|
+
|
|
148
|
+
const get5s = query(t => ({
|
|
149
|
+
path: '/5s',
|
|
150
|
+
response: { n: t.number },
|
|
151
|
+
cache: { refetchInterval: RefetchInterval.Every5Seconds },
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
const get10s = query(t => ({
|
|
155
|
+
path: '/10s',
|
|
156
|
+
response: { n: t.number },
|
|
157
|
+
cache: { refetchInterval: RefetchInterval.Every10Seconds },
|
|
158
|
+
}));
|
|
159
|
+
|
|
160
|
+
await testWithClient(client, async () => {
|
|
161
|
+
const relay5 = get5s();
|
|
162
|
+
const relay10 = get10s();
|
|
163
|
+
|
|
164
|
+
await Promise.all([relay5, relay10]);
|
|
165
|
+
|
|
166
|
+
// Wait 1100ms (11 seconds at 0.1x = 1.1s)
|
|
167
|
+
await sleep(1100);
|
|
168
|
+
|
|
169
|
+
// 5s should refetch ~2 times
|
|
170
|
+
expect(count5).toBeGreaterThanOrEqual(2);
|
|
171
|
+
|
|
172
|
+
// 10s should refetch ~1 time
|
|
173
|
+
expect(count10).toBeGreaterThanOrEqual(1);
|
|
174
|
+
expect(count10).toBeLessThanOrEqual(2);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('No Overlapping Fetches', () => {
|
|
180
|
+
it('should wait for previous fetch to complete before next refetch', async () => {
|
|
181
|
+
let activeFetches = 0;
|
|
182
|
+
let maxConcurrent = 0;
|
|
183
|
+
let fetchCount = 0;
|
|
184
|
+
|
|
185
|
+
mockFetch.get('/slow', async () => {
|
|
186
|
+
activeFetches++;
|
|
187
|
+
maxConcurrent = Math.max(maxConcurrent, activeFetches);
|
|
188
|
+
fetchCount++;
|
|
189
|
+
await sleep(80); // Slow fetch (80ms = 800ms at 0.1x)
|
|
190
|
+
activeFetches--;
|
|
191
|
+
return { count: fetchCount };
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const getSlow = query(t => ({
|
|
195
|
+
path: '/slow',
|
|
196
|
+
response: { count: t.number },
|
|
197
|
+
cache: { refetchInterval: RefetchInterval.Every1Second },
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
await testWithClient(client, async () => {
|
|
201
|
+
const relay = getSlow();
|
|
202
|
+
await relay;
|
|
203
|
+
|
|
204
|
+
// Wait for several intervals (350ms = 3.5 intervals)
|
|
205
|
+
await sleep(350);
|
|
206
|
+
|
|
207
|
+
// Should never have overlapping fetches
|
|
208
|
+
expect(maxConcurrent).toBe(1);
|
|
209
|
+
|
|
210
|
+
// Should have attempted multiple fetches but not overlapping
|
|
211
|
+
expect(fetchCount).toBeGreaterThan(1);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('Edge Cases', () => {
|
|
217
|
+
it('should handle query without refetchInterval', async () => {
|
|
218
|
+
let callCount = 0;
|
|
219
|
+
mockFetch.get('/no-interval', () => ({ n: ++callCount }));
|
|
220
|
+
|
|
221
|
+
const getItem = query(t => ({
|
|
222
|
+
path: '/no-interval',
|
|
223
|
+
response: { n: t.number },
|
|
224
|
+
// No refetchInterval
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
await testWithClient(client, async () => {
|
|
228
|
+
const relay = getItem();
|
|
229
|
+
await relay;
|
|
230
|
+
expect(relay.value).toEqual({ n: 1 });
|
|
231
|
+
|
|
232
|
+
// Wait a bit (200ms = 2s at 0.1x)
|
|
233
|
+
await sleep(200);
|
|
234
|
+
|
|
235
|
+
// Should not have refetched
|
|
236
|
+
expect(callCount).toBe(1);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should handle very fast intervals', async () => {
|
|
241
|
+
let callCount = 0;
|
|
242
|
+
mockFetch.get('/fast', () => ({ count: ++callCount }));
|
|
243
|
+
|
|
244
|
+
const getFast = query(t => ({
|
|
245
|
+
path: '/fast',
|
|
246
|
+
response: { count: t.number },
|
|
247
|
+
cache: { refetchInterval: RefetchInterval.Every1Second },
|
|
248
|
+
}));
|
|
249
|
+
|
|
250
|
+
await testWithClient(client, async () => {
|
|
251
|
+
const relay = getFast();
|
|
252
|
+
await relay;
|
|
253
|
+
|
|
254
|
+
// Wait 250ms (2.5 intervals at 0.1x)
|
|
255
|
+
await sleep(250);
|
|
256
|
+
|
|
257
|
+
// Should have refetched at least twice
|
|
258
|
+
expect(callCount).toBeGreaterThanOrEqual(2);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { SyncQueryStore, MemoryPersistentStore } from '../QueryStore.js';
|
|
3
3
|
import { QueryClient } from '../QueryClient.js';
|
|
4
4
|
import { entity, t } from '../typeDefs.js';
|
|
@@ -22,6 +22,10 @@ describe('REST Query API', () => {
|
|
|
22
22
|
client = new QueryClient(store, { fetch: mockFetch as any });
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
client?.destroy();
|
|
27
|
+
});
|
|
28
|
+
|
|
25
29
|
describe('Basic Query Execution', () => {
|
|
26
30
|
it('should execute a GET query with path parameters', async () => {
|
|
27
31
|
mockFetch.get('/users/[id]', { id: 123, name: 'Test User' });
|