bunsane 0.3.2 → 0.5.0
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/CHANGELOG.md +471 -370
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +93 -1020
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +94 -85
- package/core/RequestLoaders.ts +98 -5
- package/core/SchedulerManager.ts +28 -600
- package/core/app/cors.ts +2 -11
- package/core/app/preparedStatementWarmup.ts +9 -49
- package/core/app/requestRouter.ts +9 -8
- package/core/app/restRegistry.ts +8 -0
- package/core/archetype/fieldResolvers.ts +85 -40
- package/core/archetype/relationLoader.ts +135 -92
- package/core/cache/CacheManager.ts +91 -302
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +8 -7
- package/core/cache/health.ts +30 -0
- package/core/cache/invalidation.ts +96 -0
- package/core/cache/strategies/writeInvalidate.ts +111 -0
- package/core/cache/strategies/writeThrough.ts +233 -0
- package/core/components/BaseComponent.ts +25 -10
- package/core/components/ComponentRegistry.ts +28 -0
- package/core/decorators/IndexedField.ts +1 -1
- package/core/entity/cacheStrategies.ts +97 -0
- package/core/entity/componentAccess.ts +383 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/getCacheManager.ts +10 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +375 -0
- package/core/health.ts +93 -4
- package/core/hooks/dispatcher.ts +439 -0
- package/core/hooks/guards.ts +155 -0
- package/core/hooks/registry.ts +247 -0
- package/core/metadata/definitions/Component.ts +1 -1
- package/core/metadata/index.ts +15 -4
- package/core/middleware/RateLimit.ts +102 -105
- package/core/middleware/RequestId.ts +2 -9
- package/core/middleware/SecurityHeaders.ts +2 -11
- package/core/middleware/headers.ts +28 -0
- package/core/remote/OutboxWorker.ts +213 -183
- package/core/remote/RemoteManager.ts +401 -400
- package/core/remote/StreamConsumer.ts +535 -535
- package/core/remote/types.ts +153 -151
- package/core/requestScope.ts +34 -0
- package/core/scheduler/cronEvaluator.ts +174 -0
- package/core/scheduler/lifecycleHooks.ts +21 -0
- package/core/scheduler/lockCoordinator.ts +27 -0
- package/core/scheduler/metrics.ts +14 -0
- package/core/scheduler/taskRunner.ts +420 -0
- package/core/validateEnv.ts +10 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +8 -2
- package/database/cancellable.ts +35 -22
- package/database/index.ts +29 -3
- package/database/instrumentedDb.ts +141 -141
- package/database/sqlHelpers.ts +3 -1
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/schema/index.ts +15 -4
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +245 -14
- package/query/OrNode.ts +8 -19
- package/query/Query.ts +208 -79
- package/query/QueryContext.ts +6 -0
- package/query/QueryDAG.ts +7 -2
- package/query/membershipSource.ts +66 -0
- package/storage/LocalStorageProvider.ts +8 -3
- package/studio/dist/assets/index-BMZ67Npg.js +254 -0
- package/studio/dist/assets/index-BpbuYz9g.css +1 -0
- package/studio/{index.html → dist/index.html} +3 -2
- package/swagger/generator.ts +11 -1
- package/upload/UploadManager.ts +8 -6
- package/utils/uuid.ts +40 -10
- package/.claude/scheduled_tasks.lock +0 -1
- package/.claude/settings.local.json +0 -47
- package/.prettierrc +0 -4
- package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
- package/.serena/memories/architecture.md +0 -154
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
- package/.serena/memories/code_style_and_conventions.md +0 -76
- package/.serena/memories/project_overview.md +0 -43
- package/.serena/memories/schema-dsl-plan.md +0 -107
- package/.serena/memories/suggested_commands.md +0 -80
- package/.serena/memories/typescript-compilation-status.md +0 -54
- package/.serena/project.yml +0 -114
- package/BunSane.jpg +0 -0
- package/CLAUDE.md +0 -198
- package/TODO.md +0 -2
- package/bun.lock +0 -302
- package/bunfig.toml +0 -10
- package/docs/RFC_APP_REFACTOR.md +0 -248
- package/docs/RFC_REFACTOR_TARGETS.md +0 -251
- package/docs/SCALABILITY_PLAN.md +0 -175
- package/studio/bun.lock +0 -482
- package/studio/package.json +0 -39
- package/studio/postcss.config.js +0 -6
- package/studio/src/components/DataTable.tsx +0 -211
- package/studio/src/components/Layout.tsx +0 -13
- package/studio/src/components/PageContainer.tsx +0 -9
- package/studio/src/components/PageHeader.tsx +0 -13
- package/studio/src/components/SearchBar.tsx +0 -57
- package/studio/src/components/Sidebar.tsx +0 -294
- package/studio/src/components/ui/button.tsx +0 -56
- package/studio/src/components/ui/checkbox.tsx +0 -26
- package/studio/src/components/ui/input.tsx +0 -25
- package/studio/src/hooks/useDataTable.ts +0 -131
- package/studio/src/index.css +0 -36
- package/studio/src/lib/api.ts +0 -186
- package/studio/src/lib/utils.ts +0 -13
- package/studio/src/main.tsx +0 -17
- package/studio/src/pages/ArcheType.tsx +0 -239
- package/studio/src/pages/Components.tsx +0 -124
- package/studio/src/pages/EntityInspector.tsx +0 -302
- package/studio/src/pages/QueryRunner.tsx +0 -246
- package/studio/src/pages/Table.tsx +0 -94
- package/studio/src/pages/Welcome.tsx +0 -241
- package/studio/src/routes.tsx +0 -45
- package/studio/src/store/archeTypeSettings.ts +0 -30
- package/studio/src/store/studio.ts +0 -65
- package/studio/src/utils/columnHelpers.tsx +0 -114
- package/studio/studio-instructions.md +0 -81
- package/studio/tailwind.config.js +0 -77
- package/studio/utils.ts +0 -54
- package/studio/vite.config.js +0 -19
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
- package/tests/benchmark/bunfig.toml +0 -9
- package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
- package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
- package/tests/benchmark/fixtures/index.ts +0 -6
- package/tests/benchmark/index.ts +0 -22
- package/tests/benchmark/noop-preload.ts +0 -3
- package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
- package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
- package/tests/benchmark/runners/index.ts +0 -4
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
- package/tests/benchmark/scripts/generate-db.ts +0 -344
- package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
- package/tests/e2e/http.test.ts +0 -130
- package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
- package/tests/fixtures/components/TestOrder.ts +0 -23
- package/tests/fixtures/components/TestProduct.ts +0 -23
- package/tests/fixtures/components/TestUser.ts +0 -20
- package/tests/fixtures/components/index.ts +0 -6
- package/tests/graphql/SchemaGeneration.test.ts +0 -90
- package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
- package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
- package/tests/helpers/MockRedisClient.ts +0 -113
- package/tests/helpers/MockRedisStreamServer.ts +0 -448
- package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
- package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
- package/tests/integration/entity/Entity.persistence.test.ts +0 -333
- package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
- package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
- package/tests/integration/query/Query.abort.test.ts +0 -66
- package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
- package/tests/integration/query/Query.edgeCases.test.ts +0 -595
- package/tests/integration/query/Query.exec.test.ts +0 -576
- package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
- package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
- package/tests/integration/remote/dlq.test.ts +0 -175
- package/tests/integration/remote/event-dispatch.test.ts +0 -114
- package/tests/integration/remote/outbox.test.ts +0 -130
- package/tests/integration/remote/rpc.test.ts +0 -177
- package/tests/pglite-setup.ts +0 -62
- package/tests/setup.ts +0 -164
- package/tests/stress/BenchmarkRunner.ts +0 -203
- package/tests/stress/DataSeeder.ts +0 -190
- package/tests/stress/StressTestReporter.ts +0 -229
- package/tests/stress/cursor-perf-test.ts +0 -171
- package/tests/stress/fixtures/RealisticComponents.ts +0 -235
- package/tests/stress/fixtures/StressTestComponents.ts +0 -58
- package/tests/stress/index.ts +0 -7
- package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
- package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
- package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
- package/tests/unit/BatchLoader.test.ts +0 -196
- package/tests/unit/archetype/ArcheType.test.ts +0 -107
- package/tests/unit/cache/CacheManager.test.ts +0 -498
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/database/cancellable.test.ts +0 -81
- package/tests/unit/database/instrumentedDb.test.ts +0 -160
- package/tests/unit/entity/Entity.components.test.ts +0 -317
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
- package/tests/unit/entity/Entity.reload.test.ts +0 -63
- package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
- package/tests/unit/entity/Entity.test.ts +0 -345
- package/tests/unit/gql/depthLimit.test.ts +0 -203
- package/tests/unit/gql/operationMiddleware.test.ts +0 -293
- package/tests/unit/health/Health.test.ts +0 -129
- package/tests/unit/middleware/AccessLog.test.ts +0 -37
- package/tests/unit/middleware/Middleware.test.ts +0 -98
- package/tests/unit/middleware/RequestId.test.ts +0 -54
- package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
- package/tests/unit/query/FilterBuilder.test.ts +0 -111
- package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
- package/tests/unit/query/Query.emptyString.test.ts +0 -69
- package/tests/unit/query/Query.test.ts +0 -310
- package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
- package/tests/unit/remote/RemoteError.test.ts +0 -55
- package/tests/unit/remote/decorators.test.ts +0 -195
- package/tests/unit/remote/metrics.test.ts +0 -115
- package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
- package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
- package/tests/unit/schema/schema-integration.test.ts +0 -426
- package/tests/unit/schema/schema.test.ts +0 -580
- package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
- package/tests/unit/upload/RestUpload.test.ts +0 -267
- package/tests/unit/validateEnv.test.ts +0 -82
- package/tests/utils/entity-tracker.ts +0 -57
- package/tests/utils/index.ts +0 -13
- package/tests/utils/test-context.ts +0 -149
|
@@ -12,8 +12,8 @@ function formatBytes(bytes: number): string {
|
|
|
12
12
|
interface CacheEntry<T> {
|
|
13
13
|
value: T;
|
|
14
14
|
expiresAt?: number;
|
|
15
|
-
lastAccessed: number;
|
|
16
15
|
accessCount: number;
|
|
16
|
+
size: number; // Estimated bytes (key + value + metadata), cached for O(1) memory accounting
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export interface MemoryCacheConfig {
|
|
@@ -36,7 +36,6 @@ export class MemoryCache implements CacheProvider {
|
|
|
36
36
|
size: 0,
|
|
37
37
|
memoryUsage: 0
|
|
38
38
|
};
|
|
39
|
-
private accessCounter = 0; // For LRU ordering
|
|
40
39
|
|
|
41
40
|
constructor(config: MemoryCacheConfig = {}) {
|
|
42
41
|
this.config = {
|
|
@@ -60,13 +59,15 @@ export class MemoryCache implements CacheProvider {
|
|
|
60
59
|
// Check if expired
|
|
61
60
|
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
62
61
|
this.cache.delete(key);
|
|
62
|
+
this.stats.size--;
|
|
63
|
+
this.stats.memoryUsage = Math.max(0, this.stats.memoryUsage - entry.size);
|
|
63
64
|
this.stats.misses++;
|
|
64
|
-
this.updateMemoryUsage();
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
//
|
|
69
|
-
|
|
68
|
+
// Move to end of Map iteration order (most recently used) for O(1) LRU.
|
|
69
|
+
this.cache.delete(key);
|
|
70
|
+
this.cache.set(key, entry);
|
|
70
71
|
entry.accessCount++;
|
|
71
72
|
|
|
72
73
|
this.stats.hits++;
|
|
@@ -76,21 +77,27 @@ export class MemoryCache implements CacheProvider {
|
|
|
76
77
|
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
|
77
78
|
const expiresAt = ttl ? Date.now() + ttl : (this.config.defaultTTL ? Date.now() + this.config.defaultTTL : undefined);
|
|
78
79
|
|
|
80
|
+
const size = this.entrySize(key, value);
|
|
79
81
|
const entry: CacheEntry<T> = {
|
|
80
82
|
value,
|
|
81
83
|
expiresAt,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
accessCount: 1,
|
|
85
|
+
size
|
|
84
86
|
};
|
|
85
87
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
// Incremental memory accounting: adjust by the delta instead of
|
|
89
|
+
// re-walking the entire cache on every write.
|
|
90
|
+
// Delete before set so the key moves to the end of Map iteration
|
|
91
|
+
// order (most recently used position for LRU eviction).
|
|
92
|
+
const existing = this.cache.get(key);
|
|
93
|
+
if (existing) {
|
|
94
|
+
this.stats.memoryUsage = Math.max(0, this.stats.memoryUsage - existing.size);
|
|
95
|
+
this.cache.delete(key);
|
|
96
|
+
} else {
|
|
90
97
|
this.stats.size++;
|
|
91
98
|
}
|
|
92
|
-
|
|
93
|
-
this.
|
|
99
|
+
this.cache.set(key, entry);
|
|
100
|
+
this.stats.memoryUsage += size;
|
|
94
101
|
|
|
95
102
|
// Evict if necessary
|
|
96
103
|
await this.evictIfNeeded();
|
|
@@ -98,16 +105,15 @@ export class MemoryCache implements CacheProvider {
|
|
|
98
105
|
|
|
99
106
|
async delete(key: string | string[]): Promise<void> {
|
|
100
107
|
const keys = Array.isArray(key) ? key : [key];
|
|
101
|
-
let deletedCount = 0;
|
|
102
108
|
|
|
103
109
|
for (const k of keys) {
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
const entry = this.cache.get(k);
|
|
111
|
+
if (entry) {
|
|
112
|
+
this.cache.delete(k);
|
|
113
|
+
this.stats.size--;
|
|
114
|
+
this.stats.memoryUsage = Math.max(0, this.stats.memoryUsage - entry.size);
|
|
106
115
|
}
|
|
107
116
|
}
|
|
108
|
-
|
|
109
|
-
this.stats.size -= deletedCount;
|
|
110
|
-
this.updateMemoryUsage();
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
async clear(): Promise<void> {
|
|
@@ -144,7 +150,7 @@ export class MemoryCache implements CacheProvider {
|
|
|
144
150
|
const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.'));
|
|
145
151
|
|
|
146
152
|
const keysToDelete: string[] = [];
|
|
147
|
-
for (const key of
|
|
153
|
+
for (const key of this.cache.keys()) {
|
|
148
154
|
if (regex.test(key)) {
|
|
149
155
|
keysToDelete.push(key);
|
|
150
156
|
}
|
|
@@ -171,16 +177,9 @@ export class MemoryCache implements CacheProvider {
|
|
|
171
177
|
};
|
|
172
178
|
}
|
|
173
179
|
|
|
174
|
-
private
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
let memoryUsage = 0;
|
|
178
|
-
for (const [key, entry] of Array.from(this.cache.entries())) {
|
|
179
|
-
memoryUsage += key.length * 2; // Rough string overhead
|
|
180
|
-
memoryUsage += this.estimateValueSize(entry.value);
|
|
181
|
-
memoryUsage += 100; // Overhead for entry metadata
|
|
182
|
-
}
|
|
183
|
-
this.stats.memoryUsage = memoryUsage;
|
|
180
|
+
private entrySize(key: string, value: any): number {
|
|
181
|
+
// key string overhead + value estimate + fixed per-entry metadata
|
|
182
|
+
return key.length * 2 + this.estimateValueSize(value) + 100;
|
|
184
183
|
}
|
|
185
184
|
|
|
186
185
|
private estimateValueSize(value: any): number {
|
|
@@ -214,14 +213,16 @@ export class MemoryCache implements CacheProvider {
|
|
|
214
213
|
}
|
|
215
214
|
|
|
216
215
|
private async evictLRU(count: number): Promise<void> {
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
216
|
+
// Map iteration order is insertion order. get() and set() move accessed/
|
|
217
|
+
// updated keys to the end, so keys at the front are least recently used.
|
|
218
|
+
// Collect the first `count` keys without sorting or materialising a full array.
|
|
219
|
+
const keysToDelete: string[] = [];
|
|
220
|
+
for (const key of this.cache.keys()) {
|
|
221
|
+
if (keysToDelete.length >= count) break;
|
|
222
|
+
keysToDelete.push(key);
|
|
223
|
+
}
|
|
221
224
|
|
|
222
|
-
const keysToDelete = entries.slice(0, count).map(item => item.key);
|
|
223
225
|
await this.delete(keysToDelete);
|
|
224
|
-
|
|
225
226
|
logger.debug(`Evicted ${keysToDelete.length} entries from cache due to LRU policy`);
|
|
226
227
|
}
|
|
227
228
|
|
|
@@ -229,13 +230,15 @@ export class MemoryCache implements CacheProvider {
|
|
|
229
230
|
this.cleanupTimer = setInterval(() => {
|
|
230
231
|
this.cleanupExpired();
|
|
231
232
|
}, this.config.cleanupInterval);
|
|
233
|
+
// Allow the process to exit without waiting for this maintenance timer.
|
|
234
|
+
this.cleanupTimer?.unref?.();
|
|
232
235
|
}
|
|
233
236
|
|
|
234
237
|
private cleanupExpired(): void {
|
|
235
238
|
const now = Date.now();
|
|
236
239
|
const keysToDelete: string[] = [];
|
|
237
240
|
|
|
238
|
-
for (const [key, entry] of
|
|
241
|
+
for (const [key, entry] of this.cache) {
|
|
239
242
|
if (entry.expiresAt && now > entry.expiresAt) {
|
|
240
243
|
keysToDelete.push(key);
|
|
241
244
|
}
|
package/core/cache/RedisCache.ts
CHANGED
|
@@ -161,6 +161,8 @@ export class RedisCache implements CacheProvider {
|
|
|
161
161
|
logger.error({ error, msg: 'Failed to get Redis memory info' });
|
|
162
162
|
}
|
|
163
163
|
}, 300000); // 5 minutes
|
|
164
|
+
// Allow the process to exit without waiting for this monitoring timer.
|
|
165
|
+
this.monitoringInterval?.unref?.();
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
/**
|
|
@@ -192,8 +194,7 @@ export class RedisCache implements CacheProvider {
|
|
|
192
194
|
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
|
193
195
|
try {
|
|
194
196
|
const prefixedKey = this.prefixKey(key);
|
|
195
|
-
const
|
|
196
|
-
const serializedValue = JSON.stringify(compressedValue);
|
|
197
|
+
const serializedValue = await CompressionUtils.compressForStorage(value);
|
|
197
198
|
|
|
198
199
|
if (ttl) {
|
|
199
200
|
await this.client.setex(prefixedKey, Math.floor(ttl / 1000), serializedValue);
|
|
@@ -269,19 +270,19 @@ export class RedisCache implements CacheProvider {
|
|
|
269
270
|
*/
|
|
270
271
|
async setMany<T>(entries: Array<{key: string, value: T, ttl?: number}>): Promise<void> {
|
|
271
272
|
try {
|
|
273
|
+
const compressed = await Promise.all(entries.map(e => CompressionUtils.compressForStorage(e.value)));
|
|
274
|
+
|
|
272
275
|
const pipeline = this.client.pipeline();
|
|
273
276
|
|
|
274
|
-
|
|
277
|
+
entries.forEach((entry, i) => {
|
|
275
278
|
const prefixedKey = this.prefixKey(entry.key);
|
|
276
|
-
const
|
|
277
|
-
const serializedValue = JSON.stringify(compressedValue);
|
|
278
|
-
|
|
279
|
+
const serializedValue = compressed[i] as string;
|
|
279
280
|
if (entry.ttl) {
|
|
280
281
|
pipeline.setex(prefixedKey, Math.floor(entry.ttl / 1000), serializedValue);
|
|
281
282
|
} else {
|
|
282
283
|
pipeline.set(prefixedKey, serializedValue);
|
|
283
284
|
}
|
|
284
|
-
}
|
|
285
|
+
});
|
|
285
286
|
|
|
286
287
|
await pipeline.exec();
|
|
287
288
|
} catch (error) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type CacheProvider } from './CacheProvider';
|
|
2
|
+
import { logger } from '../Logger';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Health check operations: ping and getStats.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export async function getStats(provider: CacheProvider) {
|
|
9
|
+
try {
|
|
10
|
+
return await provider.getStats();
|
|
11
|
+
} catch (error) {
|
|
12
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting cache stats', error });
|
|
13
|
+
return {
|
|
14
|
+
hits: 0,
|
|
15
|
+
misses: 0,
|
|
16
|
+
hitRate: 0,
|
|
17
|
+
size: 0,
|
|
18
|
+
memoryUsage: 0
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function ping(provider: CacheProvider): Promise<boolean> {
|
|
24
|
+
try {
|
|
25
|
+
return await provider.ping();
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Cache ping failed', error });
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type CacheProvider } from './CacheProvider';
|
|
2
|
+
import { MultiLevelCache } from './MultiLevelCache';
|
|
3
|
+
import { RedisCache } from './RedisCache';
|
|
4
|
+
import { logger } from '../Logger';
|
|
5
|
+
|
|
6
|
+
export interface InvalidationMessage {
|
|
7
|
+
instanceId: string;
|
|
8
|
+
type: 'key' | 'pattern';
|
|
9
|
+
keys?: string[];
|
|
10
|
+
pattern?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const INVALIDATION_CHANNEL = 'bunsane:cache:invalidate';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Setup pub/sub for cross-instance cache invalidation.
|
|
17
|
+
* Only activates when using MultiLevel provider with a Redis L2.
|
|
18
|
+
* Returns true if pub/sub was successfully enabled.
|
|
19
|
+
*/
|
|
20
|
+
export async function setupPubSub(
|
|
21
|
+
provider: CacheProvider,
|
|
22
|
+
instanceId: string,
|
|
23
|
+
handleRemoteInvalidation: (raw: string) => Promise<void>
|
|
24
|
+
): Promise<boolean> {
|
|
25
|
+
if (!(provider instanceof MultiLevelCache)) return false;
|
|
26
|
+
|
|
27
|
+
const l2 = provider.getL2Cache();
|
|
28
|
+
if (!(l2 instanceof RedisCache)) return false;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await l2.subscribeInvalidation(
|
|
32
|
+
INVALIDATION_CHANNEL,
|
|
33
|
+
(_channel, message) => handleRemoteInvalidation(message)
|
|
34
|
+
);
|
|
35
|
+
logger.info({ scope: 'cache', component: 'CacheManager', msg: 'Cross-instance cache invalidation enabled', instanceId });
|
|
36
|
+
return true;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
logger.warn({ scope: 'cache', component: 'CacheManager', msg: 'Failed to setup pub/sub', error });
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Handle an invalidation message from another instance.
|
|
45
|
+
* Ignores messages from self. Invalidates L1 only (L2 is shared Redis).
|
|
46
|
+
*/
|
|
47
|
+
export async function handleRemoteInvalidation(
|
|
48
|
+
provider: CacheProvider,
|
|
49
|
+
instanceId: string,
|
|
50
|
+
raw: string
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
const msg: InvalidationMessage = JSON.parse(raw);
|
|
54
|
+
|
|
55
|
+
// Ignore our own messages
|
|
56
|
+
if (msg.instanceId === instanceId) return;
|
|
57
|
+
|
|
58
|
+
if (!(provider instanceof MultiLevelCache)) return;
|
|
59
|
+
const l1 = provider.getL1Cache();
|
|
60
|
+
|
|
61
|
+
if (msg.type === 'key' && msg.keys) {
|
|
62
|
+
await l1.deleteMany(msg.keys);
|
|
63
|
+
} else if (msg.type === 'pattern' && msg.pattern) {
|
|
64
|
+
await l1.invalidatePattern(msg.pattern);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
logger.debug({ scope: 'cache', component: 'CacheManager', msg: 'Applied remote invalidation', from: msg.instanceId, type: msg.type });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error handling remote invalidation', error });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Publish an invalidation event to other instances via Redis pub/sub.
|
|
75
|
+
*/
|
|
76
|
+
export async function publishInvalidation(
|
|
77
|
+
provider: CacheProvider,
|
|
78
|
+
pubSubEnabled: boolean,
|
|
79
|
+
instanceId: string,
|
|
80
|
+
type: 'key' | 'pattern',
|
|
81
|
+
keys?: string[],
|
|
82
|
+
pattern?: string
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
if (!pubSubEnabled) return;
|
|
85
|
+
if (!(provider instanceof MultiLevelCache)) return;
|
|
86
|
+
|
|
87
|
+
const l2 = provider.getL2Cache();
|
|
88
|
+
if (!(l2 instanceof RedisCache)) return;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const msg: InvalidationMessage = { instanceId, type, keys, pattern };
|
|
92
|
+
await l2.publishInvalidation(INVALIDATION_CHANNEL, JSON.stringify(msg));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error publishing invalidation', error });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { type CacheProvider } from '../CacheProvider';
|
|
2
|
+
import { type CacheConfig } from '../../../config/cache.config';
|
|
3
|
+
import { logger } from '../../Logger';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Write-invalidate strategy: entity and component invalidation operations.
|
|
7
|
+
* publishInvalidation is passed as a callback to avoid circular imports.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type PublishFn = (type: 'key' | 'pattern', keys?: string[], pattern?: string) => Promise<void>;
|
|
11
|
+
|
|
12
|
+
export async function invalidateEntity(provider: CacheProvider, config: CacheConfig, publishInvalidation: PublishFn, id: string): Promise<void> {
|
|
13
|
+
if (!config.enabled || !config.entity?.enabled) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const key = `entity:${id}`;
|
|
19
|
+
await provider.delete(key);
|
|
20
|
+
await publishInvalidation('key', [key]);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating entity from cache', error });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function invalidateEntities(provider: CacheProvider, config: CacheConfig, publishInvalidation: PublishFn, entityIds: string[]): Promise<void> {
|
|
27
|
+
if (!config.enabled || entityIds.length === 0) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
await Promise.all(
|
|
31
|
+
entityIds.flatMap(id => [
|
|
32
|
+
invalidateEntity(provider, config, publishInvalidation, id),
|
|
33
|
+
invalidateAllEntityComponents(provider, config, publishInvalidation, id),
|
|
34
|
+
])
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function invalidateAllEntityComponents(provider: CacheProvider, config: CacheConfig, publishInvalidation: PublishFn, entityId: string): Promise<void> {
|
|
39
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const pattern = `component:${entityId}:*`;
|
|
45
|
+
await provider.invalidatePattern(pattern);
|
|
46
|
+
await publishInvalidation('pattern', undefined, pattern);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating all entity components from cache', error });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function invalidateComponent(provider: CacheProvider, config: CacheConfig, publishInvalidation: PublishFn, entityId: string, typeId: string): Promise<void> {
|
|
53
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
logger.trace({
|
|
59
|
+
msg: 'Invalidating component from cache',
|
|
60
|
+
entityId,
|
|
61
|
+
typeId
|
|
62
|
+
})
|
|
63
|
+
const key = `component:${entityId}:${typeId}`;
|
|
64
|
+
await provider.delete(key);
|
|
65
|
+
await publishInvalidation('key', [key]);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating component from cache', error });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function invalidateComponents(provider: CacheProvider, config: CacheConfig, publishInvalidation: PublishFn, components: Array<{ entityId: string; typeId: string }>): Promise<void> {
|
|
72
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const keys = components.map(comp => `component:${comp.entityId}:${comp.typeId}`);
|
|
78
|
+
await provider.deleteMany(keys);
|
|
79
|
+
await publishInvalidation('key', keys);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating components from cache', error });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Invalidate all listed component types for one entity in a single round-trip.
|
|
87
|
+
* Optionally includes the entity existence key.
|
|
88
|
+
* Emits a single pub/sub message carrying all keys rather than one per component.
|
|
89
|
+
*/
|
|
90
|
+
export async function invalidateEntityComponents(
|
|
91
|
+
provider: CacheProvider,
|
|
92
|
+
config: CacheConfig,
|
|
93
|
+
publishInvalidation: PublishFn,
|
|
94
|
+
entityId: string,
|
|
95
|
+
componentTypeIds: string[],
|
|
96
|
+
opts?: { includeEntityKey?: boolean },
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
if (!config.enabled) return;
|
|
99
|
+
if (componentTypeIds.length === 0 && !opts?.includeEntityKey) return;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const keys: string[] = componentTypeIds.map(typeId => `component:${entityId}:${typeId}`);
|
|
103
|
+
if (opts?.includeEntityKey) {
|
|
104
|
+
keys.push(`entity:${entityId}`);
|
|
105
|
+
}
|
|
106
|
+
await provider.deleteMany(keys);
|
|
107
|
+
await publishInvalidation('key', keys);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating entity components', entityId, error });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { type CacheProvider } from '../CacheProvider';
|
|
2
|
+
import { type CacheConfig } from '../../../config/cache.config';
|
|
3
|
+
import { logger } from '../../Logger';
|
|
4
|
+
import type { Entity } from '../../Entity';
|
|
5
|
+
import type { BaseComponent } from '../../components';
|
|
6
|
+
import type { ComponentData } from '../../RequestLoaders';
|
|
7
|
+
|
|
8
|
+
// Must match the value exported by CacheManager — inlined here to avoid
|
|
9
|
+
// a circular import (CacheManager imports this module).
|
|
10
|
+
const COMPONENT_TOMBSTONE = '__TOMBSTONE__' as const;
|
|
11
|
+
type ComponentCacheValue = ComponentData | typeof COMPONENT_TOMBSTONE;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Write-through strategy: entity get/set operations
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export async function getEntity(provider: CacheProvider, config: CacheConfig, id: string): Promise<string | null> {
|
|
18
|
+
if (!config.enabled || !config.entity?.enabled) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const key = `entity:${id}`;
|
|
24
|
+
const result = await provider.get<string>(key);
|
|
25
|
+
return result || null;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting entity from cache', error });
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function setEntityWriteThrough(provider: CacheProvider, config: CacheConfig, entity: Entity, ttl?: number): Promise<void> {
|
|
33
|
+
if (!config.enabled || !config.entity?.enabled) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const key = `entity:${entity.id}`;
|
|
39
|
+
const effectiveTTL = ttl ?? config.entity.ttl;
|
|
40
|
+
// Only cache entity ID for existence check
|
|
41
|
+
await provider.set(key, entity.id, effectiveTTL);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting entity in cache', error });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function getEntities(provider: CacheProvider, config: CacheConfig, ids: string[]): Promise<(string | null)[]> {
|
|
48
|
+
if (!config.enabled || !config.entity?.enabled) {
|
|
49
|
+
return ids.map(() => null);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const cacheKeys = ids.map(id => `entity:${id}`);
|
|
54
|
+
const results = await provider.getMany<string>(cacheKeys);
|
|
55
|
+
return results;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting entities from cache', error });
|
|
58
|
+
return ids.map(() => null);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function setEntitiesWriteThrough(provider: CacheProvider, config: CacheConfig, entities: Entity[], ttl?: number): Promise<void> {
|
|
63
|
+
if (!config.enabled || !config.entity?.enabled) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const effectiveTTL = ttl ?? config.entity?.ttl;
|
|
69
|
+
const entries = entities.map(entity => ({
|
|
70
|
+
key: `entity:${entity.id}`,
|
|
71
|
+
// Only cache entity ID for existence check
|
|
72
|
+
value: entity.id,
|
|
73
|
+
ttl: effectiveTTL
|
|
74
|
+
}));
|
|
75
|
+
await provider.setMany(entries);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting entities in cache', error });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function getComponentsByEntity(provider: CacheProvider, config: CacheConfig, entityId: string, componentType?: string): Promise<BaseComponent[] | null> {
|
|
82
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const key = componentType
|
|
88
|
+
? `component:${entityId}:${componentType}`
|
|
89
|
+
: `components:${entityId}`;
|
|
90
|
+
return await provider.get<BaseComponent[]>(key);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting components from cache', error });
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Set components for an entity in cache with write-through strategy.
|
|
99
|
+
* Converts BaseComponent instances to ComponentData format for cache compatibility with DataLoader.
|
|
100
|
+
* Delegates to setComponentsBatchWriteThrough for a single-entity, 2-RTT batch.
|
|
101
|
+
*/
|
|
102
|
+
export async function setComponentWriteThrough(provider: CacheProvider, config: CacheConfig, entityId: string, components: BaseComponent[], componentType?: string, ttl?: number): Promise<void> {
|
|
103
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const entries = components.map(c => ({
|
|
107
|
+
entityId,
|
|
108
|
+
typeId: componentType || c.getTypeID(),
|
|
109
|
+
component: c,
|
|
110
|
+
ttl,
|
|
111
|
+
}));
|
|
112
|
+
await setComponentsBatchWriteThrough(provider, config, entries);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Batch write-through for BaseComponent instances across any number of
|
|
117
|
+
* entities. Performs exactly 2 Redis round-trips regardless of entry count:
|
|
118
|
+
* 1. pipelined getMany — reads existing entries to preserve createdAt (H-CACHE-3)
|
|
119
|
+
* 2. pipelined setMany — writes all updated entries
|
|
120
|
+
*/
|
|
121
|
+
export async function setComponentsBatchWriteThrough(
|
|
122
|
+
provider: CacheProvider,
|
|
123
|
+
config: CacheConfig,
|
|
124
|
+
entries: Array<{ entityId: string; typeId: string; component: BaseComponent; ttl?: number }>,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
if (!config.enabled || !config.component?.enabled || entries.length === 0) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const effectiveTTL = config.component.ttl;
|
|
132
|
+
const keys = entries.map(e => `component:${e.entityId}:${e.typeId}`);
|
|
133
|
+
|
|
134
|
+
// One batched read — preserves createdAt from existing entries (H-CACHE-3).
|
|
135
|
+
const existing = await provider.getMany<ComponentData>(keys);
|
|
136
|
+
|
|
137
|
+
const now = new Date();
|
|
138
|
+
const setEntries = entries.map((e, i) => {
|
|
139
|
+
const prev = existing[i];
|
|
140
|
+
const createdAt: Date =
|
|
141
|
+
prev && prev.createdAt
|
|
142
|
+
? (prev.createdAt instanceof Date ? prev.createdAt : new Date(prev.createdAt))
|
|
143
|
+
: now;
|
|
144
|
+
|
|
145
|
+
const componentData: ComponentData = {
|
|
146
|
+
id: e.component.id,
|
|
147
|
+
entityId: e.entityId,
|
|
148
|
+
typeId: e.typeId,
|
|
149
|
+
data: e.component.data(),
|
|
150
|
+
createdAt,
|
|
151
|
+
updatedAt: now,
|
|
152
|
+
deletedAt: null,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return { key: keys[i]!, value: componentData, ttl: e.ttl ?? effectiveTTL };
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// One batched write.
|
|
159
|
+
await provider.setMany(setEntries);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting components in cache (batch)', err: error });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function getComponents(provider: CacheProvider, config: CacheConfig, keys: Array<{ entityId: string; typeId: string }>): Promise<(ComponentCacheValue | null)[]> {
|
|
166
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
167
|
+
return keys.map(() => null);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const cacheKeys = keys.map(k => `component:${k.entityId}:${k.typeId}`);
|
|
172
|
+
const results = await provider.getMany<ComponentCacheValue>(cacheKeys);
|
|
173
|
+
return results;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting components from cache', error });
|
|
176
|
+
return keys.map(() => null);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Set components in cache with write-through strategy (for DataLoader integration).
|
|
182
|
+
*
|
|
183
|
+
* When `requestedKeys` is supplied and `component.negativeCacheEnabled` is
|
|
184
|
+
* true, tombstones are written for any requested key not present in
|
|
185
|
+
* `components` (within the same setMany call — single round-trip).
|
|
186
|
+
*/
|
|
187
|
+
export async function setComponentsWriteThrough(
|
|
188
|
+
provider: CacheProvider,
|
|
189
|
+
config: CacheConfig,
|
|
190
|
+
components: ComponentData[],
|
|
191
|
+
ttlOrRequested?: number | Array<{ entityId: string; typeId: string }>,
|
|
192
|
+
ttlIfRequested?: number,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Backward-compatible overload: (components, ttl?) or (components, requestedKeys, ttl?)
|
|
199
|
+
const requestedKeys = Array.isArray(ttlOrRequested) ? ttlOrRequested : undefined;
|
|
200
|
+
const ttl = Array.isArray(ttlOrRequested) ? ttlIfRequested : ttlOrRequested;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const componentTTL = ttl ?? config.component.ttl;
|
|
204
|
+
const entries: Array<{ key: string; value: ComponentCacheValue; ttl: number }> = components.map(comp => ({
|
|
205
|
+
key: `component:${comp.entityId}:${comp.typeId}`,
|
|
206
|
+
value: comp,
|
|
207
|
+
ttl: componentTTL,
|
|
208
|
+
}));
|
|
209
|
+
|
|
210
|
+
const negativeEnabled = config.component.negativeCacheEnabled === true;
|
|
211
|
+
if (negativeEnabled && requestedKeys && requestedKeys.length > 0) {
|
|
212
|
+
const found = new Set(components.map(c => `${c.entityId}-${c.typeId}`));
|
|
213
|
+
const tombstoneTTL = config.component.negativeCacheTtl
|
|
214
|
+
?? Math.min(componentTTL, 60_000);
|
|
215
|
+
for (const k of requestedKeys) {
|
|
216
|
+
const dedupeKey = `${k.entityId}-${k.typeId}`;
|
|
217
|
+
if (!found.has(dedupeKey)) {
|
|
218
|
+
entries.push({
|
|
219
|
+
key: `component:${k.entityId}:${k.typeId}`,
|
|
220
|
+
value: COMPONENT_TOMBSTONE,
|
|
221
|
+
ttl: tombstoneTTL,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (entries.length > 0) {
|
|
228
|
+
await provider.setMany(entries);
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting components in cache', error });
|
|
232
|
+
}
|
|
233
|
+
}
|