@memberjunction/server 5.25.0 → 5.26.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/src/config.ts CHANGED
@@ -192,6 +192,19 @@ const serverExtensionSchema = z.object({
192
192
  Settings: z.record(z.unknown()).default({})
193
193
  }).passthrough();
194
194
 
195
+ const cacheSettingsSchema = z.object({
196
+ /** Maximum total estimated memory for all cached results in MB. Default: 150. Set to 0 to disable memory-based eviction. */
197
+ maxMemoryMB: z.number().optional().default(150),
198
+ /** Maximum percentage of total cache memory that any single entity can occupy. Default: 50. Set to 0 to disable. */
199
+ maxPercentOfCachePerEntity: z.number().optional().default(50),
200
+ /** Default TTL in seconds. 0 = no TTL, rely on event-based invalidation. Default: 0. */
201
+ defaultTTLSeconds: z.number().optional().default(0),
202
+ /** Interval in seconds for periodic eviction sweep. 0 = disabled. Default: 300 (5 minutes). */
203
+ evictionSweepIntervalSeconds: z.number().optional().default(300),
204
+ /** Enable verbose cache logging (hits, misses, evictions). Default: false. */
205
+ verboseLogging: z.boolean().optional().default(false),
206
+ });
207
+
195
208
  const configInfoSchema = z.object({
196
209
  userHandling: userHandlingInfoSchema,
197
210
  databaseSettings: databaseSettingsInfoSchema,
@@ -206,6 +219,7 @@ const configInfoSchema = z.object({
206
219
  queryDialects: queryDialectSchema.optional().default({}),
207
220
  multiTenancy: multiTenancySchema.optional().default({}),
208
221
  serverExtensions: z.array(serverExtensionSchema).optional().default([]),
222
+ cacheSettings: cacheSettingsSchema.optional().default({}),
209
223
 
210
224
  apiKey: z.string().optional(),
211
225
  baseUrl: z.string().default('http://localhost'),
@@ -252,6 +266,7 @@ export type TelemetryConfig = z.infer<typeof telemetrySchema>;
252
266
  export type QueryDialectConfig = z.infer<typeof queryDialectSchema>;
253
267
  export type MultiTenancyConfig = z.infer<typeof multiTenancySchema>;
254
268
  export type ServerExtensionConfig = z.infer<typeof serverExtensionSchema>;
269
+ export type CacheSettingsConfig = z.infer<typeof cacheSettingsSchema>;
255
270
  export type ConfigInfo = z.infer<typeof configInfoSchema>;
256
271
 
257
272
  /**
@@ -378,6 +393,15 @@ export const DEFAULT_SERVER_CONFIG: Partial<ConfigInfo> = {
378
393
  level: 'standard'
379
394
  },
380
395
 
396
+ // Cache settings defaults
397
+ cacheSettings: {
398
+ maxMemoryMB: 150,
399
+ maxPercentOfCachePerEntity: 50,
400
+ defaultTTLSeconds: 0,
401
+ evictionSweepIntervalSeconds: 300,
402
+ verboseLogging: false,
403
+ },
404
+
381
405
  // Auth providers (environment-driven)
382
406
  authProviders: [
383
407
  // Microsoft Azure AD / Entra ID
@@ -37711,6 +37711,12 @@ export class MJEntity_ {
37711
37711
  @Field(() => Boolean, {description: `When true (default), CodeGen can automatically set SupportsGeoCoding based on LLM analysis of entity fields. Set to 0 to lock the value and prevent CodeGen from changing it.`})
37712
37712
  AutoUpdateSupportsGeoCoding: boolean;
37713
37713
 
37714
+ @Field(() => Boolean, {description: `Controls whether this entity participates in server-side and client-side caching. When false, all cache operations (PreRunView checks, auto-cache storage, BaseEntity event fingerprint scans, client-side IndexedDB cache) are skipped entirely. This column is the single source of truth at runtime; schema-level defaults are applied at CodeGen time via newEntityDefaults.AllowCachingBySchema.`})
37715
+ AllowCaching: boolean;
37716
+
37717
+ @Field(() => Boolean, {description: `When set to 1 AND TrackRecordChanges is also 1, the external change detection system will scan this entity for changes made outside the MJ framework (direct SQL, third-party tools, etc.) and replay them through Save() to create proper RecordChange audit entries. Default is 0 (opt-out) because most entities, especially __mj schema metadata tables, are managed by migrations/CodeGen and should not be scanned.`})
37718
+ DetectExternalChanges: boolean;
37719
+
37714
37720
  @Field({nullable: true, description: `Schema-based programmatic code name derived from the entity Name. Uses GetClassNameSchemaPrefix(SchemaName) as the prefix, then strips EntityNamePrefix from the Name and removes spaces. For "__mj" schema with entity "MJ: AI Models", this produces "MJAIModels". For entities in other schemas, the sanitized schema name is prepended. Used in GraphQL type generation and internal code references.`})
37715
37721
  CodeName?: string;
37716
37722
 
@@ -38080,6 +38086,12 @@ export class CreateMJEntityInput {
38080
38086
 
38081
38087
  @Field(() => Boolean, { nullable: true })
38082
38088
  AutoUpdateSupportsGeoCoding?: boolean;
38089
+
38090
+ @Field(() => Boolean, { nullable: true })
38091
+ AllowCaching?: boolean;
38092
+
38093
+ @Field(() => Boolean, { nullable: true })
38094
+ DetectExternalChanges?: boolean;
38083
38095
  }
38084
38096
 
38085
38097
 
@@ -38265,6 +38277,12 @@ export class UpdateMJEntityInput {
38265
38277
  @Field(() => Boolean, { nullable: true })
38266
38278
  AutoUpdateSupportsGeoCoding?: boolean;
38267
38279
 
38280
+ @Field(() => Boolean, { nullable: true })
38281
+ AllowCaching?: boolean;
38282
+
38283
+ @Field(() => Boolean, { nullable: true })
38284
+ DetectExternalChanges?: boolean;
38285
+
38268
38286
  @Field(() => [KeyValuePairInput], { nullable: true })
38269
38287
  OldValues___?: KeyValuePairInput[];
38270
38288
  }
package/src/index.ts CHANGED
@@ -128,6 +128,7 @@ export * from './resolvers/TelemetryResolver.js';
128
128
  export * from './resolvers/APIKeyResolver.js';
129
129
  export * from './resolvers/MCPResolver.js';
130
130
  export * from './resolvers/ActionResolver.js';
131
+ export * from './resolvers/CacheStatsResolver.js';
131
132
  export * from './resolvers/EntityCommunicationsResolver.js';
132
133
  export * from './resolvers/EntityResolver.js';
133
134
  export * from './resolvers/ISAEntityResolver.js';
@@ -451,8 +452,21 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
451
452
  }
452
453
  // Ensure LocalCacheManager is initialized (no-op if already done during engine loading)
453
454
  if (!LocalCacheManager.Instance.IsInitialized) {
454
- await LocalCacheManager.Instance.Initialize(Metadata.Provider.LocalStorageProvider);
455
- console.log('LocalCacheManager initialized');
455
+ // Build cache config from mj.config.cjs cacheSettings
456
+ const cs = configInfo.cacheSettings;
457
+ const cacheConfig = {
458
+ maxSizeBytes: (cs.maxMemoryMB ?? 150) * 1024 * 1024,
459
+ maxPercentOfCachePerEntity: cs.maxPercentOfCachePerEntity ?? 50,
460
+ defaultTTLMs: (cs.defaultTTLSeconds ?? 0) * 1000,
461
+ evictionSweepIntervalMs: (cs.evictionSweepIntervalSeconds ?? 300) * 1000,
462
+ verboseLogging: cs.verboseLogging ?? false,
463
+ };
464
+ await LocalCacheManager.Instance.Initialize(Metadata.Provider.LocalStorageProvider, cacheConfig);
465
+ console.log('LocalCacheManager initialized with cache config:', JSON.stringify({
466
+ maxMemoryMB: cs.maxMemoryMB ?? 150,
467
+ maxPercentOfCachePerEntity: cs.maxPercentOfCachePerEntity ?? 50,
468
+ evictionSweepIntervalSeconds: cs.evictionSweepIntervalSeconds ?? 300,
469
+ }));
456
470
  }
457
471
 
458
472
  // Initialize APIKeyEngine singleton — reads apiKeyGeneration from mj.config.cjs automatically
@@ -0,0 +1,142 @@
1
+ import { Field, Float, Int, ObjectType, Query, Resolver } from 'type-graphql';
2
+ import { LocalCacheManager, CacheStats, CacheEntryType } from '@memberjunction/core';
3
+ import { RequireSystemUser } from '../directives/index.js';
4
+
5
+ // ============================================================================
6
+ // GraphQL Object Types
7
+ // ============================================================================
8
+
9
+ @ObjectType()
10
+ class CacheStatsByTypeGQL {
11
+ @Field(() => String)
12
+ Type: CacheEntryType;
13
+
14
+ @Field(() => Int)
15
+ Count: number;
16
+
17
+ @Field(() => Int)
18
+ SizeBytes: number;
19
+ }
20
+
21
+ @ObjectType()
22
+ class CacheStatsGQL {
23
+ @Field(() => Int)
24
+ TotalEntries: number;
25
+
26
+ @Field(() => Int)
27
+ TotalSizeBytes: number;
28
+
29
+ @Field(() => [CacheStatsByTypeGQL])
30
+ ByType: CacheStatsByTypeGQL[];
31
+
32
+ @Field(() => Float)
33
+ OldestEntry: number;
34
+
35
+ @Field(() => Float)
36
+ NewestEntry: number;
37
+
38
+ @Field(() => Int)
39
+ Hits: number;
40
+
41
+ @Field(() => Int)
42
+ Misses: number;
43
+
44
+ @Field(() => Float)
45
+ HitRate: number;
46
+ }
47
+
48
+ @ObjectType()
49
+ class CacheEntityBreakdownGQL {
50
+ @Field(() => String)
51
+ EntityName: string;
52
+
53
+ @Field(() => Int)
54
+ EntryCount: number;
55
+
56
+ @Field(() => Int)
57
+ TotalSizeBytes: number;
58
+
59
+ @Field(() => Int)
60
+ TotalAccessCount: number;
61
+ }
62
+
63
+ @ObjectType()
64
+ class CacheStatsDetailGQL extends CacheStatsGQL {
65
+ @Field(() => [CacheEntityBreakdownGQL])
66
+ EntityBreakdown: CacheEntityBreakdownGQL[];
67
+ }
68
+
69
+ // ============================================================================
70
+ // Helpers (pure core)
71
+ // ============================================================================
72
+
73
+ function toGQL(stats: CacheStats): CacheStatsGQL {
74
+ const gql = new CacheStatsGQL();
75
+ gql.TotalEntries = stats.totalEntries;
76
+ gql.TotalSizeBytes = stats.totalSizeBytes;
77
+ gql.ByType = (['dataset', 'runview', 'runquery'] as const).map(t => {
78
+ const bt = stats.byType[t];
79
+ const entry = new CacheStatsByTypeGQL();
80
+ entry.Type = t;
81
+ entry.Count = bt.count;
82
+ entry.SizeBytes = bt.sizeBytes;
83
+ return entry;
84
+ });
85
+ gql.OldestEntry = stats.oldestEntry;
86
+ gql.NewestEntry = stats.newestEntry;
87
+ gql.Hits = stats.hits;
88
+ gql.Misses = stats.misses;
89
+ gql.HitRate = (stats.hits + stats.misses) > 0
90
+ ? (stats.hits / (stats.hits + stats.misses)) * 100
91
+ : 0;
92
+ return gql;
93
+ }
94
+
95
+ function buildEntityBreakdown(): CacheEntityBreakdownGQL[] {
96
+ const entries = LocalCacheManager.Instance.GetAllEntries();
97
+ const entityMap = new Map<string, { count: number; sizeBytes: number; accessCount: number }>();
98
+
99
+ for (const entry of entries) {
100
+ if (entry.type !== 'runview' || !entry.name) continue;
101
+ const existing = entityMap.get(entry.name) ?? { count: 0, sizeBytes: 0, accessCount: 0 };
102
+ existing.count++;
103
+ existing.sizeBytes += entry.sizeBytes;
104
+ existing.accessCount += entry.accessCount;
105
+ entityMap.set(entry.name, existing);
106
+ }
107
+
108
+ return [...entityMap.entries()]
109
+ .map(([name, data]) => {
110
+ const gql = new CacheEntityBreakdownGQL();
111
+ gql.EntityName = name;
112
+ gql.EntryCount = data.count;
113
+ gql.TotalSizeBytes = data.sizeBytes;
114
+ gql.TotalAccessCount = data.accessCount;
115
+ return gql;
116
+ })
117
+ .sort((a, b) => b.EntryCount - a.EntryCount);
118
+ }
119
+
120
+ // ============================================================================
121
+ // Resolver
122
+ // ============================================================================
123
+
124
+ @Resolver()
125
+ export class CacheStatsResolver {
126
+ @RequireSystemUser()
127
+ @Query(() => CacheStatsGQL)
128
+ CacheStats(): CacheStatsGQL {
129
+ const stats = LocalCacheManager.Instance.GetStats();
130
+ return toGQL(stats);
131
+ }
132
+
133
+ @RequireSystemUser()
134
+ @Query(() => CacheStatsDetailGQL)
135
+ CacheStatsDetail(): CacheStatsDetailGQL {
136
+ const stats = LocalCacheManager.Instance.GetStats();
137
+ const result = new CacheStatsDetailGQL();
138
+ Object.assign(result, toGQL(stats));
139
+ result.EntityBreakdown = buildEntityBreakdown();
140
+ return result;
141
+ }
142
+ }