@choksheak/react-utils 0.1.6 → 0.1.7

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 (58) hide show
  1. package/docs/assets/navigation.js +1 -1
  2. package/docs/assets/search.js +1 -1
  3. package/docs/classes/sharedQuery.SharedQuery.html +15 -0
  4. package/docs/classes/sharedState.SharedState.html +8 -0
  5. package/docs/classes/sharedState.SharedStateConfig.html +5 -5
  6. package/docs/classes/utils_storage.InMemoryStorageAdapter.html +9 -0
  7. package/docs/classes/utils_storage.IndexedDbAdapter.html +9 -0
  8. package/docs/classes/utils_storage.LocalStorageAdapter.html +9 -0
  9. package/docs/functions/sharedQuery.sharedQuery.html +5 -0
  10. package/docs/functions/sharedQuery.useQuery.html +4 -0
  11. package/docs/functions/sharedState.sharedState.html +2 -2
  12. package/docs/functions/sharedState.useSharedState.html +2 -2
  13. package/docs/functions/sharedState.useSharedStateSetter.html +2 -2
  14. package/docs/functions/sharedState.useSharedStateValue.html +2 -2
  15. package/docs/functions/utils_storage.getStorageAdapter.html +1 -0
  16. package/docs/functions/utils_stringify.stringifyDeterministicForKeys.html +7 -0
  17. package/docs/modules/sharedQuery.html +1 -0
  18. package/docs/modules/sharedState.html +1 -1
  19. package/docs/modules/utils_storage.html +1 -0
  20. package/docs/modules/utils_stringify.html +1 -0
  21. package/docs/modules.html +1 -1
  22. package/docs/types/sharedQuery.FetchFn.html +1 -0
  23. package/docs/types/sharedQuery.FetchedData.html +3 -0
  24. package/docs/types/sharedQuery.QueryStateValue.html +4 -0
  25. package/docs/types/sharedQuery.SharedQueryOptions.html +1 -0
  26. package/docs/types/sharedQuery.SharedQueryState.html +3 -0
  27. package/docs/types/sharedState.SharedStateOptions.html +1 -0
  28. package/docs/types/utils_storage.StorageAdapter.html +5 -0
  29. package/docs/types/utils_storage.StorageOptions.html +5 -0
  30. package/docs/variables/fetcher.fetcher.html +1 -1
  31. package/docs/variables/fetcher.fetcherWithRetries.html +1 -1
  32. package/package.json +9 -6
  33. package/sharedQuery.d.mts +61 -0
  34. package/sharedQuery.d.ts +58 -0
  35. package/sharedQuery.js +471 -0
  36. package/sharedQuery.mjs +444 -0
  37. package/sharedState.d.mts +21 -16
  38. package/sharedState.d.ts +19 -15
  39. package/sharedState.js +156 -85
  40. package/sharedState.mjs +155 -85
  41. package/src/sharedQuery.ts +303 -0
  42. package/src/sharedState.ts +74 -137
  43. package/src/utils/storage.ts +197 -0
  44. package/src/utils/stringify.test.ts +38 -0
  45. package/src/utils/stringify.ts +49 -0
  46. package/utils/storage.d.mts +46 -0
  47. package/utils/storage.d.ts +44 -0
  48. package/utils/storage.js +162 -0
  49. package/utils/storage.mjs +134 -0
  50. package/utils/stringify.d.mts +12 -0
  51. package/utils/stringify.d.ts +10 -0
  52. package/utils/stringify.js +58 -0
  53. package/utils/stringify.mjs +33 -0
  54. package/utils/stringify.test.d.mts +2 -0
  55. package/utils/stringify.test.d.ts +2 -0
  56. package/utils/stringify.test.js +64 -0
  57. package/utils/stringify.test.mjs +81 -0
  58. package/docs/types/sharedState.SharedState.html +0 -6
@@ -0,0 +1,303 @@
1
+ import { DEFAULT_EXPIRY_DELTA_MS } from "@choksheak/ts-utils/kvStore";
2
+ import { useCallback, useEffect, useRef } from "react";
3
+
4
+ import { SharedState, sharedState, useSharedState } from "./sharedState";
5
+ import { StorageOptions } from "./utils/storage";
6
+ import { stringifyDeterministicForKeys } from "./utils/stringify";
7
+
8
+ export type FetchFn<TArgs extends unknown[], TResult> = (
9
+ ...args: TArgs
10
+ ) => Promise<TResult>;
11
+
12
+ export type FetchedData<TResult> = {
13
+ value: TResult;
14
+ updatedMs: number;
15
+ };
16
+
17
+ export type QueryStateValue<TResult> = {
18
+ data: FetchedData<TResult> | null;
19
+ loading: boolean;
20
+ error: Error | null;
21
+ };
22
+
23
+ /**
24
+ * The full query state is a record of all the fetch results mapped by
25
+ * query key to the state entry.
26
+ */
27
+ export type SharedQueryState<TResult> = Record<
28
+ string,
29
+ QueryStateValue<TResult>
30
+ >;
31
+
32
+ export type SharedQueryOptions<TResult> = {
33
+ // ms before data considered stale; 0 means never stale.
34
+ staleMs?: number;
35
+ // ms before data is removed from cache; 0 means never expire.
36
+ expiryMs?: number;
37
+ // Trigger background re-fetch if stale.
38
+ revalidateOnStale?: boolean;
39
+ } & StorageOptions<SharedQueryState<TResult>>;
40
+
41
+ const DEFAULT_STALE_MS = 0;
42
+
43
+ export class SharedQuery<TArgs extends unknown[], TResult> {
44
+ private readonly inflightPromises = new Map<string, Promise<TResult>>();
45
+ public readonly queryState: SharedState<SharedQueryState<TResult>>;
46
+ public readonly expiryMs: number;
47
+ public readonly staleMs: number;
48
+ public readonly revalidateOnStale: boolean;
49
+
50
+ public constructor(
51
+ public readonly queryName: string,
52
+ private readonly fetchFn: FetchFn<TArgs, TResult>,
53
+ options?: SharedQueryOptions<TResult>,
54
+ ) {
55
+ this.expiryMs =
56
+ options?.expiryMs !== undefined
57
+ ? options?.expiryMs
58
+ : DEFAULT_EXPIRY_DELTA_MS;
59
+
60
+ this.staleMs =
61
+ options?.staleMs !== undefined ? options?.staleMs : DEFAULT_STALE_MS;
62
+
63
+ this.revalidateOnStale = Boolean(options?.revalidateOnStale);
64
+
65
+ this.queryState = sharedState<SharedQueryState<TResult>>(
66
+ {},
67
+ {
68
+ ...options,
69
+ // Fallback to use expiryMs for the storeExpiryMs.
70
+ storeExpiryMs: options?.storeExpiryMs || this.expiryMs,
71
+ },
72
+ );
73
+ }
74
+
75
+ public getQueryKey(args: TArgs): string {
76
+ return this.queryName + ":" + stringifyDeterministicForKeys(args);
77
+ }
78
+
79
+ private isStale(entry: FetchedData<TResult>): boolean {
80
+ if (this.staleMs === 0) return true; // always stale
81
+
82
+ const ageMs = Date.now() - entry.updatedMs;
83
+ return ageMs > this.staleMs;
84
+ }
85
+
86
+ private isExpired(entry: FetchedData<TResult>): boolean {
87
+ if (this.expiryMs === 0) return false; // never expire
88
+
89
+ const ageMs = Date.now() - entry.updatedMs;
90
+ return ageMs > this.expiryMs;
91
+ }
92
+
93
+ private fetchNoCaching(queryKey: string, ...args: TArgs): Promise<TResult> {
94
+ const inflightPromise = this.inflightPromises.get(queryKey);
95
+
96
+ // De-duplicate in-flight requests
97
+ if (inflightPromise) {
98
+ console.log(`Deduplicating inflight fetch for ${queryKey}`);
99
+ return inflightPromise;
100
+ }
101
+
102
+ // Fetch new data
103
+ console.log(`Start fetching ${queryKey}`);
104
+
105
+ const promise = this.fetchFn(...args)
106
+ .then((result) => {
107
+ console.log(`Successfully fetched ${queryKey}`);
108
+ this.setCache(queryKey, result);
109
+ return result;
110
+ })
111
+ .catch((e) => {
112
+ console.error(`Failed to fetch ${queryKey}: ${e}`);
113
+ throw e;
114
+ })
115
+ .finally(() => {
116
+ this.inflightPromises.delete(queryKey);
117
+ });
118
+
119
+ this.inflightPromises.set(queryKey, promise);
120
+ return promise;
121
+ }
122
+
123
+ public async getCachedOrFetch(...args: TArgs): Promise<TResult> {
124
+ const queryKey = this.getQueryKey(args);
125
+ const cached = this.queryState.getSnapshot()?.[queryKey];
126
+
127
+ if (cached?.data && !this.isExpired(cached.data)) {
128
+ // Return cached value if not stale.
129
+ if (!this.isStale(cached.data)) {
130
+ console.log(
131
+ `Return fresh data ${queryKey} from cache without fetching`,
132
+ );
133
+ return cached.data.value;
134
+ }
135
+
136
+ // If stale, optionally update the data in the background.
137
+ if (this.revalidateOnStale) {
138
+ console.log(`revalidateOnStale for ${queryKey}`);
139
+ void this.fetchNoCaching(queryKey, ...args);
140
+ }
141
+
142
+ // Still return stale data immediately.
143
+ console.log(`Returning stale data for ${queryKey}`);
144
+ return cached.data.value;
145
+ }
146
+
147
+ return await this.fetchNoCaching(queryKey, ...args);
148
+ }
149
+
150
+ private async setCache(queryKey: string, value: TResult): Promise<void> {
151
+ const entry: QueryStateValue<TResult> = {
152
+ data: { value, updatedMs: Date.now() },
153
+ loading: false,
154
+ error: null,
155
+ };
156
+
157
+ this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
158
+ }
159
+
160
+ /** Do a new fetch even when the data is already cached. */
161
+ public async updateFromSource(...args: TArgs): Promise<TResult> {
162
+ const key = this.getQueryKey(args);
163
+ return await this.fetchNoCaching(key, ...args);
164
+ }
165
+
166
+ /** Delete the currently cached data. */
167
+ public invalidate(args?: TArgs): void {
168
+ if (args) {
169
+ const queryKey = this.getQueryKey(args);
170
+
171
+ const record = this.queryState.getSnapshot();
172
+ const clone = { ...record };
173
+ delete clone[queryKey];
174
+ this.queryState.setValue(clone);
175
+
176
+ this.inflightPromises.delete(queryKey);
177
+ } else {
178
+ this.clearAll();
179
+ }
180
+ }
181
+
182
+ /** Delete the currently cached data & all inflight promises. */
183
+ public clearAll(): void {
184
+ this.queryState.delete();
185
+ this.inflightPromises.clear();
186
+ }
187
+ }
188
+
189
+ const seenQueryNames = new Set<string>();
190
+
191
+ /**
192
+ * Create a reusable shared query object that can be used to auto de-duplicate
193
+ * and cache all data fetches, with auto-expiration and staleness checks.
194
+ *
195
+ * Each `queryName` must be unique, and can only be declared once, most often
196
+ * in the top level scope. The queryName is used for logging only.
197
+ */
198
+ export function sharedQuery<TArgs extends unknown[], TResult>(
199
+ // The name could have been auto-generated, but we let the user give us a
200
+ // human-readable name that can be identified quickly in the logs.
201
+ queryName: string,
202
+ fetchFn: FetchFn<TArgs, TResult>,
203
+ options?: SharedQueryOptions<TResult>,
204
+ ): SharedQuery<TArgs, TResult> {
205
+ if (seenQueryNames.has(queryName)) {
206
+ throw new Error(`Duplicate shared query "${queryName}"`);
207
+ }
208
+ seenQueryNames.add(queryName);
209
+
210
+ return new SharedQuery(queryName, fetchFn, options);
211
+ }
212
+
213
+ const DEFAULT_QUERY_STATE_ENTRY: QueryStateValue<unknown> = {
214
+ data: null,
215
+ loading: true,
216
+ error: null,
217
+ };
218
+
219
+ /**
220
+ * React hook to make use of a shared query inside any React component.
221
+ *
222
+ * const users = useQuery(usersQuery, { userId: "123" });
223
+ */
224
+ export function useQuery<TArgs extends unknown[], TResult>(
225
+ query: SharedQuery<TArgs, TResult>,
226
+ args: TArgs,
227
+ ): QueryStateValue<TResult> {
228
+ const [queryState, setQueryState] = useSharedState(query.queryState);
229
+
230
+ const isMounted = useRef(true);
231
+
232
+ // Identify args changes using deep equality.
233
+ // We intentionally chose not to memoize this value because args is likely
234
+ // to be an unstable value (changes on every render).
235
+ const queryKey = query.getQueryKey(args);
236
+
237
+ // The fetch logic wrapped in useCallback to be stable for useEffect
238
+ const execute = useCallback(async () => {
239
+ console.log(`Begin executing shared query ${queryKey}`);
240
+
241
+ setQueryState((prev) => {
242
+ const clone = { ...prev };
243
+ clone[queryKey] = {
244
+ data: clone[queryKey]?.data ?? null,
245
+ loading: true,
246
+ error: null,
247
+ };
248
+ return clone;
249
+ });
250
+
251
+ try {
252
+ const value = await query.getCachedOrFetch(...args);
253
+
254
+ if (isMounted.current) {
255
+ setQueryState((prev) => {
256
+ const clone = { ...prev };
257
+ const now = Date.now();
258
+ clone[queryKey] = {
259
+ data: {
260
+ value,
261
+ updatedMs: now,
262
+ },
263
+ loading: false,
264
+ error: null,
265
+ };
266
+ return clone;
267
+ });
268
+ }
269
+ } catch (e) {
270
+ const error =
271
+ e instanceof Error
272
+ ? e
273
+ : new Error(`An unknown error occurred during fetch: ${e}`);
274
+
275
+ if (isMounted.current) {
276
+ setQueryState((prev) => {
277
+ const clone = { ...prev };
278
+ clone[queryKey] = {
279
+ data: clone[queryKey]?.data ?? null,
280
+ loading: false,
281
+ error,
282
+ };
283
+ return clone;
284
+ });
285
+ }
286
+ }
287
+ }, [queryKey]);
288
+
289
+ useEffect(() => {
290
+ isMounted.current = true;
291
+
292
+ void execute();
293
+
294
+ return () => {
295
+ isMounted.current = false;
296
+ };
297
+ }, [execute]);
298
+
299
+ return (
300
+ queryState[queryKey] ??
301
+ (DEFAULT_QUERY_STATE_ENTRY as QueryStateValue<TResult>)
302
+ );
303
+ }
@@ -1,7 +1,13 @@
1
- import { kvStore } from "@choksheak/ts-utils/kvStore";
1
+ import { DEFAULT_EXPIRY_DELTA_MS } from "@choksheak/ts-utils/kvStore";
2
2
  import { MS_PER_DAY } from "@choksheak/ts-utils/timeConstants";
3
3
  import { useSyncExternalStore } from "react";
4
4
 
5
+ import {
6
+ getStorageAdapter,
7
+ StorageAdapter,
8
+ StorageOptions,
9
+ } from "./utils/storage";
10
+
5
11
  /**
6
12
  * The "shared state" is a React state that shares the same value across the
7
13
  * entire app. It is largely similar to the jotai library, but is much smaller
@@ -22,13 +28,13 @@ import { useSyncExternalStore } from "react";
22
28
  */
23
29
  export class SharedStateConfig {
24
30
  /**
25
- * Default duration for persistence expiration in milliseconds. Initial value
31
+ * Default duration for storage expiration in milliseconds. Initial value
26
32
  * is 30 days, but can be changed to anything.
27
33
  */
28
- public static persistExpiryMs = MS_PER_DAY * 30;
34
+ public static storeExpiryMs = MS_PER_DAY * 30;
29
35
 
30
36
  /**
31
- * True to load from persistence only on mount, false (default) to load in
37
+ * True to load from storage only on mount, false (default) to load in
32
38
  * the top level scope when the shared state is defined.
33
39
  */
34
40
  public static lazyLoad = false;
@@ -40,11 +46,6 @@ export class SharedStateConfig {
40
46
 
41
47
  type Subscriber<T> = (next: T, prev: T) => void;
42
48
 
43
- type PersistenceAdapter<T> = {
44
- save?: (value: T) => Promise<void> | void;
45
- load?: () => Promise<T | undefined> | T | undefined;
46
- };
47
-
48
49
  class PubSubStore {
49
50
  private dataByKey = new Map<string, unknown>();
50
51
  private subscribersByKey = new Map<string, Set<Subscriber<unknown>>>();
@@ -65,6 +66,10 @@ class PubSubStore {
65
66
  this.notify(key, value, prev);
66
67
  }
67
68
 
69
+ public delete(key: string): void {
70
+ this.dataByKey.delete(key);
71
+ }
72
+
68
73
  public setNoNotify<T>(key: string, value: T): void {
69
74
  this.dataByKey.set(key, value);
70
75
  }
@@ -103,95 +108,91 @@ class PubSubStore {
103
108
  }
104
109
  }
105
110
 
106
- const store = new PubSubStore();
111
+ const pubSubStore = new PubSubStore();
107
112
 
108
- export type SharedState<T> = {
109
- subscribe: (subscriber: () => void) => () => void;
110
- getSnapshot: () => T;
111
- initDefaultValueOnce: () => Promise<void> | void;
112
- initDone: boolean;
113
- setValue: (next: T | ((prev: T) => T)) => void;
114
- };
113
+ const STORAGE_KEY = "state";
114
+
115
+ export type SharedStateOptions<T> = {
116
+ lazyLoad?: boolean;
117
+ } & StorageOptions<T>;
115
118
 
116
119
  let stateKey = 0;
117
120
 
118
- /**
119
- * Create a new shared state object. Put this code at the top level scope:
120
- *
121
- * const myState = sharedState<number>(0);
122
- */
123
- export function sharedState<T>(
124
- defaultValue: T,
125
- options?: {
126
- localStorageKey?: string;
127
- indexedDbKey?: string;
128
- persistExpiryMs?: number;
129
- lazyLoad?: boolean;
130
- },
131
- ): SharedState<T> {
132
- const { localStorageKey, indexedDbKey, persistExpiryMs, lazyLoad } =
133
- options ?? {};
121
+ export class SharedState<T> {
122
+ private readonly pubSubKey: string;
123
+ private readonly storageAdapter: StorageAdapter<T> | null;
124
+ public initDone = false;
125
+
126
+ public constructor(
127
+ private readonly defaultValue: T,
128
+ options?: SharedStateOptions<T>,
129
+ ) {
130
+ this.pubSubKey = String(stateKey++);
131
+
132
+ // Use max of 1 storage adapter per shared state.
133
+ this.storageAdapter = getStorageAdapter(options, DEFAULT_EXPIRY_DELTA_MS);
134
134
 
135
- const key = String(stateKey++);
135
+ // Always set the default value first to avoid returning undefineds if that
136
+ // is not part of T.
137
+ pubSubStore.setNoNotify(this.pubSubKey, defaultValue);
136
138
 
137
- // Use up to 1 persistence adapter per shared state.
138
- let persistenceAdapter: PersistenceAdapter<T> | undefined;
139
- const expiryMs = persistExpiryMs ?? SharedStateConfig.persistExpiryMs;
139
+ const lazyLoad = options?.lazyLoad;
140
+ const lazy = lazyLoad !== undefined ? lazyLoad : SharedStateConfig.lazyLoad;
140
141
 
141
- if (localStorageKey) {
142
- persistenceAdapter = new LocalStorageAdapter<T>(localStorageKey, expiryMs);
143
- } else if (indexedDbKey) {
144
- persistenceAdapter = new IndexedDbAdapter<T>(indexedDbKey, expiryMs);
142
+ if (!lazy) {
143
+ void this.initDefaultValueOnce();
144
+ }
145
145
  }
146
146
 
147
147
  // `subscribe` function required by useSyncExternalStore
148
- function subscribe(subscriber: () => void): () => void {
149
- return store.subscribe(key, () => subscriber());
148
+ public subscribe(subscriber: () => void): () => void {
149
+ return pubSubStore.subscribe(this.pubSubKey, () => subscriber());
150
150
  }
151
151
 
152
+ // Get the value of the shared state.
152
153
  // `getSnapshot` function — must return same ref if value unchanged
153
- function getSnapshot(): T {
154
- return store.get(key);
154
+ public getSnapshot(): T {
155
+ return pubSubStore.get(this.pubSubKey);
155
156
  }
156
157
 
157
- function setValue(next: T | ((prev: T) => T)): void {
158
+ // Set the value of the shared state.
159
+ public setValue(next: T | ((prev: T) => T)): void {
158
160
  if (typeof next === "function") {
159
- const prev = store.get<T>(key);
161
+ const prev = pubSubStore.get<T>(this.pubSubKey);
160
162
  next = (next as (p: T) => T)(prev);
161
163
  }
162
164
 
163
- store.set(key, next);
164
- void persistenceAdapter?.save?.(next);
165
+ pubSubStore.set(this.pubSubKey, next);
166
+ void this.storageAdapter?.save(STORAGE_KEY, next);
165
167
  }
166
168
 
167
- // Always set the default value first to avoid returning undefineds if that
168
- // is not part of T.
169
- store.setNoNotify(key, defaultValue);
170
-
171
- const state = {
172
- subscribe,
173
- getSnapshot,
174
-
175
- // initial default handling
176
- initDefaultValueOnce: async () => {
177
- if (state.initDone) return;
178
- state.initDone = true;
179
-
180
- const v = await persistenceAdapter?.load?.();
181
- store.set(key, v !== undefined ? v : defaultValue);
182
- },
183
- initDone: false,
184
-
185
- setValue,
186
- };
169
+ // Remove the value of the shared state. Actually this just sets the value
170
+ // back to the given default value.
171
+ public delete() {
172
+ pubSubStore.set(this.pubSubKey, this.defaultValue);
173
+ void this.storageAdapter?.save(STORAGE_KEY, this.defaultValue);
174
+ }
187
175
 
188
- const lazy = lazyLoad !== undefined ? lazyLoad : SharedStateConfig.lazyLoad;
176
+ // Initial default handling
177
+ public async initDefaultValueOnce() {
178
+ if (this.initDone) return;
179
+ this.initDone = true;
189
180
 
190
- if (!lazy) {
191
- void state.initDefaultValueOnce();
181
+ const v = await this.storageAdapter?.load(STORAGE_KEY);
182
+ pubSubStore.set(this.pubSubKey, v !== undefined ? v : this.defaultValue);
192
183
  }
184
+ }
193
185
 
194
- return state;
186
+ /**
187
+ * Create a new shared state object. Put this code at the top level scope:
188
+ *
189
+ * const myState = sharedState<number>(0);
190
+ */
191
+ export function sharedState<T>(
192
+ defaultValue: T,
193
+ options?: SharedStateOptions<T>,
194
+ ): SharedState<T> {
195
+ return new SharedState(defaultValue, options);
195
196
  }
196
197
 
197
198
  /**
@@ -222,67 +223,3 @@ export function useSharedStateValue<T>(state: SharedState<T>) {
222
223
  export function useSharedStateSetter<T>(state: SharedState<T>) {
223
224
  return useSharedState(state)[1];
224
225
  }
225
-
226
- /************************************************************************/
227
- /* Persistence adapters */
228
- /************************************************************************/
229
-
230
- /** Save state to local storage. */
231
- type LSBox<T> = { value: T; expiresAt: number };
232
-
233
- class LocalStorageAdapter<T> implements PersistenceAdapter<T> {
234
- public constructor(
235
- public readonly key: string,
236
- public readonly expiryMs: number,
237
- ) {}
238
-
239
- public save(value: T): void {
240
- if (typeof window === "undefined") return;
241
-
242
- window.localStorage.setItem(
243
- this.key,
244
- JSON.stringify({
245
- value,
246
- expiresAt: Date.now() + this.expiryMs,
247
- } satisfies LSBox<T>),
248
- );
249
- }
250
-
251
- public load(): T | undefined {
252
- if (typeof window === "undefined") return undefined;
253
-
254
- const raw = window.localStorage.getItem(this.key);
255
- if (!raw) return undefined;
256
-
257
- const box = JSON.parse(raw) as LSBox<T>;
258
- if (!box || !box?.expiresAt || Date.now() >= box.expiresAt) {
259
- window.localStorage.removeItem(this.key);
260
- return undefined;
261
- }
262
-
263
- return box.value;
264
- }
265
- }
266
-
267
- /** Save state to indexed db. */
268
- class IndexedDbAdapter<T> implements PersistenceAdapter<T> {
269
- public constructor(
270
- public readonly key: string,
271
- public readonly expiryMs: number,
272
- ) {}
273
-
274
- public async save(value: T): Promise<void> {
275
- if (typeof window === "undefined") return;
276
-
277
- await kvStore.set(this.key, JSON.stringify(value), this.expiryMs);
278
- }
279
-
280
- public async load(): Promise<T | undefined> {
281
- if (typeof window === "undefined") return undefined;
282
-
283
- const raw = await kvStore.get<string>(this.key);
284
- if (raw === undefined) return undefined;
285
-
286
- return JSON.parse(raw);
287
- }
288
- }