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.
- package/CHANGELOG.md +445 -370
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +85 -1020
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +94 -85
- package/core/RequestLoaders.ts +98 -5
- package/core/SchedulerManager.ts +28 -600
- package/core/app/cors.ts +2 -11
- package/core/app/preparedStatementWarmup.ts +9 -49
- package/core/app/requestRouter.ts +9 -8
- package/core/app/restRegistry.ts +8 -0
- package/core/archetype/fieldResolvers.ts +85 -40
- package/core/archetype/relationLoader.ts +135 -92
- package/core/cache/CacheManager.ts +91 -302
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +4 -4
- package/core/cache/health.ts +30 -0
- package/core/cache/invalidation.ts +96 -0
- package/core/cache/strategies/writeInvalidate.ts +111 -0
- package/core/cache/strategies/writeThrough.ts +233 -0
- package/core/components/BaseComponent.ts +16 -8
- package/core/components/ComponentRegistry.ts +28 -0
- package/core/decorators/IndexedField.ts +1 -1
- package/core/entity/cacheStrategies.ts +97 -0
- package/core/entity/componentAccess.ts +364 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +377 -0
- package/core/hooks/dispatcher.ts +439 -0
- package/core/hooks/guards.ts +155 -0
- package/core/hooks/registry.ts +247 -0
- package/core/metadata/definitions/Component.ts +1 -1
- package/core/metadata/index.ts +15 -4
- package/core/middleware/RateLimit.ts +102 -105
- package/core/middleware/RequestId.ts +2 -9
- package/core/middleware/SecurityHeaders.ts +2 -11
- package/core/middleware/headers.ts +28 -0
- package/core/remote/OutboxWorker.ts +213 -183
- package/core/remote/RemoteManager.ts +401 -400
- package/core/remote/types.ts +153 -151
- package/core/requestScope.ts +34 -0
- package/core/scheduler/cronEvaluator.ts +174 -0
- package/core/scheduler/lifecycleHooks.ts +21 -0
- package/core/scheduler/lockCoordinator.ts +27 -0
- package/core/scheduler/metrics.ts +14 -0
- package/core/scheduler/taskRunner.ts +420 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +8 -2
- package/database/cancellable.ts +35 -22
- package/database/index.ts +15 -3
- package/database/instrumentedDb.ts +141 -141
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +240 -13
- package/query/OrNode.ts +6 -5
- package/query/Query.ts +157 -46
- package/query/QueryContext.ts +6 -0
- package/query/QueryDAG.ts +7 -2
- package/query/membershipSource.ts +66 -0
- package/storage/LocalStorageProvider.ts +8 -3
- package/studio/dist/assets/index-BMZ67Npg.js +254 -0
- package/studio/dist/assets/index-BpbuYz9g.css +1 -0
- package/studio/{index.html → dist/index.html} +3 -2
- package/swagger/generator.ts +11 -1
- package/upload/UploadManager.ts +8 -6
- package/utils/uuid.ts +40 -10
- package/.claude/scheduled_tasks.lock +0 -1
- package/.claude/settings.local.json +0 -47
- package/.prettierrc +0 -4
- package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
- package/.serena/memories/architecture.md +0 -154
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
- package/.serena/memories/code_style_and_conventions.md +0 -76
- package/.serena/memories/project_overview.md +0 -43
- package/.serena/memories/schema-dsl-plan.md +0 -107
- package/.serena/memories/suggested_commands.md +0 -80
- package/.serena/memories/typescript-compilation-status.md +0 -54
- package/.serena/project.yml +0 -114
- package/BunSane.jpg +0 -0
- package/CLAUDE.md +0 -198
- package/TODO.md +0 -2
- package/bun.lock +0 -302
- package/bunfig.toml +0 -10
- package/docs/RFC_APP_REFACTOR.md +0 -248
- package/docs/RFC_REFACTOR_TARGETS.md +0 -251
- package/docs/SCALABILITY_PLAN.md +0 -175
- package/studio/bun.lock +0 -482
- package/studio/package.json +0 -39
- package/studio/postcss.config.js +0 -6
- package/studio/src/components/DataTable.tsx +0 -211
- package/studio/src/components/Layout.tsx +0 -13
- package/studio/src/components/PageContainer.tsx +0 -9
- package/studio/src/components/PageHeader.tsx +0 -13
- package/studio/src/components/SearchBar.tsx +0 -57
- package/studio/src/components/Sidebar.tsx +0 -294
- package/studio/src/components/ui/button.tsx +0 -56
- package/studio/src/components/ui/checkbox.tsx +0 -26
- package/studio/src/components/ui/input.tsx +0 -25
- package/studio/src/hooks/useDataTable.ts +0 -131
- package/studio/src/index.css +0 -36
- package/studio/src/lib/api.ts +0 -186
- package/studio/src/lib/utils.ts +0 -13
- package/studio/src/main.tsx +0 -17
- package/studio/src/pages/ArcheType.tsx +0 -239
- package/studio/src/pages/Components.tsx +0 -124
- package/studio/src/pages/EntityInspector.tsx +0 -302
- package/studio/src/pages/QueryRunner.tsx +0 -246
- package/studio/src/pages/Table.tsx +0 -94
- package/studio/src/pages/Welcome.tsx +0 -241
- package/studio/src/routes.tsx +0 -45
- package/studio/src/store/archeTypeSettings.ts +0 -30
- package/studio/src/store/studio.ts +0 -65
- package/studio/src/utils/columnHelpers.tsx +0 -114
- package/studio/studio-instructions.md +0 -81
- package/studio/tailwind.config.js +0 -77
- package/studio/utils.ts +0 -54
- package/studio/vite.config.js +0 -19
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
- package/tests/benchmark/bunfig.toml +0 -9
- package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
- package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
- package/tests/benchmark/fixtures/index.ts +0 -6
- package/tests/benchmark/index.ts +0 -22
- package/tests/benchmark/noop-preload.ts +0 -3
- package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
- package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
- package/tests/benchmark/runners/index.ts +0 -4
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
- package/tests/benchmark/scripts/generate-db.ts +0 -344
- package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
- package/tests/e2e/http.test.ts +0 -130
- package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
- package/tests/fixtures/components/TestOrder.ts +0 -23
- package/tests/fixtures/components/TestProduct.ts +0 -23
- package/tests/fixtures/components/TestUser.ts +0 -20
- package/tests/fixtures/components/index.ts +0 -6
- package/tests/graphql/SchemaGeneration.test.ts +0 -90
- package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
- package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
- package/tests/helpers/MockRedisClient.ts +0 -113
- package/tests/helpers/MockRedisStreamServer.ts +0 -448
- package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
- package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
- package/tests/integration/entity/Entity.persistence.test.ts +0 -333
- package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
- package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
- package/tests/integration/query/Query.abort.test.ts +0 -66
- package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
- package/tests/integration/query/Query.edgeCases.test.ts +0 -595
- package/tests/integration/query/Query.exec.test.ts +0 -576
- package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
- package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
- package/tests/integration/remote/dlq.test.ts +0 -175
- package/tests/integration/remote/event-dispatch.test.ts +0 -114
- package/tests/integration/remote/outbox.test.ts +0 -130
- package/tests/integration/remote/rpc.test.ts +0 -177
- package/tests/pglite-setup.ts +0 -62
- package/tests/setup.ts +0 -164
- package/tests/stress/BenchmarkRunner.ts +0 -203
- package/tests/stress/DataSeeder.ts +0 -190
- package/tests/stress/StressTestReporter.ts +0 -229
- package/tests/stress/cursor-perf-test.ts +0 -171
- package/tests/stress/fixtures/RealisticComponents.ts +0 -235
- package/tests/stress/fixtures/StressTestComponents.ts +0 -58
- package/tests/stress/index.ts +0 -7
- package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
- package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
- package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
- package/tests/unit/BatchLoader.test.ts +0 -196
- package/tests/unit/archetype/ArcheType.test.ts +0 -107
- package/tests/unit/cache/CacheManager.test.ts +0 -498
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/database/cancellable.test.ts +0 -81
- package/tests/unit/database/instrumentedDb.test.ts +0 -160
- package/tests/unit/entity/Entity.components.test.ts +0 -317
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
- package/tests/unit/entity/Entity.reload.test.ts +0 -63
- package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
- package/tests/unit/entity/Entity.test.ts +0 -345
- package/tests/unit/gql/depthLimit.test.ts +0 -203
- package/tests/unit/gql/operationMiddleware.test.ts +0 -293
- package/tests/unit/health/Health.test.ts +0 -129
- package/tests/unit/middleware/AccessLog.test.ts +0 -37
- package/tests/unit/middleware/Middleware.test.ts +0 -98
- package/tests/unit/middleware/RequestId.test.ts +0 -54
- package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
- package/tests/unit/query/FilterBuilder.test.ts +0 -111
- package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
- package/tests/unit/query/Query.emptyString.test.ts +0 -69
- package/tests/unit/query/Query.test.ts +0 -310
- package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
- package/tests/unit/remote/RemoteError.test.ts +0 -55
- package/tests/unit/remote/decorators.test.ts +0 -195
- package/tests/unit/remote/metrics.test.ts +0 -115
- package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
- package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
- package/tests/unit/schema/schema-integration.test.ts +0 -426
- package/tests/unit/schema/schema.test.ts +0 -580
- package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
- package/tests/unit/upload/RestUpload.test.ts +0 -267
- package/tests/unit/validateEnv.test.ts +0 -82
- package/tests/utils/entity-tracker.ts +0 -57
- package/tests/utils/index.ts +0 -13
- 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
184
|
+
return _invalidateComponent(this.provider, this.config, this._publishInvalidation.bind(this), entityId, typeId);
|
|
185
|
+
}
|
|
264
186
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
|
630
|
-
|
|
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
|
*/
|