@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.
Files changed (95) hide show
  1. package/.turbo/turbo-build.log +12 -0
  2. package/CHANGELOG.md +17 -0
  3. package/dist/cjs/EntityMap.js +46 -0
  4. package/dist/cjs/EntityMap.js.map +1 -0
  5. package/dist/cjs/QueryClient.js +368 -0
  6. package/dist/cjs/QueryClient.js.map +1 -0
  7. package/dist/cjs/QueryStore.js +222 -0
  8. package/dist/cjs/QueryStore.js.map +1 -0
  9. package/dist/cjs/errors.js +105 -0
  10. package/dist/cjs/errors.js.map +1 -0
  11. package/dist/cjs/index.js +24 -0
  12. package/dist/cjs/index.js.map +1 -0
  13. package/dist/cjs/parseEntities.js +127 -0
  14. package/dist/cjs/parseEntities.js.map +1 -0
  15. package/dist/cjs/pathInterpolator.js +69 -0
  16. package/dist/cjs/pathInterpolator.js.map +1 -0
  17. package/dist/cjs/proxy.js +187 -0
  18. package/dist/cjs/proxy.js.map +1 -0
  19. package/dist/cjs/query.js +41 -0
  20. package/dist/cjs/query.js.map +1 -0
  21. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -0
  22. package/dist/cjs/typeDefs.js +249 -0
  23. package/dist/cjs/typeDefs.js.map +1 -0
  24. package/dist/cjs/types.js +15 -0
  25. package/dist/cjs/types.js.map +1 -0
  26. package/dist/cjs/utils.js +65 -0
  27. package/dist/cjs/utils.js.map +1 -0
  28. package/dist/esm/EntityMap.d.ts +18 -0
  29. package/dist/esm/EntityMap.d.ts.map +1 -0
  30. package/dist/esm/EntityMap.js +42 -0
  31. package/dist/esm/EntityMap.js.map +1 -0
  32. package/dist/esm/QueryClient.d.ts +123 -0
  33. package/dist/esm/QueryClient.d.ts.map +1 -0
  34. package/dist/esm/QueryClient.js +363 -0
  35. package/dist/esm/QueryClient.js.map +1 -0
  36. package/dist/esm/QueryStore.d.ts +77 -0
  37. package/dist/esm/QueryStore.d.ts.map +1 -0
  38. package/dist/esm/QueryStore.js +212 -0
  39. package/dist/esm/QueryStore.js.map +1 -0
  40. package/dist/esm/errors.d.ts +4 -0
  41. package/dist/esm/errors.d.ts.map +1 -0
  42. package/dist/esm/errors.js +101 -0
  43. package/dist/esm/errors.js.map +1 -0
  44. package/dist/esm/index.d.ts +7 -0
  45. package/dist/esm/index.d.ts.map +1 -0
  46. package/dist/esm/index.js +4 -0
  47. package/dist/esm/index.js.map +1 -0
  48. package/dist/esm/parseEntities.d.ts +8 -0
  49. package/dist/esm/parseEntities.d.ts.map +1 -0
  50. package/dist/esm/parseEntities.js +120 -0
  51. package/dist/esm/parseEntities.js.map +1 -0
  52. package/dist/esm/pathInterpolator.d.ts +29 -0
  53. package/dist/esm/pathInterpolator.d.ts.map +1 -0
  54. package/dist/esm/pathInterpolator.js +66 -0
  55. package/dist/esm/pathInterpolator.js.map +1 -0
  56. package/dist/esm/proxy.d.ts +8 -0
  57. package/dist/esm/proxy.d.ts.map +1 -0
  58. package/dist/esm/proxy.js +180 -0
  59. package/dist/esm/proxy.js.map +1 -0
  60. package/dist/esm/query.d.ts +41 -0
  61. package/dist/esm/query.d.ts.map +1 -0
  62. package/dist/esm/query.js +38 -0
  63. package/dist/esm/query.js.map +1 -0
  64. package/dist/esm/typeDefs.d.ts +25 -0
  65. package/dist/esm/typeDefs.d.ts.map +1 -0
  66. package/dist/esm/typeDefs.js +239 -0
  67. package/dist/esm/typeDefs.js.map +1 -0
  68. package/dist/esm/types.d.ts +96 -0
  69. package/dist/esm/types.d.ts.map +1 -0
  70. package/dist/esm/types.js +12 -0
  71. package/dist/esm/types.js.map +1 -0
  72. package/dist/esm/utils.d.ts +6 -0
  73. package/dist/esm/utils.d.ts.map +1 -0
  74. package/dist/esm/utils.js +60 -0
  75. package/dist/esm/utils.js.map +1 -0
  76. package/dist/tsconfig.esm.tsbuildinfo +1 -0
  77. package/package.json +3 -3
  78. package/src/QueryClient.ts +321 -105
  79. package/src/QueryStore.ts +15 -7
  80. package/src/__tests__/caching-persistence.test.ts +31 -2
  81. package/src/__tests__/entity-system.test.ts +5 -1
  82. package/src/__tests__/gc-time.test.ts +327 -0
  83. package/src/__tests__/mock-fetch.test.ts +8 -4
  84. package/src/__tests__/parse-entities.test.ts +5 -1
  85. package/src/__tests__/reactivity.test.ts +5 -1
  86. package/src/__tests__/refetch-interval.test.ts +262 -0
  87. package/src/__tests__/rest-query-api.test.ts +5 -1
  88. package/src/__tests__/stale-time.test.ts +357 -0
  89. package/src/__tests__/utils.ts +28 -12
  90. package/src/__tests__/validation-edge-cases.test.ts +1 -0
  91. package/src/query.ts +2 -1
  92. package/src/react/__tests__/basic.test.tsx +9 -4
  93. package/src/react/__tests__/component.test.tsx +10 -3
  94. package/src/types.ts +11 -0
  95. package/vitest.config.ts +4 -10
@@ -11,18 +11,16 @@
11
11
  * - Self-contained validator (no external dependencies except Signalium)
12
12
  */
13
13
 
14
- import {
15
- relay,
16
- type RelayState,
17
- context,
18
- DiscriminatedReactivePromise,
19
- type Context,
20
- notifier,
21
- Notifier,
22
- Signal,
23
- } from 'signalium';
14
+ import { relay, type RelayState, context, DiscriminatedReactivePromise, type Context, Signal, signal } from 'signalium';
24
15
  import { hashValue, setReactivePromise } from 'signalium/utils';
25
- import { DiscriminatedQueryResult, EntityDef, QueryResult, ObjectFieldTypeDef, ComplexTypeDef } from './types.js';
16
+ import {
17
+ DiscriminatedQueryResult,
18
+ EntityDef,
19
+ QueryResult,
20
+ ObjectFieldTypeDef,
21
+ ComplexTypeDef,
22
+ RefetchInterval,
23
+ } from './types.js';
26
24
  import { parseValue } from './proxy.js';
27
25
  import { parseEntities } from './parseEntities.js';
28
26
  import { EntityRecord, EntityStore } from './EntityMap.js';
@@ -31,11 +29,15 @@ import { ValidatorDef } from './typeDefs.js';
31
29
 
32
30
  export interface QueryContext {
33
31
  fetch: typeof fetch;
32
+ evictionMultiplier?: number;
33
+ refetchMultiplier?: number;
34
34
  }
35
35
 
36
36
  export interface QueryCacheOptions {
37
37
  maxCount?: number;
38
- maxAge?: number; // milliseconds
38
+ gcTime?: number; // milliseconds - only applies to on-disk/persistent storage cleanup
39
+ staleTime?: number;
40
+ refetchInterval?: RefetchInterval;
39
41
  }
40
42
 
41
43
  export interface QueryDefinition<Params, Result> {
@@ -43,35 +45,203 @@ export interface QueryDefinition<Params, Result> {
43
45
  shape: ObjectFieldTypeDef;
44
46
  fetchFn: (context: QueryContext, params: Params) => Promise<Result>;
45
47
 
46
- staleTime?: number;
47
- refetchInterval?: number;
48
-
49
48
  cache?: QueryCacheOptions;
50
49
  }
51
50
 
52
- interface QueryInstance<T> {
53
- relay: QueryResultImpl<T>;
54
- initialized: boolean;
55
- notifier: Notifier;
56
- }
51
+ // QueryInstance is now merged into QueryResultImpl below
57
52
 
58
53
  const queryKeyFor = (queryDef: QueryDefinition<any, any>, params: unknown): number => {
59
54
  return hashValue([queryDef.id, params]);
60
55
  };
61
56
 
57
+ const BASE_TICK_INTERVAL = 1000; // 1 second
58
+
59
+ // Refetch interval manager - uses a fixed 1-second tick
60
+ class RefetchManager {
61
+ private intervalId: NodeJS.Timeout;
62
+ private clock: number = 0; // Increments by 1000ms on each tick
63
+
64
+ // Buckets: Map of actual interval -> Set of query instances
65
+ private buckets = new Map<number, Set<QueryResultImpl<any>>>();
66
+
67
+ constructor(private multiplier: number = 1) {
68
+ // Start the timer immediately and keep it running
69
+ const tickInterval = BASE_TICK_INTERVAL * this.multiplier;
70
+ this.intervalId = setTimeout(() => this.tick(), tickInterval);
71
+ }
72
+
73
+ addQuery(instance: QueryResultImpl<any>) {
74
+ const interval = instance.def.cache?.refetchInterval;
75
+
76
+ if (!interval) {
77
+ return;
78
+ }
79
+
80
+ const actualInterval = interval * this.multiplier;
81
+ // Add to bucket by actual interval
82
+ let bucket = this.buckets.get(actualInterval);
83
+ if (!bucket) {
84
+ bucket = new Set();
85
+ this.buckets.set(actualInterval, bucket);
86
+ }
87
+ bucket.add(instance);
88
+ }
89
+
90
+ removeQuery(query: QueryResultImpl<any>) {
91
+ const interval = query.def.cache?.refetchInterval;
92
+
93
+ if (!interval) {
94
+ return;
95
+ }
96
+
97
+ const actualInterval = interval * this.multiplier;
98
+ // Remove from bucket
99
+ const bucket = this.buckets.get(actualInterval);
100
+ if (bucket) {
101
+ bucket.delete(query);
102
+
103
+ if (bucket.size === 0) {
104
+ this.buckets.delete(actualInterval);
105
+ }
106
+ }
107
+ }
108
+
109
+ private tick() {
110
+ this.clock += BASE_TICK_INTERVAL * this.multiplier;
111
+
112
+ // Only process buckets where clock is aligned with the interval
113
+ for (const [interval, bucket] of this.buckets.entries()) {
114
+ if (this.clock % interval === 0) {
115
+ // Process all queries in this bucket
116
+ for (const query of bucket) {
117
+ // Skip if already fetching - let the current fetch complete
118
+ if (query && !query.isFetching) {
119
+ query.refetch();
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ const tickInterval = BASE_TICK_INTERVAL * this.multiplier;
126
+ this.intervalId = setTimeout(() => this.tick(), tickInterval);
127
+ }
128
+
129
+ destroy(): void {
130
+ clearTimeout(this.intervalId);
131
+ }
132
+ }
133
+
134
+ const EVICTION_INTERVAL = 60 * 1000; // 1 minute
135
+
136
+ // Memory eviction manager - uses a single interval with rotating sets to avoid timeout overhead
137
+ class MemoryEvictionManager {
138
+ private intervalId: NodeJS.Timeout;
139
+ private currentFlush = new Set<number>(); // Queries to evict on next tick
140
+ private nextFlush = new Set<number>(); // Queries to evict on tick after next
141
+
142
+ constructor(
143
+ private queryClient: QueryClient,
144
+ private multiplier: number = 1,
145
+ ) {
146
+ this.intervalId = setInterval(this.tick, EVICTION_INTERVAL * this.multiplier);
147
+ }
148
+
149
+ scheduleEviction(queryKey: number) {
150
+ // Add to nextFlush so it waits at least one full interval
151
+ // This prevents immediate eviction if scheduled right before a tick
152
+ this.nextFlush.add(queryKey);
153
+ }
154
+
155
+ cancelEviction(queryKey: number) {
156
+ // Remove from both sets to handle reactivation
157
+ this.currentFlush.delete(queryKey);
158
+ this.nextFlush.delete(queryKey);
159
+ }
160
+
161
+ private tick = () => {
162
+ if (!this.queryClient) return;
163
+
164
+ // Evict all queries in currentFlush
165
+ for (const queryKey of this.currentFlush) {
166
+ this.queryClient.queryInstances.delete(queryKey);
167
+ }
168
+
169
+ // Rotate: currentFlush becomes nextFlush, nextFlush becomes empty
170
+ this.currentFlush = this.nextFlush;
171
+ this.nextFlush = new Set();
172
+ };
173
+
174
+ destroy(): void {
175
+ clearInterval(this.intervalId);
176
+ }
177
+ }
178
+
62
179
  /**
63
180
  * QueryResult wraps a DiscriminatedReactivePromise and adds additional functionality
64
181
  * like refetch, while forwarding all the base relay properties.
182
+ * This class combines the old QueryInstance and QueryResultImpl into a single entity.
65
183
  */
66
184
  export class QueryResultImpl<T> implements QueryResult<T> {
67
- constructor(
68
- private relay: DiscriminatedReactivePromise<T>,
69
- private instance: QueryInstance<T>,
70
- ) {
185
+ // Fields from old QueryInstance
186
+ def: QueryDefinition<any, any>;
187
+ initialized: boolean = false;
188
+ isRefetchingSignal: Signal<boolean>;
189
+ updatedAt: number | undefined = undefined;
190
+
191
+ // References for refetch functionality
192
+ private queryClient: QueryClient;
193
+ queryKey: number;
194
+ private params: any;
195
+ private relay: DiscriminatedReactivePromise<T>;
196
+ private relayState: RelayState<any> | undefined = undefined;
197
+
198
+ constructor(def: QueryDefinition<any, any>, queryClient: QueryClient, queryKey: number, params: any) {
71
199
  setReactivePromise(this);
200
+ this.def = def;
201
+ this.queryClient = queryClient;
202
+ this.queryKey = queryKey;
203
+ this.params = params;
204
+ this.isRefetchingSignal = signal(false);
205
+
206
+ // Create the relay and handle activation/deactivation
207
+ this.relay = relay<T>(
208
+ state => {
209
+ this.relayState = state;
210
+ // Load from cache first, then fetch fresh data
211
+ this.queryClient.activateQuery(this);
212
+
213
+ if (this.initialized) {
214
+ if (this.isStale()) {
215
+ this.refetch();
216
+ }
217
+ } else {
218
+ this.initialize(state as RelayState<unknown>);
219
+ }
220
+
221
+ // Return deactivation callback
222
+ return {
223
+ update: () => {
224
+ state.setPromise(this.runQuery());
225
+ },
226
+ deactivate: () => {
227
+ // Last subscriber left, deactivate refetch and schedule memory eviction
228
+ if (this.def.cache?.refetchInterval) {
229
+ this.queryClient.refetchManager.removeQuery(this);
230
+ }
231
+
232
+ // Schedule removal from memory using the global eviction manager
233
+ // This allows quick reactivation from memory if needed again soon
234
+ // Disk cache (if configured) will still be available after eviction
235
+ this.queryClient.memoryEvictionManager.scheduleEviction(this.queryKey);
236
+ },
237
+ };
238
+ },
239
+ // {
240
+ // equals: false,
241
+ // },
242
+ ) as DiscriminatedReactivePromise<T>;
72
243
  }
73
244
 
74
- // Forward all ReactivePromise properties through getters
75
245
  get value(): T | undefined {
76
246
  return this.relay.value;
77
247
  }
@@ -100,9 +270,17 @@ export class QueryResultImpl<T> implements QueryResult<T> {
100
270
  return this.relay.isReady;
101
271
  }
102
272
 
273
+ get isRefetching(): boolean {
274
+ return this.isRefetchingSignal.value;
275
+ }
276
+
277
+ get isFetching(): boolean {
278
+ return this.relay.isPending || this.isRefetching;
279
+ }
280
+
103
281
  // TODO: Intimate APIs needed for `useReactive`, this is a code smell and
104
282
  // we should find a better way to entangle these more generically
105
- private get _version(): Promise<T> {
283
+ private get _version(): Signal<number> {
106
284
  return (this.relay as any)._version;
107
285
  }
108
286
 
@@ -133,121 +311,154 @@ export class QueryResultImpl<T> implements QueryResult<T> {
133
311
  }
134
312
 
135
313
  // Additional methods
136
- refetch(): Promise<T> {
137
- this.instance.notifier.notify();
138
- // pull the value to make sure the relay is activated
139
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
140
- this.instance.relay.value;
141
- return this.relay;
142
- }
314
+ async refetch(): Promise<T> {
315
+ this.isRefetchingSignal.value = true;
143
316
 
144
- // Make it work with Symbol.toStringTag for Promise detection
145
- get [Symbol.toStringTag](): string {
146
- return 'QueryResult';
147
- }
148
- }
317
+ try {
318
+ const result = await this.runQuery();
149
319
 
150
- export class QueryClient {
151
- private entityMap = new EntityStore();
152
- private queryInstances = new Map<number, QueryInstance<unknown>>();
320
+ if (this.relayState) {
321
+ this.relayState.value = result;
153
322
 
154
- constructor(
155
- private store: QueryStore,
156
- private context: QueryContext = { fetch },
157
- ) {}
323
+ // Update the version to trigger a re-render for direct React consumers
324
+ // e.g. `useReactive(query, params)`
325
+ this._version.update(v => v + 1);
326
+ }
327
+
328
+ return result;
329
+ } finally {
330
+ this.isRefetchingSignal.value = false;
331
+ }
332
+ }
158
333
 
159
334
  /**
160
- * Loads a query from the document store and returns a QueryResult
161
- * that triggers fetches and prepopulates with cached data
335
+ * Fetches fresh data, updates the cache, and updates updatedAt timestamp
162
336
  */
163
- getQuery<Params, Result>(
164
- queryDef: QueryDefinition<Params, Result>,
165
- params: Params,
166
- ): DiscriminatedQueryResult<Result> {
167
- const queryKey = queryKeyFor(queryDef, params);
337
+ async runQuery(): Promise<T> {
338
+ const freshData = await this.def.fetchFn(this.queryClient.getContext(), this.params);
168
339
 
169
- let queryInstance = this.queryInstances.get(queryKey);
170
-
171
- // Create a new relay if it doesn't exist
172
- if (queryInstance === undefined) {
173
- queryInstance = {
174
- relay: undefined as any,
175
- initialized: false,
176
- notifier: notifier(),
177
- };
340
+ // Parse and cache the fresh data
341
+ const entityRefs = new Set<number>();
342
+ const shape = this.def.shape;
178
343
 
179
- const queryRelay = relay<Result>(state => {
180
- // Load from cache first, then fetch fresh data
181
- queryInstance!.notifier.consume();
344
+ const parsedData =
345
+ shape instanceof ValidatorDef
346
+ ? parseEntities(freshData, shape as ComplexTypeDef, this.queryClient, entityRefs)
347
+ : parseValue(freshData, shape, this.def.id);
182
348
 
183
- this.store.activateQuery(queryDef, queryKey);
349
+ // Cache the data (synchronous, fire-and-forget)
350
+ this.queryClient.saveQueryData(this.def, this.queryKey, freshData, entityRefs);
184
351
 
185
- if (queryInstance!.initialized) {
186
- state.setPromise(this.runQuery(queryDef, queryKey, params));
187
- } else {
188
- this.initializeQuery(queryDef, params, state as RelayState<unknown>, queryInstance as QueryInstance<Result>);
189
- }
190
- });
352
+ // Update the timestamp
353
+ this.updatedAt = Date.now();
191
354
 
192
- queryInstance.relay = new QueryResultImpl(queryRelay as DiscriminatedReactivePromise<Result>, queryInstance);
355
+ return parsedData as T;
356
+ }
193
357
 
194
- // Store the relay for future use
195
- this.queryInstances.set(queryKey, queryInstance);
358
+ isStale(): boolean {
359
+ if (this.updatedAt === undefined) {
360
+ return true; // No data yet, needs fetch
196
361
  }
197
362
 
198
- return queryInstance.relay as DiscriminatedQueryResult<Result>;
363
+ const staleTime = this.def.cache?.staleTime ?? 0;
364
+ return Date.now() - this.updatedAt >= staleTime;
199
365
  }
200
366
 
201
- private async initializeQuery<Params, Result>(
202
- queryDef: QueryDefinition<Params, Result>,
203
- params: Params,
204
- state: RelayState<unknown>,
205
- instance: QueryInstance<Result>,
206
- ): Promise<void> {
367
+ /**
368
+ * Initialize the query by loading from cache and fetching if stale
369
+ */
370
+ private async initialize(state: RelayState<unknown>): Promise<void> {
207
371
  try {
208
- instance.initialized = true;
209
- const queryKey = queryKeyFor(queryDef, params);
372
+ this.initialized = true;
210
373
  // Load from cache first
211
- const query = this.store.loadQuery(queryDef, queryKey, this.entityMap);
374
+ const cached = await this.queryClient.loadCachedQuery(this.def, this.queryKey);
212
375
 
213
- if (query !== undefined) {
214
- const shape = queryDef.shape;
376
+ if (cached !== undefined) {
377
+ const shape = this.def.shape;
215
378
  state.value =
216
379
  shape instanceof ValidatorDef
217
- ? parseEntities(query, shape as ComplexTypeDef, this, new Set())
218
- : parseValue(query, shape, queryDef.id);
219
- }
380
+ ? parseEntities(cached.value, shape as ComplexTypeDef, this.queryClient, new Set())
381
+ : parseValue(cached.value, shape, this.def.id);
220
382
 
221
- state.setPromise(this.runQuery(queryDef, queryKey, params));
383
+ // Set the cached timestamp
384
+ this.updatedAt = cached.updatedAt;
385
+
386
+ // Check if data is stale
387
+ if (this.isStale()) {
388
+ // Data is stale, trigger background refetch
389
+ this.refetch();
390
+ }
391
+ } else {
392
+ // No cached data, fetch fresh
393
+ state.setPromise(this.runQuery());
394
+ }
222
395
  } catch (error) {
223
396
  // Relay will handle the error state automatically
224
397
  state.setError(error as Error);
225
398
  }
226
399
  }
227
400
 
401
+ // Make it work with Symbol.toStringTag for Promise detection
402
+ get [Symbol.toStringTag](): string {
403
+ return 'QueryResult';
404
+ }
405
+ }
406
+
407
+ export class QueryClient {
408
+ private entityMap = new EntityStore();
409
+ queryInstances = new Map<number, QueryResultImpl<unknown>>();
410
+ memoryEvictionManager: MemoryEvictionManager;
411
+ refetchManager: RefetchManager;
412
+
413
+ constructor(
414
+ private store: QueryStore,
415
+ private context: QueryContext = { fetch },
416
+ ) {
417
+ this.memoryEvictionManager = new MemoryEvictionManager(this, this.context.evictionMultiplier);
418
+ this.refetchManager = new RefetchManager(this.context.refetchMultiplier);
419
+ }
420
+
421
+ getContext(): QueryContext {
422
+ return this.context;
423
+ }
424
+
425
+ saveQueryData(queryDef: QueryDefinition<any, any>, queryKey: number, data: unknown, entityRefs: Set<number>): void {
426
+ this.store.saveQuery(queryDef, queryKey, data, entityRefs);
427
+ }
428
+
429
+ activateQuery(queryInstance: QueryResultImpl<unknown>): void {
430
+ const { def, queryKey } = queryInstance;
431
+ this.store.activateQuery(def, queryKey);
432
+
433
+ this.refetchManager.addQuery(queryInstance);
434
+ this.memoryEvictionManager.cancelEviction(queryKey);
435
+ }
436
+
437
+ loadCachedQuery(queryDef: QueryDefinition<any, any>, queryKey: number) {
438
+ return this.store.loadQuery(queryDef, queryKey, this.entityMap);
439
+ }
440
+
228
441
  /**
229
- * Fetches fresh data and updates the cache
442
+ * Loads a query from the document store and returns a QueryResult
443
+ * that triggers fetches and prepopulates with cached data
230
444
  */
231
- private async runQuery<Params, Result>(
445
+ getQuery<Params, Result>(
232
446
  queryDef: QueryDefinition<Params, Result>,
233
- queryKey: number,
234
447
  params: Params,
235
- ): Promise<Result> {
236
- const freshData = await queryDef.fetchFn(this.context, params);
237
- // Parse and cache the fresh data
238
- const entityRefs = new Set<number>();
448
+ ): DiscriminatedQueryResult<Result> {
449
+ const queryKey = queryKeyFor(queryDef, params);
239
450
 
240
- const shape = queryDef.shape;
451
+ let queryInstance = this.queryInstances.get(queryKey) as QueryResultImpl<Result> | undefined;
241
452
 
242
- const parsedData =
243
- shape instanceof ValidatorDef
244
- ? parseEntities(freshData, shape as ComplexTypeDef, this, entityRefs)
245
- : parseValue(freshData, shape, queryDef.id);
453
+ // Create a new instance if it doesn't exist
454
+ if (queryInstance === undefined) {
455
+ queryInstance = new QueryResultImpl(queryDef, this, queryKey, params);
246
456
 
247
- // Cache the data (synchronous, fire-and-forget)
248
- this.store.saveQuery(queryDef, queryKey, freshData, entityRefs);
457
+ // Store for future use
458
+ this.queryInstances.set(queryKey, queryInstance as QueryResultImpl<unknown>);
459
+ }
249
460
 
250
- return parsedData as Result;
461
+ return queryInstance as DiscriminatedQueryResult<Result>;
251
462
  }
252
463
 
253
464
  hydrateEntity(key: number, shape: EntityDef): EntityRecord {
@@ -261,6 +472,11 @@ export class QueryClient {
261
472
 
262
473
  return record;
263
474
  }
475
+
476
+ destroy(): void {
477
+ this.refetchManager.destroy();
478
+ this.memoryEvictionManager.destroy();
479
+ }
264
480
  }
265
481
 
266
482
  export const QueryClientContext: Context<QueryClient | undefined> = context<QueryClient | undefined>(undefined);
package/src/QueryStore.ts CHANGED
@@ -13,6 +13,11 @@ import { QueryDefinition } from './QueryClient.js';
13
13
  // QueryStore Interface
14
14
  // -----------------------------------------------------------------------------
15
15
 
16
+ export interface CachedQuery {
17
+ value: unknown;
18
+ updatedAt: number;
19
+ }
20
+
16
21
  export interface QueryStore {
17
22
  /**
18
23
  * Asynchronously retrieves a document by key.
@@ -22,7 +27,7 @@ export interface QueryStore {
22
27
  queryDef: QueryDefinition<any, any>,
23
28
  queryKey: number,
24
29
  entityMap: EntityStore,
25
- ): MaybePromise<unknown | undefined>;
30
+ ): MaybePromise<CachedQuery | undefined>;
26
31
 
27
32
  /**
28
33
  * Synchronously stores a document with optional reference IDs.
@@ -61,7 +66,7 @@ export interface SyncPersistentStore {
61
66
  }
62
67
 
63
68
  const DEFAULT_MAX_COUNT = 50;
64
- const DEFAULT_MAX_AGE = 1000 * 60 * 60 * 24; // 24 hours
69
+ const DEFAULT_GC_TIME = 1000 * 60 * 60 * 24; // 24 hours
65
70
 
66
71
  export class MemoryPersistentStore implements SyncPersistentStore {
67
72
  private readonly kv: Record<string, unknown> = Object.create(null);
@@ -113,16 +118,16 @@ export class SyncQueryStore implements QueryStore {
113
118
 
114
119
  constructor(private readonly kv: SyncPersistentStore) {}
115
120
 
116
- loadQuery(queryDef: QueryDefinition<any, any>, queryKey: number, entityMap: EntityStore): unknown | undefined {
121
+ loadQuery(queryDef: QueryDefinition<any, any>, queryKey: number, entityMap: EntityStore): CachedQuery | undefined {
117
122
  const updatedAt = this.kv.getNumber(updatedAtKeyFor(queryKey));
118
123
 
119
- if (updatedAt === undefined || updatedAt < Date.now() - (queryDef.cache?.maxAge ?? DEFAULT_MAX_AGE)) {
124
+ if (updatedAt === undefined || updatedAt < Date.now() - (queryDef.cache?.gcTime ?? DEFAULT_GC_TIME)) {
120
125
  return;
121
126
  }
122
127
 
123
- const value = this.kv.getString(valueKeyFor(queryKey));
128
+ const valueStr = this.kv.getString(valueKeyFor(queryKey));
124
129
 
125
- if (value === undefined) {
130
+ if (valueStr === undefined) {
126
131
  return;
127
132
  }
128
133
 
@@ -134,7 +139,10 @@ export class SyncQueryStore implements QueryStore {
134
139
 
135
140
  this.activateQuery(queryDef, queryKey);
136
141
 
137
- return JSON.parse(value) as Record<string, unknown>;
142
+ return {
143
+ value: JSON.parse(valueStr) as Record<string, unknown>,
144
+ updatedAt,
145
+ };
138
146
  }
139
147
 
140
148
  private preloadEntities(entityIds: Uint32Array, entityMap: EntityStore): void {
@@ -139,6 +139,7 @@ describe('Caching and Persistence', () => {
139
139
  let store: any;
140
140
 
141
141
  beforeEach(() => {
142
+ client?.destroy();
142
143
  kv = new MemoryPersistentStore();
143
144
  const queryStore = new SyncQueryStore(kv);
144
145
  mockFetch = createMockFetch();
@@ -199,8 +200,18 @@ describe('Caching and Persistence', () => {
199
200
 
200
201
  const result = await relay;
201
202
 
202
- expect(result).toEqual({ id: 1, name: 'Fresh Data' });
203
+ // Immediate value should be the same as the cached value because we're
204
+ // background refetching but have a value that is still valid.
205
+ expect(result).toEqual({ id: 1, name: 'Cached Data' });
206
+ expect(relay.value).toEqual({ id: 1, name: 'Cached Data' });
207
+ expect(relay.isPending).toBe(false);
208
+ expect(relay.isRefetching).toBe(true);
209
+
210
+ await sleep(20);
211
+
212
+ expect(relay.isRefetching).toBe(false);
203
213
  expect(relay.value).toEqual({ id: 1, name: 'Fresh Data' });
214
+ expect(await relay).toEqual({ id: 1, name: 'Fresh Data' });
204
215
  });
205
216
  });
206
217
 
@@ -234,8 +245,17 @@ describe('Caching and Persistence', () => {
234
245
 
235
246
  const result = await relay;
236
247
 
237
- expect(result).toEqual({ id: 1, value: 'New Data' });
248
+ // Immediate value should be the same as the cached value because we're
249
+ // background refetching but have a value that is still valid.
250
+ expect(result).toEqual({ id: 1, value: 'Persistent' });
251
+ expect(relay.value).toEqual({ id: 1, value: 'Persistent' });
252
+ expect(relay.isPending).toBe(false);
253
+ expect(relay.isRefetching).toBe(true);
254
+
255
+ await sleep(30);
256
+
238
257
  expect(relay.value).toEqual({ id: 1, value: 'New Data' });
258
+ expect(await relay).toEqual({ id: 1, value: 'New Data' });
239
259
  });
240
260
  });
241
261
  });
@@ -321,6 +341,15 @@ describe('Caching and Persistence', () => {
321
341
 
322
342
  const result = await relay;
323
343
 
344
+ // Immediate value should be the same as the cached value because we're
345
+ // background refetching but have a value that is still valid.
346
+ expect(result).toEqual({ user: { __typename: 'User', id: 1, name: 'Persisted User' } });
347
+ expect(relay.value).toEqual({ user: { __typename: 'User', id: 1, name: 'Persisted User' } });
348
+ expect(relay.isPending).toBe(false);
349
+ expect(relay.isRefetching).toBe(true);
350
+
351
+ await sleep(20);
352
+
324
353
  expect(result).toEqual({ user: { __typename: 'User', id: 1, name: 'Fresh User' } });
325
354
  expect(relay.value).toEqual({ user: { __typename: 'User', id: 1, name: 'Fresh User' } });
326
355
  });
@@ -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, refIdsKeyFor, refCountKeyFor } from '../QueryStore.js';
3
3
  import { QueryClient } from '../QueryClient.js';
4
4
  import { entity, t } from '../typeDefs.js';
@@ -26,6 +26,10 @@ describe('Entity System', () => {
26
26
  client = new QueryClient(store, { fetch: mockFetch as any });
27
27
  });
28
28
 
29
+ afterEach(() => {
30
+ client?.destroy();
31
+ });
32
+
29
33
  describe('Entity Proxies', () => {
30
34
  it('should create reactive entity proxies', async () => {
31
35
  const User = entity(() => ({