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