@c9up/echo 0.1.3

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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Memory cache driver — in-process Map with TTL support.
3
+ * Suitable for development and single-process deployments.
4
+ */
5
+
6
+ import type { CacheDriver } from "../CacheManager.js";
7
+
8
+ interface CacheEntry {
9
+ value: unknown;
10
+ expiresAt: number;
11
+ tags: string[];
12
+ }
13
+
14
+ export class MemoryDriver implements CacheDriver {
15
+ #store: Map<string, CacheEntry> = new Map();
16
+ #tagIndex: Map<string, Set<string>> = new Map();
17
+ #sweepInterval: ReturnType<typeof setInterval>;
18
+
19
+ constructor(sweepIntervalMs = 60_000) {
20
+ this.#sweepInterval = setInterval(() => {
21
+ const now = Date.now();
22
+ for (const [key, entry] of this.#store) {
23
+ if (entry.expiresAt > 0 && entry.expiresAt < now) {
24
+ this.#store.delete(key);
25
+ }
26
+ }
27
+ }, sweepIntervalMs);
28
+ if (
29
+ typeof this.#sweepInterval === "object" &&
30
+ "unref" in this.#sweepInterval
31
+ ) {
32
+ (this.#sweepInterval as { unref(): void }).unref();
33
+ }
34
+ }
35
+
36
+ destroy(): void {
37
+ clearInterval(this.#sweepInterval);
38
+ }
39
+
40
+ async get<T = unknown>(key: string): Promise<T | null> {
41
+ const entry = this.#store.get(key);
42
+ if (!entry) return null;
43
+ if (entry.expiresAt > 0 && entry.expiresAt < Date.now()) {
44
+ this.#store.delete(key);
45
+ return null;
46
+ }
47
+ return entry.value as T;
48
+ }
49
+
50
+ async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
51
+ if (value === null || value === undefined) {
52
+ throw new TypeError(
53
+ "Echo: caching null/undefined values is not supported",
54
+ );
55
+ }
56
+ const expiresAt =
57
+ ttlSeconds != null && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : 0;
58
+ this.#store.set(key, { value, expiresAt, tags: [] });
59
+ }
60
+
61
+ async delete(key: string): Promise<boolean> {
62
+ const entry = this.#store.get(key);
63
+ if (entry) {
64
+ for (const tag of entry.tags) {
65
+ this.#tagIndex.get(tag)?.delete(key);
66
+ }
67
+ }
68
+ return this.#store.delete(key);
69
+ }
70
+
71
+ async flush(): Promise<void> {
72
+ this.#store.clear();
73
+ this.#tagIndex.clear();
74
+ }
75
+
76
+ async has(key: string): Promise<boolean> {
77
+ const val = await this.get(key);
78
+ return val !== null;
79
+ }
80
+
81
+ /** Set with tags for group invalidation. */
82
+ async setWithTags(
83
+ key: string,
84
+ value: unknown,
85
+ tags: string[],
86
+ ttlSeconds?: number,
87
+ ): Promise<void> {
88
+ // Audit 2026-05-22 F4: align with `set()` — both paths now treat any
89
+ // `ttlSeconds <= 0` (and undefined) as "no expiration". Previously
90
+ // `setWithTags` used a truthy check (`ttlSeconds ?`), which let a
91
+ // negative value through and produced `Date.now() + (-N * 1000)` —
92
+ // an already-past timestamp — so the entry was born already-expired.
93
+ // `set()` correctly returned the immortal-entry branch on the same
94
+ // input. The divergence made cache semantics depend on whether the
95
+ // caller used tags or not, which is the worst kind of "very hard to
96
+ // diagnose in prod" bug.
97
+ const expiresAt =
98
+ ttlSeconds != null && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : 0;
99
+ // Audit 2026-05-22 F3 (overwrite leg): if `key` already exists with
100
+ // a different tag set, the old #tagIndex entries become dangling
101
+ // refs to the (now overwritten) key. Clean them up before re-tagging
102
+ // so subsequent flushTags doesn't iterate stale references.
103
+ const prev = this.#store.get(key);
104
+ if (prev !== undefined) {
105
+ for (const t of prev.tags) this.#tagIndex.get(t)?.delete(key);
106
+ }
107
+ this.#store.set(key, { value, expiresAt, tags });
108
+ for (const tag of tags) {
109
+ let set = this.#tagIndex.get(tag);
110
+ if (!set) {
111
+ set = new Set();
112
+ this.#tagIndex.set(tag, set);
113
+ }
114
+ set.add(key);
115
+ }
116
+ }
117
+
118
+ /** Flush all entries tagged with any of the given tags. */
119
+ async flushTags(tags: string[]): Promise<void> {
120
+ // Audit 2026-05-22 F3: when a key is multi-tagged (e.g. `[news, fr]`)
121
+ // and we flush by `news`, the old code deleted the key from #store
122
+ // and dropped the `news` Set, but `fr`'s Set kept a dangling
123
+ // reference. A later flushTags(`fr`) would iterate over the
124
+ // stale entry, attempt `#store.delete(key)` (no-op), and the entry
125
+ // would silently linger in #tagIndex forever. Worse — if a new
126
+ // entry was later written under the same key with different tags,
127
+ // flushTags(`fr`) would WRONGLY purge it because of the residue.
128
+ // Collect keys first, then scrub each one from EVERY tag set it
129
+ // belonged to (including the tags we're not flushing this round).
130
+ const toDelete = new Set<string>();
131
+ for (const tag of tags) {
132
+ const keys = this.#tagIndex.get(tag);
133
+ if (keys) {
134
+ for (const key of keys) toDelete.add(key);
135
+ }
136
+ }
137
+ for (const key of toDelete) {
138
+ const entry = this.#store.get(key);
139
+ if (entry) {
140
+ for (const t of entry.tags) {
141
+ this.#tagIndex.get(t)?.delete(key);
142
+ }
143
+ }
144
+ this.#store.delete(key);
145
+ }
146
+ // Now drop the flushed tag sets themselves (any other keys still
147
+ // referenced by them have been processed in the toDelete loop and
148
+ // already removed via the inner scrub).
149
+ for (const tag of tags) this.#tagIndex.delete(tag);
150
+ }
151
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Redis cache driver — production-grade cache with TTL and tags.
3
+ *
4
+ * Requires a Redis client instance implementing the minimal interface below.
5
+ * Compatible with ioredis and redis (node-redis) clients.
6
+ *
7
+ * @implements MISS-10
8
+ */
9
+
10
+ import type { CacheDriver } from "../CacheManager.js";
11
+
12
+ /** Minimal Redis client interface — compatible with ioredis and node-redis. */
13
+ export interface RedisClient {
14
+ get(key: string): Promise<string | null>;
15
+ set(key: string, value: string, ...args: unknown[]): Promise<unknown>;
16
+ del(key: string | string[]): Promise<number>;
17
+ exists(key: string): Promise<number>;
18
+ keys(pattern: string): Promise<string[]>;
19
+ sadd(key: string, ...members: string[]): Promise<number>;
20
+ srem(key: string, ...members: string[]): Promise<number>;
21
+ smembers(key: string): Promise<string[]>;
22
+ expire(key: string, seconds: number): Promise<number>;
23
+ ttl(key: string): Promise<number>;
24
+ scan?(
25
+ cursor: string,
26
+ matchOption: "MATCH",
27
+ pattern: string,
28
+ countOption: "COUNT",
29
+ count: number,
30
+ ): Promise<[string, string[]]>;
31
+ }
32
+
33
+ export class RedisDriver implements CacheDriver {
34
+ #client: RedisClient;
35
+ #prefix: string;
36
+
37
+ constructor(client: RedisClient, prefix = "cache:") {
38
+ this.#client = client;
39
+ this.#prefix = prefix;
40
+ }
41
+
42
+ #key(k: string): string {
43
+ return `${this.#prefix}${k}`;
44
+ }
45
+
46
+ /**
47
+ * Reverse-index for per-key tag membership. Lets `setWithTags()` clean
48
+ * stale memberships when a key is retagged, and `delete()` drop the key
49
+ * from every tag-set it belongs to. Without this, a re-tag like
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.
53
+ */
54
+ #metaKey(k: string): string {
55
+ return `${this.#prefix}meta:tags:${k}`;
56
+ }
57
+
58
+ async delete(key: string): Promise<boolean> {
59
+ const fullKey = this.#key(key);
60
+ const metaKey = this.#metaKey(key);
61
+ const tags = await this.#client.smembers(metaKey);
62
+ for (const tag of tags) {
63
+ const tagKey = `${this.#prefix}tag:${tag}`;
64
+ await this.#client.srem(tagKey, fullKey);
65
+ }
66
+ if (tags.length > 0) {
67
+ await this.#client.del(metaKey);
68
+ }
69
+ const count = await this.#client.del(fullKey);
70
+ return count > 0;
71
+ }
72
+
73
+ async get<T = unknown>(key: string): Promise<T | null> {
74
+ const raw = await this.#client.get(this.#key(key));
75
+ if (raw === null) return null;
76
+ return JSON.parse(raw) as T;
77
+ }
78
+
79
+ async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
80
+ const serialized = JSON.stringify(value);
81
+ if (ttlSeconds && ttlSeconds > 0) {
82
+ await this.#client.set(this.#key(key), serialized, "EX", ttlSeconds);
83
+ } else {
84
+ await this.#client.set(this.#key(key), serialized);
85
+ }
86
+ }
87
+
88
+ async flush(): Promise<void> {
89
+ const scan = this.#client.scan;
90
+ if (typeof scan === "function") {
91
+ let cursor = "0";
92
+ do {
93
+ const [nextCursor, keys] = await scan(
94
+ cursor,
95
+ "MATCH",
96
+ `${this.#prefix}*`,
97
+ "COUNT",
98
+ 100,
99
+ );
100
+ cursor = nextCursor;
101
+ if (keys.length > 0) {
102
+ await this.#client.del(keys);
103
+ }
104
+ } while (cursor !== "0");
105
+ } else {
106
+ throw new Error(
107
+ "Echo: RedisDriver.flush() requires a client with scan() support. KEYS is not safe for production use.",
108
+ );
109
+ }
110
+ }
111
+
112
+ async has(key: string): Promise<boolean> {
113
+ const exists = await this.#client.exists(this.#key(key));
114
+ return exists > 0;
115
+ }
116
+
117
+ /**
118
+ * Set a value with tag memberships for group invalidation.
119
+ *
120
+ * Re-tagging an existing key (e.g. `['news']` → `['homepage']`) cleans
121
+ * the stale memberships via the per-key reverse-index — without this,
122
+ * a later `flushTags(['news'])` would silently wipe the still-current
123
+ * value because the abandoned `tag:news` set kept pointing at it.
124
+ */
125
+ async setWithTags(
126
+ key: string,
127
+ value: unknown,
128
+ tags: string[],
129
+ ttlSeconds?: number,
130
+ ): Promise<void> {
131
+ await this.set(key, value, ttlSeconds);
132
+ const fullKey = this.#key(key);
133
+ const metaKey = this.#metaKey(key);
134
+ const oldTags = await this.#client.smembers(metaKey);
135
+ const newTagSet = new Set(tags);
136
+ const oldTagSet = new Set(oldTags);
137
+ const removedTags = oldTags.filter((t) => !newTagSet.has(t));
138
+ const addedTags = tags.filter((t) => !oldTagSet.has(t));
139
+
140
+ // Drop the key from tag-sets it no longer belongs to.
141
+ for (const tag of removedTags) {
142
+ const tagKey = `${this.#prefix}tag:${tag}`;
143
+ await this.#client.srem(tagKey, fullKey);
144
+ }
145
+
146
+ // Add to new tag-sets; re-touch TTLs on every declared tag so existing
147
+ // memberships extend correctly on re-set.
148
+ for (const tag of tags) {
149
+ const tagKey = `${this.#prefix}tag:${tag}`;
150
+ if (addedTags.includes(tag)) {
151
+ await this.#client.sadd(tagKey, fullKey);
152
+ }
153
+ if (ttlSeconds && ttlSeconds > 0) {
154
+ const currentTtl = await this.#client.ttl(tagKey);
155
+ if (currentTtl < 0 || ttlSeconds > currentTtl) {
156
+ await this.#client.expire(tagKey, ttlSeconds);
157
+ }
158
+ }
159
+ }
160
+
161
+ // Refresh the reverse-index to match the new tag set. Drop+re-add is
162
+ // simpler than diff-mutating the set and matches `tags` exactly even
163
+ // in the empty-array case.
164
+ if (oldTags.length > 0) {
165
+ await this.#client.del(metaKey);
166
+ }
167
+ if (tags.length > 0) {
168
+ await this.#client.sadd(metaKey, ...tags);
169
+ if (ttlSeconds && ttlSeconds > 0) {
170
+ await this.#client.expire(metaKey, ttlSeconds);
171
+ }
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Flush all entries tagged with any of the given tags. Cleans up the
177
+ * per-key reverse-index AND cross-tag memberships so a multi-tag key
178
+ * (e.g. `['news', 'homepage']`) flushed via `news` is also removed from
179
+ * the `homepage` tag-set — otherwise the `homepage` set ends up with a
180
+ * dangling reference to a now-deleted value.
181
+ */
182
+ async flushTags(tags: string[]): Promise<void> {
183
+ for (const tag of tags) {
184
+ const tagKey = `${this.#prefix}tag:${tag}`;
185
+ const members = await this.#client.smembers(tagKey);
186
+ for (const fullKey of members) {
187
+ // Read every OTHER tag this key claims (via the reverse-index)
188
+ // and SREM the key from each. The reverse-index key derives
189
+ // from the unprefixed user key — recover it by stripping the
190
+ // prefix.
191
+ const userKey = fullKey.startsWith(this.#prefix)
192
+ ? fullKey.slice(this.#prefix.length)
193
+ : fullKey;
194
+ const metaKey = this.#metaKey(userKey);
195
+ const allTags = await this.#client.smembers(metaKey);
196
+ for (const otherTag of allTags) {
197
+ if (otherTag === tag) continue;
198
+ const otherTagKey = `${this.#prefix}tag:${otherTag}`;
199
+ await this.#client.srem(otherTagKey, fullKey);
200
+ }
201
+ if (allTags.length > 0) {
202
+ await this.#client.del(metaKey);
203
+ }
204
+ }
205
+ if (members.length > 0) {
206
+ await this.#client.del(members);
207
+ }
208
+ await this.#client.del(tagKey);
209
+ }
210
+ }
211
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @c9up/echo — Cache layer for the Ream framework.
3
+ *
4
+ * Provides get/set/delete/flush/tags with pluggable drivers (Memory, Redis).
5
+ *
6
+ * @implements MISS-10
7
+ */
8
+
9
+ export type { CacheConfig, CacheDriver } from "./CacheManager.js";
10
+ export { CacheManager } from "./CacheManager.js";
11
+ export { MemoryDriver } from "./drivers/MemoryDriver.js";
12
+ export type { RedisClient } from "./drivers/RedisDriver.js";
13
+ export { RedisDriver } from "./drivers/RedisDriver.js";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Default `CacheManager` singleton — mirror of Adonis's
3
+ * `import cache from '@adonisjs/cache/services/main'` shape.
4
+ *
5
+ * Populated by `EchoProvider.boot()` or by the app directly through
6
+ * `setCache(myManager)` when the cache wiring needs custom config.
7
+ *
8
+ * import cache from '@c9up/echo/services/main'
9
+ *
10
+ * await cache.set('user:42', user, 60)
11
+ * const cached = await cache.get<User>('user:42')
12
+ */
13
+
14
+ import type { CacheManager } from "../CacheManager.js";
15
+
16
+ let instance: CacheManager | undefined;
17
+
18
+ /** @internal Bind the singleton (called by EchoProvider or by the app). */
19
+ export function setCache(value: CacheManager): void {
20
+ instance = value;
21
+ }
22
+
23
+ /** @internal Read the singleton (or `undefined` pre-boot). */
24
+ export function getCache(): CacheManager | undefined {
25
+ return instance;
26
+ }
27
+
28
+ const cache: CacheManager = new Proxy({} as CacheManager, {
29
+ get(_target, prop) {
30
+ if (!instance) {
31
+ throw new Error(
32
+ "[echo] CacheManager singleton accessed before EchoProvider.boot() ran " +
33
+ "or `setCache(myCache)` was called. Wire one of them first.",
34
+ );
35
+ }
36
+ const value = Reflect.get(instance, prop, instance);
37
+ return typeof value === "function" ? value.bind(instance) : value;
38
+ },
39
+ });
40
+
41
+ export default cache;