@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,166 @@
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
+ export class RedisDriver {
10
+ #client;
11
+ #prefix;
12
+ constructor(client, prefix = "cache:") {
13
+ this.#client = client;
14
+ this.#prefix = prefix;
15
+ }
16
+ #key(k) {
17
+ return `${this.#prefix}${k}`;
18
+ }
19
+ /**
20
+ * Reverse-index for per-key tag membership. Lets `setWithTags()` clean
21
+ * stale memberships when a key is retagged, and `delete()` drop the key
22
+ * from every tag-set it belongs to. Without this, a re-tag like
23
+ * `setWithTags('article:42', v, ['homepage'])` (was `['news']`) leaves
24
+ * `tag:news` pointing at `article:42`, and a later `flushTags(['news'])`
25
+ * silently deletes the value.
26
+ */
27
+ #metaKey(k) {
28
+ return `${this.#prefix}meta:tags:${k}`;
29
+ }
30
+ async delete(key) {
31
+ const fullKey = this.#key(key);
32
+ const metaKey = this.#metaKey(key);
33
+ const tags = await this.#client.smembers(metaKey);
34
+ for (const tag of tags) {
35
+ const tagKey = `${this.#prefix}tag:${tag}`;
36
+ await this.#client.srem(tagKey, fullKey);
37
+ }
38
+ if (tags.length > 0) {
39
+ await this.#client.del(metaKey);
40
+ }
41
+ const count = await this.#client.del(fullKey);
42
+ return count > 0;
43
+ }
44
+ async get(key) {
45
+ const raw = await this.#client.get(this.#key(key));
46
+ if (raw === null)
47
+ return null;
48
+ return JSON.parse(raw);
49
+ }
50
+ async set(key, value, ttlSeconds) {
51
+ const serialized = JSON.stringify(value);
52
+ if (ttlSeconds && ttlSeconds > 0) {
53
+ await this.#client.set(this.#key(key), serialized, "EX", ttlSeconds);
54
+ }
55
+ else {
56
+ await this.#client.set(this.#key(key), serialized);
57
+ }
58
+ }
59
+ async flush() {
60
+ const scan = this.#client.scan;
61
+ if (typeof scan === "function") {
62
+ let cursor = "0";
63
+ do {
64
+ const [nextCursor, keys] = await scan(cursor, "MATCH", `${this.#prefix}*`, "COUNT", 100);
65
+ cursor = nextCursor;
66
+ if (keys.length > 0) {
67
+ await this.#client.del(keys);
68
+ }
69
+ } while (cursor !== "0");
70
+ }
71
+ else {
72
+ throw new Error("Echo: RedisDriver.flush() requires a client with scan() support. KEYS is not safe for production use.");
73
+ }
74
+ }
75
+ async has(key) {
76
+ const exists = await this.#client.exists(this.#key(key));
77
+ return exists > 0;
78
+ }
79
+ /**
80
+ * Set a value with tag memberships for group invalidation.
81
+ *
82
+ * Re-tagging an existing key (e.g. `['news']` → `['homepage']`) cleans
83
+ * the stale memberships via the per-key reverse-index — without this,
84
+ * a later `flushTags(['news'])` would silently wipe the still-current
85
+ * value because the abandoned `tag:news` set kept pointing at it.
86
+ */
87
+ async setWithTags(key, value, tags, ttlSeconds) {
88
+ await this.set(key, value, ttlSeconds);
89
+ const fullKey = this.#key(key);
90
+ const metaKey = this.#metaKey(key);
91
+ const oldTags = await this.#client.smembers(metaKey);
92
+ const newTagSet = new Set(tags);
93
+ const oldTagSet = new Set(oldTags);
94
+ const removedTags = oldTags.filter((t) => !newTagSet.has(t));
95
+ const addedTags = tags.filter((t) => !oldTagSet.has(t));
96
+ // Drop the key from tag-sets it no longer belongs to.
97
+ for (const tag of removedTags) {
98
+ const tagKey = `${this.#prefix}tag:${tag}`;
99
+ await this.#client.srem(tagKey, fullKey);
100
+ }
101
+ // Add to new tag-sets; re-touch TTLs on every declared tag so existing
102
+ // memberships extend correctly on re-set.
103
+ for (const tag of tags) {
104
+ const tagKey = `${this.#prefix}tag:${tag}`;
105
+ if (addedTags.includes(tag)) {
106
+ await this.#client.sadd(tagKey, fullKey);
107
+ }
108
+ if (ttlSeconds && ttlSeconds > 0) {
109
+ const currentTtl = await this.#client.ttl(tagKey);
110
+ if (currentTtl < 0 || ttlSeconds > currentTtl) {
111
+ await this.#client.expire(tagKey, ttlSeconds);
112
+ }
113
+ }
114
+ }
115
+ // Refresh the reverse-index to match the new tag set. Drop+re-add is
116
+ // simpler than diff-mutating the set and matches `tags` exactly even
117
+ // in the empty-array case.
118
+ if (oldTags.length > 0) {
119
+ await this.#client.del(metaKey);
120
+ }
121
+ if (tags.length > 0) {
122
+ await this.#client.sadd(metaKey, ...tags);
123
+ if (ttlSeconds && ttlSeconds > 0) {
124
+ await this.#client.expire(metaKey, ttlSeconds);
125
+ }
126
+ }
127
+ }
128
+ /**
129
+ * Flush all entries tagged with any of the given tags. Cleans up the
130
+ * per-key reverse-index AND cross-tag memberships so a multi-tag key
131
+ * (e.g. `['news', 'homepage']`) flushed via `news` is also removed from
132
+ * the `homepage` tag-set — otherwise the `homepage` set ends up with a
133
+ * dangling reference to a now-deleted value.
134
+ */
135
+ async flushTags(tags) {
136
+ for (const tag of tags) {
137
+ const tagKey = `${this.#prefix}tag:${tag}`;
138
+ const members = await this.#client.smembers(tagKey);
139
+ for (const fullKey of members) {
140
+ // Read every OTHER tag this key claims (via the reverse-index)
141
+ // and SREM the key from each. The reverse-index key derives
142
+ // from the unprefixed user key — recover it by stripping the
143
+ // prefix.
144
+ const userKey = fullKey.startsWith(this.#prefix)
145
+ ? fullKey.slice(this.#prefix.length)
146
+ : fullKey;
147
+ const metaKey = this.#metaKey(userKey);
148
+ const allTags = await this.#client.smembers(metaKey);
149
+ for (const otherTag of allTags) {
150
+ if (otherTag === tag)
151
+ continue;
152
+ const otherTagKey = `${this.#prefix}tag:${otherTag}`;
153
+ await this.#client.srem(otherTagKey, fullKey);
154
+ }
155
+ if (allTags.length > 0) {
156
+ await this.#client.del(metaKey);
157
+ }
158
+ }
159
+ if (members.length > 0) {
160
+ await this.#client.del(members);
161
+ }
162
+ await this.#client.del(tagKey);
163
+ }
164
+ }
165
+ }
166
+ //# sourceMappingURL=RedisDriver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RedisDriver.js","sourceRoot":"","sources":["../../src/drivers/RedisDriver.ts"],"names":[],"mappings":"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"}
@@ -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
+ export type { CacheConfig, CacheDriver } from "./CacheManager.js";
9
+ export { CacheManager } from "./CacheManager.js";
10
+ export { MemoryDriver } from "./drivers/MemoryDriver.js";
11
+ export type { RedisClient } from "./drivers/RedisDriver.js";
12
+ export { RedisDriver } from "./drivers/RedisDriver.js";
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,YAAY,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
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
+ export { CacheManager } from "./CacheManager.js";
9
+ export { MemoryDriver } from "./drivers/MemoryDriver.js";
10
+ export { RedisDriver } from "./drivers/RedisDriver.js";
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAEzD,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC"}
@@ -0,0 +1,20 @@
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
+ import type { CacheManager } from "../CacheManager.js";
14
+ /** @internal Bind the singleton (called by EchoProvider or by the app). */
15
+ export declare function setCache(value: CacheManager): void;
16
+ /** @internal Read the singleton (or `undefined` pre-boot). */
17
+ export declare function getCache(): CacheManager | undefined;
18
+ declare const cache: CacheManager;
19
+ export default cache;
20
+ //# sourceMappingURL=main.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/services/main.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAIvD,2EAA2E;AAC3E,wBAAgB,QAAQ,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAElD;AAED,8DAA8D;AAC9D,wBAAgB,QAAQ,IAAI,YAAY,GAAG,SAAS,CAEnD;AAED,QAAA,MAAM,KAAK,EAAE,YAWX,CAAC;AAEH,eAAe,KAAK,CAAC"}
@@ -0,0 +1,33 @@
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
+ let instance;
14
+ /** @internal Bind the singleton (called by EchoProvider or by the app). */
15
+ export function setCache(value) {
16
+ instance = value;
17
+ }
18
+ /** @internal Read the singleton (or `undefined` pre-boot). */
19
+ export function getCache() {
20
+ return instance;
21
+ }
22
+ const cache = new Proxy({}, {
23
+ get(_target, prop) {
24
+ if (!instance) {
25
+ throw new Error("[echo] CacheManager singleton accessed before EchoProvider.boot() ran " +
26
+ "or `setCache(myCache)` was called. Wire one of them first.");
27
+ }
28
+ const value = Reflect.get(instance, prop, instance);
29
+ return typeof value === "function" ? value.bind(instance) : value;
30
+ },
31
+ });
32
+ export default cache;
33
+ //# sourceMappingURL=main.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.js","sourceRoot":"","sources":["../../src/services/main.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,IAAI,QAAkC,CAAC;AAEvC,2EAA2E;AAC3E,MAAM,UAAU,QAAQ,CAAC,KAAmB;IAC3C,QAAQ,GAAG,KAAK,CAAC;AAClB,CAAC;AAED,8DAA8D;AAC9D,MAAM,UAAU,QAAQ;IACvB,OAAO,QAAQ,CAAC;AACjB,CAAC;AAED,MAAM,KAAK,GAAiB,IAAI,KAAK,CAAC,EAAkB,EAAE;IACzD,GAAG,CAAC,OAAO,EAAE,IAAI;QAChB,IAAI,CAAC,QAAQ,EAAE,CAAC;YACf,MAAM,IAAI,KAAK,CACd,wEAAwE;gBACvE,4DAA4D,CAC7D,CAAC;QACH,CAAC;QACD,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,EAAE,QAAQ,CAAC,CAAC;QACpD,OAAO,OAAO,KAAK,KAAK,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;IACnE,CAAC;CACD,CAAC,CAAC;AAEH,eAAe,KAAK,CAAC"}
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@c9up/echo",
3
+ "version": "0.1.3",
4
+ "description": "Cache — pluggable cache contract with memory + Redis drivers for the Ream framework",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./provider": {
15
+ "types": "./dist/EchoProvider.d.ts",
16
+ "import": "./dist/EchoProvider.js"
17
+ },
18
+ "./services/main": {
19
+ "types": "./dist/services/main.d.ts",
20
+ "import": "./dist/services/main.js"
21
+ }
22
+ },
23
+ "peerDependencies": {
24
+ "@c9up/ream": "^0.1.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.19.15",
28
+ "typescript": "^6.0.2",
29
+ "vitest": "^4.1.2"
30
+ },
31
+ "engines": {
32
+ "node": ">=22.0.0"
33
+ },
34
+ "files": [
35
+ "src",
36
+ "dist",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@c9up/ream": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/C9up/echo.git"
51
+ },
52
+ "scripts": {
53
+ "build": "tsc -p tsconfig.build.json",
54
+ "test": "vitest run",
55
+ "lint": "biome check src/",
56
+ "test:coverage": "vitest run --coverage",
57
+ "typecheck": "tsc --noEmit"
58
+ }
59
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * CacheManager — unified cache API with driver abstraction.
3
+ */
4
+
5
+ export interface CacheDriver {
6
+ get<T = unknown>(key: string): Promise<T | null>;
7
+ set(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
8
+ delete(key: string): Promise<boolean>;
9
+ flush(): Promise<void>;
10
+ has(key: string): Promise<boolean>;
11
+ }
12
+
13
+ interface TaggableDriver extends CacheDriver {
14
+ setWithTags(
15
+ key: string,
16
+ value: unknown,
17
+ tags: string[],
18
+ ttlSeconds?: number,
19
+ ): Promise<void>;
20
+ flushTags(tags: string[]): Promise<void>;
21
+ }
22
+
23
+ function isTaggableDriver(driver: CacheDriver): driver is TaggableDriver {
24
+ const candidate = driver as Partial<TaggableDriver>;
25
+ return (
26
+ typeof candidate.flushTags === "function" &&
27
+ typeof candidate.setWithTags === "function"
28
+ );
29
+ }
30
+
31
+ export interface CacheConfig {
32
+ driver?: string;
33
+ prefix?: string;
34
+ ttl?: number;
35
+ }
36
+
37
+ export class CacheManager implements CacheDriver {
38
+ private driver: CacheDriver;
39
+ private prefix: string;
40
+ private defaultTtl: number;
41
+
42
+ constructor(driver: CacheDriver, config?: CacheConfig) {
43
+ this.driver = driver;
44
+ this.prefix = config?.prefix ?? "";
45
+ this.defaultTtl = config?.ttl ?? 3600;
46
+ }
47
+
48
+ private prefixKey(key: string): string {
49
+ return this.prefix ? `${this.prefix}:${key}` : key;
50
+ }
51
+
52
+ async get<T = unknown>(key: string): Promise<T | null> {
53
+ return this.driver.get<T>(this.prefixKey(key));
54
+ }
55
+
56
+ async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
57
+ if (value === null || value === undefined) {
58
+ throw new TypeError(
59
+ "Echo: caching null/undefined values is not supported",
60
+ );
61
+ }
62
+ return this.driver.set(
63
+ this.prefixKey(key),
64
+ value,
65
+ ttlSeconds ?? this.defaultTtl,
66
+ );
67
+ }
68
+
69
+ async delete(key: string): Promise<boolean> {
70
+ return this.driver.delete(this.prefixKey(key));
71
+ }
72
+
73
+ async flush(): Promise<void> {
74
+ return this.driver.flush();
75
+ }
76
+
77
+ async has(key: string): Promise<boolean> {
78
+ return this.driver.has(this.prefixKey(key));
79
+ }
80
+
81
+ /** Set a value with tags for grouped invalidation. */
82
+ async setWithTags(
83
+ key: string,
84
+ value: unknown,
85
+ tags: string[],
86
+ ttlSeconds?: number,
87
+ ): Promise<void> {
88
+ if (value === null || value === undefined) {
89
+ throw new TypeError(
90
+ "Echo: caching null/undefined values is not supported",
91
+ );
92
+ }
93
+ if (!isTaggableDriver(this.driver)) {
94
+ throw new Error(
95
+ "Echo: the configured driver does not support tag-based invalidation",
96
+ );
97
+ }
98
+ return this.driver.setWithTags(
99
+ this.prefixKey(key),
100
+ value,
101
+ tags,
102
+ ttlSeconds ?? this.defaultTtl,
103
+ );
104
+ }
105
+
106
+ /** Flush only entries with matching tags. */
107
+ async flushTags(tags: string[]): Promise<void> {
108
+ if (isTaggableDriver(this.driver)) {
109
+ return this.driver.flushTags(tags);
110
+ }
111
+ throw new Error(
112
+ "Echo: the configured driver does not support tag-based invalidation",
113
+ );
114
+ }
115
+
116
+ /** In-flight promises for stampede prevention. Each factory is typed per-call; the map is keyed by prefixed cache key. */
117
+ private inflight: Map<string, Promise<unknown>> = new Map();
118
+
119
+ /** Get or set — fetch from cache, or compute and store. Single-flight: concurrent misses share one factory call. */
120
+ async remember<T>(
121
+ key: string,
122
+ ttl: number,
123
+ factory: () => Promise<T>,
124
+ ): Promise<T> {
125
+ const prefixed = this.prefixKey(key);
126
+
127
+ const existing = this.inflight.get(prefixed);
128
+ if (existing) return existing.then((v) => v as T);
129
+
130
+ const cached = await this.get<T>(key);
131
+ if (cached !== null) return cached;
132
+
133
+ const existingAfterAwait = this.inflight.get(prefixed);
134
+ if (existingAfterAwait) return existingAfterAwait.then((v) => v as T);
135
+
136
+ const promise: Promise<T> = factory()
137
+ .then(async (value) => {
138
+ await this.set(key, value, ttl);
139
+ this.inflight.delete(prefixed);
140
+ return value;
141
+ })
142
+ .catch((err) => {
143
+ this.inflight.delete(prefixed);
144
+ throw err;
145
+ });
146
+
147
+ this.inflight.set(prefixed, promise);
148
+ return promise;
149
+ }
150
+ }
@@ -0,0 +1,74 @@
1
+ import { type CacheConfig, CacheManager } from "./CacheManager.js";
2
+ import { MemoryDriver } from "./drivers/MemoryDriver.js";
3
+ import { setCache } from "./services/main.js";
4
+
5
+ /**
6
+ * Duck-typed host context — echo stays publishable without importing
7
+ * `@c9up/ream`. Any framework that exposes a Container + a config
8
+ * store satisfies the contract.
9
+ */
10
+ interface EchoContainer {
11
+ singleton(token: unknown, factory: () => unknown): void;
12
+ resolve<T = unknown>(token: unknown): T;
13
+ }
14
+ interface EchoConfigStore {
15
+ get<T = unknown>(key: string): T | undefined;
16
+ }
17
+ export interface EchoAppContext {
18
+ container: EchoContainer;
19
+ config: EchoConfigStore;
20
+ }
21
+
22
+ export interface EchoProviderConfig extends CacheConfig {
23
+ /**
24
+ * Driver to bind by default. Only `"memory"` is created
25
+ * automatically — other drivers (Redis etc.) need custom client
26
+ * wiring, so apps build the `CacheManager` themselves and call
27
+ * `setCache(...)` from `@c9up/echo/services/main`.
28
+ *
29
+ * Default `"memory"`.
30
+ */
31
+ driver?: "memory";
32
+ }
33
+
34
+ /**
35
+ * EchoProvider — registers a default in-memory `CacheManager` so apps
36
+ * that don't need Redis can `import cache from '@c9up/echo/services/main'`
37
+ * and `await cache.get(...)` straight away.
38
+ *
39
+ * // reamrc.ts
40
+ * providers: [() => import('@c9up/echo/provider')]
41
+ *
42
+ * // config/cache.ts
43
+ * export default { driver: 'memory', prefix: 'myapp', ttl: 300 }
44
+ *
45
+ * // anywhere
46
+ * import cache from '@c9up/echo/services/main'
47
+ * await cache.set('k', v, 60)
48
+ */
49
+ export default class EchoProvider {
50
+ constructor(protected app: EchoAppContext) {}
51
+
52
+ register(): void {
53
+ this.app.container.singleton(CacheManager, () => {
54
+ const config = this.app.config.get<EchoProviderConfig>("cache");
55
+ const driver = config?.driver ?? "memory";
56
+ if (driver !== "memory") {
57
+ throw new Error(
58
+ `[echo] Unsupported driver '${driver}' for default provider — ` +
59
+ "wire CacheManager yourself for non-memory drivers.",
60
+ );
61
+ }
62
+ return new CacheManager(new MemoryDriver(), config);
63
+ });
64
+ this.app.container.singleton("cache", () =>
65
+ this.app.container.resolve<CacheManager>(CacheManager),
66
+ );
67
+ }
68
+
69
+ async boot(): Promise<void> {
70
+ setCache(this.app.container.resolve<CacheManager>(CacheManager));
71
+ }
72
+
73
+ async shutdown(): Promise<void> {}
74
+ }