bunsane 0.1.4 → 0.2.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/.claude/settings.local.json +47 -0
- package/.claude/skills/update-memory.md +74 -0
- package/.prettierrc +4 -0
- package/.serena/memories/architectural-decision-no-dependency-injection.md +76 -0
- package/.serena/memories/architecture.md +154 -0
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +165 -0
- package/.serena/memories/code_style_and_conventions.md +76 -0
- package/.serena/memories/project_overview.md +43 -0
- package/.serena/memories/schema-dsl-plan.md +107 -0
- package/.serena/memories/suggested_commands.md +80 -0
- package/.serena/memories/typescript-compilation-status.md +54 -0
- package/.serena/project.yml +114 -0
- package/TODO.md +1 -7
- package/bun.lock +150 -4
- package/bunfig.toml +10 -0
- package/config/cache.config.ts +77 -0
- package/config/upload.config.ts +4 -5
- package/core/App.ts +870 -123
- package/core/ArcheType.ts +2268 -377
- package/core/BatchLoader.ts +181 -71
- package/core/Config.ts +153 -0
- package/core/Decorators.ts +4 -1
- package/core/Entity.ts +621 -92
- package/core/EntityHookManager.ts +1 -1
- package/core/EntityInterface.ts +3 -1
- package/core/EntityManager.ts +1 -13
- package/core/ErrorHandler.ts +8 -2
- package/core/Logger.ts +9 -0
- package/core/Middleware.ts +34 -0
- package/core/RequestContext.ts +5 -1
- package/core/RequestLoaders.ts +227 -93
- package/core/SchedulerManager.ts +193 -52
- package/core/cache/CacheAnalytics.ts +399 -0
- package/core/cache/CacheFactory.ts +145 -0
- package/core/cache/CacheManager.ts +520 -0
- package/core/cache/CacheProvider.ts +34 -0
- package/core/cache/CacheWarmer.ts +157 -0
- package/core/cache/CompressionUtils.ts +110 -0
- package/core/cache/MemoryCache.ts +251 -0
- package/core/cache/MultiLevelCache.ts +180 -0
- package/core/cache/NoOpCache.ts +53 -0
- package/core/cache/RedisCache.ts +464 -0
- package/core/cache/TTLStrategy.ts +254 -0
- package/core/cache/index.ts +6 -0
- package/core/components/BaseComponent.ts +120 -0
- package/core/{ComponentRegistry.ts → components/ComponentRegistry.ts} +148 -54
- package/core/components/Decorators.ts +88 -0
- package/core/components/Interfaces.ts +7 -0
- package/core/components/index.ts +5 -0
- package/core/decorators/EntityHooks.ts +0 -3
- package/core/decorators/IndexedField.ts +26 -0
- package/core/decorators/ScheduledTask.ts +0 -47
- package/core/events/EntityLifecycleEvents.ts +1 -1
- package/core/health.ts +112 -0
- package/core/metadata/definitions/ArcheType.ts +14 -0
- package/core/metadata/definitions/Component.ts +9 -0
- package/core/metadata/definitions/gqlObject.ts +1 -1
- package/core/metadata/index.ts +42 -1
- package/core/metadata/metadata-storage.ts +28 -2
- package/core/middleware/AccessLog.ts +59 -0
- package/core/middleware/RequestId.ts +38 -0
- package/core/middleware/SecurityHeaders.ts +62 -0
- package/core/middleware/index.ts +3 -0
- package/core/scheduler/DistributedLock.ts +266 -0
- package/core/scheduler/index.ts +15 -0
- package/core/validateEnv.ts +92 -0
- package/database/DatabaseHelper.ts +416 -40
- package/database/IndexingStrategy.ts +342 -0
- package/database/PreparedStatementCache.ts +226 -0
- package/database/index.ts +32 -7
- package/database/sqlHelpers.ts +14 -2
- package/endpoints/archetypes.ts +362 -0
- package/endpoints/components.ts +58 -0
- package/endpoints/entity.ts +80 -0
- package/endpoints/index.ts +27 -0
- package/endpoints/query.ts +93 -0
- package/endpoints/stats.ts +76 -0
- package/endpoints/tables.ts +212 -0
- package/endpoints/types.ts +155 -0
- package/gql/ArchetypeOperations.ts +32 -86
- package/gql/Generator.ts +27 -315
- package/gql/GeneratorV2.ts +37 -0
- package/gql/builders/InputTypeBuilder.ts +99 -0
- package/gql/builders/ResolverBuilder.ts +234 -0
- package/gql/builders/TypeDefBuilder.ts +105 -0
- package/gql/builders/index.ts +3 -0
- package/gql/decorators/Upload.ts +1 -1
- package/gql/depthLimit.ts +85 -0
- package/gql/graph/GraphNode.ts +224 -0
- package/gql/graph/SchemaGraph.ts +278 -0
- package/gql/helpers.ts +8 -2
- package/gql/index.ts +56 -4
- package/gql/middleware.ts +79 -0
- package/gql/orchestration/GraphQLSchemaOrchestrator.ts +241 -0
- package/gql/orchestration/index.ts +1 -0
- package/gql/scanner/ServiceScanner.ts +347 -0
- package/gql/schema/index.ts +458 -0
- package/gql/strategies/TypeGenerationStrategy.ts +329 -0
- package/gql/types.ts +1 -0
- package/gql/utils/TypeSignature.ts +220 -0
- package/gql/utils/index.ts +1 -0
- package/gql/visitors/ArchetypePreprocessorVisitor.ts +80 -0
- package/gql/visitors/DeduplicationVisitor.ts +82 -0
- package/gql/visitors/GraphVisitor.ts +78 -0
- package/gql/visitors/ResolverGeneratorVisitor.ts +122 -0
- package/gql/visitors/SchemaGeneratorVisitor.ts +851 -0
- package/gql/visitors/TypeCollectorVisitor.ts +79 -0
- package/gql/visitors/VisitorComposer.ts +96 -0
- package/gql/visitors/index.ts +7 -0
- package/package.json +59 -37
- package/plugins/index.ts +2 -2
- package/query/CTENode.ts +97 -0
- package/query/ComponentInclusionNode.ts +689 -0
- package/query/FilterBuilder.ts +127 -0
- package/query/FilterBuilderRegistry.ts +202 -0
- package/query/OrNode.ts +517 -0
- package/query/OrQuery.ts +42 -0
- package/query/Query.ts +1022 -0
- package/query/QueryContext.ts +170 -0
- package/query/QueryDAG.ts +122 -0
- package/query/QueryNode.ts +65 -0
- package/query/SourceNode.ts +53 -0
- package/query/builders/FullTextSearchBuilder.ts +236 -0
- package/query/index.ts +21 -0
- package/scheduler/index.ts +40 -8
- package/service/Service.ts +2 -1
- package/service/ServiceRegistry.ts +6 -5
- package/{core/storage → storage}/LocalStorageProvider.ts +2 -2
- package/storage/S3StorageProvider.ts +316 -0
- package/{core/storage → storage}/StorageProvider.ts +7 -3
- package/studio/bun.lock +482 -0
- package/studio/index.html +13 -0
- package/studio/package.json +39 -0
- package/studio/postcss.config.js +6 -0
- package/studio/src/components/DataTable.tsx +211 -0
- package/studio/src/components/Layout.tsx +13 -0
- package/studio/src/components/PageContainer.tsx +9 -0
- package/studio/src/components/PageHeader.tsx +13 -0
- package/studio/src/components/SearchBar.tsx +57 -0
- package/studio/src/components/Sidebar.tsx +294 -0
- package/studio/src/components/ui/button.tsx +56 -0
- package/studio/src/components/ui/checkbox.tsx +26 -0
- package/studio/src/components/ui/input.tsx +25 -0
- package/studio/src/hooks/useDataTable.ts +131 -0
- package/studio/src/index.css +36 -0
- package/studio/src/lib/api.ts +186 -0
- package/studio/src/lib/utils.ts +13 -0
- package/studio/src/main.tsx +17 -0
- package/studio/src/pages/ArcheType.tsx +239 -0
- package/studio/src/pages/Components.tsx +124 -0
- package/studio/src/pages/EntityInspector.tsx +302 -0
- package/studio/src/pages/QueryRunner.tsx +246 -0
- package/studio/src/pages/Table.tsx +94 -0
- package/studio/src/pages/Welcome.tsx +241 -0
- package/studio/src/routes.tsx +45 -0
- package/studio/src/store/archeTypeSettings.ts +30 -0
- package/studio/src/store/studio.ts +65 -0
- package/studio/src/utils/columnHelpers.tsx +114 -0
- package/studio/studio-instructions.md +81 -0
- package/studio/tailwind.config.js +77 -0
- package/studio/tsconfig.json +24 -0
- package/studio/utils.ts +54 -0
- package/studio/vite.config.js +19 -0
- package/swagger/generator.ts +1 -1
- package/tests/e2e/http.test.ts +126 -0
- package/tests/fixtures/archetypes/TestUserArchetype.ts +21 -0
- package/tests/fixtures/components/TestOrder.ts +23 -0
- package/tests/fixtures/components/TestProduct.ts +23 -0
- package/tests/fixtures/components/TestUser.ts +20 -0
- package/tests/fixtures/components/index.ts +6 -0
- package/tests/graphql/SchemaGeneration.test.ts +90 -0
- package/tests/graphql/builders/ResolverBuilder.test.ts +223 -0
- package/tests/graphql/builders/TypeDefBuilder.test.ts +153 -0
- package/tests/integration/archetype/ArcheType.persistence.test.ts +241 -0
- package/tests/integration/cache/CacheInvalidation.test.ts +259 -0
- package/tests/integration/entity/Entity.persistence.test.ts +333 -0
- package/tests/integration/query/Query.exec.test.ts +523 -0
- package/tests/pglite-setup.ts +61 -0
- package/tests/setup.ts +164 -0
- package/tests/stress/BenchmarkRunner.ts +203 -0
- package/tests/stress/DataSeeder.ts +190 -0
- package/tests/stress/StressTestReporter.ts +229 -0
- package/tests/stress/cursor-perf-test.ts +171 -0
- package/tests/stress/fixtures/StressTestComponents.ts +58 -0
- package/tests/stress/index.ts +7 -0
- package/tests/stress/scenarios/query-benchmarks.test.ts +285 -0
- package/tests/unit/BatchLoader.test.ts +82 -0
- package/tests/unit/archetype/ArcheType.test.ts +107 -0
- package/tests/unit/cache/CacheManager.test.ts +347 -0
- package/tests/unit/cache/MemoryCache.test.ts +260 -0
- package/tests/unit/cache/RedisCache.test.ts +411 -0
- package/tests/unit/entity/Entity.components.test.ts +244 -0
- package/tests/unit/entity/Entity.test.ts +345 -0
- package/tests/unit/gql/depthLimit.test.ts +203 -0
- package/tests/unit/gql/operationMiddleware.test.ts +293 -0
- package/tests/unit/health/Health.test.ts +129 -0
- package/tests/unit/middleware/AccessLog.test.ts +37 -0
- package/tests/unit/middleware/Middleware.test.ts +98 -0
- package/tests/unit/middleware/RequestId.test.ts +54 -0
- package/tests/unit/middleware/SecurityHeaders.test.ts +66 -0
- package/tests/unit/query/FilterBuilder.test.ts +111 -0
- package/tests/unit/query/Query.test.ts +308 -0
- package/tests/unit/scheduler/DistributedLock.test.ts +274 -0
- package/tests/unit/schema/schema-integration.test.ts +426 -0
- package/tests/unit/schema/schema.test.ts +580 -0
- package/tests/unit/storage/S3StorageProvider.test.ts +571 -0
- package/tests/unit/upload/RestUpload.test.ts +267 -0
- package/tests/unit/validateEnv.test.ts +82 -0
- package/tests/utils/entity-tracker.ts +57 -0
- package/tests/utils/index.ts +13 -0
- package/tests/utils/test-context.ts +149 -0
- package/tsconfig.json +5 -1
- package/types/archetype.types.ts +6 -0
- package/types/hooks.types.ts +1 -1
- package/types/query.types.ts +110 -0
- package/types/scheduler.types.ts +68 -7
- package/types/upload.types.ts +1 -0
- package/{core → upload}/FileValidator.ts +10 -1
- package/upload/RestUpload.ts +130 -0
- package/{core/components → upload}/UploadComponent.ts +11 -11
- package/{core → upload}/UploadManager.ts +3 -3
- package/upload/index.ts +23 -7
- package/utils/UploadHelper.ts +27 -6
- package/utils/cronParser.ts +16 -6
- package/.github/workflows/deploy-docs.yml +0 -57
- package/core/Components.ts +0 -202
- package/core/EntityCache.ts +0 -15
- package/core/Query.ts +0 -880
- package/docs/README.md +0 -149
- package/docs/_coverpage.md +0 -36
- package/docs/_sidebar.md +0 -23
- package/docs/api/core.md +0 -568
- package/docs/api/hooks.md +0 -554
- package/docs/api/index.md +0 -222
- package/docs/api/query.md +0 -678
- package/docs/api/service.md +0 -744
- package/docs/core-concepts/archetypes.md +0 -512
- package/docs/core-concepts/components.md +0 -498
- package/docs/core-concepts/entity.md +0 -314
- package/docs/core-concepts/hooks.md +0 -683
- package/docs/core-concepts/query.md +0 -588
- package/docs/core-concepts/services.md +0 -647
- package/docs/examples/code-examples.md +0 -425
- package/docs/getting-started.md +0 -337
- package/docs/index.html +0 -97
- package/tests/bench/insert.bench.ts +0 -60
- package/tests/bench/relations.bench.ts +0 -270
- package/tests/bench/sorting.bench.ts +0 -416
- package/tests/component-hooks-simple.test.ts +0 -117
- package/tests/component-hooks.test.ts +0 -1461
- package/tests/component.test.ts +0 -339
- package/tests/errorHandling.test.ts +0 -155
- package/tests/hooks.test.ts +0 -667
- package/tests/query-sorting.test.ts +0 -101
- package/tests/query.test.ts +0 -81
- package/tests/relations.test.ts +0 -170
- package/tests/scheduler.test.ts +0 -724
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type CacheProvider, type CacheStats } from './CacheProvider';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* No-op cache implementation for testing and cache-disabled scenarios
|
|
5
|
+
* All operations return null/void and stats show zero activity
|
|
6
|
+
*/
|
|
7
|
+
export class NoOpCache implements CacheProvider {
|
|
8
|
+
async get<T>(key: string): Promise<T | null> {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
|
13
|
+
// No-op
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async delete(key: string | string[]): Promise<void> {
|
|
17
|
+
// No-op
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async clear(): Promise<void> {
|
|
21
|
+
// No-op
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async getMany<T>(keys: string[]): Promise<(T | null)[]> {
|
|
25
|
+
return new Array(keys.length).fill(null);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async setMany<T>(entries: Array<{key: string, value: T, ttl?: number}>): Promise<void> {
|
|
29
|
+
// No-op
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async deleteMany(keys: string[]): Promise<void> {
|
|
33
|
+
// No-op
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async invalidatePattern(pattern: string): Promise<void> {
|
|
37
|
+
// No-op
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async ping(): Promise<boolean> {
|
|
41
|
+
return true; // No-op cache is always "available"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async getStats(): Promise<CacheStats> {
|
|
45
|
+
return {
|
|
46
|
+
hits: 0,
|
|
47
|
+
misses: 0,
|
|
48
|
+
hitRate: 0,
|
|
49
|
+
size: 0,
|
|
50
|
+
memoryUsage: 0
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis Cache Implementation for BunSane Framework
|
|
3
|
+
* Provides distributed caching with Redis backend
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import Redis, { type RedisOptions } from 'ioredis';
|
|
7
|
+
import { type CacheProvider, type CacheStats } from './CacheProvider';
|
|
8
|
+
import { type CacheConfig } from '../../config/cache.config';
|
|
9
|
+
import { logger } from '../Logger';
|
|
10
|
+
import { CompressionUtils } from './CompressionUtils';
|
|
11
|
+
|
|
12
|
+
export interface HealthStatus {
|
|
13
|
+
connected: boolean;
|
|
14
|
+
latency: number;
|
|
15
|
+
memoryUsage?: number;
|
|
16
|
+
connections?: number;
|
|
17
|
+
version?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RedisCacheConfig {
|
|
21
|
+
host: string;
|
|
22
|
+
port: number;
|
|
23
|
+
password?: string;
|
|
24
|
+
db?: number;
|
|
25
|
+
keyPrefix?: string;
|
|
26
|
+
retryStrategy?: (times: number) => number | void;
|
|
27
|
+
maxRetriesPerRequest?: number;
|
|
28
|
+
lazyConnect?: boolean;
|
|
29
|
+
enableReadyCheck?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Redis-based cache implementation with connection pooling and Pub/Sub support
|
|
34
|
+
*/
|
|
35
|
+
export class RedisCache implements CacheProvider {
|
|
36
|
+
private client: Redis;
|
|
37
|
+
private subscriber?: Redis;
|
|
38
|
+
private publisher?: Redis;
|
|
39
|
+
private config: RedisCacheConfig;
|
|
40
|
+
private keyPrefix: string;
|
|
41
|
+
private stats = {
|
|
42
|
+
hits: 0,
|
|
43
|
+
misses: 0,
|
|
44
|
+
size: 0
|
|
45
|
+
};
|
|
46
|
+
private invalidationHandlers: Map<string, (channel: string, message: string) => void> = new Map();
|
|
47
|
+
private monitoringInterval: Timer | null = null;
|
|
48
|
+
private subscriberListenerAttached = false;
|
|
49
|
+
|
|
50
|
+
constructor(config: RedisCacheConfig) {
|
|
51
|
+
this.config = config;
|
|
52
|
+
this.keyPrefix = config.keyPrefix || 'bunsane:';
|
|
53
|
+
|
|
54
|
+
const redisOptions: RedisOptions = {
|
|
55
|
+
host: config.host,
|
|
56
|
+
port: config.port,
|
|
57
|
+
password: config.password,
|
|
58
|
+
db: config.db || 0,
|
|
59
|
+
retryStrategy: config.retryStrategy,
|
|
60
|
+
maxRetriesPerRequest: config.maxRetriesPerRequest || 3,
|
|
61
|
+
lazyConnect: config.lazyConnect || false,
|
|
62
|
+
enableReadyCheck: config.enableReadyCheck || false,
|
|
63
|
+
// Connection pooling settings
|
|
64
|
+
enableOfflineQueue: true,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
this.client = new Redis(redisOptions);
|
|
68
|
+
this.setupEventHandlers();
|
|
69
|
+
this.setupMonitoring();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Setup Redis event handlers for connection monitoring
|
|
74
|
+
*/
|
|
75
|
+
private setupEventHandlers(): void {
|
|
76
|
+
this.client.on('connect', () => {
|
|
77
|
+
logger.info('Redis cache connected');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
this.client.on('ready', () => {
|
|
81
|
+
logger.info('Redis cache ready');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.client.on('error', (error: Error) => {
|
|
85
|
+
logger.error({ error, msg: 'Redis cache error' });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
this.client.on('close', () => {
|
|
89
|
+
logger.warn('Redis cache connection closed');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
this.client.on('reconnecting', (delay: number) => {
|
|
93
|
+
logger.info(`Redis cache reconnecting in ${delay}ms`);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Setup monitoring for memory usage and connection stats
|
|
99
|
+
*/
|
|
100
|
+
private setupMonitoring(): void {
|
|
101
|
+
// Log memory usage every 5 minutes
|
|
102
|
+
this.monitoringInterval = setInterval(async () => {
|
|
103
|
+
try {
|
|
104
|
+
const info = await this.client.info('memory');
|
|
105
|
+
const memoryMatch = info.match(/used_memory:(\d+)/);
|
|
106
|
+
if (memoryMatch && memoryMatch[1]) {
|
|
107
|
+
const memoryUsage = parseInt(memoryMatch[1], 10);
|
|
108
|
+
logger.debug({ msg: 'Redis memory usage', memoryUsage });
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
logger.error({ error, msg: 'Failed to get Redis memory info' });
|
|
112
|
+
}
|
|
113
|
+
}, 300000); // 5 minutes
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get a value from cache
|
|
118
|
+
*/
|
|
119
|
+
async get<T>(key: string): Promise<T | null> {
|
|
120
|
+
try {
|
|
121
|
+
const prefixedKey = this.prefixKey(key);
|
|
122
|
+
const value = await this.client.get(prefixedKey);
|
|
123
|
+
|
|
124
|
+
if (value === null) {
|
|
125
|
+
this.stats.misses++;
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.stats.hits++;
|
|
130
|
+
const parsed = JSON.parse(value);
|
|
131
|
+
return await CompressionUtils.decompress(parsed) as T;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
logger.error({ error, msg: 'Redis get error' });
|
|
134
|
+
this.stats.misses++;
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Set a value in cache with optional TTL
|
|
141
|
+
*/
|
|
142
|
+
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
|
143
|
+
try {
|
|
144
|
+
const prefixedKey = this.prefixKey(key);
|
|
145
|
+
const compressedValue = await CompressionUtils.compress(value);
|
|
146
|
+
const serializedValue = JSON.stringify(compressedValue);
|
|
147
|
+
|
|
148
|
+
if (ttl) {
|
|
149
|
+
await this.client.setex(prefixedKey, Math.floor(ttl / 1000), serializedValue);
|
|
150
|
+
} else {
|
|
151
|
+
await this.client.set(prefixedKey, serializedValue);
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
logger.error({ error, msg: 'Redis set error' });
|
|
155
|
+
// Don't throw - cache failures shouldn't break the app
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Delete a key or array of keys from cache
|
|
161
|
+
*/
|
|
162
|
+
async delete(key: string | string[]): Promise<void> {
|
|
163
|
+
try {
|
|
164
|
+
const keys = Array.isArray(key) ? key : [key];
|
|
165
|
+
const prefixedKeys = keys.map(k => this.prefixKey(k));
|
|
166
|
+
|
|
167
|
+
if (prefixedKeys.length > 0) {
|
|
168
|
+
await this.client.del(...prefixedKeys);
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
logger.error({ error, msg: 'Redis delete error' });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Clear all cache entries
|
|
177
|
+
*/
|
|
178
|
+
async clear(): Promise<void> {
|
|
179
|
+
try {
|
|
180
|
+
const keys = await this.client.keys(`${this.keyPrefix}*`);
|
|
181
|
+
if (keys.length > 0) {
|
|
182
|
+
await this.client.del(...keys);
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
logger.error({ error, msg: 'Redis clear error' });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Get multiple values from cache
|
|
191
|
+
*/
|
|
192
|
+
async getMany<T>(keys: string[]): Promise<(T | null)[]> {
|
|
193
|
+
try {
|
|
194
|
+
const prefixedKeys = keys.map(k => this.prefixKey(k));
|
|
195
|
+
const values = await this.client.mget(...prefixedKeys);
|
|
196
|
+
|
|
197
|
+
return values.map((value, index) => {
|
|
198
|
+
if (value === null) {
|
|
199
|
+
this.stats.misses++;
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
this.stats.hits++;
|
|
203
|
+
try {
|
|
204
|
+
return JSON.parse(value) as T;
|
|
205
|
+
} catch (parseError) {
|
|
206
|
+
logger.error({ error: parseError, key: keys[index], msg: 'Failed to parse cached value' });
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
} catch (error) {
|
|
211
|
+
logger.error({ error, msg: 'Redis getMany error' });
|
|
212
|
+
return new Array(keys.length).fill(null);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Set multiple values in cache
|
|
218
|
+
*/
|
|
219
|
+
async setMany<T>(entries: Array<{key: string, value: T, ttl?: number}>): Promise<void> {
|
|
220
|
+
try {
|
|
221
|
+
const pipeline = this.client.pipeline();
|
|
222
|
+
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
const prefixedKey = this.prefixKey(entry.key);
|
|
225
|
+
const serializedValue = JSON.stringify(entry.value);
|
|
226
|
+
|
|
227
|
+
if (entry.ttl) {
|
|
228
|
+
pipeline.setex(prefixedKey, Math.floor(entry.ttl / 1000), serializedValue);
|
|
229
|
+
} else {
|
|
230
|
+
pipeline.set(prefixedKey, serializedValue);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await pipeline.exec();
|
|
235
|
+
} catch (error) {
|
|
236
|
+
logger.error({ error, msg: 'Redis setMany error' });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Delete multiple keys from cache
|
|
242
|
+
*/
|
|
243
|
+
async deleteMany(keys: string[]): Promise<void> {
|
|
244
|
+
try {
|
|
245
|
+
const prefixedKeys = keys.map(k => this.prefixKey(k));
|
|
246
|
+
if (prefixedKeys.length > 0) {
|
|
247
|
+
await this.client.del(...prefixedKeys);
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
logger.error({ error, msg: 'Redis deleteMany error' });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Invalidate keys matching a pattern using SCAN to avoid blocking
|
|
256
|
+
*/
|
|
257
|
+
async invalidatePattern(pattern: string): Promise<void> {
|
|
258
|
+
try {
|
|
259
|
+
const prefixedPattern = this.prefixKey(pattern);
|
|
260
|
+
let cursor = '0';
|
|
261
|
+
const keysToDelete: string[] = [];
|
|
262
|
+
|
|
263
|
+
do {
|
|
264
|
+
const [newCursor, keys] = await this.client.scan(cursor, 'MATCH', prefixedPattern, 'COUNT', 100);
|
|
265
|
+
cursor = newCursor;
|
|
266
|
+
keysToDelete.push(...keys);
|
|
267
|
+
} while (cursor !== '0');
|
|
268
|
+
|
|
269
|
+
if (keysToDelete.length > 0) {
|
|
270
|
+
await this.client.del(...keysToDelete);
|
|
271
|
+
logger.debug({ pattern, count: keysToDelete.length, msg: `Invalidated ${keysToDelete.length} keys matching pattern` });
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
logger.error({ error, msg: 'Redis invalidatePattern error' });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Check if Redis is reachable
|
|
280
|
+
*/
|
|
281
|
+
async ping(): Promise<boolean> {
|
|
282
|
+
try {
|
|
283
|
+
const result = await this.client.ping();
|
|
284
|
+
return result === 'PONG';
|
|
285
|
+
} catch (error) {
|
|
286
|
+
logger.error({ error, msg: 'Redis ping error' });
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get cache statistics
|
|
293
|
+
*/
|
|
294
|
+
async getStats(): Promise<CacheStats> {
|
|
295
|
+
try {
|
|
296
|
+
// Get approximate key count using DBSIZE
|
|
297
|
+
const size = await this.client.dbsize();
|
|
298
|
+
|
|
299
|
+
// Get memory usage
|
|
300
|
+
const info = await this.client.info('memory');
|
|
301
|
+
const memoryMatch = info.match(/used_memory:(\d+)/);
|
|
302
|
+
const memoryUsage = memoryMatch && memoryMatch[1] ? parseInt(memoryMatch[1], 10) : undefined;
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
hits: this.stats.hits,
|
|
306
|
+
misses: this.stats.misses,
|
|
307
|
+
hitRate: this.stats.hits / (this.stats.hits + this.stats.misses) || 0,
|
|
308
|
+
size,
|
|
309
|
+
memoryUsage
|
|
310
|
+
};
|
|
311
|
+
} catch (error) {
|
|
312
|
+
logger.error({ error, msg: 'Redis getStats error' });
|
|
313
|
+
return {
|
|
314
|
+
hits: this.stats.hits,
|
|
315
|
+
misses: this.stats.misses,
|
|
316
|
+
hitRate: 0,
|
|
317
|
+
size: 0
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Perform health check with detailed status
|
|
324
|
+
*/
|
|
325
|
+
async healthCheck(): Promise<HealthStatus> {
|
|
326
|
+
const startTime = Date.now();
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const pingResult = await this.ping();
|
|
330
|
+
const latency = Date.now() - startTime;
|
|
331
|
+
|
|
332
|
+
if (!pingResult) {
|
|
333
|
+
return {
|
|
334
|
+
connected: false,
|
|
335
|
+
latency
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Get additional health info
|
|
340
|
+
const info = await this.client.info();
|
|
341
|
+
const memoryMatch = info.match(/used_memory:(\d+)/);
|
|
342
|
+
const memoryUsage = memoryMatch && memoryMatch[1] ? parseInt(memoryMatch[1], 10) : undefined;
|
|
343
|
+
|
|
344
|
+
const connectionsMatch = info.match(/connected_clients:(\d+)/);
|
|
345
|
+
const connections = connectionsMatch && connectionsMatch[1] ? parseInt(connectionsMatch[1], 10) : undefined;
|
|
346
|
+
|
|
347
|
+
const versionMatch = info.match(/redis_version:([^\r\n]+)/);
|
|
348
|
+
const version = versionMatch ? versionMatch[1] : undefined;
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
connected: true,
|
|
352
|
+
latency,
|
|
353
|
+
memoryUsage,
|
|
354
|
+
connections,
|
|
355
|
+
version
|
|
356
|
+
};
|
|
357
|
+
} catch (error) {
|
|
358
|
+
logger.error({ error, msg: 'Redis health check error' });
|
|
359
|
+
return {
|
|
360
|
+
connected: false,
|
|
361
|
+
latency: Date.now() - startTime
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Publish cache invalidation event
|
|
368
|
+
*/
|
|
369
|
+
async publishInvalidation(channel: string, message: string): Promise<void> {
|
|
370
|
+
try {
|
|
371
|
+
if (!this.publisher) {
|
|
372
|
+
this.publisher = this.client.duplicate();
|
|
373
|
+
}
|
|
374
|
+
await this.publisher.publish(channel, message);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
logger.error({ error, msg: 'Redis publish invalidation error' });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Subscribe to cache invalidation events
|
|
382
|
+
*/
|
|
383
|
+
async subscribeInvalidation(channel: string, handler: (channel: string, message: string) => void): Promise<void> {
|
|
384
|
+
try {
|
|
385
|
+
if (!this.subscriber) {
|
|
386
|
+
this.subscriber = this.client.duplicate();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
this.invalidationHandlers.set(channel, handler);
|
|
390
|
+
await this.subscriber.subscribe(channel);
|
|
391
|
+
|
|
392
|
+
// Only attach the message listener once to avoid stacking
|
|
393
|
+
if (!this.subscriberListenerAttached) {
|
|
394
|
+
this.subscriberListenerAttached = true;
|
|
395
|
+
this.subscriber.on('message', (receivedChannel, message) => {
|
|
396
|
+
this.handleInvalidationEvent(receivedChannel, message);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
} catch (error) {
|
|
400
|
+
logger.error({ error, msg: 'Redis subscribe invalidation error' });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Unsubscribe from cache invalidation events
|
|
406
|
+
*/
|
|
407
|
+
async unsubscribeInvalidation(channel: string): Promise<void> {
|
|
408
|
+
try {
|
|
409
|
+
if (this.subscriber) {
|
|
410
|
+
await this.subscriber.unsubscribe(channel);
|
|
411
|
+
this.invalidationHandlers.delete(channel);
|
|
412
|
+
}
|
|
413
|
+
} catch (error) {
|
|
414
|
+
logger.error({ error, msg: 'Redis unsubscribe invalidation error' });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Handle incoming invalidation events
|
|
420
|
+
*/
|
|
421
|
+
private handleInvalidationEvent(channel: string, message: string): void {
|
|
422
|
+
try {
|
|
423
|
+
const handler = this.invalidationHandlers.get(channel);
|
|
424
|
+
if (handler) {
|
|
425
|
+
handler(channel, message);
|
|
426
|
+
}
|
|
427
|
+
} catch (error) {
|
|
428
|
+
logger.error({ error, msg: 'Error handling invalidation event' });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Prefix a key with the configured prefix
|
|
434
|
+
*/
|
|
435
|
+
private prefixKey(key: string): string {
|
|
436
|
+
return `${this.keyPrefix}${key}`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Close all Redis connections
|
|
441
|
+
*/
|
|
442
|
+
async disconnect(): Promise<void> {
|
|
443
|
+
try {
|
|
444
|
+
if (this.monitoringInterval) {
|
|
445
|
+
clearInterval(this.monitoringInterval);
|
|
446
|
+
this.monitoringInterval = null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
await this.client.disconnect();
|
|
450
|
+
|
|
451
|
+
if (this.subscriber) {
|
|
452
|
+
await this.subscriber.disconnect();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (this.publisher) {
|
|
456
|
+
await this.publisher.disconnect();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
logger.info('Redis cache disconnected');
|
|
460
|
+
} catch (error) {
|
|
461
|
+
logger.error({ error, msg: 'Redis disconnect error' });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|