@c9up/echo 0.1.5 → 0.1.6

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 (56) hide show
  1. package/dist/CacheManager.d.ts +63 -28
  2. package/dist/CacheManager.d.ts.map +1 -1
  3. package/dist/CacheManager.js +348 -70
  4. package/dist/CacheManager.js.map +1 -1
  5. package/dist/EchoProvider.d.ts +14 -8
  6. package/dist/EchoProvider.d.ts.map +1 -1
  7. package/dist/EchoProvider.js +45 -11
  8. package/dist/EchoProvider.js.map +1 -1
  9. package/dist/StoreManager.d.ts +61 -0
  10. package/dist/StoreManager.d.ts.map +1 -0
  11. package/dist/StoreManager.js +70 -0
  12. package/dist/StoreManager.js.map +1 -0
  13. package/dist/drivers/MemoryDriver.d.ts +13 -6
  14. package/dist/drivers/MemoryDriver.d.ts.map +1 -1
  15. package/dist/drivers/MemoryDriver.js +67 -61
  16. package/dist/drivers/MemoryDriver.js.map +1 -1
  17. package/dist/drivers/RedisDriver.d.ts +17 -17
  18. package/dist/drivers/RedisDriver.d.ts.map +1 -1
  19. package/dist/drivers/RedisDriver.js +103 -55
  20. package/dist/drivers/RedisDriver.js.map +1 -1
  21. package/dist/drivers/TieredDriver.d.ts +41 -0
  22. package/dist/drivers/TieredDriver.d.ts.map +1 -0
  23. package/dist/drivers/TieredDriver.js +132 -0
  24. package/dist/drivers/TieredDriver.js.map +1 -0
  25. package/dist/duration.d.ts +32 -0
  26. package/dist/duration.d.ts.map +1 -0
  27. package/dist/duration.js +75 -0
  28. package/dist/duration.js.map +1 -0
  29. package/dist/errors.d.ts +21 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/errors.js +31 -0
  32. package/dist/errors.js.map +1 -0
  33. package/dist/index.d.ts +15 -3
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +10 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/testing/main.d.ts +41 -0
  38. package/dist/testing/main.d.ts.map +1 -0
  39. package/dist/testing/main.js +41 -0
  40. package/dist/testing/main.js.map +1 -0
  41. package/dist/types.d.ts +146 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +6 -0
  44. package/dist/types.js.map +1 -0
  45. package/package.json +6 -1
  46. package/src/CacheManager.ts +497 -99
  47. package/src/EchoProvider.ts +55 -12
  48. package/src/StoreManager.ts +104 -0
  49. package/src/drivers/MemoryDriver.ts +97 -66
  50. package/src/drivers/RedisDriver.ts +135 -58
  51. package/src/drivers/TieredDriver.ts +186 -0
  52. package/src/duration.ts +86 -0
  53. package/src/errors.ts +33 -0
  54. package/src/index.ts +43 -3
  55. package/src/testing/main.ts +69 -0
  56. package/src/types.ts +156 -0
@@ -1,168 +1,566 @@
1
1
  /**
2
- * CacheManager — unified cache API with driver abstraction.
2
+ * CacheManager — a single cache store with a unified, bentocache/@adonisjs/cache
3
+ * parity API over a pluggable {@link CacheDriver}.
4
+ *
5
+ * Every read/write method accepts EITHER the Adonis/bento object form
6
+ * (`get({ key })`, `set({ key, value, ttl, tags })`, `getOrSet({ key, factory,
7
+ * … })`) OR echo's original positional form (`get(key)`, `set(key, value,
8
+ * ttlSeconds)`, `getOrSet(key, ttlSeconds, factory)`). The object form is the
9
+ * canonical one; the positional form is kept for back-compat.
3
10
  */
4
11
 
5
- export interface CacheDriver {
6
- get<T = unknown>(key: string): Promise<T | null>;
7
- set(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
8
- delete(key: string): Promise<boolean>;
9
- flush(): Promise<void>;
10
- has(key: string): Promise<boolean>;
11
- }
12
+ import { type Duration, parseDuration, resolveTtlSeconds } from "./duration.js";
13
+ import { FactoryError, TimeoutError } from "./errors.js";
14
+ import type {
15
+ CacheEmitter,
16
+ CacheEntry,
17
+ CacheEventMap,
18
+ DeleteByTagOptions,
19
+ DeleteManyOptions,
20
+ DeleteOptions,
21
+ ExpireOptions,
22
+ Factory,
23
+ GetOptions,
24
+ GetOrSetForeverOptions,
25
+ GetOrSetOptions,
26
+ HasOptions,
27
+ SetOptions,
28
+ TaggableDriver,
29
+ } from "./types.js";
12
30
 
13
- interface TaggableDriver extends CacheDriver {
14
- setWithTags(
15
- key: string,
16
- value: unknown,
17
- tags: string[],
18
- ttlSeconds?: number,
19
- ): Promise<void>;
20
- flushTags(tags: string[]): Promise<void>;
21
- }
31
+ // Re-exported for back-compat (echo <=0.1.5 exported these from CacheManager).
32
+ export type { CacheDriver } from "./types.js";
33
+
34
+ import type { CacheDriver } from "./types.js";
22
35
 
23
36
  function isTaggableDriver(driver: CacheDriver): driver is TaggableDriver {
24
- const candidate = driver as Partial<TaggableDriver>;
37
+ const candidate: Partial<TaggableDriver> = driver;
25
38
  return (
26
- typeof candidate.flushTags === "function" &&
27
- typeof candidate.setWithTags === "function"
39
+ typeof candidate.setWithTags === "function" &&
40
+ (typeof candidate.deleteByTag === "function" ||
41
+ typeof candidate.flushTags === "function")
28
42
  );
29
43
  }
30
44
 
45
+ /** Resolve an optional {@link Duration} to milliseconds; `undefined` when unset. */
46
+ function resolveMs(duration: Duration | undefined): number | undefined {
47
+ if (duration === undefined || duration === null) return undefined;
48
+ const seconds =
49
+ typeof duration === "number" ? duration : parseDuration(duration);
50
+ return Math.max(0, seconds) * 1000;
51
+ }
52
+
53
+ const TIMEOUT: unique symbol = Symbol("echo.timeout");
54
+
55
+ function withTimeout<T>(
56
+ promise: Promise<T>,
57
+ ms: number,
58
+ ): Promise<T | typeof TIMEOUT> {
59
+ return new Promise((resolve, reject) => {
60
+ const timer = setTimeout(() => resolve(TIMEOUT), ms);
61
+ if (typeof timer === "object" && "unref" in timer) {
62
+ (timer as { unref(): void }).unref();
63
+ }
64
+ promise.then(
65
+ (value) => {
66
+ clearTimeout(timer);
67
+ resolve(value);
68
+ },
69
+ (error) => {
70
+ clearTimeout(timer);
71
+ reject(error);
72
+ },
73
+ );
74
+ });
75
+ }
76
+
31
77
  export interface CacheConfig {
32
78
  driver?: string;
33
79
  prefix?: string;
80
+ /** Default TTL in **seconds** (echo-native unit; see `duration.ts`). */
34
81
  ttl?: number;
82
+ /** Default grace period (stale-while-revalidate) as a {@link Duration}. */
83
+ grace?: Duration;
84
+ /** Default soft timeout for `getOrSet` (return stale if the factory is slower). */
85
+ timeout?: Duration;
86
+ /** Default hard timeout for `getOrSet`. */
87
+ hardTimeout?: Duration;
88
+ /** Default single-flight lock wait before falling back to stale. */
89
+ lockTimeout?: Duration;
90
+ /** Store name reported in events (default `"default"`). */
91
+ name?: string;
92
+ /** Optional event emitter (`cache:hit` / `miss` / `written` / `deleted` / `cleared`). */
93
+ emitter?: CacheEmitter;
94
+ }
95
+
96
+ /** Shared single-flight state — one map per store, threaded through namespaces. */
97
+ interface SharedState {
98
+ inflight: Map<string, { promise: Promise<unknown> }>;
99
+ }
100
+
101
+ interface NormalizedGetOrSet<T> {
102
+ key: string;
103
+ factory: Factory<T>;
104
+ ttlSeconds: number;
105
+ graceSeconds: number;
106
+ timeoutMs: number | undefined;
107
+ hardTimeoutMs: number | undefined;
108
+ lockTimeoutMs: number | undefined;
109
+ tags: string[];
110
+ onFactoryError?: (error: FactoryError) => void;
35
111
  }
36
112
 
37
113
  export class CacheManager {
38
- private driver: CacheDriver;
39
- private prefix: string;
40
- private defaultTtl: number;
114
+ #driver: CacheDriver;
115
+ #prefix: string;
116
+ #defaultTtl: number;
117
+ #defaultGrace: Duration | undefined;
118
+ #defaultTimeout: Duration | undefined;
119
+ #defaultHardTimeout: Duration | undefined;
120
+ #defaultLockTimeout: Duration | undefined;
121
+ #name: string;
122
+ #emitter: CacheEmitter | undefined;
123
+ #shared: SharedState;
124
+
125
+ constructor(driver: CacheDriver, config?: CacheConfig, shared?: SharedState) {
126
+ this.#driver = driver;
127
+ this.#prefix = config?.prefix ?? "";
128
+ this.#defaultTtl = config?.ttl ?? 3600;
129
+ this.#defaultGrace = config?.grace;
130
+ this.#defaultTimeout = config?.timeout;
131
+ this.#defaultHardTimeout = config?.hardTimeout;
132
+ this.#defaultLockTimeout = config?.lockTimeout;
133
+ this.#name = config?.name ?? "default";
134
+ this.#emitter = config?.emitter;
135
+ this.#shared = shared ?? { inflight: new Map() };
136
+ }
41
137
 
42
- constructor(driver: CacheDriver, config?: CacheConfig) {
43
- this.driver = driver;
44
- this.prefix = config?.prefix ?? "";
45
- this.defaultTtl = config?.ttl ?? 3600;
138
+ #prefixKey(key: string): string {
139
+ return this.#prefix ? `${this.#prefix}:${key}` : key;
46
140
  }
47
141
 
48
- private prefixKey(key: string): string {
49
- return this.prefix ? `${this.prefix}:${key}` : key;
142
+ #emit<E extends keyof CacheEventMap>(
143
+ event: E,
144
+ payload: CacheEventMap[E],
145
+ ): void {
146
+ this.#emitter?.emit(event, payload);
50
147
  }
51
148
 
52
- async get<T = unknown>(key: string): Promise<T | null> {
53
- return this.driver.get<T>(this.prefixKey(key));
149
+ async #readEntry<T>(prefixed: string): Promise<CacheEntry<T> | null> {
150
+ if (this.#driver.getEntry) return this.#driver.getEntry<T>(prefixed);
151
+ const value = await this.#driver.get<T>(prefixed);
152
+ return value === null ? null : { value, stale: false };
54
153
  }
55
154
 
56
- async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
155
+ async #writeValue(
156
+ prefixed: string,
157
+ value: unknown,
158
+ ttlSeconds: number,
159
+ graceSeconds: number,
160
+ tags: string[],
161
+ ): Promise<void> {
57
162
  if (value === null || value === undefined) {
163
+ // Named divergence (fail-loud): unlike bento (which no-ops), echo throws
164
+ // on caching null/undefined — a null cache write is almost always a bug.
58
165
  throw new TypeError(
59
166
  "Echo: caching null/undefined values is not supported",
60
167
  );
61
168
  }
62
- return this.driver.set(
63
- this.prefixKey(key),
64
- value,
65
- ttlSeconds ?? this.defaultTtl,
66
- );
169
+ if (this.#driver.setEntry) {
170
+ await this.#driver.setEntry(prefixed, value, {
171
+ ttlSeconds,
172
+ graceSeconds,
173
+ tags,
174
+ });
175
+ return;
176
+ }
177
+ if (tags.length > 0) {
178
+ if (!isTaggableDriver(this.#driver)) {
179
+ throw new Error(
180
+ "Echo: the configured driver does not support tag-based invalidation",
181
+ );
182
+ }
183
+ await this.#driver.setWithTags(prefixed, value, tags, ttlSeconds);
184
+ return;
185
+ }
186
+ await this.#driver.set(prefixed, value, ttlSeconds);
67
187
  }
68
188
 
69
- async delete(key: string): Promise<boolean> {
70
- return this.driver.delete(this.prefixKey(key));
189
+ // ---- get -------------------------------------------------------------
190
+
191
+ get<T = unknown>(key: string): Promise<T | null>;
192
+ get<T = unknown>(options: GetOptions<T>): Promise<T | null>;
193
+ async get<T = unknown>(
194
+ keyOrOptions: string | GetOptions<T>,
195
+ ): Promise<T | null> {
196
+ const key =
197
+ typeof keyOrOptions === "string" ? keyOrOptions : keyOrOptions.key;
198
+ const graceSeconds =
199
+ typeof keyOrOptions === "string"
200
+ ? resolveTtlSeconds(this.#defaultGrace, 0)
201
+ : resolveTtlSeconds(keyOrOptions.grace ?? this.#defaultGrace, 0);
202
+ const defaultValue =
203
+ typeof keyOrOptions === "string" ? undefined : keyOrOptions.defaultValue;
204
+
205
+ const entry = await this.#readEntry<T>(this.#prefixKey(key));
206
+ if (entry && !entry.stale) {
207
+ this.#emit("cache:hit", {
208
+ key,
209
+ value: entry.value,
210
+ store: this.#name,
211
+ graced: false,
212
+ });
213
+ return entry.value;
214
+ }
215
+ if (entry && entry.stale && graceSeconds > 0) {
216
+ this.#emit("cache:hit", {
217
+ key,
218
+ value: entry.value,
219
+ store: this.#name,
220
+ graced: true,
221
+ });
222
+ return entry.value;
223
+ }
224
+
225
+ this.#emit("cache:miss", { key, store: this.#name });
226
+ if (defaultValue !== undefined) {
227
+ return defaultValue instanceof Function ? defaultValue() : defaultValue;
228
+ }
229
+ return null;
71
230
  }
72
231
 
73
- /** Clear the whole cache (AdonisJS `cache.clear()`). */
74
- async clear(): Promise<void> {
75
- return this.driver.flush();
232
+ // ---- set -------------------------------------------------------------
233
+
234
+ set(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
235
+ set(options: SetOptions): Promise<void>;
236
+ async set(
237
+ keyOrOptions: string | SetOptions,
238
+ value?: unknown,
239
+ ttlSeconds?: number,
240
+ ): Promise<void> {
241
+ let key: string;
242
+ let val: unknown;
243
+ let ttl: number;
244
+ let graceSeconds: number;
245
+ let tags: string[];
246
+ if (typeof keyOrOptions === "string") {
247
+ key = keyOrOptions;
248
+ val = value;
249
+ ttl = ttlSeconds ?? this.#defaultTtl;
250
+ graceSeconds = 0;
251
+ tags = [];
252
+ } else {
253
+ key = keyOrOptions.key;
254
+ val = keyOrOptions.value;
255
+ ttl = resolveTtlSeconds(keyOrOptions.ttl, this.#defaultTtl);
256
+ graceSeconds = resolveTtlSeconds(
257
+ keyOrOptions.grace ?? this.#defaultGrace,
258
+ 0,
259
+ );
260
+ tags = keyOrOptions.tags ?? [];
261
+ }
262
+ await this.#writeValue(this.#prefixKey(key), val, ttl, graceSeconds, tags);
263
+ this.#emit("cache:written", { key, value: val, store: this.#name });
76
264
  }
77
265
 
78
- async has(key: string): Promise<boolean> {
79
- return this.driver.has(this.prefixKey(key));
266
+ /** Set a value that never expires (bento `setForever`). */
267
+ setForever(options: Omit<SetOptions, "ttl">): Promise<void> {
268
+ return this.set({ ...options, ttl: null });
269
+ }
270
+
271
+ // ---- delete / has / clear -------------------------------------------
272
+
273
+ delete(key: string): Promise<boolean>;
274
+ delete(options: DeleteOptions): Promise<boolean>;
275
+ async delete(keyOrOptions: string | DeleteOptions): Promise<boolean> {
276
+ const key =
277
+ typeof keyOrOptions === "string" ? keyOrOptions : keyOrOptions.key;
278
+ const deleted = await this.#driver.delete(this.#prefixKey(key));
279
+ this.#emit("cache:deleted", { key, store: this.#name });
280
+ return deleted;
281
+ }
282
+
283
+ /** Delete multiple keys (bento `deleteMany`). */
284
+ async deleteMany(
285
+ keysOrOptions: string[] | DeleteManyOptions,
286
+ ): Promise<boolean> {
287
+ const keys = Array.isArray(keysOrOptions)
288
+ ? keysOrOptions
289
+ : keysOrOptions.keys;
290
+ let all = true;
291
+ for (const key of keys) {
292
+ const ok = await this.#driver.delete(this.#prefixKey(key));
293
+ this.#emit("cache:deleted", { key, store: this.#name });
294
+ if (!ok) all = false;
295
+ }
296
+ return all;
297
+ }
298
+
299
+ has(key: string): Promise<boolean>;
300
+ has(options: HasOptions): Promise<boolean>;
301
+ async has(keyOrOptions: string | HasOptions): Promise<boolean> {
302
+ const key =
303
+ typeof keyOrOptions === "string" ? keyOrOptions : keyOrOptions.key;
304
+ return this.#driver.has(this.#prefixKey(key));
305
+ }
306
+
307
+ /** Inverse of {@link has} (bento `missing`). */
308
+ async missing(keyOrOptions: string | HasOptions): Promise<boolean> {
309
+ const key =
310
+ typeof keyOrOptions === "string" ? keyOrOptions : keyOrOptions.key;
311
+ return !(await this.has(key));
312
+ }
313
+
314
+ /** Read a key and delete it in one step (bento `pull`). Returns `null` on miss. */
315
+ async pull<T = unknown>(key: string): Promise<T | null> {
316
+ const value = await this.get<T>(key);
317
+ if (value !== null) await this.delete(key);
318
+ return value;
80
319
  }
81
320
 
82
321
  /**
83
- * A cache view scoped under an extra key prefix (AdonisJS `cache.namespace()`).
84
- * `cache.namespace('users').get('42')` reads/writes `users:42` (composed with
85
- * any existing prefix). Shares the same driver + default TTL.
322
+ * Expire a key: mark it stale immediately while retaining it for the grace
323
+ * window (bento `expire`). Without grace this is equivalent to a delete.
86
324
  */
87
- namespace(ns: string): CacheManager {
88
- return new CacheManager(this.driver, {
89
- prefix: this.prefix ? `${this.prefix}:${ns}` : ns,
90
- ttl: this.defaultTtl,
91
- });
325
+ async expire(keyOrOptions: string | ExpireOptions): Promise<boolean> {
326
+ const key =
327
+ typeof keyOrOptions === "string" ? keyOrOptions : keyOrOptions.key;
328
+ const prefixed = this.#prefixKey(key);
329
+ const graceSeconds = resolveTtlSeconds(this.#defaultGrace, 0);
330
+ const entry = await this.#readEntry<unknown>(prefixed);
331
+ if (entry === null) return false;
332
+ if (graceSeconds > 0 && this.#driver.setEntry) {
333
+ // Mark the entry stale RIGHT NOW (logical expiry one ms in the past) while
334
+ // keeping it physically for the grace window. A positive `ttlSeconds`
335
+ // would leave a brief fresh window during which the value is still served
336
+ // and the factory never runs.
337
+ await this.#driver.setEntry(prefixed, entry.value, {
338
+ expiresAt: Date.now() - 1,
339
+ graceSeconds,
340
+ });
341
+ return true;
342
+ }
343
+ return this.#driver.delete(prefixed);
92
344
  }
93
345
 
94
- /** Set a value with tags for grouped invalidation. */
346
+ /** Clear the whole store (bento/Adonis `clear`). */
347
+ async clear(): Promise<void> {
348
+ await this.#driver.flush();
349
+ this.#emit("cache:cleared", { store: this.#name });
350
+ }
351
+
352
+ // ---- tags ------------------------------------------------------------
353
+
354
+ /** Set a value with tags for grouped invalidation (bento parity). */
95
355
  async setWithTags(
96
356
  key: string,
97
357
  value: unknown,
98
358
  tags: string[],
99
359
  ttlSeconds?: number,
100
360
  ): Promise<void> {
101
- if (value === null || value === undefined) {
102
- throw new TypeError(
103
- "Echo: caching null/undefined values is not supported",
104
- );
105
- }
106
- if (!isTaggableDriver(this.driver)) {
361
+ await this.#writeValue(
362
+ this.#prefixKey(key),
363
+ value,
364
+ ttlSeconds ?? this.#defaultTtl,
365
+ 0,
366
+ tags,
367
+ );
368
+ this.#emit("cache:written", { key, value, store: this.#name });
369
+ }
370
+
371
+ /** Invalidate all entries carrying any of the given tags (bento `deleteByTag`). */
372
+ deleteByTag(tags: string[]): Promise<void>;
373
+ deleteByTag(options: DeleteByTagOptions): Promise<void>;
374
+ async deleteByTag(
375
+ tagsOrOptions: string[] | DeleteByTagOptions,
376
+ ): Promise<void> {
377
+ const tags = Array.isArray(tagsOrOptions)
378
+ ? tagsOrOptions
379
+ : tagsOrOptions.tags;
380
+ if (!isTaggableDriver(this.#driver)) {
107
381
  throw new Error(
108
382
  "Echo: the configured driver does not support tag-based invalidation",
109
383
  );
110
384
  }
111
- return this.driver.setWithTags(
112
- this.prefixKey(key),
113
- value,
114
- tags,
115
- ttlSeconds ?? this.defaultTtl,
116
- );
385
+ if (typeof this.#driver.deleteByTag === "function") {
386
+ return this.#driver.deleteByTag(tags);
387
+ }
388
+ return this.#driver.flushTags(tags);
117
389
  }
118
390
 
119
- /** Flush only entries with matching tags. */
391
+ /** @deprecated alias of {@link deleteByTag}. */
120
392
  async flushTags(tags: string[]): Promise<void> {
121
- if (isTaggableDriver(this.driver)) {
122
- return this.driver.flushTags(tags);
123
- }
124
- throw new Error(
125
- "Echo: the configured driver does not support tag-based invalidation",
393
+ return this.deleteByTag(tags);
394
+ }
395
+
396
+ // ---- namespace -------------------------------------------------------
397
+
398
+ /**
399
+ * A cache view scoped under an extra key prefix (Adonis `cache.namespace()`).
400
+ * Shares the SAME driver, defaults, emitter AND single-flight state, so a
401
+ * `getOrSet` stampede is collapsed across namespace views of the same key.
402
+ */
403
+ namespace(ns: string): CacheManager {
404
+ return new CacheManager(
405
+ this.#driver,
406
+ {
407
+ prefix: this.#prefix ? `${this.#prefix}:${ns}` : ns,
408
+ ttl: this.#defaultTtl,
409
+ grace: this.#defaultGrace,
410
+ timeout: this.#defaultTimeout,
411
+ hardTimeout: this.#defaultHardTimeout,
412
+ lockTimeout: this.#defaultLockTimeout,
413
+ name: this.#name,
414
+ emitter: this.#emitter,
415
+ },
416
+ this.#shared,
126
417
  );
127
418
  }
128
419
 
129
- /** In-flight promises for stampede prevention. Each factory is typed per-call; the map is keyed by prefixed cache key. */
130
- private inflight: Map<string, Promise<unknown>> = new Map();
420
+ // ---- getOrSet --------------------------------------------------------
421
+
422
+ #normalizeGetOrSet<T>(
423
+ a: string | GetOrSetOptions<T>,
424
+ b: number | undefined,
425
+ c: Factory<T> | undefined,
426
+ ): NormalizedGetOrSet<T> {
427
+ if (typeof a === "string") {
428
+ if (typeof c !== "function") {
429
+ throw new TypeError(
430
+ "Echo: getOrSet(key, ttl, factory) requires a factory function",
431
+ );
432
+ }
433
+ return {
434
+ key: a,
435
+ factory: c,
436
+ ttlSeconds: resolveTtlSeconds(b, this.#defaultTtl),
437
+ graceSeconds: resolveTtlSeconds(this.#defaultGrace, 0),
438
+ timeoutMs: resolveMs(this.#defaultTimeout),
439
+ hardTimeoutMs: resolveMs(this.#defaultHardTimeout),
440
+ lockTimeoutMs: resolveMs(this.#defaultLockTimeout),
441
+ tags: [],
442
+ };
443
+ }
444
+ return {
445
+ key: a.key,
446
+ factory: a.factory,
447
+ ttlSeconds: resolveTtlSeconds(a.ttl, this.#defaultTtl),
448
+ graceSeconds: resolveTtlSeconds(a.grace ?? this.#defaultGrace, 0),
449
+ timeoutMs: resolveMs(a.timeout ?? this.#defaultTimeout),
450
+ hardTimeoutMs: resolveMs(a.hardTimeout ?? this.#defaultHardTimeout),
451
+ lockTimeoutMs: resolveMs(a.lockTimeout ?? this.#defaultLockTimeout),
452
+ tags: a.tags ?? [],
453
+ onFactoryError: a.onFactoryError,
454
+ };
455
+ }
131
456
 
132
457
  /**
133
- * Fetch from cache, or compute via `factory` and store (AdonisJS
134
- * `cache.getOrSet`). Single-flight: concurrent misses share one factory call.
135
- * (Positional `(key, ttl, factory)` is a deliberate simplification over
136
- * Adonis's object form — echo keeps a leaner cache surface.)
458
+ * Run (or join) the single-flight factory for `prefixed`. Resolves to the
459
+ * fresh value on success; on failure it calls `onFactoryError` and either
460
+ * resolves to `staleValue` (when a stale fallback exists) or rejects.
137
461
  */
462
+ #invokeFactory<T>(
463
+ prefixed: string,
464
+ o: NormalizedGetOrSet<T>,
465
+ hasStale: boolean,
466
+ staleValue: T | undefined,
467
+ ): Promise<T> {
468
+ const run = async (): Promise<T> => {
469
+ try {
470
+ const value = await o.factory();
471
+ await this.#writeValue(
472
+ prefixed,
473
+ value,
474
+ o.ttlSeconds,
475
+ o.graceSeconds,
476
+ o.tags,
477
+ );
478
+ this.#emit("cache:written", {
479
+ key: o.key,
480
+ value,
481
+ store: this.#name,
482
+ });
483
+ return value;
484
+ } catch (error) {
485
+ o.onFactoryError?.(new FactoryError(o.key, error, hasStale));
486
+ if (hasStale && staleValue !== undefined) return staleValue;
487
+ throw error;
488
+ } finally {
489
+ this.#shared.inflight.delete(prefixed);
490
+ }
491
+ };
492
+ return run();
493
+ }
494
+
495
+ getOrSet<T>(key: string, ttlSeconds: number, factory: Factory<T>): Promise<T>;
496
+ getOrSet<T>(options: GetOrSetOptions<T>): Promise<T>;
138
497
  async getOrSet<T>(
139
- key: string,
140
- ttl: number,
141
- factory: () => Promise<T>,
498
+ a: string | GetOrSetOptions<T>,
499
+ b?: number,
500
+ c?: Factory<T>,
142
501
  ): Promise<T> {
143
- const prefixed = this.prefixKey(key);
502
+ const o = this.#normalizeGetOrSet<T>(a, b, c);
503
+ const prefixed = this.#prefixKey(o.key);
144
504
 
145
- const existing = this.inflight.get(prefixed);
146
- if (existing) return existing.then((v) => v as T);
505
+ const entry = await this.#readEntry<T>(prefixed);
506
+ if (entry && !entry.stale) {
507
+ this.#emit("cache:hit", {
508
+ key: o.key,
509
+ value: entry.value,
510
+ store: this.#name,
511
+ graced: false,
512
+ });
513
+ return entry.value;
514
+ }
147
515
 
148
- const cached = await this.get<T>(key);
149
- if (cached !== null) return cached;
516
+ const hasStale = entry !== null && entry.stale && o.graceSeconds > 0;
517
+ const staleValue = hasStale ? entry.value : undefined;
150
518
 
151
- const existingAfterAwait = this.inflight.get(prefixed);
152
- if (existingAfterAwait) return existingAfterAwait.then((v) => v as T);
519
+ let record = this.#shared.inflight.get(prefixed);
520
+ if (!record) {
521
+ record = {
522
+ promise: this.#invokeFactory<T>(prefixed, o, hasStale, staleValue),
523
+ };
524
+ this.#shared.inflight.set(prefixed, record);
525
+ }
526
+ // Single-flight join point: the shared map is heterogeneous (many T), so
527
+ // this generic re-assertion is unavoidable (mirrors echo <=0.1.5).
528
+ const factoryPromise = record.promise as Promise<T>;
153
529
 
154
- const promise: Promise<T> = factory()
155
- .then(async (value) => {
156
- await this.set(key, value, ttl);
157
- this.inflight.delete(prefixed);
158
- return value;
159
- })
160
- .catch((err) => {
161
- this.inflight.delete(prefixed);
162
- throw err;
163
- });
530
+ if (hasStale && staleValue !== undefined) {
531
+ // Stale-while-revalidate: serve stale up to the soft timeout (default 0
532
+ // = serve immediately), let the factory refresh in the background.
533
+ const softMs = o.timeoutMs ?? 0;
534
+ const waitMs =
535
+ o.lockTimeoutMs !== undefined
536
+ ? Math.min(softMs, o.lockTimeoutMs)
537
+ : softMs;
538
+ const result = await withTimeout(factoryPromise, waitMs);
539
+ if (result === TIMEOUT) {
540
+ this.#emit("cache:hit", {
541
+ key: o.key,
542
+ value: staleValue,
543
+ store: this.#name,
544
+ graced: true,
545
+ });
546
+ return staleValue;
547
+ }
548
+ return result;
549
+ }
550
+
551
+ if (o.hardTimeoutMs !== undefined) {
552
+ const result = await withTimeout(factoryPromise, o.hardTimeoutMs);
553
+ if (result === TIMEOUT) {
554
+ throw new TimeoutError(o.key, o.hardTimeoutMs);
555
+ }
556
+ return result;
557
+ }
558
+
559
+ return factoryPromise;
560
+ }
164
561
 
165
- this.inflight.set(prefixed, promise);
166
- return promise;
562
+ /** Like {@link getOrSet} but the stored value never expires (bento `getOrSetForever`). */
563
+ getOrSetForever<T>(options: GetOrSetForeverOptions<T>): Promise<T> {
564
+ return this.getOrSet<T>({ ...options, ttl: null });
167
565
  }
168
566
  }