@c9up/echo 0.1.5 → 0.1.7
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/dist/CacheManager.d.ts +63 -28
- package/dist/CacheManager.d.ts.map +1 -1
- package/dist/CacheManager.js +348 -70
- package/dist/CacheManager.js.map +1 -1
- package/dist/EchoProvider.d.ts +15 -9
- package/dist/EchoProvider.d.ts.map +1 -1
- package/dist/EchoProvider.js +47 -13
- package/dist/EchoProvider.js.map +1 -1
- package/dist/StoreManager.d.ts +61 -0
- package/dist/StoreManager.d.ts.map +1 -0
- package/dist/StoreManager.js +70 -0
- package/dist/StoreManager.js.map +1 -0
- package/dist/drivers/MemoryDriver.d.ts +13 -6
- package/dist/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/drivers/MemoryDriver.js +67 -61
- package/dist/drivers/MemoryDriver.js.map +1 -1
- package/dist/drivers/RedisDriver.d.ts +17 -17
- package/dist/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/drivers/RedisDriver.js +103 -55
- package/dist/drivers/RedisDriver.js.map +1 -1
- package/dist/drivers/TieredDriver.d.ts +41 -0
- package/dist/drivers/TieredDriver.d.ts.map +1 -0
- package/dist/drivers/TieredDriver.js +132 -0
- package/dist/drivers/TieredDriver.js.map +1 -0
- package/dist/duration.d.ts +32 -0
- package/dist/duration.d.ts.map +1 -0
- package/dist/duration.js +75 -0
- package/dist/duration.js.map +1 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +31 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +15 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/testing/main.d.ts +41 -0
- package/dist/testing/main.d.ts.map +1 -0
- package/dist/testing/main.js +41 -0
- package/dist/testing/main.js.map +1 -0
- package/dist/types.d.ts +146 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +6 -1
- package/src/CacheManager.ts +497 -99
- package/src/EchoProvider.ts +58 -15
- package/src/StoreManager.ts +104 -0
- package/src/drivers/MemoryDriver.ts +97 -66
- package/src/drivers/RedisDriver.ts +135 -58
- package/src/drivers/TieredDriver.ts +186 -0
- package/src/duration.ts +86 -0
- package/src/errors.ts +33 -0
- package/src/index.ts +43 -3
- package/src/testing/main.ts +69 -0
- package/src/types.ts +156 -0
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Redis cache driver — production-grade cache with TTL
|
|
2
|
+
* Redis cache driver — production-grade cache with TTL, grace
|
|
3
|
+
* (stale-while-revalidate) and tags. Suitable as the L2 tier of
|
|
4
|
+
* {@link TieredDriver}.
|
|
3
5
|
*
|
|
4
6
|
* Requires a Redis client instance implementing the minimal interface below.
|
|
5
7
|
* Compatible with ioredis and redis (node-redis) clients.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
9
|
+
* STORAGE FORMAT (breaking vs echo <=0.1.5): values are stored as a JSON
|
|
10
|
+
* envelope `{ "v": <value>, "e": <logicalExpiryEpochMs> }`. The physical Redis
|
|
11
|
+
* TTL (`EX`) covers `ttl + grace` so a logically-expired value survives for the
|
|
12
|
+
* grace window; `e` records the logical expiry so reads can flag it stale.
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
|
-
import type {
|
|
15
|
+
import type { CacheEntry, DriverSetOptions, TaggableDriver } from "../types.js";
|
|
11
16
|
|
|
12
17
|
/** Minimal Redis client interface — compatible with ioredis and node-redis. */
|
|
13
18
|
export interface RedisClient {
|
|
@@ -30,7 +35,22 @@ export interface RedisClient {
|
|
|
30
35
|
): Promise<[string, string[]]>;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
interface Envelope {
|
|
39
|
+
v: unknown;
|
|
40
|
+
e: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isEnvelope(x: unknown): x is Envelope {
|
|
44
|
+
return (
|
|
45
|
+
typeof x === "object" &&
|
|
46
|
+
x !== null &&
|
|
47
|
+
"v" in x &&
|
|
48
|
+
"e" in x &&
|
|
49
|
+
typeof Reflect.get(x, "e") === "number"
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class RedisDriver implements TaggableDriver {
|
|
34
54
|
#client: RedisClient;
|
|
35
55
|
#prefix: string;
|
|
36
56
|
|
|
@@ -44,12 +64,9 @@ export class RedisDriver implements CacheDriver {
|
|
|
44
64
|
}
|
|
45
65
|
|
|
46
66
|
/**
|
|
47
|
-
* Reverse-index for per-key tag membership. Lets
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* `setWithTags('article:42', v, ['homepage'])` (was `['news']`) leaves
|
|
51
|
-
* `tag:news` pointing at `article:42`, and a later `flushTags(['news'])`
|
|
52
|
-
* silently deletes the value.
|
|
67
|
+
* Reverse-index for per-key tag membership. Lets tag writes clean stale
|
|
68
|
+
* memberships on retag, and `delete()` drop the key from every tag-set it
|
|
69
|
+
* belongs to.
|
|
53
70
|
*/
|
|
54
71
|
#metaKey(k: string): string {
|
|
55
72
|
return `${this.#prefix}meta:tags:${k}`;
|
|
@@ -71,29 +88,92 @@ export class RedisDriver implements CacheDriver {
|
|
|
71
88
|
}
|
|
72
89
|
|
|
73
90
|
async get<T = unknown>(key: string): Promise<T | null> {
|
|
91
|
+
const entry = await this.getEntry<T>(key);
|
|
92
|
+
if (entry === null || entry.stale) return null;
|
|
93
|
+
return entry.value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getEntry<T = unknown>(key: string): Promise<CacheEntry<T> | null> {
|
|
74
97
|
const raw = await this.#client.get(this.#key(key));
|
|
75
98
|
if (raw === null) return null;
|
|
76
|
-
|
|
99
|
+
const parsed: unknown = JSON.parse(raw);
|
|
100
|
+
if (!isEnvelope(parsed)) return null;
|
|
101
|
+
const stale = parsed.e > 0 && parsed.e < Date.now();
|
|
102
|
+
// Deserialize boundary: the on-the-wire value is genuinely `unknown`; the
|
|
103
|
+
// caller's generic `T` is the assertion. This is the single unavoidable
|
|
104
|
+
// cast site (mirrors echo <=0.1.5 `JSON.parse(raw) as T`).
|
|
105
|
+
const value = parsed.v as T;
|
|
106
|
+
return { value, stale, expiresAt: parsed.e };
|
|
77
107
|
}
|
|
78
108
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
109
|
+
/** Write the value envelope with a physical (grace-inclusive) TTL in seconds. */
|
|
110
|
+
async #writeEnvelope(
|
|
111
|
+
fullKey: string,
|
|
112
|
+
value: unknown,
|
|
113
|
+
logicalTtlSeconds: number,
|
|
114
|
+
physicalTtlSeconds: number,
|
|
115
|
+
logicalExpiresAtOverride?: number,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
// An absolute override (e.g. `expire()` marking stale-now) wins over the
|
|
118
|
+
// ttl-derived logical expiry; a past value flags the entry stale on read.
|
|
119
|
+
const expiresAt =
|
|
120
|
+
logicalExpiresAtOverride !== undefined
|
|
121
|
+
? logicalExpiresAtOverride
|
|
122
|
+
: logicalTtlSeconds > 0
|
|
123
|
+
? Date.now() + logicalTtlSeconds * 1000
|
|
124
|
+
: 0;
|
|
125
|
+
const envelope: Envelope = { v: value, e: expiresAt };
|
|
126
|
+
const serialized = JSON.stringify(envelope);
|
|
127
|
+
if (physicalTtlSeconds > 0) {
|
|
128
|
+
await this.#client.set(fullKey, serialized, "EX", physicalTtlSeconds);
|
|
129
|
+
} else {
|
|
130
|
+
await this.#client.set(fullKey, serialized);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Drop a key from its previous tag memberships (reconcile before re-tagging). */
|
|
135
|
+
async #dropTags(fullKey: string, metaKey: string): Promise<string[]> {
|
|
86
136
|
const prevTags = await this.#client.smembers(metaKey);
|
|
87
137
|
for (const tag of prevTags) {
|
|
88
138
|
await this.#client.srem(`${this.#prefix}tag:${tag}`, fullKey);
|
|
89
139
|
}
|
|
90
140
|
if (prevTags.length > 0) await this.#client.del(metaKey);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
141
|
+
return prevTags;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
|
145
|
+
const fullKey = this.#key(key);
|
|
146
|
+
await this.#dropTags(fullKey, this.#metaKey(key));
|
|
147
|
+
const ttl = ttlSeconds && ttlSeconds > 0 ? ttlSeconds : 0;
|
|
148
|
+
await this.#writeEnvelope(fullKey, value, ttl, ttl);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async setEntry(
|
|
152
|
+
key: string,
|
|
153
|
+
value: unknown,
|
|
154
|
+
options: DriverSetOptions,
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
const fullKey = this.#key(key);
|
|
157
|
+
const metaKey = this.#metaKey(key);
|
|
158
|
+
const ttl =
|
|
159
|
+
options.ttlSeconds && options.ttlSeconds > 0 ? options.ttlSeconds : 0;
|
|
160
|
+
const grace =
|
|
161
|
+
options.graceSeconds && options.graceSeconds > 0
|
|
162
|
+
? options.graceSeconds
|
|
163
|
+
: 0;
|
|
164
|
+
// With an absolute logical expiry (e.g. `expire()` marking stale-now), the
|
|
165
|
+
// ttl no longer drives physical retention — keep the value for the grace
|
|
166
|
+
// window measured from now so a stale-but-graced read still finds it.
|
|
167
|
+
const override = options.expiresAt;
|
|
168
|
+
const physical = override !== undefined ? grace : ttl > 0 ? ttl + grace : 0;
|
|
169
|
+
const tags = options.tags ?? [];
|
|
170
|
+
if (tags.length === 0) {
|
|
171
|
+
await this.#dropTags(fullKey, metaKey);
|
|
172
|
+
await this.#writeEnvelope(fullKey, value, ttl, physical, override);
|
|
173
|
+
return;
|
|
96
174
|
}
|
|
175
|
+
await this.#writeEnvelope(fullKey, value, ttl, physical, override);
|
|
176
|
+
await this.#applyTags(key, tags, physical);
|
|
97
177
|
}
|
|
98
178
|
|
|
99
179
|
async flush(): Promise<void> {
|
|
@@ -121,25 +201,18 @@ export class RedisDriver implements CacheDriver {
|
|
|
121
201
|
}
|
|
122
202
|
|
|
123
203
|
async has(key: string): Promise<boolean> {
|
|
124
|
-
|
|
125
|
-
return exists > 0;
|
|
204
|
+
return (await this.get(key)) !== null;
|
|
126
205
|
}
|
|
127
206
|
|
|
128
207
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* Re-tagging an existing key (e.g. `['news']` → `['homepage']`) cleans
|
|
132
|
-
* the stale memberships via the per-key reverse-index — without this,
|
|
133
|
-
* a later `flushTags(['news'])` would silently wipe the still-current
|
|
134
|
-
* value because the abandoned `tag:news` set kept pointing at it.
|
|
208
|
+
* Reconcile the tag reverse-index for `key` to exactly `tags`, and extend
|
|
209
|
+
* tag-set / meta TTLs to the physical (grace-inclusive) retention.
|
|
135
210
|
*/
|
|
136
|
-
async
|
|
211
|
+
async #applyTags(
|
|
137
212
|
key: string,
|
|
138
|
-
value: unknown,
|
|
139
213
|
tags: string[],
|
|
140
|
-
|
|
214
|
+
physicalTtlSeconds: number,
|
|
141
215
|
): Promise<void> {
|
|
142
|
-
await this.set(key, value, ttlSeconds);
|
|
143
216
|
const fullKey = this.#key(key);
|
|
144
217
|
const metaKey = this.#metaKey(key);
|
|
145
218
|
const oldTags = await this.#client.smembers(metaKey);
|
|
@@ -148,57 +221,56 @@ export class RedisDriver implements CacheDriver {
|
|
|
148
221
|
const removedTags = oldTags.filter((t) => !newTagSet.has(t));
|
|
149
222
|
const addedTags = tags.filter((t) => !oldTagSet.has(t));
|
|
150
223
|
|
|
151
|
-
// Drop the key from tag-sets it no longer belongs to.
|
|
152
224
|
for (const tag of removedTags) {
|
|
153
|
-
|
|
154
|
-
await this.#client.srem(tagKey, fullKey);
|
|
225
|
+
await this.#client.srem(`${this.#prefix}tag:${tag}`, fullKey);
|
|
155
226
|
}
|
|
156
227
|
|
|
157
|
-
// Add to new tag-sets; re-touch TTLs on every declared tag so existing
|
|
158
|
-
// memberships extend correctly on re-set.
|
|
159
228
|
for (const tag of tags) {
|
|
160
229
|
const tagKey = `${this.#prefix}tag:${tag}`;
|
|
161
230
|
if (addedTags.includes(tag)) {
|
|
162
231
|
await this.#client.sadd(tagKey, fullKey);
|
|
163
232
|
}
|
|
164
|
-
if (
|
|
233
|
+
if (physicalTtlSeconds > 0) {
|
|
165
234
|
const currentTtl = await this.#client.ttl(tagKey);
|
|
166
|
-
if (currentTtl < 0 ||
|
|
167
|
-
await this.#client.expire(tagKey,
|
|
235
|
+
if (currentTtl < 0 || physicalTtlSeconds > currentTtl) {
|
|
236
|
+
await this.#client.expire(tagKey, physicalTtlSeconds);
|
|
168
237
|
}
|
|
169
238
|
}
|
|
170
239
|
}
|
|
171
240
|
|
|
172
|
-
// Refresh the reverse-index to match the new tag set. Drop+re-add is
|
|
173
|
-
// simpler than diff-mutating the set and matches `tags` exactly even
|
|
174
|
-
// in the empty-array case.
|
|
175
241
|
if (oldTags.length > 0) {
|
|
176
242
|
await this.#client.del(metaKey);
|
|
177
243
|
}
|
|
178
244
|
if (tags.length > 0) {
|
|
179
245
|
await this.#client.sadd(metaKey, ...tags);
|
|
180
|
-
if (
|
|
181
|
-
await this.#client.expire(metaKey,
|
|
246
|
+
if (physicalTtlSeconds > 0) {
|
|
247
|
+
await this.#client.expire(metaKey, physicalTtlSeconds);
|
|
182
248
|
}
|
|
183
249
|
}
|
|
184
250
|
}
|
|
185
251
|
|
|
252
|
+
/** Set a value with tag memberships for group invalidation (no grace). */
|
|
253
|
+
async setWithTags(
|
|
254
|
+
key: string,
|
|
255
|
+
value: unknown,
|
|
256
|
+
tags: string[],
|
|
257
|
+
ttlSeconds?: number,
|
|
258
|
+
): Promise<void> {
|
|
259
|
+
const ttl = ttlSeconds && ttlSeconds > 0 ? ttlSeconds : 0;
|
|
260
|
+
await this.#writeEnvelope(this.#key(key), value, ttl, ttl);
|
|
261
|
+
await this.#applyTags(key, tags, ttl);
|
|
262
|
+
}
|
|
263
|
+
|
|
186
264
|
/**
|
|
187
|
-
*
|
|
188
|
-
* per-key reverse-index AND cross-tag memberships so a multi-tag key
|
|
189
|
-
*
|
|
190
|
-
* the `homepage` tag-set — otherwise the `homepage` set ends up with a
|
|
191
|
-
* dangling reference to a now-deleted value.
|
|
265
|
+
* Invalidate all entries tagged with any of the given tags. Cleans the
|
|
266
|
+
* per-key reverse-index AND cross-tag memberships so a multi-tag key flushed
|
|
267
|
+
* via one tag is also removed from the others.
|
|
192
268
|
*/
|
|
193
|
-
async
|
|
269
|
+
async deleteByTag(tags: string[]): Promise<void> {
|
|
194
270
|
for (const tag of tags) {
|
|
195
271
|
const tagKey = `${this.#prefix}tag:${tag}`;
|
|
196
272
|
const members = await this.#client.smembers(tagKey);
|
|
197
273
|
for (const fullKey of members) {
|
|
198
|
-
// Read every OTHER tag this key claims (via the reverse-index)
|
|
199
|
-
// and SREM the key from each. The reverse-index key derives
|
|
200
|
-
// from the unprefixed user key — recover it by stripping the
|
|
201
|
-
// prefix.
|
|
202
274
|
const userKey = fullKey.startsWith(this.#prefix)
|
|
203
275
|
? fullKey.slice(this.#prefix.length)
|
|
204
276
|
: fullKey;
|
|
@@ -219,4 +291,9 @@ export class RedisDriver implements CacheDriver {
|
|
|
219
291
|
await this.#client.del(tagKey);
|
|
220
292
|
}
|
|
221
293
|
}
|
|
294
|
+
|
|
295
|
+
/** @deprecated alias of {@link deleteByTag}. */
|
|
296
|
+
async flushTags(tags: string[]): Promise<void> {
|
|
297
|
+
return this.deleteByTag(tags);
|
|
298
|
+
}
|
|
222
299
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TieredDriver — two-tier cache composing an L1 (in-process, e.g.
|
|
3
|
+
* {@link MemoryDriver}) and an L2 (distributed, e.g. {@link RedisDriver}).
|
|
4
|
+
*
|
|
5
|
+
* Reads go L1 → L2 → miss, promoting an L2 hit back into L1. Writes are
|
|
6
|
+
* write-through to both tiers. An optional {@link CacheBus} broadcasts
|
|
7
|
+
* invalidations so peer instances drop their (now stale) L1 copies — the L2 is
|
|
8
|
+
* shared, so only L1 needs cross-instance invalidation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
CacheDriver,
|
|
13
|
+
CacheEntry,
|
|
14
|
+
DriverSetOptions,
|
|
15
|
+
TaggableDriver,
|
|
16
|
+
} from "../types.js";
|
|
17
|
+
|
|
18
|
+
/** A cross-instance invalidation message. */
|
|
19
|
+
export interface BusMessage {
|
|
20
|
+
type: "delete" | "clear";
|
|
21
|
+
keys: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Duck-typed pub/sub bus for cross-instance L1 invalidation (e.g. Redis pub/sub). */
|
|
25
|
+
export interface CacheBus {
|
|
26
|
+
publish(message: BusMessage): void | Promise<void>;
|
|
27
|
+
subscribe(handler: (message: BusMessage) => void): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TieredDriverOptions {
|
|
31
|
+
l1: CacheDriver;
|
|
32
|
+
l2: CacheDriver;
|
|
33
|
+
bus?: CacheBus;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isTaggable(driver: CacheDriver): driver is TaggableDriver {
|
|
37
|
+
const candidate: Partial<TaggableDriver> = driver;
|
|
38
|
+
return (
|
|
39
|
+
typeof candidate.setWithTags === "function" &&
|
|
40
|
+
(typeof candidate.deleteByTag === "function" ||
|
|
41
|
+
typeof candidate.flushTags === "function")
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readEntry<T>(
|
|
46
|
+
driver: CacheDriver,
|
|
47
|
+
key: string,
|
|
48
|
+
): Promise<CacheEntry<T> | null> {
|
|
49
|
+
if (driver.getEntry) return driver.getEntry<T>(key);
|
|
50
|
+
const value = await driver.get<T>(key);
|
|
51
|
+
return value === null ? null : { value, stale: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function writeEntry(
|
|
55
|
+
driver: CacheDriver,
|
|
56
|
+
key: string,
|
|
57
|
+
value: unknown,
|
|
58
|
+
options: DriverSetOptions,
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
if (driver.setEntry) {
|
|
61
|
+
await driver.setEntry(key, value, options);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (options.tags && options.tags.length > 0 && isTaggable(driver)) {
|
|
65
|
+
await driver.setWithTags(key, value, options.tags, options.ttlSeconds);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await driver.set(key, value, options.ttlSeconds);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class TieredDriver implements TaggableDriver {
|
|
72
|
+
#l1: CacheDriver;
|
|
73
|
+
#l2: CacheDriver;
|
|
74
|
+
#bus: CacheBus | undefined;
|
|
75
|
+
|
|
76
|
+
constructor(options: TieredDriverOptions) {
|
|
77
|
+
this.#l1 = options.l1;
|
|
78
|
+
this.#l2 = options.l2;
|
|
79
|
+
this.#bus = options.bus;
|
|
80
|
+
this.#bus?.subscribe((message) => {
|
|
81
|
+
// Peer invalidation: only the local L1 needs clearing (L2 is shared).
|
|
82
|
+
if (message.type === "clear") {
|
|
83
|
+
void this.#l1.flush();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const key of message.keys) void this.#l1.delete(key);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async getEntry<T = unknown>(key: string): Promise<CacheEntry<T> | null> {
|
|
91
|
+
const l1 = await readEntry<T>(this.#l1, key);
|
|
92
|
+
if (l1 && !l1.stale) return l1;
|
|
93
|
+
|
|
94
|
+
const l2 = await readEntry<T>(this.#l2, key);
|
|
95
|
+
if (l2 && !l2.stale) {
|
|
96
|
+
// Promote a fresh L2 hit into L1 — preserving the remaining logical TTL
|
|
97
|
+
// so L1 cannot outlive L2 (a promotion with no TTL left an immortal L1
|
|
98
|
+
// entry that kept serving a value the L2 had already expired).
|
|
99
|
+
if (l2.expiresAt === undefined) {
|
|
100
|
+
// Driver doesn't expose expiry — promoting with no TTL risks an
|
|
101
|
+
// immortal L1 copy; skip promotion rather than cache it forever.
|
|
102
|
+
return l2;
|
|
103
|
+
}
|
|
104
|
+
const opts: DriverSetOptions = {};
|
|
105
|
+
if (l2.expiresAt > 0) {
|
|
106
|
+
const remainingSeconds = (l2.expiresAt - Date.now()) / 1000;
|
|
107
|
+
// Raced past expiry between the stale check and now — don't promote
|
|
108
|
+
// a value that is already dead.
|
|
109
|
+
if (remainingSeconds <= 0) return l2;
|
|
110
|
+
opts.ttlSeconds = remainingSeconds;
|
|
111
|
+
}
|
|
112
|
+
// expiresAt === 0 → the L2 entry genuinely never expires → promote as-is.
|
|
113
|
+
await writeEntry(this.#l1, key, l2.value, opts);
|
|
114
|
+
return l2;
|
|
115
|
+
}
|
|
116
|
+
if (l2) return l2; // stale L2 (grace)
|
|
117
|
+
if (l1) return l1; // stale L1 (grace)
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async get<T = unknown>(key: string): Promise<T | null> {
|
|
122
|
+
const entry = await this.getEntry<T>(key);
|
|
123
|
+
if (entry === null || entry.stale) return null;
|
|
124
|
+
return entry.value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
|
128
|
+
await this.setEntry(key, value, { ttlSeconds });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async setEntry(
|
|
132
|
+
key: string,
|
|
133
|
+
value: unknown,
|
|
134
|
+
options: DriverSetOptions,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
await writeEntry(this.#l1, key, value, options);
|
|
137
|
+
await writeEntry(this.#l2, key, value, options);
|
|
138
|
+
await this.#bus?.publish({ type: "delete", keys: [key] });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async delete(key: string): Promise<boolean> {
|
|
142
|
+
const l1 = await this.#l1.delete(key);
|
|
143
|
+
const l2 = await this.#l2.delete(key);
|
|
144
|
+
await this.#bus?.publish({ type: "delete", keys: [key] });
|
|
145
|
+
return l1 || l2;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async flush(): Promise<void> {
|
|
149
|
+
await this.#l1.flush();
|
|
150
|
+
await this.#l2.flush();
|
|
151
|
+
await this.#bus?.publish({ type: "clear", keys: [] });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async has(key: string): Promise<boolean> {
|
|
155
|
+
return (await this.get(key)) !== null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async setWithTags(
|
|
159
|
+
key: string,
|
|
160
|
+
value: unknown,
|
|
161
|
+
tags: string[],
|
|
162
|
+
ttlSeconds?: number,
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
await this.setEntry(key, value, { ttlSeconds, tags });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async deleteByTag(tags: string[]): Promise<void> {
|
|
168
|
+
const l1 = this.#l1;
|
|
169
|
+
const l2 = this.#l2;
|
|
170
|
+
if (!isTaggable(l1) || !isTaggable(l2)) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
"Echo: TieredDriver.deleteByTag requires both tiers to be taggable",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
await l1.deleteByTag(tags);
|
|
176
|
+
await l2.deleteByTag(tags);
|
|
177
|
+
// Peers can't map tags → keys locally; broadcast a clear so their L1 drops
|
|
178
|
+
// any tagged copies (conservative but correct).
|
|
179
|
+
await this.#bus?.publish({ type: "clear", keys: [] });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @deprecated alias of {@link deleteByTag}. */
|
|
183
|
+
async flushTags(tags: string[]): Promise<void> {
|
|
184
|
+
return this.deleteByTag(tags);
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/duration.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration parsing — bentocache `Duration` parity, adapted to echo's
|
|
3
|
+
* seconds-native driver layer.
|
|
4
|
+
*
|
|
5
|
+
* DIVERGENCE (named): bentocache treats a bare `number` as **milliseconds**.
|
|
6
|
+
* Echo's drivers, positional API (`set(key, value, ttlSeconds)`) and the
|
|
7
|
+
* kitchen-sink app are all **seconds-native**, so a bare `number` here means
|
|
8
|
+
* **seconds**. Keeping one unit across the positional and object forms avoids
|
|
9
|
+
* a dual-unit footgun (cf. DNR `helpers_no_magic_layer`). String durations use
|
|
10
|
+
* the same human syntax as bento (`'5m'`, `'300s'`, `'2h'`, `'500ms'`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A cache duration:
|
|
15
|
+
* - `number` — seconds (echo-native; see divergence note above)
|
|
16
|
+
* - `string` — human duration (`'5m'`, `'2h'`, `'1d'`, `'500ms'`, …)
|
|
17
|
+
* - `null` — never expires
|
|
18
|
+
*/
|
|
19
|
+
export type Duration = number | string | null;
|
|
20
|
+
|
|
21
|
+
const UNIT_SECONDS: Record<string, number> = {
|
|
22
|
+
ms: 1 / 1000,
|
|
23
|
+
msec: 1 / 1000,
|
|
24
|
+
msecs: 1 / 1000,
|
|
25
|
+
millisecond: 1 / 1000,
|
|
26
|
+
milliseconds: 1 / 1000,
|
|
27
|
+
s: 1,
|
|
28
|
+
sec: 1,
|
|
29
|
+
secs: 1,
|
|
30
|
+
second: 1,
|
|
31
|
+
seconds: 1,
|
|
32
|
+
m: 60,
|
|
33
|
+
min: 60,
|
|
34
|
+
mins: 60,
|
|
35
|
+
minute: 60,
|
|
36
|
+
minutes: 60,
|
|
37
|
+
h: 3600,
|
|
38
|
+
hr: 3600,
|
|
39
|
+
hrs: 3600,
|
|
40
|
+
hour: 3600,
|
|
41
|
+
hours: 3600,
|
|
42
|
+
d: 86_400,
|
|
43
|
+
day: 86_400,
|
|
44
|
+
days: 86_400,
|
|
45
|
+
w: 604_800,
|
|
46
|
+
week: 604_800,
|
|
47
|
+
weeks: 604_800,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const DURATION_RE = /^\s*(-?\d+(?:\.\d+)?)\s*([a-z]+)?\s*$/i;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse a human duration string (`'5m'`, `'300s'`, `'500ms'`) into seconds.
|
|
54
|
+
* A unit-less numeric string is interpreted as seconds.
|
|
55
|
+
*/
|
|
56
|
+
export function parseDuration(value: string): number {
|
|
57
|
+
const match = DURATION_RE.exec(value);
|
|
58
|
+
if (!match) {
|
|
59
|
+
throw new TypeError(`Echo: invalid duration string "${value}"`);
|
|
60
|
+
}
|
|
61
|
+
const amount = Number(match[1]);
|
|
62
|
+
const unit = match[2]?.toLowerCase();
|
|
63
|
+
if (unit === undefined) return amount;
|
|
64
|
+
const factor = UNIT_SECONDS[unit];
|
|
65
|
+
if (factor === undefined) {
|
|
66
|
+
throw new TypeError(`Echo: unknown duration unit "${unit}" in "${value}"`);
|
|
67
|
+
}
|
|
68
|
+
return amount * factor;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a {@link Duration} to a TTL in **seconds**.
|
|
73
|
+
*
|
|
74
|
+
* @returns the TTL in seconds; `0` means "never expires" (either an explicit
|
|
75
|
+
* `null`, or a non-positive value, matching echo's driver convention where
|
|
76
|
+
* `ttlSeconds <= 0` is treated as immortal).
|
|
77
|
+
*/
|
|
78
|
+
export function resolveTtlSeconds(
|
|
79
|
+
ttl: Duration | undefined,
|
|
80
|
+
defaultSeconds: number,
|
|
81
|
+
): number {
|
|
82
|
+
if (ttl === null) return 0;
|
|
83
|
+
if (ttl === undefined) return defaultSeconds;
|
|
84
|
+
const seconds = typeof ttl === "number" ? ttl : parseDuration(ttl);
|
|
85
|
+
return seconds > 0 ? seconds : 0;
|
|
86
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Echo cache errors — bentocache parity (`errors.ts`).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Raised when a factory exceeds its configured `hardTimeout`. */
|
|
6
|
+
export class TimeoutError extends Error {
|
|
7
|
+
constructor(key: string, timeoutMs: number) {
|
|
8
|
+
super(`Echo: factory for "${key}" timed out after ${timeoutMs}ms`);
|
|
9
|
+
this.name = "TimeoutError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wraps an error thrown by a `getOrSet` factory. Passed to `onFactoryError`
|
|
15
|
+
* so callers can observe both foreground and background factory failures.
|
|
16
|
+
*/
|
|
17
|
+
export class FactoryError extends Error {
|
|
18
|
+
/** The cache key the factory was computing. */
|
|
19
|
+
readonly key: string;
|
|
20
|
+
/** The original error thrown by the factory. */
|
|
21
|
+
override readonly cause: unknown;
|
|
22
|
+
/** `true` when the factory was running in the background (soft timeout / refresh). */
|
|
23
|
+
readonly isBackground: boolean;
|
|
24
|
+
|
|
25
|
+
constructor(key: string, cause: unknown, isBackground: boolean) {
|
|
26
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
27
|
+
super(`Echo: factory for "${key}" failed: ${reason}`);
|
|
28
|
+
this.name = "FactoryError";
|
|
29
|
+
this.key = key;
|
|
30
|
+
this.cause = cause;
|
|
31
|
+
this.isBackground = isBackground;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @c9up/echo — Cache layer for the Ream framework.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* (
|
|
4
|
+
* bentocache / @adonisjs/cache parity: object-argument API, grace
|
|
5
|
+
* (stale-while-revalidate), soft/hard timeouts, tags, multi-tier (L1+L2) and
|
|
6
|
+
* multi-store (`{ default, stores }` + `use(name)`) — over pluggable drivers
|
|
7
|
+
* (Memory, Redis, Tiered).
|
|
6
8
|
*
|
|
7
9
|
* @implements MISS-10
|
|
8
10
|
*/
|
|
@@ -12,14 +14,52 @@ export { CacheManager } from "./CacheManager.js";
|
|
|
12
14
|
export { MemoryDriver } from "./drivers/MemoryDriver.js";
|
|
13
15
|
export type { RedisClient } from "./drivers/RedisDriver.js";
|
|
14
16
|
export { RedisDriver } from "./drivers/RedisDriver.js";
|
|
17
|
+
export type {
|
|
18
|
+
BusMessage,
|
|
19
|
+
CacheBus,
|
|
20
|
+
TieredDriverOptions,
|
|
21
|
+
} from "./drivers/TieredDriver.js";
|
|
22
|
+
export { TieredDriver } from "./drivers/TieredDriver.js";
|
|
23
|
+
export type { Duration } from "./duration.js";
|
|
24
|
+
export { parseDuration, resolveTtlSeconds } from "./duration.js";
|
|
15
25
|
export type { EchoProviderConfig } from "./EchoProvider.js";
|
|
26
|
+
export { FactoryError, TimeoutError } from "./errors.js";
|
|
27
|
+
export {
|
|
28
|
+
CacheStoreManager,
|
|
29
|
+
type DriverFactory,
|
|
30
|
+
drivers,
|
|
31
|
+
type MultiStoreConfig,
|
|
32
|
+
type StoreConfig,
|
|
33
|
+
} from "./StoreManager.js";
|
|
34
|
+
export type {
|
|
35
|
+
CacheEmitter,
|
|
36
|
+
CacheEntry,
|
|
37
|
+
CacheEventMap,
|
|
38
|
+
DefaultValue,
|
|
39
|
+
DeleteByTagOptions,
|
|
40
|
+
DeleteManyOptions,
|
|
41
|
+
DeleteOptions,
|
|
42
|
+
ExpireOptions,
|
|
43
|
+
Factory,
|
|
44
|
+
GetOptions,
|
|
45
|
+
GetOrSetForeverOptions,
|
|
46
|
+
GetOrSetOptions,
|
|
47
|
+
HasOptions,
|
|
48
|
+
SetOptions,
|
|
49
|
+
TaggableDriver,
|
|
50
|
+
} from "./types.js";
|
|
16
51
|
|
|
17
52
|
import type { EchoProviderConfig } from "./EchoProvider.js";
|
|
53
|
+
import type { MultiStoreConfig } from "./StoreManager.js";
|
|
18
54
|
|
|
19
55
|
/**
|
|
20
56
|
* Author-time config helper for `config/cache.ts` — AdonisJS cache `defineConfig`
|
|
21
57
|
* parity. Identity at runtime; the generic preserves literal types for inference.
|
|
58
|
+
* Accepts both the single-store {@link EchoProviderConfig} and the multi-store
|
|
59
|
+
* {@link MultiStoreConfig} (`{ default, stores }`) shapes.
|
|
22
60
|
*/
|
|
23
|
-
export function defineConfig<T extends EchoProviderConfig>(
|
|
61
|
+
export function defineConfig<T extends EchoProviderConfig | MultiStoreConfig>(
|
|
62
|
+
config: T,
|
|
63
|
+
): T {
|
|
24
64
|
return config;
|
|
25
65
|
}
|