@signalium/query 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
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 {
15
+ relay,
16
+ type RelayState,
17
+ context,
18
+ DiscriminatedReactivePromise,
19
+ type Context,
20
+ notifier,
21
+ Notifier,
22
+ Signal,
23
+ } from 'signalium';
24
+ import { hashValue, setReactivePromise } from 'signalium/utils';
25
+ import { DiscriminatedQueryResult, EntityDef, QueryResult, ObjectFieldTypeDef, ComplexTypeDef } from './types.js';
26
+ import { parseValue } from './proxy.js';
27
+ import { parseEntities } from './parseEntities.js';
28
+ import { EntityRecord, EntityStore } from './EntityMap.js';
29
+ import { QueryStore } from './QueryStore.js';
30
+ import { ValidatorDef } from './typeDefs.js';
31
+
32
+ export interface QueryContext {
33
+ fetch: typeof fetch;
34
+ }
35
+
36
+ export interface QueryCacheOptions {
37
+ maxCount?: number;
38
+ maxAge?: number; // milliseconds
39
+ }
40
+
41
+ export interface QueryDefinition<Params, Result> {
42
+ id: string;
43
+ shape: ObjectFieldTypeDef;
44
+ fetchFn: (context: QueryContext, params: Params) => Promise<Result>;
45
+
46
+ staleTime?: number;
47
+ refetchInterval?: number;
48
+
49
+ cache?: QueryCacheOptions;
50
+ }
51
+
52
+ interface QueryInstance<T> {
53
+ relay: QueryResultImpl<T>;
54
+ initialized: boolean;
55
+ notifier: Notifier;
56
+ }
57
+
58
+ const queryKeyFor = (queryDef: QueryDefinition<any, any>, params: unknown): number => {
59
+ return hashValue([queryDef.id, params]);
60
+ };
61
+
62
+ /**
63
+ * QueryResult wraps a DiscriminatedReactivePromise and adds additional functionality
64
+ * like refetch, while forwarding all the base relay properties.
65
+ */
66
+ export class QueryResultImpl<T> implements QueryResult<T> {
67
+ constructor(
68
+ private relay: DiscriminatedReactivePromise<T>,
69
+ private instance: QueryInstance<T>,
70
+ ) {
71
+ setReactivePromise(this);
72
+ }
73
+
74
+ // Forward all ReactivePromise properties through getters
75
+ get value(): T | undefined {
76
+ return this.relay.value;
77
+ }
78
+
79
+ get error(): unknown {
80
+ return this.relay.error;
81
+ }
82
+
83
+ get isPending(): boolean {
84
+ return this.relay.isPending;
85
+ }
86
+
87
+ get isRejected(): boolean {
88
+ return this.relay.isRejected;
89
+ }
90
+
91
+ get isResolved(): boolean {
92
+ return this.relay.isResolved;
93
+ }
94
+
95
+ get isSettled(): boolean {
96
+ return this.relay.isSettled;
97
+ }
98
+
99
+ get isReady(): boolean {
100
+ return this.relay.isReady;
101
+ }
102
+
103
+ // TODO: Intimate APIs needed for `useReactive`, this is a code smell and
104
+ // we should find a better way to entangle these more generically
105
+ private get _version(): Promise<T> {
106
+ return (this.relay as any)._version;
107
+ }
108
+
109
+ private get _signal(): Signal<T> {
110
+ return (this.relay as any)._signal;
111
+ }
112
+
113
+ private get _flags(): number {
114
+ return (this.relay as any)._flags;
115
+ }
116
+
117
+ // Forward Promise methods
118
+ then<TResult1 = T, TResult2 = never>(
119
+ onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null | undefined,
120
+ onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null | undefined,
121
+ ): Promise<TResult1 | TResult2> {
122
+ return this.relay.then(onfulfilled, onrejected);
123
+ }
124
+
125
+ catch<TResult = never>(
126
+ onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null | undefined,
127
+ ): Promise<T | TResult> {
128
+ return this.relay.catch(onrejected);
129
+ }
130
+
131
+ finally(onfinally?: (() => void) | null | undefined): Promise<T> {
132
+ return this.relay.finally(onfinally);
133
+ }
134
+
135
+ // 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
+ }
143
+
144
+ // Make it work with Symbol.toStringTag for Promise detection
145
+ get [Symbol.toStringTag](): string {
146
+ return 'QueryResult';
147
+ }
148
+ }
149
+
150
+ export class QueryClient {
151
+ private entityMap = new EntityStore();
152
+ private queryInstances = new Map<number, QueryInstance<unknown>>();
153
+
154
+ constructor(
155
+ private store: QueryStore,
156
+ private context: QueryContext = { fetch },
157
+ ) {}
158
+
159
+ /**
160
+ * Loads a query from the document store and returns a QueryResult
161
+ * that triggers fetches and prepopulates with cached data
162
+ */
163
+ getQuery<Params, Result>(
164
+ queryDef: QueryDefinition<Params, Result>,
165
+ params: Params,
166
+ ): DiscriminatedQueryResult<Result> {
167
+ const queryKey = queryKeyFor(queryDef, params);
168
+
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
+ };
178
+
179
+ const queryRelay = relay<Result>(state => {
180
+ // Load from cache first, then fetch fresh data
181
+ queryInstance!.notifier.consume();
182
+
183
+ this.store.activateQuery(queryDef, queryKey);
184
+
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
+ });
191
+
192
+ queryInstance.relay = new QueryResultImpl(queryRelay as DiscriminatedReactivePromise<Result>, queryInstance);
193
+
194
+ // Store the relay for future use
195
+ this.queryInstances.set(queryKey, queryInstance);
196
+ }
197
+
198
+ return queryInstance.relay as DiscriminatedQueryResult<Result>;
199
+ }
200
+
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> {
207
+ try {
208
+ instance.initialized = true;
209
+ const queryKey = queryKeyFor(queryDef, params);
210
+ // Load from cache first
211
+ const query = this.store.loadQuery(queryDef, queryKey, this.entityMap);
212
+
213
+ if (query !== undefined) {
214
+ const shape = queryDef.shape;
215
+ state.value =
216
+ shape instanceof ValidatorDef
217
+ ? parseEntities(query, shape as ComplexTypeDef, this, new Set())
218
+ : parseValue(query, shape, queryDef.id);
219
+ }
220
+
221
+ state.setPromise(this.runQuery(queryDef, queryKey, params));
222
+ } catch (error) {
223
+ // Relay will handle the error state automatically
224
+ state.setError(error as Error);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Fetches fresh data and updates the cache
230
+ */
231
+ private async runQuery<Params, Result>(
232
+ queryDef: QueryDefinition<Params, Result>,
233
+ queryKey: number,
234
+ 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>();
239
+
240
+ const shape = queryDef.shape;
241
+
242
+ const parsedData =
243
+ shape instanceof ValidatorDef
244
+ ? parseEntities(freshData, shape as ComplexTypeDef, this, entityRefs)
245
+ : parseValue(freshData, shape, queryDef.id);
246
+
247
+ // Cache the data (synchronous, fire-and-forget)
248
+ this.store.saveQuery(queryDef, queryKey, freshData, entityRefs);
249
+
250
+ return parsedData as Result;
251
+ }
252
+
253
+ hydrateEntity(key: number, shape: EntityDef): EntityRecord {
254
+ return this.entityMap.hydratePreloadedEntity(key, shape);
255
+ }
256
+
257
+ saveEntity(key: number, obj: Record<string, unknown>, shape: EntityDef, entityRefs?: Set<number>): EntityRecord {
258
+ const record = this.entityMap.setEntity(key, obj, shape);
259
+
260
+ this.store.saveEntity(key, obj, entityRefs);
261
+
262
+ return record;
263
+ }
264
+ }
265
+
266
+ export const QueryClientContext: Context<QueryClient | undefined> = context<QueryClient | undefined>(undefined);
@@ -0,0 +1,314 @@
1
+ /**
2
+ * QueryStore - Minimal interface for query persistence
3
+ *
4
+ * Provides a clean abstraction over document storage, reference counting,
5
+ * and LRU cache management. Supports both synchronous (in-memory) and
6
+ * asynchronous (writer-backed) implementations.
7
+ */
8
+
9
+ import { EntityStore } from './EntityMap.js';
10
+ import { QueryDefinition } from './QueryClient.js';
11
+
12
+ // -----------------------------------------------------------------------------
13
+ // QueryStore Interface
14
+ // -----------------------------------------------------------------------------
15
+
16
+ export interface QueryStore {
17
+ /**
18
+ * Asynchronously retrieves a document by key.
19
+ * May return undefined if the document is not in the store.
20
+ */
21
+ loadQuery(
22
+ queryDef: QueryDefinition<any, any>,
23
+ queryKey: number,
24
+ entityMap: EntityStore,
25
+ ): MaybePromise<unknown | undefined>;
26
+
27
+ /**
28
+ * Synchronously stores a document with optional reference IDs.
29
+ * This is fire-and-forget for async implementations.
30
+ */
31
+ saveQuery(queryDef: QueryDefinition<any, any>, queryKey: number, value: unknown, refIds?: Set<number>): void;
32
+
33
+ /**
34
+ * Synchronously stores an entity with optional reference IDs.
35
+ * This is fire-and-forget for async implementations.
36
+ */
37
+ saveEntity(entityKey: number, value: unknown, refIds?: Set<number>): void;
38
+
39
+ /**
40
+ * Marks a query as accessed, updating the LRU queue.
41
+ * Handles eviction internally when the cache is full.
42
+ */
43
+ activateQuery(queryDef: QueryDefinition<any, any>, queryKey: number): void;
44
+ }
45
+
46
+ export type MaybePromise<T> = T | Promise<T>;
47
+
48
+ export interface SyncPersistentStore {
49
+ has(key: string): boolean;
50
+
51
+ getString(key: string): string | undefined;
52
+ setString(key: string, value: string): void;
53
+
54
+ getNumber(key: string): number | undefined;
55
+ setNumber(key: string, value: number): void;
56
+
57
+ getBuffer(key: string): Uint32Array | undefined;
58
+ setBuffer(key: string, value: Uint32Array): void;
59
+
60
+ delete(key: string): void;
61
+ }
62
+
63
+ const DEFAULT_MAX_COUNT = 50;
64
+ const DEFAULT_MAX_AGE = 1000 * 60 * 60 * 24; // 24 hours
65
+
66
+ export class MemoryPersistentStore implements SyncPersistentStore {
67
+ private readonly kv: Record<string, unknown> = Object.create(null);
68
+
69
+ has(key: string): boolean {
70
+ return key in this.kv;
71
+ }
72
+
73
+ getString(key: string): string | undefined {
74
+ return this.kv[key] as string | undefined;
75
+ }
76
+
77
+ setString(key: string, value: string): void {
78
+ this.kv[key] = value;
79
+ }
80
+
81
+ getNumber(key: string): number | undefined {
82
+ return this.kv[key] as number | undefined;
83
+ }
84
+
85
+ setNumber(key: string, value: number): void {
86
+ this.kv[key] = value;
87
+ }
88
+
89
+ getBuffer(key: string): Uint32Array | undefined {
90
+ return this.kv[key] as Uint32Array | undefined;
91
+ }
92
+
93
+ setBuffer(key: string, value: Uint32Array): void {
94
+ this.kv[key] = value;
95
+ }
96
+
97
+ delete(key: string): void {
98
+ delete this.kv[key];
99
+ }
100
+ }
101
+
102
+ // Query Instance keys
103
+ export const valueKeyFor = (id: number) => `sq:doc:value:${id}`;
104
+ export const refCountKeyFor = (id: number) => `sq:doc:refCount:${id}`;
105
+ export const refIdsKeyFor = (id: number) => `sq:doc:refIds:${id}`;
106
+ export const updatedAtKeyFor = (id: number) => `sq:doc:updatedAt:${id}`;
107
+
108
+ // Query Type keys
109
+ export const queueKeyFor = (queryDefId: string) => `sq:doc:queue:${queryDefId}`;
110
+
111
+ export class SyncQueryStore implements QueryStore {
112
+ queues: Map<string, Uint32Array> = new Map();
113
+
114
+ constructor(private readonly kv: SyncPersistentStore) {}
115
+
116
+ loadQuery(queryDef: QueryDefinition<any, any>, queryKey: number, entityMap: EntityStore): unknown | undefined {
117
+ const updatedAt = this.kv.getNumber(updatedAtKeyFor(queryKey));
118
+
119
+ if (updatedAt === undefined || updatedAt < Date.now() - (queryDef.cache?.maxAge ?? DEFAULT_MAX_AGE)) {
120
+ return;
121
+ }
122
+
123
+ const value = this.kv.getString(valueKeyFor(queryKey));
124
+
125
+ if (value === undefined) {
126
+ return;
127
+ }
128
+
129
+ const entityIds = this.kv.getBuffer(refIdsKeyFor(queryKey));
130
+
131
+ if (entityIds !== undefined) {
132
+ this.preloadEntities(entityIds, entityMap);
133
+ }
134
+
135
+ this.activateQuery(queryDef, queryKey);
136
+
137
+ return JSON.parse(value) as Record<string, unknown>;
138
+ }
139
+
140
+ private preloadEntities(entityIds: Uint32Array, entityMap: EntityStore): void {
141
+ for (const entityId of entityIds) {
142
+ const entityValue = this.kv.getString(valueKeyFor(entityId));
143
+
144
+ if (entityValue === undefined) {
145
+ continue;
146
+ }
147
+
148
+ const entity = JSON.parse(entityValue) as Record<string, unknown>;
149
+ entityMap.setPreloadedEntity(entityId, entity);
150
+
151
+ const childIds = this.kv.getBuffer(refIdsKeyFor(entityId));
152
+
153
+ if (childIds === undefined) {
154
+ continue;
155
+ }
156
+
157
+ this.preloadEntities(childIds, entityMap);
158
+ }
159
+ }
160
+
161
+ saveQuery(queryDef: QueryDefinition<any, any>, queryKey: number, value: unknown, refIds?: Set<number>): void {
162
+ this.setValue(queryKey, value, refIds);
163
+ this.kv.setNumber(updatedAtKeyFor(queryKey), Date.now());
164
+ this.activateQuery(queryDef, queryKey);
165
+ }
166
+
167
+ saveEntity(entityKey: number, value: unknown, refIds?: Set<number>): void {
168
+ this.setValue(entityKey, value, refIds);
169
+ }
170
+
171
+ activateQuery(queryDef: QueryDefinition<any, any>, queryKey: number): void {
172
+ if (!this.kv.has(valueKeyFor(queryKey))) {
173
+ // Query not in store, nothing to do. This can happen if the query has
174
+ // been evicted from the cache, but is still active in memory.
175
+ return;
176
+ }
177
+
178
+ let queue = this.queues.get(queryDef.id);
179
+
180
+ if (queue === undefined) {
181
+ const maxCount = queryDef.cache?.maxCount ?? DEFAULT_MAX_COUNT;
182
+ queue = this.kv.getBuffer(queueKeyFor(queryDef.id));
183
+
184
+ if (queue === undefined) {
185
+ queue = new Uint32Array(maxCount);
186
+ this.kv.setBuffer(queueKeyFor(queryDef.id), queue);
187
+ } else if (queue.length !== maxCount) {
188
+ const newQueue = new Uint32Array(maxCount);
189
+ newQueue.set(queue);
190
+ queue = newQueue;
191
+ this.kv.setBuffer(queueKeyFor(queryDef.id), queue);
192
+ }
193
+
194
+ this.queues.set(queryDef.id, queue);
195
+ }
196
+
197
+ const indexOfKey = queue.indexOf(queryKey);
198
+
199
+ // Item already in queue, move to front
200
+ if (indexOfKey >= 0) {
201
+ if (indexOfKey === 0) {
202
+ // Already at front, nothing to do
203
+ return;
204
+ }
205
+ // Shift items right to make space at front
206
+ queue.copyWithin(1, 0, indexOfKey);
207
+ queue[0] = queryKey;
208
+ return;
209
+ }
210
+
211
+ // Item not in queue, add to front and evict tail
212
+ const evicted = queue[queue.length - 1];
213
+ queue.copyWithin(1, 0, queue.length - 1);
214
+ queue[0] = queryKey;
215
+
216
+ if (evicted !== 0) {
217
+ this.deleteValue(evicted);
218
+ this.kv.delete(updatedAtKeyFor(evicted));
219
+ }
220
+ }
221
+
222
+ private setValue(id: number, value: unknown, refIds?: Set<number>): void {
223
+ const kv = this.kv;
224
+
225
+ kv.setString(valueKeyFor(id), JSON.stringify(value));
226
+
227
+ const refIdsKey = refIdsKeyFor(id);
228
+
229
+ const prevRefIds = kv.getBuffer(refIdsKey);
230
+
231
+ if (refIds === undefined || refIds.size === 0) {
232
+ kv.delete(refIdsKey);
233
+
234
+ // Decrement all previous refs
235
+ if (prevRefIds !== undefined) {
236
+ for (let i = 0; i < prevRefIds.length; i++) {
237
+ const refId = prevRefIds[i];
238
+ this.decrementRefCount(refId);
239
+ }
240
+ }
241
+ } else {
242
+ // Convert the set to a Uint32Array and capture all the refIds before we
243
+ // delete previous ones from the set
244
+ const newRefIds = new Uint32Array(refIds);
245
+
246
+ if (prevRefIds !== undefined) {
247
+ // Process new refs: increment if not in old
248
+ for (let i = 0; i < prevRefIds.length; i++) {
249
+ const refId = prevRefIds[i];
250
+
251
+ if (refIds.has(refId)) {
252
+ refIds.delete(refId);
253
+ } else {
254
+ this.decrementRefCount(refId);
255
+ }
256
+ }
257
+ }
258
+
259
+ // No previous refs, increment all unique new refs
260
+ for (const refId of refIds) {
261
+ this.incrementRefCount(refId);
262
+ }
263
+
264
+ kv.setBuffer(refIdsKey, newRefIds);
265
+ }
266
+ }
267
+
268
+ private deleteValue(id: number): void {
269
+ const kv = this.kv;
270
+
271
+ kv.delete(valueKeyFor(id));
272
+ kv.delete(refCountKeyFor(id));
273
+
274
+ const refIds = kv.getBuffer(refIdsKeyFor(id));
275
+ kv.delete(refIdsKeyFor(id)); // Clean up the refIds key
276
+
277
+ if (refIds === undefined) {
278
+ return;
279
+ }
280
+
281
+ // Decrement ref counts for all referenced entities
282
+ for (const refId of refIds) {
283
+ if (refId !== 0) {
284
+ this.decrementRefCount(refId);
285
+ }
286
+ }
287
+ }
288
+
289
+ private incrementRefCount(refId: number): void {
290
+ const refCountKey = refCountKeyFor(refId);
291
+ const currentCount = this.kv.getNumber(refCountKey) ?? 0;
292
+ const newCount = currentCount + 1;
293
+ this.kv.setNumber(refCountKey, newCount);
294
+ }
295
+
296
+ private decrementRefCount(refId: number): void {
297
+ const refCountKey = refCountKeyFor(refId);
298
+ const currentCount = this.kv.getNumber(refCountKey);
299
+
300
+ if (currentCount === undefined) {
301
+ // Already deleted or never existed
302
+ return;
303
+ }
304
+
305
+ const newCount = currentCount - 1;
306
+
307
+ if (newCount === 0) {
308
+ // Entity exists, cascade delete it
309
+ this.deleteValue(refId);
310
+ } else {
311
+ this.kv.setNumber(refCountKey, newCount);
312
+ }
313
+ }
314
+ }