@signalium/query 0.0.1 → 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 +3 -3
- package/CHANGELOG.md +8 -0
- package/dist/cjs/QueryClient.js +254 -66
- package/dist/cjs/QueryClient.js.map +1 -1
- package/dist/cjs/QueryStore.js +8 -5
- package/dist/cjs/QueryStore.js.map +1 -1
- package/dist/cjs/query.js +1 -1
- package/dist/cjs/query.js.map +1 -1
- package/dist/cjs/types.js +10 -1
- package/dist/cjs/types.js.map +1 -1
- package/dist/esm/QueryClient.d.ts +58 -17
- package/dist/esm/QueryClient.d.ts.map +1 -1
- package/dist/esm/QueryClient.js +255 -67
- package/dist/esm/QueryClient.js.map +1 -1
- package/dist/esm/QueryStore.d.ts +6 -2
- package/dist/esm/QueryStore.d.ts.map +1 -1
- package/dist/esm/QueryStore.js +8 -5
- package/dist/esm/QueryStore.js.map +1 -1
- package/dist/esm/query.d.ts +1 -0
- package/dist/esm/query.d.ts.map +1 -1
- package/dist/esm/query.js +1 -1
- package/dist/esm/query.js.map +1 -1
- package/dist/esm/types.d.ts +10 -0
- package/dist/esm/types.d.ts.map +1 -1
- package/dist/esm/types.js +9 -0
- package/dist/esm/types.js.map +1 -1
- 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/src/QueryClient.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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():
|
|
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.
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
return 'QueryResult';
|
|
147
|
-
}
|
|
148
|
-
}
|
|
317
|
+
try {
|
|
318
|
+
const result = await this.runQuery();
|
|
149
319
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
private queryInstances = new Map<number, QueryInstance<unknown>>();
|
|
320
|
+
if (this.relayState) {
|
|
321
|
+
this.relayState.value = result;
|
|
153
322
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
*
|
|
161
|
-
* that triggers fetches and prepopulates with cached data
|
|
335
|
+
* Fetches fresh data, updates the cache, and updates updatedAt timestamp
|
|
162
336
|
*/
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
349
|
+
// Cache the data (synchronous, fire-and-forget)
|
|
350
|
+
this.queryClient.saveQueryData(this.def, this.queryKey, freshData, entityRefs);
|
|
184
351
|
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
355
|
+
return parsedData as T;
|
|
356
|
+
}
|
|
193
357
|
|
|
194
|
-
|
|
195
|
-
|
|
358
|
+
isStale(): boolean {
|
|
359
|
+
if (this.updatedAt === undefined) {
|
|
360
|
+
return true; // No data yet, needs fetch
|
|
196
361
|
}
|
|
197
362
|
|
|
198
|
-
|
|
363
|
+
const staleTime = this.def.cache?.staleTime ?? 0;
|
|
364
|
+
return Date.now() - this.updatedAt >= staleTime;
|
|
199
365
|
}
|
|
200
366
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
209
|
-
const queryKey = queryKeyFor(queryDef, params);
|
|
372
|
+
this.initialized = true;
|
|
210
373
|
// Load from cache first
|
|
211
|
-
const
|
|
374
|
+
const cached = await this.queryClient.loadCachedQuery(this.def, this.queryKey);
|
|
212
375
|
|
|
213
|
-
if (
|
|
214
|
-
const shape =
|
|
376
|
+
if (cached !== undefined) {
|
|
377
|
+
const shape = this.def.shape;
|
|
215
378
|
state.value =
|
|
216
379
|
shape instanceof ValidatorDef
|
|
217
|
-
? parseEntities(
|
|
218
|
-
: parseValue(
|
|
219
|
-
}
|
|
380
|
+
? parseEntities(cached.value, shape as ComplexTypeDef, this.queryClient, new Set())
|
|
381
|
+
: parseValue(cached.value, shape, this.def.id);
|
|
220
382
|
|
|
221
|
-
|
|
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
|
-
*
|
|
442
|
+
* Loads a query from the document store and returns a QueryResult
|
|
443
|
+
* that triggers fetches and prepopulates with cached data
|
|
230
444
|
*/
|
|
231
|
-
|
|
445
|
+
getQuery<Params, Result>(
|
|
232
446
|
queryDef: QueryDefinition<Params, Result>,
|
|
233
|
-
queryKey: number,
|
|
234
447
|
params: Params,
|
|
235
|
-
):
|
|
236
|
-
const
|
|
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
|
-
|
|
451
|
+
let queryInstance = this.queryInstances.get(queryKey) as QueryResultImpl<Result> | undefined;
|
|
241
452
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
248
|
-
|
|
457
|
+
// Store for future use
|
|
458
|
+
this.queryInstances.set(queryKey, queryInstance as QueryResultImpl<unknown>);
|
|
459
|
+
}
|
|
249
460
|
|
|
250
|
-
return
|
|
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<
|
|
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
|
|
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):
|
|
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?.
|
|
124
|
+
if (updatedAt === undefined || updatedAt < Date.now() - (queryDef.cache?.gcTime ?? DEFAULT_GC_TIME)) {
|
|
120
125
|
return;
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
const
|
|
128
|
+
const valueStr = this.kv.getString(valueKeyFor(queryKey));
|
|
124
129
|
|
|
125
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(() => ({
|