Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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<void>` — 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 "<namespace>:<key>": HIT|MISS <Xms>`) 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 "<namespace>:<key>": HIT (L<n>)|MISS <Xms>`) 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.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
44 changes: 24 additions & 20 deletions src/Cacheable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type FreshnessPredicate = (meta: BucketEntryMeta) => boolean
type CascadeFn<R> = (
fullKey: string,
isFresh?: FreshnessPredicate,
) => Promise<{ result: R; meta: BucketEntryMeta } | undefined>
) => Promise<{ result: R; meta: BucketEntryMeta; hitIdx: number } | undefined>

interface CascadeHit {
bucket: IBucket<unknown>
Expand Down Expand Up @@ -82,7 +82,7 @@ export class Cacheable<TView = void> {

const fullKey = this.#fullKey(key)
const dedupKey = this.#dedupKey(key, 'value')
const { result, hit } = await this.#runPolicy<T, T>(
const { result, hitIdx } = await this.#runPolicy<T, T>(
resource,
fullKey,
dedupKey,
Expand All @@ -92,8 +92,9 @@ export class Cacheable<TView = void> {

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`,
)
}

Expand All @@ -106,7 +107,7 @@ export class Cacheable<TView = void> {

const fullKey = this.#fullKey(key)
const dedupKey = this.#dedupKey(key, 'view')
const { result, hit } = await this.#runPolicy<T, TView>(
const { result, hitIdx } = await this.#runPolicy<T, TView>(
resource,
fullKey,
dedupKey,
Expand All @@ -116,8 +117,9 @@ export class Cacheable<TView = void> {

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`,
)
}

Expand All @@ -140,25 +142,25 @@ export class Cacheable<TView = void> {
dedupKey: string,
cascadeFn: CascadeFn<R>,
fromValue: (value: T) => Promise<R>,
): 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': {
Expand All @@ -168,9 +170,9 @@ export class Cacheable<TView = void> {
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': {
Expand All @@ -183,18 +185,18 @@ export class Cacheable<TView = void> {
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 }
})
}
}
Expand Down Expand Up @@ -252,7 +254,7 @@ export class Cacheable<TView = void> {
async #cascadeRead<T>(
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
Expand All @@ -268,13 +270,15 @@ export class Cacheable<TView = void> {
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
Expand All @@ -287,7 +291,7 @@ export class Cacheable<TView = void> {
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<TView>).read<unknown>(fullKey)
Expand Down Expand Up @@ -315,7 +319,7 @@ export class Cacheable<TView = void> {
// 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<T>(
Expand Down
5 changes: 3 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ export interface IBucket<TView = void> {
* 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 "<namespace>:<key>": HIT|MISS <Xms>`. When no logger is
* provided, the engine is silent.
* `Cacheable "<namespace>:<key>": HIT (L<n>)|MISS <Xms>` where `L<n>`
* 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
Expand Down
8 changes: 4 additions & 4 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/),
)
})

Expand Down Expand Up @@ -164,15 +164,15 @@ 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)

// 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)
Expand Down
2 changes: 1 addition & 1 deletion tests/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/),
)
})
})
Expand Down
Loading