@memberjunction/server 5.51.1 → 5.51.3

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.
@@ -0,0 +1,290 @@
1
+ // ResolverBase transitively pulls in type-graphql decorators, which need the
2
+ // Reflect.metadata polyfill at import time.
3
+ import 'reflect-metadata';
4
+ import { describe, it, expect } from 'vitest';
5
+ import type { DatabaseProviderBase, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
6
+ import type { MJUserViewEntityExtended } from '@memberjunction/core-entities';
7
+ import { FieldMapper } from '@memberjunction/graphql-dataprovider';
8
+ import { ResolverBase } from '../generic/ResolverBase.js';
9
+ import type { UserPayload } from '../types.js';
10
+
11
+ /**
12
+ * Regression guard for the server-cache corruption behind
13
+ * "Field _mj__CreatedAt does not exist on MJ: Template Categories".
14
+ *
15
+ * GraphQL reserves the `__` prefix, so MJ renames `__mj_*` columns to the
16
+ * transport alias `_mj__*` on the way out. `ResolverBase` used to apply that
17
+ * rename IN PLACE over `result.Results` — but those are the data provider's own
18
+ * row objects, and the server's cache holds them BY REFERENCE. Preparing one
19
+ * GraphQL response therefore rewrote the keys inside the live cache, and every
20
+ * subsequent read served from it handed the client transport-shaped rows that
21
+ * `BaseEntity.SetMany` rejects. Process-wide cache ⇒ one response poisoned every
22
+ * later request, across all workers.
23
+ *
24
+ * The fix is copy-then-map. The first block exercises the REAL resolver path (so a
25
+ * revert to in-place mapping fails here); the second pins the two `FieldMapper`
26
+ * invariants that make the fix correct.
27
+ *
28
+ * The third block runs the same path against FROZEN input. On this line nothing freezes
29
+ * cache rows, so `Object.freeze` here is a test instrument, not a simulation of runtime:
30
+ * it is how "never writes to its argument" is asserted directly rather than inferred. It
31
+ * also pre-stages the 6.x freeze-on-write behaviour, under which in-place mapping stops
32
+ * being silent corruption and becomes an outright `TypeError`.
33
+ */
34
+
35
+ /** A cache-held row, shaped the way the provider hands them back. */
36
+ type CachedRow = Record<string, unknown>;
37
+
38
+ /**
39
+ * Reaches the protected `RunViewGenericInternal` and records what
40
+ * `ArrayFilterEncryptedFieldsForAPI` was handed — the second in-place mutator that
41
+ * must also see copies, and stubbed here so the assertion needs no live Metadata.
42
+ */
43
+ class Probe extends ResolverBase {
44
+ public filteredRows: Record<string, unknown>[] | null = null;
45
+
46
+ protected override async ArrayFilterEncryptedFieldsForAPI(
47
+ _entityName: string,
48
+ dataObjectArray: Record<string, unknown>[]
49
+ ): Promise<Record<string, unknown>[]> {
50
+ this.filteredRows = dataObjectArray;
51
+ return dataObjectArray;
52
+ }
53
+
54
+ public Run(provider: DatabaseProviderBase, viewInfo: MJUserViewEntityExtended, userPayload: UserPayload) {
55
+ return this.RunViewGenericInternal(
56
+ provider,
57
+ viewInfo,
58
+ '', // extraFilter
59
+ '', // orderBy
60
+ '', // userSearchString
61
+ undefined, // excludeUserViewRunID
62
+ undefined, // overrideExcludeFilter
63
+ undefined, // saveViewResults
64
+ undefined, // fields
65
+ undefined, // ignoreMaxRows
66
+ undefined, // excludeDataFromAllPriorViewRuns
67
+ undefined, // forceAuditLog
68
+ undefined, // auditLogDescription
69
+ 'simple', // resultType
70
+ userPayload,
71
+ undefined, // maxRows
72
+ undefined // startRow
73
+ );
74
+ }
75
+ }
76
+
77
+ const ENTITY_NAME = 'MJ: Template Categories';
78
+
79
+ /** Minimal provider: the entity lookup + the RunView the resolver awaits. */
80
+ function fakeProvider(rows: CachedRow[]): DatabaseProviderBase {
81
+ return {
82
+ Entities: [{ Name: ENTITY_NAME, PrimaryKeys: [{ Name: 'ID' }] }],
83
+ RunView: async (_params: RunViewParams): Promise<RunViewResult> =>
84
+ ({ Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult),
85
+ } as unknown as DatabaseProviderBase;
86
+ }
87
+
88
+ const fakeViewInfo = () =>
89
+ ({ ID: 'view-1', Name: 'Test View', Entity: ENTITY_NAME } as unknown as MJUserViewEntityExtended);
90
+
91
+ /** `userRecord` short-circuits the UserCache lookup; no apiKeyHash skips the scope check. */
92
+ const fakePayload = () =>
93
+ ({ email: 'tester@example.com', userRecord: { Email: 'tester@example.com' } as UserInfo } as UserPayload);
94
+
95
+ describe('ResolverBase.RunViewGenericInternal — cache safety', () => {
96
+ it('leaves the provider\'s (cache-held) rows untouched while returning transport keys', async () => {
97
+ const cachedRow: CachedRow = { ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0', __mj_UpdatedAt: 'T1' };
98
+ const probe = new Probe();
99
+
100
+ const result = await probe.Run(fakeProvider([cachedRow]), fakeViewInfo(), fakePayload());
101
+
102
+ // The pre-fix code renamed these keys in place — this is the assertion that fails on a revert.
103
+ expect(cachedRow.__mj_CreatedAt).toBe('T0');
104
+ expect(cachedRow.__mj_UpdatedAt).toBe('T1');
105
+ expect(cachedRow._mj__CreatedAt).toBeUndefined();
106
+
107
+ // The outgoing rows carry the GraphQL-legal aliases, and are NOT the cached objects.
108
+ const wire = result?.Results as Record<string, unknown>[];
109
+ expect(wire[0]._mj__CreatedAt).toBe('T0');
110
+ expect(wire[0]).not.toBe(cachedRow);
111
+ });
112
+
113
+ it('hands the encrypted-field filter the copies, not the cached rows', async () => {
114
+ const cachedRow: CachedRow = { ID: 'a1', Secret: 'plaintext', __mj_CreatedAt: 'T0' };
115
+ const probe = new Probe();
116
+
117
+ await probe.Run(fakeProvider([cachedRow]), fakeViewInfo(), fakePayload());
118
+
119
+ expect(probe.filteredRows).not.toBeNull();
120
+ expect(probe.filteredRows![0]).not.toBe(cachedRow);
121
+ });
122
+ });
123
+
124
+ describe('FieldMapper transport mapping — the invariants the fix rests on', () => {
125
+ it('MapFields MUTATES its argument (the hazard the resolver must not expose the cache to)', () => {
126
+ // Documents WHY the resolver must copy. If this ever becomes non-mutating,
127
+ // the copy in ResolverBase is redundant rather than load-bearing — and this
128
+ // test failing is the signal to revisit it.
129
+ const row: Record<string, unknown> = { ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0' };
130
+
131
+ new FieldMapper().MapFields(row);
132
+
133
+ expect(row.__mj_CreatedAt).toBeUndefined();
134
+ expect(row._mj__CreatedAt).toBe('T0');
135
+ });
136
+
137
+ it('post-map mutation of the copy cannot reach the cached row', () => {
138
+ // ArrayFilterEncryptedFieldsForAPI redacts in place after mapping; on the
139
+ // pre-fix code that stripped secrets out of the CACHED row too.
140
+ const mapper = new FieldMapper();
141
+ const cachedRow: Record<string, unknown> = { ID: 'a1', Secret: 'plaintext', __mj_CreatedAt: 'T0' };
142
+
143
+ const wireRow = mapper.MapFields({ ...cachedRow })!;
144
+ wireRow.Secret = null; // stand-in for the encrypted-field filter
145
+
146
+ expect(cachedRow.Secret).toBe('plaintext');
147
+ expect(wireRow.Secret).toBeNull();
148
+ });
149
+
150
+ it('round-trips a mapped copy back to entity field names', () => {
151
+ // The client's ConvertBackToMJFields must undo exactly what the server did.
152
+ const mapper = new FieldMapper();
153
+ const original: Record<string, unknown> = { ID: 'a1', __mj_CreatedAt: 'T0', __mj_UpdatedAt: 'T1' };
154
+
155
+ const wire = mapper.MapFields({ ...original })!;
156
+ const back = mapper.ReverseMapFields({ ...wire });
157
+
158
+ expect(back).toEqual(original);
159
+ });
160
+
161
+ it('leaves rows without __mj_ fields structurally unchanged', () => {
162
+ const mapper = new FieldMapper();
163
+ const row = { ID: 'a1', Name: 'Cat' };
164
+
165
+ expect(mapper.MapFields({ ...row })).toEqual(row);
166
+ });
167
+ });
168
+
169
+ describe('transport mapping against FROZEN cache rows', () => {
170
+ // Freezing is the assertion mechanism, not a reproduction of runtime state: on this line
171
+ // the cache hands out live, unfrozen references. A frozen input makes "the mapper wrote to
172
+ // its argument" fail loudly here instead of passing silently and corrupting the cache in
173
+ // production. It is also the shape 6.x's freeze-on-write will actually hand out.
174
+ it('copy-then-map works on a frozen row', () => {
175
+ const mapper = new FieldMapper();
176
+ const cachedRow = Object.freeze({ ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0' }) as Record<string, unknown>;
177
+
178
+ const wireRow = mapper.MapFields({ ...cachedRow })!;
179
+
180
+ expect(wireRow._mj__CreatedAt).toBe('T0');
181
+ expect(wireRow.__mj_CreatedAt).toBeUndefined();
182
+ // The copy is mutable, so the encrypted-field filter can still redact in place.
183
+ expect(Object.isFrozen(wireRow)).toBe(false);
184
+ // And the frozen cached row is untouched.
185
+ expect(cachedRow.__mj_CreatedAt).toBe('T0');
186
+ });
187
+
188
+ it('in-place mapping of a frozen row THROWS — silent corruption is now impossible', () => {
189
+ // This is the safety net the freeze adds on top of the copy: if a future refactor
190
+ // reverts to in-place mapping, it fails loudly at the offending line instead of
191
+ // quietly rewriting process-wide state.
192
+ const mapper = new FieldMapper();
193
+ const cachedRow = Object.freeze({ ID: 'a1', __mj_CreatedAt: 'T0' }) as Record<string, unknown>;
194
+
195
+ expect(() => mapper.MapFields(cachedRow)).toThrow(TypeError);
196
+ });
197
+ });
198
+
199
+ /**
200
+ * The BATCH path (`RunViewsGenericInternal`), which the single-view tests above do not reach
201
+ * (PR #3425 review, finding M8).
202
+ *
203
+ * This is the path multi-view clients actually take — MJExplorer batches its view loads — so
204
+ * leaving it uncovered meant the original corruption bug could be reintroduced on the more
205
+ * heavily used of the two legs with CI still green.
206
+ */
207
+ class BatchProbe extends ResolverBase {
208
+ public filteredRows: Record<string, unknown>[] | null = null;
209
+
210
+ protected override async ArrayFilterEncryptedFieldsForAPI(
211
+ _entityName: string,
212
+ dataObjectArray: Record<string, unknown>[]
213
+ ): Promise<Record<string, unknown>[]> {
214
+ this.filteredRows = dataObjectArray;
215
+ return dataObjectArray;
216
+ }
217
+
218
+ public RunBatch(provider: DatabaseProviderBase, viewInfos: MJUserViewEntityExtended[], userPayload: UserPayload) {
219
+ return this.RunViewsGenericInternal(
220
+ viewInfos.map(viewInfo => ({ provider, viewInfo, userPayload, resultType: 'simple' })) as never
221
+ );
222
+ }
223
+ }
224
+
225
+ /** Batch provider: `RunViews` returns one result per param, in order. */
226
+ function fakeBatchProvider(rowsPerView: CachedRow[][]): DatabaseProviderBase {
227
+ return {
228
+ Entities: [{ Name: ENTITY_NAME, PrimaryKeys: [{ Name: 'ID' }] }],
229
+ EntityByName: (name: string) => (name === ENTITY_NAME ? { Name: ENTITY_NAME } : undefined),
230
+ RunViews: async (params: RunViewParams[]): Promise<RunViewResult[]> =>
231
+ params.map((_p, i) => {
232
+ const rows = rowsPerView[i] ?? [];
233
+ return { Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult;
234
+ }),
235
+ } as unknown as DatabaseProviderBase;
236
+ }
237
+
238
+ describe('ResolverBase.RunViewsGenericInternal (batch) — cache safety', () => {
239
+ it('leaves every view\'s cache-held rows untouched while returning transport keys', async () => {
240
+ const viewARow: CachedRow = { ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0', __mj_UpdatedAt: 'T1' };
241
+ const viewBRow: CachedRow = { ID: 'b1', Name: 'Dog', __mj_CreatedAt: 'T2', __mj_UpdatedAt: 'T3' };
242
+ const probe = new BatchProbe();
243
+
244
+ const results = await probe.RunBatch(
245
+ fakeBatchProvider([[viewARow], [viewBRow]]),
246
+ [fakeViewInfo(), fakeViewInfo()],
247
+ fakePayload()
248
+ );
249
+
250
+ // Both source rows keep entity field names — the assertion that fails on a revert
251
+ // to in-place mapping in the batch loop.
252
+ expect(viewARow.__mj_CreatedAt).toBe('T0');
253
+ expect(viewARow._mj__CreatedAt).toBeUndefined();
254
+ expect(viewBRow.__mj_CreatedAt).toBe('T2');
255
+ expect(viewBRow._mj__CreatedAt).toBeUndefined();
256
+
257
+ // ...and both outgoing views carry the aliases, on objects that are not the cached ones.
258
+ const wireA = results[0].Results as Record<string, unknown>[];
259
+ const wireB = results[1].Results as Record<string, unknown>[];
260
+ expect(wireA[0]._mj__CreatedAt).toBe('T0');
261
+ expect(wireB[0]._mj__CreatedAt).toBe('T2');
262
+ expect(wireA[0]).not.toBe(viewARow);
263
+ expect(wireB[0]).not.toBe(viewBRow);
264
+ });
265
+
266
+ it('maps FROZEN cache rows rather than throwing on them', async () => {
267
+ // The shape the cache actually hands out on a hit.
268
+ const frozenRow = Object.freeze({ ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0' }) as CachedRow;
269
+ const probe = new BatchProbe();
270
+
271
+ const results = await probe.RunBatch(
272
+ fakeBatchProvider([[frozenRow]]),
273
+ [fakeViewInfo()],
274
+ fakePayload()
275
+ );
276
+
277
+ expect((results[0].Results as Record<string, unknown>[])[0]._mj__CreatedAt).toBe('T0');
278
+ expect(frozenRow.__mj_CreatedAt).toBe('T0');
279
+ });
280
+
281
+ it('hands the encrypted-field filter copies, not the cached rows', async () => {
282
+ const cachedRow: CachedRow = { ID: 'a1', Secret: 'plaintext', __mj_CreatedAt: 'T0' };
283
+ const probe = new BatchProbe();
284
+
285
+ await probe.RunBatch(fakeBatchProvider([[cachedRow]]), [fakeViewInfo()], fakePayload());
286
+
287
+ expect(probe.filteredRows).not.toBeNull();
288
+ expect(probe.filteredRows![0]).not.toBe(cachedRow);
289
+ });
290
+ });
@@ -62,16 +62,27 @@ export class ResolverBase {
62
62
  * - AllowDecryptInAPI=false + SendEncryptedValue=true: Keep encrypted ciphertext
63
63
  * - AllowDecryptInAPI=false + SendEncryptedValue=false: Replace with sentinel
64
64
  *
65
+ * Returns a COPY — `dataObject` is never written to. Callers routinely pass rows straight
66
+ * from `findBy`/`RunView`, which are the server cache's own objects held by reference under a
67
+ * reference-sharing storage provider. Renaming in place therefore rewrote the cached row's
68
+ * keys, so later readers were served transport-shaped rows that `BaseEntity.SetMany` rejects —
69
+ * and because that cache is process-wide, one response corrupted every subsequent request
70
+ * across all workers. It bit every `UserByEmail` / `UserByID` / `UserByEmployeeID` call and
71
+ * every generated single-record resolver whose entity has caching enabled. Copying here fixes
72
+ * every call site at once and makes the hazard unreachable for future ones.
73
+ *
65
74
  * @param entityName - The entity name
66
- * @param dataObject - The data object with field values
75
+ * @param dataObject - The data object with field values. Not modified.
67
76
  * @param contextUser - Optional user context for decryption (required for encrypted fields)
68
- * @returns The processed data object
77
+ * @returns A new object in transport shape, or null when there is nothing to map
69
78
  */
70
79
  protected async MapFieldNamesToCodeNames(entityName: string, dataObject: any, contextUser?: UserInfo, provider?: IMetadataProvider): Promise<any> {
71
80
  // Return null for empty objects (e.g. when no rows found due to RLS filtering)
72
81
  if (!dataObject || Object.keys(dataObject).length === 0) {
73
82
  return null;
74
83
  }
84
+ // Shallow copy up front so every write below lands on our object, never the caller's.
85
+ dataObject = { ...dataObject };
75
86
 
76
87
  // for the given entity name provided, check to see if there are any fields
77
88
  // where the code name is different from the field name, and for just those
@@ -184,14 +195,21 @@ export class ResolverBase {
184
195
  return true;
185
196
  }
186
197
 
187
- protected async ArrayMapFieldNamesToCodeNames(entityName: string, dataObjectArray: any[], contextUser?: UserInfo): Promise<any[]> {
188
- // iterate through the array and call MapFieldNamesToCodeNames for each element
189
- if (dataObjectArray && dataObjectArray.length > 0) {
190
- for (const element of dataObjectArray) {
191
- await this.MapFieldNamesToCodeNames(entityName, element, contextUser);
192
- }
198
+ /**
199
+ * Array form of {@link MapFieldNamesToCodeNames}. Returns a NEW array of NEW objects; neither
200
+ * the input array nor its rows are modified. Both matter: a reference-sharing cache hands back
201
+ * the stored array itself, not a copy of it, so collecting the mapped copies (rather than
202
+ * mapping in place and returning the original) is what makes this safe on cache-served input.
203
+ */
204
+ protected async ArrayMapFieldNamesToCodeNames(entityName: string, dataObjectArray: any[], contextUser?: UserInfo, provider?: IMetadataProvider): Promise<any[]> {
205
+ if (!dataObjectArray || dataObjectArray.length === 0) {
206
+ return dataObjectArray;
193
207
  }
194
- return dataObjectArray;
208
+ const mapped: any[] = [];
209
+ for (const element of dataObjectArray) {
210
+ mapped.push(await this.MapFieldNamesToCodeNames(entityName, element, contextUser, provider));
211
+ }
212
+ return mapped;
195
213
  }
196
214
 
197
215
  /**
@@ -819,12 +837,24 @@ export class ResolverBase {
819
837
  LogStatus(`[ResolverBase] RunView result aggregate info: entityName=${viewInfo.Entity}, hasAggregateResults=${!!result?.AggregateResults}, aggregateResultCount=${result?.AggregateResults?.length || 0}, aggregateExecutionTime=${result?.AggregateExecutionTime}, aggregateResults=${JSON.stringify(result?.AggregateResults)}`);
820
838
  }
821
839
 
822
- // Process results for GraphQL transport
840
+ // Process results for GraphQL transport.
841
+ //
842
+ // Map onto COPIES, never in place. `FieldMapper.MapFields` renames keys by
843
+ // mutating (`obj[mapped] = obj[k]; delete obj[k]`), and these rows are the
844
+ // provider's own result objects — which the server cache holds BY REFERENCE
845
+ // under a reference-sharing storage provider. Mapping them in place rewrote
846
+ // `__mj_CreatedAt` to the transport alias `_mj__CreatedAt` inside the live
847
+ // cache, and because that cache is process-wide, one GraphQL response made
848
+ // every later read hand back transport-shaped rows that `BaseEntity.SetMany`
849
+ // rejects. `ArrayFilterEncryptedFieldsForAPI` mutates too, so it must also
850
+ // see the copies. (FileResolver already maps a spread copy.)
851
+ //
852
+ // `{ ...r }` is a SHALLOW copy, which is sufficient here because the only
853
+ // post-map mutators rename top-level keys and redact scalar fields; nothing
854
+ // below writes through to a nested value.
823
855
  const mapper = new FieldMapper();
824
856
  if (result?.Success && result.Results?.length) {
825
- for (const r of result.Results) {
826
- mapper.MapFields(r);
827
- }
857
+ result.Results = result.Results.map(r => mapper.MapFields({ ...r }));
828
858
  // Filter encrypted fields before sending to API client
829
859
  await this.ArrayFilterEncryptedFieldsForAPI(
830
860
  viewInfo.Entity,
@@ -941,9 +971,9 @@ export class ResolverBase {
941
971
  for (let i = 0; i < runViewResults.length; i++) {
942
972
  const runViewResult = runViewResults[i];
943
973
  if (runViewResult?.Success && runViewResult.Results?.length) {
944
- for (const result of runViewResult.Results) {
945
- mapper.MapFields(result);
946
- }
974
+ // Copy-then-map, same reason as the single-view path above: these rows
975
+ // are cache-held references and MapFields renames keys in place.
976
+ runViewResult.Results = runViewResult.Results.map(r => mapper.MapFields({ ...r }));
947
977
  // Filter encrypted fields before sending to API client
948
978
  // Use the corresponding param's entity name
949
979
  const entityName = params[i]?.viewInfo?.Entity;
package/src/index.ts CHANGED
@@ -481,7 +481,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
481
481
  startupLog.LogIf('verbose', 'Read-only Connection Pool has been initialized.');
482
482
  }
483
483
 
484
- const config = new SQLServerProviderConfigData(pool, mj_core_schema, cacheRefreshInterval);
484
+ const config = new SQLServerProviderConfigData(pool, mj_core_schema, cacheRefreshInterval / 1000); // convert ms to seconds (checkRefreshIntervalSeconds)
485
485
  // MJAPI is a long-running server, so entry-point default is 'full' engine pre-warm;
486
486
  // MJ_STARTUP_MODE / mj.config.cjs startup.mode can override per the shared precedence chain
487
487
  const startupMode = ResolveStartupMode({ configValue: configInfo.startup?.mode, defaultMode: 'full' });
@@ -516,7 +516,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
516
516
  await codegenPool.connect();
517
517
 
518
518
  const { RuntimeSchemaManager } = await import('@memberjunction/schema-engine');
519
- const codegenConfig = new SQLServerProviderConfigData(codegenPool, mj_core_schema, cacheRefreshInterval);
519
+ const codegenConfig = new SQLServerProviderConfigData(codegenPool, mj_core_schema, cacheRefreshInterval / 1000); // convert ms to seconds (checkRefreshIntervalSeconds)
520
520
  const codegenProvider = new SQLServerDataProvider();
521
521
  await codegenProvider.Config(codegenConfig);
522
522
  RuntimeSchemaManager.Instance.SetDDLProvider(codegenProvider);