bunsane 0.2.4 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/core/ArcheType.ts +67 -34
  2. package/core/BatchLoader.ts +215 -30
  3. package/core/Entity.ts +2 -2
  4. package/core/RequestContext.ts +15 -10
  5. package/core/RequestLoaders.ts +4 -2
  6. package/core/cache/CacheProvider.ts +1 -0
  7. package/core/cache/MemoryCache.ts +10 -1
  8. package/core/cache/RedisCache.ts +16 -2
  9. package/core/validateEnv.ts +8 -0
  10. package/database/DatabaseHelper.ts +113 -1
  11. package/database/index.ts +78 -45
  12. package/docs/SCALABILITY_PLAN.md +175 -0
  13. package/package.json +13 -2
  14. package/query/CTENode.ts +44 -24
  15. package/query/ComponentInclusionNode.ts +181 -91
  16. package/query/Query.ts +9 -9
  17. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +338 -0
  18. package/tests/benchmark/bunfig.toml +9 -0
  19. package/tests/benchmark/fixtures/EcommerceComponents.ts +283 -0
  20. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +301 -0
  21. package/tests/benchmark/fixtures/RelationTracker.ts +159 -0
  22. package/tests/benchmark/fixtures/index.ts +6 -0
  23. package/tests/benchmark/index.ts +22 -0
  24. package/tests/benchmark/noop-preload.ts +3 -0
  25. package/tests/benchmark/runners/BenchmarkLoader.ts +132 -0
  26. package/tests/benchmark/runners/index.ts +4 -0
  27. package/tests/benchmark/scenarios/query-benchmarks.test.ts +465 -0
  28. package/tests/benchmark/scripts/generate-db.ts +344 -0
  29. package/tests/benchmark/scripts/run-benchmarks.ts +97 -0
  30. package/tests/integration/query/Query.complexAnalysis.test.ts +557 -0
  31. package/tests/integration/query/Query.explainAnalyze.test.ts +233 -0
  32. package/tests/stress/fixtures/RealisticComponents.ts +235 -0
  33. package/tests/stress/scenarios/realistic-scenarios.test.ts +1081 -0
  34. package/tests/stress/scenarios/timeout-investigation.test.ts +522 -0
  35. package/tests/unit/BatchLoader.test.ts +139 -25
package/core/ArcheType.ts CHANGED
@@ -1323,16 +1323,29 @@ export class BaseArcheType {
1323
1323
  }
1324
1324
  }
1325
1325
  } else {
1326
+ // OPTIMIZED: Find candidate components first, then load in parallel
1327
+ const candidateComponents: Array<{ compCtor: any }> = [];
1326
1328
  for (const compCtor of Object.values(this.componentMap)) {
1327
1329
  const typeId = storage.getComponentId(compCtor.name);
1328
1330
  const componentProps = storage.getComponentProperties(typeId);
1329
1331
  const hasForeignKey = componentProps.some(prop => prop.propertyKey === foreignKey);
1330
- if (!hasForeignKey) continue;
1331
-
1332
- const componentInstance = await entity.get(compCtor as any);
1333
- if (componentInstance && (componentInstance as any)[foreignKey] !== undefined) {
1334
- foreignId = (componentInstance as any)[foreignKey];
1335
- break;
1332
+ if (hasForeignKey) {
1333
+ candidateComponents.push({ compCtor });
1334
+ }
1335
+ }
1336
+
1337
+ if (candidateComponents.length > 0) {
1338
+ // Load all candidate components in parallel
1339
+ const componentInstances = await Promise.all(
1340
+ candidateComponents.map(({ compCtor }) => entity.get(compCtor as any))
1341
+ );
1342
+
1343
+ // Find the first one with the foreign key value
1344
+ for (const componentInstance of componentInstances) {
1345
+ if (componentInstance && (componentInstance as any)[foreignKey] !== undefined) {
1346
+ foreignId = (componentInstance as any)[foreignKey];
1347
+ break;
1348
+ }
1336
1349
  }
1337
1350
  }
1338
1351
  }
@@ -1392,20 +1405,14 @@ export class BaseArcheType {
1392
1405
  }
1393
1406
 
1394
1407
  if (foreignKeyComponent) {
1395
- // Query related entities
1396
- const relatedEntities = await new Query()
1397
- .with(foreignKeyComponent)
1408
+ // OPTIMIZED: Use Query with filter instead of fetching all + filtering in JS
1409
+ // This pushes the filtering to the database, avoiding N+1 queries
1410
+ const matchingEntities = await new Query()
1411
+ .with(foreignKeyComponent, {
1412
+ filters: [{ field: foreignKey, operator: '=', value: entity.id }]
1413
+ })
1398
1414
  .exec();
1399
1415
 
1400
- // Filter entities that reference this entity
1401
- const matchingEntities: Entity[] = [];
1402
- for (const relatedEntity of relatedEntities) {
1403
- const componentInstance = await relatedEntity.get(foreignKeyComponent);
1404
- if (componentInstance && (componentInstance as any)[foreignKey] === entity.id) {
1405
- matchingEntities.push(relatedEntity);
1406
- }
1407
- }
1408
-
1409
1416
  // Attach as computed property
1410
1417
  (entity as any)[fieldName] = matchingEntities;
1411
1418
  }
@@ -2037,21 +2044,34 @@ export class BaseArcheType {
2037
2044
  }
2038
2045
  }
2039
2046
  } else {
2040
- // Original logic for flat foreign key
2047
+ // OPTIMIZED: Load all candidate components in parallel via DataLoader
2048
+ const candidateLoads: Array<{ compCtor: any; typeId: string }> = [];
2041
2049
  for (const [componentField, compCtor] of Object.entries(this.componentMap)) {
2042
2050
  const typeIdForComponent = storage.getComponentId(compCtor.name);
2043
2051
  const componentProps = storage.getComponentProperties(typeIdForComponent);
2044
2052
  const hasForeignKey = componentProps.some(prop => prop.propertyKey === foreignKey);
2045
- if (!hasForeignKey || !foreignKey) continue;
2046
-
2047
- const componentData = await context.loaders.componentsByEntityType.load({
2048
- entityId: entityId,
2049
- typeId: typeIdForComponent,
2050
- });
2053
+ if (hasForeignKey && foreignKey) {
2054
+ candidateLoads.push({ compCtor, typeId: typeIdForComponent });
2055
+ }
2056
+ }
2051
2057
 
2052
- if (componentData?.data && componentData.data[foreignKey] !== undefined) {
2053
- foreignId = componentData.data[foreignKey];
2054
- break;
2058
+ if (candidateLoads.length > 0) {
2059
+ // Load all candidate components in parallel
2060
+ const componentDataResults = await Promise.all(
2061
+ candidateLoads.map(({ typeId }) =>
2062
+ context.loaders.componentsByEntityType.load({
2063
+ entityId: entityId,
2064
+ typeId: typeId,
2065
+ })
2066
+ )
2067
+ );
2068
+
2069
+ // Find the first one with the foreign key value
2070
+ for (const componentData of componentDataResults) {
2071
+ if (componentData?.data && componentData.data[foreignKey] !== undefined) {
2072
+ foreignId = componentData.data[foreignKey];
2073
+ break;
2074
+ }
2055
2075
  }
2056
2076
  }
2057
2077
  }
@@ -2072,16 +2092,29 @@ export class BaseArcheType {
2072
2092
  }
2073
2093
  }
2074
2094
  } else {
2075
- // Original logic for flat foreign key
2095
+ // OPTIMIZED: Find candidates first, then load in parallel
2096
+ const candidateComponents: Array<{ compCtor: any }> = [];
2076
2097
  for (const compCtor of Object.values(this.componentMap)) {
2077
2098
  const typeIdForComponent = storage.getComponentId(compCtor.name);
2078
2099
  const componentProps = storage.getComponentProperties(typeIdForComponent);
2079
2100
  const hasForeignKey = componentProps.some(prop => prop.propertyKey === foreignKey);
2080
- if (!hasForeignKey || !foreignKey) continue;
2081
- const componentInstance = await entity.get(compCtor as any);
2082
- if (componentInstance && (componentInstance as any)[foreignKey] !== undefined) {
2083
- foreignId = (componentInstance as any)[foreignKey];
2084
- break;
2101
+ if (hasForeignKey && foreignKey) {
2102
+ candidateComponents.push({ compCtor });
2103
+ }
2104
+ }
2105
+
2106
+ if (candidateComponents.length > 0) {
2107
+ // Load all candidate components in parallel
2108
+ const componentInstances = await Promise.all(
2109
+ candidateComponents.map(({ compCtor }) => entity.get(compCtor as any))
2110
+ );
2111
+
2112
+ // Find the first one with the foreign key value
2113
+ for (const componentInstance of componentInstances) {
2114
+ if (componentInstance && (componentInstance as any)[foreignKey] !== undefined) {
2115
+ foreignId = (componentInstance as any)[foreignKey];
2116
+ break;
2117
+ }
2085
2118
  }
2086
2119
  }
2087
2120
  }
@@ -7,6 +7,7 @@ import { sql } from "bun";
7
7
  interface CachedRelation {
8
8
  ids: string[];
9
9
  expiresAt: number;
10
+ lastAccessed: number;
10
11
  }
11
12
 
12
13
  interface BatchLoaderOptions {
@@ -16,11 +17,192 @@ interface BatchLoaderOptions {
16
17
  cacheTTL?: number;
17
18
  }
18
19
 
20
+ interface BatchLoaderConfig {
21
+ maxCacheEntries: number;
22
+ maxCacheTypes: number;
23
+ evictionBatchSize: number;
24
+ }
25
+
26
+ /**
27
+ * LRU-bounded cache for relation lookups.
28
+ * Prevents unbounded memory growth under high cardinality.
29
+ */
30
+ class BoundedRelationCache {
31
+ private cache = new Map<string, Map<string, CachedRelation>>();
32
+ private accessOrder: string[] = []; // Tracks global access order for LRU eviction
33
+ private totalEntries = 0;
34
+ private config: BatchLoaderConfig;
35
+
36
+ constructor(config: BatchLoaderConfig) {
37
+ this.config = config;
38
+ }
39
+
40
+ getTypeCache(cacheKey: string): Map<string, CachedRelation> {
41
+ let typeCache = this.cache.get(cacheKey);
42
+ if (!typeCache) {
43
+ // Evict oldest type caches if at limit
44
+ if (this.cache.size >= this.config.maxCacheTypes) {
45
+ this.evictOldestType();
46
+ }
47
+ typeCache = new Map();
48
+ this.cache.set(cacheKey, typeCache);
49
+ }
50
+ return typeCache;
51
+ }
52
+
53
+ get(cacheKey: string, parentId: string): CachedRelation | undefined {
54
+ const typeCache = this.cache.get(cacheKey);
55
+ if (!typeCache) return undefined;
56
+
57
+ const entry = typeCache.get(parentId);
58
+ if (entry) {
59
+ // Update access time for LRU
60
+ entry.lastAccessed = Date.now();
61
+ }
62
+ return entry;
63
+ }
64
+
65
+ set(cacheKey: string, parentId: string, entry: CachedRelation): void {
66
+ const typeCache = this.getTypeCache(cacheKey);
67
+
68
+ // Evict if at capacity before adding new entries
69
+ if (this.totalEntries >= this.config.maxCacheEntries && !typeCache.has(parentId)) {
70
+ this.evictLRUEntries(this.config.evictionBatchSize);
71
+ }
72
+
73
+ if (!typeCache.has(parentId)) {
74
+ this.totalEntries++;
75
+ }
76
+ typeCache.set(parentId, entry);
77
+ }
78
+
79
+ delete(cacheKey: string, parentId: string): boolean {
80
+ const typeCache = this.cache.get(cacheKey);
81
+ if (!typeCache) return false;
82
+
83
+ const existed = typeCache.delete(parentId);
84
+ if (existed) {
85
+ this.totalEntries--;
86
+ }
87
+ return existed;
88
+ }
89
+
90
+ clear(): void {
91
+ this.cache.clear();
92
+ this.accessOrder = [];
93
+ this.totalEntries = 0;
94
+ }
95
+
96
+ getStats(): { types: number; entries: number; expired: number; memoryEstimate: string } {
97
+ let expiredEntries = 0;
98
+ const now = Date.now();
99
+
100
+ for (const [, typeCache] of this.cache) {
101
+ for (const [, entry] of typeCache) {
102
+ if (now > entry.expiresAt) {
103
+ expiredEntries++;
104
+ }
105
+ }
106
+ }
107
+
108
+ // Rough memory estimate: ~100 bytes per entry (UUID strings + overhead)
109
+ const memoryBytes = this.totalEntries * 100;
110
+ const memoryEstimate = memoryBytes > 1024 * 1024
111
+ ? `${(memoryBytes / (1024 * 1024)).toFixed(2)} MB`
112
+ : `${(memoryBytes / 1024).toFixed(2)} KB`;
113
+
114
+ return {
115
+ types: this.cache.size,
116
+ entries: this.totalEntries,
117
+ expired: expiredEntries,
118
+ memoryEstimate
119
+ };
120
+ }
121
+
122
+ private evictOldestType(): void {
123
+ // Find the type cache with oldest average access time
124
+ let oldestKey: string | null = null;
125
+ let oldestAvgAccess = Infinity;
126
+
127
+ for (const [key, typeCache] of this.cache) {
128
+ let totalAccess = 0;
129
+ let count = 0;
130
+ for (const [, entry] of typeCache) {
131
+ totalAccess += entry.lastAccessed;
132
+ count++;
133
+ }
134
+ const avgAccess = count > 0 ? totalAccess / count : 0;
135
+ if (avgAccess < oldestAvgAccess) {
136
+ oldestAvgAccess = avgAccess;
137
+ oldestKey = key;
138
+ }
139
+ }
140
+
141
+ if (oldestKey) {
142
+ const evictedCache = this.cache.get(oldestKey);
143
+ if (evictedCache) {
144
+ this.totalEntries -= evictedCache.size;
145
+ }
146
+ this.cache.delete(oldestKey);
147
+ }
148
+ }
149
+
150
+ private evictLRUEntries(count: number): void {
151
+ // Collect all entries with their access times
152
+ const entries: Array<{ cacheKey: string; parentId: string; lastAccessed: number }> = [];
153
+
154
+ for (const [cacheKey, typeCache] of this.cache) {
155
+ for (const [parentId, entry] of typeCache) {
156
+ entries.push({ cacheKey, parentId, lastAccessed: entry.lastAccessed });
157
+ }
158
+ }
159
+
160
+ // Sort by lastAccessed ascending (oldest first)
161
+ entries.sort((a, b) => a.lastAccessed - b.lastAccessed);
162
+
163
+ // Evict the oldest entries
164
+ const toEvict = entries.slice(0, count);
165
+ for (const { cacheKey, parentId } of toEvict) {
166
+ this.delete(cacheKey, parentId);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Prune expired entries. Call periodically to reclaim memory.
172
+ */
173
+ pruneExpired(): number {
174
+ const now = Date.now();
175
+ let prunedCount = 0;
176
+
177
+ for (const [cacheKey, typeCache] of this.cache) {
178
+ for (const [parentId, entry] of typeCache) {
179
+ if (now > entry.expiresAt) {
180
+ typeCache.delete(parentId);
181
+ this.totalEntries--;
182
+ prunedCount++;
183
+ }
184
+ }
185
+ // Remove empty type caches
186
+ if (typeCache.size === 0) {
187
+ this.cache.delete(cacheKey);
188
+ }
189
+ }
190
+
191
+ return prunedCount;
192
+ }
193
+ }
194
+
19
195
  export class BatchLoader {
20
- private static cache = new Map<string, Map<string, CachedRelation>>();
196
+ private static cache = new BoundedRelationCache({
197
+ maxCacheEntries: 100_000, // Max 100k relation entries (~10MB)
198
+ maxCacheTypes: 500, // Max 500 different component types
199
+ evictionBatchSize: 1000 // Evict 1000 entries at a time
200
+ });
21
201
  private static readonly DEFAULT_BATCH_SIZE = 1000;
22
202
  private static readonly DEFAULT_MAX_CONCURRENCY = 5;
23
203
  private static readonly DEFAULT_CACHE_TTL = 300_000; // 5 minutes
204
+ private static lastPruneTime = 0;
205
+ private static readonly PRUNE_INTERVAL = 60_000; // Prune every minute
24
206
 
25
207
  /**
26
208
  * Load related entities efficiently with caching and batching
@@ -34,6 +216,13 @@ export class BatchLoader {
34
216
  ): Promise<Map<string, Entity>> {
35
217
  if (entities.length === 0) return new Map();
36
218
 
219
+ // Periodic pruning of expired entries
220
+ const now = Date.now();
221
+ if (now - this.lastPruneTime > this.PRUNE_INTERVAL) {
222
+ this.cache.pruneExpired();
223
+ this.lastPruneTime = now;
224
+ }
225
+
37
226
  const {
38
227
  fieldName = 'value',
39
228
  batchSize = this.DEFAULT_BATCH_SIZE,
@@ -45,22 +234,15 @@ export class BatchLoader {
45
234
  const typeId = comp.getTypeID();
46
235
  const parentIds = entities.map(e => e.id);
47
236
 
48
- // Check cache first
49
- const cacheKey = `${typeId}:${fieldName}`;
50
- let cachedResults = this.cache.get(cacheKey);
51
- if (!cachedResults) {
52
- cachedResults = new Map();
53
- this.cache.set(cacheKey, cachedResults);
54
- }
55
-
56
- const now = Date.now();
237
+ // Cache key uses null byte separator to prevent collision
238
+ const cacheKey = `${typeId}\x00${fieldName}`;
57
239
 
58
240
  // Get uncached or expired parent IDs
59
241
  const uncachedParentIds = parentIds.filter(id => {
60
- const entry = cachedResults.get(id);
242
+ const entry = this.cache.get(cacheKey, id);
61
243
  if (!entry) return true;
62
244
  if (now > entry.expiresAt) {
63
- cachedResults.delete(id);
245
+ this.cache.delete(cacheKey, id);
64
246
  return true;
65
247
  }
66
248
  return false;
@@ -92,16 +274,17 @@ export class BatchLoader {
92
274
  }
93
275
 
94
276
  const expiresAt = Date.now() + cacheTTL;
277
+ const lastAccessed = Date.now();
95
278
 
96
279
  // Cache the related IDs for each parent
97
280
  for (const [parentId, relatedIds] of parentGroups) {
98
- cachedResults.set(parentId, { ids: relatedIds, expiresAt });
281
+ this.cache.set(cacheKey, parentId, { ids: relatedIds, expiresAt, lastAccessed });
99
282
  }
100
283
 
101
284
  // Cache empty arrays for parents with no relations
102
285
  for (const parentId of batch) {
103
286
  if (!parentGroups.has(parentId)) {
104
- cachedResults.set(parentId, { ids: [], expiresAt });
287
+ this.cache.set(cacheKey, parentId, { ids: [], expiresAt, lastAccessed });
105
288
  }
106
289
  }
107
290
  }
@@ -110,7 +293,7 @@ export class BatchLoader {
110
293
  // Collect all unique related IDs from cache
111
294
  const allRelatedIds = new Set<string>();
112
295
  for (const parentId of parentIds) {
113
- const entry = cachedResults.get(parentId);
296
+ const entry = this.cache.get(cacheKey, parentId);
114
297
  const relatedIds = entry?.ids || [];
115
298
  relatedIds.forEach((id: string) => allRelatedIds.add(id));
116
299
  }
@@ -151,21 +334,23 @@ export class BatchLoader {
151
334
  }
152
335
 
153
336
  /**
154
- * Get cache statistics including expired entry count
337
+ * Get cache statistics including memory estimate
155
338
  */
156
- static getCacheStats(): { size: number; entries: number; expired: number } {
157
- let totalEntries = 0;
158
- let expiredEntries = 0;
159
- const now = Date.now();
160
- for (const [, parentMap] of this.cache) {
161
- for (const [, entry] of parentMap) {
162
- totalEntries++;
163
- if (now > entry.expiresAt) {
164
- expiredEntries++;
165
- }
166
- }
167
- }
168
- return { size: this.cache.size, entries: totalEntries, expired: expiredEntries };
339
+ static getCacheStats(): { size: number; entries: number; expired: number; memoryEstimate?: string } {
340
+ const stats = this.cache.getStats();
341
+ return {
342
+ size: stats.types,
343
+ entries: stats.entries,
344
+ expired: stats.expired,
345
+ memoryEstimate: stats.memoryEstimate
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Manually prune expired entries. Returns count of pruned entries.
351
+ */
352
+ static pruneExpiredEntries(): number {
353
+ return this.cache.pruneExpired();
169
354
  }
170
355
 
171
356
  private static chunkArray<T>(array: T[], size: number): T[][] {
@@ -207,4 +392,4 @@ class Semaphore {
207
392
  resolve();
208
393
  }
209
394
  }
210
- }
395
+ }
package/core/Entity.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ComponentDataType, ComponentGetter, BaseComponent } from "./components";
2
2
  import { logger } from "./Logger";
3
- import db from "../database";
3
+ import db, { QUERY_TIMEOUT_MS } from "../database";
4
4
  import EntityManager from "./EntityManager";
5
5
  import ComponentRegistry from "./components/ComponentRegistry";
6
6
  import { uuidv7 } from "../utils/uuid";
@@ -389,7 +389,7 @@ export class Entity implements IEntity {
389
389
  const timeout = setTimeout(() => {
390
390
  logger.error(`Entity save timeout for entity ${this.id}`);
391
391
  reject(new Error(`Entity save timeout for entity ${this.id}`));
392
- }, 30000); // 30 second timeout
392
+ }, QUERY_TIMEOUT_MS); // Configurable timeout via DB_QUERY_TIMEOUT env var
393
393
 
394
394
  // Capture dirty components BEFORE doSave clears the dirty flags
395
395
  const changedComponentTypeIds = this.getDirtyComponents();
@@ -6,23 +6,28 @@ import { CacheManager } from './cache/CacheManager';
6
6
 
7
7
  declare module 'graphql-yoga' {
8
8
  interface Context {
9
- locals: {
10
- loaders: RequestLoaders;
11
- requestId: string;
12
- cacheManager: CacheManager;
13
- };
9
+ // Loaders mounted at top-level context for ArcheType resolver access
10
+ loaders: RequestLoaders;
11
+ requestId: string;
12
+ cacheManager: CacheManager;
14
13
  }
15
14
  }
16
15
 
16
+ /**
17
+ * GraphQL Yoga plugin that creates per-request DataLoaders for batching.
18
+ *
19
+ * IMPORTANT: Loaders are mounted at context.loaders (NOT context.locals.loaders)
20
+ * to match what ArcheType.ts resolvers expect. This enables DataLoader batching
21
+ * for BelongsTo/HasMany relations, preventing N+1 queries.
22
+ */
17
23
  export function createRequestContextPlugin(): Plugin {
18
24
  return {
19
25
  onExecute: ({ args }) => {
20
26
  const cacheManager = CacheManager.getInstance();
21
- (args as any).contextValue.locals = {
22
- loaders: createRequestLoaders(db, cacheManager),
23
- requestId: crypto.randomUUID(),
24
- cacheManager: cacheManager,
25
- };
27
+ // Mount loaders at context.loaders to match ArcheType.ts resolver access pattern
28
+ (args as any).contextValue.loaders = createRequestLoaders(db, cacheManager);
29
+ (args as any).contextValue.requestId = crypto.randomUUID();
30
+ (args as any).contextValue.cacheManager = cacheManager;
26
31
  },
27
32
  };
28
33
  }
@@ -273,7 +273,8 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
273
273
  return entity;
274
274
  });
275
275
 
276
- const mapKey = `${key.entityId}-${key.relationField}-${key.relatedType}`;
276
+ // Use null byte separator to prevent key collision when fields contain hyphens
277
+ const mapKey = `${key.entityId}\x00${key.relationField}\x00${key.relatedType}`;
277
278
  resultMap.set(mapKey, entities);
278
279
 
279
280
  logger.trace(`[RelationLoader] Mapped ${entities.length} entities for ${key.relationField} on ${key.entityId}`);
@@ -291,7 +292,8 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
291
292
  if (!k.entityId || typeof k.entityId !== 'string' || k.entityId.trim() === '') {
292
293
  return [];
293
294
  }
294
- const mapKey = `${k.entityId}-${k.relationField}-${k.relatedType}`;
295
+ // Use null byte separator to prevent key collision when fields contain hyphens
296
+ const mapKey = `${k.entityId}\x00${k.relationField}\x00${k.relatedType}`;
295
297
  const result = resultMap.get(mapKey) || [];
296
298
  return result;
297
299
  });
@@ -9,6 +9,7 @@ export interface CacheStats {
9
9
  hitRate: number;
10
10
  size: number;
11
11
  memoryUsage?: number;
12
+ memoryUsageHuman?: string;
12
13
  }
13
14
 
14
15
  export interface CacheProvider {
@@ -1,6 +1,14 @@
1
1
  import { type CacheProvider, type CacheStats } from './CacheProvider';
2
2
  import { logger } from '../Logger';
3
3
 
4
+ function formatBytes(bytes: number): string {
5
+ if (bytes === 0) return '0 B';
6
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
7
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
8
+ const value = bytes / Math.pow(1024, i);
9
+ return `${value.toFixed(2)} ${units[i]}`;
10
+ }
11
+
4
12
  interface CacheEntry<T> {
5
13
  value: T;
6
14
  expiresAt?: number;
@@ -158,7 +166,8 @@ export class MemoryCache implements CacheProvider {
158
166
  misses: this.stats.misses,
159
167
  hitRate,
160
168
  size: this.stats.size,
161
- memoryUsage: this.stats.memoryUsage
169
+ memoryUsage: this.stats.memoryUsage,
170
+ memoryUsageHuman: formatBytes(this.stats.memoryUsage)
162
171
  };
163
172
  }
164
173
 
@@ -13,10 +13,22 @@ export interface HealthStatus {
13
13
  connected: boolean;
14
14
  latency: number;
15
15
  memoryUsage?: number;
16
+ memoryUsageHuman?: string;
16
17
  connections?: number;
17
18
  version?: string;
18
19
  }
19
20
 
21
+ /**
22
+ * Format bytes into human-readable string
23
+ */
24
+ function formatBytes(bytes: number): string {
25
+ if (bytes === 0) return '0 B';
26
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
27
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
28
+ const value = bytes / Math.pow(1024, i);
29
+ return `${value.toFixed(2)} ${units[i]}`;
30
+ }
31
+
20
32
  export interface RedisCacheConfig {
21
33
  host: string;
22
34
  port: number;
@@ -108,7 +120,7 @@ export class RedisCache implements CacheProvider {
108
120
  const memoryMatch = info.match(/used_memory:(\d+)/);
109
121
  if (memoryMatch && memoryMatch[1]) {
110
122
  const memoryUsage = parseInt(memoryMatch[1], 10);
111
- logger.debug({ msg: 'Redis memory usage', memoryUsage });
123
+ logger.debug({ msg: 'Redis memory usage', memoryUsage: formatBytes(memoryUsage), memoryUsageBytes: memoryUsage });
112
124
  }
113
125
  } catch (error) {
114
126
  logger.error({ error, msg: 'Failed to get Redis memory info' });
@@ -311,7 +323,8 @@ export class RedisCache implements CacheProvider {
311
323
  misses: this.stats.misses,
312
324
  hitRate: this.stats.hits / (this.stats.hits + this.stats.misses) || 0,
313
325
  size,
314
- memoryUsage
326
+ memoryUsage,
327
+ memoryUsageHuman: memoryUsage !== undefined ? formatBytes(memoryUsage) : undefined
315
328
  };
316
329
  } catch (error) {
317
330
  logger.error({ error, msg: 'Redis getStats error' });
@@ -356,6 +369,7 @@ export class RedisCache implements CacheProvider {
356
369
  connected: true,
357
370
  latency,
358
371
  memoryUsage,
372
+ memoryUsageHuman: memoryUsage !== undefined ? formatBytes(memoryUsage) : undefined,
359
373
  connections,
360
374
  version
361
375
  };
@@ -52,6 +52,14 @@ const envSchema = z
52
52
  .string()
53
53
  .regex(/^\d+$/, "DB_STATEMENT_TIMEOUT must be numeric")
54
54
  .optional(),
55
+ DB_QUERY_TIMEOUT: z
56
+ .string()
57
+ .regex(/^\d+$/, "DB_QUERY_TIMEOUT must be numeric (milliseconds)")
58
+ .optional(),
59
+ DB_CONNECTION_TIMEOUT: z
60
+ .string()
61
+ .regex(/^\d+$/, "DB_CONNECTION_TIMEOUT must be numeric (seconds)")
62
+ .optional(),
55
63
  })
56
64
  .refine(
57
65
  (env) => {