@choksheak/react-utils 0.1.5 → 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 +7 -6
  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 +22 -16
  38. package/sharedState.d.ts +20 -15
  39. package/sharedState.js +157 -85
  40. package/sharedState.mjs +156 -85
  41. package/src/sharedQuery.ts +303 -0
  42. package/src/sharedState.ts +75 -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,12 +28,13 @@ import { useSyncExternalStore } from "react";
22
28
  */
23
29
  export class SharedStateConfig {
24
30
  /**
25
- * Default duration for persistence expiration in milliseconds.
31
+ * Default duration for storage expiration in milliseconds. Initial value
32
+ * is 30 days, but can be changed to anything.
26
33
  */
27
- public static defaultPersistExpiryMs = MS_PER_DAY;
34
+ public static storeExpiryMs = MS_PER_DAY * 30;
28
35
 
29
36
  /**
30
- * 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
31
38
  * the top level scope when the shared state is defined.
32
39
  */
33
40
  public static lazyLoad = false;
@@ -39,11 +46,6 @@ export class SharedStateConfig {
39
46
 
40
47
  type Subscriber<T> = (next: T, prev: T) => void;
41
48
 
42
- type PersistenceAdapter<T> = {
43
- save?: (value: T) => Promise<void> | void;
44
- load?: () => Promise<T | undefined> | T | undefined;
45
- };
46
-
47
49
  class PubSubStore {
48
50
  private dataByKey = new Map<string, unknown>();
49
51
  private subscribersByKey = new Map<string, Set<Subscriber<unknown>>>();
@@ -64,6 +66,10 @@ class PubSubStore {
64
66
  this.notify(key, value, prev);
65
67
  }
66
68
 
69
+ public delete(key: string): void {
70
+ this.dataByKey.delete(key);
71
+ }
72
+
67
73
  public setNoNotify<T>(key: string, value: T): void {
68
74
  this.dataByKey.set(key, value);
69
75
  }
@@ -102,95 +108,91 @@ class PubSubStore {
102
108
  }
103
109
  }
104
110
 
105
- const store = new PubSubStore();
111
+ const pubSubStore = new PubSubStore();
106
112
 
107
- export type SharedState<T> = {
108
- subscribe: (subscriber: () => void) => () => void;
109
- getSnapshot: () => T;
110
- initDefaultValueOnce: () => Promise<void> | void;
111
- initDone: boolean;
112
- setValue: (next: T | ((prev: T) => T)) => void;
113
- };
113
+ const STORAGE_KEY = "state";
114
+
115
+ export type SharedStateOptions<T> = {
116
+ lazyLoad?: boolean;
117
+ } & StorageOptions<T>;
114
118
 
115
119
  let stateKey = 0;
116
120
 
117
- /**
118
- * Create a new shared state object. Put this code at the top level scope:
119
- *
120
- * const myState = sharedState<number>(0);
121
- */
122
- export function sharedState<T>(
123
- defaultValue: T,
124
- options?: {
125
- localStorageKey?: string;
126
- indexedDbKey?: string;
127
- persistExpiryMs?: number;
128
- lazyLoad?: boolean;
129
- },
130
- ): SharedState<T> {
131
- const { localStorageKey, indexedDbKey, persistExpiryMs, lazyLoad } =
132
- 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);
133
134
 
134
- 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);
135
138
 
136
- // Use up to 1 persistence adapter per shared state.
137
- let persistenceAdapter: PersistenceAdapter<T> | undefined;
138
- const expiryMs = persistExpiryMs ?? SharedStateConfig.defaultPersistExpiryMs;
139
+ const lazyLoad = options?.lazyLoad;
140
+ const lazy = lazyLoad !== undefined ? lazyLoad : SharedStateConfig.lazyLoad;
139
141
 
140
- if (localStorageKey) {
141
- persistenceAdapter = new LocalStorageAdapter<T>(localStorageKey, expiryMs);
142
- } else if (indexedDbKey) {
143
- persistenceAdapter = new IndexedDbAdapter<T>(indexedDbKey, expiryMs);
142
+ if (!lazy) {
143
+ void this.initDefaultValueOnce();
144
+ }
144
145
  }
145
146
 
146
147
  // `subscribe` function required by useSyncExternalStore
147
- function subscribe(subscriber: () => void): () => void {
148
- return store.subscribe(key, () => subscriber());
148
+ public subscribe(subscriber: () => void): () => void {
149
+ return pubSubStore.subscribe(this.pubSubKey, () => subscriber());
149
150
  }
150
151
 
152
+ // Get the value of the shared state.
151
153
  // `getSnapshot` function — must return same ref if value unchanged
152
- function getSnapshot(): T {
153
- return store.get(key);
154
+ public getSnapshot(): T {
155
+ return pubSubStore.get(this.pubSubKey);
154
156
  }
155
157
 
156
- 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 {
157
160
  if (typeof next === "function") {
158
- const prev = store.get<T>(key);
161
+ const prev = pubSubStore.get<T>(this.pubSubKey);
159
162
  next = (next as (p: T) => T)(prev);
160
163
  }
161
164
 
162
- store.set(key, next);
163
- void persistenceAdapter?.save?.(next);
165
+ pubSubStore.set(this.pubSubKey, next);
166
+ void this.storageAdapter?.save(STORAGE_KEY, next);
164
167
  }
165
168
 
166
- // Always set the default value first to avoid returning undefineds if that
167
- // is not part of T.
168
- store.setNoNotify(key, defaultValue);
169
-
170
- const state = {
171
- subscribe,
172
- getSnapshot,
173
-
174
- // initial default handling
175
- initDefaultValueOnce: async () => {
176
- if (state.initDone) return;
177
- state.initDone = true;
178
-
179
- const v = await persistenceAdapter?.load?.();
180
- store.set(key, v !== undefined ? v : defaultValue);
181
- },
182
- initDone: false,
183
-
184
- setValue,
185
- };
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
+ }
186
175
 
187
- const lazy = lazyLoad !== undefined ? lazyLoad : SharedStateConfig.lazyLoad;
176
+ // Initial default handling
177
+ public async initDefaultValueOnce() {
178
+ if (this.initDone) return;
179
+ this.initDone = true;
188
180
 
189
- if (!lazy) {
190
- void state.initDefaultValueOnce();
181
+ const v = await this.storageAdapter?.load(STORAGE_KEY);
182
+ pubSubStore.set(this.pubSubKey, v !== undefined ? v : this.defaultValue);
191
183
  }
184
+ }
192
185
 
193
- 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);
194
196
  }
195
197
 
196
198
  /**
@@ -221,67 +223,3 @@ export function useSharedStateValue<T>(state: SharedState<T>) {
221
223
  export function useSharedStateSetter<T>(state: SharedState<T>) {
222
224
  return useSharedState(state)[1];
223
225
  }
224
-
225
- /************************************************************************/
226
- /* Persistence adapters */
227
- /************************************************************************/
228
-
229
- /** Save state to local storage. */
230
- type LSBox<T> = { value: T; expiresAt: number };
231
-
232
- class LocalStorageAdapter<T> implements PersistenceAdapter<T> {
233
- public constructor(
234
- public readonly key: string,
235
- public readonly expiryMs: number,
236
- ) {}
237
-
238
- public save(value: T): void {
239
- if (typeof window === "undefined") return;
240
-
241
- window.localStorage.setItem(
242
- this.key,
243
- JSON.stringify({
244
- value,
245
- expiresAt: Date.now() + this.expiryMs,
246
- } satisfies LSBox<T>),
247
- );
248
- }
249
-
250
- public load(): T | undefined {
251
- if (typeof window === "undefined") return undefined;
252
-
253
- const raw = window.localStorage.getItem(this.key);
254
- if (!raw) return undefined;
255
-
256
- const box = JSON.parse(raw) as LSBox<T>;
257
- if (!box || !box?.expiresAt || Date.now() >= box.expiresAt) {
258
- window.localStorage.removeItem(this.key);
259
- return undefined;
260
- }
261
-
262
- return box.value;
263
- }
264
- }
265
-
266
- /** Save state to indexed db. */
267
- class IndexedDbAdapter<T> implements PersistenceAdapter<T> {
268
- public constructor(
269
- public readonly key: string,
270
- public readonly expiryMs: number,
271
- ) {}
272
-
273
- public async save(value: T): Promise<void> {
274
- if (typeof window === "undefined") return;
275
-
276
- await kvStore.set(this.key, JSON.stringify(value), this.expiryMs);
277
- }
278
-
279
- public async load(): Promise<T | undefined> {
280
- if (typeof window === "undefined") return undefined;
281
-
282
- const raw = await kvStore.get<string>(this.key);
283
- if (raw === undefined) return undefined;
284
-
285
- return JSON.parse(raw);
286
- }
287
- }