-
-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathStateCache.ts
More file actions
257 lines (218 loc) · 7.04 KB
/
Copy pathStateCache.ts
File metadata and controls
257 lines (218 loc) · 7.04 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
/* eslint-disable @typescript-eslint/naming-convention */
import { assert } from '@metamask/utils';
import type { Serializable } from '../serialization/types';
import type { IStateManager } from '../services/state/IStateManager';
import type { ILogger } from '../utils/logger';
import type { ICache } from './ICache';
import type { CacheEntry } from './types';
/**
* The whole cache store.
*/
export type CacheStore = Record<string, CacheEntry> | undefined;
/**
* A prefix for the cache "location" in the state. Enforced to start with `__cache__` to avoid collisions with other state values.
*/
export type CachePrefix = `__cache__${string}`;
/**
* Describes the shape of the whole state inside which the cache is stored.
*/
export type StateValue = {
[x: string]: Serializable;
} & {
[K in CachePrefix]?: CacheStore;
};
/**
* A cache that wraps any implementation of the `IStateManager` interface to store the cache.
*
* It is intended to be used with the snap's `State` class, but can be used with any other implementation of the `IStateManager` interface. For instance it can be used with the `InMemoryState` class for testing purposes.
*
* By default, it stores its data in the `__cache__default` property of the state, but you can specify any other prefix you want, provided it starts with `__cache__` to avoid collisions with other state values.
* This is useful if you want to have multiple independent caches in the same state.
*
* ```
* {
* ..., // other state values
* __cache__default: {
* key1: value1,
* key2: value2,
* },
* __cache__my-prefix: {
* key3: value3,
* key4: value4,
* },
* }
* ```
*
* @example
* ```ts
* const state = new State({}); // Here we use the real snap's state
* const cache = new StateCache(state, '__cache__my-prefix');
*
* // state looks like this:
* // {
* // ..., // other state values
* // no __cache__my-prefix yet
* // }
*
* await cache.set('key1', 'value1');
*
* // state looks like this:
* // {
* // ..., // other state values
* // __cache__my-prefix: {
* // key1: value1,
* // },
* // }
* ```
*/
export class StateCache implements ICache<Serializable | undefined> {
#state: IStateManager<StateValue>;
public readonly prefix: CachePrefix;
public readonly logger: ILogger;
constructor(
state: IStateManager<StateValue>,
logger: ILogger = console,
prefix: CachePrefix = '__cache__default',
) {
this.#state = state;
this.logger = logger;
this.prefix = prefix;
}
async get(key: string): Promise<Serializable | undefined> {
const result = await this.mget([key]);
return result[key];
}
async set(
key: string,
value: Serializable,
ttlMilliseconds = Number.MAX_SAFE_INTEGER,
): Promise<void> {
this.#validateTtlOrThrow(ttlMilliseconds);
await this.#state.setKey(`${this.prefix}.${key}`, {
value,
expiresAt: Math.min(
Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER),
Number.MAX_SAFE_INTEGER,
),
});
}
#validateTtlOrThrow(ttlMilliseconds?: number): void {
if (ttlMilliseconds === undefined) {
return;
}
if (typeof ttlMilliseconds !== 'number') {
throw new Error('TTL must be a number');
}
if (ttlMilliseconds < 0) {
throw new Error('TTL must be positive');
}
if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) {
throw new Error('TTL must be less than 2^53 - 1');
}
}
async delete(key: string): Promise<boolean> {
const result = await this.mdelete([key]);
return result[key] ?? false;
}
async clear(): Promise<void> {
await this.#state.setKey(this.prefix, {});
}
async has(key: string): Promise<boolean> {
const result = await this.get(key);
return result !== undefined;
}
async keys(): Promise<string[]> {
const cacheStore = await this.#state.getKey(this.prefix);
return Object.keys(cacheStore ?? {});
}
async size(): Promise<number> {
const cacheStore = await this.#state.getKey(this.prefix);
return Object.keys(cacheStore ?? {}).length;
}
async peek(key: string): Promise<Serializable | undefined> {
const cacheStore = await this.#state.getKey<CacheStore>(this.prefix);
const cacheEntry = cacheStore?.[key];
return cacheEntry?.value;
}
async mget(
keys: string[],
): Promise<Record<string, Serializable | undefined>> {
const cacheStore = await this.#state.getKey(this.prefix);
const keysAndValues = Object.entries(cacheStore ?? {}).filter(([key]) =>
keys.includes(key),
);
const expiredKeys = keysAndValues.filter(
([_, cacheEntry]) => cacheEntry && cacheEntry.expiresAt < Date.now(),
);
await this.mdelete(expiredKeys.map(([key]) => key));
return keysAndValues.reduce<Record<string, Serializable | undefined>>(
(acc, [key, cacheEntry]) => {
if (cacheEntry === undefined) {
this.logger.info(`[StateCache] ❌ Cache miss for key "${key}"`);
return acc;
}
if (cacheEntry.expiresAt < Date.now()) {
this.logger.info(`[StateCache] ⌛ Cache expired for key "${key}"`);
acc[key] = undefined;
} else {
this.logger.info(`[StateCache] 🎉 Cache hit for key "${key}"`);
acc[key] = cacheEntry.value;
}
return acc;
},
{},
);
}
async mset(
entries: { key: string; value: Serializable; ttlMilliseconds?: number }[],
): Promise<void> {
if (entries.length === 0) {
return;
}
if (entries.length === 1) {
assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined
const { key, value, ttlMilliseconds } = entries[0];
await this.set(key, value, ttlMilliseconds);
return;
}
entries.forEach(({ ttlMilliseconds }) => {
this.#validateTtlOrThrow(ttlMilliseconds);
});
// Using `state.update` is preferred for bulk `set`s, because it's more efficient and atomic.
await this.#state.update((stateValue) => {
const cacheStore = stateValue[this.prefix] ?? {};
entries.forEach(({ key, value, ttlMilliseconds }) => {
if (value === undefined) {
return;
}
cacheStore[key] = {
value,
expiresAt: Math.min(
Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER),
Number.MAX_SAFE_INTEGER,
),
};
});
stateValue[this.prefix] = cacheStore;
return stateValue;
});
}
async mdelete(keys: string[]): Promise<Record<string, boolean>> {
const result: Record<string, boolean> = {};
// Using `state.update` is preferred for bulk `delete`s, because it's more efficient and atomic.
await this.#state.update((stateValue) => {
const cacheStore = stateValue[this.prefix] ?? {};
keys.forEach((key) => {
if (cacheStore[key] === undefined) {
result[key] = false;
} else {
delete cacheStore[key];
result[key] = true;
}
});
stateValue[this.prefix] = cacheStore;
return stateValue;
});
return result;
}
}