@dbx-tools/cache 0.0.0-stage → 0.9.119
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/LICENSE +202 -0
- package/README.md +41 -2
- package/index.ts +8 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +7 -0
- package/lib/src/cache.d.ts +64 -0
- package/lib/src/cache.js +363 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +82 -4
- package/src/cache.ts +494 -0
package/src/cache.ts
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory-first Cacheable and Keyv composition.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { EventEmitter } from "node:events";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { projectUtils } from "@dbx-tools/core";
|
|
10
|
+
import { errorUtils, log } from "@dbx-tools/shared-core";
|
|
11
|
+
import {
|
|
12
|
+
Cacheable,
|
|
13
|
+
CacheableHooks,
|
|
14
|
+
createKeyv,
|
|
15
|
+
KeyvCacheableMemory,
|
|
16
|
+
type CacheableOptions,
|
|
17
|
+
type GetOrSetFunctionOptions,
|
|
18
|
+
type KeyvStoreAdapter,
|
|
19
|
+
} from "cacheable";
|
|
20
|
+
import envPaths from "env-paths";
|
|
21
|
+
import { KeyvFile } from "keyv-file";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MAX_ENTRIES = 1_000;
|
|
24
|
+
const DEFAULT_WRITE_DELAY_MS = 25;
|
|
25
|
+
const ERROR_VALUE_PREFIX = "__dbx_tools_cached_on_error__:";
|
|
26
|
+
const ENVELOPE_VERSION = 1;
|
|
27
|
+
const logger = log.logger("cache");
|
|
28
|
+
|
|
29
|
+
interface ValueEnvelope {
|
|
30
|
+
readonly __dbxToolsCacheEnvelope: typeof ENVELOPE_VERSION;
|
|
31
|
+
readonly value: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SetMutation {
|
|
35
|
+
readonly kind: "set";
|
|
36
|
+
readonly ttl?: number;
|
|
37
|
+
readonly value: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface DeleteMutation {
|
|
41
|
+
readonly kind: "delete";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Mutation = DeleteMutation | SetMutation;
|
|
45
|
+
|
|
46
|
+
interface CacheState {
|
|
47
|
+
readonly adapter?: TieredStoreAdapter;
|
|
48
|
+
path?: string;
|
|
49
|
+
registryKey?: string;
|
|
50
|
+
closed: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One ordered persistent cache level. Earlier levels are read first. */
|
|
54
|
+
export interface CacheLevel {
|
|
55
|
+
/** Optional name included in persistence error messages. */
|
|
56
|
+
readonly name?: string;
|
|
57
|
+
/** Keyv-compatible store adapter. */
|
|
58
|
+
readonly store: KeyvStoreAdapter;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Options for an AppKit-independent memory-first cache. */
|
|
62
|
+
export interface CacheOptions extends Pick<
|
|
63
|
+
CacheableOptions,
|
|
64
|
+
"cacheId" | "maxTtl" | "namespace" | "stats" | "tags" | "ttl"
|
|
65
|
+
> {
|
|
66
|
+
/** Additional ordered persistence levels. No levels creates a memory-only cache. */
|
|
67
|
+
readonly levels?: readonly CacheLevel[];
|
|
68
|
+
/** Maximum entries retained in the memory primary. Defaults to 1,000. */
|
|
69
|
+
readonly maxEntries?: number;
|
|
70
|
+
/** Whether memory reads clone values. Defaults to true. */
|
|
71
|
+
readonly useClone?: boolean;
|
|
72
|
+
/** Delay used to coalesce persistence mutations. Defaults to 25 milliseconds. */
|
|
73
|
+
readonly writeDelayMs?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Options for the default project-scoped memory and disk cache. */
|
|
77
|
+
export interface ProjectCacheOptions extends CacheOptions {
|
|
78
|
+
/** Override the cache directory, primarily for tests. */
|
|
79
|
+
readonly cacheDir?: string;
|
|
80
|
+
/** Disable the default disk level. Defaults to true. */
|
|
81
|
+
readonly disk?: boolean;
|
|
82
|
+
/** Delay used by keyv-file to coalesce file rewrites. Defaults to 100 milliseconds. */
|
|
83
|
+
readonly diskWriteDelayMs?: number;
|
|
84
|
+
/** Override the project name resolved through projectUtils.name(). */
|
|
85
|
+
readonly projectName?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Options for the Cacheable-compatible get-or-set extension. */
|
|
89
|
+
export interface GetOrSetOptions extends GetOrSetFunctionOptions {
|
|
90
|
+
/** Return a separately retained successful value when the loader fails. Defaults to false. */
|
|
91
|
+
readonly returnCachedOnError?: boolean;
|
|
92
|
+
/** TTL for the cached-on-error value. Defaults to 0, meaning no expiration. */
|
|
93
|
+
readonly cachedOnErrorTtl?: number | string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const states = new WeakMap<Cacheable, CacheState>();
|
|
97
|
+
const projectCaches = new Map<string, Cacheable>();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Keyv adapter that composes arbitrary ordered levels behind Cacheable's one
|
|
101
|
+
* secondary-store slot and owns tracked write-behind persistence.
|
|
102
|
+
*/
|
|
103
|
+
class TieredStoreAdapter extends EventEmitter implements KeyvStoreAdapter {
|
|
104
|
+
readonly opts = {};
|
|
105
|
+
namespace?: string;
|
|
106
|
+
|
|
107
|
+
private readonly levels: readonly CacheLevel[];
|
|
108
|
+
private readonly pending = new Map<string, Mutation>();
|
|
109
|
+
private readonly promotions = new Set<Promise<void>>();
|
|
110
|
+
private readonly writeDelayMs: number;
|
|
111
|
+
private flushPromise?: Promise<void>;
|
|
112
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
113
|
+
private disconnected = false;
|
|
114
|
+
|
|
115
|
+
constructor(levels: readonly CacheLevel[], writeDelayMs: number) {
|
|
116
|
+
super();
|
|
117
|
+
this.levels = levels;
|
|
118
|
+
this.writeDelayMs = writeDelayMs;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async get<Value>(key: string): Promise<Value | undefined> {
|
|
122
|
+
const mutation = this.pending.get(key);
|
|
123
|
+
if (mutation?.kind === "delete") return undefined;
|
|
124
|
+
if (mutation?.kind === "set") return mutation.value as Value;
|
|
125
|
+
|
|
126
|
+
for (const [index, level] of this.levels.entries()) {
|
|
127
|
+
const value = await level.store.get<Value>(key);
|
|
128
|
+
if (value === undefined) continue;
|
|
129
|
+
if (index > 0) this.promote(key, value, this.levels.slice(0, index));
|
|
130
|
+
return value as Value;
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async getMany<Value>(keys: string[]): Promise<Array<Value | undefined>> {
|
|
136
|
+
return Promise.all(keys.map((key) => this.get<Value>(key)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
set(key: string, value: unknown, ttl?: number): Promise<boolean> {
|
|
140
|
+
this.assertConnected();
|
|
141
|
+
this.pending.set(key, { kind: "set", value, ...(ttl === undefined ? {} : { ttl }) });
|
|
142
|
+
this.schedule();
|
|
143
|
+
return Promise.resolve(true);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async setMany(values: Array<{ key: string; value: unknown; ttl?: number }>): Promise<void> {
|
|
147
|
+
for (const entry of values) await this.set(entry.key, entry.value, entry.ttl);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
delete(key: string): Promise<boolean> {
|
|
151
|
+
this.assertConnected();
|
|
152
|
+
this.pending.set(key, { kind: "delete" });
|
|
153
|
+
this.schedule();
|
|
154
|
+
return Promise.resolve(true);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async deleteMany(keys: string[]): Promise<boolean> {
|
|
158
|
+
await Promise.all(keys.map((key) => this.delete(key)));
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async has(key: string): Promise<boolean> {
|
|
163
|
+
const pending = this.pending.get(key);
|
|
164
|
+
if (pending) return pending.kind === "set";
|
|
165
|
+
for (const level of this.levels) {
|
|
166
|
+
if (
|
|
167
|
+
level.store.has ? await level.store.has(key) : (await level.store.get(key)) !== undefined
|
|
168
|
+
) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async clear(): Promise<void> {
|
|
176
|
+
this.assertConnected();
|
|
177
|
+
await this.flush();
|
|
178
|
+
this.pending.clear();
|
|
179
|
+
await settleLevels(this.levels, (level) => level.store.clear(), "clear");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async flush(): Promise<void> {
|
|
183
|
+
if (this.timer) {
|
|
184
|
+
clearTimeout(this.timer);
|
|
185
|
+
this.timer = undefined;
|
|
186
|
+
}
|
|
187
|
+
if (!this.flushPromise) {
|
|
188
|
+
this.flushPromise = this.flushPending().finally(() => {
|
|
189
|
+
this.flushPromise = undefined;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
await this.flushPromise;
|
|
193
|
+
await this.flushPromotions();
|
|
194
|
+
if (this.pending.size > 0) await this.flush();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async disconnect(): Promise<void> {
|
|
198
|
+
if (this.disconnected) return;
|
|
199
|
+
await this.flush();
|
|
200
|
+
this.disconnected = true;
|
|
201
|
+
await settleLevels(
|
|
202
|
+
this.levels.filter((level) => level.store.disconnect),
|
|
203
|
+
(level) => level.store.disconnect!(),
|
|
204
|
+
"disconnect",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private assertConnected(): void {
|
|
209
|
+
if (this.disconnected) throw new Error("Cache persistence is disconnected");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private schedule(): void {
|
|
213
|
+
if (this.timer || this.flushPromise) return;
|
|
214
|
+
this.timer = setTimeout(() => {
|
|
215
|
+
this.timer = undefined;
|
|
216
|
+
void this.flush().catch((error) => {
|
|
217
|
+
logger.warn("cache persistence flush failed", { error: errorUtils.errorMessage(error) });
|
|
218
|
+
this.emit("error", error);
|
|
219
|
+
});
|
|
220
|
+
}, this.writeDelayMs);
|
|
221
|
+
this.timer.unref?.();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async flushPending(): Promise<void> {
|
|
225
|
+
while (this.pending.size > 0) {
|
|
226
|
+
const batch = new Map(this.pending);
|
|
227
|
+
for (const key of batch.keys()) this.pending.delete(key);
|
|
228
|
+
const failures: Array<{ key: string; mutation: Mutation; error: unknown }> = [];
|
|
229
|
+
await Promise.all(
|
|
230
|
+
[...batch].map(async ([key, mutation]) => {
|
|
231
|
+
try {
|
|
232
|
+
await settleLevels(
|
|
233
|
+
this.levels,
|
|
234
|
+
(level) =>
|
|
235
|
+
mutation.kind === "set"
|
|
236
|
+
? level.store.set(key, mutation.value, mutation.ttl)
|
|
237
|
+
: level.store.delete(key),
|
|
238
|
+
mutation.kind,
|
|
239
|
+
);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
failures.push({ key, mutation, error });
|
|
242
|
+
}
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
if (failures.length > 0) {
|
|
246
|
+
for (const failure of failures) {
|
|
247
|
+
if (!this.pending.has(failure.key)) this.pending.set(failure.key, failure.mutation);
|
|
248
|
+
}
|
|
249
|
+
throw new AggregateError(
|
|
250
|
+
failures.map((failure) => failure.error),
|
|
251
|
+
`Cache persistence failed for ${failures.length} mutation(s)`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private promote(key: string, value: unknown, levels: readonly CacheLevel[]): void {
|
|
258
|
+
const promotion = settleLevels(levels, (level) => level.store.set(key, value), "promote")
|
|
259
|
+
.catch((error) => {
|
|
260
|
+
logger.warn("cache persistence promotion failed", {
|
|
261
|
+
key,
|
|
262
|
+
error: errorUtils.errorMessage(error),
|
|
263
|
+
});
|
|
264
|
+
})
|
|
265
|
+
.finally(() => {
|
|
266
|
+
this.promotions.delete(promotion);
|
|
267
|
+
});
|
|
268
|
+
this.promotions.add(promotion);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async flushPromotions(): Promise<void> {
|
|
272
|
+
while (this.promotions.size > 0) await Promise.all([...this.promotions]);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Create a memory-first cache with optional ordered persistent levels. */
|
|
277
|
+
export function createCache(options: CacheOptions = {}): Cacheable {
|
|
278
|
+
const maxEntries = positiveInteger(options.maxEntries, DEFAULT_MAX_ENTRIES, "maxEntries");
|
|
279
|
+
const writeDelayMs = nonNegativeInteger(
|
|
280
|
+
options.writeDelayMs,
|
|
281
|
+
DEFAULT_WRITE_DELAY_MS,
|
|
282
|
+
"writeDelayMs",
|
|
283
|
+
);
|
|
284
|
+
const primary = createKeyv({
|
|
285
|
+
lruSize: maxEntries,
|
|
286
|
+
ttl: options.ttl,
|
|
287
|
+
useClone: options.useClone ?? true,
|
|
288
|
+
});
|
|
289
|
+
const adapter = options.levels?.length
|
|
290
|
+
? new TieredStoreAdapter(options.levels, writeDelayMs)
|
|
291
|
+
: undefined;
|
|
292
|
+
const cache = new Cacheable({
|
|
293
|
+
primary,
|
|
294
|
+
...(adapter ? { secondary: adapter, nonBlocking: true } : {}),
|
|
295
|
+
...(options.cacheId === undefined ? {} : { cacheId: options.cacheId }),
|
|
296
|
+
...(options.maxTtl === undefined ? {} : { maxTtl: options.maxTtl }),
|
|
297
|
+
...(options.namespace === undefined ? {} : { namespace: options.namespace }),
|
|
298
|
+
...(options.stats === undefined ? {} : { stats: options.stats }),
|
|
299
|
+
...(options.tags === undefined ? {} : { tags: options.tags }),
|
|
300
|
+
...(options.ttl === undefined ? {} : { ttl: options.ttl }),
|
|
301
|
+
});
|
|
302
|
+
installValueEnvelope(cache);
|
|
303
|
+
cache.on("error", (error) => {
|
|
304
|
+
logger.warn("cacheable operation failed", { error: errorUtils.errorMessage(error) });
|
|
305
|
+
});
|
|
306
|
+
states.set(cache, { adapter, closed: false });
|
|
307
|
+
return cache;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Resolve the default portable disk path for a project cache scope. */
|
|
311
|
+
export function cachePath(
|
|
312
|
+
scope: string,
|
|
313
|
+
options: Pick<ProjectCacheOptions, "cacheDir" | "projectName"> = {},
|
|
314
|
+
): string {
|
|
315
|
+
const projectName = options.projectName ?? projectUtils.name();
|
|
316
|
+
const root = options.cacheDir ?? envPaths("dbx-tools", { suffix: "" }).cache;
|
|
317
|
+
return join(root, "cache", safeSegment(projectName), `${safeSegment(scope)}.json`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Return a process-shared project cache. By default it is memory-first with a
|
|
322
|
+
* portable keyv-file secondary scoped by project and logical cache name.
|
|
323
|
+
*/
|
|
324
|
+
export function projectCache(scope: string, options: ProjectCacheOptions = {}): Cacheable {
|
|
325
|
+
const path = cachePath(scope, options);
|
|
326
|
+
const registryKey = JSON.stringify([path, options.disk !== false]);
|
|
327
|
+
const existing = projectCaches.get(registryKey);
|
|
328
|
+
if (existing) return existing;
|
|
329
|
+
|
|
330
|
+
const levels = [...(options.levels ?? [])];
|
|
331
|
+
if (options.disk !== false) {
|
|
332
|
+
levels.push({
|
|
333
|
+
name: "disk",
|
|
334
|
+
store: new KeyvFile({
|
|
335
|
+
filename: path,
|
|
336
|
+
writeDelay: nonNegativeInteger(options.diskWriteDelayMs, 100, "diskWriteDelayMs"),
|
|
337
|
+
}),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
const cache = createCache({ ...options, levels });
|
|
341
|
+
const state = requireState(cache);
|
|
342
|
+
state.path = path;
|
|
343
|
+
state.registryKey = registryKey;
|
|
344
|
+
projectCaches.set(registryKey, cache);
|
|
345
|
+
return cache;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Wait for all tracked write-behind persistence without closing the cache. */
|
|
349
|
+
export async function flushCache(cache: Cacheable): Promise<void> {
|
|
350
|
+
await requireState(cache).adapter?.flush();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Flush pending persistence and disconnect all cache levels. Idempotent. */
|
|
354
|
+
export async function closeCache(cache: Cacheable): Promise<void> {
|
|
355
|
+
const state = requireState(cache);
|
|
356
|
+
if (state.closed) return;
|
|
357
|
+
await state.adapter?.disconnect();
|
|
358
|
+
state.closed = true;
|
|
359
|
+
if (state.registryKey && projectCaches.get(state.registryKey) === cache) {
|
|
360
|
+
projectCaches.delete(state.registryKey);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Iterate keys currently retained by the bounded memory primary. */
|
|
365
|
+
export function cacheKeys(cache: Cacheable): IterableIterator<string> {
|
|
366
|
+
const store = cache.primary.store;
|
|
367
|
+
if (!(store instanceof KeyvCacheableMemory)) {
|
|
368
|
+
throw new TypeError("Cache primary is not Cacheable memory");
|
|
369
|
+
}
|
|
370
|
+
return store.store.keys;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Delete a normal cache value and its optional cached-on-error companion. */
|
|
374
|
+
export async function deleteCachedValue(cache: Cacheable, key: string): Promise<boolean> {
|
|
375
|
+
const [value, fallback] = await Promise.all([
|
|
376
|
+
cache.delete(key),
|
|
377
|
+
cache.delete(cachedOnErrorKey(key)),
|
|
378
|
+
]);
|
|
379
|
+
return Boolean(value || fallback);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Use Cacheable's native in-flight get-or-set behavior, optionally retaining a
|
|
384
|
+
* successful value under a separate key for recovery when a later loader fails.
|
|
385
|
+
*/
|
|
386
|
+
export async function getOrSet<T>(
|
|
387
|
+
cache: Cacheable,
|
|
388
|
+
key: string,
|
|
389
|
+
load: () => Promise<T>,
|
|
390
|
+
options: GetOrSetOptions = {},
|
|
391
|
+
): Promise<T | undefined> {
|
|
392
|
+
const { returnCachedOnError = false, cachedOnErrorTtl = 0, ...cacheableOptions } = options;
|
|
393
|
+
if (!returnCachedOnError) return cache.getOrSet(key, load, cacheableOptions);
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
return await cache.getOrSet(
|
|
397
|
+
key,
|
|
398
|
+
async () => {
|
|
399
|
+
const value = await load();
|
|
400
|
+
await cache.set(cachedOnErrorKey(key), value, { ttl: cachedOnErrorTtl });
|
|
401
|
+
return value;
|
|
402
|
+
},
|
|
403
|
+
{ ...cacheableOptions, throwErrors: true },
|
|
404
|
+
);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
const cached = await cache.get<T>(cachedOnErrorKey(key));
|
|
407
|
+
if (cached !== undefined) return cached;
|
|
408
|
+
if (cacheableOptions.throwErrors) throw error;
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function installValueEnvelope(cache: Cacheable): void {
|
|
414
|
+
cache.onHook(CacheableHooks.BEFORE_SET, (item) => {
|
|
415
|
+
item.value = envelope(item.value);
|
|
416
|
+
});
|
|
417
|
+
cache.onHook(CacheableHooks.BEFORE_SET_MANY, (items) => {
|
|
418
|
+
for (const item of items) item.value = envelope(item.value);
|
|
419
|
+
});
|
|
420
|
+
cache.onHook(CacheableHooks.AFTER_GET, (item) => {
|
|
421
|
+
if (item.result) item.result.value = unwrap(item.result.value);
|
|
422
|
+
});
|
|
423
|
+
cache.onHook(CacheableHooks.AFTER_GET_MANY, (item) => {
|
|
424
|
+
for (const result of item.result) {
|
|
425
|
+
if (result) result.value = unwrap(result.value);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function envelope(value: unknown): ValueEnvelope {
|
|
431
|
+
return { __dbxToolsCacheEnvelope: ENVELOPE_VERSION, value };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function unwrap(value: unknown): unknown {
|
|
435
|
+
return isEnvelope(value) ? value.value : value;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function isEnvelope(value: unknown): value is ValueEnvelope {
|
|
439
|
+
return Boolean(
|
|
440
|
+
value &&
|
|
441
|
+
typeof value === "object" &&
|
|
442
|
+
"__dbxToolsCacheEnvelope" in value &&
|
|
443
|
+
(value as ValueEnvelope).__dbxToolsCacheEnvelope === ENVELOPE_VERSION &&
|
|
444
|
+
"value" in value,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function cachedOnErrorKey(key: string): string {
|
|
449
|
+
return `${ERROR_VALUE_PREFIX}${key}`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function settleLevels(
|
|
453
|
+
levels: readonly CacheLevel[],
|
|
454
|
+
operation: (level: CacheLevel) => unknown | Promise<unknown>,
|
|
455
|
+
operationName: string,
|
|
456
|
+
): Promise<void> {
|
|
457
|
+
const results = await Promise.allSettled(levels.map((level) => operation(level)));
|
|
458
|
+
const errors = results.flatMap((result, index) =>
|
|
459
|
+
result.status === "rejected"
|
|
460
|
+
? [
|
|
461
|
+
new Error(`${levels[index]?.name ?? `level-${index + 1}`} ${operationName} failed`, {
|
|
462
|
+
cause: result.reason,
|
|
463
|
+
}),
|
|
464
|
+
]
|
|
465
|
+
: [],
|
|
466
|
+
);
|
|
467
|
+
if (errors.length > 0) throw new AggregateError(errors, `Cache ${operationName} failed`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function requireState(cache: Cacheable): CacheState {
|
|
471
|
+
const state = states.get(cache);
|
|
472
|
+
if (!state) throw new TypeError("Cache was not created by @dbx-tools/cache");
|
|
473
|
+
return state;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function safeSegment(value: string): string {
|
|
477
|
+
return encodeURIComponent(value.trim() || "default");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function positiveInteger(value: number | undefined, fallback: number, name: string): number {
|
|
481
|
+
const resolved = value ?? fallback;
|
|
482
|
+
if (!Number.isInteger(resolved) || resolved <= 0) {
|
|
483
|
+
throw new RangeError(`${name} must be a positive integer`);
|
|
484
|
+
}
|
|
485
|
+
return resolved;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function nonNegativeInteger(value: number | undefined, fallback: number, name: string): number {
|
|
489
|
+
const resolved = value ?? fallback;
|
|
490
|
+
if (!Number.isInteger(resolved) || resolved < 0) {
|
|
491
|
+
throw new RangeError(`${name} must be a non-negative integer`);
|
|
492
|
+
}
|
|
493
|
+
return resolved;
|
|
494
|
+
}
|