@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.
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/dist/CacheManager.d.ts +36 -0
- package/dist/CacheManager.d.ts.map +1 -0
- package/dist/CacheManager.js +84 -0
- package/dist/CacheManager.js.map +1 -0
- package/dist/EchoProvider.d.ts +52 -0
- package/dist/EchoProvider.d.ts.map +1 -0
- package/dist/EchoProvider.js +41 -0
- package/dist/EchoProvider.js.map +1 -0
- package/dist/drivers/MemoryDriver.d.ts +20 -0
- package/dist/drivers/MemoryDriver.d.ts.map +1 -0
- package/dist/drivers/MemoryDriver.js +127 -0
- package/dist/drivers/MemoryDriver.js.map +1 -0
- package/dist/drivers/RedisDriver.d.ts +50 -0
- package/dist/drivers/RedisDriver.d.ts.map +1 -0
- package/dist/drivers/RedisDriver.js +166 -0
- package/dist/drivers/RedisDriver.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/services/main.d.ts +20 -0
- package/dist/services/main.d.ts.map +1 -0
- package/dist/services/main.js +33 -0
- package/dist/services/main.js.map +1 -0
- package/package.json +59 -0
- package/src/CacheManager.ts +150 -0
- package/src/EchoProvider.ts +74 -0
- package/src/drivers/MemoryDriver.ts +151 -0
- package/src/drivers/RedisDriver.ts +211 -0
- package/src/index.ts +13 -0
- package/src/services/main.ts +41 -0
|
@@ -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;
|