bunsane 0.3.1 → 0.4.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.
Files changed (224) hide show
  1. package/CHANGELOG.md +445 -318
  2. package/config/cache.config.ts +35 -1
  3. package/core/App.ts +24 -1064
  4. package/core/ArcheType.ts +78 -2110
  5. package/core/BatchLoader.ts +56 -32
  6. package/core/Entity.ts +85 -1043
  7. package/core/EntityHookManager.ts +52 -754
  8. package/core/Logger.ts +10 -0
  9. package/core/RequestContext.ts +64 -6
  10. package/core/RequestLoaders.ts +187 -36
  11. package/core/SchedulerManager.ts +28 -600
  12. package/core/app/bootstrap.ts +133 -0
  13. package/core/app/cors.ts +85 -0
  14. package/core/app/graphqlSetup.ts +56 -0
  15. package/core/app/healthEndpoints.ts +31 -0
  16. package/core/app/metricsCollector.ts +27 -0
  17. package/core/app/preparedStatementWarmup.ts +15 -0
  18. package/core/app/processHandlers.ts +43 -0
  19. package/core/app/requestRouter.ts +310 -0
  20. package/core/app/restRegistry.ts +80 -0
  21. package/core/app/shutdown.ts +97 -0
  22. package/core/app/studioRouter.ts +83 -0
  23. package/core/archetype/customTypes.ts +100 -0
  24. package/core/archetype/decorators.ts +171 -0
  25. package/core/archetype/fieldResolvers.ts +666 -0
  26. package/core/archetype/helpers.ts +29 -0
  27. package/core/archetype/relationLoader.ts +161 -0
  28. package/core/archetype/schemaBuilder.ts +141 -0
  29. package/core/archetype/weaver.ts +218 -0
  30. package/core/archetype/zodSchemaBuilder.ts +527 -0
  31. package/core/cache/CacheManager.ts +173 -267
  32. package/core/cache/CompressionUtils.ts +34 -3
  33. package/core/cache/MemoryCache.ts +40 -37
  34. package/core/cache/RedisCache.ts +4 -4
  35. package/core/cache/health.ts +30 -0
  36. package/core/cache/invalidation.ts +96 -0
  37. package/core/cache/strategies/writeInvalidate.ts +111 -0
  38. package/core/cache/strategies/writeThrough.ts +233 -0
  39. package/core/components/BaseComponent.ts +16 -8
  40. package/core/components/ComponentRegistry.ts +28 -0
  41. package/core/decorators/IndexedField.ts +1 -1
  42. package/core/entity/cacheStrategies.ts +97 -0
  43. package/core/entity/componentAccess.ts +364 -0
  44. package/core/entity/finders.ts +202 -0
  45. package/core/entity/pendingOps.ts +72 -0
  46. package/core/entity/saveEntity.ts +377 -0
  47. package/core/hooks/dispatcher.ts +439 -0
  48. package/core/hooks/guards.ts +155 -0
  49. package/core/hooks/registry.ts +247 -0
  50. package/core/metadata/definitions/Component.ts +1 -1
  51. package/core/metadata/index.ts +15 -4
  52. package/core/middleware/AccessLog.ts +8 -1
  53. package/core/middleware/RateLimit.ts +102 -105
  54. package/core/middleware/RequestId.ts +2 -9
  55. package/core/middleware/SecurityHeaders.ts +2 -11
  56. package/core/middleware/headers.ts +28 -0
  57. package/core/remote/OutboxWorker.ts +213 -183
  58. package/core/remote/RemoteManager.ts +401 -400
  59. package/core/remote/types.ts +153 -151
  60. package/core/requestScope.ts +34 -0
  61. package/core/scheduler/cronEvaluator.ts +174 -0
  62. package/core/scheduler/lifecycleHooks.ts +21 -0
  63. package/core/scheduler/lockCoordinator.ts +27 -0
  64. package/core/scheduler/metrics.ts +14 -0
  65. package/core/scheduler/taskRunner.ts +420 -0
  66. package/database/DatabaseHelper.ts +128 -101
  67. package/database/IndexingStrategy.ts +72 -2
  68. package/database/PreparedStatementCache.ts +20 -5
  69. package/database/cancellable.ts +35 -0
  70. package/database/index.ts +15 -3
  71. package/database/instrumentedDb.ts +141 -0
  72. package/endpoints/archetypes.ts +2 -8
  73. package/endpoints/tables.ts +6 -1
  74. package/gql/index.ts +1 -1
  75. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  76. package/package.json +22 -1
  77. package/query/CTENode.ts +5 -3
  78. package/query/ComponentInclusionNode.ts +240 -13
  79. package/query/OrNode.ts +6 -5
  80. package/query/Query.ts +203 -59
  81. package/query/QueryContext.ts +6 -0
  82. package/query/QueryDAG.ts +7 -2
  83. package/query/membershipSource.ts +66 -0
  84. package/storage/LocalStorageProvider.ts +8 -3
  85. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  86. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  87. package/studio/{index.html → dist/index.html} +3 -2
  88. package/swagger/generator.ts +11 -1
  89. package/upload/UploadManager.ts +8 -6
  90. package/utils/uuid.ts +40 -10
  91. package/.claude/settings.local.json +0 -47
  92. package/.prettierrc +0 -4
  93. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  94. package/.serena/memories/architecture.md +0 -154
  95. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  96. package/.serena/memories/code_style_and_conventions.md +0 -76
  97. package/.serena/memories/project_overview.md +0 -43
  98. package/.serena/memories/schema-dsl-plan.md +0 -107
  99. package/.serena/memories/suggested_commands.md +0 -80
  100. package/.serena/memories/typescript-compilation-status.md +0 -54
  101. package/.serena/project.yml +0 -114
  102. package/BunSane.jpg +0 -0
  103. package/CLAUDE.md +0 -198
  104. package/TODO.md +0 -2
  105. package/bun.lock +0 -302
  106. package/bunfig.toml +0 -10
  107. package/docs/SCALABILITY_PLAN.md +0 -175
  108. package/studio/bun.lock +0 -482
  109. package/studio/package.json +0 -39
  110. package/studio/postcss.config.js +0 -6
  111. package/studio/src/components/DataTable.tsx +0 -211
  112. package/studio/src/components/Layout.tsx +0 -13
  113. package/studio/src/components/PageContainer.tsx +0 -9
  114. package/studio/src/components/PageHeader.tsx +0 -13
  115. package/studio/src/components/SearchBar.tsx +0 -57
  116. package/studio/src/components/Sidebar.tsx +0 -294
  117. package/studio/src/components/ui/button.tsx +0 -56
  118. package/studio/src/components/ui/checkbox.tsx +0 -26
  119. package/studio/src/components/ui/input.tsx +0 -25
  120. package/studio/src/hooks/useDataTable.ts +0 -131
  121. package/studio/src/index.css +0 -36
  122. package/studio/src/lib/api.ts +0 -186
  123. package/studio/src/lib/utils.ts +0 -13
  124. package/studio/src/main.tsx +0 -17
  125. package/studio/src/pages/ArcheType.tsx +0 -239
  126. package/studio/src/pages/Components.tsx +0 -124
  127. package/studio/src/pages/EntityInspector.tsx +0 -302
  128. package/studio/src/pages/QueryRunner.tsx +0 -246
  129. package/studio/src/pages/Table.tsx +0 -94
  130. package/studio/src/pages/Welcome.tsx +0 -241
  131. package/studio/src/routes.tsx +0 -45
  132. package/studio/src/store/archeTypeSettings.ts +0 -30
  133. package/studio/src/store/studio.ts +0 -65
  134. package/studio/src/utils/columnHelpers.tsx +0 -114
  135. package/studio/studio-instructions.md +0 -81
  136. package/studio/tailwind.config.js +0 -77
  137. package/studio/utils.ts +0 -54
  138. package/studio/vite.config.js +0 -19
  139. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  140. package/tests/benchmark/bunfig.toml +0 -9
  141. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  142. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  143. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  144. package/tests/benchmark/fixtures/index.ts +0 -6
  145. package/tests/benchmark/index.ts +0 -22
  146. package/tests/benchmark/noop-preload.ts +0 -3
  147. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  148. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  149. package/tests/benchmark/runners/index.ts +0 -4
  150. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  151. package/tests/benchmark/scripts/generate-db.ts +0 -344
  152. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  153. package/tests/e2e/http.test.ts +0 -130
  154. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  155. package/tests/fixtures/components/TestOrder.ts +0 -23
  156. package/tests/fixtures/components/TestProduct.ts +0 -23
  157. package/tests/fixtures/components/TestUser.ts +0 -20
  158. package/tests/fixtures/components/index.ts +0 -6
  159. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  160. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  161. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  162. package/tests/helpers/MockRedisClient.ts +0 -113
  163. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  164. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  165. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  166. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  167. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  168. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  169. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  170. package/tests/integration/query/Query.exec.test.ts +0 -576
  171. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  172. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  173. package/tests/integration/remote/dlq.test.ts +0 -175
  174. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  175. package/tests/integration/remote/outbox.test.ts +0 -130
  176. package/tests/integration/remote/rpc.test.ts +0 -177
  177. package/tests/pglite-setup.ts +0 -62
  178. package/tests/setup.ts +0 -164
  179. package/tests/stress/BenchmarkRunner.ts +0 -203
  180. package/tests/stress/DataSeeder.ts +0 -190
  181. package/tests/stress/StressTestReporter.ts +0 -229
  182. package/tests/stress/cursor-perf-test.ts +0 -171
  183. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  184. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  185. package/tests/stress/index.ts +0 -7
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  187. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  188. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  189. package/tests/unit/BatchLoader.test.ts +0 -196
  190. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  191. package/tests/unit/cache/CacheManager.test.ts +0 -367
  192. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  193. package/tests/unit/cache/RedisCache.test.ts +0 -411
  194. package/tests/unit/entity/Entity.components.test.ts +0 -317
  195. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  196. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  197. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  198. package/tests/unit/entity/Entity.test.ts +0 -345
  199. package/tests/unit/gql/depthLimit.test.ts +0 -203
  200. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  201. package/tests/unit/health/Health.test.ts +0 -129
  202. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  203. package/tests/unit/middleware/Middleware.test.ts +0 -98
  204. package/tests/unit/middleware/RequestId.test.ts +0 -54
  205. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  206. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  207. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  208. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  209. package/tests/unit/query/Query.test.ts +0 -310
  210. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  211. package/tests/unit/remote/RemoteError.test.ts +0 -55
  212. package/tests/unit/remote/decorators.test.ts +0 -195
  213. package/tests/unit/remote/metrics.test.ts +0 -115
  214. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  215. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  216. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  217. package/tests/unit/schema/schema-integration.test.ts +0 -426
  218. package/tests/unit/schema/schema.test.ts +0 -580
  219. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  220. package/tests/unit/upload/RestUpload.test.ts +0 -267
  221. package/tests/unit/validateEnv.test.ts +0 -82
  222. package/tests/utils/entity-tracker.ts +0 -57
  223. package/tests/utils/index.ts +0 -13
  224. package/tests/utils/test-context.ts +0 -149
@@ -19,7 +19,11 @@ export class CompressionUtils {
19
19
  private static readonly COMPRESSION_PREFIX = '__COMPRESSED__';
20
20
 
21
21
  /**
22
- * Compresses data if it exceeds the threshold size
22
+ * Compresses data if it exceeds the threshold size.
23
+ * Returns the original value (unmodified) when below threshold, or a
24
+ * compressed wrapper object when above. Callers that need the final
25
+ * storage string should use compressForStorage() to avoid a second
26
+ * JSON.stringify pass.
23
27
  */
24
28
  static async compress(data: any): Promise<any> {
25
29
  try {
@@ -33,7 +37,6 @@ export class CompressionUtils {
33
37
  const compressed = await gzipAsync(serialized);
34
38
  const compressedData = compressed.toString('base64');
35
39
 
36
- // Return compressed data with metadata
37
40
  return {
38
41
  [this.COMPRESSION_PREFIX]: true,
39
42
  data: compressedData,
@@ -41,12 +44,40 @@ export class CompressionUtils {
41
44
  compressedSize: compressed.length
42
45
  };
43
46
  } catch (error) {
44
- // Fallback to uncompressed data on compression error
45
47
  console.warn('Compression failed, using uncompressed data:', error);
46
48
  return data;
47
49
  }
48
50
  }
49
51
 
52
+ /**
53
+ * Serializes data to the final string written to Redis.
54
+ * Reuses the JSON.stringify pass done for size measurement so values
55
+ * below the compression threshold are never stringified twice.
56
+ * Values above the threshold are gzip-compressed and then stringified once.
57
+ */
58
+ static async compressForStorage(data: any): Promise<string> {
59
+ try {
60
+ const serialized = JSON.stringify(data);
61
+ const size = Buffer.byteLength(serialized, 'utf8');
62
+
63
+ if (size <= this.COMPRESSION_THRESHOLD) {
64
+ return serialized;
65
+ }
66
+
67
+ const compressed = await gzipAsync(serialized);
68
+ const wrapper = {
69
+ [this.COMPRESSION_PREFIX]: true,
70
+ data: compressed.toString('base64'),
71
+ originalSize: size,
72
+ compressedSize: compressed.length,
73
+ };
74
+ return JSON.stringify(wrapper);
75
+ } catch (error) {
76
+ console.warn('Compression failed, using uncompressed data:', error);
77
+ return JSON.stringify(data);
78
+ }
79
+ }
80
+
50
81
  /**
51
82
  * Decompresses data if it was previously compressed
52
83
  */
@@ -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
- // Update access tracking for LRU
69
- entry.lastAccessed = ++this.accessCounter;
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
- lastAccessed: ++this.accessCounter,
83
- accessCount: 1
84
+ accessCount: 1,
85
+ size
84
86
  };
85
87
 
86
- const wasNew = !this.cache.has(key);
87
- this.cache.set(key, entry);
88
-
89
- if (wasNew) {
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.updateMemoryUsage();
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
- if (this.cache.delete(k)) {
105
- deletedCount++;
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 Array.from(this.cache.keys())) {
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 updateMemoryUsage(): void {
175
- // Rough estimation of memory usage
176
- // Each entry has overhead for the key, value, and metadata
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
- // Sort entries by last accessed time (oldest first)
218
- const entries = Array.from(this.cache.entries())
219
- .map(([key, entry]) => ({ key, entry }))
220
- .sort((a, b) => a.entry.lastAccessed - b.entry.lastAccessed);
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 Array.from(this.cache.entries())) {
241
+ for (const [key, entry] of this.cache) {
239
242
  if (entry.expiresAt && now > entry.expiresAt) {
240
243
  keysToDelete.push(key);
241
244
  }
@@ -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 compressedValue = await CompressionUtils.compress(value);
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);
@@ -273,8 +274,7 @@ export class RedisCache implements CacheProvider {
273
274
 
274
275
  for (const entry of entries) {
275
276
  const prefixedKey = this.prefixKey(entry.key);
276
- const compressedValue = await CompressionUtils.compress(entry.value);
277
- const serializedValue = JSON.stringify(compressedValue);
277
+ const serializedValue = await CompressionUtils.compressForStorage(entry.value);
278
278
 
279
279
  if (entry.ttl) {
280
280
  pipeline.setex(prefixedKey, Math.floor(entry.ttl / 1000), serializedValue);
@@ -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
+ }