@nage-api/cache 1.0.0-beta.2
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 +127 -0
- package/dist/cache.module.d.ts +34 -0
- package/dist/cache.module.js +100 -0
- package/dist/cache.service.d.ts +73 -0
- package/dist/cache.service.js +147 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +27 -0
- package/dist/memory.store.d.ts +44 -0
- package/dist/memory.store.js +128 -0
- package/dist/ports.d.ts +51 -0
- package/dist/ports.js +14 -0
- package/dist/redis.store.d.ts +48 -0
- package/dist/redis.store.js +101 -0
- package/dist/tokens.d.ts +14 -0
- package/dist/tokens.js +15 -0
- package/dist/two-tier.store.d.ts +41 -0
- package/dist/two-tier.store.js +102 -0
- package/package.json +58 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The typed cache application code uses (PLAN.md §8).
|
|
4
|
+
*
|
|
5
|
+
* Three things it adds over a bare store:
|
|
6
|
+
*
|
|
7
|
+
* **Namespacing.** Every key is prefixed, so `cache.namespace('product')` can
|
|
8
|
+
* be dropped wholesale without knowing which keys it wrote. The legacy
|
|
9
|
+
* framework shared one flat keyspace, which meant invalidation was either
|
|
10
|
+
* surgical-and-wrong or a full flush.
|
|
11
|
+
*
|
|
12
|
+
* **Single-flight.** Concurrent `wrap` calls for the same key run the loader
|
|
13
|
+
* once. Without it, a popular key expiring under load starts one identical
|
|
14
|
+
* database query per in-flight request — the cache stampede that turns a cache
|
|
15
|
+
* miss into an outage.
|
|
16
|
+
*
|
|
17
|
+
* **Typing.** `wrap<T>` returns `T`, not `any`. A cache that hands back
|
|
18
|
+
* `unknown` pushes a cast to every call site, and those casts are where a
|
|
19
|
+
* schema change stops being a type error.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.CacheService = void 0;
|
|
23
|
+
exports.toMilliseconds = toMilliseconds;
|
|
24
|
+
const core_1 = require("@nage-api/core");
|
|
25
|
+
const NAMESPACE_SEPARATOR = ':';
|
|
26
|
+
class CacheService {
|
|
27
|
+
#store;
|
|
28
|
+
#namespace;
|
|
29
|
+
#defaultTtlMs;
|
|
30
|
+
#clock;
|
|
31
|
+
#inFlight = new Map();
|
|
32
|
+
#hits = 0;
|
|
33
|
+
#misses = 0;
|
|
34
|
+
#coalesced = 0;
|
|
35
|
+
constructor(options) {
|
|
36
|
+
this.#store = options.store;
|
|
37
|
+
this.#namespace = options.namespace ?? '';
|
|
38
|
+
this.#defaultTtlMs = options.defaultTtlMs ?? 0;
|
|
39
|
+
this.#clock = options.clock ?? { now: () => Date.now() };
|
|
40
|
+
}
|
|
41
|
+
/** A view scoped to a sub-namespace, sharing the same store and counters. */
|
|
42
|
+
namespace(name) {
|
|
43
|
+
const scoped = new CacheService({
|
|
44
|
+
store: this.#store,
|
|
45
|
+
namespace: this.#key(name),
|
|
46
|
+
defaultTtlMs: this.#defaultTtlMs,
|
|
47
|
+
clock: this.#clock,
|
|
48
|
+
});
|
|
49
|
+
return scoped;
|
|
50
|
+
}
|
|
51
|
+
async get(key) {
|
|
52
|
+
const entry = await this.#store.get(this.#key(key));
|
|
53
|
+
if (entry === undefined) {
|
|
54
|
+
this.#misses += 1;
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
this.#hits += 1;
|
|
58
|
+
return entry.value;
|
|
59
|
+
}
|
|
60
|
+
async set(key, value, options = {}) {
|
|
61
|
+
await this.#store.set(this.#key(key), this.#entry(value, options));
|
|
62
|
+
}
|
|
63
|
+
async delete(key) {
|
|
64
|
+
await this.#store.delete(this.#key(key));
|
|
65
|
+
}
|
|
66
|
+
/** Drop everything this namespace wrote. */
|
|
67
|
+
async invalidateNamespace(name) {
|
|
68
|
+
const prefix = name === undefined ? this.#namespace : this.#key(name);
|
|
69
|
+
return this.#store.deletePrefix(prefix === '' ? '' : `${prefix}${NAMESPACE_SEPARATOR}`);
|
|
70
|
+
}
|
|
71
|
+
/** Drop every entry labelled with a tag, wherever it was written. */
|
|
72
|
+
async invalidateTag(tag) {
|
|
73
|
+
return this.#store.deleteTag(tag);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Read through the cache, loading on a miss.
|
|
77
|
+
*
|
|
78
|
+
* Concurrent calls for the same key share one loader invocation, so a key
|
|
79
|
+
* expiring under load costs one query rather than one per request.
|
|
80
|
+
*/
|
|
81
|
+
async wrap(key, load, options = {}) {
|
|
82
|
+
const fullKey = this.#key(key);
|
|
83
|
+
if (options.refresh !== true) {
|
|
84
|
+
const entry = await this.#store.get(fullKey);
|
|
85
|
+
if (entry !== undefined) {
|
|
86
|
+
this.#hits += 1;
|
|
87
|
+
return entry.value;
|
|
88
|
+
}
|
|
89
|
+
this.#misses += 1;
|
|
90
|
+
}
|
|
91
|
+
const existing = this.#inFlight.get(fullKey);
|
|
92
|
+
if (existing !== undefined) {
|
|
93
|
+
this.#coalesced += 1;
|
|
94
|
+
return existing;
|
|
95
|
+
}
|
|
96
|
+
const pending = this.#load(fullKey, load, options);
|
|
97
|
+
this.#inFlight.set(fullKey, pending);
|
|
98
|
+
try {
|
|
99
|
+
return await pending;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
// Cleared in `finally` so a rejected load does not poison the key: the
|
|
103
|
+
// next caller retries rather than inheriting the failure.
|
|
104
|
+
this.#inFlight.delete(fullKey);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
get stats() {
|
|
108
|
+
return { hits: this.#hits, misses: this.#misses, promotions: 0, coalesced: this.#coalesced };
|
|
109
|
+
}
|
|
110
|
+
resetStats() {
|
|
111
|
+
this.#hits = 0;
|
|
112
|
+
this.#misses = 0;
|
|
113
|
+
this.#coalesced = 0;
|
|
114
|
+
}
|
|
115
|
+
async #load(fullKey, load, options) {
|
|
116
|
+
const value = await load();
|
|
117
|
+
// Cast to `unknown` first: `value` is an unconstrained type parameter, and
|
|
118
|
+
// TypeScript narrows the comparison away without it.
|
|
119
|
+
const empty = value === undefined || value === null;
|
|
120
|
+
if (!empty || options.cacheEmpty === true) {
|
|
121
|
+
await this.#store.set(fullKey, this.#entry(value, options));
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
#entry(value, options) {
|
|
126
|
+
const ttlMs = toMilliseconds(options.ttl) ?? this.#defaultTtlMs;
|
|
127
|
+
return {
|
|
128
|
+
value,
|
|
129
|
+
...(ttlMs > 0 ? { expiresAt: this.#clock.now() + ttlMs } : {}),
|
|
130
|
+
...(options.tags === undefined ? {} : { tags: options.tags }),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
#key(key) {
|
|
134
|
+
return this.#namespace === '' ? key : `${this.#namespace}${NAMESPACE_SEPARATOR}${key}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
exports.CacheService = CacheService;
|
|
138
|
+
/** Accepts milliseconds or a duration string, so config and code agree. */
|
|
139
|
+
function toMilliseconds(ttl) {
|
|
140
|
+
if (ttl === undefined)
|
|
141
|
+
return undefined;
|
|
142
|
+
if (typeof ttl === 'number')
|
|
143
|
+
return ttl;
|
|
144
|
+
const parsed = (0, core_1.parseDurationMs)(ttl);
|
|
145
|
+
return parsed > 0 ? parsed : undefined;
|
|
146
|
+
}
|
|
147
|
+
//# sourceMappingURL=cache.service.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nage-api/cache` — the typed two-tier cache (PLAN.md §8, §25 P1).
|
|
3
|
+
*
|
|
4
|
+
* A cache is an optimisation, so this package is built to fail soft: a broken
|
|
5
|
+
* remote tier degrades to a slower application rather than a broken one, and a
|
|
6
|
+
* value it cannot parse is a miss rather than an exception.
|
|
7
|
+
*/
|
|
8
|
+
export type * from '@nage-api/contracts';
|
|
9
|
+
export { NageCacheModule, type NageCacheModuleOptions } from './cache.module.js';
|
|
10
|
+
export { CacheService, toMilliseconds, type CacheServiceOptions, type CacheSetOptions, type CacheWrapOptions, } from './cache.service.js';
|
|
11
|
+
export { MemoryCacheStore, type MemoryCacheStoreOptions } from './memory.store.js';
|
|
12
|
+
export { TwoTierCacheStore, type TwoTierCacheStoreOptions } from './two-tier.store.js';
|
|
13
|
+
export { RedisCacheStore, type RedisCacheStoreOptions, type RedisClientPort, } from './redis.store.js';
|
|
14
|
+
export { NAGE_CACHE, NAGE_CACHE_STORE } from './tokens.js';
|
|
15
|
+
export { systemClock, type CacheEntry, type CacheOutcome, type CacheStats, type CacheStore, type Clock, } from './ports.js';
|
|
16
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `@nage-api/cache` — the typed two-tier cache (PLAN.md §8, §25 P1).
|
|
4
|
+
*
|
|
5
|
+
* A cache is an optimisation, so this package is built to fail soft: a broken
|
|
6
|
+
* remote tier degrades to a slower application rather than a broken one, and a
|
|
7
|
+
* value it cannot parse is a miss rather than an exception.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.systemClock = exports.NAGE_CACHE_STORE = exports.NAGE_CACHE = exports.RedisCacheStore = exports.TwoTierCacheStore = exports.MemoryCacheStore = exports.toMilliseconds = exports.CacheService = exports.NageCacheModule = void 0;
|
|
11
|
+
var cache_module_js_1 = require("./cache.module.js");
|
|
12
|
+
Object.defineProperty(exports, "NageCacheModule", { enumerable: true, get: function () { return cache_module_js_1.NageCacheModule; } });
|
|
13
|
+
var cache_service_js_1 = require("./cache.service.js");
|
|
14
|
+
Object.defineProperty(exports, "CacheService", { enumerable: true, get: function () { return cache_service_js_1.CacheService; } });
|
|
15
|
+
Object.defineProperty(exports, "toMilliseconds", { enumerable: true, get: function () { return cache_service_js_1.toMilliseconds; } });
|
|
16
|
+
var memory_store_js_1 = require("./memory.store.js");
|
|
17
|
+
Object.defineProperty(exports, "MemoryCacheStore", { enumerable: true, get: function () { return memory_store_js_1.MemoryCacheStore; } });
|
|
18
|
+
var two_tier_store_js_1 = require("./two-tier.store.js");
|
|
19
|
+
Object.defineProperty(exports, "TwoTierCacheStore", { enumerable: true, get: function () { return two_tier_store_js_1.TwoTierCacheStore; } });
|
|
20
|
+
var redis_store_js_1 = require("./redis.store.js");
|
|
21
|
+
Object.defineProperty(exports, "RedisCacheStore", { enumerable: true, get: function () { return redis_store_js_1.RedisCacheStore; } });
|
|
22
|
+
var tokens_js_1 = require("./tokens.js");
|
|
23
|
+
Object.defineProperty(exports, "NAGE_CACHE", { enumerable: true, get: function () { return tokens_js_1.NAGE_CACHE; } });
|
|
24
|
+
Object.defineProperty(exports, "NAGE_CACHE_STORE", { enumerable: true, get: function () { return tokens_js_1.NAGE_CACHE_STORE; } });
|
|
25
|
+
var ports_js_1 = require("./ports.js");
|
|
26
|
+
Object.defineProperty(exports, "systemClock", { enumerable: true, get: function () { return ports_js_1.systemClock; } });
|
|
27
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process cache with a bounded size and lazy expiry.
|
|
3
|
+
*
|
|
4
|
+
* This is both the L1 of the two-tier arrangement and a complete cache in its
|
|
5
|
+
* own right for single-instance deployments. Two properties make it safe to
|
|
6
|
+
* leave running for months:
|
|
7
|
+
*
|
|
8
|
+
* **It is bounded.** An unbounded `Map` keyed by anything a request can
|
|
9
|
+
* influence is a memory leak with a slow fuse. Eviction is least-recently-used,
|
|
10
|
+
* which is what a cache wants and what a plain `Map` gives almost for free —
|
|
11
|
+
* `Map` preserves insertion order, so re-inserting on read moves an entry to
|
|
12
|
+
* the end and the first key is always the coldest.
|
|
13
|
+
*
|
|
14
|
+
* **It sweeps.** Expired entries are dropped on read, but a key that is written
|
|
15
|
+
* once and never read again would otherwise sit there forever, so a periodic
|
|
16
|
+
* sweep removes them. The timer is `unref`'d: a cache must not be the reason a
|
|
17
|
+
* process refuses to exit.
|
|
18
|
+
*/
|
|
19
|
+
import type { CacheEntry, CacheStore, Clock } from './ports.js';
|
|
20
|
+
export interface MemoryCacheStoreOptions {
|
|
21
|
+
/** Entries kept before the least recently used one is evicted. */
|
|
22
|
+
readonly maxEntries?: number;
|
|
23
|
+
/** How often expired entries are swept. `0` disables the timer. */
|
|
24
|
+
readonly sweepIntervalMs?: number;
|
|
25
|
+
readonly clock?: Clock;
|
|
26
|
+
}
|
|
27
|
+
export declare class MemoryCacheStore implements CacheStore {
|
|
28
|
+
#private;
|
|
29
|
+
constructor(options?: MemoryCacheStoreOptions);
|
|
30
|
+
get<TValue>(key: string): Promise<CacheEntry<TValue> | undefined>;
|
|
31
|
+
set<TValue>(key: string, entry: CacheEntry<TValue>): Promise<void>;
|
|
32
|
+
delete(key: string): Promise<void>;
|
|
33
|
+
deletePrefix(prefix: string): Promise<number>;
|
|
34
|
+
deleteTag(tag: string): Promise<number>;
|
|
35
|
+
clear(): Promise<void>;
|
|
36
|
+
/** Drop expired entries. Called by the timer and directly by tests. */
|
|
37
|
+
sweep(): number;
|
|
38
|
+
/** Stop the sweeper. Wired to Nest's shutdown hooks by the module. */
|
|
39
|
+
stop(): void;
|
|
40
|
+
get size(): number;
|
|
41
|
+
/** Keys currently held, oldest first. Test and diagnostics only. */
|
|
42
|
+
get keys(): readonly string[];
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=memory.store.d.ts.map
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* In-process cache with a bounded size and lazy expiry.
|
|
4
|
+
*
|
|
5
|
+
* This is both the L1 of the two-tier arrangement and a complete cache in its
|
|
6
|
+
* own right for single-instance deployments. Two properties make it safe to
|
|
7
|
+
* leave running for months:
|
|
8
|
+
*
|
|
9
|
+
* **It is bounded.** An unbounded `Map` keyed by anything a request can
|
|
10
|
+
* influence is a memory leak with a slow fuse. Eviction is least-recently-used,
|
|
11
|
+
* which is what a cache wants and what a plain `Map` gives almost for free —
|
|
12
|
+
* `Map` preserves insertion order, so re-inserting on read moves an entry to
|
|
13
|
+
* the end and the first key is always the coldest.
|
|
14
|
+
*
|
|
15
|
+
* **It sweeps.** Expired entries are dropped on read, but a key that is written
|
|
16
|
+
* once and never read again would otherwise sit there forever, so a periodic
|
|
17
|
+
* sweep removes them. The timer is `unref`'d: a cache must not be the reason a
|
|
18
|
+
* process refuses to exit.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.MemoryCacheStore = void 0;
|
|
22
|
+
const DEFAULT_MAX_ENTRIES = 10_000;
|
|
23
|
+
const DEFAULT_SWEEP_INTERVAL_MS = 60_000;
|
|
24
|
+
class MemoryCacheStore {
|
|
25
|
+
#entries = new Map();
|
|
26
|
+
#maxEntries;
|
|
27
|
+
#clock;
|
|
28
|
+
#sweeper;
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
this.#maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
31
|
+
this.#clock = options.clock ?? { now: () => Date.now() };
|
|
32
|
+
const interval = options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
|
|
33
|
+
if (interval > 0) {
|
|
34
|
+
this.#sweeper = setInterval(() => this.sweep(), interval);
|
|
35
|
+
// A cache must never be the reason a process refuses to exit.
|
|
36
|
+
this.#sweeper.unref();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async get(key) {
|
|
40
|
+
await Promise.resolve();
|
|
41
|
+
const entry = this.#entries.get(key);
|
|
42
|
+
if (entry === undefined)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (this.#expired(entry)) {
|
|
45
|
+
this.#entries.delete(key);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
// Re-insert to mark it as most recently used.
|
|
49
|
+
this.#entries.delete(key);
|
|
50
|
+
this.#entries.set(key, entry);
|
|
51
|
+
return entry;
|
|
52
|
+
}
|
|
53
|
+
async set(key, entry) {
|
|
54
|
+
await Promise.resolve();
|
|
55
|
+
this.#entries.delete(key);
|
|
56
|
+
this.#entries.set(key, entry);
|
|
57
|
+
this.#evictIfFull();
|
|
58
|
+
}
|
|
59
|
+
async delete(key) {
|
|
60
|
+
await Promise.resolve();
|
|
61
|
+
this.#entries.delete(key);
|
|
62
|
+
}
|
|
63
|
+
async deletePrefix(prefix) {
|
|
64
|
+
await Promise.resolve();
|
|
65
|
+
let deleted = 0;
|
|
66
|
+
for (const key of [...this.#entries.keys()]) {
|
|
67
|
+
if (!key.startsWith(prefix))
|
|
68
|
+
continue;
|
|
69
|
+
this.#entries.delete(key);
|
|
70
|
+
deleted += 1;
|
|
71
|
+
}
|
|
72
|
+
return deleted;
|
|
73
|
+
}
|
|
74
|
+
async deleteTag(tag) {
|
|
75
|
+
await Promise.resolve();
|
|
76
|
+
let deleted = 0;
|
|
77
|
+
for (const [key, entry] of [...this.#entries]) {
|
|
78
|
+
if (entry.tags?.includes(tag) !== true)
|
|
79
|
+
continue;
|
|
80
|
+
this.#entries.delete(key);
|
|
81
|
+
deleted += 1;
|
|
82
|
+
}
|
|
83
|
+
return deleted;
|
|
84
|
+
}
|
|
85
|
+
async clear() {
|
|
86
|
+
await Promise.resolve();
|
|
87
|
+
this.#entries.clear();
|
|
88
|
+
}
|
|
89
|
+
/** Drop expired entries. Called by the timer and directly by tests. */
|
|
90
|
+
sweep() {
|
|
91
|
+
let removed = 0;
|
|
92
|
+
for (const [key, entry] of [...this.#entries]) {
|
|
93
|
+
if (!this.#expired(entry))
|
|
94
|
+
continue;
|
|
95
|
+
this.#entries.delete(key);
|
|
96
|
+
removed += 1;
|
|
97
|
+
}
|
|
98
|
+
return removed;
|
|
99
|
+
}
|
|
100
|
+
/** Stop the sweeper. Wired to Nest's shutdown hooks by the module. */
|
|
101
|
+
stop() {
|
|
102
|
+
if (this.#sweeper === undefined)
|
|
103
|
+
return;
|
|
104
|
+
clearInterval(this.#sweeper);
|
|
105
|
+
this.#sweeper = undefined;
|
|
106
|
+
}
|
|
107
|
+
get size() {
|
|
108
|
+
return this.#entries.size;
|
|
109
|
+
}
|
|
110
|
+
/** Keys currently held, oldest first. Test and diagnostics only. */
|
|
111
|
+
get keys() {
|
|
112
|
+
return [...this.#entries.keys()];
|
|
113
|
+
}
|
|
114
|
+
#expired(entry) {
|
|
115
|
+
return entry.expiresAt !== undefined && entry.expiresAt <= this.#clock.now();
|
|
116
|
+
}
|
|
117
|
+
#evictIfFull() {
|
|
118
|
+
while (this.#entries.size > this.#maxEntries) {
|
|
119
|
+
// Insertion order makes the first key the least recently used.
|
|
120
|
+
const oldest = this.#entries.keys().next();
|
|
121
|
+
if (oldest.done)
|
|
122
|
+
return;
|
|
123
|
+
this.#entries.delete(oldest.value);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
exports.MemoryCacheStore = MemoryCacheStore;
|
|
128
|
+
//# sourceMappingURL=memory.store.js.map
|
package/dist/ports.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cache contract (PLAN.md §8, §25 P1).
|
|
3
|
+
*
|
|
4
|
+
* One port, two tiers. `CacheStore` is what a backend implements — memory,
|
|
5
|
+
* Redis, anything with get/set/delete — and `CacheService` is what application
|
|
6
|
+
* code uses. Keeping them apart is what lets the two-tier arrangement (a small
|
|
7
|
+
* in-process L1 in front of a shared L2) be a composition rather than a special
|
|
8
|
+
* case every caller has to know about.
|
|
9
|
+
*/
|
|
10
|
+
/** A stored entry plus the metadata needed to expire and invalidate it. */
|
|
11
|
+
export interface CacheEntry<TValue> {
|
|
12
|
+
readonly value: TValue;
|
|
13
|
+
/** Epoch milliseconds; `undefined` means it never expires on its own. */
|
|
14
|
+
readonly expiresAt?: number;
|
|
15
|
+
/** Labels this entry can be invalidated by, e.g. `product:17`. */
|
|
16
|
+
readonly tags?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A cache backend.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately small: a store that only knows how to read, write and forget
|
|
22
|
+
* cannot be where a subtle correctness bug lives. Everything else — negative
|
|
23
|
+
* caching, single-flight, tag bookkeeping — is composed above it.
|
|
24
|
+
*/
|
|
25
|
+
export interface CacheStore {
|
|
26
|
+
/** The stored entry, or `undefined` when absent or expired. */
|
|
27
|
+
get<TValue>(key: string): Promise<CacheEntry<TValue> | undefined>;
|
|
28
|
+
set<TValue>(key: string, entry: CacheEntry<TValue>): Promise<void>;
|
|
29
|
+
delete(key: string): Promise<void>;
|
|
30
|
+
/** Remove every key under a prefix; how a namespace is dropped. */
|
|
31
|
+
deletePrefix(prefix: string): Promise<number>;
|
|
32
|
+
/** Remove every entry carrying a tag. */
|
|
33
|
+
deleteTag(tag: string): Promise<number>;
|
|
34
|
+
clear(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
/** What `CacheService.wrap` was asked to do, for metrics and tests. */
|
|
37
|
+
export type CacheOutcome = 'hit' | 'miss' | 'stale' | 'bypass';
|
|
38
|
+
export interface CacheStats {
|
|
39
|
+
readonly hits: number;
|
|
40
|
+
readonly misses: number;
|
|
41
|
+
/** Served from L2 and promoted into L1. */
|
|
42
|
+
readonly promotions: number;
|
|
43
|
+
/** Loads that joined an in-flight load rather than starting their own. */
|
|
44
|
+
readonly coalesced: number;
|
|
45
|
+
}
|
|
46
|
+
/** Injected so TTLs are testable without waiting. */
|
|
47
|
+
export interface Clock {
|
|
48
|
+
now(): number;
|
|
49
|
+
}
|
|
50
|
+
export declare const systemClock: Clock;
|
|
51
|
+
//# sourceMappingURL=ports.d.ts.map
|
package/dist/ports.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The cache contract (PLAN.md §8, §25 P1).
|
|
4
|
+
*
|
|
5
|
+
* One port, two tiers. `CacheStore` is what a backend implements — memory,
|
|
6
|
+
* Redis, anything with get/set/delete — and `CacheService` is what application
|
|
7
|
+
* code uses. Keeping them apart is what lets the two-tier arrangement (a small
|
|
8
|
+
* in-process L1 in front of a shared L2) be a composition rather than a special
|
|
9
|
+
* case every caller has to know about.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.systemClock = void 0;
|
|
13
|
+
exports.systemClock = { now: () => Date.now() };
|
|
14
|
+
//# sourceMappingURL=ports.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Redis-backed L2, written against a **client port** rather than a client.
|
|
3
|
+
*
|
|
4
|
+
* `@nage-api/cache` does not depend on ioredis or node-redis. It states the six
|
|
5
|
+
* commands it needs, an application passes something that implements them, and
|
|
6
|
+
* this file contains the part that is actually cache-specific: how an entry is
|
|
7
|
+
* serialised, how a namespace is dropped without `KEYS *`, and how tags are
|
|
8
|
+
* tracked.
|
|
9
|
+
*
|
|
10
|
+
* That indirection is not ceremony. It keeps a heavyweight optional dependency
|
|
11
|
+
* out of the install for the many deployments that never use it, and it makes
|
|
12
|
+
* the store testable without a Redis daemon — which is what the suite does.
|
|
13
|
+
*/
|
|
14
|
+
import type { CacheEntry, CacheStore } from './ports.js';
|
|
15
|
+
/**
|
|
16
|
+
* The subset of a Redis client this store uses.
|
|
17
|
+
*
|
|
18
|
+
* `scan` rather than `keys`: `KEYS` is O(n) over the whole keyspace and blocks
|
|
19
|
+
* the server, which is exactly the wrong thing to do on the shared instance
|
|
20
|
+
* every service depends on.
|
|
21
|
+
*/
|
|
22
|
+
export interface RedisClientPort {
|
|
23
|
+
get(key: string): Promise<string | null>;
|
|
24
|
+
set(key: string, value: string, expiryMode?: 'PX', ttl?: number): Promise<unknown>;
|
|
25
|
+
del(...keys: string[]): Promise<number>;
|
|
26
|
+
/** Returns `[cursor, keys]`, exactly as Redis does. */
|
|
27
|
+
scan(cursor: string, match: string, count: number): Promise<[string, string[]]>;
|
|
28
|
+
sadd(key: string, ...members: string[]): Promise<number>;
|
|
29
|
+
smembers(key: string): Promise<string[]>;
|
|
30
|
+
}
|
|
31
|
+
export interface RedisCacheStoreOptions {
|
|
32
|
+
readonly client: RedisClientPort;
|
|
33
|
+
/** Prefix for every key, so several apps can share one Redis. */
|
|
34
|
+
readonly keyPrefix?: string;
|
|
35
|
+
/** Keys scanned per round trip. */
|
|
36
|
+
readonly scanCount?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare class RedisCacheStore implements CacheStore {
|
|
39
|
+
#private;
|
|
40
|
+
constructor(options: RedisCacheStoreOptions);
|
|
41
|
+
get<TValue>(key: string): Promise<CacheEntry<TValue> | undefined>;
|
|
42
|
+
set<TValue>(key: string, entry: CacheEntry<TValue>): Promise<void>;
|
|
43
|
+
delete(key: string): Promise<void>;
|
|
44
|
+
deletePrefix(prefix: string): Promise<number>;
|
|
45
|
+
deleteTag(tag: string): Promise<number>;
|
|
46
|
+
clear(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=redis.store.d.ts.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* A Redis-backed L2, written against a **client port** rather than a client.
|
|
4
|
+
*
|
|
5
|
+
* `@nage-api/cache` does not depend on ioredis or node-redis. It states the six
|
|
6
|
+
* commands it needs, an application passes something that implements them, and
|
|
7
|
+
* this file contains the part that is actually cache-specific: how an entry is
|
|
8
|
+
* serialised, how a namespace is dropped without `KEYS *`, and how tags are
|
|
9
|
+
* tracked.
|
|
10
|
+
*
|
|
11
|
+
* That indirection is not ceremony. It keeps a heavyweight optional dependency
|
|
12
|
+
* out of the install for the many deployments that never use it, and it makes
|
|
13
|
+
* the store testable without a Redis daemon — which is what the suite does.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.RedisCacheStore = void 0;
|
|
17
|
+
const DEFAULT_SCAN_COUNT = 200;
|
|
18
|
+
const TAG_INDEX_PREFIX = '__tag__';
|
|
19
|
+
class RedisCacheStore {
|
|
20
|
+
#client;
|
|
21
|
+
#prefix;
|
|
22
|
+
#scanCount;
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.#client = options.client;
|
|
25
|
+
this.#prefix = options.keyPrefix ?? '';
|
|
26
|
+
this.#scanCount = options.scanCount ?? DEFAULT_SCAN_COUNT;
|
|
27
|
+
}
|
|
28
|
+
async get(key) {
|
|
29
|
+
const raw = await this.#client.get(this.#full(key));
|
|
30
|
+
if (raw === null)
|
|
31
|
+
return undefined;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(raw);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// A value written by another version, or by something else entirely. A
|
|
37
|
+
// cache miss is the right answer; a parse error would take down a request
|
|
38
|
+
// that could have been served from the source.
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async set(key, entry) {
|
|
43
|
+
const full = this.#full(key);
|
|
44
|
+
const payload = JSON.stringify(entry);
|
|
45
|
+
// Redis owns expiry, so a restarted process cannot resurrect stale values.
|
|
46
|
+
if (entry.expiresAt === undefined) {
|
|
47
|
+
await this.#client.set(full, payload);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const ttl = Math.max(1, entry.expiresAt - Date.now());
|
|
51
|
+
await this.#client.set(full, payload, 'PX', ttl);
|
|
52
|
+
}
|
|
53
|
+
for (const tag of entry.tags ?? []) {
|
|
54
|
+
await this.#client.sadd(this.#tagKey(tag), full);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async delete(key) {
|
|
58
|
+
await this.#client.del(this.#full(key));
|
|
59
|
+
}
|
|
60
|
+
async deletePrefix(prefix) {
|
|
61
|
+
const keys = await this.#scan(`${this.#full(prefix)}*`);
|
|
62
|
+
if (keys.length === 0)
|
|
63
|
+
return 0;
|
|
64
|
+
return this.#client.del(...keys);
|
|
65
|
+
}
|
|
66
|
+
async deleteTag(tag) {
|
|
67
|
+
const tagKey = this.#tagKey(tag);
|
|
68
|
+
const members = await this.#client.smembers(tagKey);
|
|
69
|
+
if (members.length === 0)
|
|
70
|
+
return 0;
|
|
71
|
+
// The index goes too, so a tag never accumulates keys that no longer exist.
|
|
72
|
+
const deleted = await this.#client.del(...members);
|
|
73
|
+
await this.#client.del(tagKey);
|
|
74
|
+
return deleted;
|
|
75
|
+
}
|
|
76
|
+
async clear() {
|
|
77
|
+
// Scoped to this store's prefix. Flushing the whole database would take out
|
|
78
|
+
// every other service sharing the instance.
|
|
79
|
+
const keys = await this.#scan(`${this.#prefix}*`);
|
|
80
|
+
if (keys.length > 0)
|
|
81
|
+
await this.#client.del(...keys);
|
|
82
|
+
}
|
|
83
|
+
async #scan(match) {
|
|
84
|
+
const found = [];
|
|
85
|
+
let cursor = '0';
|
|
86
|
+
do {
|
|
87
|
+
const [next, keys] = await this.#client.scan(cursor, match, this.#scanCount);
|
|
88
|
+
found.push(...keys);
|
|
89
|
+
cursor = next;
|
|
90
|
+
} while (cursor !== '0');
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
#full(key) {
|
|
94
|
+
return `${this.#prefix}${key}`;
|
|
95
|
+
}
|
|
96
|
+
#tagKey(tag) {
|
|
97
|
+
return `${this.#prefix}${TAG_INDEX_PREFIX}:${tag}`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
exports.RedisCacheStore = RedisCacheStore;
|
|
101
|
+
//# sourceMappingURL=redis.store.js.map
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DI tokens for the cache (PLAN.md §7.3).
|
|
3
|
+
*
|
|
4
|
+
* Two tokens, not one: application code injects `NAGE_CACHE` (the typed
|
|
5
|
+
* service), and a driver or a diagnostic tool that needs the raw store injects
|
|
6
|
+
* `NAGE_CACHE_STORE`. Collapsing them would make every consumer depend on the
|
|
7
|
+
* backend's surface.
|
|
8
|
+
*/
|
|
9
|
+
import { type Token } from '@nage-api/core';
|
|
10
|
+
import type { CacheService } from './cache.service.js';
|
|
11
|
+
import type { CacheStore } from './ports.js';
|
|
12
|
+
export declare const NAGE_CACHE: Token<CacheService>;
|
|
13
|
+
export declare const NAGE_CACHE_STORE: Token<CacheStore>;
|
|
14
|
+
//# sourceMappingURL=tokens.d.ts.map
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* DI tokens for the cache (PLAN.md §7.3).
|
|
4
|
+
*
|
|
5
|
+
* Two tokens, not one: application code injects `NAGE_CACHE` (the typed
|
|
6
|
+
* service), and a driver or a diagnostic tool that needs the raw store injects
|
|
7
|
+
* `NAGE_CACHE_STORE`. Collapsing them would make every consumer depend on the
|
|
8
|
+
* backend's surface.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.NAGE_CACHE_STORE = exports.NAGE_CACHE = void 0;
|
|
12
|
+
const core_1 = require("@nage-api/core");
|
|
13
|
+
exports.NAGE_CACHE = (0, core_1.createToken)('NAGE_CACHE');
|
|
14
|
+
exports.NAGE_CACHE_STORE = (0, core_1.createToken)('NAGE_CACHE_STORE');
|
|
15
|
+
//# sourceMappingURL=tokens.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 in front of L2 (PLAN.md §8: "two-tier Keyv (memory+redis), typed").
|
|
3
|
+
*
|
|
4
|
+
* The point is latency, not capacity: a shared Redis is one network round trip
|
|
5
|
+
* away, and a request that reads the same key five times should pay for it
|
|
6
|
+
* once. L1 is a small in-process cache with a **shorter** TTL than L2, because
|
|
7
|
+
* an in-process copy is the one nothing can invalidate remotely.
|
|
8
|
+
*
|
|
9
|
+
* That shorter TTL is the whole safety argument. Another instance deleting a
|
|
10
|
+
* key clears L2 immediately and every L1 within its own TTL — so staleness is
|
|
11
|
+
* bounded by a number you choose, rather than by when a process happens to
|
|
12
|
+
* restart. Deleting locally also clears the local copy at once, so the instance
|
|
13
|
+
* that made a change never reads its own stale value.
|
|
14
|
+
*/
|
|
15
|
+
import type { CacheEntry, CacheStore, Clock } from './ports.js';
|
|
16
|
+
export interface TwoTierCacheStoreOptions {
|
|
17
|
+
readonly l1: CacheStore;
|
|
18
|
+
readonly l2: CacheStore;
|
|
19
|
+
/**
|
|
20
|
+
* Cap on how long L1 may hold a value, in milliseconds. Keep it small: it is
|
|
21
|
+
* the window in which this instance can serve a value another instance has
|
|
22
|
+
* already invalidated.
|
|
23
|
+
*/
|
|
24
|
+
readonly l1TtlMs?: number;
|
|
25
|
+
readonly clock?: Clock;
|
|
26
|
+
/** Called when L2 fails, so a broken remote degrades rather than throws. */
|
|
27
|
+
readonly onRemoteError?: (operation: string, error: unknown) => void;
|
|
28
|
+
}
|
|
29
|
+
export declare class TwoTierCacheStore implements CacheStore {
|
|
30
|
+
#private;
|
|
31
|
+
constructor(options: TwoTierCacheStoreOptions);
|
|
32
|
+
get<TValue>(key: string): Promise<CacheEntry<TValue> | undefined>;
|
|
33
|
+
set<TValue>(key: string, entry: CacheEntry<TValue>): Promise<void>;
|
|
34
|
+
delete(key: string): Promise<void>;
|
|
35
|
+
deletePrefix(prefix: string): Promise<number>;
|
|
36
|
+
deleteTag(tag: string): Promise<number>;
|
|
37
|
+
clear(): Promise<void>;
|
|
38
|
+
/** How many reads were served by L2 and copied into L1. */
|
|
39
|
+
get promotions(): number;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=two-tier.store.d.ts.map
|