bunsane 0.3.2 → 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 (214) hide show
  1. package/CHANGELOG.md +445 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +85 -1020
  4. package/core/EntityHookManager.ts +52 -754
  5. package/core/Logger.ts +10 -0
  6. package/core/RequestContext.ts +94 -85
  7. package/core/RequestLoaders.ts +98 -5
  8. package/core/SchedulerManager.ts +28 -600
  9. package/core/app/cors.ts +2 -11
  10. package/core/app/preparedStatementWarmup.ts +9 -49
  11. package/core/app/requestRouter.ts +9 -8
  12. package/core/app/restRegistry.ts +8 -0
  13. package/core/archetype/fieldResolvers.ts +85 -40
  14. package/core/archetype/relationLoader.ts +135 -92
  15. package/core/cache/CacheManager.ts +91 -302
  16. package/core/cache/CompressionUtils.ts +34 -3
  17. package/core/cache/MemoryCache.ts +40 -37
  18. package/core/cache/RedisCache.ts +4 -4
  19. package/core/cache/health.ts +30 -0
  20. package/core/cache/invalidation.ts +96 -0
  21. package/core/cache/strategies/writeInvalidate.ts +111 -0
  22. package/core/cache/strategies/writeThrough.ts +233 -0
  23. package/core/components/BaseComponent.ts +16 -8
  24. package/core/components/ComponentRegistry.ts +28 -0
  25. package/core/decorators/IndexedField.ts +1 -1
  26. package/core/entity/cacheStrategies.ts +97 -0
  27. package/core/entity/componentAccess.ts +364 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/pendingOps.ts +72 -0
  30. package/core/entity/saveEntity.ts +377 -0
  31. package/core/hooks/dispatcher.ts +439 -0
  32. package/core/hooks/guards.ts +155 -0
  33. package/core/hooks/registry.ts +247 -0
  34. package/core/metadata/definitions/Component.ts +1 -1
  35. package/core/metadata/index.ts +15 -4
  36. package/core/middleware/RateLimit.ts +102 -105
  37. package/core/middleware/RequestId.ts +2 -9
  38. package/core/middleware/SecurityHeaders.ts +2 -11
  39. package/core/middleware/headers.ts +28 -0
  40. package/core/remote/OutboxWorker.ts +213 -183
  41. package/core/remote/RemoteManager.ts +401 -400
  42. package/core/remote/types.ts +153 -151
  43. package/core/requestScope.ts +34 -0
  44. package/core/scheduler/cronEvaluator.ts +174 -0
  45. package/core/scheduler/lifecycleHooks.ts +21 -0
  46. package/core/scheduler/lockCoordinator.ts +27 -0
  47. package/core/scheduler/metrics.ts +14 -0
  48. package/core/scheduler/taskRunner.ts +420 -0
  49. package/database/DatabaseHelper.ts +128 -101
  50. package/database/IndexingStrategy.ts +72 -2
  51. package/database/PreparedStatementCache.ts +8 -2
  52. package/database/cancellable.ts +35 -22
  53. package/database/index.ts +15 -3
  54. package/database/instrumentedDb.ts +141 -141
  55. package/endpoints/archetypes.ts +2 -8
  56. package/endpoints/tables.ts +6 -1
  57. package/gql/index.ts +1 -1
  58. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  59. package/package.json +22 -1
  60. package/query/CTENode.ts +5 -3
  61. package/query/ComponentInclusionNode.ts +240 -13
  62. package/query/OrNode.ts +6 -5
  63. package/query/Query.ts +157 -46
  64. package/query/QueryContext.ts +6 -0
  65. package/query/QueryDAG.ts +7 -2
  66. package/query/membershipSource.ts +66 -0
  67. package/storage/LocalStorageProvider.ts +8 -3
  68. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  69. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  70. package/studio/{index.html → dist/index.html} +3 -2
  71. package/swagger/generator.ts +11 -1
  72. package/upload/UploadManager.ts +8 -6
  73. package/utils/uuid.ts +40 -10
  74. package/.claude/scheduled_tasks.lock +0 -1
  75. package/.claude/settings.local.json +0 -47
  76. package/.prettierrc +0 -4
  77. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  78. package/.serena/memories/architecture.md +0 -154
  79. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  80. package/.serena/memories/code_style_and_conventions.md +0 -76
  81. package/.serena/memories/project_overview.md +0 -43
  82. package/.serena/memories/schema-dsl-plan.md +0 -107
  83. package/.serena/memories/suggested_commands.md +0 -80
  84. package/.serena/memories/typescript-compilation-status.md +0 -54
  85. package/.serena/project.yml +0 -114
  86. package/BunSane.jpg +0 -0
  87. package/CLAUDE.md +0 -198
  88. package/TODO.md +0 -2
  89. package/bun.lock +0 -302
  90. package/bunfig.toml +0 -10
  91. package/docs/RFC_APP_REFACTOR.md +0 -248
  92. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  93. package/docs/SCALABILITY_PLAN.md +0 -175
  94. package/studio/bun.lock +0 -482
  95. package/studio/package.json +0 -39
  96. package/studio/postcss.config.js +0 -6
  97. package/studio/src/components/DataTable.tsx +0 -211
  98. package/studio/src/components/Layout.tsx +0 -13
  99. package/studio/src/components/PageContainer.tsx +0 -9
  100. package/studio/src/components/PageHeader.tsx +0 -13
  101. package/studio/src/components/SearchBar.tsx +0 -57
  102. package/studio/src/components/Sidebar.tsx +0 -294
  103. package/studio/src/components/ui/button.tsx +0 -56
  104. package/studio/src/components/ui/checkbox.tsx +0 -26
  105. package/studio/src/components/ui/input.tsx +0 -25
  106. package/studio/src/hooks/useDataTable.ts +0 -131
  107. package/studio/src/index.css +0 -36
  108. package/studio/src/lib/api.ts +0 -186
  109. package/studio/src/lib/utils.ts +0 -13
  110. package/studio/src/main.tsx +0 -17
  111. package/studio/src/pages/ArcheType.tsx +0 -239
  112. package/studio/src/pages/Components.tsx +0 -124
  113. package/studio/src/pages/EntityInspector.tsx +0 -302
  114. package/studio/src/pages/QueryRunner.tsx +0 -246
  115. package/studio/src/pages/Table.tsx +0 -94
  116. package/studio/src/pages/Welcome.tsx +0 -241
  117. package/studio/src/routes.tsx +0 -45
  118. package/studio/src/store/archeTypeSettings.ts +0 -30
  119. package/studio/src/store/studio.ts +0 -65
  120. package/studio/src/utils/columnHelpers.tsx +0 -114
  121. package/studio/studio-instructions.md +0 -81
  122. package/studio/tailwind.config.js +0 -77
  123. package/studio/utils.ts +0 -54
  124. package/studio/vite.config.js +0 -19
  125. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  126. package/tests/benchmark/bunfig.toml +0 -9
  127. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  128. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  129. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  130. package/tests/benchmark/fixtures/index.ts +0 -6
  131. package/tests/benchmark/index.ts +0 -22
  132. package/tests/benchmark/noop-preload.ts +0 -3
  133. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  134. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  135. package/tests/benchmark/runners/index.ts +0 -4
  136. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  137. package/tests/benchmark/scripts/generate-db.ts +0 -344
  138. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  139. package/tests/e2e/http.test.ts +0 -130
  140. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  141. package/tests/fixtures/components/TestOrder.ts +0 -23
  142. package/tests/fixtures/components/TestProduct.ts +0 -23
  143. package/tests/fixtures/components/TestUser.ts +0 -20
  144. package/tests/fixtures/components/index.ts +0 -6
  145. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  146. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  147. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  148. package/tests/helpers/MockRedisClient.ts +0 -113
  149. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  150. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  151. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  152. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  153. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  154. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  155. package/tests/integration/query/Query.abort.test.ts +0 -66
  156. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  157. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  158. package/tests/integration/query/Query.exec.test.ts +0 -576
  159. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  160. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  161. package/tests/integration/remote/dlq.test.ts +0 -175
  162. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  163. package/tests/integration/remote/outbox.test.ts +0 -130
  164. package/tests/integration/remote/rpc.test.ts +0 -177
  165. package/tests/pglite-setup.ts +0 -62
  166. package/tests/setup.ts +0 -164
  167. package/tests/stress/BenchmarkRunner.ts +0 -203
  168. package/tests/stress/DataSeeder.ts +0 -190
  169. package/tests/stress/StressTestReporter.ts +0 -229
  170. package/tests/stress/cursor-perf-test.ts +0 -171
  171. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  172. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  173. package/tests/stress/index.ts +0 -7
  174. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  175. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  176. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  177. package/tests/unit/BatchLoader.test.ts +0 -196
  178. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  179. package/tests/unit/cache/CacheManager.test.ts +0 -498
  180. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  181. package/tests/unit/cache/RedisCache.test.ts +0 -411
  182. package/tests/unit/database/cancellable.test.ts +0 -81
  183. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  184. package/tests/unit/entity/Entity.components.test.ts +0 -317
  185. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  186. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  187. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  188. package/tests/unit/entity/Entity.test.ts +0 -345
  189. package/tests/unit/gql/depthLimit.test.ts +0 -203
  190. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  191. package/tests/unit/health/Health.test.ts +0 -129
  192. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  193. package/tests/unit/middleware/Middleware.test.ts +0 -98
  194. package/tests/unit/middleware/RequestId.test.ts +0 -54
  195. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  196. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  197. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  198. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  199. package/tests/unit/query/Query.test.ts +0 -310
  200. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  201. package/tests/unit/remote/RemoteError.test.ts +0 -55
  202. package/tests/unit/remote/decorators.test.ts +0 -195
  203. package/tests/unit/remote/metrics.test.ts +0 -115
  204. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  205. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  206. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  207. package/tests/unit/schema/schema-integration.test.ts +0 -426
  208. package/tests/unit/schema/schema.test.ts +0 -580
  209. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  210. package/tests/unit/upload/RestUpload.test.ts +0 -267
  211. package/tests/unit/validateEnv.test.ts +0 -82
  212. package/tests/utils/entity-tracker.ts +0 -57
  213. package/tests/utils/index.ts +0 -13
  214. 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
- // 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
+ }
@@ -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
+ }