diff --git a/README.md b/README.md index 2fea30b..a07288d 100644 --- a/README.md +++ b/README.md @@ -420,11 +420,11 @@ interface ILogger { } ``` -Every `cache.remember(...)` and `cache.resolve(...)` emits one message, tagged `HIT` or `MISS` with the elapsed time. The format is `Cacheable "${namespace}:${key}": …` so two namespaces sharing one logger remain distinguishable: +Every `cache.remember(...)` and `cache.resolve(...)` emits one message, tagged `HIT` (with the layer that served it, e.g. `(L1)`) or `MISS` with the elapsed time. The format is `Cacheable "${namespace}:${key}": …` so two namespaces sharing one logger remain distinguishable: ``` Cacheable "weather-data:karlsruhe": MISS 12ms -Cacheable "weather-data:karlsruhe": HIT 0.2ms +Cacheable "weather-data:karlsruhe": HIT (L1) 0.2ms ``` The built-in `consoleLogger` forwards to `console.log`: @@ -506,7 +506,7 @@ Breaking changes: - **`keys()` removed.** Enumerating heterogeneous async layers (some non-enumerable, like CDNs) has no single sensible semantic. - **`delete` and `clear` are async.** They now return `Promise` — add `await`. - **`isCached` removed.** v3 has no public presence-check API; if you need one, query your bucket directly (e.g. `await bucket.meta(fullKey)`). -- **`log` / `logTiming` replaced by `logger`.** Pass the exported `consoleLogger` singleton to restore the previous default-on logging, or implement `ILogger` to route messages elsewhere. Each `remember()` call emits a single formatted message (`Cacheable ":": HIT|MISS `) instead of `console.time` / `timeEnd`. +- **`log` / `logTiming` replaced by `logger`.** Pass the exported `consoleLogger` singleton to restore the previous default-on logging, or implement `ILogger` to route messages elsewhere. Each `remember()` call emits a single formatted message (`Cacheable ":": HIT (L)|MISS `) instead of `console.time` / `timeEnd`. - **Options types reshaped.** v2's `CacheOptions` (constructor) and `CacheableOptions` (per-call) are gone. v3's only exported options type is `CacheableOptions` — same name as v2's per-call type, completely different shape (it now carries `buckets`, `policy`, and `logger`; `namespace` is the constructor's first positional argument). v2's `CacheOptions` is no longer exported. - **Buckets can throw.** Any throw from any bucket rejects `remember()`. v2's in-memory store couldn't fail, so this is a new error surface to be aware of once you wire up a custom bucket. diff --git a/package-lock.json b/package-lock.json index e18a6f3..9263964 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cacheables", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cacheables", - "version": "3.0.0", + "version": "3.0.1", "license": "MIT", "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", diff --git a/package.json b/package.json index ef2cb3e..3121a0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cacheables", - "version": "3.0.0", + "version": "3.0.1", "description": "A small, typed cache with multilayer storage buckets, five cache policies (incl. stale-while-revalidate) and concurrency-safe deduplication. Zero dependencies, browser + Node.", "type": "module", "main": "./dist/index.cjs", diff --git a/src/Cacheable.ts b/src/Cacheable.ts index 2339eae..c0906bb 100644 --- a/src/Cacheable.ts +++ b/src/Cacheable.ts @@ -11,7 +11,7 @@ type FreshnessPredicate = (meta: BucketEntryMeta) => boolean type CascadeFn = ( fullKey: string, isFresh?: FreshnessPredicate, -) => Promise<{ result: R; meta: BucketEntryMeta } | undefined> +) => Promise<{ result: R; meta: BucketEntryMeta; hitIdx: number } | undefined> interface CascadeHit { bucket: IBucket @@ -82,7 +82,7 @@ export class Cacheable { const fullKey = this.#fullKey(key) const dedupKey = this.#dedupKey(key, 'value') - const { result, hit } = await this.#runPolicy( + const { result, hitIdx } = await this.#runPolicy( resource, fullKey, dedupKey, @@ -92,8 +92,9 @@ export class Cacheable { if (logger) { const elapsed = Math.round((performance.now() - start) * 10) / 10 + const status = hitIdx === undefined ? 'MISS' : `HIT (L${hitIdx + 1})` logger.log( - `Cacheable "${this.#namespace}:${key}": ${hit ? 'HIT' : 'MISS'} ${elapsed}ms`, + `Cacheable "${this.#namespace}:${key}": ${status} ${elapsed}ms`, ) } @@ -106,7 +107,7 @@ export class Cacheable { const fullKey = this.#fullKey(key) const dedupKey = this.#dedupKey(key, 'view') - const { result, hit } = await this.#runPolicy( + const { result, hitIdx } = await this.#runPolicy( resource, fullKey, dedupKey, @@ -116,8 +117,9 @@ export class Cacheable { if (logger) { const elapsed = Math.round((performance.now() - start) * 10) / 10 + const status = hitIdx === undefined ? 'MISS' : `HIT (L${hitIdx + 1})` logger.log( - `Cacheable "${this.#namespace}:${key}": ${hit ? 'HIT' : 'MISS'} ${elapsed}ms`, + `Cacheable "${this.#namespace}:${key}": ${status} ${elapsed}ms`, ) } @@ -140,25 +142,25 @@ export class Cacheable { dedupKey: string, cascadeFn: CascadeFn, fromValue: (value: T) => Promise, - ): Promise<{ result: R; hit: boolean }> { + ): Promise<{ result: R; hitIdx: number | undefined }> { switch (this.#policy) { case 'cache-only': { return this.#dedupPolicy(dedupKey, async () => { const cached = await cascadeFn(fullKey) - if (cached) return { result: cached.result, hit: true } + if (cached) return { result: cached.result, hitIdx: cached.hitIdx } const value = await this.#produceAndWrite(fullKey, resource) - return { result: await fromValue(value), hit: false } + return { result: await fromValue(value), hitIdx: undefined } }) } case 'network-only': { const value = await resource() await this.#cascadeWrite(fullKey, value) - return { result: await fromValue(value), hit: false } + return { result: await fromValue(value), hitIdx: undefined } } case 'network-only-non-concurrent': { return this.#dedupPolicy(dedupKey, async () => { const value = await this.#produceAndWrite(fullKey, resource) - return { result: await fromValue(value), hit: false } + return { result: await fromValue(value), hitIdx: undefined } }) } case 'max-age': { @@ -168,9 +170,9 @@ export class Cacheable { fullKey, (m) => Date.now() - m.storedAt <= maxAge, ) - if (cached) return { result: cached.result, hit: true } + if (cached) return { result: cached.result, hitIdx: cached.hitIdx } const value = await this.#produceAndWrite(fullKey, resource) - return { result: await fromValue(value), hit: false } + return { result: await fromValue(value), hitIdx: undefined } }) } case 'stale-while-revalidate': { @@ -183,18 +185,18 @@ export class Cacheable { Date.now() - cached.meta.storedAt > maxAge if (cached && !isStale) { - return { result: cached.result, hit: true } + return { result: cached.result, hitIdx: cached.hitIdx } } if (cached && isStale) { this.#produceAndWrite(fullKey, resource).catch(() => { /* swallow background revalidation errors */ }) - return { result: cached.result, hit: true } + return { result: cached.result, hitIdx: cached.hitIdx } } const value = await this.#produceAndWrite(fullKey, resource) - return { result: await fromValue(value), hit: false } + return { result: await fromValue(value), hitIdx: undefined } }) } } @@ -252,7 +254,7 @@ export class Cacheable { async #cascadeRead( fullKey: string, isFresh?: FreshnessPredicate, - ): Promise<{ result: T; meta: BucketEntryMeta } | undefined> { + ): Promise<{ result: T; meta: BucketEntryMeta; hitIdx: number } | undefined> { const probes = await this.#cascadeProbe(fullKey) const hit = this.#findHit(probes, isFresh) if (!hit) return undefined @@ -268,13 +270,15 @@ export class Cacheable { hit.idx, isFresh, ) - return { result: result.value, meta: hit.meta } + return { result: result.value, meta: hit.meta, hitIdx: hit.idx } } async #cascadeResolve( fullKey: string, isFresh?: FreshnessPredicate, - ): Promise<{ result: TView; meta: BucketEntryMeta } | undefined> { + ): Promise< + { result: TView; meta: BucketEntryMeta; hitIdx: number } | undefined + > { const probes = await this.#cascadeProbe(fullKey) const hit = this.#findHit(probes, isFresh) if (!hit) return undefined @@ -287,7 +291,7 @@ export class Cacheable { if (!needsFill) { const wrapped = await this.#l1.view(fullKey) if (wrapped === undefined) return undefined - return { result: wrapped.view, meta: hit.meta } + return { result: wrapped.view, meta: hit.meta, hitIdx: hit.idx } } const result = await (hit.bucket as IBucket).read(fullKey) @@ -315,7 +319,7 @@ export class Cacheable { // through to the producer. return undefined } - return { result: wrapped.view, meta: hit.meta } + return { result: wrapped.view, meta: hit.meta, hitIdx: hit.idx } } async #cascadeFill( diff --git a/src/types.ts b/src/types.ts index 7338194..84f83f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,8 +57,9 @@ export interface IBucket { * logging stack. When a `Cacheable` is constructed with a `logger`, * the engine emits one combined `HIT`/`MISS` message per * `remember()` or `resolve()` call, formatted as - * `Cacheable ":": HIT|MISS `. When no logger is - * provided, the engine is silent. + * `Cacheable ":": HIT (L)|MISS ` where `L` + * is the 1-indexed bucket layer that served the hit. When no logger + * is provided, the engine is silent. */ export interface ILogger { log(message: string): void diff --git a/tests/index.test.ts b/tests/index.test.ts index 12d7082..264d669 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -92,12 +92,12 @@ describe('Cache operations', () => { await cachedRequest() expect(console.log).toHaveBeenLastCalledWith( - expect.stringMatching(/^Cacheable "test:a": HIT \d+(\.\d+)?ms$/), + expect.stringMatching(/^Cacheable "test:a": HIT \(L1\) \d+(\.\d+)?ms$/), ) await cachedRequest() expect(console.log).toHaveBeenLastCalledWith( - expect.stringMatching(/^Cacheable "test:a": HIT \d+(\.\d+)?ms$/), + expect.stringMatching(/^Cacheable "test:a": HIT \(L1\) \d+(\.\d+)?ms$/), ) }) @@ -164,7 +164,7 @@ describe('Cache operations', () => { // This should be a hit and take ~0ms await hitCache() expect(console.log).toHaveBeenLastCalledWith( - expect.stringMatching(/^Cacheable "test:a": HIT \d+(\.\d+)?ms$/), + expect.stringMatching(/^Cacheable "test:a": HIT \(L1\) \d+(\.\d+)?ms$/), ) await wait(60) @@ -172,7 +172,7 @@ describe('Cache operations', () => { // This should be a hit and take ~0ms await hitCache() expect(console.log).toHaveBeenLastCalledWith( - expect.stringMatching(/^Cacheable "test:a": HIT \d+(\.\d+)?ms$/), + expect.stringMatching(/^Cacheable "test:a": HIT \(L1\) \d+(\.\d+)?ms$/), ) await wait(60) diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts index 77c5715..cb5a175 100644 --- a/tests/resolve.test.ts +++ b/tests/resolve.test.ts @@ -221,7 +221,7 @@ describe('cache.resolve(): policy semantics', () => { await cache.resolve(async () => 'v', 'k') expect(log).toHaveBeenLastCalledWith( - expect.stringMatching(/^Cacheable "test:k": HIT \d+(\.\d+)?ms$/), + expect.stringMatching(/^Cacheable "test:k": HIT \(L1\) \d+(\.\d+)?ms$/), ) }) })