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
@@ -2,18 +2,38 @@ import { type CacheProvider } from './CacheProvider';
2
2
  import { type CacheConfig, defaultCacheConfig } from '../../config/cache.config';
3
3
  import { CacheFactory } from './CacheFactory';
4
4
  import { MultiLevelCache } from './MultiLevelCache';
5
- import { RedisCache } from './RedisCache';
6
5
  import { logger } from '../Logger';
7
6
  import type { Entity } from '../Entity';
8
7
  import type { BaseComponent } from '../components';
9
8
  import type { ComponentData } from '../RequestLoaders';
10
-
11
- interface InvalidationMessage {
12
- instanceId: string;
13
- type: 'key' | 'pattern';
14
- keys?: string[];
15
- pattern?: string;
16
- }
9
+ import {
10
+ getEntity as _getEntity,
11
+ setEntityWriteThrough as _setEntityWriteThrough,
12
+ getEntities as _getEntities,
13
+ setEntitiesWriteThrough as _setEntitiesWriteThrough,
14
+ getComponentsByEntity as _getComponentsByEntity,
15
+ setComponentWriteThrough as _setComponentWriteThrough,
16
+ setComponentsBatchWriteThrough as _setComponentsBatchWriteThrough,
17
+ getComponents as _getComponents,
18
+ setComponentsWriteThrough as _setComponentsWriteThrough,
19
+ } from './strategies/writeThrough';
20
+ import {
21
+ invalidateEntity as _invalidateEntity,
22
+ invalidateEntities as _invalidateEntities,
23
+ invalidateAllEntityComponents as _invalidateAllEntityComponents,
24
+ invalidateComponent as _invalidateComponent,
25
+ invalidateComponents as _invalidateComponents,
26
+ invalidateEntityComponents as _invalidateEntityComponents,
27
+ } from './strategies/writeInvalidate';
28
+ import {
29
+ setupPubSub as _setupPubSub,
30
+ handleRemoteInvalidation as _handleRemoteInvalidation,
31
+ publishInvalidation as _publishInvalidation,
32
+ } from './invalidation';
33
+ import {
34
+ getStats as _getStats,
35
+ ping as _ping,
36
+ } from './health';
17
37
 
18
38
  /**
19
39
  * Sentinel value written to the cache to record "known absent" lookups.
@@ -33,13 +53,12 @@ export type ComponentCacheValue = ComponentData | typeof COMPONENT_TOMBSTONE;
33
53
  export class CacheManager {
34
54
  private static instance: CacheManager;
35
55
  private provider: CacheProvider;
36
- private config: CacheConfig;
56
+ private config: Readonly<CacheConfig>;
37
57
  private instanceId = crypto.randomUUID();
38
58
  private pubSubEnabled = false;
39
- private static readonly INVALIDATION_CHANNEL = 'bunsane:cache:invalidate';
40
59
 
41
60
  private constructor() {
42
- this.config = defaultCacheConfig;
61
+ this.config = Object.freeze({ ...defaultCacheConfig });
43
62
  this.provider = CacheFactory.create(this.config);
44
63
  }
45
64
 
@@ -58,7 +77,7 @@ export class CacheManager {
58
77
  await this.shutdownProvider();
59
78
  this.pubSubEnabled = false;
60
79
 
61
- this.config = { ...defaultCacheConfig, ...config };
80
+ this.config = Object.freeze({ ...defaultCacheConfig, ...config });
62
81
  this.provider = CacheFactory.create(this.config);
63
82
 
64
83
  await this.setupPubSub();
@@ -67,10 +86,11 @@ export class CacheManager {
67
86
  }
68
87
 
69
88
  /**
70
- * Get the current cache configuration
89
+ * Get the current cache configuration.
90
+ * Config is frozen once set so callers may hold the reference safely.
71
91
  */
72
- public getConfig(): CacheConfig {
73
- return { ...this.config };
92
+ public getConfig(): Readonly<CacheConfig> {
93
+ return this.config;
74
94
  }
75
95
 
76
96
  /**
@@ -87,18 +107,7 @@ export class CacheManager {
87
107
  * Returns entity ID if exists, null if not found
88
108
  */
89
109
  public async getEntity(id: string): Promise<string | null> {
90
- if (!this.config.enabled || !this.config.entity?.enabled) {
91
- return null;
92
- }
93
-
94
- try {
95
- const key = `entity:${id}`;
96
- const result = await this.provider.get<string>(key);
97
- return result || null;
98
- } catch (error) {
99
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting entity from cache', error });
100
- return null;
101
- }
110
+ return _getEntity(this.provider, this.config, id);
102
111
  }
103
112
 
104
113
  /**
@@ -106,35 +115,14 @@ export class CacheManager {
106
115
  * Only caches entity ID for existence tracking, not full entity data
107
116
  */
108
117
  public async setEntityWriteThrough(entity: Entity, ttl?: number): Promise<void> {
109
- if (!this.config.enabled || !this.config.entity?.enabled) {
110
- return;
111
- }
112
-
113
- try {
114
- const key = `entity:${entity.id}`;
115
- const effectiveTTL = ttl ?? this.config.entity.ttl;
116
- // Only cache entity ID for existence check
117
- await this.provider.set(key, entity.id, effectiveTTL);
118
- } catch (error) {
119
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting entity in cache', error });
120
- }
118
+ return _setEntityWriteThrough(this.provider, this.config, entity, ttl);
121
119
  }
122
120
 
123
121
  /**
124
122
  * Invalidate an entity from cache
125
123
  */
126
124
  public async invalidateEntity(id: string): Promise<void> {
127
- if (!this.config.enabled || !this.config.entity?.enabled) {
128
- return;
129
- }
130
-
131
- try {
132
- const key = `entity:${id}`;
133
- await this.provider.delete(key);
134
- await this.publishInvalidation('key', [key]);
135
- } catch (error) {
136
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating entity from cache', error });
137
- }
125
+ return _invalidateEntity(this.provider, this.config, this._publishInvalidation.bind(this), id);
138
126
  }
139
127
 
140
128
  /**
@@ -142,18 +130,7 @@ export class CacheManager {
142
130
  * Returns entity IDs if they exist, null if not found
143
131
  */
144
132
  public async getEntities(ids: string[]): Promise<(string | null)[]> {
145
- if (!this.config.enabled || !this.config.entity?.enabled) {
146
- return ids.map(() => null);
147
- }
148
-
149
- try {
150
- const cacheKeys = ids.map(id => `entity:${id}`);
151
- const results = await this.provider.getMany<string>(cacheKeys);
152
- return results;
153
- } catch (error) {
154
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting entities from cache', error });
155
- return ids.map(() => null);
156
- }
133
+ return _getEntities(this.provider, this.config, ids);
157
134
  }
158
135
 
159
136
  /**
@@ -161,22 +138,7 @@ export class CacheManager {
161
138
  * Only caches entity IDs for existence tracking, not full entity data
162
139
  */
163
140
  public async setEntitiesWriteThrough(entities: Entity[], ttl?: number): Promise<void> {
164
- if (!this.config.enabled || !this.config.entity?.enabled) {
165
- return;
166
- }
167
-
168
- try {
169
- const effectiveTTL = ttl ?? this.config.entity?.ttl;
170
- const entries = entities.map(entity => ({
171
- key: `entity:${entity.id}`,
172
- // Only cache entity ID for existence check
173
- value: entity.id,
174
- ttl: effectiveTTL
175
- }));
176
- await this.provider.setMany(entries);
177
- } catch (error) {
178
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting entities in cache', error });
179
- }
141
+ return _setEntitiesWriteThrough(this.provider, this.config, entities, ttl);
180
142
  }
181
143
 
182
144
  // Component caching methods
@@ -185,72 +147,33 @@ export class CacheManager {
185
147
  * Get components for an entity from cache
186
148
  */
187
149
  public async getComponentsByEntity(entityId: string, componentType?: string): Promise<BaseComponent[] | null> {
188
- if (!this.config.enabled || !this.config.component?.enabled) {
189
- return null;
190
- }
191
-
192
- try {
193
- const key = componentType
194
- ? `component:${entityId}:${componentType}`
195
- : `components:${entityId}`;
196
- return await this.provider.get<BaseComponent[]>(key);
197
- } catch (error) {
198
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting components from cache', error });
199
- return null;
200
- }
150
+ return _getComponentsByEntity(this.provider, this.config, entityId, componentType);
201
151
  }
202
152
 
203
153
  /**
204
154
  * Set components for an entity in cache with write-through strategy.
205
155
  * Converts BaseComponent instances to ComponentData format for cache compatibility with DataLoader.
156
+ * Delegates to setComponentsBatchWriteThrough for a single-entity, 2-RTT batch.
206
157
  */
207
158
  public async setComponentWriteThrough(entityId: string, components: BaseComponent[], componentType?: string, ttl?: number): Promise<void> {
208
- if (!this.config.enabled || !this.config.component?.enabled) {
209
- return;
210
- }
211
-
212
- try {
213
- const effectiveTTL = ttl ?? this.config.component.ttl;
214
-
215
- // Convert BaseComponent to ComponentData format for cache
216
- // compatibility with DataLoader. BaseComponent does not track
217
- // createdAt/updatedAt today (data-model gap), but we preserve an
218
- // existing cache entry's createdAt when available and stamp
219
- // updatedAt=now, so consumers see monotonic update times rather
220
- // than a reset on every write-through (H-CACHE-3 — full fix
221
- // requires BaseComponent timestamp tracking).
222
- for (const component of components) {
223
- const typeId = componentType || component.getTypeID();
224
- const key = `component:${entityId}:${typeId}`;
225
-
226
- const now = new Date();
227
- let createdAt: Date = now;
228
- try {
229
- const existing = await this.provider.get<ComponentData>(key);
230
- if (existing && existing.createdAt) {
231
- createdAt = existing.createdAt instanceof Date
232
- ? existing.createdAt
233
- : new Date(existing.createdAt);
234
- }
235
- } catch {
236
- // Cache miss or provider error — fall through to now.
237
- }
159
+ return _setComponentWriteThrough(this.provider, this.config, entityId, components, componentType, ttl);
160
+ }
238
161
 
239
- const componentData: ComponentData = {
240
- id: component.id,
241
- entityId: entityId,
242
- typeId: typeId,
243
- data: component.data(),
244
- createdAt,
245
- updatedAt: now,
246
- deletedAt: null
247
- };
248
-
249
- await this.provider.set(key, componentData, effectiveTTL);
250
- }
251
- } catch (error) {
252
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting components in cache', err: error });
253
- }
162
+ /**
163
+ * Batch write-through for BaseComponent instances across any number of
164
+ * entities. Performs exactly 2 Redis round-trips regardless of entry count:
165
+ * 1. pipelined getMany — reads existing entries to preserve createdAt (H-CACHE-3)
166
+ * 2. pipelined setMany — writes all updated entries
167
+ *
168
+ * Signature:
169
+ * setComponentsBatchWriteThrough(
170
+ * entries: Array<{ entityId: string; typeId: string; component: BaseComponent; ttl?: number }>
171
+ * ): Promise<void>
172
+ */
173
+ public async setComponentsBatchWriteThrough(
174
+ entries: Array<{ entityId: string; typeId: string; component: BaseComponent; ttl?: number }>,
175
+ ): Promise<void> {
176
+ return _setComponentsBatchWriteThrough(this.provider, this.config, entries);
254
177
  }
255
178
 
256
179
  /**
@@ -258,22 +181,20 @@ export class CacheManager {
258
181
  * More granular than invalidateComponents which can invalidate all components
259
182
  */
260
183
  public async invalidateComponent(entityId: string, typeId: string): Promise<void> {
261
- if (!this.config.enabled || !this.config.component?.enabled) {
262
- return;
263
- }
184
+ return _invalidateComponent(this.provider, this.config, this._publishInvalidation.bind(this), entityId, typeId);
185
+ }
264
186
 
265
- try {
266
- logger.trace({
267
- msg: 'Invalidating component from cache',
268
- entityId,
269
- typeId
270
- })
271
- const key = `component:${entityId}:${typeId}`;
272
- await this.provider.delete(key);
273
- await this.publishInvalidation('key', [key]);
274
- } catch (error) {
275
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating component from cache', error });
276
- }
187
+ /**
188
+ * Invalidate all listed component types for one entity in a single round-trip.
189
+ * Optionally includes the entity existence key.
190
+ * Emits a single pub/sub message carrying all keys rather than one per component.
191
+ */
192
+ public async invalidateEntityComponents(
193
+ entityId: string,
194
+ componentTypeIds: string[],
195
+ opts?: { includeEntityKey?: boolean },
196
+ ): Promise<void> {
197
+ return _invalidateEntityComponents(this.provider, this.config, this._publishInvalidation.bind(this), entityId, componentTypeIds, opts);
277
198
  }
278
199
 
279
200
  /**
@@ -281,17 +202,7 @@ export class CacheManager {
281
202
  * Useful for bulk invalidation operations
282
203
  */
283
204
  public async invalidateComponents(components: Array<{ entityId: string; typeId: string }>): Promise<void> {
284
- if (!this.config.enabled || !this.config.component?.enabled) {
285
- return;
286
- }
287
-
288
- try {
289
- const keys = components.map(comp => `component:${comp.entityId}:${comp.typeId}`);
290
- await this.provider.deleteMany(keys);
291
- await this.publishInvalidation('key', keys);
292
- } catch (error) {
293
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating components from cache', error });
294
- }
205
+ return _invalidateComponents(this.provider, this.config, this._publishInvalidation.bind(this), components);
295
206
  }
296
207
 
297
208
  /**
@@ -301,15 +212,7 @@ export class CacheManager {
301
212
  * stale L1/L2 cache entries.
302
213
  */
303
214
  public async invalidateEntities(entityIds: string[]): Promise<void> {
304
- if (!this.config.enabled || entityIds.length === 0) {
305
- return;
306
- }
307
- await Promise.all(
308
- entityIds.flatMap(id => [
309
- this.invalidateEntity(id),
310
- this.invalidateAllEntityComponents(id),
311
- ])
312
- );
215
+ return _invalidateEntities(this.provider, this.config, this._publishInvalidation.bind(this), entityIds);
313
216
  }
314
217
 
315
218
  /**
@@ -317,17 +220,7 @@ export class CacheManager {
317
220
  * Uses pattern matching to efficiently clear all component caches for an entity
318
221
  */
319
222
  public async invalidateAllEntityComponents(entityId: string): Promise<void> {
320
- if (!this.config.enabled || !this.config.component?.enabled) {
321
- return;
322
- }
323
-
324
- try {
325
- const pattern = `component:${entityId}:*`;
326
- await this.provider.invalidatePattern(pattern);
327
- await this.publishInvalidation('pattern', undefined, pattern);
328
- } catch (error) {
329
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating all entity components from cache', error });
330
- }
223
+ return _invalidateAllEntityComponents(this.provider, this.config, this._publishInvalidation.bind(this), entityId);
331
224
  }
332
225
 
333
226
  /**
@@ -336,18 +229,7 @@ export class CacheManager {
336
229
  * recorded; callers must treat this as a hit and propagate null upstream.
337
230
  */
338
231
  public async getComponents(keys: Array<{ entityId: string; typeId: string }>): Promise<(ComponentCacheValue | null)[]> {
339
- if (!this.config.enabled || !this.config.component?.enabled) {
340
- return keys.map(() => null);
341
- }
342
-
343
- try {
344
- const cacheKeys = keys.map(k => `component:${k.entityId}:${k.typeId}`);
345
- const results = await this.provider.getMany<ComponentCacheValue>(cacheKeys);
346
- return results;
347
- } catch (error) {
348
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting components from cache', error });
349
- return keys.map(() => null);
350
- }
232
+ return _getComponents(this.provider, this.config, keys);
351
233
  }
352
234
 
353
235
  /**
@@ -362,45 +244,7 @@ export class CacheManager {
362
244
  ttlOrRequested?: number | Array<{ entityId: string; typeId: string }>,
363
245
  ttlIfRequested?: number,
364
246
  ): Promise<void> {
365
- if (!this.config.enabled || !this.config.component?.enabled) {
366
- return;
367
- }
368
-
369
- // Backward-compatible overload: (components, ttl?) or (components, requestedKeys, ttl?)
370
- const requestedKeys = Array.isArray(ttlOrRequested) ? ttlOrRequested : undefined;
371
- const ttl = Array.isArray(ttlOrRequested) ? ttlIfRequested : ttlOrRequested;
372
-
373
- try {
374
- const componentTTL = ttl ?? this.config.component.ttl;
375
- const entries: Array<{ key: string; value: ComponentCacheValue; ttl: number }> = components.map(comp => ({
376
- key: `component:${comp.entityId}:${comp.typeId}`,
377
- value: comp,
378
- ttl: componentTTL,
379
- }));
380
-
381
- const negativeEnabled = this.config.component.negativeCacheEnabled === true;
382
- if (negativeEnabled && requestedKeys && requestedKeys.length > 0) {
383
- const found = new Set(components.map(c => `${c.entityId}-${c.typeId}`));
384
- const tombstoneTTL = this.config.component.negativeCacheTtl
385
- ?? Math.min(componentTTL, 60_000);
386
- for (const k of requestedKeys) {
387
- const dedupeKey = `${k.entityId}-${k.typeId}`;
388
- if (!found.has(dedupeKey)) {
389
- entries.push({
390
- key: `component:${k.entityId}:${k.typeId}`,
391
- value: COMPONENT_TOMBSTONE,
392
- ttl: tombstoneTTL,
393
- });
394
- }
395
- }
396
- }
397
-
398
- if (entries.length > 0) {
399
- await this.provider.setMany(entries);
400
- }
401
- } catch (error) {
402
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting components in cache', error });
403
- }
247
+ return _setComponentsWriteThrough(this.provider, this.config, components, ttlOrRequested, ttlIfRequested);
404
248
  }
405
249
 
406
250
  // Relation negative-cache methods
@@ -470,7 +314,7 @@ export class CacheManager {
470
314
  try {
471
315
  const key = CacheManager.relationCacheKey(entityId, relationField, relatedType, foreignKey);
472
316
  await this.provider.delete(key);
473
- await this.publishInvalidation('key', [key]);
317
+ await this._publishInvalidation('key', [key]);
474
318
  } catch (error) {
475
319
  logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating relation tombstone', error });
476
320
  }
@@ -521,7 +365,7 @@ export class CacheManager {
521
365
  try {
522
366
  await this.provider.delete(key);
523
367
  const keys = Array.isArray(key) ? key : [key];
524
- await this.publishInvalidation('key', keys);
368
+ await this._publishInvalidation('key', keys);
525
369
  } catch (error) {
526
370
  logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error deleting from cache', error });
527
371
  }
@@ -537,7 +381,7 @@ export class CacheManager {
537
381
 
538
382
  try {
539
383
  await this.provider.clear();
540
- await this.publishInvalidation('pattern', undefined, '*');
384
+ await this._publishInvalidation('pattern', undefined, '*');
541
385
  } catch (error) {
542
386
  logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error clearing cache', error });
543
387
  }
@@ -547,30 +391,14 @@ export class CacheManager {
547
391
  * Get cache statistics
548
392
  */
549
393
  public async getStats() {
550
- try {
551
- return await this.provider.getStats();
552
- } catch (error) {
553
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting cache stats', error });
554
- return {
555
- hits: 0,
556
- misses: 0,
557
- hitRate: 0,
558
- size: 0,
559
- memoryUsage: 0
560
- };
561
- }
394
+ return _getStats(this.provider);
562
395
  }
563
396
 
564
397
  /**
565
398
  * Health check for cache
566
399
  */
567
400
  public async ping(): Promise<boolean> {
568
- try {
569
- return await this.provider.ping();
570
- } catch (error) {
571
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Cache ping failed', error });
572
- return false;
573
- }
401
+ return _ping(this.provider);
574
402
  }
575
403
 
576
404
  // --- Cross-instance pub/sub ---
@@ -580,21 +408,11 @@ export class CacheManager {
580
408
  * Only activates when using MultiLevel provider with a Redis L2.
581
409
  */
582
410
  private async setupPubSub(): Promise<void> {
583
- if (!(this.provider instanceof MultiLevelCache)) return;
584
-
585
- const l2 = this.provider.getL2Cache();
586
- if (!(l2 instanceof RedisCache)) return;
587
-
588
- try {
589
- await l2.subscribeInvalidation(
590
- CacheManager.INVALIDATION_CHANNEL,
591
- (_channel, message) => this.handleRemoteInvalidation(message)
592
- );
593
- this.pubSubEnabled = true;
594
- logger.info({ scope: 'cache', component: 'CacheManager', msg: 'Cross-instance cache invalidation enabled', instanceId: this.instanceId });
595
- } catch (error) {
596
- logger.warn({ scope: 'cache', component: 'CacheManager', msg: 'Failed to setup pub/sub', error });
597
- }
411
+ this.pubSubEnabled = await _setupPubSub(
412
+ this.provider,
413
+ this.instanceId,
414
+ (raw) => this.handleRemoteInvalidation(raw)
415
+ );
598
416
  }
599
417
 
600
418
  /**
@@ -602,43 +420,14 @@ export class CacheManager {
602
420
  * Ignores messages from self. Invalidates L1 only (L2 is shared Redis).
603
421
  */
604
422
  private async handleRemoteInvalidation(raw: string): Promise<void> {
605
- try {
606
- const msg: InvalidationMessage = JSON.parse(raw);
607
-
608
- // Ignore our own messages
609
- if (msg.instanceId === this.instanceId) return;
610
-
611
- if (!(this.provider instanceof MultiLevelCache)) return;
612
- const l1 = this.provider.getL1Cache();
613
-
614
- if (msg.type === 'key' && msg.keys) {
615
- await l1.deleteMany(msg.keys);
616
- } else if (msg.type === 'pattern' && msg.pattern) {
617
- await l1.invalidatePattern(msg.pattern);
618
- }
619
-
620
- logger.debug({ scope: 'cache', component: 'CacheManager', msg: 'Applied remote invalidation', from: msg.instanceId, type: msg.type });
621
- } catch (error) {
622
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error handling remote invalidation', error });
623
- }
423
+ return _handleRemoteInvalidation(this.provider, this.instanceId, raw);
624
424
  }
625
425
 
626
426
  /**
627
427
  * Publish an invalidation event to other instances via Redis pub/sub.
628
428
  */
629
- private async publishInvalidation(type: 'key' | 'pattern', keys?: string[], pattern?: string): Promise<void> {
630
- if (!this.pubSubEnabled) return;
631
- if (!(this.provider instanceof MultiLevelCache)) return;
632
-
633
- const l2 = this.provider.getL2Cache();
634
- if (!(l2 instanceof RedisCache)) return;
635
-
636
- try {
637
- const msg: InvalidationMessage = { instanceId: this.instanceId, type, keys, pattern };
638
- await l2.publishInvalidation(CacheManager.INVALIDATION_CHANNEL, JSON.stringify(msg));
639
- } catch (error) {
640
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error publishing invalidation', error });
641
- }
429
+ private async _publishInvalidation(type: 'key' | 'pattern', keys?: string[], pattern?: string): Promise<void> {
430
+ return _publishInvalidation(this.provider, this.pubSubEnabled, this.instanceId, type, keys, pattern);
642
431
  }
643
432
 
644
433
  /**
@@ -689,4 +478,4 @@ export class CacheManager {
689
478
  logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error shutting down cache', error });
690
479
  }
691
480
  }
692
- }
481
+ }
@@ -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
  */