bunsane 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/CHANGELOG.md +445 -318
  2. package/config/cache.config.ts +35 -1
  3. package/core/App.ts +24 -1064
  4. package/core/ArcheType.ts +78 -2110
  5. package/core/BatchLoader.ts +56 -32
  6. package/core/Entity.ts +85 -1043
  7. package/core/EntityHookManager.ts +52 -754
  8. package/core/Logger.ts +10 -0
  9. package/core/RequestContext.ts +64 -6
  10. package/core/RequestLoaders.ts +187 -36
  11. package/core/SchedulerManager.ts +28 -600
  12. package/core/app/bootstrap.ts +133 -0
  13. package/core/app/cors.ts +85 -0
  14. package/core/app/graphqlSetup.ts +56 -0
  15. package/core/app/healthEndpoints.ts +31 -0
  16. package/core/app/metricsCollector.ts +27 -0
  17. package/core/app/preparedStatementWarmup.ts +15 -0
  18. package/core/app/processHandlers.ts +43 -0
  19. package/core/app/requestRouter.ts +310 -0
  20. package/core/app/restRegistry.ts +80 -0
  21. package/core/app/shutdown.ts +97 -0
  22. package/core/app/studioRouter.ts +83 -0
  23. package/core/archetype/customTypes.ts +100 -0
  24. package/core/archetype/decorators.ts +171 -0
  25. package/core/archetype/fieldResolvers.ts +666 -0
  26. package/core/archetype/helpers.ts +29 -0
  27. package/core/archetype/relationLoader.ts +161 -0
  28. package/core/archetype/schemaBuilder.ts +141 -0
  29. package/core/archetype/weaver.ts +218 -0
  30. package/core/archetype/zodSchemaBuilder.ts +527 -0
  31. package/core/cache/CacheManager.ts +173 -267
  32. package/core/cache/CompressionUtils.ts +34 -3
  33. package/core/cache/MemoryCache.ts +40 -37
  34. package/core/cache/RedisCache.ts +4 -4
  35. package/core/cache/health.ts +30 -0
  36. package/core/cache/invalidation.ts +96 -0
  37. package/core/cache/strategies/writeInvalidate.ts +111 -0
  38. package/core/cache/strategies/writeThrough.ts +233 -0
  39. package/core/components/BaseComponent.ts +16 -8
  40. package/core/components/ComponentRegistry.ts +28 -0
  41. package/core/decorators/IndexedField.ts +1 -1
  42. package/core/entity/cacheStrategies.ts +97 -0
  43. package/core/entity/componentAccess.ts +364 -0
  44. package/core/entity/finders.ts +202 -0
  45. package/core/entity/pendingOps.ts +72 -0
  46. package/core/entity/saveEntity.ts +377 -0
  47. package/core/hooks/dispatcher.ts +439 -0
  48. package/core/hooks/guards.ts +155 -0
  49. package/core/hooks/registry.ts +247 -0
  50. package/core/metadata/definitions/Component.ts +1 -1
  51. package/core/metadata/index.ts +15 -4
  52. package/core/middleware/AccessLog.ts +8 -1
  53. package/core/middleware/RateLimit.ts +102 -105
  54. package/core/middleware/RequestId.ts +2 -9
  55. package/core/middleware/SecurityHeaders.ts +2 -11
  56. package/core/middleware/headers.ts +28 -0
  57. package/core/remote/OutboxWorker.ts +213 -183
  58. package/core/remote/RemoteManager.ts +401 -400
  59. package/core/remote/types.ts +153 -151
  60. package/core/requestScope.ts +34 -0
  61. package/core/scheduler/cronEvaluator.ts +174 -0
  62. package/core/scheduler/lifecycleHooks.ts +21 -0
  63. package/core/scheduler/lockCoordinator.ts +27 -0
  64. package/core/scheduler/metrics.ts +14 -0
  65. package/core/scheduler/taskRunner.ts +420 -0
  66. package/database/DatabaseHelper.ts +128 -101
  67. package/database/IndexingStrategy.ts +72 -2
  68. package/database/PreparedStatementCache.ts +20 -5
  69. package/database/cancellable.ts +35 -0
  70. package/database/index.ts +15 -3
  71. package/database/instrumentedDb.ts +141 -0
  72. package/endpoints/archetypes.ts +2 -8
  73. package/endpoints/tables.ts +6 -1
  74. package/gql/index.ts +1 -1
  75. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  76. package/package.json +22 -1
  77. package/query/CTENode.ts +5 -3
  78. package/query/ComponentInclusionNode.ts +240 -13
  79. package/query/OrNode.ts +6 -5
  80. package/query/Query.ts +203 -59
  81. package/query/QueryContext.ts +6 -0
  82. package/query/QueryDAG.ts +7 -2
  83. package/query/membershipSource.ts +66 -0
  84. package/storage/LocalStorageProvider.ts +8 -3
  85. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  86. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  87. package/studio/{index.html → dist/index.html} +3 -2
  88. package/swagger/generator.ts +11 -1
  89. package/upload/UploadManager.ts +8 -6
  90. package/utils/uuid.ts +40 -10
  91. package/.claude/settings.local.json +0 -47
  92. package/.prettierrc +0 -4
  93. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  94. package/.serena/memories/architecture.md +0 -154
  95. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  96. package/.serena/memories/code_style_and_conventions.md +0 -76
  97. package/.serena/memories/project_overview.md +0 -43
  98. package/.serena/memories/schema-dsl-plan.md +0 -107
  99. package/.serena/memories/suggested_commands.md +0 -80
  100. package/.serena/memories/typescript-compilation-status.md +0 -54
  101. package/.serena/project.yml +0 -114
  102. package/BunSane.jpg +0 -0
  103. package/CLAUDE.md +0 -198
  104. package/TODO.md +0 -2
  105. package/bun.lock +0 -302
  106. package/bunfig.toml +0 -10
  107. package/docs/SCALABILITY_PLAN.md +0 -175
  108. package/studio/bun.lock +0 -482
  109. package/studio/package.json +0 -39
  110. package/studio/postcss.config.js +0 -6
  111. package/studio/src/components/DataTable.tsx +0 -211
  112. package/studio/src/components/Layout.tsx +0 -13
  113. package/studio/src/components/PageContainer.tsx +0 -9
  114. package/studio/src/components/PageHeader.tsx +0 -13
  115. package/studio/src/components/SearchBar.tsx +0 -57
  116. package/studio/src/components/Sidebar.tsx +0 -294
  117. package/studio/src/components/ui/button.tsx +0 -56
  118. package/studio/src/components/ui/checkbox.tsx +0 -26
  119. package/studio/src/components/ui/input.tsx +0 -25
  120. package/studio/src/hooks/useDataTable.ts +0 -131
  121. package/studio/src/index.css +0 -36
  122. package/studio/src/lib/api.ts +0 -186
  123. package/studio/src/lib/utils.ts +0 -13
  124. package/studio/src/main.tsx +0 -17
  125. package/studio/src/pages/ArcheType.tsx +0 -239
  126. package/studio/src/pages/Components.tsx +0 -124
  127. package/studio/src/pages/EntityInspector.tsx +0 -302
  128. package/studio/src/pages/QueryRunner.tsx +0 -246
  129. package/studio/src/pages/Table.tsx +0 -94
  130. package/studio/src/pages/Welcome.tsx +0 -241
  131. package/studio/src/routes.tsx +0 -45
  132. package/studio/src/store/archeTypeSettings.ts +0 -30
  133. package/studio/src/store/studio.ts +0 -65
  134. package/studio/src/utils/columnHelpers.tsx +0 -114
  135. package/studio/studio-instructions.md +0 -81
  136. package/studio/tailwind.config.js +0 -77
  137. package/studio/utils.ts +0 -54
  138. package/studio/vite.config.js +0 -19
  139. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  140. package/tests/benchmark/bunfig.toml +0 -9
  141. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  142. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  143. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  144. package/tests/benchmark/fixtures/index.ts +0 -6
  145. package/tests/benchmark/index.ts +0 -22
  146. package/tests/benchmark/noop-preload.ts +0 -3
  147. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  148. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  149. package/tests/benchmark/runners/index.ts +0 -4
  150. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  151. package/tests/benchmark/scripts/generate-db.ts +0 -344
  152. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  153. package/tests/e2e/http.test.ts +0 -130
  154. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  155. package/tests/fixtures/components/TestOrder.ts +0 -23
  156. package/tests/fixtures/components/TestProduct.ts +0 -23
  157. package/tests/fixtures/components/TestUser.ts +0 -20
  158. package/tests/fixtures/components/index.ts +0 -6
  159. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  160. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  161. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  162. package/tests/helpers/MockRedisClient.ts +0 -113
  163. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  164. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  165. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  166. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  167. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  168. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  169. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  170. package/tests/integration/query/Query.exec.test.ts +0 -576
  171. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  172. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  173. package/tests/integration/remote/dlq.test.ts +0 -175
  174. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  175. package/tests/integration/remote/outbox.test.ts +0 -130
  176. package/tests/integration/remote/rpc.test.ts +0 -177
  177. package/tests/pglite-setup.ts +0 -62
  178. package/tests/setup.ts +0 -164
  179. package/tests/stress/BenchmarkRunner.ts +0 -203
  180. package/tests/stress/DataSeeder.ts +0 -190
  181. package/tests/stress/StressTestReporter.ts +0 -229
  182. package/tests/stress/cursor-perf-test.ts +0 -171
  183. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  184. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  185. package/tests/stress/index.ts +0 -7
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  187. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  188. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  189. package/tests/unit/BatchLoader.test.ts +0 -196
  190. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  191. package/tests/unit/cache/CacheManager.test.ts +0 -367
  192. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  193. package/tests/unit/cache/RedisCache.test.ts +0 -411
  194. package/tests/unit/entity/Entity.components.test.ts +0 -317
  195. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  196. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  197. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  198. package/tests/unit/entity/Entity.test.ts +0 -345
  199. package/tests/unit/gql/depthLimit.test.ts +0 -203
  200. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  201. package/tests/unit/health/Health.test.ts +0 -129
  202. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  203. package/tests/unit/middleware/Middleware.test.ts +0 -98
  204. package/tests/unit/middleware/RequestId.test.ts +0 -54
  205. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  206. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  207. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  208. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  209. package/tests/unit/query/Query.test.ts +0 -310
  210. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  211. package/tests/unit/remote/RemoteError.test.ts +0 -55
  212. package/tests/unit/remote/decorators.test.ts +0 -195
  213. package/tests/unit/remote/metrics.test.ts +0 -115
  214. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  215. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  216. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  217. package/tests/unit/schema/schema-integration.test.ts +0 -426
  218. package/tests/unit/schema/schema.test.ts +0 -580
  219. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  220. package/tests/unit/upload/RestUpload.test.ts +0 -267
  221. package/tests/unit/validateEnv.test.ts +0 -82
  222. package/tests/utils/entity-tracker.ts +0 -57
  223. package/tests/utils/index.ts +0 -13
  224. package/tests/utils/test-context.ts +0 -149
@@ -2,18 +2,48 @@ 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';
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';
10
37
 
11
- interface InvalidationMessage {
12
- instanceId: string;
13
- type: 'key' | 'pattern';
14
- keys?: string[];
15
- pattern?: string;
16
- }
38
+ /**
39
+ * Sentinel value written to the cache to record "known absent" lookups.
40
+ * String literal (not object) so it round-trips cleanly through
41
+ * JSON.stringify in RedisCache + CompressionUtils. Callers must treat it
42
+ * as a cache hit but propagate a `null`/`[]` upstream.
43
+ */
44
+ export const COMPONENT_TOMBSTONE = '__TOMBSTONE__' as const;
45
+ export const RELATION_TOMBSTONE = '__TOMBSTONE__' as const;
46
+ export type ComponentCacheValue = ComponentData | typeof COMPONENT_TOMBSTONE;
17
47
 
18
48
  /**
19
49
  * High-level cache operations manager
@@ -23,13 +53,12 @@ interface InvalidationMessage {
23
53
  export class CacheManager {
24
54
  private static instance: CacheManager;
25
55
  private provider: CacheProvider;
26
- private config: CacheConfig;
56
+ private config: Readonly<CacheConfig>;
27
57
  private instanceId = crypto.randomUUID();
28
58
  private pubSubEnabled = false;
29
- private static readonly INVALIDATION_CHANNEL = 'bunsane:cache:invalidate';
30
59
 
31
60
  private constructor() {
32
- this.config = defaultCacheConfig;
61
+ this.config = Object.freeze({ ...defaultCacheConfig });
33
62
  this.provider = CacheFactory.create(this.config);
34
63
  }
35
64
 
@@ -48,7 +77,7 @@ export class CacheManager {
48
77
  await this.shutdownProvider();
49
78
  this.pubSubEnabled = false;
50
79
 
51
- this.config = { ...defaultCacheConfig, ...config };
80
+ this.config = Object.freeze({ ...defaultCacheConfig, ...config });
52
81
  this.provider = CacheFactory.create(this.config);
53
82
 
54
83
  await this.setupPubSub();
@@ -57,10 +86,11 @@ export class CacheManager {
57
86
  }
58
87
 
59
88
  /**
60
- * Get the current cache configuration
89
+ * Get the current cache configuration.
90
+ * Config is frozen once set so callers may hold the reference safely.
61
91
  */
62
- public getConfig(): CacheConfig {
63
- return { ...this.config };
92
+ public getConfig(): Readonly<CacheConfig> {
93
+ return this.config;
64
94
  }
65
95
 
66
96
  /**
@@ -77,18 +107,7 @@ export class CacheManager {
77
107
  * Returns entity ID if exists, null if not found
78
108
  */
79
109
  public async getEntity(id: string): Promise<string | null> {
80
- if (!this.config.enabled || !this.config.entity?.enabled) {
81
- return null;
82
- }
83
-
84
- try {
85
- const key = `entity:${id}`;
86
- const result = await this.provider.get<string>(key);
87
- return result || null;
88
- } catch (error) {
89
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting entity from cache', error });
90
- return null;
91
- }
110
+ return _getEntity(this.provider, this.config, id);
92
111
  }
93
112
 
94
113
  /**
@@ -96,35 +115,14 @@ export class CacheManager {
96
115
  * Only caches entity ID for existence tracking, not full entity data
97
116
  */
98
117
  public async setEntityWriteThrough(entity: Entity, ttl?: number): Promise<void> {
99
- if (!this.config.enabled || !this.config.entity?.enabled) {
100
- return;
101
- }
102
-
103
- try {
104
- const key = `entity:${entity.id}`;
105
- const effectiveTTL = ttl ?? this.config.entity.ttl;
106
- // Only cache entity ID for existence check
107
- await this.provider.set(key, entity.id, effectiveTTL);
108
- } catch (error) {
109
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting entity in cache', error });
110
- }
118
+ return _setEntityWriteThrough(this.provider, this.config, entity, ttl);
111
119
  }
112
120
 
113
121
  /**
114
122
  * Invalidate an entity from cache
115
123
  */
116
124
  public async invalidateEntity(id: string): Promise<void> {
117
- if (!this.config.enabled || !this.config.entity?.enabled) {
118
- return;
119
- }
120
-
121
- try {
122
- const key = `entity:${id}`;
123
- await this.provider.delete(key);
124
- await this.publishInvalidation('key', [key]);
125
- } catch (error) {
126
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating entity from cache', error });
127
- }
125
+ return _invalidateEntity(this.provider, this.config, this._publishInvalidation.bind(this), id);
128
126
  }
129
127
 
130
128
  /**
@@ -132,18 +130,7 @@ export class CacheManager {
132
130
  * Returns entity IDs if they exist, null if not found
133
131
  */
134
132
  public async getEntities(ids: string[]): Promise<(string | null)[]> {
135
- if (!this.config.enabled || !this.config.entity?.enabled) {
136
- return ids.map(() => null);
137
- }
138
-
139
- try {
140
- const cacheKeys = ids.map(id => `entity:${id}`);
141
- const results = await this.provider.getMany<string>(cacheKeys);
142
- return results;
143
- } catch (error) {
144
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting entities from cache', error });
145
- return ids.map(() => null);
146
- }
133
+ return _getEntities(this.provider, this.config, ids);
147
134
  }
148
135
 
149
136
  /**
@@ -151,22 +138,7 @@ export class CacheManager {
151
138
  * Only caches entity IDs for existence tracking, not full entity data
152
139
  */
153
140
  public async setEntitiesWriteThrough(entities: Entity[], ttl?: number): Promise<void> {
154
- if (!this.config.enabled || !this.config.entity?.enabled) {
155
- return;
156
- }
157
-
158
- try {
159
- const effectiveTTL = ttl ?? this.config.entity?.ttl;
160
- const entries = entities.map(entity => ({
161
- key: `entity:${entity.id}`,
162
- // Only cache entity ID for existence check
163
- value: entity.id,
164
- ttl: effectiveTTL
165
- }));
166
- await this.provider.setMany(entries);
167
- } catch (error) {
168
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting entities in cache', error });
169
- }
141
+ return _setEntitiesWriteThrough(this.provider, this.config, entities, ttl);
170
142
  }
171
143
 
172
144
  // Component caching methods
@@ -175,72 +147,33 @@ export class CacheManager {
175
147
  * Get components for an entity from cache
176
148
  */
177
149
  public async getComponentsByEntity(entityId: string, componentType?: string): Promise<BaseComponent[] | null> {
178
- if (!this.config.enabled || !this.config.component?.enabled) {
179
- return null;
180
- }
181
-
182
- try {
183
- const key = componentType
184
- ? `component:${entityId}:${componentType}`
185
- : `components:${entityId}`;
186
- return await this.provider.get<BaseComponent[]>(key);
187
- } catch (error) {
188
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting components from cache', error });
189
- return null;
190
- }
150
+ return _getComponentsByEntity(this.provider, this.config, entityId, componentType);
191
151
  }
192
152
 
193
153
  /**
194
154
  * Set components for an entity in cache with write-through strategy.
195
155
  * Converts BaseComponent instances to ComponentData format for cache compatibility with DataLoader.
156
+ * Delegates to setComponentsBatchWriteThrough for a single-entity, 2-RTT batch.
196
157
  */
197
158
  public async setComponentWriteThrough(entityId: string, components: BaseComponent[], componentType?: string, ttl?: number): Promise<void> {
198
- if (!this.config.enabled || !this.config.component?.enabled) {
199
- return;
200
- }
201
-
202
- try {
203
- const effectiveTTL = ttl ?? this.config.component.ttl;
204
-
205
- // Convert BaseComponent to ComponentData format for cache
206
- // compatibility with DataLoader. BaseComponent does not track
207
- // createdAt/updatedAt today (data-model gap), but we preserve an
208
- // existing cache entry's createdAt when available and stamp
209
- // updatedAt=now, so consumers see monotonic update times rather
210
- // than a reset on every write-through (H-CACHE-3 — full fix
211
- // requires BaseComponent timestamp tracking).
212
- for (const component of components) {
213
- const typeId = componentType || component.getTypeID();
214
- const key = `component:${entityId}:${typeId}`;
215
-
216
- const now = new Date();
217
- let createdAt: Date = now;
218
- try {
219
- const existing = await this.provider.get<ComponentData>(key);
220
- if (existing && existing.createdAt) {
221
- createdAt = existing.createdAt instanceof Date
222
- ? existing.createdAt
223
- : new Date(existing.createdAt);
224
- }
225
- } catch {
226
- // Cache miss or provider error — fall through to now.
227
- }
159
+ return _setComponentWriteThrough(this.provider, this.config, entityId, components, componentType, ttl);
160
+ }
228
161
 
229
- const componentData: ComponentData = {
230
- id: component.id,
231
- entityId: entityId,
232
- typeId: typeId,
233
- data: component.data(),
234
- createdAt,
235
- updatedAt: now,
236
- deletedAt: null
237
- };
238
-
239
- await this.provider.set(key, componentData, effectiveTTL);
240
- }
241
- } catch (error) {
242
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting components in cache', err: error });
243
- }
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);
244
177
  }
245
178
 
246
179
  /**
@@ -248,22 +181,20 @@ export class CacheManager {
248
181
  * More granular than invalidateComponents which can invalidate all components
249
182
  */
250
183
  public async invalidateComponent(entityId: string, typeId: string): Promise<void> {
251
- if (!this.config.enabled || !this.config.component?.enabled) {
252
- return;
253
- }
184
+ return _invalidateComponent(this.provider, this.config, this._publishInvalidation.bind(this), entityId, typeId);
185
+ }
254
186
 
255
- try {
256
- logger.trace({
257
- msg: 'Invalidating component from cache',
258
- entityId,
259
- typeId
260
- })
261
- const key = `component:${entityId}:${typeId}`;
262
- await this.provider.delete(key);
263
- await this.publishInvalidation('key', [key]);
264
- } catch (error) {
265
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating component from cache', error });
266
- }
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);
267
198
  }
268
199
 
269
200
  /**
@@ -271,17 +202,7 @@ export class CacheManager {
271
202
  * Useful for bulk invalidation operations
272
203
  */
273
204
  public async invalidateComponents(components: Array<{ entityId: string; typeId: string }>): Promise<void> {
274
- if (!this.config.enabled || !this.config.component?.enabled) {
275
- return;
276
- }
277
-
278
- try {
279
- const keys = components.map(comp => `component:${comp.entityId}:${comp.typeId}`);
280
- await this.provider.deleteMany(keys);
281
- await this.publishInvalidation('key', keys);
282
- } catch (error) {
283
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating components from cache', error });
284
- }
205
+ return _invalidateComponents(this.provider, this.config, this._publishInvalidation.bind(this), components);
285
206
  }
286
207
 
287
208
  /**
@@ -291,15 +212,7 @@ export class CacheManager {
291
212
  * stale L1/L2 cache entries.
292
213
  */
293
214
  public async invalidateEntities(entityIds: string[]): Promise<void> {
294
- if (!this.config.enabled || entityIds.length === 0) {
295
- return;
296
- }
297
- await Promise.all(
298
- entityIds.flatMap(id => [
299
- this.invalidateEntity(id),
300
- this.invalidateAllEntityComponents(id),
301
- ])
302
- );
215
+ return _invalidateEntities(this.provider, this.config, this._publishInvalidation.bind(this), entityIds);
303
216
  }
304
217
 
305
218
  /**
@@ -307,55 +220,103 @@ export class CacheManager {
307
220
  * Uses pattern matching to efficiently clear all component caches for an entity
308
221
  */
309
222
  public async invalidateAllEntityComponents(entityId: string): Promise<void> {
310
- if (!this.config.enabled || !this.config.component?.enabled) {
311
- return;
312
- }
223
+ return _invalidateAllEntityComponents(this.provider, this.config, this._publishInvalidation.bind(this), entityId);
224
+ }
225
+
226
+ /**
227
+ * Get components by entity and type from cache (for DataLoader integration).
228
+ * Returns COMPONENT_TOMBSTONE for keys whose absence was previously
229
+ * recorded; callers must treat this as a hit and propagate null upstream.
230
+ */
231
+ public async getComponents(keys: Array<{ entityId: string; typeId: string }>): Promise<(ComponentCacheValue | null)[]> {
232
+ return _getComponents(this.provider, this.config, keys);
233
+ }
313
234
 
235
+ /**
236
+ * Set components in cache with write-through strategy (for DataLoader integration).
237
+ *
238
+ * When `requestedKeys` is supplied and `component.negativeCacheEnabled` is
239
+ * true, tombstones are written for any requested key not present in
240
+ * `components` (within the same setMany call — single round-trip).
241
+ */
242
+ public async setComponentsWriteThrough(
243
+ components: ComponentData[],
244
+ ttlOrRequested?: number | Array<{ entityId: string; typeId: string }>,
245
+ ttlIfRequested?: number,
246
+ ): Promise<void> {
247
+ return _setComponentsWriteThrough(this.provider, this.config, components, ttlOrRequested, ttlIfRequested);
248
+ }
249
+
250
+ // Relation negative-cache methods
251
+
252
+ /**
253
+ * Build the cache key for a relation tombstone. Null byte separator
254
+ * prevents collision when relationField contains hyphens or colons.
255
+ */
256
+ private static relationCacheKey(entityId: string, relationField: string, relatedType: string, foreignKey?: string): string {
257
+ const fk = foreignKey ?? '';
258
+ return `relation:${entityId}\x00${relationField}\x00${relatedType}\x00${fk}`;
259
+ }
260
+
261
+ /**
262
+ * Bulk-check relation tombstones. Returns true at index i when the
263
+ * relation at keys[i] was previously recorded as empty.
264
+ */
265
+ public async getRelationsEmpty(
266
+ keys: Array<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }>,
267
+ ): Promise<boolean[]> {
268
+ if (!this.config.enabled || !this.config.relation?.negativeCacheEnabled) {
269
+ return keys.map(() => false);
270
+ }
314
271
  try {
315
- const pattern = `component:${entityId}:*`;
316
- await this.provider.invalidatePattern(pattern);
317
- await this.publishInvalidation('pattern', undefined, pattern);
272
+ const cacheKeys = keys.map(k => CacheManager.relationCacheKey(k.entityId, k.relationField, k.relatedType, k.foreignKey));
273
+ const values = await this.provider.getMany<string>(cacheKeys);
274
+ return values.map(v => v === RELATION_TOMBSTONE);
318
275
  } catch (error) {
319
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating all entity components from cache', error });
276
+ logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting relation tombstones', error });
277
+ return keys.map(() => false);
320
278
  }
321
279
  }
322
280
 
323
281
  /**
324
- * Get components by entity and type from cache (for DataLoader integration)
282
+ * Record relation tombstones for keys whose query returned []. TTL
283
+ * defaults to relation.negativeCacheTtl (60s).
325
284
  */
326
- public async getComponents(keys: Array<{ entityId: string; typeId: string }>): Promise<(ComponentData | null)[]> {
327
- if (!this.config.enabled || !this.config.component?.enabled) {
328
- return keys.map(() => null);
285
+ public async setRelationsEmpty(
286
+ keys: Array<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }>,
287
+ ttl?: number,
288
+ ): Promise<void> {
289
+ if (!this.config.enabled || !this.config.relation?.negativeCacheEnabled || keys.length === 0) {
290
+ return;
329
291
  }
330
-
331
292
  try {
332
- const cacheKeys = keys.map(k => `component:${k.entityId}:${k.typeId}`);
333
- const results = await this.provider.getMany<ComponentData>(cacheKeys);
334
- return results;
293
+ const effectiveTTL = ttl ?? this.config.relation.negativeCacheTtl ?? 60_000;
294
+ const entries = keys.map(k => ({
295
+ key: CacheManager.relationCacheKey(k.entityId, k.relationField, k.relatedType, k.foreignKey),
296
+ value: RELATION_TOMBSTONE,
297
+ ttl: effectiveTTL,
298
+ }));
299
+ await this.provider.setMany(entries);
335
300
  } catch (error) {
336
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting components from cache', error });
337
- return keys.map(() => null);
301
+ logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting relation tombstones', error });
338
302
  }
339
303
  }
340
304
 
341
305
  /**
342
- * Set components in cache with write-through strategy (for DataLoader integration)
306
+ * Drop a relation tombstone. Call when a target component is created
307
+ * that may newly satisfy the relation. Pub/sub invalidation is wired
308
+ * identically to component invalidation.
343
309
  */
344
- public async setComponentsWriteThrough(components: ComponentData[], ttl?: number): Promise<void> {
345
- if (!this.config.enabled || !this.config.component?.enabled) {
310
+ public async invalidateRelation(entityId: string, relationField: string, relatedType: string, foreignKey?: string): Promise<void> {
311
+ if (!this.config.enabled || !this.config.relation?.negativeCacheEnabled) {
346
312
  return;
347
313
  }
348
-
349
314
  try {
350
- const effectiveTTL = ttl ?? this.config.component?.ttl;
351
- const entries = components.map(comp => ({
352
- key: `component:${comp.entityId}:${comp.typeId}`,
353
- value: comp,
354
- ttl: effectiveTTL
355
- }));
356
- await this.provider.setMany(entries);
315
+ const key = CacheManager.relationCacheKey(entityId, relationField, relatedType, foreignKey);
316
+ await this.provider.delete(key);
317
+ await this._publishInvalidation('key', [key]);
357
318
  } catch (error) {
358
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting components in cache', error });
319
+ logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating relation tombstone', error });
359
320
  }
360
321
  }
361
322
 
@@ -404,7 +365,7 @@ export class CacheManager {
404
365
  try {
405
366
  await this.provider.delete(key);
406
367
  const keys = Array.isArray(key) ? key : [key];
407
- await this.publishInvalidation('key', keys);
368
+ await this._publishInvalidation('key', keys);
408
369
  } catch (error) {
409
370
  logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error deleting from cache', error });
410
371
  }
@@ -420,7 +381,7 @@ export class CacheManager {
420
381
 
421
382
  try {
422
383
  await this.provider.clear();
423
- await this.publishInvalidation('pattern', undefined, '*');
384
+ await this._publishInvalidation('pattern', undefined, '*');
424
385
  } catch (error) {
425
386
  logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error clearing cache', error });
426
387
  }
@@ -430,30 +391,14 @@ export class CacheManager {
430
391
  * Get cache statistics
431
392
  */
432
393
  public async getStats() {
433
- try {
434
- return await this.provider.getStats();
435
- } catch (error) {
436
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting cache stats', error });
437
- return {
438
- hits: 0,
439
- misses: 0,
440
- hitRate: 0,
441
- size: 0,
442
- memoryUsage: 0
443
- };
444
- }
394
+ return _getStats(this.provider);
445
395
  }
446
396
 
447
397
  /**
448
398
  * Health check for cache
449
399
  */
450
400
  public async ping(): Promise<boolean> {
451
- try {
452
- return await this.provider.ping();
453
- } catch (error) {
454
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Cache ping failed', error });
455
- return false;
456
- }
401
+ return _ping(this.provider);
457
402
  }
458
403
 
459
404
  // --- Cross-instance pub/sub ---
@@ -463,21 +408,11 @@ export class CacheManager {
463
408
  * Only activates when using MultiLevel provider with a Redis L2.
464
409
  */
465
410
  private async setupPubSub(): Promise<void> {
466
- if (!(this.provider instanceof MultiLevelCache)) return;
467
-
468
- const l2 = this.provider.getL2Cache();
469
- if (!(l2 instanceof RedisCache)) return;
470
-
471
- try {
472
- await l2.subscribeInvalidation(
473
- CacheManager.INVALIDATION_CHANNEL,
474
- (_channel, message) => this.handleRemoteInvalidation(message)
475
- );
476
- this.pubSubEnabled = true;
477
- logger.info({ scope: 'cache', component: 'CacheManager', msg: 'Cross-instance cache invalidation enabled', instanceId: this.instanceId });
478
- } catch (error) {
479
- logger.warn({ scope: 'cache', component: 'CacheManager', msg: 'Failed to setup pub/sub', error });
480
- }
411
+ this.pubSubEnabled = await _setupPubSub(
412
+ this.provider,
413
+ this.instanceId,
414
+ (raw) => this.handleRemoteInvalidation(raw)
415
+ );
481
416
  }
482
417
 
483
418
  /**
@@ -485,43 +420,14 @@ export class CacheManager {
485
420
  * Ignores messages from self. Invalidates L1 only (L2 is shared Redis).
486
421
  */
487
422
  private async handleRemoteInvalidation(raw: string): Promise<void> {
488
- try {
489
- const msg: InvalidationMessage = JSON.parse(raw);
490
-
491
- // Ignore our own messages
492
- if (msg.instanceId === this.instanceId) return;
493
-
494
- if (!(this.provider instanceof MultiLevelCache)) return;
495
- const l1 = this.provider.getL1Cache();
496
-
497
- if (msg.type === 'key' && msg.keys) {
498
- await l1.deleteMany(msg.keys);
499
- } else if (msg.type === 'pattern' && msg.pattern) {
500
- await l1.invalidatePattern(msg.pattern);
501
- }
502
-
503
- logger.debug({ scope: 'cache', component: 'CacheManager', msg: 'Applied remote invalidation', from: msg.instanceId, type: msg.type });
504
- } catch (error) {
505
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error handling remote invalidation', error });
506
- }
423
+ return _handleRemoteInvalidation(this.provider, this.instanceId, raw);
507
424
  }
508
425
 
509
426
  /**
510
427
  * Publish an invalidation event to other instances via Redis pub/sub.
511
428
  */
512
- private async publishInvalidation(type: 'key' | 'pattern', keys?: string[], pattern?: string): Promise<void> {
513
- if (!this.pubSubEnabled) return;
514
- if (!(this.provider instanceof MultiLevelCache)) return;
515
-
516
- const l2 = this.provider.getL2Cache();
517
- if (!(l2 instanceof RedisCache)) return;
518
-
519
- try {
520
- const msg: InvalidationMessage = { instanceId: this.instanceId, type, keys, pattern };
521
- await l2.publishInvalidation(CacheManager.INVALIDATION_CHANNEL, JSON.stringify(msg));
522
- } catch (error) {
523
- logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error publishing invalidation', error });
524
- }
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);
525
431
  }
526
432
 
527
433
  /**
@@ -572,4 +478,4 @@ export class CacheManager {
572
478
  logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error shutting down cache', error });
573
479
  }
574
480
  }
575
- }
481
+ }