@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.
- package/ENTITY_STORE_DESIGN.md +386 -0
- package/package.json +71 -0
- package/src/EntityMap.ts +63 -0
- package/src/QueryClient.ts +266 -0
- package/src/QueryStore.ts +314 -0
- package/src/__tests__/caching-persistence.test.ts +954 -0
- package/src/__tests__/entity-system.test.ts +552 -0
- package/src/__tests__/mock-fetch.test.ts +182 -0
- package/src/__tests__/parse-entities.test.ts +421 -0
- package/src/__tests__/path-interpolation.test.ts +225 -0
- package/src/__tests__/reactivity.test.ts +420 -0
- package/src/__tests__/rest-query-api.test.ts +564 -0
- package/src/__tests__/type-to-string.test.ts +129 -0
- package/src/__tests__/utils.ts +242 -0
- package/src/__tests__/validation-edge-cases.test.ts +820 -0
- package/src/errors.ts +124 -0
- package/src/index.ts +7 -0
- package/src/parseEntities.ts +213 -0
- package/src/pathInterpolator.ts +74 -0
- package/src/proxy.ts +257 -0
- package/src/query.ts +163 -0
- package/src/react/__tests__/basic.test.tsx +921 -0
- package/src/react/__tests__/component.test.tsx +977 -0
- package/src/react/__tests__/utils.tsx +71 -0
- package/src/typeDefs.ts +351 -0
- package/src/types.ts +121 -0
- package/src/utils.ts +66 -0
- package/tsconfig.cjs.json +14 -0
- package/tsconfig.esm.json +13 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +71 -0
|
@@ -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
|
+
}
|