@memberjunction/generic-database-provider 6.1.0-edge.1 → 6.1.0-edge.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.
@@ -15,12 +15,14 @@
15
15
  *
16
16
  * @module @memberjunction/generic-database-provider
17
17
  */
18
- import { DatabaseProviderBase, EntityFieldTSType, ProjectRowsToFields, ProviderBase, EntityPermissionType, InMemoryLocalStorageProvider, Metadata, RunView, LocalCacheManager, LogError, LogStatus, LogStatusEx, StripStopWords, AfterKeyNotSupportedError, IsKeysetPaginationOrderableType, ExternalDataSourceReadRouter, resolveQueryResultEnricher, } from '@memberjunction/core';
18
+ import { DatabaseProviderBase, EntityFieldTSType, ProjectRowsToFields, ProviderBase, EntityPermissionType, InMemoryLocalStorageProvider, Metadata, RunView, IsMaterializedDataSource, LocalCacheManager, LogError, LogStatus, LogStatusEx, StripStopWords, AfterKeyNotSupportedError, IsKeysetPaginationOrderableType, ExternalDataSourceReadRouter, resolveQueryResultEnricher, } from '@memberjunction/core';
19
19
  import { MJGlobal, SQLExpressionValidator, UUIDsEqual } from '@memberjunction/global';
20
20
  import { QueryPagingEngine } from './queryPagingEngine.js';
21
21
  // QueryParameterProcessor is now called internally by RenderPipeline
22
22
  import { v4 as uuidv4 } from 'uuid';
23
23
  import { SqlLoggingSessionImpl } from './SqlLogger.js';
24
+ import { GetDialect } from '@memberjunction/sql-dialect';
25
+ import { SQLParser } from '@memberjunction/sql-parser';
24
26
  // QueryCompositionEngine is now owned by RenderPipeline
25
27
  import { RenderPipeline } from './renderPipeline.js';
26
28
  import { useJsonArgShape } from './crudSprocFieldRules.js';
@@ -29,6 +31,7 @@ import { AIEngine } from '@memberjunction/aiengine';
29
31
  import { SimpleVectorServiceProvider } from '@memberjunction/ai-vectors-memory';
30
32
  import { QueueManager } from '@memberjunction/queue';
31
33
  import { BuildEntityActionDispatchKey, EntityActionDispatchGuard, EntityActionEngineServer } from '@memberjunction/actions';
34
+ import { BuildEntityChangeContext } from '@memberjunction/actions-base';
32
35
  import { EncryptionEngine } from '@memberjunction/encryption';
33
36
  import { GeoCodeSyncService } from '@memberjunction/geo-core';
34
37
  /**
@@ -296,12 +299,24 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
296
299
  * synchronous half of the pipeline: `Validate` and `Before*` participate in the save and can
297
300
  * abort it, so skipping one would let a record through that should have been refused, and
298
301
  * deferring one would decide the save's outcome after it had already happened.
302
+ *
303
+ * **An After-hook binding may also ask to run durably** (`EntityAction.RunMode = 'Durable'`).
304
+ * After-hooks are dispatched fire-and-forget, so a process that dies mid-flight loses the work
305
+ * with nothing to retry it; durable dispatch hands it to the task-graph substrate instead (D14).
306
+ * `Validate` and `Before*` ignore RunMode entirely — deferring work that decides whether the
307
+ * save succeeds is not a durability improvement, it is a different feature.
299
308
  */
300
309
  async HandleEntityActions(entity, baseType, before, user, originatingEntityActionIDs) {
310
+ // FIRST STATEMENT, and deliberately before the first `await`. After-hooks are dispatched
311
+ // fire-and-forget, and the moment this method yields, the save completes and `finalizeSave()`
312
+ // reloads the entity — resetting every field's OldValue to its new value. A change context
313
+ // built any later would report that nothing changed. Everything below this line may yield;
314
+ // nothing above it does.
315
+ const entityChange = BuildEntityChangeContext(entity);
301
316
  try {
302
317
  const engine = EntityActionEngineServer.Instance;
303
318
  await engine.Config(false, user);
304
- const newRecord = entity.IsSaved ? false : true;
319
+ const newRecord = entityChange.IsCreate;
305
320
  const baseTypeType = baseType === 'save' ? (newRecord ? 'Create' : 'Update') : 'Delete';
306
321
  const invocationType = baseType === 'validate' ? 'Validate' : before ? 'Before' + baseTypeType : 'After' + baseTypeType;
307
322
  const invocationTypeEntity = engine.InvocationTypes.find((i) => i.Name === invocationType);
@@ -322,11 +337,15 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
322
337
  continue;
323
338
  }
324
339
  const runOnce = async () => {
340
+ // Durability is NOT decided here. A binding's RunMode is honoured inside the
341
+ // invocation path, after its scope check and its filters — deciding it at this
342
+ // level would hand the work over before either gate ran.
325
343
  const result = await engine.RunEntityAction({
326
344
  EntityAction: a,
327
345
  EntityObject: entity,
328
346
  InvocationType: invocationTypeEntity,
329
347
  ContextUser: user,
348
+ EntityChange: entityChange,
330
349
  });
331
350
  // null means the binding is scoped (ScopeEntityID/ScopeRecordID) and this record falls
332
351
  // outside it — the action never ran, so there is no result to report.
@@ -882,11 +901,109 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
882
901
  * `QuoteIdentifier` produces `"TotalRowCount"` on PG and `[TotalRowCount]` on SQL
883
902
  * Server — both preserve case.
884
903
  */
885
- BuildTotalRowCountSQL(entityInfo, usingPagination, maxRowsForQuery) {
904
+ BuildTotalRowCountSQL(entityInfo, usingPagination, maxRowsForQuery, baseViewOverride) {
886
905
  const rowsAreLimited = usingPagination || maxRowsForQuery > 0;
887
906
  if (!rowsAreLimited)
888
907
  return null;
889
- return `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRowCount')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)}`;
908
+ return `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRowCount')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, baseViewOverride ?? entityInfo.BaseView)}`;
909
+ }
910
+ /**
911
+ * Resolves the view a RunView reads from: the entity's live base view by default, or its materialized
912
+ * wrapper view when the caller opts into the snapshot via `DataSource: 'Materialized'` (plan §7). The
913
+ * choice is explicit (never silent), so the same RLS/paging/field-selection apply against the identical shape.
914
+ *
915
+ * Two materialization shapes:
916
+ * - **Base-view materialization** reuses the SOURCE entity, whose `BaseView` stays the LIVE view; the
917
+ * snapshot lives beside it as `materialized_vw<CodeName>` (the name CodeGen's base-view path emits).
918
+ * `'Materialized'` swaps the live view for that snapshot.
919
+ * - **Query materialization** mints a NEW entity whose `BaseView` ALREADY IS the materialized wrapper
920
+ * view (`materialized_vw<...>`), so there is no separate live source to swap — `'Materialized'` is a
921
+ * no-op and we return the entity's own base view. (Deriving `materialized_vw<CodeName>` here would be
922
+ * wrong: the minted entity's CodeName need not match the query-derived view name.)
923
+ *
924
+ * Convention-based for the base-view case: if the entity has no such materialization the wrapper view
925
+ * won't exist and the read will error — opting into `'Materialized'` asserts the snapshot exists.
926
+ */
927
+ GetEffectiveBaseView(entityInfo, params) {
928
+ // Case-INSENSITIVE prefix test (matches the sibling guard in providerBase.IsServerCacheAllowedForEntity):
929
+ // a BaseView returned with non-lowercase casing (e.g. 'Materialized_vwFoo' from a case-insensitive SQL
930
+ // Server, or a hand-authored entity) is still an already-materialized view — a case-sensitive check
931
+ // would miss it and wrongly derive materialized_vw<CodeName>, targeting a non-existent object.
932
+ if (IsMaterializedDataSource(params.DataSource) && !entityInfo.BaseView?.toLowerCase().startsWith('materialized_vw')) {
933
+ return `materialized_vw${entityInfo.CodeName}`;
934
+ }
935
+ return entityInfo.BaseView;
936
+ }
937
+ /**
938
+ * Async status-aware wrapper around {@link GetEffectiveBaseView} for the BASE-VIEW materialization case.
939
+ * `GetEffectiveBaseView` name-swaps unconditionally, which (a) serves a `Building`/`DriftHold`/`Disabled`
940
+ * snapshot — defeating "flag and hold" (§13/§17.2), since a base-view materialization reuses the source
941
+ * entity and thus has no read-permission revoke to fall back on the way a minted query entity does — and
942
+ * (b) hard-errors on a `Materialized` read of a non-materialized entity (missing view). This gates the swap
943
+ * on an ACTIVE `MaterializedResult` and otherwise returns the LIVE base view (graceful fallback). The status
944
+ * read uses `BypassCache` because DriftHold/Disabled are written by CodeGen via direct SQL (no BaseEntity
945
+ * cache-invalidation event), so a cached status could otherwise be stale. Non-materialized reads and minted
946
+ * query virtual entities (BaseView already `materialized_vw…`) skip the lookup entirely (no extra query).
947
+ */
948
+ async resolveEffectiveBaseView(entityInfo, params, contextUser) {
949
+ if (!IsMaterializedDataSource(params.DataSource) || entityInfo.BaseView?.toLowerCase().startsWith('materialized_vw')) {
950
+ return this.GetEffectiveBaseView(entityInfo, params);
951
+ }
952
+ const rv = new RunView(this);
953
+ const res = await rv.RunView({
954
+ EntityName: 'MJ: Materialized Results',
955
+ ExtraFilter: `SourceType='EntityBaseView' AND SourceEntityID='${entityInfo.ID}'`, // entityInfo.ID: trusted metadata PK
956
+ Fields: ['Status', 'ViewName'],
957
+ ResultType: 'simple',
958
+ MaxRows: 1,
959
+ BypassCache: true,
960
+ }, contextUser);
961
+ // A FAILED lookup must not masquerade as "no materialization exists" — see MaterializationLookupFailed
962
+ // for why the two must stay distinguishable. Falling back to the LIVE base view is correct in BOTH
963
+ // cases; only the silence was the defect.
964
+ if (this.MaterializationLookupFailed(res, `entity "${entityInfo.Name}" (base-view materialization status)`)) {
965
+ return entityInfo.BaseView;
966
+ }
967
+ const row = res.Results?.length > 0 ? res.Results[0] : null;
968
+ if (row?.Status !== 'Active')
969
+ return entityInfo.BaseView;
970
+ // Use the AUTHORITATIVE wrapper-view name persisted on the row rather than re-deriving
971
+ // materialized_vw<CodeName>: the row's ViewName is what the mint/migration actually created, so it stays
972
+ // correct for a migration-provided (non-conventionally-named) view or an entity renamed since mint (whose
973
+ // current CodeName no longer matches the view). Matches how the query path reads ViewName from the row.
974
+ // Fall back to the convention only if the row somehow lacks a ViewName. (SchemaName stays the entity's —
975
+ // base-view materialization always mints into the source entity's own schema.)
976
+ return row.ViewName?.trim() || `materialized_vw${entityInfo.CodeName}`;
977
+ }
978
+ /**
979
+ * Reports whether a materialization-metadata `RunView` FAILED, logging the failure when it did.
980
+ *
981
+ * Every materialization read path falls back to LIVE data when it cannot confirm an Active snapshot, and
982
+ * that fallback is correct — live data is always right, materialization is a transparent optimization,
983
+ * never a correctness dependency. The defect this guards is the **silence**: `RunView` signals an
984
+ * authorization or query failure through `Success === false` rather than by throwing, so collapsing a
985
+ * failure into the same branch as the legitimate "no materialization row exists" case makes the two
986
+ * indistinguishable to operator and caller alike.
987
+ *
988
+ * That matters because read access to the materialization entities is role-gated (`CanRead` is granted
989
+ * only to the UI / Developer / Integration roles). A user on a restricted role — including MJ's
990
+ * magic-link / external-access pattern — therefore has every one of these lookups fail, and so
991
+ * permanently reads LIVE data for every `DataSource:'Materialized'` request, while an admin issuing the
992
+ * identical request is served the snapshot. Two users, different data, no error surfaced to either.
993
+ * Logging leaves the safe fallback intact but makes the divergence diagnosable.
994
+ *
995
+ * @param result the `RunView` result to inspect (structurally a `RunViewResult`).
996
+ * @param context human-readable description of what was being resolved, for the log message.
997
+ * @returns `true` if the lookup failed and the caller should take its live-data fallback; `false` otherwise.
998
+ */
999
+ MaterializationLookupFailed(result, context) {
1000
+ if (result.Success)
1001
+ return false;
1002
+ LogError(`GenericDatabaseProvider: materialization metadata lookup failed for ${context} — falling back to LIVE data. ` +
1003
+ `The fallback is safe (live data is always correct), but the snapshot will NEVER be served for this caller. ` +
1004
+ `The most likely cause is the current user's roles lacking CanRead on the materialization entities. ` +
1005
+ `Error: ${result.ErrorMessage || 'unknown error'}`);
1006
+ return true;
890
1007
  }
891
1008
  /**
892
1009
  * Validates that the entity and RunViewParams are compatible with keyset (AfterKey) pagination,
@@ -1033,7 +1150,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1033
1150
  * SQL Server overrides to use spCreateUserViewRunWithDetail.
1034
1151
  * Default: returns null (no view run logging).
1035
1152
  */
1036
- async executeSQLForUserViewRunLogging(_viewId, _entityBaseView, _whereSQL, _orderBySQL, _user) {
1153
+ async executeSQLForUserViewRunLogging(_viewId, _entityInfo, _effectiveBaseView, _whereSQL, _orderBySQL, _user) {
1037
1154
  return null;
1038
1155
  }
1039
1156
  /**
@@ -1151,14 +1268,17 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1151
1268
  // ── Field selection ──
1152
1269
  const fields = this.getRunTimeViewFieldString(params, viewEntity);
1153
1270
  // ── Build SELECT and COUNT SQL ──
1271
+ // DataSource:'Materialized' routes the read to the entity's materialized wrapper view
1272
+ // (same shape, so RLS/paging/fields all apply identically); default stays the live base view.
1273
+ const effectiveBaseView = await this.resolveEffectiveBaseView(entityInfo, params, contextUser);
1154
1274
  const topFragment = topSQL ? topSQL + ' ' : '';
1155
- let viewSQL = `SELECT ${topFragment}${fields} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)}`;
1275
+ let viewSQL = `SELECT ${topFragment}${fields} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, effectiveBaseView)}`;
1156
1276
  // count_only ALWAYS needs the count query — BuildTotalRowCountSQL only emits
1157
1277
  // it when rows are limited (its pagination purpose), which previously left
1158
1278
  // count_only with no COUNT at all (silently returned TotalRowCount 0).
1159
1279
  let countSQL = params.ResultType === 'count_only'
1160
- ? `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRowCount')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)}`
1161
- : this.BuildTotalRowCountSQL(entityInfo, usingPagination, maxRowsForQuery);
1280
+ ? `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRowCount')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, effectiveBaseView)}`
1281
+ : this.BuildTotalRowCountSQL(entityInfo, usingPagination, maxRowsForQuery, effectiveBaseView);
1162
1282
  // ── WHERE clause assembly ──
1163
1283
  let whereSQL = '';
1164
1284
  let bHasWhere = false;
@@ -1232,36 +1352,63 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1232
1352
  // caller's OrderBy — BuildKeysetSeekClause already validated that any caller-provided
1233
1353
  // OrderBy referenced the PK and we use the resolved direction.
1234
1354
  let rawOrderBy;
1355
+ let orderByIsPkFallback = false;
1235
1356
  if (usingKeyset) {
1236
1357
  rawOrderBy = `${keysetPkColumnName} ${keysetDirection}`;
1237
1358
  }
1238
1359
  else {
1239
1360
  rawOrderBy = params.OrderBy ? params.OrderBy : (viewEntity ? viewEntity.OrderByClause ?? '' : '');
1361
+ if (rawOrderBy.trim().length === 0 && maxRowsForQuery > 0 && entityInfo.FirstPrimaryKey) {
1362
+ // ── DETERMINISM FALLBACK ──
1363
+ // A row-LIMITED query with no ORDER BY returns an ARBITRARY subset: `TOP N` /
1364
+ // `LIMIT N` without an ordering is undefined by definition, and the engine is
1365
+ // free to return different rows run to run (or between two runs of the *same*
1366
+ // walk). Order by the PK so "the first N rows" means something.
1367
+ //
1368
+ // This is what broke keyset (AfterKey) pagination end to end. Page 1 of a walk
1369
+ // has no cursor yet, so `usingKeyset` is false and it landed here unordered,
1370
+ // while page 2+ force `ORDER BY <pk>` + `<pk> > @afterKey`. The two pages were
1371
+ // ordered differently, so a walk both RE-RETURNED page-1 rows and could silently
1372
+ // MISS others. Reproduced as 4 duplicates in an 8-row page (IT25 V10).
1373
+ //
1374
+ // OFFSET pagination already had exactly this fallback (see the pagination block
1375
+ // below); it was simply never applied to the TOP/LIMIT path. Same PK, so a
1376
+ // keyset walk's page 1 now agrees with every later page.
1377
+ rawOrderBy = this.QuoteIdentifier(entityInfo.FirstPrimaryKey.Name);
1378
+ orderByIsPkFallback = true;
1379
+ }
1240
1380
  }
1241
1381
  const orderBy = rawOrderBy.length > 0
1242
- ? (usingKeyset ? rawOrderBy : this.TransformExternalSQLClause(rawOrderBy, entityInfo))
1382
+ ? (usingKeyset || orderByIsPkFallback ? rawOrderBy : this.TransformExternalSQLClause(rawOrderBy, entityInfo))
1243
1383
  : '';
1244
1384
  // View run logging (SQL Server-specific, others return null)
1245
1385
  let userViewRunID = '';
1246
1386
  if (viewEntity?.ID && String(viewEntity.ID).length > 0 && saveViewResults && user) {
1247
- const logResult = await this.executeSQLForUserViewRunLogging(Number(viewEntity.ID), viewEntity.EntityBaseView, whereSQL, orderBy, user);
1387
+ // Pass entityInfo + effectiveBaseView so the logged read honors DataSource:'Materialized'
1388
+ // (reads the snapshot). effectiveBaseView === entityInfo.BaseView on the default live path,
1389
+ // so non-materialized reads are unchanged.
1390
+ const logResult = await this.executeSQLForUserViewRunLogging(Number(viewEntity.ID), entityInfo, effectiveBaseView, whereSQL, orderBy, user);
1248
1391
  if (logResult) {
1249
1392
  viewSQL = logResult.executeViewSQL;
1250
1393
  userViewRunID = logResult.runID;
1251
1394
  }
1252
1395
  else if (orderBy.length > 0) {
1253
- if (!usingKeyset && !this.ValidateUserProvidedSQLClause(orderBy))
1396
+ if (!usingKeyset && !orderByIsPkFallback && !this.ValidateUserProvidedSQLClause(orderBy))
1254
1397
  throw new Error(`Invalid Order By clause: ${orderBy}, contains one more for forbidden keywords`);
1255
1398
  viewSQL += ` ORDER BY ${orderBy}`;
1256
1399
  }
1257
1400
  }
1258
1401
  else if (orderBy.length > 0) {
1259
- if (!usingKeyset && !this.ValidateUserProvidedSQLClause(orderBy))
1402
+ if (!usingKeyset && !orderByIsPkFallback && !this.ValidateUserProvidedSQLClause(orderBy))
1260
1403
  throw new Error(`Invalid Order By clause: ${orderBy}, contains one more for forbidden keywords`);
1261
1404
  viewSQL += ` ORDER BY ${orderBy}`;
1262
1405
  }
1263
1406
  // ── Pagination / Non-paginated limit ──
1264
1407
  if (usingPagination && entityInfo.FirstPrimaryKey) {
1408
+ // Belt-and-braces: the determinism fallback above already supplies ORDER BY <PK>
1409
+ // for every row-limited query (pagination included), so `orderBy` is normally
1410
+ // non-empty here. Kept because OFFSET/FETCH is a hard SYNTAX error without an
1411
+ // ORDER BY — if the fallback above is ever narrowed, this must still hold.
1265
1412
  if (!orderBy) {
1266
1413
  viewSQL += ` ORDER BY ${this.QuoteIdentifier(entityInfo.FirstPrimaryKey.Name)}`;
1267
1414
  }
@@ -1279,7 +1426,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1279
1426
  let aggregateSQL = null;
1280
1427
  let aggregateValidationErrors = [];
1281
1428
  if (params.Aggregates && params.Aggregates.length > 0) {
1282
- const aggregateBuild = this.BuildAggregateSQL(params.Aggregates, entityInfo, entityInfo.SchemaName, entityInfo.BaseView, whereSQL);
1429
+ // Aggregate over the SAME source the rows/count came from — effectiveBaseView, so a caller
1430
+ // asking for DataSource:'Materialized' gets aggregates over the snapshot, not the live view.
1431
+ const aggregateBuild = this.BuildAggregateSQL(params.Aggregates, entityInfo, entityInfo.SchemaName, effectiveBaseView, whereSQL);
1283
1432
  aggregateSQL = aggregateBuild.aggregateSQL;
1284
1433
  aggregateValidationErrors = aggregateBuild.validationErrors;
1285
1434
  }
@@ -1528,7 +1677,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1528
1677
  */
1529
1678
  buildPerFieldSearchPredicate(field, escapedTerm, rawSafeTerm) {
1530
1679
  if (field.UserSearchParamFormatAPI && field.UserSearchParamFormatAPI.length > 0) {
1531
- return field.UserSearchParamFormatAPI.replace('{0}', rawSafeTerm);
1680
+ // Function replacement: the term is end-user input, so `$&`/`` $` ``/`$'`/`$$`
1681
+ // in it must be data, not splice directives. See issue #3171.
1682
+ return field.UserSearchParamFormatAPI.replace('{0}', () => rawSafeTerm);
1532
1683
  }
1533
1684
  if (!this.isTextSearchableType(field))
1534
1685
  return '';
@@ -1600,7 +1751,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1600
1751
  if (innerViewEntity) {
1601
1752
  const innerWhere = await this.RenderViewWhereClause(innerViewEntity, user, stack);
1602
1753
  const innerSQL = `SELECT ${this.QuoteIdentifier(innerViewEntity.ViewEntityInfo.FirstPrimaryKey.Name)} FROM ${this.QuoteSchemaAndView(innerViewEntity.ViewEntityInfo.SchemaName, innerViewEntity.ViewEntityInfo.BaseView)} WHERE (${innerWhere})`;
1603
- sWhere = sWhere.replace(match, innerSQL);
1754
+ // Function replacement — `innerSQL` is generated SQL that can
1755
+ // legitimately contain `$`. See issue #3171.
1756
+ sWhere = sWhere.replace(match, () => innerSQL);
1604
1757
  }
1605
1758
  else
1606
1759
  throw new Error(`View ID ${variableValue} not found in metadata`);
@@ -1943,9 +2096,16 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1943
2096
  const results = new Map();
1944
2097
  if (items.length === 0)
1945
2098
  return results;
1946
- const promises = items.map(async ({ index, entityInfo, whereSQL }) => {
2099
+ const promises = items.map(async ({ index, item, entityInfo, whereSQL }) => {
1947
2100
  try {
1948
- const statusSQL = `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRows')}, MAX(${this.QuoteIdentifier('__mj_UpdatedAt')}) AS ${this.QuoteIdentifier('MaxUpdatedAt')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)}${whereSQL ? ' WHERE ' + whereSQL : ''}`;
2101
+ // Probe the SAME physical view the read targets for a DataSource:'Materialized' read that's
2102
+ // the materialized_vw<CodeName> snapshot (a full SELECT * of the base view, so it carries
2103
+ // __mj_UpdatedAt), NOT the live base view. Probing the live view would compare the client's
2104
+ // snapshot cache against an unrelated source, yielding a meaningless current/stale verdict.
2105
+ // (Materialized reads are normally kept out of the client cache by runViewCacheEligible; this
2106
+ // matches the SQL Server override and is defense-in-depth on the PG/default path.)
2107
+ const effectiveView = await this.resolveEffectiveBaseView(entityInfo, item.params, contextUser);
2108
+ const statusSQL = `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRows')}, MAX(${this.QuoteIdentifier('__mj_UpdatedAt')}) AS ${this.QuoteIdentifier('MaxUpdatedAt')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, effectiveView)}${whereSQL ? ' WHERE ' + whereSQL : ''}`;
1949
2109
  const rows = await this.ExecuteSQL(statusSQL, undefined, undefined, contextUser);
1950
2110
  if (rows && rows.length > 0) {
1951
2111
  const row = rows[0];
@@ -2167,6 +2327,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2167
2327
  // caller's requested fields (∪ PK) before returning, exactly like the
2168
2328
  // ProviderBase hit path. Serving unprojected rows here previously leaked
2169
2329
  // whatever shape happened to be cached to every subsequent caller.
2330
+ //
2331
+ // `serverCached.results` are the cache's shared, deep-frozen rows — the runtime freeze
2332
+ // is what actually protects the cache. See ProviderBase's hit path.
2170
2333
  const results = callerFields
2171
2334
  ? ProjectRowsToFields(serverCached.results, callerFields)
2172
2335
  : serverCached.results;
@@ -2178,8 +2341,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2178
2341
  rowCount: serverCached.totalRowCount ?? serverCached.rowCount,
2179
2342
  // Carry aggregates through (B40-family, 4th drop). The server slot stores them and
2180
2343
  // GetRunViewResult returns them, but this serve-leg's inline serverCached type omitted
2181
- // the field, so TypeScript could not flag the drop. Both callers (noCacheStatus + stale)
2182
- // now widen their type to match, so the field flows.
2344
+ // the field, so TypeScript could not flag the drop. Both legs now share the canonical
2345
+ // CachedRunViewResult type, so a future field can no longer be silently dropped here.
2183
2346
  aggregateResults: serverCached.aggregateResults,
2184
2347
  };
2185
2348
  }
@@ -2523,7 +2686,12 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2523
2686
  const row = rows[0];
2524
2687
  results.set(index, {
2525
2688
  success: true,
2526
- rowCount: Number(row['RowCount']),
2689
+ // Both spellings accepted. The shipped `CacheValidationSQL` field description tells authors
2690
+ // to return `TotalRows`, while this has only ever read `RowCount` — so a query written
2691
+ // from the documentation yields `Number(undefined)` = NaN, `NaN !== NaN` makes the slot
2692
+ // never validate, and NaN is then written back into the client's cache entry where `??`
2693
+ // cannot clear it. Silent, permanent degradation to always-refetch, with no error.
2694
+ rowCount: Number(row['RowCount'] ?? row['TotalRows']),
2527
2695
  maxUpdatedAt: row['MaxUpdatedAt'] ? new Date(String(row['MaxUpdatedAt'])).toISOString() : undefined,
2528
2696
  });
2529
2697
  }
@@ -2577,6 +2745,285 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2577
2745
  * execute → paginate → audit → cache store. Platform providers inherit this; only
2578
2746
  * `ExecuteSQL()` is platform-specific.
2579
2747
  */
2748
+ /**
2749
+ * Phase 2 read-time filter predicate — the runtime mirror of CodeGen's persisted `ReadFilterSpec`
2750
+ * entry. Duplicated here (not imported) because the provider must not depend on the dev-time
2751
+ * CodeGenLib; the JSON shape is the contract (plan §4).
2752
+ */
2753
+ static { this.RUNTIME_SAFE_READ_FILTER_OPERATORS = new Set([
2754
+ '=', '!=', '<>', '<', '>', '<=', '>=', 'IN', 'NOT IN',
2755
+ ]); }
2756
+ /**
2757
+ * The stable surrogate row-id column every materialized snapshot table carries (CodeGenLib's
2758
+ * `MATERIALIZATION_SURROGATE_COLUMN`). Duplicated here (not imported) for the same reason as the operator
2759
+ * set above — the provider must not depend on dev-time CodeGenLib. The wrapper view exposes it (`SELECT *`),
2760
+ * so ordering by it gives paged materialized reads a deterministic, refresh-stable order (see
2761
+ * {@link buildMaterializedReadQuery}).
2762
+ */
2763
+ static { this.MATERIALIZED_SURROGATE_ORDER_COLUMN = '__mj_MaterializedRowID'; }
2764
+ /** Quotes a SQL identifier for the target engine (SQL Server `[x]`, PostgreSQL `"x"`), escaping the closer. */
2765
+ static quoteMaterializedIdentifier(name, isPostgres) {
2766
+ return isPostgres ? `"${name.replace(/"/g, '""')}"` : `[${name.replace(/]/g, ']]')}]`;
2767
+ }
2768
+ /**
2769
+ * PURE, dialect-aware builder for the Phase-2 materialized read query (plan §5). Given the query's
2770
+ * output columns, the materialized view (schema + name), the persisted read-filter spec, the caller's
2771
+ * parameter values, and the platform, returns `{ sql, parameters }` whose WHERE injects each spec
2772
+ * predicate as `column <op> <placeholder>` with the value **bound** (SQL Server `?`, PostgreSQL `$n`) —
2773
+ * never interpolating a caller value (SQL-injection-safe by construction).
2774
+ *
2775
+ * Returns null on ANY condition that would make the materialized read UNFAITHFUL to the live query, so
2776
+ * the caller falls back to running live (always correct): an operator outside the safe set, a spec
2777
+ * parameter the caller did not supply (the live query would apply the param's default), a null value,
2778
+ * or an empty/non-array value for a list (`IN`/`NOT IN`) predicate. No IO — fully unit-testable.
2779
+ */
2780
+ /**
2781
+ * True if `sql`'s top-level SELECT carries an ORDER BY. Used to refuse a materialized RowFilterBroad read:
2782
+ * {@link buildMaterializedReadQuery} emits no ORDER BY and the snapshot is built with the source's top-level
2783
+ * ORDER BY stripped, so an ordered query must be served LIVE (where its ordering — and therefore its
2784
+ * pagination under StartRow/MaxRows — is preserved) rather than from the unordered snapshot. Parse failure or
2785
+ * an un-reasoned statement shape returns `true` (refuse-to-live: treat unknown as ordered rather than risk
2786
+ * serving mis-ordered pages). Mirrors MaterializationRefresher.stripTopLevelOrderBy's AST detection.
2787
+ *
2788
+ * @internal Materialized-read ordering-fidelity gate — NOT part of this package's supported public API;
2789
+ * `static` only so it can be unit-tested. Do not call from outside `@memberjunction/generic-database-provider`.
2790
+ */
2791
+ static queryHasTopLevelOrderBy(sql, platformKey) {
2792
+ if (!sql || sql.trim().length === 0)
2793
+ return false;
2794
+ try {
2795
+ const parsed = SQLParser.Astify(sql, GetDialect(platformKey ?? 'sqlserver'));
2796
+ if (!parsed.astParsed || parsed.ast == null)
2797
+ return true; // unparseable → refuse to live
2798
+ const stmtNode = Array.isArray(parsed.ast) ? (parsed.ast.length === 1 ? parsed.ast[0] : null) : parsed.ast;
2799
+ if (stmtNode == null || typeof stmtNode !== 'object')
2800
+ return true;
2801
+ const s = stmtNode;
2802
+ if (s.type !== 'select')
2803
+ return true; // not a simple SELECT we can reason about → refuse to live
2804
+ return s.orderby != null;
2805
+ }
2806
+ catch {
2807
+ return true; // parser threw → refuse to live (safe: served correctly by the live path)
2808
+ }
2809
+ }
2810
+ static buildMaterializedReadQuery(opts) {
2811
+ const { outputColumns, schemaName, viewName, spec, paramValues, paramTypes, isPostgres } = opts;
2812
+ if (!outputColumns || outputColumns.length === 0)
2813
+ return null;
2814
+ if (!spec || spec.length === 0)
2815
+ return null;
2816
+ const q = (name) => GenericDatabaseProvider.quoteMaterializedIdentifier(name, isPostgres);
2817
+ const parameters = [];
2818
+ const predicates = [];
2819
+ // Placeholder for the NEXT bound value: SQL Server uses positional `?`; PostgreSQL uses `$n` (1-based,
2820
+ // computed BEFORE the value is pushed so the index aligns with the array position).
2821
+ const nextPlaceholder = () => (isPostgres ? `$${parameters.length + 1}` : '?');
2822
+ for (const e of spec) {
2823
+ // Defensive: the spec is parsed from a persisted JSON string, so validate each element's shape
2824
+ // before use — a malformed element (missing/non-string column/operator/paramName) returns null
2825
+ // (→ caller falls back to live), never throws mid-build (Phase 2 §2: any uncertainty → live).
2826
+ if (!e || typeof e.column !== 'string' || typeof e.operator !== 'string' || typeof e.paramName !== 'string')
2827
+ return null;
2828
+ if (!GenericDatabaseProvider.RUNTIME_SAFE_READ_FILTER_OPERATORS.has(e.operator))
2829
+ return null;
2830
+ const val = paramValues ? paramValues[e.paramName] : undefined;
2831
+ if (val === undefined || val === null)
2832
+ return null; // caller omitted it → live applies the default
2833
+ const col = q(e.column);
2834
+ const isListOp = e.operator === 'IN' || e.operator === 'NOT IN';
2835
+ if (isListOp) {
2836
+ if (!Array.isArray(val) || val.length === 0)
2837
+ return null; // empty/non-array IN → live
2838
+ const phs = val.map((item) => {
2839
+ const ph = nextPlaceholder();
2840
+ parameters.push(item); // list elements bound as-is (array element type is not declared)
2841
+ return ph;
2842
+ });
2843
+ predicates.push(`${col} ${e.operator} (${phs.join(', ')})`);
2844
+ }
2845
+ else {
2846
+ // Bind the value AS ITS DECLARED TYPE. The live path renders params as typed SQL literals via the
2847
+ // Nunjucks pipeline; binding the raw string here would instead make the DB implicitly coerce it,
2848
+ // which can (a) error on PostgreSQL (text vs numeric/date) and (b) silently match DIFFERENT rows
2849
+ // than live on SQL Server for format/whitespace-sensitive values. Coercing to the declared type
2850
+ // aligns the two; an unconvertible value fails closed → live (never a wrong-rows materialized read).
2851
+ const coerced = GenericDatabaseProvider.coerceMaterializedScalarValue(val, paramTypes?.[e.paramName], isPostgres);
2852
+ if (!coerced.ok)
2853
+ return null;
2854
+ const ph = nextPlaceholder();
2855
+ parameters.push(coerced.value);
2856
+ predicates.push(`${col} ${e.operator} ${ph}`);
2857
+ }
2858
+ }
2859
+ const cols = outputColumns.map((c) => q(c)).join(', ');
2860
+ // Deterministic page order. A materialized read carries no user ORDER BY (an ordered source query falls
2861
+ // back to live), so without an explicit order the paging engine would append `ORDER BY (SELECT NULL)` and
2862
+ // successive pages could skip or duplicate rows whenever the execution plan differs between calls. The
2863
+ // snapshot's stable surrogate row-id — exposed by the wrapper view and unchanged between refreshes — gives
2864
+ // a total, refresh-stable order; it need not appear in the SELECT list to be an ORDER BY target, and
2865
+ // buildDataSQL detects this ORDER BY and pages against it instead of the dialect default.
2866
+ const orderBy = q(GenericDatabaseProvider.MATERIALIZED_SURROGATE_ORDER_COLUMN);
2867
+ const sql = `SELECT ${cols} FROM ${q(schemaName)}.${q(viewName)} WHERE ${predicates.join(' AND ')} ORDER BY ${orderBy}`;
2868
+ return { sql, parameters };
2869
+ }
2870
+ /**
2871
+ * Coerces a scalar row-filter value to its declared `MJ: Query Parameters`.Type for type-faithful binding
2872
+ * (see {@link buildMaterializedReadQuery}). This mirrors the TYPE-CONVERSION SWITCH of the live path's
2873
+ * `@memberjunction/queryprocessor` `QueryParameterProcessor.validateParameters` (number/boolean/date/string),
2874
+ * so the materialized read binds the same value the live query renders. Keep in sync with that switch. It does
2875
+ * NOT replay the subsequent ValidationFilters chain: value-TRANSFORMING filters (trim/upper/lower/etc.) are
2876
+ * already excluded from RowFilterBroad materialization at classify time (materializationParamClassifier's
2877
+ * `isValuePassthrough`), so they never reach here. Pure VALIDATORS (min/max/email/...) are a documented low
2878
+ * residual — they reject invalid input at runtime on the live path only, so an invalid caller value can make
2879
+ * live error while the materialized read returns rows (divergence on the error path only; tracked as a
2880
+ * follow-up to refuse validator-bearing row-filter params at classify time). Returns `{ok:false}` only where
2881
+ * the live TYPE conversion would ALSO reject the value (→ caller falls back to live).
2882
+ * - `number` → `Number(value)` (JS trims); non-finite → refuse (live pushes a validation error → live).
2883
+ * - `boolean` → live truthiness: ONLY 'true' (case-insensitive) or a real boolean `true` is true; everything
2884
+ * else is false (live never refuses a boolean). SQL Server binds BIT 1/0; PostgreSQL binds bool.
2885
+ * - `date` → `new Date(value).toISOString()` — the UTC ISO string, exactly what live stores/renders. NOT
2886
+ * the naive input string: live applies the local→UTC shift, so binding the raw string would
2887
+ * diverge for any timed value. Invalid date → refuse.
2888
+ * - `string` → `String(value)` (matches live); `array`-element / unspecified → bound as-is.
2889
+ */
2890
+ static coerceMaterializedScalarValue(value, type, isPostgres) {
2891
+ switch (type) {
2892
+ case 'number': {
2893
+ const n = Number(typeof value === 'string' ? value.trim() : value);
2894
+ return Number.isFinite(n) ? { ok: true, value: n } : { ok: false };
2895
+ }
2896
+ case 'boolean': {
2897
+ const b = typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true';
2898
+ return { ok: true, value: isPostgres ? b : b ? 1 : 0 };
2899
+ }
2900
+ case 'date': {
2901
+ const d = value instanceof Date ? value : new Date(String(value));
2902
+ if (Number.isNaN(d.getTime()))
2903
+ return { ok: false };
2904
+ const iso = d.toISOString();
2905
+ // SQL Server rejects the ISO 'Z' zone suffix for datetime2/datetime (error 241 — 'Z' is valid only
2906
+ // for datetimeoffset), so binding it would throw and force a fallback to live. Strip it for SS: the
2907
+ // value is already the UTC moment and a zone-less ISO string binds as that same datetime2 wall-clock,
2908
+ // matching the live path's rendered literal (no row divergence). PostgreSQL's timestamptz accepts
2909
+ // 'Z', so keep it there.
2910
+ return { ok: true, value: isPostgres ? iso : iso.replace(/Z$/, '') };
2911
+ }
2912
+ case 'string':
2913
+ return { ok: true, value: String(value) };
2914
+ default: // 'array' (element-wise, no declared element type) or unknown → bind verbatim
2915
+ return { ok: true, value };
2916
+ }
2917
+ }
2918
+ /**
2919
+ * Phase 2 (plan §5): resolve a materialized read plan for a query IF the caller opted into
2920
+ * `DataSource:'Materialized'` AND the query has a fresh, Active `RowFilterBroad` materialization whose
2921
+ * persisted spec fully covers the query's parameters. Returns null on ANY uncertainty → the caller runs
2922
+ * the live query (serving live is always correct — this is a transparent optimization, never a
2923
+ * correctness dependency).
2924
+ */
2925
+ async tryBuildMaterializedQueryPlan(query, params, contextUser) {
2926
+ if (!IsMaterializedDataSource(params.DataSource))
2927
+ return null; // not opted in → live
2928
+ if (query.ExternalDataSourceID)
2929
+ return null; // external source → materialized table is local; live
2930
+ // The query<->materialization link lives in the MaterializedResultQuery join table — there is no
2931
+ // Query.MaterializedResultID column (that direct FK, paired with MaterializedResult.SourceQueryID,
2932
+ // formed a circular dependency). Resolve this query's materialization via the join; absent → live.
2933
+ // query.ID is our own metadata UUID (never caller input), so it is safe to interpolate.
2934
+ const linkRv = new RunView(this);
2935
+ const linkRes = await linkRv.RunView({
2936
+ EntityName: 'MJ: Materialized Result Queries',
2937
+ ExtraFilter: `QueryID='${query.ID}'`,
2938
+ Fields: ['MaterializedResultID'],
2939
+ ResultType: 'simple',
2940
+ MaxRows: 1,
2941
+ }, contextUser);
2942
+ // Same failure-vs-absence distinction as the base-view path: a permission/query failure here would
2943
+ // otherwise be indistinguishable from "this query is not materialized", silently pinning the caller to
2944
+ // live data forever. Still fall back to live (correct either way) — just not silently.
2945
+ if (this.MaterializationLookupFailed(linkRes, `query "${query.Name}" (materialization join lookup)`))
2946
+ return null;
2947
+ if (!linkRes.Results || linkRes.Results.length === 0)
2948
+ return null; // query not materialized → live
2949
+ const matId = linkRes.Results[0].MaterializedResultID;
2950
+ if (!matId)
2951
+ return null; // query not materialized → live
2952
+ // Ordering fidelity: buildMaterializedReadQuery emits no ORDER BY, and the snapshot was built with the
2953
+ // source's top-level ORDER BY stripped (it has no inherent order). A query that carries a top-level ORDER
2954
+ // BY would therefore page differently from the live query. Refuse → live (which preserves the ordering)
2955
+ // rather than serve a divergent page order — consistent with this method's "any uncertainty → live".
2956
+ // Check the PLATFORM-resolved SQL (the exact SQL the live path executes — GetPlatformSQL, line ~3642),
2957
+ // not the base query.SQL: a per-platform QuerySQL variant (e.g. a PostgreSQL variant) may add a top-level
2958
+ // ORDER BY the base SQL lacks, and parsing the base SQL would miss it and serve mis-ordered snapshot pages.
2959
+ if (GenericDatabaseProvider.queryHasTopLevelOrderBy(query.GetPlatformSQL(this.PlatformKey) ?? '', this.PlatformKey))
2960
+ return null;
2961
+ // Load the materialization metadata. matId is our own UUID (from committed metadata), so it is safe
2962
+ // to interpolate into ExtraFilter — it never carries caller input.
2963
+ const rv = new RunView(this);
2964
+ // BypassCache is REQUIRED here (H6): this read gates whether we route to the materialized table, and the
2965
+ // decisive column is Status. A refresher/CodeGen run can flip Status to 'DriftHold' or 'Disabled' out of
2966
+ // band, but a cached 'Active' row would let this plan keep serving the held/disabled snapshot — the exact
2967
+ // stale-serve the DriftHold mechanism exists to prevent. Reading straight from the DB guarantees we see
2968
+ // the current terminal status. This is a single-row point lookup, so the bypass cost is negligible.
2969
+ const res = await rv.RunView({
2970
+ EntityName: 'MJ: Materialized Results',
2971
+ ExtraFilter: `ID='${matId}'`,
2972
+ Fields: ['Status', 'ParamMode', 'ReadFilterSpec', 'SchemaName', 'ViewName'],
2973
+ ResultType: 'simple',
2974
+ MaxRows: 1,
2975
+ BypassCache: true,
2976
+ }, contextUser);
2977
+ if (this.MaterializationLookupFailed(res, `query "${query.Name}" (materialization metadata)`))
2978
+ return null;
2979
+ if (!res.Results || res.Results.length === 0)
2980
+ return null;
2981
+ const mat = res.Results[0];
2982
+ if (mat.Status !== 'Active')
2983
+ return null; // Building / DriftHold / stale → live
2984
+ if (mat.ParamMode !== 'RowFilterBroad')
2985
+ return null; // None / PerValueCache → live (this path only serves Bucket 1)
2986
+ if (!mat.ReadFilterSpec)
2987
+ return null;
2988
+ let spec;
2989
+ try {
2990
+ spec = JSON.parse(mat.ReadFilterSpec);
2991
+ }
2992
+ catch {
2993
+ return null; // malformed spec → live
2994
+ }
2995
+ if (!Array.isArray(spec) || spec.length === 0)
2996
+ return null;
2997
+ // Coverage invariant (BOTH directions): a RowFilterBroad query's parameters are ALL row-filters (a mix
2998
+ // refuses at classify time), so the query's parameter set and the persisted spec's parameter set MUST be
2999
+ // identical. A query param missing from the spec → we would UNDER-filter; a spec param the query no longer
3000
+ // has (stale metadata after an out-of-band edit — the same window H6 guards against) → we would OVER-filter
3001
+ // vs. live, silently returning fewer rows. Either mismatch means the spec is inconsistent → refuse to live.
3002
+ const specNames = new Set(spec.map((s) => s.paramName));
3003
+ const queryParamNames = (query.QueryParameters ?? []).map((p) => p.Name);
3004
+ const queryParamNameSet = new Set(queryParamNames);
3005
+ if (queryParamNames.some((n) => !specNames.has(n)))
3006
+ return null; // query param not in spec → under-filter
3007
+ if (spec.some((s) => !queryParamNameSet.has(s.paramName)))
3008
+ return null; // spec param not in query → over-filter
3009
+ const outputColumns = (query.QueryFields ?? []).map((f) => f.Name).filter((n) => !!n);
3010
+ if (outputColumns.length === 0)
3011
+ return null;
3012
+ // Declared parameter types (name → Type) so the row-filter values bind type-faithfully (see
3013
+ // buildMaterializedReadQuery / coerceMaterializedScalarValue), matching the live path's typed literals.
3014
+ const paramTypes = {};
3015
+ for (const p of query.QueryParameters ?? [])
3016
+ paramTypes[p.Name] = p.Type;
3017
+ return GenericDatabaseProvider.buildMaterializedReadQuery({
3018
+ outputColumns,
3019
+ schemaName: mat.SchemaName,
3020
+ viewName: mat.ViewName,
3021
+ spec,
3022
+ paramValues: params.Parameters,
3023
+ paramTypes,
3024
+ isPostgres: this.PlatformKey === 'postgresql',
3025
+ });
3026
+ }
2580
3027
  async InternalRunQuery(params, contextUser) {
2581
3028
  // Route ad-hoc SQL queries to dedicated handler
2582
3029
  if (params.SQL) {
@@ -2590,6 +3037,81 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2590
3037
  const resolved = this.processQueryParameters(query, params.Parameters, contextUser);
2591
3038
  finalSQL = resolved.finalSQL;
2592
3039
  const appliedParameters = resolved.appliedParameters;
3040
+ // ── Phase 2: materialized read redirect (plan §5) ──
3041
+ // If the caller opted into DataSource:'Materialized' and this query has a fresh, Active
3042
+ // RowFilterBroad materialization, serve from the materialized table with the row-filter params
3043
+ // injected as BOUND predicates. On ANY uncertainty tryBuildMaterializedQueryPlan returns null and
3044
+ // we fall through to the live execution below — a materialized read can never diverge from live.
3045
+ const matPlan = await this.tryBuildMaterializedQueryPlan(query, params, contextUser);
3046
+ if (matPlan) {
3047
+ // Use a LOCAL for the materialized SQL — never overwrite `finalSQL` (which stays the
3048
+ // live-rendered SQL), so a fallback below runs the live path unchanged.
3049
+ try {
3050
+ const materializedSQL = matPlan.sql;
3051
+ // SQL-level paging parity with the live path: when the caller requested a page, wrap the
3052
+ // materialized read with OFFSET/FETCH (SQL Server) / LIMIT-OFFSET (PostgreSQL) plus a parallel
3053
+ // COUNT — via the same QueryPagingEngine the live path uses — so only the requested page is
3054
+ // pulled from the snapshot. Without this the whole (potentially multi-million-row) filtered
3055
+ // snapshot was loaded into Node and sliced in memory, defeating the large-dataset case
3056
+ // materialization targets. The bound row-filter parameters (matPlan.parameters) are preserved
3057
+ // in both the data and count SQL; StartRow/MaxRows are inlined by WrapWithPaging. The materialized
3058
+ // read carries an explicit `ORDER BY <surrogate row-id>` (buildMaterializedReadQuery), so paging
3059
+ // is deterministic and refresh-stable across pages rather than relying on the dialect default
3060
+ // order. No cache layer — materialized reads bypass the query cache by design. When no page is
3061
+ // requested, keep the full-load path.
3062
+ const matUseSQLPaging = QueryPagingEngine.ShouldPage(params.StartRow, params.MaxRows);
3063
+ let rows;
3064
+ let matTotalRowCount;
3065
+ let matExecutionTime;
3066
+ if (matUseSQLPaging) {
3067
+ const paging = QueryPagingEngine.WrapWithPaging(materializedSQL, params.StartRow, params.MaxRows, this.PlatformKey);
3068
+ const start = Date.now();
3069
+ const [dataResult, countResult] = await Promise.all([
3070
+ this.ExecuteSQL(paging.DataSQL, matPlan.parameters, undefined, contextUser),
3071
+ this.ExecuteSQL(paging.CountSQL, matPlan.parameters, undefined, contextUser),
3072
+ ]);
3073
+ matExecutionTime = Date.now() - start;
3074
+ rows = dataResult ?? [];
3075
+ matTotalRowCount = countResult?.[0]?.TotalRowCount != null ? Number(countResult[0].TotalRowCount) : rows.length;
3076
+ }
3077
+ else {
3078
+ const timing = await this.executeQueryWithTiming(materializedSQL, contextUser, matPlan.parameters);
3079
+ matExecutionTime = timing.executionTime;
3080
+ const paginated = this.applyQueryPagination(timing.result, params);
3081
+ rows = paginated.paginatedResult;
3082
+ matTotalRowCount = paginated.totalRowCount;
3083
+ }
3084
+ if (params.Enrichment?.EnricherKey) {
3085
+ rows = await this.enrichQueryResults(rows, params, query, contextUser);
3086
+ }
3087
+ this.auditQueryExecution(query, params, materializedSQL, rows.length, matTotalRowCount, matExecutionTime, contextUser);
3088
+ return {
3089
+ Success: true,
3090
+ QueryID: query.ID,
3091
+ QueryName: query.Name,
3092
+ Results: rows,
3093
+ RowCount: rows.length,
3094
+ TotalRowCount: matTotalRowCount,
3095
+ PageNumber: matUseSQLPaging ? Math.floor(params.StartRow / params.MaxRows) + 1 : undefined,
3096
+ PageSize: matUseSQLPaging ? params.MaxRows : undefined,
3097
+ ExecutionTime: matExecutionTime,
3098
+ ErrorMessage: '',
3099
+ AppliedParameters: appliedParameters,
3100
+ RenderedSQL: materializedSQL,
3101
+ CacheHit: false,
3102
+ };
3103
+ }
3104
+ catch (matErr) {
3105
+ // A connection error is fatal for the live path too — let the outer handler surface it.
3106
+ if (this.isConnectionError(matErr))
3107
+ throw matErr;
3108
+ // Any other materialized-read failure (e.g. the wrapper view was rebuilt/dropped between the
3109
+ // freshness check and execution, or a column/grant mismatch) FALLS BACK to the live query.
3110
+ // Serving live is always correct, so a materialized-read failure must never fail a request
3111
+ // that would otherwise succeed (Phase 2 §2 safety model). `finalSQL` is still the live SQL.
3112
+ LogError(`Materialized read failed for query '${query.Name}' — falling back to live: ${matErr instanceof Error ? matErr.message : String(matErr)}`);
3113
+ }
3114
+ }
2593
3115
  // ── External data source dispatch ──
2594
3116
  // Queries bound to an external data source execute their (now fully-rendered)
2595
3117
  // native SQL via the driver, not the MJ DB. No-op for MJ-DB queries.
@@ -2849,6 +3371,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2849
3371
  QueryID: cached.queryId ?? query.ID,
2850
3372
  QueryName: query.Name,
2851
3373
  Success: true,
3374
+ // Transport boundary: `cached.results` is readonly (shared, deep-frozen cache
3375
+ // rows) while the outbound Results is mutable — the runtime freeze is the
3376
+ // enforcement. Same cast as the RunView hit paths in ProviderBase.
2852
3377
  Results: cached.results,
2853
3378
  RowCount: cached.results.length,
2854
3379
  TotalRowCount: cached.rowCount ?? cached.results.length,
@@ -2986,9 +3511,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2986
3511
  /**
2987
3512
  * Executes the query SQL and tracks execution time.
2988
3513
  */
2989
- async executeQueryWithTiming(sql, contextUser) {
3514
+ async executeQueryWithTiming(sql, contextUser, parameters) {
2990
3515
  const start = Date.now();
2991
- const result = await this.ExecuteSQL(sql, undefined, undefined, contextUser);
3516
+ const result = await this.ExecuteSQL(sql, parameters, undefined, contextUser);
2992
3517
  const executionTime = Date.now() - start;
2993
3518
  if (!result) {
2994
3519
  throw new Error('Error executing query SQL');
@@ -3336,11 +3861,40 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
3336
3861
  */
3337
3862
  renderRLSProjectionValue(field, val) {
3338
3863
  if (val == null) {
3339
- const sqlType = field.SQLFullType;
3340
- if (!sqlType || sqlType.trim().length === 0) {
3864
+ // Metadata records field types in SQL Server's vocabulary, so interpolating one raw
3865
+ // emits `CAST(NULL AS uniqueidentifier)` on PostgreSQL, where that type does not exist.
3866
+ // The statement throws — and because this is the RLS post-image gate, the caller gets a
3867
+ // type error in place of the row-scope rejection it was testing for: an authorization
3868
+ // decision surfacing as a SQL fault. Translate through the dialect, the same mapping
3869
+ // CodeGen and the converter use.
3870
+ //
3871
+ // Map from `field.Type` (the BASE type), NOT `field.SQLFullType`, which already has the
3872
+ // length formatted in as `nvarchar(50)`. `MapDataTypeToString` looks the base type up
3873
+ // and returns anything unrecognized UNCHANGED, so a full type string maps to itself and
3874
+ // the translation silently no-ops. `uniqueidentifier` carries no length and so maps
3875
+ // correctly either way — which is precisely how a half-fixed version of this passes a
3876
+ // spot check and still emits `CAST(NULL AS nvarchar(50))` on the very next field.
3877
+ const baseType = field.Type;
3878
+ if (!baseType || baseType.trim().length === 0) {
3341
3879
  throw new Error(`Cannot build RLS post-image projection: field ${field.Name} has no resolvable SQL type for a typed NULL`);
3342
3880
  }
3343
- return `CAST(NULL AS ${sqlType})`;
3881
+ // `field.Length` is the SYSTEM (byte) length from `sys.columns`, but
3882
+ // `MapDataTypeToString` takes a CHARACTER count — `RuntimeSchemaManager` calls it as
3883
+ // `('NVARCHAR', 200)` meaning 200 characters. `SQLFullType` knows the difference and
3884
+ // halves it for the n-types; passing the raw byte length instead DOUBLES every
3885
+ // `nvarchar`/`nchar` width. That is not cosmetic: SQL Server caps `NVARCHAR` at 4000,
3886
+ // and 119 fields in a stock database — including the shipped
3887
+ // `MJ: Conversation Detail Attachments.FileName` at 8000 — exceed it once doubled, so
3888
+ // the CAST becomes illegal and the RLS post-image gate throws on SQL SERVER: the exact
3889
+ // failure this change set out to remove from PostgreSQL, relocated to the primary
3890
+ // platform. `MaxLength` does the halving, but flattens the `-1` MAX sentinel to 0, so
3891
+ // the sentinel is passed through explicitly.
3892
+ const charLength = field.Length === -1 ? -1 : field.MaxLength;
3893
+ // `getDialect()` is nullable on this base. Falling back to `SQLFullType` — the exact
3894
+ // string this used to emit — keeps SQL Server, whose vocabulary that already is,
3895
+ // byte-identical to its previous behaviour.
3896
+ const mapped = this.getDialect()?.MapDataTypeToString(baseType, charLength, field.Precision, field.Scale);
3897
+ return `CAST(NULL AS ${mapped ?? field.SQLFullType})`;
3344
3898
  }
3345
3899
  if (typeof val === 'boolean') {
3346
3900
  return val ? '1' : '0';
@@ -3447,7 +4001,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
3447
4001
  }
3448
4002
  // Try cache first
3449
4003
  if (cacheAvailable) {
3450
- const fingerprint = cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString);
4004
+ const fingerprint = cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString, undefined, this.datasetCacheSegment(datasetName, code));
3451
4005
  const cached = await cache.GetRunViewResult(fingerprint);
3452
4006
  if (cached) {
3453
4007
  cacheHitCount++;
@@ -3484,7 +4038,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
3484
4038
  uncachedItems.push(item);
3485
4039
  // Store fingerprint for write-through caching after SQL
3486
4040
  const fp = cacheAvailable
3487
- ? cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString)
4041
+ ? cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString, undefined, this.datasetCacheSegment(datasetName, code))
3488
4042
  : '';
3489
4043
  uncachedFingerprints.push(fp);
3490
4044
  }
@@ -3522,7 +4076,18 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
3522
4076
  ? this.extractMaxUpdatedAtFromRows(itemData, dateFieldToCheck)
3523
4077
  : new Date(0).toISOString();
3524
4078
  const syntheticParams = { EntityName: entityName };
3525
- await cache.SetRunViewResult(uncachedFingerprints[i], syntheticParams, itemData, maxUpdatedAt, undefined, undefined, this);
4079
+ // ProviderInternalScaffolding for the MJ_Metadata dataset ONLY. Those rows are
4080
+ // consumed by this provider's own assembly steps, which mutate them in place by
4081
+ // design: PostProcessEntityMetadata sorts the entity array and attaches child
4082
+ // collections onto each entity/field row, and GetAllMetadata's Applications
4083
+ // assembly writes ApplicationEntities/ApplicationSettings onto Application rows.
4084
+ // Freezing them makes metadata bootstrap throw and the process starts blind.
4085
+ //
4086
+ // Every OTHER dataset is served to arbitrary consumers (BaseEngine.Load hands
4087
+ // item.Results — these very arrays — to every engine subclass), so those slots
4088
+ // must stay under the defensive deep-freeze like any RunView result.
4089
+ const isMetadataScaffolding = datasetName === ProviderBase._mjMetadataDatasetName;
4090
+ await cache.SetRunViewResult(uncachedFingerprints[i], syntheticParams, itemData, maxUpdatedAt, undefined, undefined, this, undefined, isMetadataScaffolding ? { ProviderInternalScaffolding: true } : undefined);
3526
4091
  }
3527
4092
  sqlResults.push({
3528
4093
  EntityID: entityID,
@@ -3630,7 +4195,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
3630
4195
  const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime()));
3631
4196
  // Try to derive status from cached data
3632
4197
  if (cacheAvailable) {
3633
- const fingerprint = cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString);
4198
+ const fingerprint = cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString, undefined, this.datasetCacheSegment(datasetName, code));
3634
4199
  const cached = await cache.GetRunViewResult(fingerprint);
3635
4200
  if (cached) {
3636
4201
  cacheHitCount++;
@@ -3770,10 +4335,29 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
3770
4335
  /**************************************************************************/
3771
4336
  // Dataset Cache Helpers
3772
4337
  /**************************************************************************/
4338
+ /**
4339
+ * Key namespace for a dataset item's cached rows.
4340
+ *
4341
+ * Dataset items are cached through the same fingerprint builder ordinary RunViews use, with
4342
+ * only `{ EntityName, ExtraFilter }` — and every shipped item has a NULL `WhereClause`, so
4343
+ * without this segment a dataset item and a plain unfiltered read of the same entity produce
4344
+ * an IDENTICAL key and share one slot. That leaks the `MJ_Metadata` scaffolding exemption
4345
+ * (deliberately unfrozen rows) to ordinary callers of `MJ: Entities` / `MJ: Entity Fields`,
4346
+ * and lets an ordinary read repopulate an evicted slot FROZEN, which then breaks the next
4347
+ * metadata refresh.
4348
+ *
4349
+ * Keyed by dataset + item code so two items over the same entity also stay distinct.
4350
+ * Callers must use this on the read, the write-through, and the status paths alike — the
4351
+ * three must agree or dataset reads stop finding dataset writes.
4352
+ */
4353
+ datasetCacheSegment(datasetName, itemCode) {
4354
+ return `${datasetName}/${itemCode}`;
4355
+ }
3773
4356
  /**
3774
4357
  * Computes the latest update date for a dataset item from its result rows and dataset metadata.
3775
4358
  * Used by both the cache-hit and cache-miss paths in GetDatasetByName.
3776
- * @param rows - The result rows (from cache or SQL)
4359
+ * @param rows - The result rows (from cache or SQL). `readonly` because cache-hit callers
4360
+ * pass the cache's shared, frozen rows; this method only scans them.
3777
4361
  * @param dateFieldToCheck - The field name to scan for latest date
3778
4362
  * @param item - The dataset item metadata row (contains DatasetItemUpdatedAt, DatasetUpdatedAt)
3779
4363
  * @returns The latest date across all rows and dataset metadata