@signalium/query 0.0.2 → 1.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/cjs/EntityMap.js +2 -2
  3. package/dist/cjs/EntityMap.js.map +1 -1
  4. package/dist/cjs/NetworkManager.js +105 -0
  5. package/dist/cjs/NetworkManager.js.map +1 -0
  6. package/dist/cjs/QueryClient.js +390 -76
  7. package/dist/cjs/QueryClient.js.map +1 -1
  8. package/dist/cjs/QueryStore.js +295 -3
  9. package/dist/cjs/QueryStore.js.map +1 -1
  10. package/dist/cjs/index.js +16 -1
  11. package/dist/cjs/index.js.map +1 -1
  12. package/dist/cjs/package.json +3 -0
  13. package/dist/cjs/parseEntities.js +3 -0
  14. package/dist/cjs/parseEntities.js.map +1 -1
  15. package/dist/cjs/proxy.js +19 -0
  16. package/dist/cjs/proxy.js.map +1 -1
  17. package/dist/cjs/query.js +40 -2
  18. package/dist/cjs/query.js.map +1 -1
  19. package/dist/cjs/stores/async.js +6 -0
  20. package/dist/cjs/stores/async.js.map +1 -0
  21. package/dist/cjs/stores/sync.js +7 -0
  22. package/dist/cjs/stores/sync.js.map +1 -0
  23. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  24. package/dist/cjs/type-utils.js +3 -0
  25. package/dist/cjs/type-utils.js.map +1 -0
  26. package/dist/cjs/types.js +19 -1
  27. package/dist/cjs/types.js.map +1 -1
  28. package/dist/esm/EntityMap.js +3 -3
  29. package/dist/esm/EntityMap.js.map +1 -1
  30. package/dist/esm/NetworkManager.d.ts +48 -0
  31. package/dist/esm/NetworkManager.d.ts.map +1 -0
  32. package/dist/esm/NetworkManager.js +101 -0
  33. package/dist/esm/NetworkManager.js.map +1 -0
  34. package/dist/esm/QueryClient.d.ts +81 -25
  35. package/dist/esm/QueryClient.d.ts.map +1 -1
  36. package/dist/esm/QueryClient.js +390 -76
  37. package/dist/esm/QueryClient.js.map +1 -1
  38. package/dist/esm/QueryStore.d.ts +64 -2
  39. package/dist/esm/QueryStore.d.ts.map +1 -1
  40. package/dist/esm/QueryStore.js +293 -2
  41. package/dist/esm/QueryStore.js.map +1 -1
  42. package/dist/esm/index.d.ts +5 -3
  43. package/dist/esm/index.d.ts.map +1 -1
  44. package/dist/esm/index.js +3 -1
  45. package/dist/esm/index.js.map +1 -1
  46. package/dist/esm/parseEntities.d.ts.map +1 -1
  47. package/dist/esm/parseEntities.js +3 -0
  48. package/dist/esm/parseEntities.js.map +1 -1
  49. package/dist/esm/proxy.d.ts +6 -0
  50. package/dist/esm/proxy.d.ts.map +1 -1
  51. package/dist/esm/proxy.js +18 -0
  52. package/dist/esm/proxy.js.map +1 -1
  53. package/dist/esm/query.d.ts +30 -29
  54. package/dist/esm/query.d.ts.map +1 -1
  55. package/dist/esm/query.js +39 -3
  56. package/dist/esm/query.js.map +1 -1
  57. package/dist/esm/stores/async.d.ts +2 -0
  58. package/dist/esm/stores/async.d.ts.map +1 -0
  59. package/dist/esm/stores/async.js +2 -0
  60. package/dist/esm/stores/async.js.map +1 -0
  61. package/dist/esm/stores/sync.d.ts +2 -0
  62. package/dist/esm/stores/sync.d.ts.map +1 -0
  63. package/dist/esm/stores/sync.js +2 -0
  64. package/dist/esm/stores/sync.js.map +1 -0
  65. package/dist/esm/type-utils.d.ts +12 -0
  66. package/dist/esm/type-utils.d.ts.map +1 -0
  67. package/dist/esm/type-utils.js +2 -0
  68. package/dist/esm/type-utils.js.map +1 -0
  69. package/dist/esm/types.d.ts +62 -5
  70. package/dist/esm/types.d.ts.map +1 -1
  71. package/dist/esm/types.js +18 -0
  72. package/dist/esm/types.js.map +1 -1
  73. package/index.d.ts +1 -0
  74. package/package.json +25 -7
  75. package/stores/async.d.ts +1 -0
  76. package/stores/async.js +15 -0
  77. package/stores/sync.d.ts +1 -0
  78. package/stores/sync.js +15 -0
  79. package/.turbo/turbo-build.log +0 -12
  80. package/ENTITY_STORE_DESIGN.md +0 -386
  81. package/dist/tsconfig.esm.tsbuildinfo +0 -1
  82. package/src/EntityMap.ts +0 -63
  83. package/src/QueryClient.ts +0 -482
  84. package/src/QueryStore.ts +0 -322
  85. package/src/__tests__/caching-persistence.test.ts +0 -983
  86. package/src/__tests__/entity-system.test.ts +0 -556
  87. package/src/__tests__/gc-time.test.ts +0 -327
  88. package/src/__tests__/mock-fetch.test.ts +0 -186
  89. package/src/__tests__/parse-entities.test.ts +0 -425
  90. package/src/__tests__/path-interpolation.test.ts +0 -225
  91. package/src/__tests__/reactivity.test.ts +0 -424
  92. package/src/__tests__/refetch-interval.test.ts +0 -262
  93. package/src/__tests__/rest-query-api.test.ts +0 -568
  94. package/src/__tests__/stale-time.test.ts +0 -357
  95. package/src/__tests__/type-to-string.test.ts +0 -129
  96. package/src/__tests__/utils.ts +0 -258
  97. package/src/__tests__/validation-edge-cases.test.ts +0 -821
  98. package/src/errors.ts +0 -124
  99. package/src/index.ts +0 -7
  100. package/src/parseEntities.ts +0 -213
  101. package/src/pathInterpolator.ts +0 -74
  102. package/src/proxy.ts +0 -257
  103. package/src/query.ts +0 -164
  104. package/src/react/__tests__/basic.test.tsx +0 -926
  105. package/src/react/__tests__/component.test.tsx +0 -984
  106. package/src/react/__tests__/utils.tsx +0 -71
  107. package/src/typeDefs.ts +0 -351
  108. package/src/types.ts +0 -132
  109. package/src/utils.ts +0 -66
  110. package/tsconfig.cjs.json +0 -14
  111. package/tsconfig.esm.json +0 -13
  112. package/tsconfig.json +0 -20
  113. package/vitest.config.ts +0 -65
@@ -1,482 +0,0 @@
1
- /**
2
- * Query Client with Entity Caching and Deduplication
3
- *
4
- * Features:
5
- * - Global entity map for deduplication
6
- * - Entity definitions with cached sub-entity paths
7
- * - Eager entity discovery and caching
8
- * - Permanent proxy cache for entities
9
- * - Response caching for offline access
10
- * - Signalium-based reactivity for entity updates
11
- * - Self-contained validator (no external dependencies except Signalium)
12
- */
13
-
14
- import { relay, type RelayState, context, DiscriminatedReactivePromise, type Context, Signal, signal } from 'signalium';
15
- import { hashValue, setReactivePromise } from 'signalium/utils';
16
- import {
17
- DiscriminatedQueryResult,
18
- EntityDef,
19
- QueryResult,
20
- ObjectFieldTypeDef,
21
- ComplexTypeDef,
22
- RefetchInterval,
23
- } from './types.js';
24
- import { parseValue } from './proxy.js';
25
- import { parseEntities } from './parseEntities.js';
26
- import { EntityRecord, EntityStore } from './EntityMap.js';
27
- import { QueryStore } from './QueryStore.js';
28
- import { ValidatorDef } from './typeDefs.js';
29
-
30
- export interface QueryContext {
31
- fetch: typeof fetch;
32
- evictionMultiplier?: number;
33
- refetchMultiplier?: number;
34
- }
35
-
36
- export interface QueryCacheOptions {
37
- maxCount?: number;
38
- gcTime?: number; // milliseconds - only applies to on-disk/persistent storage cleanup
39
- staleTime?: number;
40
- refetchInterval?: RefetchInterval;
41
- }
42
-
43
- export interface QueryDefinition<Params, Result> {
44
- id: string;
45
- shape: ObjectFieldTypeDef;
46
- fetchFn: (context: QueryContext, params: Params) => Promise<Result>;
47
-
48
- cache?: QueryCacheOptions;
49
- }
50
-
51
- // QueryInstance is now merged into QueryResultImpl below
52
-
53
- const queryKeyFor = (queryDef: QueryDefinition<any, any>, params: unknown): number => {
54
- return hashValue([queryDef.id, params]);
55
- };
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
-
179
- /**
180
- * QueryResult wraps a DiscriminatedReactivePromise and adds additional functionality
181
- * like refetch, while forwarding all the base relay properties.
182
- * This class combines the old QueryInstance and QueryResultImpl into a single entity.
183
- */
184
- export class QueryResultImpl<T> implements QueryResult<T> {
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) {
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>;
243
- }
244
-
245
- get value(): T | undefined {
246
- return this.relay.value;
247
- }
248
-
249
- get error(): unknown {
250
- return this.relay.error;
251
- }
252
-
253
- get isPending(): boolean {
254
- return this.relay.isPending;
255
- }
256
-
257
- get isRejected(): boolean {
258
- return this.relay.isRejected;
259
- }
260
-
261
- get isResolved(): boolean {
262
- return this.relay.isResolved;
263
- }
264
-
265
- get isSettled(): boolean {
266
- return this.relay.isSettled;
267
- }
268
-
269
- get isReady(): boolean {
270
- return this.relay.isReady;
271
- }
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
-
281
- // TODO: Intimate APIs needed for `useReactive`, this is a code smell and
282
- // we should find a better way to entangle these more generically
283
- private get _version(): Signal<number> {
284
- return (this.relay as any)._version;
285
- }
286
-
287
- private get _signal(): Signal<T> {
288
- return (this.relay as any)._signal;
289
- }
290
-
291
- private get _flags(): number {
292
- return (this.relay as any)._flags;
293
- }
294
-
295
- // Forward Promise methods
296
- then<TResult1 = T, TResult2 = never>(
297
- onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null | undefined,
298
- onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null | undefined,
299
- ): Promise<TResult1 | TResult2> {
300
- return this.relay.then(onfulfilled, onrejected);
301
- }
302
-
303
- catch<TResult = never>(
304
- onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null | undefined,
305
- ): Promise<T | TResult> {
306
- return this.relay.catch(onrejected);
307
- }
308
-
309
- finally(onfinally?: (() => void) | null | undefined): Promise<T> {
310
- return this.relay.finally(onfinally);
311
- }
312
-
313
- // Additional methods
314
- async refetch(): Promise<T> {
315
- this.isRefetchingSignal.value = true;
316
-
317
- try {
318
- const result = await this.runQuery();
319
-
320
- if (this.relayState) {
321
- this.relayState.value = result;
322
-
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
- }
333
-
334
- /**
335
- * Fetches fresh data, updates the cache, and updates updatedAt timestamp
336
- */
337
- async runQuery(): Promise<T> {
338
- const freshData = await this.def.fetchFn(this.queryClient.getContext(), this.params);
339
-
340
- // Parse and cache the fresh data
341
- const entityRefs = new Set<number>();
342
- const shape = this.def.shape;
343
-
344
- const parsedData =
345
- shape instanceof ValidatorDef
346
- ? parseEntities(freshData, shape as ComplexTypeDef, this.queryClient, entityRefs)
347
- : parseValue(freshData, shape, this.def.id);
348
-
349
- // Cache the data (synchronous, fire-and-forget)
350
- this.queryClient.saveQueryData(this.def, this.queryKey, freshData, entityRefs);
351
-
352
- // Update the timestamp
353
- this.updatedAt = Date.now();
354
-
355
- return parsedData as T;
356
- }
357
-
358
- isStale(): boolean {
359
- if (this.updatedAt === undefined) {
360
- return true; // No data yet, needs fetch
361
- }
362
-
363
- const staleTime = this.def.cache?.staleTime ?? 0;
364
- return Date.now() - this.updatedAt >= staleTime;
365
- }
366
-
367
- /**
368
- * Initialize the query by loading from cache and fetching if stale
369
- */
370
- private async initialize(state: RelayState<unknown>): Promise<void> {
371
- try {
372
- this.initialized = true;
373
- // Load from cache first
374
- const cached = await this.queryClient.loadCachedQuery(this.def, this.queryKey);
375
-
376
- if (cached !== undefined) {
377
- const shape = this.def.shape;
378
- state.value =
379
- shape instanceof ValidatorDef
380
- ? parseEntities(cached.value, shape as ComplexTypeDef, this.queryClient, new Set())
381
- : parseValue(cached.value, shape, this.def.id);
382
-
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
- }
395
- } catch (error) {
396
- // Relay will handle the error state automatically
397
- state.setError(error as Error);
398
- }
399
- }
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
-
441
- /**
442
- * Loads a query from the document store and returns a QueryResult
443
- * that triggers fetches and prepopulates with cached data
444
- */
445
- getQuery<Params, Result>(
446
- queryDef: QueryDefinition<Params, Result>,
447
- params: Params,
448
- ): DiscriminatedQueryResult<Result> {
449
- const queryKey = queryKeyFor(queryDef, params);
450
-
451
- let queryInstance = this.queryInstances.get(queryKey) as QueryResultImpl<Result> | undefined;
452
-
453
- // Create a new instance if it doesn't exist
454
- if (queryInstance === undefined) {
455
- queryInstance = new QueryResultImpl(queryDef, this, queryKey, params);
456
-
457
- // Store for future use
458
- this.queryInstances.set(queryKey, queryInstance as QueryResultImpl<unknown>);
459
- }
460
-
461
- return queryInstance as DiscriminatedQueryResult<Result>;
462
- }
463
-
464
- hydrateEntity(key: number, shape: EntityDef): EntityRecord {
465
- return this.entityMap.hydratePreloadedEntity(key, shape);
466
- }
467
-
468
- saveEntity(key: number, obj: Record<string, unknown>, shape: EntityDef, entityRefs?: Set<number>): EntityRecord {
469
- const record = this.entityMap.setEntity(key, obj, shape);
470
-
471
- this.store.saveEntity(key, obj, entityRefs);
472
-
473
- return record;
474
- }
475
-
476
- destroy(): void {
477
- this.refetchManager.destroy();
478
- this.memoryEvictionManager.destroy();
479
- }
480
- }
481
-
482
- export const QueryClientContext: Context<QueryClient | undefined> = context<QueryClient | undefined>(undefined);