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.
- package/CHANGELOG.md +445 -318
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1064
- package/core/ArcheType.ts +78 -2110
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +85 -1043
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +64 -6
- package/core/RequestLoaders.ts +187 -36
- package/core/SchedulerManager.ts +28 -600
- package/core/app/bootstrap.ts +133 -0
- package/core/app/cors.ts +85 -0
- package/core/app/graphqlSetup.ts +56 -0
- package/core/app/healthEndpoints.ts +31 -0
- package/core/app/metricsCollector.ts +27 -0
- package/core/app/preparedStatementWarmup.ts +15 -0
- package/core/app/processHandlers.ts +43 -0
- package/core/app/requestRouter.ts +310 -0
- package/core/app/restRegistry.ts +80 -0
- package/core/app/shutdown.ts +97 -0
- package/core/app/studioRouter.ts +83 -0
- package/core/archetype/customTypes.ts +100 -0
- package/core/archetype/decorators.ts +171 -0
- package/core/archetype/fieldResolvers.ts +666 -0
- package/core/archetype/helpers.ts +29 -0
- package/core/archetype/relationLoader.ts +161 -0
- package/core/archetype/schemaBuilder.ts +141 -0
- package/core/archetype/weaver.ts +218 -0
- package/core/archetype/zodSchemaBuilder.ts +527 -0
- package/core/cache/CacheManager.ts +173 -267
- 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/AccessLog.ts +8 -1
- 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 +20 -5
- package/database/cancellable.ts +35 -0
- package/database/index.ts +15 -3
- package/database/instrumentedDb.ts +141 -0
- 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 +203 -59
- 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/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/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/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 -367
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- 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,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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
184
|
+
return _invalidateComponent(this.provider, this.config, this._publishInvalidation.bind(this), entityId, typeId);
|
|
185
|
+
}
|
|
254
186
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
|
316
|
-
await this.provider.
|
|
317
|
-
|
|
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
|
|
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
|
-
*
|
|
282
|
+
* Record relation tombstones for keys whose query returned []. TTL
|
|
283
|
+
* defaults to relation.negativeCacheTtl (60s).
|
|
325
284
|
*/
|
|
326
|
-
public async
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
333
|
-
const
|
|
334
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
345
|
-
if (!this.config.enabled || !this.config.
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
|
513
|
-
|
|
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
|
+
}
|