@memberjunction/server 5.30.1 → 5.32.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.
Files changed (104) hide show
  1. package/dist/agents/skip-sdk.d.ts +17 -1
  2. package/dist/agents/skip-sdk.d.ts.map +1 -1
  3. package/dist/agents/skip-sdk.js +18 -5
  4. package/dist/agents/skip-sdk.js.map +1 -1
  5. package/dist/auth/exampleNewUserSubClass.js +1 -1
  6. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  7. package/dist/auth/index.js +2 -2
  8. package/dist/auth/index.js.map +1 -1
  9. package/dist/auth/newUsers.js +2 -2
  10. package/dist/auth/newUsers.js.map +1 -1
  11. package/dist/context.js +3 -3
  12. package/dist/context.js.map +1 -1
  13. package/dist/generated/generated.d.ts +217 -4
  14. package/dist/generated/generated.d.ts.map +1 -1
  15. package/dist/generated/generated.js +1251 -24
  16. package/dist/generated/generated.js.map +1 -1
  17. package/dist/generic/ResolverBase.d.ts +5 -5
  18. package/dist/generic/ResolverBase.d.ts.map +1 -1
  19. package/dist/generic/ResolverBase.js +21 -18
  20. package/dist/generic/ResolverBase.js.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +9 -8
  24. package/dist/index.js.map +1 -1
  25. package/dist/multiTenancy/index.js +1 -1
  26. package/dist/multiTenancy/index.js.map +1 -1
  27. package/dist/resolvers/APIKeyResolver.d.ts.map +1 -1
  28. package/dist/resolvers/APIKeyResolver.js +5 -3
  29. package/dist/resolvers/APIKeyResolver.js.map +1 -1
  30. package/dist/resolvers/AutotagPipelineResolver.d.ts +3 -3
  31. package/dist/resolvers/AutotagPipelineResolver.d.ts.map +1 -1
  32. package/dist/resolvers/AutotagPipelineResolver.js +18 -12
  33. package/dist/resolvers/AutotagPipelineResolver.js.map +1 -1
  34. package/dist/resolvers/ComponentRegistryResolver.d.ts +1 -1
  35. package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
  36. package/dist/resolvers/ComponentRegistryResolver.js +6 -4
  37. package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
  38. package/dist/resolvers/FileResolver.js +2 -2
  39. package/dist/resolvers/FileResolver.js.map +1 -1
  40. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  41. package/dist/resolvers/GetDataContextDataResolver.js +1 -2
  42. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  43. package/dist/resolvers/ISAEntityResolver.d.ts.map +1 -1
  44. package/dist/resolvers/ISAEntityResolver.js +2 -5
  45. package/dist/resolvers/ISAEntityResolver.js.map +1 -1
  46. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  47. package/dist/resolvers/IntegrationDiscoveryResolver.js +75 -66
  48. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  49. package/dist/resolvers/SyncDataResolver.d.ts +4 -4
  50. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  51. package/dist/resolvers/SyncDataResolver.js +9 -8
  52. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  53. package/dist/resolvers/SyncRolesUsersResolver.d.ts +6 -6
  54. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  55. package/dist/resolvers/SyncRolesUsersResolver.js +22 -18
  56. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  57. package/dist/resolvers/TagGovernanceResolver.d.ts +43 -0
  58. package/dist/resolvers/TagGovernanceResolver.d.ts.map +1 -0
  59. package/dist/resolvers/TagGovernanceResolver.js +245 -0
  60. package/dist/resolvers/TagGovernanceResolver.js.map +1 -0
  61. package/dist/resolvers/TaskResolver.d.ts +1 -1
  62. package/dist/resolvers/TaskResolver.d.ts.map +1 -1
  63. package/dist/resolvers/TaskResolver.js +4 -2
  64. package/dist/resolvers/TaskResolver.js.map +1 -1
  65. package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
  66. package/dist/resolvers/TransactionGroupResolver.js +2 -1
  67. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  68. package/dist/rest/EntityCRUDHandler.js +4 -4
  69. package/dist/rest/EntityCRUDHandler.js.map +1 -1
  70. package/dist/rest/RESTEndpointHandler.js +9 -9
  71. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  72. package/dist/rest/ViewOperationsHandler.js +4 -4
  73. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  74. package/dist/services/TaskOrchestrator.d.ts +4 -2
  75. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  76. package/dist/services/TaskOrchestrator.js +16 -12
  77. package/dist/services/TaskOrchestrator.js.map +1 -1
  78. package/package.json +68 -66
  79. package/src/__tests__/TagGovernanceResolver.test.ts +255 -0
  80. package/src/agents/skip-sdk.ts +30 -7
  81. package/src/auth/exampleNewUserSubClass.ts +1 -1
  82. package/src/auth/index.ts +2 -2
  83. package/src/auth/newUsers.ts +2 -2
  84. package/src/context.ts +3 -3
  85. package/src/generated/generated.ts +861 -21
  86. package/src/generic/ResolverBase.ts +28 -21
  87. package/src/index.ts +9 -9
  88. package/src/multiTenancy/index.ts +1 -1
  89. package/src/resolvers/APIKeyResolver.ts +7 -4
  90. package/src/resolvers/AutotagPipelineResolver.ts +20 -11
  91. package/src/resolvers/ComponentRegistryResolver.ts +8 -5
  92. package/src/resolvers/FileResolver.ts +2 -2
  93. package/src/resolvers/GetDataContextDataResolver.ts +1 -2
  94. package/src/resolvers/ISAEntityResolver.ts +3 -5
  95. package/src/resolvers/IntegrationDiscoveryResolver.ts +83 -66
  96. package/src/resolvers/SyncDataResolver.ts +12 -11
  97. package/src/resolvers/SyncRolesUsersResolver.ts +23 -19
  98. package/src/resolvers/TagGovernanceResolver.ts +189 -0
  99. package/src/resolvers/TaskResolver.ts +5 -3
  100. package/src/resolvers/TransactionGroupResolver.ts +3 -2
  101. package/src/rest/EntityCRUDHandler.ts +4 -4
  102. package/src/rest/RESTEndpointHandler.ts +9 -9
  103. package/src/rest/ViewOperationsHandler.ts +4 -4
  104. package/src/services/TaskOrchestrator.ts +18 -13
@@ -0,0 +1,255 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ /**
4
+ * Tests for TagGovernanceResolver.
5
+ *
6
+ * type-graphql decorators on the resolver class need a registered TypeGraphQL
7
+ * runtime to instantiate, so we don't construct the resolver directly. Instead
8
+ * we verify the resolver wraps the engines correctly by stubbing the engines
9
+ * and asserting the dispatch contract — what gets called, with what args, and
10
+ * how errors are surfaced.
11
+ */
12
+
13
+ const { promoteCalls, rejectCalls, rebuildCalls, healthCalls, behaviorFlags } = vi.hoisted(() => ({
14
+ promoteCalls: [] as Array<{ id: string; strategy: unknown; user: unknown }>,
15
+ rejectCalls: [] as Array<{ id: string; notes: string | null; user: unknown }>,
16
+ rebuildCalls: [] as Array<{ user: unknown }>,
17
+ healthCalls: [] as Array<{ thresholds: unknown; user: unknown }>,
18
+ behaviorFlags: { promoteThrows: false, rejectThrows: false, rebuildThrows: false, healthThrows: false },
19
+ }));
20
+
21
+ vi.mock('@memberjunction/tag-engine', () => ({
22
+ TagGovernanceEngine: {
23
+ Instance: {
24
+ PromoteSuggestion: async (id: string, strategy: unknown, user: unknown) => {
25
+ promoteCalls.push({ id, strategy, user });
26
+ if (behaviorFlags.promoteThrows) throw new Error('promote-fail');
27
+ return { ID: 'new-tag-id', Name: 'Resolved Tag' };
28
+ },
29
+ RejectSuggestion: async (id: string, notes: string | null, user: unknown) => {
30
+ rejectCalls.push({ id, notes, user });
31
+ if (behaviorFlags.rejectThrows) throw new Error('reject-fail');
32
+ },
33
+ },
34
+ },
35
+ TagEngine: {
36
+ Instance: {
37
+ RebuildTagEmbeddings: async (user: unknown) => {
38
+ rebuildCalls.push({ user });
39
+ if (behaviorFlags.rebuildThrows) throw new Error('rebuild-fail');
40
+ return { refreshed: 7, total: 100 };
41
+ },
42
+ },
43
+ },
44
+ TagHealthJob: {
45
+ Instance: {
46
+ Run: async (thresholds: unknown, user: unknown) => {
47
+ healthCalls.push({ thresholds, user });
48
+ if (behaviorFlags.healthThrows) throw new Error('health-fail');
49
+ return { mergeCount: 3, lowUsageCount: 2, wideNodeCount: 1, durationMs: 42 };
50
+ },
51
+ },
52
+ },
53
+ DEFAULT_TAG_HEALTH_THRESHOLDS: {
54
+ minCoOccurrence: 5,
55
+ minNameSimilarity: 0.5,
56
+ minEmbeddingSimilarity: 0.85,
57
+ maxUsage: 2,
58
+ maxImplicitChildren: 25,
59
+ },
60
+ }));
61
+
62
+ // Minimal type-graphql shim — we only need the decorators to be no-ops so the
63
+ // resolver class can load. Method behavior is what we test.
64
+ vi.mock('type-graphql', () => ({
65
+ Resolver: () => () => undefined,
66
+ Mutation: () => () => undefined,
67
+ Query: () => () => undefined,
68
+ Ctx: () => () => undefined,
69
+ Arg: () => () => undefined,
70
+ ObjectType: () => () => undefined,
71
+ Field: () => () => undefined,
72
+ Int: () => undefined,
73
+ Float: () => undefined,
74
+ }));
75
+
76
+ vi.mock('@memberjunction/core', () => ({
77
+ LogError: vi.fn(),
78
+ LogStatus: vi.fn(),
79
+ }));
80
+
81
+ // Stub ResolverBase — we only need GetUserFromPayload to return a synthetic user.
82
+ vi.mock('../generic/ResolverBase.js', () => ({
83
+ ResolverBase: class {
84
+ GetUserFromPayload(payload: unknown) {
85
+ if (!payload) return null;
86
+ return { ID: 'user-1', Email: 'test@example.com' };
87
+ }
88
+ },
89
+ }));
90
+
91
+ import { TagGovernanceResolver } from '../resolvers/TagGovernanceResolver.js';
92
+
93
+ describe('TagGovernanceResolver', () => {
94
+ let resolver: TagGovernanceResolver;
95
+
96
+ beforeEach(() => {
97
+ promoteCalls.length = 0;
98
+ rejectCalls.length = 0;
99
+ rebuildCalls.length = 0;
100
+ healthCalls.length = 0;
101
+ behaviorFlags.promoteThrows = false;
102
+ behaviorFlags.rejectThrows = false;
103
+ behaviorFlags.rebuildThrows = false;
104
+ behaviorFlags.healthThrows = false;
105
+ resolver = new TagGovernanceResolver();
106
+ });
107
+
108
+ const ctx = (withUser = true) => withUser ? { userPayload: { sub: 'u-1' } } : { userPayload: undefined };
109
+
110
+ // ---- PromoteTagSuggestion ----------------------------------------------
111
+
112
+ describe('PromoteTagSuggestion', () => {
113
+ it('rejects when no current user is resolved from the payload', async () => {
114
+ const r = await resolver.PromoteTagSuggestion('s-1', 'create-new', undefined, ctx(false) as never);
115
+ expect(r.Success).toBe(false);
116
+ expect(r.ErrorMessage).toContain('current user');
117
+ expect(promoteCalls).toHaveLength(0);
118
+ });
119
+
120
+ it('rejects unknown strategies', async () => {
121
+ const r = await resolver.PromoteTagSuggestion('s-1', 'wat' as never, undefined, ctx() as never);
122
+ expect(r.Success).toBe(false);
123
+ expect(r.ErrorMessage).toContain('Unknown strategy');
124
+ expect(promoteCalls).toHaveLength(0);
125
+ });
126
+
127
+ it('requires targetTagID for merge-into-existing', async () => {
128
+ const r = await resolver.PromoteTagSuggestion('s-1', 'merge-into-existing', undefined, ctx() as never);
129
+ expect(r.Success).toBe(false);
130
+ expect(r.ErrorMessage).toContain('targetTagID is required');
131
+ expect(promoteCalls).toHaveLength(0);
132
+ });
133
+
134
+ it('dispatches create-new and returns the resolved tag', async () => {
135
+ const r = await resolver.PromoteTagSuggestion('s-1', 'create-new', undefined, ctx() as never);
136
+ expect(r.Success).toBe(true);
137
+ expect(r.ResolvedTagID).toBe('new-tag-id');
138
+ expect(r.ResolvedTagName).toBe('Resolved Tag');
139
+ expect(promoteCalls[0].id).toBe('s-1');
140
+ expect((promoteCalls[0].strategy as { kind: string }).kind).toBe('create-new');
141
+ });
142
+
143
+ it('dispatches merge-into-existing with the target tag ID', async () => {
144
+ const r = await resolver.PromoteTagSuggestion('s-1', 'merge-into-existing', 'target-tag', ctx() as never);
145
+ expect(r.Success).toBe(true);
146
+ expect((promoteCalls[0].strategy as { kind: string; targetTagID: string }).targetTagID).toBe('target-tag');
147
+ });
148
+
149
+ it('returns Success=false with the error message when the engine throws', async () => {
150
+ behaviorFlags.promoteThrows = true;
151
+ const r = await resolver.PromoteTagSuggestion('s-1', 'create-new', undefined, ctx() as never);
152
+ expect(r.Success).toBe(false);
153
+ expect(r.ErrorMessage).toBe('promote-fail');
154
+ });
155
+ });
156
+
157
+ // ---- RejectTagSuggestion -----------------------------------------------
158
+
159
+ describe('RejectTagSuggestion', () => {
160
+ it('rejects without a user', async () => {
161
+ const r = await resolver.RejectTagSuggestion('s-1', undefined, ctx(false) as never);
162
+ expect(r.Success).toBe(false);
163
+ expect(rejectCalls).toHaveLength(0);
164
+ });
165
+
166
+ it('passes optional reviewer notes to the engine', async () => {
167
+ const r = await resolver.RejectTagSuggestion('s-1', 'looked spammy', ctx() as never);
168
+ expect(r.Success).toBe(true);
169
+ expect(rejectCalls[0]).toMatchObject({ id: 's-1', notes: 'looked spammy' });
170
+ });
171
+
172
+ it('coerces undefined notes to null', async () => {
173
+ await resolver.RejectTagSuggestion('s-1', undefined, ctx() as never);
174
+ expect(rejectCalls[0].notes).toBeNull();
175
+ });
176
+
177
+ it('returns the engine error on failure', async () => {
178
+ behaviorFlags.rejectThrows = true;
179
+ const r = await resolver.RejectTagSuggestion('s-1', undefined, ctx() as never);
180
+ expect(r.Success).toBe(false);
181
+ expect(r.ErrorMessage).toBe('reject-fail');
182
+ });
183
+ });
184
+
185
+ // ---- RebuildTagEmbeddings ----------------------------------------------
186
+
187
+ describe('RebuildTagEmbeddings', () => {
188
+ it('rejects without a user', async () => {
189
+ const r = await resolver.RebuildTagEmbeddings(ctx(false) as never);
190
+ expect(r.Success).toBe(false);
191
+ expect(r.Refreshed).toBe(0);
192
+ expect(r.Total).toBe(0);
193
+ });
194
+
195
+ it('returns the refresh counts from the engine', async () => {
196
+ const r = await resolver.RebuildTagEmbeddings(ctx() as never);
197
+ expect(r.Success).toBe(true);
198
+ expect(r.Refreshed).toBe(7);
199
+ expect(r.Total).toBe(100);
200
+ expect(rebuildCalls).toHaveLength(1);
201
+ });
202
+
203
+ it('returns 0/0 with an error message when the engine throws', async () => {
204
+ behaviorFlags.rebuildThrows = true;
205
+ const r = await resolver.RebuildTagEmbeddings(ctx() as never);
206
+ expect(r.Success).toBe(false);
207
+ expect(r.ErrorMessage).toBe('rebuild-fail');
208
+ expect(r.Refreshed).toBe(0);
209
+ });
210
+ });
211
+
212
+ // ---- RunTagHealth ------------------------------------------------------
213
+
214
+ describe('RunTagHealth', () => {
215
+ it('rejects without a user', async () => {
216
+ const r = await resolver.RunTagHealth(undefined, undefined, undefined, undefined, undefined, ctx(false) as never);
217
+ expect(r.Success).toBe(false);
218
+ expect(healthCalls).toHaveLength(0);
219
+ });
220
+
221
+ it('falls back to defaults when no thresholds are supplied', async () => {
222
+ const r = await resolver.RunTagHealth(undefined, undefined, undefined, undefined, undefined, ctx() as never);
223
+ expect(r.Success).toBe(true);
224
+ expect(r.MergeCount).toBe(3);
225
+ expect(r.LowUsageCount).toBe(2);
226
+ expect(r.WideNodeCount).toBe(1);
227
+ const t = healthCalls[0].thresholds as Record<string, number>;
228
+ expect(t.minCoOccurrence).toBe(5); // from DEFAULT
229
+ expect(t.maxImplicitChildren).toBe(25);// from DEFAULT
230
+ });
231
+
232
+ it('overrides only the supplied threshold fields', async () => {
233
+ await resolver.RunTagHealth(20, 0.7, undefined, 1, undefined, ctx() as never);
234
+ const t = healthCalls[0].thresholds as Record<string, number>;
235
+ expect(t.minCoOccurrence).toBe(20);
236
+ expect(t.minNameSimilarity).toBe(0.7);
237
+ expect(t.minEmbeddingSimilarity).toBe(0.85); // from DEFAULT
238
+ expect(t.maxUsage).toBe(1);
239
+ expect(t.maxImplicitChildren).toBe(25); // from DEFAULT
240
+ });
241
+
242
+ it('returns the duration alongside the counts', async () => {
243
+ const r = await resolver.RunTagHealth(undefined, undefined, undefined, undefined, undefined, ctx() as never);
244
+ expect(r.DurationMs).toBe(42);
245
+ });
246
+
247
+ it('surfaces engine errors with zeroed counts', async () => {
248
+ behaviorFlags.healthThrows = true;
249
+ const r = await resolver.RunTagHealth(undefined, undefined, undefined, undefined, undefined, ctx() as never);
250
+ expect(r.Success).toBe(false);
251
+ expect(r.MergeCount).toBe(0);
252
+ expect(r.ErrorMessage).toBe('health-fail');
253
+ });
254
+ });
255
+ });
@@ -24,7 +24,7 @@ import {
24
24
  SkipAPIArtifactType
25
25
  } from '@memberjunction/skip-types';
26
26
  import { DataContext } from '@memberjunction/data-context';
27
- import { UserInfo, LogStatus, LogError, Metadata, RunQuery, EntityInfo, EntityFieldInfo, EntityFieldValueInfo, DatabaseProviderBase } from '@memberjunction/core';
27
+ import { IMetadataProvider, UserInfo, LogStatus, LogError, Metadata, RunQuery, EntityInfo, EntityFieldInfo, EntityFieldValueInfo, DatabaseProviderBase } from '@memberjunction/core';
28
28
  import { request as httpRequest } from 'http';
29
29
  import { request as httpsRequest } from 'https';
30
30
  import { gzip as gzipCompress, createGunzip } from 'zlib';
@@ -61,6 +61,15 @@ export interface SkipSDKConfig {
61
61
  * Optional organization context information
62
62
  */
63
63
  organizationInfo?: string;
64
+
65
+ /**
66
+ * Optional metadata provider this SDK instance binds to. When set, every metadata
67
+ * lookup (entities, queries, schema) and direct SQL call is routed through this
68
+ * provider — multi-tenant servers should pass the per-request provider here.
69
+ * When omitted, the SDK falls back to the global `Metadata.Provider` (single-server
70
+ * mode, legacy callers).
71
+ */
72
+ provider?: IMetadataProvider;
64
73
  }
65
74
 
66
75
  /**
@@ -199,6 +208,19 @@ interface SkipStreamMessage {
199
208
  export class SkipSDK {
200
209
  private config: SkipSDKConfig;
201
210
 
211
+ /**
212
+ * The metadata provider this SDK instance is bound to. Set via the constructor
213
+ * config or the `Provider` setter. Falls back to the global `Metadata.Provider`
214
+ * when not set — multi-tenant servers should always supply an explicit provider
215
+ * so each request reaches its own database connection.
216
+ */
217
+ public get Provider(): IMetadataProvider {
218
+ return this.config.provider ?? (new Metadata() as unknown as IMetadataProvider);
219
+ }
220
+ public set Provider(value: IMetadataProvider | null) {
221
+ this.config.provider = value ?? undefined;
222
+ }
223
+
202
224
  // Static cache for Skip entities (shared across all instances)
203
225
  private static __skipEntitiesCache$: BehaviorSubject<Promise<EntityInfo[]> | null> = new BehaviorSubject<Promise<EntityInfo[]> | null>(null);
204
226
  private static __lastRefreshTime: number = 0;
@@ -460,7 +482,7 @@ export class SkipSDK {
460
482
  * Build saved queries for Skip
461
483
  */
462
484
  private buildQueries(status: "Pending" | "In-Review" | "Approved" | "Rejected" | "Obsolete" = 'Approved'): SkipQueryInfo[] {
463
- const md = new Metadata();
485
+ const md = this.Provider;
464
486
  const approvedQueries = md.Queries.filter((q) => q.Status === status);
465
487
 
466
488
  return approvedQueries.map((q) => ({
@@ -521,7 +543,7 @@ export class SkipSDK {
521
543
  /**
522
544
  * Recursively build category path for a query
523
545
  */
524
- private buildQueryCategoryPath(md: Metadata, categoryID: string): string {
546
+ private buildQueryCategoryPath(md: IMetadataProvider, categoryID: string): string {
525
547
  const cat = md.QueryCategories.find((c) => UUIDsEqual(c.ID, categoryID));
526
548
  if (!cat) return '';
527
549
  if (!cat.ParentID) return cat.Name;
@@ -537,7 +559,7 @@ export class SkipSDK {
537
559
  * across all queries, not just approved ones.
538
560
  */
539
561
  private buildQueryCatalog(): SkipQueryCatalogEntry[] {
540
- const md = new Metadata();
562
+ const md = this.Provider;
541
563
 
542
564
  return md.Queries.map((q) => ({
543
565
  Name: q.Name,
@@ -856,7 +878,7 @@ export class SkipSDK {
856
878
  */
857
879
  private async refreshSkipEntities(): Promise<EntityInfo[]> {
858
880
  try {
859
- const md = new Metadata();
881
+ const md = this.Provider;
860
882
 
861
883
  // Diagnostic logging
862
884
  LogStatus(`[SkipSDK.refreshSkipEntities] Total entities in metadata: ${md.Entities.length}`);
@@ -983,8 +1005,9 @@ export class SkipSDK {
983
1005
  */
984
1006
  private async getFieldDistinctValues(f: EntityFieldInfo): Promise<EntityFieldValueInfo[]> {
985
1007
  try {
986
- // Uses the provider's ExecuteSQL so this works on both SQL Server and PostgreSQL.
987
- const provider = Metadata.Provider as DatabaseProviderBase;
1008
+ // Use this SDK instance's bound provider so multi-tenant servers route the SQL
1009
+ // through the right connection. ExecuteSQL works on both SQL Server and PostgreSQL.
1010
+ const provider = this.Provider as unknown as DatabaseProviderBase;
988
1011
  const sql = `SELECT DISTINCT ${f.Name} FROM ${f.SchemaName}.${f.BaseView}`;
989
1012
  const rows = await provider.ExecuteSQL<Record<string, unknown>>(sql);
990
1013
  if (!rows || rows.length === 0) {
@@ -16,7 +16,7 @@ import { MJUserEntity } from '@memberjunction/core-entities';
16
16
  export class ExampleNewUserSubClass extends NewUserBase {
17
17
  public override async createNewUser(firstName: string, lastName: string, email: string, linkedRecordType: string = 'None', linkedEntityId?: string, linkedEntityRecordId?: string): Promise<MJUserEntity | null> {
18
18
  try {
19
- const md = new Metadata();
19
+ const md = new Metadata(); // global-provider-ok: new-user creation runs in the JWT auth flow, before AppContext.providers is built
20
20
 
21
21
  const contextUser = UserCache.Instance.Users.find(
22
22
  (u) => u.Email.trim().toLowerCase() === configInfo?.userHandling?.contextUserForNewUserCreation?.trim().toLowerCase()
package/src/auth/index.ts CHANGED
@@ -233,7 +233,7 @@ export const verifyUserRecord = async (
233
233
  if (newUser) {
234
234
  // new user worked! we already have the stuff we need for the cache, so no need to go to the DB now, just create a new UserInfo object and use the return value from the createNewUser method
235
235
  // to init it, including passing in the role list for the user.
236
- const md: Metadata = new Metadata();
236
+ const md: Metadata = new Metadata(); // global-provider-ok: JWT validation + role lookup runs BEFORE AppContext.providers is built — no per-request provider yet
237
237
 
238
238
  const initData: MJUserEntityType & { UserRoles: { UserID: string; RoleName: string; RoleID: string }[] } = newUser.GetAll();
239
239
 
@@ -244,7 +244,7 @@ export const verifyUserRecord = async (
244
244
  return { UserID: initData.ID, RoleName: role, RoleID: roleID };
245
245
  });
246
246
 
247
- user = new UserInfo(Metadata.Provider, initData);
247
+ user = new UserInfo(Metadata.Provider, initData); // global-provider-ok: same JWT-validation context — no per-request provider yet
248
248
  UserCache.Instance.Users.push(user);
249
249
  console.warn(` >>> New user ${email} created successfully!`);
250
250
  }
@@ -24,7 +24,7 @@ export class NewUserBase {
24
24
  }
25
25
  }
26
26
 
27
- const md: Metadata = new Metadata();
27
+ const md: Metadata = new Metadata(); // global-provider-ok: new-user creation runs in the JWT auth flow, before AppContext.providers is built
28
28
  const user = await md.GetEntityObject<MJUserEntity>('MJ: Users', contextUser) // To-Do - change this to be a different defined user for the user creation process
29
29
  user.NewRecord();
30
30
  user.Name = email;
@@ -46,7 +46,7 @@ export class NewUserBase {
46
46
  // Create the user and all of its role/application/app-entity records atomically.
47
47
  // If any Save fails partway through, the whole provisioning rolls back so we never
48
48
  // leave a half-created user with partial roles/applications behind.
49
- const provider = Metadata.Provider as DatabaseProviderBase;
49
+ const provider = Metadata.Provider as DatabaseProviderBase; // global-provider-ok: new-user creation runs in the JWT auth flow, before AppContext.providers is built
50
50
  await provider.BeginTransaction();
51
51
  try {
52
52
  if (!await user.Save()) {
package/src/context.ts CHANGED
@@ -333,8 +333,8 @@ export const contextFunction =
333
333
  throw new AuthenticationError('No user payload — auth middleware may not have run');
334
334
  }
335
335
 
336
- if (Metadata.Provider.Entities.length === 0 ) {
337
- console.warn('WARNING: No entities found in global/shared metadata, this can often be due to the use of **global** Metadata/RunView/DB Providers in a multi-user environment. Check your code to make sure you are using the providers passed to you in AppContext by MJServer and not calling new Metadata() new RunView() new RunQuery() and similar patterns as those are unstable at times in multi-user server environments!!!');
336
+ if (Metadata.Provider.Entities.length === 0 ) { // global-provider-ok: diagnostic warning about global provider state
337
+ console.warn('WARNING: No entities found in global/shared metadata, this can often be due to the use of **global** Metadata/RunView/DB Providers in a multi-user environment. Check your code to make sure you are using the providers passed to you in AppContext by MJServer and not calling new Metadata() new RunView() new RunQuery() and similar patterns as those are unstable at times in multi-user server environments!!!'); // global-provider-ok: diagnostic warning text mentions the anti-pattern by name
338
338
  }
339
339
 
340
340
  // Create per-request provider instance based on database type
@@ -406,7 +406,7 @@ async function createPostgresProvider(): Promise<DatabaseProviderBase> {
406
406
  );
407
407
 
408
408
  // Share the connection pool from the primary provider to avoid pool exhaustion
409
- const primaryProvider = Metadata.Provider as unknown as { DatabaseConnection?: import('pg').Pool };
409
+ const primaryProvider = Metadata.Provider as unknown as { DatabaseConnection?: import('pg').Pool }; // global-provider-ok: bootstrap (per-connection PG pool sharing)
410
410
  if (primaryProvider?.DatabaseConnection) {
411
411
  await pgProvider.ConfigWithSharedPool(pgConfig, primaryProvider.DatabaseConnection);
412
412
  } else {