@memberjunction/server 5.25.0 → 5.27.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/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
+ }
@@ -2,7 +2,6 @@ import { EntitySaveOptions, IRunReportProvider, Metadata, RunReport } from '@mem
2
2
  import { Arg, Ctx, Field, Int, Mutation, ObjectType, Query, Resolver } from 'type-graphql';
3
3
  import { AppContext } from '../types.js';
4
4
  import { MJConversationDetailEntity, MJReportEntity } from '@memberjunction/core-entities';
5
- import { SkipAPIAnalysisCompleteResponse } from '@memberjunction/skip-types';
6
5
  import { DataContext } from '@memberjunction/data-context';
7
6
  import { UserCache } from '@memberjunction/sqlserver-dataprovider';
8
7
  import { z } from 'zod';
@@ -104,7 +103,7 @@ export class ReportResolverExtended extends ResolverBase {
104
103
  const request = new mssql.Request(dataSource);
105
104
  const result = await request.query(sql);
106
105
  if (!result || !result.recordset || result.recordset.length === 0) throw new Error('Unable to retrieve converation details');
107
- const skipData = <SkipAPIAnalysisCompleteResponse>JSON.parse(result.recordset[0].Message);
106
+ const skipData: { title?: string; reportTitle?: string; userExplanation?: string; messages?: unknown[] } = JSON.parse(result.recordset[0].Message);
108
107
 
109
108
  const report = await md.GetEntityObject<MJReportEntity>('MJ: Reports', u);
110
109
  report.NewRecord();
@@ -1,274 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
-
3
- /**
4
- * Tests for the organic-key packing logic in SkipSDK.
5
- *
6
- * The packing helpers (`packSingleSkipOrganicKey`, `packSingleSkipOrganicKeyRelatedEntity`,
7
- * and the organic-key block inside `packSingleSkipEntityInfo`) are private methods on the
8
- * SkipSDK class and the SDK module pulls in a large dependency graph (mssql, http, MJ
9
- * config, AI engine, rxjs). To keep these as focused unit tests we mock every transitive
10
- * dependency aggressively and reach the private methods via bracket notation.
11
- */
12
-
13
- // ---- Mutable state shared with the @memberjunction/core mock ----------------
14
-
15
- const mockMetadataEntities: Array<{ ID: string; Name: string; SchemaName: string; BaseView: string }> = [];
16
-
17
- // ---- Module mocks (must be defined before importing skip-sdk) ---------------
18
-
19
- vi.mock('@memberjunction/core', () => ({
20
- LogStatus: vi.fn(),
21
- LogError: vi.fn(),
22
- Metadata: class {
23
- static get Provider() {
24
- return { Entities: mockMetadataEntities };
25
- }
26
- },
27
- RunQuery: class {},
28
- EntityInfo: class {},
29
- EntityFieldInfo: class {},
30
- EntityRelationshipInfo: class {},
31
- EntityOrganicKeyInfo: class {},
32
- EntityOrganicKeyRelatedEntityInfo: class {},
33
- UserInfo: class {},
34
- }));
35
-
36
- vi.mock('@memberjunction/global', () => ({
37
- CopyScalarsAndArrays: (o: unknown) => o,
38
- // The packing helpers compare entity IDs with UUIDsEqual; for the test we
39
- // can use simple string equality.
40
- UUIDsEqual: (a: string, b: string) => a === b,
41
- }));
42
-
43
- vi.mock('@memberjunction/ai', () => ({
44
- GetAIAPIKey: vi.fn(() => 'test-key'),
45
- }));
46
-
47
- vi.mock('@memberjunction/aiengine', () => ({
48
- AIEngine: { Instance: { Config: () => Promise.resolve() } },
49
- }));
50
-
51
- vi.mock('@memberjunction/data-context', () => ({
52
- DataContext: class {},
53
- }));
54
-
55
- vi.mock('mssql', () => ({
56
- default: {},
57
- ConnectionPool: class {},
58
- }));
59
-
60
- vi.mock('http', () => ({ request: vi.fn() }));
61
- vi.mock('https', () => ({ request: vi.fn() }));
62
- vi.mock('zlib', () => ({ gzip: vi.fn(), createGunzip: vi.fn() }));
63
-
64
- vi.mock('rxjs', () => ({
65
- BehaviorSubject: class {
66
- private value: unknown;
67
- constructor(initial: unknown) { this.value = initial; }
68
- next(v: unknown) { this.value = v; }
69
- pipe() { return this; }
70
- },
71
- }));
72
- vi.mock('rxjs/operators', () => ({ take: vi.fn() }));
73
-
74
- // Skip SDK pulls server config and a couple of resolver internals; stub all of them.
75
- vi.mock('../config.js', () => ({
76
- configInfo: { askSkip: { chatURL: '', apiKey: '', orgID: '', organizationInfo: '' } },
77
- baseUrl: '',
78
- publicUrl: '',
79
- graphqlPort: 4000,
80
- graphqlRootPath: '/graphql',
81
- apiKey: '',
82
- }));
83
-
84
- vi.mock('../index.js', () => ({
85
- getDbType: vi.fn(() => 'mssql'),
86
- }));
87
-
88
- vi.mock('../resolvers/GetDataResolver.js', () => ({
89
- registerAccessToken: vi.fn(),
90
- GetDataAccessToken: vi.fn(),
91
- }));
92
-
93
- // ---- Imports under test (must come AFTER vi.mock calls) ---------------------
94
-
95
- import { SkipSDK } from '../agents/skip-sdk';
96
- import type { SkipEntityOrganicKeyInfo } from '@memberjunction/skip-types';
97
-
98
- // ---- Helpers to fabricate runtime-shaped MJ organic key objects -------------
99
-
100
- function makeOrganicKey(overrides: Record<string, unknown> = {}) {
101
- const base = {
102
- ID: 'ok-1',
103
- EntityID: 'ent-source',
104
- Name: 'EmailMatch',
105
- Description: 'Match members across systems by email address',
106
- MatchFieldNames: 'EmailAddress',
107
- NormalizationStrategy: 'LowerCaseTrim' as const,
108
- CustomNormalizationExpression: null,
109
- Sequence: 0,
110
- Status: 'Active' as const,
111
- RelatedEntities: [] as unknown[],
112
- };
113
- const merged = { ...base, ...overrides };
114
- Object.defineProperty(merged, 'MatchFieldNamesArray', {
115
- get() {
116
- return this.MatchFieldNames ? this.MatchFieldNames.split(',').map((f: string) => f.trim()) : [];
117
- },
118
- });
119
- return merged;
120
- }
121
-
122
- function makeOrganicKeyRelatedEntity(overrides: Record<string, unknown> = {}) {
123
- const base = {
124
- ID: 'okre-1',
125
- EntityOrganicKeyID: 'ok-1',
126
- RelatedEntityID: 'ent-target',
127
- RelatedEntityFieldNames: null as string | null,
128
- TransitiveObjectName: null as string | null,
129
- TransitiveObjectMatchFieldNames: null as string | null,
130
- TransitiveObjectOutputFieldName: null as string | null,
131
- RelatedEntityJoinFieldName: null as string | null,
132
- DisplayName: null as string | null,
133
- Sequence: 0,
134
- };
135
- const merged = { ...base, ...overrides };
136
- Object.defineProperty(merged, 'IsDirectMatch', {
137
- get() { return this.RelatedEntityFieldNames != null; },
138
- });
139
- Object.defineProperty(merged, 'IsTransitiveMatch', {
140
- get() { return this.TransitiveObjectName != null; },
141
- });
142
- Object.defineProperty(merged, 'RelatedEntityFieldNamesArray', {
143
- get() {
144
- return this.RelatedEntityFieldNames ? this.RelatedEntityFieldNames.split(',').map((f: string) => f.trim()) : [];
145
- },
146
- });
147
- Object.defineProperty(merged, 'TransitiveObjectMatchFieldNamesArray', {
148
- get() {
149
- return this.TransitiveObjectMatchFieldNames ? this.TransitiveObjectMatchFieldNames.split(',').map((f: string) => f.trim()) : [];
150
- },
151
- });
152
- return merged;
153
- }
154
-
155
- // Bracket-notation accessors so the tests can reach private methods without
156
- // adopting a // @ts-expect-error per call site.
157
- type SkipSDKPrivate = SkipSDK & {
158
- packSingleSkipOrganicKey: (ok: unknown) => SkipEntityOrganicKeyInfo | null;
159
- packSingleSkipOrganicKeyRelatedEntity: (re: unknown) => unknown | null;
160
- };
161
-
162
- describe('SkipSDK organic key packing', () => {
163
- let sdk: SkipSDKPrivate;
164
-
165
- beforeEach(() => {
166
- mockMetadataEntities.length = 0;
167
- sdk = new SkipSDK() as SkipSDKPrivate;
168
- });
169
-
170
- describe('packSingleSkipOrganicKeyRelatedEntity', () => {
171
- it('should pack a direct-match related entity using metadata for schema/baseView', () => {
172
- mockMetadataEntities.push({ ID: 'ent-target', Name: 'Members', SchemaName: 'ym', BaseView: 'vwMembers' });
173
- const re = makeOrganicKeyRelatedEntity({
174
- ID: 'okre-direct',
175
- RelatedEntityID: 'ent-target',
176
- RelatedEntityFieldNames: 'EmailAddress',
177
- DisplayName: 'Members by Email',
178
- Sequence: 1,
179
- });
180
-
181
- const result = sdk['packSingleSkipOrganicKeyRelatedEntity'](re) as Record<string, unknown> | null;
182
- expect(result).not.toBeNull();
183
- expect(result!.id).toBe('okre-direct');
184
- expect(result!.relatedEntityName).toBe('Members');
185
- expect(result!.relatedEntitySchemaName).toBe('ym');
186
- expect(result!.relatedEntityBaseView).toBe('vwMembers');
187
- expect(result!.isDirectMatch).toBe(true);
188
- expect(result!.isTransitiveMatch).toBe(false);
189
- expect(result!.relatedEntityFieldNames).toEqual(['EmailAddress']);
190
- expect(result!.transitiveObjectName).toBeUndefined();
191
- });
192
-
193
- it('should pack a transitive-match related entity through a bridge view', () => {
194
- mockMetadataEntities.push({ ID: 'ent-target', Name: 'Members', SchemaName: 'ym', BaseView: 'vwMembers' });
195
- const re = makeOrganicKeyRelatedEntity({
196
- RelatedEntityID: 'ent-target',
197
- TransitiveObjectName: 'ym.vwAcronymToMember',
198
- TransitiveObjectMatchFieldNames: 'Acronym',
199
- TransitiveObjectOutputFieldName: 'MemberID',
200
- RelatedEntityJoinFieldName: 'ID',
201
- });
202
-
203
- const result = sdk['packSingleSkipOrganicKeyRelatedEntity'](re) as Record<string, unknown> | null;
204
- expect(result).not.toBeNull();
205
- expect(result!.isDirectMatch).toBe(false);
206
- expect(result!.isTransitiveMatch).toBe(true);
207
- expect(result!.relatedEntityFieldNames).toBeUndefined();
208
- expect(result!.transitiveObjectName).toBe('ym.vwAcronymToMember');
209
- expect(result!.transitiveObjectMatchFieldNames).toEqual(['Acronym']);
210
- expect(result!.transitiveObjectOutputFieldName).toBe('MemberID');
211
- expect(result!.relatedEntityJoinFieldName).toBe('ID');
212
- });
213
-
214
- it('should return null when the related entity is not in metadata', () => {
215
- // mockMetadataEntities is empty
216
- const re = makeOrganicKeyRelatedEntity({
217
- RelatedEntityID: 'ent-missing',
218
- RelatedEntityFieldNames: 'EmailAddress',
219
- });
220
-
221
- const result = sdk['packSingleSkipOrganicKeyRelatedEntity'](re);
222
- expect(result).toBeNull();
223
- });
224
- });
225
-
226
- describe('packSingleSkipOrganicKey', () => {
227
- beforeEach(() => {
228
- mockMetadataEntities.push({ ID: 'ent-target', Name: 'Members', SchemaName: 'ym', BaseView: 'vwMembers' });
229
- });
230
-
231
- it('should pack a basic organic key with parsed match field names', () => {
232
- const ok = makeOrganicKey({
233
- ID: 'ok-acronym',
234
- Name: 'AcronymMatch',
235
- MatchFieldNames: 'MemberOrganization',
236
- Sequence: 5,
237
- RelatedEntities: [makeOrganicKeyRelatedEntity({ RelatedEntityID: 'ent-target', RelatedEntityFieldNames: 'EmailAddress' })],
238
- });
239
-
240
- const result = sdk['packSingleSkipOrganicKey'](ok);
241
- expect(result).not.toBeNull();
242
- expect(result!.id).toBe('ok-acronym');
243
- expect(result!.name).toBe('AcronymMatch');
244
- expect(result!.matchFieldNames).toEqual(['MemberOrganization']);
245
- expect(result!.normalizationStrategy).toBe('LowerCaseTrim');
246
- expect(result!.sequence).toBe(5);
247
- expect(result!.relatedEntities).toHaveLength(1);
248
- });
249
-
250
- it('should preserve a custom normalization expression', () => {
251
- const ok = makeOrganicKey({
252
- NormalizationStrategy: 'Custom',
253
- CustomNormalizationExpression: 'REPLACE(LOWER({{FieldName}}), \' \', \'\')',
254
- });
255
-
256
- const result = sdk['packSingleSkipOrganicKey'](ok);
257
- expect(result!.normalizationStrategy).toBe('Custom');
258
- expect(result!.customNormalizationExpression).toBe('REPLACE(LOWER({{FieldName}}), \' \', \'\')');
259
- });
260
-
261
- it('should drop related entities that cannot be resolved against metadata', () => {
262
- const ok = makeOrganicKey({
263
- RelatedEntities: [
264
- makeOrganicKeyRelatedEntity({ ID: 're-good', RelatedEntityID: 'ent-target', RelatedEntityFieldNames: 'EmailAddress' }),
265
- makeOrganicKeyRelatedEntity({ ID: 're-bad', RelatedEntityID: 'ent-missing', RelatedEntityFieldNames: 'EmailAddress' }),
266
- ],
267
- });
268
-
269
- const result = sdk['packSingleSkipOrganicKey'](ok);
270
- expect(result!.relatedEntities).toHaveLength(1);
271
- expect(result!.relatedEntities[0].id).toBe('re-good');
272
- });
273
- });
274
- });