@memberjunction/codegen-lib 6.1.0-edge.2 → 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.
Files changed (94) hide show
  1. package/LICENSE +180 -4
  2. package/README.md +39 -22
  3. package/dist/Angular/angular-codegen.d.ts +0 -8
  4. package/dist/Angular/angular-codegen.d.ts.map +1 -1
  5. package/dist/Angular/angular-codegen.js +32 -22
  6. package/dist/Angular/angular-codegen.js.map +1 -1
  7. package/dist/Angular/entity-data-grid-related-entity-component.js +2 -2
  8. package/dist/Angular/entity-data-grid-related-entity-component.js.map +1 -1
  9. package/dist/Angular/related-entity-components.d.ts +1 -1
  10. package/dist/Angular/related-entity-components.js +1 -1
  11. package/dist/Database/codeGenDatabaseProvider.d.ts +101 -0
  12. package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -1
  13. package/dist/Database/codeGenDatabaseProvider.js +77 -10
  14. package/dist/Database/codeGenDatabaseProvider.js.map +1 -1
  15. package/dist/Database/manage-metadata.d.ts +292 -2
  16. package/dist/Database/manage-metadata.d.ts.map +1 -1
  17. package/dist/Database/manage-metadata.js +1176 -45
  18. package/dist/Database/manage-metadata.js.map +1 -1
  19. package/dist/Database/materializationAnalysis.d.ts +241 -0
  20. package/dist/Database/materializationAnalysis.d.ts.map +1 -0
  21. package/dist/Database/materializationAnalysis.js +415 -0
  22. package/dist/Database/materializationAnalysis.js.map +1 -0
  23. package/dist/Database/materializationBroadRender.d.ts +67 -0
  24. package/dist/Database/materializationBroadRender.d.ts.map +1 -0
  25. package/dist/Database/materializationBroadRender.js +104 -0
  26. package/dist/Database/materializationBroadRender.js.map +1 -0
  27. package/dist/Database/materializationDrift.d.ts +59 -0
  28. package/dist/Database/materializationDrift.d.ts.map +1 -0
  29. package/dist/Database/materializationDrift.js +85 -0
  30. package/dist/Database/materializationDrift.js.map +1 -0
  31. package/dist/Database/materializationParamClassifier.d.ts +92 -0
  32. package/dist/Database/materializationParamClassifier.d.ts.map +1 -0
  33. package/dist/Database/materializationParamClassifier.js +224 -0
  34. package/dist/Database/materializationParamClassifier.js.map +1 -0
  35. package/dist/Database/materializationParamVerifier.d.ts +71 -0
  36. package/dist/Database/materializationParamVerifier.d.ts.map +1 -0
  37. package/dist/Database/materializationParamVerifier.js +302 -0
  38. package/dist/Database/materializationParamVerifier.js.map +1 -0
  39. package/dist/Database/materializationSqlAst.d.ts +78 -0
  40. package/dist/Database/materializationSqlAst.d.ts.map +1 -0
  41. package/dist/Database/materializationSqlAst.js +142 -0
  42. package/dist/Database/materializationSqlAst.js.map +1 -0
  43. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +68 -21
  44. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -1
  45. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +303 -197
  46. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -1
  47. package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.d.ts +2 -0
  48. package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.d.ts.map +1 -0
  49. package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.js +119 -0
  50. package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.js.map +1 -0
  51. package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.d.ts +2 -0
  52. package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.d.ts.map +1 -0
  53. package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.js +155 -0
  54. package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.js.map +1 -0
  55. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +60 -1
  56. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -1
  57. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +294 -0
  58. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -1
  59. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.d.ts +2 -0
  60. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.d.ts.map +1 -0
  61. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js +547 -0
  62. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js.map +1 -0
  63. package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.d.ts +2 -0
  64. package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.d.ts.map +1 -0
  65. package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.js +721 -0
  66. package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.js.map +1 -0
  67. package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.d.ts +2 -0
  68. package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.d.ts.map +1 -0
  69. package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.js +78 -0
  70. package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.js.map +1 -0
  71. package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.d.ts +2 -0
  72. package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.d.ts.map +1 -0
  73. package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.js +155 -0
  74. package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.js.map +1 -0
  75. package/dist/Database/sql_codegen.d.ts +21 -5
  76. package/dist/Database/sql_codegen.d.ts.map +1 -1
  77. package/dist/Database/sql_codegen.js +84 -26
  78. package/dist/Database/sql_codegen.js.map +1 -1
  79. package/dist/Misc/action_subclasses_codegen.d.ts.map +1 -1
  80. package/dist/Misc/action_subclasses_codegen.js +4 -4
  81. package/dist/Misc/action_subclasses_codegen.js.map +1 -1
  82. package/dist/Misc/entity_subclasses_codegen.d.ts +44 -0
  83. package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
  84. package/dist/Misc/entity_subclasses_codegen.js +369 -156
  85. package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
  86. package/dist/Misc/graphql_server_codegen.d.ts +4 -58
  87. package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
  88. package/dist/Misc/graphql_server_codegen.js +22 -236
  89. package/dist/Misc/graphql_server_codegen.js.map +1 -1
  90. package/dist/Misc/sql_logging.d.ts +25 -0
  91. package/dist/Misc/sql_logging.d.ts.map +1 -1
  92. package/dist/Misc/sql_logging.js +51 -8
  93. package/dist/Misc/sql_logging.js.map +1 -1
  94. package/package.json +30 -30
@@ -1,11 +1,17 @@
1
+ import { SQLServerDialect, PostgreSQLDialect } from '@memberjunction/sql-dialect';
1
2
  import { CodeGenDatabaseProvider } from './codeGenDatabaseProvider.js';
3
+ import { analyzeQueryForMaterialization, detectAggregationKeyColumns, detectAdditiveMeasures, MATERIALIZATION_SURROGATE_COLUMN } from './materializationAnalysis.js';
4
+ import { evaluateMaterializationDrift } from './materializationDrift.js';
5
+ import { classifyQueryParameters, buildHeldValues } from './materializationParamClassifier.js';
6
+ import { buildBroadRowFilterSQL } from './materializationBroadRender.js';
7
+ import { QueryParameterProcessor } from '@memberjunction/query-processor';
2
8
  // Side-effect import — registers `SQLServerCodeGenProvider` with `MJGlobal.ClassFactory`
3
9
  // under the `'sqlserver'` key via its `@RegisterClass` decorator. Without this import,
4
10
  // `ClassFactory.CreateInstance(CodeGenDatabaseProvider, 'sqlserver')` returns nothing
5
11
  // and the SS code path silently fails.
6
12
  import './providers/sqlserver/SQLServerCodeGenProvider.js';
7
13
  import { configInfo, currentWorkingDirectory, dbPlatform, getSettingValue, mj_core_schema, outputDir } from '../Config/config.js';
8
- import { EntityInfo, ExternalDataSourceReadRouter, ExtractActualDefaultValue, LogError, LogStatus, Metadata, SeverityType } from "@memberjunction/core";
14
+ import { CodeNameFromString, EntityInfo, ExternalDataSourceReadRouter, ExtractActualDefaultValue, LogError, LogStatus, Metadata, RunQuerySQLFilterManager, SeverityType } from "@memberjunction/core";
9
15
  import { MJEntityFieldSchema } from "@memberjunction/core-entities";
10
16
  import { logError, logMessage, logStatus, startSpinner, updateSpinner, succeedSpinner } from "../Misc/status_logging.js";
11
17
  import { SQLUtilityBase } from "./sql.js";
@@ -15,7 +21,7 @@ import { CodeGenReporter } from "../Misc/codegen-reporter.js";
15
21
  import { applySearchableFieldsCap, entityLevelEnableBlockedReason, isNarrativeFieldName, normalizePredicate, normalizeSmartFieldResultShape, } from "./search-guardrails.js";
16
22
  import { mapExternalNativeTypeToMJ } from "../Misc/externalTypeMapping.js";
17
23
  import { SQLParser } from "@memberjunction/sql-parser";
18
- import { createDisplayName, generatePluralName, MJGlobal, stripTrailingChars, UUIDsEqual } from "@memberjunction/global";
24
+ import { createDisplayName, generatePluralName, MJGlobal, ResolveSingleEntityResourceTarget, stripTrailingChars, UUIDsEqual } from "@memberjunction/global";
19
25
  import { v4 as uuidv4 } from 'uuid';
20
26
  import * as fs from 'fs';
21
27
  import path from 'path';
@@ -48,22 +54,30 @@ export class ManageMetadataBase {
48
54
  // ─── End Dialect Infrastructure ───────────────────────────────────
49
55
  this._sqlUtilityObject = MJGlobal.Instance.ClassFactory.CreateInstance(SQLUtilityBase);
50
56
  /**
51
- * External-data-source analogue of {@link manageVirtualEntities}. For each entity backed by an
52
- * external data source, introspect the REMOTE schema (via the EDS router resolved through the
53
- * ClassFactory) and sync its `EntityField` rows to match — the remote equivalent of reading the
54
- * local INFORMATION_SCHEMA for a virtual/view entity. No-op when there are no external entities.
57
+ * True if the MaterializedResult table exists. createNewEntities and detectMaterializationDrift gate their
58
+ * references to it on this so codegen doesn't throw on a DB lacking the materialization schema. Cached per run.
55
59
  */
60
+ this._materializedResultTableExists = null;
56
61
  /**
57
- * Whether the current database's `Entity` table has the `ExternalDataSourceID` column. External
58
- * Data Sources ship as a SQL Server migration only, so on any database/schema that predates it
59
- * (PostgreSQL today) the column is absent — and ANY raw SQL referencing it throws
60
- * "column does not exist" and aborts the entire CodeGen run, even for a pure MJ-DB schema with no
61
- * external entities. Callers gate EDS-specific queries on this so CodeGen stays green everywhere
62
- * and auto-activates once the column lands on other platforms (mirrors the PG stored-proc guard in
63
- * metadataSupportObjects.ts). Cross-platform via INFORMATION_SCHEMA; cached for the run (the schema
64
- * cannot change mid-run).
62
+ * Per-run cache of the entity NAMES (trimmed + lowercased) that an API-KEY row filter
63
+ * (`APIKeyScope.RowFilterID`) or an API-APPLICATION row ceiling (`APIApplicationScope.RowFilterID`) binds to.
64
+ *
65
+ * `'unknown'` — the INITIAL value — means "not loaded, or could not be enumerated", and makes
66
+ * {@link entityHasRowLevelRestriction} treat EVERY entity as row-restricted. That is the fail-CLOSED
67
+ * direction on purpose: refusing to materialize costs nothing (the entity/query stays live-only), while
68
+ * materializing an entity whose API-key row filter we failed to see is the precise leak these gates exist to
69
+ * prevent. Loaded by {@link loadAPIKeyRowFilterTargets} at the top of every materialization entry point.
70
+ * `protected` (not `private`) so a unit-test seam can declare "this fixture configures no API-key row filters".
65
71
  */
66
- this._entityHasExternalDataSourceColumn = null;
72
+ this.apiKeyRowFilterTargets = 'unknown';
73
+ /**
74
+ * THE cached INFORMATION_SCHEMA column-existence probe against the MJ core schema — cross-platform, and
75
+ * cached for the run (the schema cannot change mid-run). Every column gate in this class routes through
76
+ * it: {@link entityHasExternalDataSourceColumn}, {@link queryHasExternalDataSourceColumn},
77
+ * {@link queryHasIsMaterializedColumn} and {@link loadAPIKeyRowFilterTargets} are each a one-line call,
78
+ * so a new gate has no reason to hand-roll a fourth copy of the same query and its own cache field.
79
+ */
80
+ this._coreSchemaColumnExists = new Map();
67
81
  }
68
82
  /**
69
83
  * Returns the CodeGenDatabaseProvider for the current database platform.
@@ -398,6 +412,22 @@ export class ManageMetadataBase {
398
412
  ForeignKeys: Array.isArray(ve.ForeignKeys) ? ve.ForeignKeys : undefined,
399
413
  }));
400
414
  }
415
+ /**
416
+ * Extracts the MaterializedBaseViews array from the additionalSchemaInfo config file.
417
+ * Each entry declares a 1:1 base-view materialization of an existing entity (plan §4.1).
418
+ */
419
+ extractMaterializedBaseViewsFromConfig(config) {
420
+ const decls = config.MaterializedBaseViews;
421
+ if (!Array.isArray(decls))
422
+ return [];
423
+ return decls.map((d) => ({
424
+ EntityName: d.EntityName || undefined,
425
+ BaseTable: d.BaseTable || undefined,
426
+ SchemaName: d.SchemaName || undefined,
427
+ RefreshSchedule: d.RefreshSchedule || undefined,
428
+ IntendedWorkload: d.IntendedWorkload || undefined,
429
+ }));
430
+ }
401
431
  /**
402
432
  * Extracts ISARelationships array from the additionalSchemaInfo config file.
403
433
  * The config may contain a top-level "ISARelationships" key with an array of
@@ -657,6 +687,34 @@ export class ManageMetadataBase {
657
687
  }
658
688
  return { success: true, createdCount, updatedCount };
659
689
  }
690
+ /**
691
+ * Builds the entity lookup behind {@link processISARelationshipConfig}: match on Name first, else
692
+ * on BaseTable, optionally constrained to a schema.
693
+ *
694
+ * The schema predicate is composed CONDITIONALLY rather than written as
695
+ * `(@SchemaName IS NULL OR SchemaName = @SchemaName)`. That form is fatal on PostgreSQL: the
696
+ * parameter's only unambiguous use is `$n IS NULL`, which gives the planner no type to infer, so
697
+ * the whole statement fails to prepare with "could not determine data type of parameter $n".
698
+ * SQL Server infers the type from the other side of the OR and never saw the problem.
699
+ *
700
+ * The failure was silent in the worst way — processISARelationshipConfig catches per-relationship
701
+ * errors and logs them, so CodeGen ran to completion with a zero exit code while every declared
702
+ * IS-A relationship on PostgreSQL was quietly skipped, leaving Entity.ParentID NULL. Downstream
703
+ * that means no mirrored parent fields, no parent JOIN in the child base view, and a child whose
704
+ * Save() never writes the parent row.
705
+ *
706
+ * Emitting the predicate only when a schema was supplied keeps the parameter list free of
707
+ * type-ambiguous entries and is portable to both dialects without a cast.
708
+ */
709
+ buildISAEntityLookupSQL(schema, selectBody, nameParam, schemaName) {
710
+ const schemaPredicate = schemaName ? ` AND SchemaName = @ISASchemaName` : '';
711
+ const sql = `
712
+ ${this.selectTop(1, selectBody, `FROM ${this.qs(schema, 'vwEntities')}
713
+ WHERE Name = @${nameParam}
714
+ OR (BaseTable = @${nameParam}${schemaPredicate})`, `CASE WHEN Name = @${nameParam} THEN 0 ELSE 1 END`)}
715
+ `;
716
+ return { sql, params: schemaName ? { 'ISASchemaName': schemaName } : {} };
717
+ }
660
718
  /**
661
719
  * Processes IS-A relationship configurations from the additionalSchemaInfo config.
662
720
  * For each configured relationship, looks up both entities by name (or by table name
@@ -675,11 +733,11 @@ export class ManageMetadataBase {
675
733
  for (const rel of relationships) {
676
734
  try {
677
735
  // Look up the parent entity — try by Name first, then by BaseTable within the given schema
678
- const parentResult = await this.runQueryWithParams(pool, `
679
- ${this.selectTop(1, 'ID, Name', `FROM ${this.qs(schema, 'vwEntities')}
680
- WHERE Name = @ParentName
681
- OR (BaseTable = @ParentName AND (@SchemaName IS NULL OR SchemaName = @SchemaName))`, 'CASE WHEN Name = @ParentName THEN 0 ELSE 1 END')}
682
- `, { 'ParentName': rel.ParentEntity, 'SchemaName': rel.SchemaName || null });
736
+ const parentLookup = this.buildISAEntityLookupSQL(schema, 'ID, Name', 'ParentName', rel.SchemaName);
737
+ const parentResult = await this.runQueryWithParams(pool, parentLookup.sql, {
738
+ 'ParentName': rel.ParentEntity,
739
+ ...parentLookup.params,
740
+ });
683
741
  if (parentResult.recordset.length === 0) {
684
742
  logError(` > IS-A config: parent entity "${rel.ParentEntity}" not found — skipping`);
685
743
  continue;
@@ -687,11 +745,11 @@ export class ManageMetadataBase {
687
745
  const parentId = parentResult.recordset[0].ID;
688
746
  const parentName = parentResult.recordset[0].Name;
689
747
  // Look up the child entity — same strategy
690
- const childResult = await this.runQueryWithParams(pool, `
691
- ${this.selectTop(1, 'ID, Name, ParentID', `FROM ${this.qs(schema, 'vwEntities')}
692
- WHERE Name = @ChildName
693
- OR (BaseTable = @ChildName AND (@SchemaName IS NULL OR SchemaName = @SchemaName))`, 'CASE WHEN Name = @ChildName THEN 0 ELSE 1 END')}
694
- `, { 'ChildName': rel.ChildEntity, 'SchemaName': rel.SchemaName || null });
748
+ const childLookup = this.buildISAEntityLookupSQL(schema, 'ID, Name, ParentID', 'ChildName', rel.SchemaName);
749
+ const childResult = await this.runQueryWithParams(pool, childLookup.sql, {
750
+ 'ChildName': rel.ChildEntity,
751
+ ...childLookup.params,
752
+ });
695
753
  if (childResult.recordset.length === 0) {
696
754
  logError(` > IS-A config: child entity "${rel.ChildEntity}" not found — skipping`);
697
755
  continue;
@@ -951,6 +1009,695 @@ export class ManageMetadataBase {
951
1009
  }
952
1010
  return { success: true, createdCount };
953
1011
  }
1012
+ /**
1013
+ * Processes base-view materialization declarations from additionalSchemaInfo (plan §4.1).
1014
+ * Each declares a 1:1 snapshot of an existing entity's base view. Because the shape is
1015
+ * identical to the source entity, NO new entity is minted — the existing entity is reused
1016
+ * (its RLS applies unchanged, §6.1). For each declaration this:
1017
+ * 1. resolves the source entity,
1018
+ * 2. derives the column shape from the entity's base-view fields (SQLFullType),
1019
+ * 3. emits the physical table (materialized_<Name>) + wrapper view (materialized_vw<Name>)
1020
+ * via the cross-engine DDL primitive (create-if-absent, so a migration-provided table
1021
+ * is reused rather than clobbered — §12),
1022
+ * 4. upserts the "MJ: Materialized Results" row (SourceType='EntityBaseView') keyed on the
1023
+ * source entity.
1024
+ * Population is the refresh driver's job (a later phase); rows land in Status='Building'.
1025
+ */
1026
+ async processBaseViewMaterializations(pool) {
1027
+ const config = ManageMetadataBase.getSoftPKFKConfig();
1028
+ if (!config)
1029
+ return { success: true, processedCount: 0 };
1030
+ const decls = this.extractMaterializedBaseViewsFromConfig(config);
1031
+ if (decls.length === 0)
1032
+ return { success: true, processedCount: 0 };
1033
+ // Gate on the MaterializedResult table existing. Base-view materializations are DECLARED in config, but the
1034
+ // target DB may not have the materialization migration applied yet (e.g. the PostgreSQL parallel world, or a
1035
+ // fresh env sharing a config). The MaterializedResult upsert below would otherwise throw and abort the whole
1036
+ // codegen pass. Unlike the unconditional gates on the sibling methods this one logs, because the user asked
1037
+ // for these materializations — a silent skip would look like the feature quietly did nothing.
1038
+ if (!(await this.materializedResultTableExists(pool))) {
1039
+ logStatus(` > Base-view materialization: ${decls.length} declaration${decls.length === 1 ? '' : 's'} in config but the MaterializedResult table is absent on this database — skipping (has the materialization migration been applied?)`);
1040
+ return { success: true, processedCount: 0 };
1041
+ }
1042
+ // Enumerate the API-key row-filter layer BEFORE any leak gate below evaluates it. Until this runs the cache
1043
+ // reads 'unknown' and every entity is treated as row-restricted (fail closed) — see loadAPIKeyRowFilterTargets.
1044
+ await this.loadAPIKeyRowFilterTargets(pool);
1045
+ // Refresh so entity.Fields reflect this run's field sync before we snapshot the shape.
1046
+ const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
1047
+ await md.Refresh();
1048
+ const coreSchema = mj_core_schema();
1049
+ const esc = (s) => s.replace(/'/g, "''");
1050
+ const lit = (s) => (s ? `'${esc(s)}'` : 'NULL');
1051
+ let processedCount = 0;
1052
+ for (const decl of decls) {
1053
+ // 1) Resolve the source entity (by name, else by base table [+ schema]).
1054
+ const entity = decl.EntityName
1055
+ ? md.EntityByName(decl.EntityName)
1056
+ : md.Entities.find((e) => !!decl.BaseTable &&
1057
+ (e.BaseTable ?? '').trim().toLowerCase() === decl.BaseTable.trim().toLowerCase() &&
1058
+ (!decl.SchemaName || (e.SchemaName ?? '').trim().toLowerCase() === decl.SchemaName.trim().toLowerCase()));
1059
+ if (!entity) {
1060
+ logError(` > Base-view materialization: source entity not found (${decl.EntityName ?? `${decl.SchemaName ?? coreSchema}.${decl.BaseTable}`}) — skipping`);
1061
+ continue;
1062
+ }
1063
+ // SECURITY (Leak 1 / C2 for base views): refuse to materialize an EXTERNAL entity that is
1064
+ // read-RLS-protected. An external entity's live reads are REFUSED under RLS (the read path throws —
1065
+ // MJ can't enforce RLS WHERE clauses on a remote system), and for external entities the read path
1066
+ // returns at the external-dispatch branch BEFORE any materialized-view swap — so the "base-view reads
1067
+ // re-apply source RLS" exemption is unreachable here. Mirroring the remote rows into a local table
1068
+ // would expose, via any raw query joining the wrapper view, data the live path refuses entirely.
1069
+ // (A LOCAL RLS base-view is safe: its read path swaps to the mirror and re-applies the entity's RLS.)
1070
+ if (entity.ExternalDataSourceID && this.entityHasRowLevelRestriction(entity)) {
1071
+ logError(` > REFUSING base-view materialization of external entity "${entity.Name}": it is row-restricted (role RLS and/or an API-key row filter) and its live reads are refused under RLS, so a local mirror would leak its rows via raw queries over the wrapper view (a mirror cannot reproduce remote row restrictions). Skipping.`);
1072
+ continue;
1073
+ }
1074
+ // Schema-name symmetry with the refresh path: MaterializationRefresher validates schema/table/view names
1075
+ // as plain SQL identifiers (^[A-Za-z_][A-Za-z0-9_]*$) before building any DDL. The mint quotes at the
1076
+ // dialect level and would otherwise accept a schema name (e.g. "Sales Data", "crm-prod") the refresh then
1077
+ // permanently rejects — the entity would provision cleanly and never refresh (a silent nightly failure).
1078
+ // Refuse up front, symmetrically, with a clear message. (TableName/ViewName are CodeName-derived and
1079
+ // always identifier-safe, so only the source entity's schema can be non-conforming here.)
1080
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(entity.SchemaName ?? '')) {
1081
+ logError(` > REFUSING base-view materialization of entity "${entity.Name}": its schema "${entity.SchemaName}" is not a plain SQL identifier — the refresh path requires one, so materialization is only supported for entities in identifier-named schemas. Skipping.`);
1082
+ continue;
1083
+ }
1084
+ // 2) Derive the snapshot column shape from the entity's base-view fields.
1085
+ // For EXTERNAL entities, virtual fields are MJ-computed and absent from the remote source, so the
1086
+ // refresh mirror (rebuildFromExternalEntity) only materializes non-virtual columns — the mint column
1087
+ // set MUST match, or the wrapper view would expose virtual columns the refreshed table lacks (breaking
1088
+ // virtual-field reads at the DB AND permanently tripping DriftHold on the next drift scan). LOCAL base
1089
+ // views compute virtual columns in the view itself, so `SELECT * FROM <baseView>` includes them and the
1090
+ // mint keeps all fields — the mint/refresh column sets stay symmetric on both paths.
1091
+ const columns = this.materializedEntityFields(entity)
1092
+ .map((f) => ({
1093
+ Name: f.Name,
1094
+ SQLType: f.SQLFullType,
1095
+ Nullable: f.AllowsNull,
1096
+ IsPrimaryKey: f.IsPrimaryKey,
1097
+ }));
1098
+ if (columns.length === 0) {
1099
+ logError(` > Base-view materialization for "${entity.Name}": no fields resolved — skipping`);
1100
+ continue;
1101
+ }
1102
+ // 3) Emit the physical table + wrapper view (create-if-absent; reuse a migration-provided table).
1103
+ const matSchema = entity.SchemaName;
1104
+ const tableName = `materialized_${entity.CodeName}`;
1105
+ const viewName = `materialized_vw${entity.CodeName}`;
1106
+ const tableSQL = this.dbProvider.generateMaterializedTableSQL(matSchema, tableName, columns);
1107
+ const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(matSchema, viewName, tableName);
1108
+ // includeBatchSeparator: each is a single GO-free batch (executed via ds.query), but the
1109
+ // migration file needs a GO between statements for Flyway/sqlcmd.
1110
+ await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for base-view materialization of entity ${entity.Name}`, false, true);
1111
+ await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for base-view materialization of entity ${entity.Name}`, false, true);
1112
+ // 4) Upsert the MJ: Materialized Results row, keyed on (SourceType, SourceEntityID).
1113
+ const existing = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'MaterializedResult')} WHERE SourceType = @SourceType AND SourceEntityID = @EntityID`, { SourceType: 'EntityBaseView', EntityID: entity.ID });
1114
+ if (existing.recordset.length > 0) {
1115
+ const sqlUpd = `UPDATE ${this.qs(coreSchema, 'MaterializedResult')}
1116
+ SET SchemaName='${esc(matSchema)}', TableName='${esc(tableName)}', ViewName='${esc(viewName)}',
1117
+ RefreshSchedule=${lit(decl.RefreshSchedule)}, IntendedWorkload=${lit(decl.IntendedWorkload)}
1118
+ WHERE ID='${existing.recordset[0].ID}'`;
1119
+ await this.LogSQLAndExecute(pool, sqlUpd, `Update MJ: Materialized Results for base-view materialization of ${entity.Name}`);
1120
+ }
1121
+ else {
1122
+ const newId = this.createNewUUID();
1123
+ const q = (n) => this.qi(n);
1124
+ const sqlIns = `INSERT INTO ${this.qs(coreSchema, 'MaterializedResult')} (
1125
+ ${q('ID')}, ${q('SourceType')}, ${q('SourceEntityID')}, ${q('SchemaName')}, ${q('TableName')}, ${q('ViewName')},
1126
+ ${q('ParamMode')}, ${q('RefreshStrategy')}, ${q('RefreshSchedule')}, ${q('Status')}, ${q('IntendedWorkload')},
1127
+ ${q('__mj_CreatedAt')}, ${q('__mj_UpdatedAt')} )
1128
+ VALUES ( '${newId}', 'EntityBaseView', '${entity.ID}', '${esc(matSchema)}', '${esc(tableName)}', '${esc(viewName)}',
1129
+ 'None', 'FullRebuild', ${lit(decl.RefreshSchedule)}, 'Building', ${lit(decl.IntendedWorkload)},
1130
+ ${this.utcNow()}, ${this.utcNow()} )`;
1131
+ await this.LogSQLAndExecute(pool, sqlIns, `Insert MJ: Materialized Results for base-view materialization of ${entity.Name}`);
1132
+ }
1133
+ processedCount++;
1134
+ logStatus(` > Base-view materialization ready for entity "${entity.Name}" → [${matSchema}].[${viewName}]`);
1135
+ }
1136
+ return { success: true, processedCount };
1137
+ }
1138
+ /**
1139
+ * Phase 2d — classifies a parameterized query's parameters (deterministic render-and-diff) and,
1140
+ * when every parameter is a safe row filter, produces the BROAD source SQL the refresh engine
1141
+ * materializes (the query with its row-filter WHERE predicates removed). Returns the persistence
1142
+ * fields, or a precise refusal (per-parameter reasons) so non-qualifying queries are skipped loudly.
1143
+ */
1144
+ async classifyParameterizedQueryForMaterialization(pool, queryId, queryName, outputColumns, currentUser) {
1145
+ const coreSchema = mj_core_schema();
1146
+ const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
1147
+ // Load the query template + parameter definitions (typed entities for the renderer).
1148
+ const qRes = await this.runQueryWithParams(pool, `SELECT SQL, UsesTemplate FROM ${this.qs(coreSchema, 'Query')} WHERE ID = @QID`, { QID: queryId });
1149
+ const sqlText = qRes.recordset[0]?.SQL ?? '';
1150
+ const usesTemplate = !!qRes.recordset[0]?.UsesTemplate;
1151
+ // NB: QueryParameter has no Sequence column (unlike QueryField) — order by Name for deterministic,
1152
+ // stable classification output. (Ordering only affects the persisted spec's element order; the
1153
+ // read-time predicate is an AND of all entries, so it's order-independent for correctness.)
1154
+ const paramRows = await this.runQueryWithParams(pool, `SELECT Name, Type, SampleValue, DefaultValue, IsRequired, ValidationFilters FROM ${this.qs(coreSchema, 'QueryParameter')} WHERE QueryID = @QID ORDER BY Name`, { QID: queryId });
1155
+ const queryParams = [];
1156
+ for (const row of paramRows.recordset) {
1157
+ const pe = await md.GetEntityObject('MJ: Query Parameters', currentUser);
1158
+ pe.LoadFromData(row);
1159
+ queryParams.push(pe);
1160
+ }
1161
+ // Platform-aware Nunjucks renderer — the same SQL-safe filters production uses.
1162
+ const platform = this.dbProvider.PlatformKey;
1163
+ RunQuerySQLFilterManager.Instance.SetPlatform(platform);
1164
+ const dialect = platform === 'postgresql' ? new PostgreSQLDialect() : new SQLServerDialect();
1165
+ const templateInput = { SQL: sqlText, UsesTemplate: usesTemplate, Parameters: queryParams };
1166
+ const render = (vals) => {
1167
+ const res = QueryParameterProcessor.processQueryTemplate(templateInput, vals, undefined, true);
1168
+ if (!res.success) {
1169
+ throw new Error(res.error ?? 'template render failed');
1170
+ }
1171
+ return res.processedSQL;
1172
+ };
1173
+ const paramDefs = queryParams.map((p) => ({ Name: p.Name, Type: p.Type, SampleValue: p.SampleValue }));
1174
+ // Phase 2 is shipped: enable Bucket-1 row-filter broad materialization. The provider now auto-injects
1175
+ // the read-time predicate from the persisted ReadFilterSpec (bound params), so a RowFilterBroad
1176
+ // materialization is safe to mint. `allowRowFilterBroad` remains the build-level kill switch.
1177
+ const classification = classifyQueryParameters({ queryName, params: paramDefs, outputColumns, dialect, render, allowRowFilterBroad: true });
1178
+ if (!classification.qualification.qualifies) {
1179
+ const detail = classification.perParam.map((pp) => `${pp.name}: ${pp.verdict.reason}`).join('; ');
1180
+ return { qualifies: false, reason: `${classification.qualification.reason ?? 'parameters not materializable'}${detail ? ` — [${detail}]` : ''}` };
1181
+ }
1182
+ if (classification.qualification.paramMode !== 'RowFilterBroad') {
1183
+ return { qualifies: false, reason: `parameterization mode '${classification.qualification.paramMode}' is not supported for materialization in v1 (only RowFilterBroad)` };
1184
+ }
1185
+ // Build the broad source SQL: render a concrete instance (held values) then strip the row-filter predicates.
1186
+ let renderedHeld;
1187
+ try {
1188
+ renderedHeld = render(buildHeldValues(paramDefs));
1189
+ }
1190
+ catch (e) {
1191
+ return { qualifies: false, reason: `could not render the query to build broad SQL: ${e instanceof Error ? e.message : String(e)}` };
1192
+ }
1193
+ const expectedRemovals = classification.qualification.rowFilterColumns.length;
1194
+ const broad = buildBroadRowFilterSQL(renderedHeld, classification.qualification.rowFilterColumns, dialect, expectedRemovals);
1195
+ if (broad.removedCount === 0) {
1196
+ return { qualifies: false, reason: `expected to strip row-filter predicate(s) on [${classification.qualification.rowFilterColumns.join(', ')}] but none were removed from the rendered SQL — refusing to avoid a wrongly-filtered materialization` };
1197
+ }
1198
+ if (broad.ambiguous) {
1199
+ return { qualifies: false, reason: `expected to strip exactly ${expectedRemovals} row-filter parameter predicate(s) on [${classification.qualification.rowFilterColumns.join(', ')}] but matched ${broad.removedCount} — the parameter predicate(s) cannot be cleanly isolated from other static or same-named predicates on those columns, so a broad materialization would include or exclude rows the live query never would. Refusing (query stays live-only).` };
1200
+ }
1201
+ return { qualifies: true, rowFilterColumns: classification.qualification.rowFilterColumns, broadSQL: broad.sql, readFilterSpec: classification.qualification.readFilterSpec };
1202
+ }
1203
+ /**
1204
+ * Processes query materialization (CodeGen materialization phase, sub-step C — plan §4.2).
1205
+ * Scans queries flagged `IsMaterialized = 1` and, for each that *qualifies* (unparameterized,
1206
+ * has declared output fields — see analyzeQueryForMaterialization, §9/§10):
1207
+ * 1. derives the materialized table's column shape + synthetic surrogate key,
1208
+ * 2. emits the physical table (materialized_<Name>) + wrapper view (materialized_vw<Name>)
1209
+ * via the cross-engine DDL primitive,
1210
+ * 3. mints a read-only Virtual Entity over the wrapper view (idempotent — reuses an existing
1211
+ * one), linked back to the source Query,
1212
+ * 4. upserts the "MJ: Materialized Results" row (SourceType='Query') and its
1213
+ * MaterializedResultQuery join row linking it to the source Query (no direct
1214
+ * SourceQueryID / MaterializedResultID FK columns — the join table breaks the cycle).
1215
+ * Runs BEFORE manageVirtualEntities so the minted entity's fields get synced from the wrapper
1216
+ * view in the same run. Population is the refresh driver's job (later phase); Status='Building'.
1217
+ * Parameterized queries are skipped with a log (deferred to Phase 2).
1218
+ */
1219
+ async processQueryMaterializations(pool, currentUser) {
1220
+ const coreSchema = mj_core_schema();
1221
+ // Gate the whole method on the IsMaterialized column existing — the flagged-query SELECT below filters on it,
1222
+ // and it (like the MaterializedResult table) is added by the materialization Foundation migration. On a DB
1223
+ // where that migration hasn't run yet the SELECT would throw and abort the entire codegen pass; with no such
1224
+ // column there are no materializations to process anyway. Same defensive pattern as the EDS-column gate below.
1225
+ if (!(await this.queryHasIsMaterializedColumn(pool)))
1226
+ return { success: true, processedCount: 0, mintedCount: 0 };
1227
+ // Gate the ExternalDataSourceID column ref so the SELECT stays valid on a DB without the EDS schema
1228
+ // (PostgreSQL today). When absent, no query can be external, so the flag defaults false everywhere.
1229
+ const queryHasEDSCol = await this.queryHasExternalDataSourceColumn(pool);
1230
+ const flagged = await this.runQueryWithParams(pool, `SELECT ID, Name, SQL${queryHasEDSCol ? ', ExternalDataSourceID' : ''} FROM ${this.qs(coreSchema, 'Query')} WHERE IsMaterialized = ${this.boolLit(true)}`, {});
1231
+ if (flagged.recordset.length === 0)
1232
+ return { success: true, processedCount: 0, mintedCount: 0 };
1233
+ // Symmetry with the three sibling gates: the per-query loop below also reads/writes the MaterializedResult
1234
+ // TABLE (not just the IsMaterialized column). Don't lean on the "same Foundation migration ships both"
1235
+ // coupling — confirm the table itself exists. If a query is flagged but the table is absent (partial schema),
1236
+ // skip cleanly instead of throwing mid-loop and aborting the whole codegen pass.
1237
+ if (!(await this.materializedResultTableExists(pool))) {
1238
+ logStatus(` > Query materialization: ${flagged.recordset.length} query(ies) flagged IsMaterialized but the MaterializedResult table is absent on this database — skipping (has the materialization migration been applied?)`);
1239
+ return { success: true, processedCount: 0, mintedCount: 0 };
1240
+ }
1241
+ // Enumerate the API-key row-filter layer BEFORE the mint-time RLS gate consults it (see
1242
+ // loadAPIKeyRowFilterTargets — an unloaded cache reads 'unknown' and refuses every materialization).
1243
+ await this.loadAPIKeyRowFilterTargets(pool);
1244
+ const esc = (s) => s.replace(/'/g, "''");
1245
+ const idLit = (id) => (id ? `'${id}'` : 'NULL');
1246
+ const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
1247
+ let processedCount = 0;
1248
+ let mintedCount = 0;
1249
+ for (const q of flagged.recordset) {
1250
+ const queryId = q.ID;
1251
+ const queryName = q.Name;
1252
+ const querySQL = q.SQL ?? '';
1253
+ // Qualifying inputs: parameterized? declared output fields?
1254
+ const paramCountRes = await this.runQueryWithParams(pool, `SELECT COUNT(*) AS n FROM ${this.qs(coreSchema, 'QueryParameter')} WHERE QueryID = @QID`, { QID: queryId });
1255
+ const isParameterized = (paramCountRes.recordset[0]?.n ?? 0) > 0;
1256
+ const fieldsRes = await this.runQueryWithParams(pool, `SELECT Name, SQLFullType, IsComputed FROM ${this.qs(coreSchema, 'QueryField')} WHERE QueryID = @QID ORDER BY Sequence`, { QID: queryId });
1257
+ const fields = fieldsRes.recordset.map((r) => ({ Name: r.Name, SQLFullType: r.SQLFullType, IsComputed: !!r.IsComputed }));
1258
+ // Parameterized queries (Phase 2d): classify via deterministic render-and-diff. A query whose
1259
+ // parameters are all safe row filters materializes BROAD (the row-filter predicate is removed)
1260
+ // and the filter is re-applied at read; anything not provably a safe row filter is refused with
1261
+ // a precise reason. Unparameterized queries skip this entirely.
1262
+ let paramMode = 'None';
1263
+ let rowFilterColumns = [];
1264
+ let broadSQL = null;
1265
+ let readFilterSpec = [];
1266
+ if (isParameterized) {
1267
+ const pc = await this.classifyParameterizedQueryForMaterialization(pool, queryId, queryName, fields.map((f) => f.Name), currentUser);
1268
+ if (!pc.qualifies) {
1269
+ logStatus(` > Skipping materialization of parameterized query "${queryName}": ${pc.reason}`);
1270
+ continue;
1271
+ }
1272
+ paramMode = 'RowFilterBroad';
1273
+ rowFilterColumns = pc.rowFilterColumns;
1274
+ broadSQL = pc.broadSQL;
1275
+ readFilterSpec = pc.readFilterSpec;
1276
+ }
1277
+ // Phase 3: detect an aggregation key (the grouping columns) so the refresh can compute a stable
1278
+ // hash surrogate (the match key for incremental / dirty-group refresh) instead of a synthetic
1279
+ // row id. Detect against the BROAD SQL for a row-filter query (the actually-materialized shape),
1280
+ // else the raw query SQL. NULL = not keyed → synthetic surrogate + full rebuild (always correct).
1281
+ // Detected BEFORE the shape analysis so the surrogate column can be typed for the keyed case.
1282
+ const detectSQL = paramMode === 'RowFilterBroad' ? (broadSQL ?? '') : querySQL;
1283
+ const matDialect = this.dbProvider.PlatformKey === 'postgresql' ? new PostgreSQLDialect() : new SQLServerDialect();
1284
+ const detectedKeyColumns = detectSQL ? detectAggregationKeyColumns({ sql: detectSQL, dialect: matDialect, fields }) : null;
1285
+ // An EXTERNAL query refreshes via rebuildFromExternalQuery, which fetches all rows through the EDS
1286
+ // driver and writes a SYNTHETIC row-index surrogate — it cannot compute the per-group combined-key
1287
+ // hash and ignores KeyColumns entirely. So minting a keyed (hash-surrogate) entity for an external
1288
+ // query would type the PK varchar/text while the refresh fills it with int row indices (a type +
1289
+ // semantics mismatch). Force such queries un-keyed → synthetic surrogate + FullRebuild, which the
1290
+ // external refresh path supports. (Incremental keyed refresh needs a local __mj_UpdatedAt watermark
1291
+ // the EDS router doesn't expose anyway, so nothing is lost.)
1292
+ const isExternalQuery = queryHasEDSCol && !!q.ExternalDataSourceID;
1293
+ const keyColumns = isExternalQuery ? null : detectedKeyColumns;
1294
+ if (isExternalQuery && detectedKeyColumns && detectedKeyColumns.length > 0) {
1295
+ logStatus(` > Query "${queryName}" is external + aggregation-keyed; the external refresh path uses a synthetic row-index surrogate, so materializing it un-keyed (FullRebuild) to keep the PK type/value consistent.`);
1296
+ }
1297
+ const isKeyed = !!keyColumns && keyColumns.length > 0;
1298
+ const keyColumnsLit = isKeyed ? `'${esc(JSON.stringify(keyColumns))}'` : 'NULL';
1299
+ // Surrogate PK type MUST match what the refresh actually writes into it: a KEYED materialization's
1300
+ // refresh writes the combined-key SHA-256 hash (a fixed-width hex string), so the minted entity's
1301
+ // PK is typed as the hash type; an un-keyed one keeps the synthetic auto-identity. Typing it int
1302
+ // for a keyed materialization would diverge from the post-refresh varchar/text column (breaking PK
1303
+ // typing, keyset pagination, and record lookups against the minted entity).
1304
+ const surrogateSQLType = isKeyed
1305
+ ? this.dbProvider.getMaterializedHashSurrogateColumnType()
1306
+ : this.dbProvider.getMaterializedSurrogateColumnType();
1307
+ // The materialized table's column shape is the query's output columns — independent of
1308
+ // parameterization (a row-filter query is materialized broad over the same columns). Pass
1309
+ // isParameterized:false so the analyzer yields the shape; the param gate above already ran.
1310
+ const analysis = analyzeQueryForMaterialization({ queryName, isParameterized: false, fields, surrogateSQLType });
1311
+ if (!analysis.qualifies) {
1312
+ logStatus(` > Skipping materialization of query "${queryName}": ${analysis.reason}`);
1313
+ continue;
1314
+ }
1315
+ // SQL literals for the row-filter persistence columns (shared by the insert/update branches).
1316
+ const rowFilterColumnsLit = rowFilterColumns.length > 0 ? `'${esc(JSON.stringify(rowFilterColumns))}'` : 'NULL';
1317
+ const broadSQLLit = broadSQL ? `'${esc(broadSQL)}'` : 'NULL';
1318
+ // Phase 2: the structured read-filter spec the provider injects at read time (bound params).
1319
+ const readFilterSpecLit = readFilterSpec.length > 0 ? `'${esc(JSON.stringify(readFilterSpec))}'` : 'NULL';
1320
+ // Phase 3/4: a keyed aggregation over a SINGLE source table is eligible for incremental in-place
1321
+ // refresh (the refresher still guards at runtime: baseline watermark + delete detection).
1322
+ // Joins/multi-table sources can't be localized by one watermark → keep FullRebuild (§10 bias).
1323
+ // Among eligible ones, a PURELY ADDITIVE aggregation (only SUM/COUNT) uses the MERGE-upsert
1324
+ // 'Incremental' path (in-place, no row churn); non-additive uses 'DirtyGroupRecompute' (delete+insert).
1325
+ const isKeyedSingleSource = isKeyed && !!detectSQL && SQLParser.ExtractTableRefs(detectSQL, matDialect).length === 1;
1326
+ const refreshStrategy = !isKeyedSingleSource
1327
+ ? 'FullRebuild'
1328
+ : detectAdditiveMeasures(detectSQL)
1329
+ ? 'Incremental'
1330
+ : 'DirtyGroupRecompute';
1331
+ // RLS downgrade gate (§6.2, ships day one) — a materialized query entity does NOT inherit its
1332
+ // source entities' row-level security. Refuse to materialize when RLS-safety cannot be proven, to
1333
+ // prevent a silent privilege escalation. Asymmetric risk (§10): over-restriction is harmless; the
1334
+ // dangerous direction (protected → unprotected) must be loud. The SAME assessment is re-run on every
1335
+ // CodeGen pass by detectMaterializationDrift, so a source that LATER gains RLS holds the existing
1336
+ // materialization AND revokes its read access rather than leaking (see assessQuerySourceRLSSafety).
1337
+ const sourceLinks = await this.runQueryWithParams(pool, `SELECT EntityID FROM ${this.qs(coreSchema, 'vwQueryEntities')} WHERE QueryID = @QID`, { QID: queryId });
1338
+ const rlsVerdict = this.assessQuerySourceRLSSafety(md, sourceLinks.recordset.map((r) => r.EntityID), detectSQL);
1339
+ if (!rlsVerdict.safe) {
1340
+ logError(` > REFUSING to materialize query "${queryName}": ${rlsVerdict.reason}. Run query analysis so its source entities are linked, or use a base-view materialization (which inherits source RLS). Skipping to avoid a silent privilege escalation.`);
1341
+ continue;
1342
+ }
1343
+ // The resolved source entities (all guaranteed to resolve — the gate above fails closed otherwise)
1344
+ // scope the minted entity's read permissions to the INTERSECTION of source read access (C2). Without
1345
+ // this, the minted entity would receive broad config-default read permissions and a user who cannot
1346
+ // read a source entity could read its rows through the unscoped snapshot.
1347
+ const resolvedSourceEntities = sourceLinks.recordset
1348
+ .map((r) => md.EntityByID(r.EntityID))
1349
+ .filter((e) => !!e);
1350
+ // 2) Physical table + wrapper view (create-if-absent; reuse a migration-provided table — §12).
1351
+ const codeName = CodeNameFromString(queryName);
1352
+ const tableName = `materialized_${codeName}`;
1353
+ const viewName = `materialized_vw${codeName}`;
1354
+ // CodeName-collision guard (C3): two query names that normalize to the SAME CodeName (e.g. "Sales
1355
+ // Report" / "Sales-Report" / "sales report") derive the same physical table. Minting the second would
1356
+ // reuse the first's create-if-absent table (wrong columns) and its minted entity, then register a
1357
+ // second MaterializedResult pointing at the SAME table — two refreshers clobbering one snapshot from
1358
+ // different source SQL. Refuse if the derived table is already owned by a different materialization.
1359
+ const tableOwners = await this.runQueryWithParams(pool, `SELECT mr.SourceType, mrq.QueryID FROM ${this.qs(coreSchema, 'MaterializedResult')} mr
1360
+ LEFT JOIN ${this.qs(coreSchema, 'MaterializedResultQuery')} mrq ON mrq.MaterializedResultID = mr.ID
1361
+ WHERE mr.SchemaName = @S AND mr.TableName = @T`, { S: coreSchema, T: tableName });
1362
+ const foreignOwner = tableOwners.recordset.find((row) => !(row.SourceType === 'Query' && UUIDsEqual(row.QueryID, queryId)));
1363
+ if (foreignOwner) {
1364
+ logError(` > REFUSING to materialize query "${queryName}": its derived table [${coreSchema}].[${tableName}] (from CodeName "${codeName}") is already owned by a different materialization (SourceType=${foreignOwner.SourceType}, QueryID=${foreignOwner.QueryID ?? 'n/a'}). Two sources whose names normalize to the same CodeName would clobber each other's snapshot — rename one so their CodeNames differ. Skipping.`);
1365
+ continue;
1366
+ }
1367
+ const tableSQL = this.dbProvider.generateMaterializedTableSQL(coreSchema, tableName, analysis.columns);
1368
+ const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(coreSchema, viewName, tableName);
1369
+ await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for query "${queryName}"`, false, true);
1370
+ await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for query "${queryName}"`, false, true);
1371
+ // 3) Mint the read-only Virtual Entity over the wrapper view (idempotent by view).
1372
+ const existingVE = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'vwEntities')} WHERE BaseView = @V AND SchemaName = @S`, { V: viewName, S: coreSchema });
1373
+ let generatedEntityId = existingVE.recordset[0]?.ID;
1374
+ if (!generatedEntityId) {
1375
+ try {
1376
+ const createRes = await pool.executeStoredProcedure(`${this.qs(coreSchema, 'spCreateVirtualEntity')}`, {
1377
+ Name: queryName,
1378
+ BaseView: viewName,
1379
+ SchemaName: coreSchema,
1380
+ PrimaryKeyFieldName: analysis.surrogateColumnName,
1381
+ Description: `Materialized result of query "${queryName}".`,
1382
+ });
1383
+ generatedEntityId = createRes.recordset?.[0]?.[''] || createRes.recordset?.[0]?.ID || createRes.recordset?.[0]?.Column0;
1384
+ if (generatedEntityId) {
1385
+ mintedCount++;
1386
+ await this.addEntityToApplicationForSchema(pool, generatedEntityId, queryName, coreSchema, currentUser);
1387
+ await this.addMaterializedQueryEntityPermissions(pool, generatedEntityId, queryName, resolvedSourceEntities);
1388
+ logStatus(` > Minted materialized entity "${queryName}" (ID: ${generatedEntityId}) over [${coreSchema}].[${viewName}]`);
1389
+ }
1390
+ }
1391
+ catch (err) {
1392
+ logError(` > Failed to mint materialized entity for query "${queryName}": ${err instanceof Error ? err.message : String(err)}`);
1393
+ continue;
1394
+ }
1395
+ }
1396
+ // If the entity was neither pre-existing nor successfully minted-and-identified, we CANNOT safely link
1397
+ // a MaterializedResult — its GeneratedEntityID would be NULL and DataSource:'Materialized' reads could
1398
+ // never resolve it — and spCreateVirtualEntity may have created a physical entity we failed to read the
1399
+ // ID of (an unrecognized result shape). Fail loud and skip rather than silently writing a broken
1400
+ // MaterializedResult row; a human may need to check for an orphaned virtual entity over the view.
1401
+ if (!generatedEntityId) {
1402
+ logError(` > Could not resolve the entity ID for materialized query "${queryName}" (spCreateVirtualEntity returned an unrecognized result shape). Skipping to avoid a MaterializedResult with a NULL GeneratedEntityID; check for an orphaned virtual entity over [${coreSchema}].[${viewName}].`);
1403
+ continue;
1404
+ }
1405
+ // 4) Upsert the MJ: Materialized Results row and its MaterializedResultQuery join row. The MR<->Query
1406
+ // link lives in the join table — there is no MaterializedResult.SourceQueryID or
1407
+ // Query.MaterializedResultID column (those direct FKs formed a circular dependency CodeGen rejects;
1408
+ // the join table carries the relationship with both FKs pointing outward).
1409
+ const existing = await this.runQueryWithParams(pool, `SELECT mr.ID FROM ${this.qs(coreSchema, 'MaterializedResult')} mr
1410
+ JOIN ${this.qs(coreSchema, 'MaterializedResultQuery')} mrq ON mrq.MaterializedResultID = mr.ID
1411
+ WHERE mr.SourceType = @T AND mrq.QueryID = @QID`, { T: 'Query', QID: queryId });
1412
+ let matResultId;
1413
+ if (existing.recordset.length > 0) {
1414
+ matResultId = existing.recordset[0].ID;
1415
+ const sqlUpd = `UPDATE ${this.qs(coreSchema, 'MaterializedResult')}
1416
+ SET GeneratedEntityID=${idLit(generatedEntityId)}, SchemaName='${esc(coreSchema)}', TableName='${esc(tableName)}', ViewName='${esc(viewName)}',
1417
+ ${this.qi('ParamMode')}='${paramMode}', ${this.qi('RowFilterColumns')}=${rowFilterColumnsLit}, ${this.qi('BroadSQL')}=${broadSQLLit}, ${this.qi('ReadFilterSpec')}=${readFilterSpecLit}, ${this.qi('KeyColumns')}=${keyColumnsLit}, ${this.qi('RefreshStrategy')}='${refreshStrategy}'
1418
+ WHERE ID='${matResultId}'`;
1419
+ await this.LogSQLAndExecute(pool, sqlUpd, `Update MJ: Materialized Results for query "${queryName}"`);
1420
+ }
1421
+ else {
1422
+ matResultId = this.createNewUUID();
1423
+ const c = (n) => this.qi(n);
1424
+ const sqlIns = `INSERT INTO ${this.qs(coreSchema, 'MaterializedResult')} (
1425
+ ${c('ID')}, ${c('SourceType')}, ${c('GeneratedEntityID')}, ${c('SchemaName')}, ${c('TableName')}, ${c('ViewName')},
1426
+ ${c('ParamMode')}, ${c('RowFilterColumns')}, ${c('BroadSQL')}, ${c('ReadFilterSpec')}, ${c('KeyColumns')}, ${c('RefreshStrategy')}, ${c('Status')}, ${c('__mj_CreatedAt')}, ${c('__mj_UpdatedAt')} )
1427
+ VALUES ( '${matResultId}', 'Query', ${idLit(generatedEntityId)}, '${esc(coreSchema)}', '${esc(tableName)}', '${esc(viewName)}',
1428
+ '${paramMode}', ${rowFilterColumnsLit}, ${broadSQLLit}, ${readFilterSpecLit}, ${keyColumnsLit}, '${refreshStrategy}', 'Building', ${this.utcNow()}, ${this.utcNow()} )`;
1429
+ await this.LogSQLAndExecute(pool, sqlIns, `Insert MJ: Materialized Results for query "${queryName}"`);
1430
+ // Link the new materialization to its source Query via the join table.
1431
+ const joinId = this.createNewUUID();
1432
+ const sqlJoinIns = `INSERT INTO ${this.qs(coreSchema, 'MaterializedResultQuery')} (
1433
+ ${c('ID')}, ${c('MaterializedResultID')}, ${c('QueryID')}, ${c('__mj_CreatedAt')}, ${c('__mj_UpdatedAt')} )
1434
+ VALUES ( '${joinId}', '${matResultId}', '${queryId}', ${this.utcNow()}, ${this.utcNow()} )`;
1435
+ await this.LogSQLAndExecute(pool, sqlJoinIns, `Link MJ: Materialized Results to Query "${queryName}" via join row`);
1436
+ }
1437
+ processedCount++;
1438
+ }
1439
+ // Refresh so manageVirtualEntities (next step) syncs fields for the newly-minted entities.
1440
+ if (mintedCount > 0) {
1441
+ await md.Refresh();
1442
+ }
1443
+ return { success: true, processedCount, mintedCount };
1444
+ }
1445
+ /**
1446
+ * Phase 4 (§13/§17.2): scan active materializations and flag those whose source shape has drifted
1447
+ * as `DriftHold` (stop refreshing, surface for review). Runs after the entity/field re-sync, so it
1448
+ * compares each materialization's provenance against the CURRENT metadata. Flag-and-hold only — never
1449
+ * auto-rebuilds. Already-held (`DriftHold`) and `Disabled` rows are skipped.
1450
+ */
1451
+ async detectMaterializationDrift(pool) {
1452
+ const coreSchema = mj_core_schema();
1453
+ // Gate on MaterializedResult existing — the SELECT below reads it. Absent (materialization migration not yet
1454
+ // applied on this DB) ⇒ no materializations exist to check for drift; skip cleanly rather than throwing and
1455
+ // aborting the codegen pass.
1456
+ if (!(await this.materializedResultTableExists(pool)))
1457
+ return { success: true, heldCount: 0 };
1458
+ // Enumerate the API-key row-filter layer BEFORE the per-row C1 re-check / leak guard consults it (see
1459
+ // loadAPIKeyRowFilterTargets — an unloaded cache reads 'unknown' and holds every materialization).
1460
+ await this.loadAPIKeyRowFilterTargets(pool);
1461
+ const rows = await this.runQuery(pool, `SELECT mr.ID, mr.SourceType, mr.SourceEntityID, mrq.QueryID AS SourceQueryID, mr.GeneratedEntityID, mr.SchemaName, mr.TableName
1462
+ FROM ${this.qs(coreSchema, 'MaterializedResult')} mr
1463
+ LEFT JOIN ${this.qs(coreSchema, 'MaterializedResultQuery')} mrq ON mrq.MaterializedResultID = mr.ID
1464
+ WHERE mr.Status NOT IN ('Disabled', 'DriftHold')`);
1465
+ if (rows.recordset.length === 0)
1466
+ return { success: true, heldCount: 0 };
1467
+ const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
1468
+ await md.Refresh(); // reflect this run's schema/field sync before judging drift
1469
+ let heldCount = 0;
1470
+ let failedCount = 0;
1471
+ // Per-row isolation: a throw while processing ONE materialization (a transient query read, a dropped
1472
+ // source table, a permission edge) must NOT abort reconciliation for the REST — otherwise a single
1473
+ // failing row indefinitely defers the C1 RLS re-check + C2 grant re-narrow for every materialization
1474
+ // after it (a latent fail-open). Log, count, and continue; the pass reports PARTIAL reconciliation.
1475
+ for (const r of rows.recordset) {
1476
+ try {
1477
+ if (await this.evaluateAndHoldDriftRow(pool, md, r, coreSchema)) {
1478
+ heldCount++;
1479
+ }
1480
+ }
1481
+ catch (rowErr) {
1482
+ failedCount++;
1483
+ logError(` > Drift/security re-check FAILED for materialization "${r.TableName}" (ID ${r.ID}) — isolated and skipped, continuing with the rest: ${rowErr instanceof Error ? rowErr.message : String(rowErr)}`);
1484
+ }
1485
+ }
1486
+ if (failedCount > 0) {
1487
+ logError(` > detectMaterializationDrift: ${failedCount} materialization(s) failed the drift/security re-check and were skipped — reconciliation is PARTIAL this run (a source that gained RLS on a skipped row may still be granting read). Investigate the logged errors.`);
1488
+ }
1489
+ return { success: failedCount === 0, heldCount };
1490
+ }
1491
+ /**
1492
+ * Processes ONE materialization row: the C1 RLS re-check, the C2 read-grant re-narrow, the external
1493
+ * base-view leak guard, and generic shape/provenance drift. Returns true if the row was held. Extracted
1494
+ * from {@link detectMaterializationDrift} so a throw on one row can be isolated by the caller's try/catch
1495
+ * instead of aborting reconciliation for every remaining materialization.
1496
+ */
1497
+ async evaluateAndHoldDriftRow(pool, md, r, coreSchema) {
1498
+ // RLS re-check (C1): a QUERY materialization whose source has SINCE gained a read RLS filter (or lost
1499
+ // its source-entity provenance) would keep serving the full unscoped snapshot to every user — the
1500
+ // mint-time gate cannot see a change made after minting. Re-run the same assessment here and, when it
1501
+ // now fails, hold the materialization AND revoke the minted entity's read access: a precomputed
1502
+ // snapshot cannot enforce per-user scoping, so fail closed until a human authors protection. Base-view
1503
+ // materializations re-apply the source entity's RLS at read time (they reuse the source entity), so
1504
+ // they are exempt from this check.
1505
+ if (r.SourceType === 'Query' && !r.SourceQueryID) {
1506
+ // FAIL CLOSED on LOST PROVENANCE. `SourceQueryID` comes from a LEFT JOIN on MaterializedResultQuery.
1507
+ // `spDeleteQuery` cascade-deletes that join row, but everything the join row protected SURVIVES: the
1508
+ // MaterializedResult, the minted read-only virtual entity, its EntityPermission read grants, and the
1509
+ // already-POPULATED materialized_<Name> table. With a NULL QueryID the C1 RLS re-check and the C2 grant
1510
+ // re-narrow below are both unreachable, `gatherDriftFacts`' Query branch returns all-empty arrays,
1511
+ // `evaluateMaterializationDrift` returns `{drift:false}`, and Status stays 'Active' — so the guard whose
1512
+ // whole job is to revoke read + hold could never fire again and the unscoped snapshot would keep serving
1513
+ // indefinitely. A query materialization that cannot name its source query has, by definition, lost the
1514
+ // provenance every one of those safety checks is computed from, so treat it as drift and drive it down
1515
+ // the established revoke-then-hold path. Over-holding is harmless (a human can relink and re-activate);
1516
+ // under-holding is the leak.
1517
+ await this.revokeReadAndHoldMaterialization(pool, r, coreSchema, 'PROVENANCE DRIFT', 'its MaterializedResultQuery link is gone (the source query was deleted), so the RLS re-check and read-grant re-narrow can no longer be computed for it');
1518
+ return true;
1519
+ }
1520
+ if (r.SourceType === 'Query' && r.SourceQueryID) {
1521
+ const qe = await this.runQueryWithParams(pool, `SELECT EntityID FROM ${this.qs(coreSchema, 'QueryEntity')} WHERE QueryID = @Q`, { Q: r.SourceQueryID });
1522
+ const sourceEntityIds = qe.recordset.map((x) => x.EntityID);
1523
+ // Load the query text so the P1 under-linking guard can parse it for source tables the QueryEntity
1524
+ // links may have missed (see assessQuerySourceRLSSafety). Best-effort: if the read fails, the
1525
+ // linked-source checks still run — only a POSITIVE unlinked-RLS detection changes the verdict.
1526
+ const qSqlRes = await this.runQueryWithParams(pool, `SELECT SQL FROM ${this.qs(coreSchema, 'Query')} WHERE ID = @Q`, { Q: r.SourceQueryID });
1527
+ const driftSQL = qSqlRes.recordset?.[0]?.SQL;
1528
+ const rlsVerdict = this.assessQuerySourceRLSSafety(md, sourceEntityIds, driftSQL);
1529
+ if (!rlsVerdict.safe) {
1530
+ await this.revokeReadAndHoldMaterialization(pool, r, coreSchema, 'RLS DRIFT', rlsVerdict.reason ?? 'source RLS drift');
1531
+ return true; // already held for the leak; the shape-drift check below is moot
1532
+ }
1533
+ // Leak 2 (C2 ongoing): RLS is still safe, but a role may have LOST plain read on a source since mint.
1534
+ // The intersection grant is computed once at mint and never re-narrowed by the RLS check above, so
1535
+ // re-narrow it here to the CURRENT source-read intersection — otherwise a role that lost read on a
1536
+ // source keeps reading the snapshot's rows it can no longer read live. Re-narrow only (never re-grant).
1537
+ if (r.GeneratedEntityID) {
1538
+ const sourceEntities = sourceEntityIds.map((id) => md.EntityByID(id)).filter((e) => !!e);
1539
+ const revoked = await this.reconcileMaterializedQueryEntityReadGrants(pool, r.GeneratedEntityID, r.TableName, sourceEntities);
1540
+ if (revoked > 0) {
1541
+ logStatus(` > PERMISSION DRIFT: materialized entity "${r.TableName}" → revoked ${revoked} over-broad read grant(s) (a role can no longer read every source).`);
1542
+ }
1543
+ }
1544
+ }
1545
+ // SECURITY (Leak 1 drift side): an existing base-view materialization of an EXTERNAL read-RLS-protected
1546
+ // entity leaks (the mirror is readable via raw queries, but the entity's live reads are RLS-refused and
1547
+ // the read path never re-applies RLS to an external mirror). The mint gate refuses NEW ones; hold any
1548
+ // that already exist (or whose entity became external/RLS after minting) so refresh stops — and flag for
1549
+ // a human to drop the mirror. (Local base-view RLS is safe and not held.)
1550
+ if (r.SourceType === 'EntityBaseView' && r.SourceEntityID) {
1551
+ const bvEntity = md.EntityByID(r.SourceEntityID);
1552
+ if (bvEntity && bvEntity.ExternalDataSourceID && this.entityHasRowLevelRestriction(bvEntity)) {
1553
+ // EMPTY the mirror now — it holds remote rows the live path refuses under RLS, and DriftHold alone
1554
+ // would only stop FUTURE refreshes while leaving the already-populated rows queryable via raw SQL
1555
+ // over the wrapper view. Emptying (not dropping) removes the leaked data without breaking any object
1556
+ // dependency; the wrapper view remains but returns nothing. Then DriftHold so refresh never refills it.
1557
+ await this.LogSQLAndExecute(pool, `DELETE FROM ${this.qs(r.SchemaName, r.TableName)}`, `Empty external RLS base-view mirror "${r.TableName}" (leak guard — mirror exposed RLS-refused rows)`);
1558
+ await this.LogSQLAndExecute(pool, `UPDATE ${this.qs(coreSchema, 'MaterializedResult')} SET ${this.qi('Status')}='DriftHold' WHERE ID='${r.ID}'`, `Flag external RLS base-view materialization "${r.TableName}" as DriftHold (leak guard)`);
1559
+ logError(` > RLS LEAK GUARD: base-view materialization "${r.TableName}" mirrors an EXTERNAL row-restricted entity ("${bvEntity.Name}" — role RLS and/or an API-key row filter) → mirror EMPTIED + DriftHold (refresh stopped). It exposed rows the live path refuses under RLS.`);
1560
+ return true;
1561
+ }
1562
+ }
1563
+ const facts = await this.gatherDriftFacts(pool, md, r, coreSchema);
1564
+ const verdict = evaluateMaterializationDrift(facts);
1565
+ if (verdict.drift) {
1566
+ // Fail-closed on ANY hold of a QUERY materialization: once held, the row drops out of
1567
+ // detectMaterializationDrift's `Status NOT IN ('DriftHold', ...)` scan, so the C1 RLS re-check and the
1568
+ // C2 grant re-narrow above NEVER run for it again. A query virtual entity's reads are NOT status-gated
1569
+ // (they always read `materialized_vw…`), so a still-readable held snapshot whose source LATER gains RLS
1570
+ // (or whose role loses source read) would leak the full unscoped rows. Revoke read access on the hold to
1571
+ // close that window now, mirroring the RLS-drift revoke above. Base-view mats reuse the source entity
1572
+ // (RLS re-applied at read time via the status-gated effective base view), so they have no minted grant here.
1573
+ if (r.SourceType === 'Query' && r.GeneratedEntityID) {
1574
+ await this.revokeMaterializedEntityReadAccess(pool, r.GeneratedEntityID, r.TableName, `drift hold — ${verdict.reason ?? 'shape/provenance drift'}`);
1575
+ }
1576
+ await this.LogSQLAndExecute(pool, `UPDATE ${this.qs(coreSchema, 'MaterializedResult')} SET ${this.qi('Status')}='DriftHold' WHERE ID='${r.ID}'`, `Flag materialization "${r.TableName}" as DriftHold`);
1577
+ logStatus(` > DRIFT: materialization "${r.TableName}" → DriftHold — ${verdict.reason}`);
1578
+ return true;
1579
+ }
1580
+ return false;
1581
+ }
1582
+ /**
1583
+ * THE single rule for "which of an entity's fields get materialized" in a base-view materialization. Every
1584
+ * site that computes a base-view column set — the MINT (physical table + wrapper view), the DRIFT comparison,
1585
+ * and the runtime REFRESH mirror — must use this one predicate, or they judge each other's output as drift.
1586
+ *
1587
+ * Rule: for an EXTERNAL entity, virtual fields are MJ-computed and absent from the remote source, so the
1588
+ * refresh mirror only materializes non-virtual columns and the mint must match. A LOCAL base view computes its
1589
+ * virtual columns in the view itself, so `SELECT * FROM <baseView>` includes them and every field is kept.
1590
+ * (`MaterializationRefresher.rebuildFromExternalEntity` applies `!f.IsVirtual` on the external path only — the
1591
+ * same rule, expressed in a context where "external" is already established.)
1592
+ */
1593
+ materializedEntityFields(entity) {
1594
+ return entity.Fields.filter((f) => !(entity.ExternalDataSourceID && f.IsVirtual));
1595
+ }
1596
+ /**
1597
+ * The established fail-closed "stop serving this materialization" sequence, shared by every branch that must
1598
+ * take a query materialization out of service.
1599
+ *
1600
+ * Revoke read FIRST, then flag DriftHold. The revoke is the security-critical action (it closes the leak — a
1601
+ * snapshot serving unscoped source rows); DriftHold only stops FUTURE refreshes. Revoke-first means that if
1602
+ * the second statement fails, the readable window is already closed (fail-safe) — whereas DriftHold-then-revoke
1603
+ * would leave read OPEN if the revoke failed. A row with no minted entity has no grant row to flip; it is still
1604
+ * held.
1605
+ */
1606
+ async revokeReadAndHoldMaterialization(pool, r, coreSchema, label, reason) {
1607
+ if (r.GeneratedEntityID) {
1608
+ await this.revokeMaterializedEntityReadAccess(pool, r.GeneratedEntityID, r.TableName, reason);
1609
+ }
1610
+ await this.LogSQLAndExecute(pool, `UPDATE ${this.qs(coreSchema, 'MaterializedResult')} SET ${this.qi('Status')}='DriftHold' WHERE ID='${r.ID}'`, `Flag materialization "${r.TableName}" as DriftHold (${label.toLowerCase()})`);
1611
+ logError(` > ${label}: materialization "${r.TableName}" → read access revoked + DriftHold — ${reason}`);
1612
+ }
1613
+ /** Gathers the drift-relevant existence facts for one materialization against current metadata. */
1614
+ async gatherDriftFacts(pool, md, r, coreSchema) {
1615
+ if (r.SourceType === 'EntityBaseView') {
1616
+ const entity = r.SourceEntityID ? md.EntityByID(r.SourceEntityID) : undefined;
1617
+ if (!entity) {
1618
+ return { sourceType: 'EntityBaseView', baseView: { sourceEntityExists: false, currentEntityFields: [], materializedColumns: [] } };
1619
+ }
1620
+ const materializedColumns = await this.getMaterializedTableColumns(pool, r.SchemaName, r.TableName);
1621
+ return {
1622
+ sourceType: 'EntityBaseView',
1623
+ // MUST use the same field-exclusion rule the MINT and the REFRESH use ({@link materializedEntityFields}).
1624
+ // Comparing the UNFILTERED field list against the mirror's columns made every external entity with an
1625
+ // IsVirtual field look like it had drifted the very first run after minting (added=[<virtual fields>])
1626
+ // → Status='DriftHold' → permanent live-only fallback, and DriftHold rows are excluded from the sweep,
1627
+ // so it could never recover. The comparison has to be apples-to-apples with what was actually built.
1628
+ baseView: { sourceEntityExists: true, currentEntityFields: this.materializedEntityFields(entity).map((f) => f.Name), materializedColumns },
1629
+ };
1630
+ }
1631
+ // Query case — provenance via QueryEntity / QueryField / QueryDependency, plus output-shape facts.
1632
+ const missingSourceEntities = [];
1633
+ const missingSourceFields = [];
1634
+ const missingComposedQueries = [];
1635
+ // Output-shape facts (§13 drift): the query's CURRENT declared output columns (ALL fields) vs the
1636
+ // snapshot table's actual DATA columns. Detects a SELECT-list edit on a create-if-absent table that
1637
+ // provenance existence checks can't see. Both empty-tolerant downstream.
1638
+ let currentOutputColumns = [];
1639
+ let materializedColumns = [];
1640
+ if (r.SourceQueryID) {
1641
+ const qid = r.SourceQueryID;
1642
+ const qe = await this.runQueryWithParams(pool, `SELECT EntityID FROM ${this.qs(coreSchema, 'QueryEntity')} WHERE QueryID = @Q`, { Q: qid });
1643
+ for (const row of qe.recordset) {
1644
+ if (!md.EntityByID(row.EntityID))
1645
+ missingSourceEntities.push(row.EntityID);
1646
+ }
1647
+ // ONE QueryField read serves BOTH needs: the output-shape column set (ALL Names) and the provenance
1648
+ // field check (rows whose SourceEntityID+SourceFieldName are both known — only those are judged, to
1649
+ // avoid false positives from incomplete/LLM-inferred provenance; filtered in JS below).
1650
+ const qf = await this.runQueryWithParams(pool, `SELECT Name, SourceEntityID, SourceFieldName FROM ${this.qs(coreSchema, 'QueryField')} WHERE QueryID = @Q`, { Q: qid });
1651
+ // Both sides of the output-shape comparison must exclude the SAME system columns: the synthetic
1652
+ // surrogate PK and any __mj_* columns are always present in the materialized table but are stripped
1653
+ // from materializedColumns below. A query that legitimately PROJECTS a __mj_* column (e.g. SELECT
1654
+ // __mj_UpdatedAt) would otherwise appear only in currentOutputColumns → a phantom "added" drift that
1655
+ // false-flags DriftHold. Applying the identical filter here keeps the comparison apples-to-apples.
1656
+ const surrogate = MATERIALIZATION_SURROGATE_COLUMN.toLowerCase();
1657
+ const isOutputShapeColumn = (name) => {
1658
+ const lc = name.trim().toLowerCase();
1659
+ return lc !== surrogate && !lc.startsWith('__mj_');
1660
+ };
1661
+ currentOutputColumns = qf.recordset.map((row) => String(row.Name)).filter(isOutputShapeColumn);
1662
+ for (const row of qf.recordset) {
1663
+ if (row.SourceEntityID == null || row.SourceFieldName == null)
1664
+ continue;
1665
+ const ent = md.EntityByID(row.SourceEntityID);
1666
+ const fieldName = String(row.SourceFieldName);
1667
+ if (!ent) {
1668
+ missingSourceFields.push(`${row.SourceEntityID}.${fieldName}`);
1669
+ }
1670
+ else if (!ent.Fields.find((f) => f.Name.trim().toLowerCase() === fieldName.trim().toLowerCase())) {
1671
+ missingSourceFields.push(`${ent.Name}.${fieldName}`);
1672
+ }
1673
+ }
1674
+ const qd = await this.runQueryWithParams(pool, `SELECT DependsOnQueryID FROM ${this.qs(coreSchema, 'QueryDependency')} WHERE QueryID = @Q`, { Q: qid });
1675
+ const depIds = qd.recordset.map((row) => row.DependsOnQueryID).filter((d) => !!d);
1676
+ if (depIds.length > 0) {
1677
+ // ONE set-based existence check instead of a SELECT per dependency (was an N+1 cascade — K
1678
+ // round trips per materialization). DependsOnQueryID values are DB-sourced UUIDs; single-quote
1679
+ // escaped defensively. Case-insensitive membership handles SS-uppercase vs stored casing.
1680
+ const inList = depIds.map((d) => `'${d.replace(/'/g, "''")}'`).join(', ');
1681
+ const existing = await this.runQuery(pool, `SELECT ID FROM ${this.qs(coreSchema, 'Query')} WHERE ID IN (${inList})`);
1682
+ const existingSet = new Set(existing.recordset.map((row) => String(row.ID).toLowerCase()));
1683
+ for (const d of depIds) {
1684
+ if (!existingSet.has(d.toLowerCase()))
1685
+ missingComposedQueries.push(d);
1686
+ }
1687
+ }
1688
+ const rawCols = await this.getMaterializedTableColumns(pool, r.SchemaName, r.TableName);
1689
+ // Same exclusion as currentOutputColumns (see isOutputShapeColumn above) — surrogate + __mj_* columns
1690
+ // are not part of the query's declared output shape.
1691
+ materializedColumns = rawCols.filter(isOutputShapeColumn);
1692
+ }
1693
+ return { sourceType: 'Query', query: { missingSourceEntities, missingSourceFields, missingComposedQueries, currentOutputColumns, materializedColumns } };
1694
+ }
1695
+ /** Actual column names of a materialized table (via INFORMATION_SCHEMA), for drift comparison. */
1696
+ async getMaterializedTableColumns(pool, schema, table) {
1697
+ const esc = (s) => s.replace(/'/g, "''");
1698
+ const res = await this.runQuery(pool, `SELECT COLUMN_NAME AS cn FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='${esc(schema)}' AND TABLE_NAME='${esc(table)}' ORDER BY ORDINAL_POSITION`);
1699
+ return res.recordset.map((row) => row.cn);
1700
+ }
954
1701
  /**
955
1702
  * Derives an entity name from a view name by removing common prefixes (vw, v_)
956
1703
  * and converting to a human-friendly format.
@@ -1108,6 +1855,13 @@ export class ManageMetadataBase {
1108
1855
  const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
1109
1856
  await md.Refresh();
1110
1857
  }
1858
+ // Config-driven query materialization — mint read-only Virtual Entities over materialized
1859
+ // query results. Runs BEFORE manageVirtualEntities so the minted entities' fields get synced
1860
+ // from their wrapper views in this same run (plan §4.2). Internally refreshes metadata when it mints.
1861
+ const queryMatResult = await this.processQueryMaterializations(pool, currentUser);
1862
+ if (queryMatResult.processedCount > 0) {
1863
+ logStatus(` > Query materialization: processed ${queryMatResult.processedCount} quer${queryMatResult.processedCount === 1 ? 'y' : 'ies'} (${queryMatResult.mintedCount} new entit${queryMatResult.mintedCount === 1 ? 'y' : 'ies'})`);
1864
+ }
1111
1865
  const veResult = await this.manageVirtualEntities(pool);
1112
1866
  if (!veResult.success) {
1113
1867
  logError(' Error managing virtual entities');
@@ -1158,6 +1912,21 @@ export class ManageMetadataBase {
1158
1912
  if (organicKeyResult.createdCount > 0 || organicKeyResult.updatedCount > 0) {
1159
1913
  logStatus(` > Organic keys: ${organicKeyResult.createdCount} created, ${organicKeyResult.updatedCount} updated from config`);
1160
1914
  }
1915
+ // Config-driven base-view materialization — emit the physical table + wrapper view and
1916
+ // attach an "MJ: Materialized Results" row to the existing entity. Runs AFTER entity fields
1917
+ // are managed so the snapshot shape reflects the current base view (plan §4.1).
1918
+ const baseViewMatResult = await this.processBaseViewMaterializations(pool);
1919
+ if (baseViewMatResult.processedCount > 0) {
1920
+ logStatus(` > Base-view materialization: processed ${baseViewMatResult.processedCount} declaration${baseViewMatResult.processedCount === 1 ? '' : 's'} from config`);
1921
+ }
1922
+ // Phase 4 (§13/§17.2): flag materializations whose source shape drifted (source entity/field
1923
+ // dropped/renamed, or a composed inner query removed) as DriftHold and stop refreshing them —
1924
+ // flag-and-hold, never silent auto-rebuild. Runs after the entity/field re-sync so it sees the
1925
+ // current schema.
1926
+ const driftResult = await this.detectMaterializationDrift(pool);
1927
+ if (driftResult.heldCount > 0) {
1928
+ logStatus(` > Materialization drift: held ${driftResult.heldCount} materialization${driftResult.heldCount === 1 ? '' : 's'} for review (Status=DriftHold)`);
1929
+ }
1161
1930
  start = new Date();
1162
1931
  logStatus(' Syncing schema info from database...');
1163
1932
  if (!await this.updateSchemaInfoFromDatabase(pool, excludeSchemas)) {
@@ -1255,19 +2024,56 @@ export class ManageMetadataBase {
1255
2024
  }
1256
2025
  return { success: bSuccess, anyUpdates: anyUpdates };
1257
2026
  }
2027
+ /**
2028
+ * External-data-source analogue of {@link manageVirtualEntities}. For each entity backed by an
2029
+ * external data source, introspect the REMOTE schema (via the EDS router resolved through the
2030
+ * ClassFactory) and sync its `EntityField` rows to match — the remote equivalent of reading the
2031
+ * local INFORMATION_SCHEMA for a virtual/view entity. No-op when there are no external entities.
2032
+ */
2033
+ /**
2034
+ * Whether the current database's `Entity` table has the `ExternalDataSourceID` column. External
2035
+ * Data Sources ship as a SQL Server migration only, so on any database/schema that predates it
2036
+ * (PostgreSQL today) the column is absent — and ANY raw SQL referencing it throws
2037
+ * "column does not exist" and aborts the entire CodeGen run, even for a pure MJ-DB schema with no
2038
+ * external entities. Callers gate EDS-specific queries on this so CodeGen stays green everywhere
2039
+ * and auto-activates once the column lands on other platforms (mirrors the PG stored-proc guard in
2040
+ * metadataSupportObjects.ts). Cross-platform via INFORMATION_SCHEMA; cached for the run (the schema
2041
+ * cannot change mid-run).
2042
+ */
1258
2043
  async entityHasExternalDataSourceColumn(pool) {
1259
- if (this._entityHasExternalDataSourceColumn === null) {
1260
- // Check the VIEW the gated queries actually read (vwEntities), not the base Entity table — a
1261
- // schema where the table has the column but the view wasn't refreshed to expose it would still
1262
- // throw. (Matches the PG guard in metadataSupportObjects.ts, which also checks the view.)
1263
- const sql = `SELECT COUNT(*) AS ColExists FROM INFORMATION_SCHEMA.COLUMNS ` +
1264
- `WHERE TABLE_SCHEMA = '${mj_core_schema()}' AND TABLE_NAME = 'vwEntities' AND COLUMN_NAME = 'ExternalDataSourceID'`;
2044
+ // Check the VIEW the gated queries actually read (vwEntities), not the base Entity table — a
2045
+ // schema where the table has the column but the view wasn't refreshed to expose it would still
2046
+ // throw. (Matches the PG guard in metadataSupportObjects.ts, which also checks the view.)
2047
+ return await this.columnExistsInCoreSchema(pool, 'vwEntities', 'ExternalDataSourceID');
2048
+ }
2049
+ /**
2050
+ * Whether the current database's `Query` table has the `ExternalDataSourceID` column. Same rationale as
2051
+ * {@link entityHasExternalDataSourceColumn}: External Data Sources ship as a SQL-Server-only migration, so
2052
+ * on a DB without it (PostgreSQL today) the column is absent and any raw SQL referencing it aborts the
2053
+ * CodeGen run. processQueryMaterializations gates its ExternalDataSourceID reference on this. Cached per run.
2054
+ */
2055
+ async queryHasExternalDataSourceColumn(pool) {
2056
+ return await this.columnExistsInCoreSchema(pool, 'Query', 'ExternalDataSourceID');
2057
+ }
2058
+ /**
2059
+ * True if the Query table has the IsMaterialized column (added by the materialization Foundation migration).
2060
+ * processQueryMaterializations gates on this so codegen doesn't throw on a DB where that migration hasn't run
2061
+ * yet (e.g. the PostgreSQL parallel world's object-availability lag) — same defensive pattern as
2062
+ * queryHasExternalDataSourceColumn. Cached per run.
2063
+ */
2064
+ async queryHasIsMaterializedColumn(pool) {
2065
+ return await this.columnExistsInCoreSchema(pool, 'Query', 'IsMaterialized');
2066
+ }
2067
+ async materializedResultTableExists(pool) {
2068
+ if (this._materializedResultTableExists === null) {
2069
+ const sql = `SELECT COUNT(*) AS TblExists FROM INFORMATION_SCHEMA.TABLES ` +
2070
+ `WHERE TABLE_SCHEMA = '${mj_core_schema()}' AND TABLE_NAME = 'MaterializedResult'`;
1265
2071
  const result = await this.runQuery(pool, sql);
1266
2072
  const row = (result.recordset?.[0] ?? {});
1267
- const cnt = row.ColExists ?? row.colexists ?? 0;
1268
- this._entityHasExternalDataSourceColumn = Number(cnt) > 0;
2073
+ const cnt = row.TblExists ?? row.tblexists ?? 0;
2074
+ this._materializedResultTableExists = Number(cnt) > 0;
1269
2075
  }
1270
- return this._entityHasExternalDataSourceColumn;
2076
+ return this._materializedResultTableExists;
1271
2077
  }
1272
2078
  async manageExternalEntities(pool, currentUser) {
1273
2079
  let bSuccess = true;
@@ -2070,9 +2876,12 @@ export class ManageMetadataBase {
2070
2876
  }
2071
2877
  // Check the DATABASE for existing field record — in-memory metadata may be stale
2072
2878
  // (e.g. createNewEntityFieldsFromSchema may have already added this field from the view)
2073
- const existsResult = await this.runQueryWithParams(pool, `SELECT ID, IsVirtual, Type, Length, Precision, Scale, AllowsNull, AllowUpdateAPI
2879
+ // Identifiers are quoted via qi(): __mj.EntityField's columns are mixed-case, and an
2880
+ // unquoted reference folds to lower case on PostgreSQL — `column "length" does not exist`.
2881
+ // SQL Server's case-insensitive resolution hid this for as long as IS-A ran only there.
2882
+ const existsResult = await this.runQueryWithParams(pool, `SELECT ${this.qi('ID')}, ${this.qi('IsVirtual')}, ${this.qi('Type')}, ${this.qi('Length')}, ${this.qi('Precision')}, ${this.qi('Scale')}, ${this.qi('AllowsNull')}, ${this.qi('AllowUpdateAPI')}
2074
2883
  FROM ${this.qs(mj_core_schema(), 'EntityField')}
2075
- WHERE EntityID = @EntityID AND Name = @FieldName`, { 'EntityID': childEntity.ID, 'FieldName': parentField.Name });
2884
+ WHERE ${this.qi('EntityID')} = @EntityID AND ${this.qi('Name')} = @FieldName`, { 'EntityID': childEntity.ID, 'FieldName': parentField.Name });
2076
2885
  if (existsResult.recordset.length > 0) {
2077
2886
  // Field already exists — update it to ensure it's marked as a virtual IS-A field
2078
2887
  const existingRow = existsResult.recordset[0];
@@ -2085,14 +2894,14 @@ export class ManageMetadataBase {
2085
2894
  !existingRow.AllowUpdateAPI;
2086
2895
  if (needsUpdate) {
2087
2896
  const sqlUpdate = `UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
2088
- SET IsVirtual=${this.boolLit(true)},
2089
- Type='${parentField.Type}',
2090
- Length=${parentField.Length},
2091
- Precision=${parentField.Precision},
2092
- Scale=${parentField.Scale},
2093
- AllowsNull=${this.boolLit(parentField.AllowsNull)},
2094
- AllowUpdateAPI=${this.boolLit(true)}
2095
- WHERE ID='${existingRow.ID}'`;
2897
+ SET ${this.qi('IsVirtual')}=${this.boolLit(true)},
2898
+ ${this.qi('Type')}='${parentField.Type}',
2899
+ ${this.qi('Length')}=${parentField.Length},
2900
+ ${this.qi('Precision')}=${parentField.Precision},
2901
+ ${this.qi('Scale')}=${parentField.Scale},
2902
+ ${this.qi('AllowsNull')}=${this.boolLit(parentField.AllowsNull)},
2903
+ ${this.qi('AllowUpdateAPI')}=${this.boolLit(true)}
2904
+ WHERE ${this.qi('ID')}='${existingRow.ID}'`;
2096
2905
  await this.LogSQLAndExecute(pool, sqlUpdate, `Update IS-A parent field ${parentField.Name} on ${childEntity.Name}`);
2097
2906
  bUpdated = true;
2098
2907
  }
@@ -3919,7 +4728,27 @@ export class ManageMetadataBase {
3919
4728
  }
3920
4729
  async createNewEntities(pool, currentUser) {
3921
4730
  try {
3922
- const sSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwSQLTablesAndEntities')} WHERE ${this.qi('EntityID')} IS NULL ` + this.createExcludeTablesAndSchemasFilter('');
4731
+ // Exclude the PHYSICAL materialization tables (materialized_<Name>) from new-entity auto-creation.
4732
+ // They are internal snapshot storage, surfaced ONLY through the read-only virtual entity minted over
4733
+ // their wrapper view. On any codegen re-run after a materialization exists, this scan would otherwise
4734
+ // find the physical table (which has no entity of its own) and auto-mint a spurious CRUD entity over
4735
+ // it — user-visible AND editable, letting someone mutate the snapshot the refresher overwrites. The
4736
+ // wrapper view is already excluded (it carries the minted entity, so EntityID IS NOT NULL). The outer
4737
+ // view is aliased `t` so the correlated MaterializedResult subquery can't resolve columns ambiguously.
4738
+ const coreSchema = mj_core_schema();
4739
+ // Gate the MaterializedResult reference on the table actually existing. Referencing it unconditionally
4740
+ // would throw — breaking new-entity creation for EVERY entity — on any DB where MaterializedResult
4741
+ // isn't present yet (e.g. the PostgreSQL parallel world's object-availability lag). If it's absent we
4742
+ // simply skip the exclusion (the physical materialized table can't exist without it either, so nothing
4743
+ // to exclude). Uses the shared cached helper so all three materialization-schema gates stay consistent.
4744
+ const matResultExists = await this.materializedResultTableExists(pool);
4745
+ const matExclusion = matResultExists
4746
+ ? `AND NOT EXISTS (SELECT 1 FROM ${this.qs(coreSchema, 'MaterializedResult')} mr WHERE mr.${this.qi('SchemaName')} = t.${this.qi('SchemaName')} AND mr.${this.qi('TableName')} = t.${this.qi('TableName')}) `
4747
+ : '';
4748
+ const sSQL = `SELECT t.* FROM ${this.qs(coreSchema, 'vwSQLTablesAndEntities')} t `
4749
+ + `WHERE t.${this.qi('EntityID')} IS NULL `
4750
+ + matExclusion
4751
+ + this.createExcludeTablesAndSchemasFilter('t.');
3923
4752
  const newEntitiesResult = await this.runQuery(pool, sSQL);
3924
4753
  const newEntities = newEntitiesResult.recordset;
3925
4754
  if (newEntities && newEntities.length > 0) {
@@ -4416,6 +5245,308 @@ export class ManageMetadataBase {
4416
5245
  }
4417
5246
  }
4418
5247
  }
5248
+ /**
5249
+ * RLS-safety assessment for a QUERY materialization, shared by the mint-time gate and the per-run drift
5250
+ * re-check (plan §6.2 / §10). A materialized query entity does NOT inherit its source entities' row-level
5251
+ * security, so it is only safe to serve an unscoped snapshot when we can PROVE no source is RLS-protected.
5252
+ * Fails closed (returns `{ safe:false }`) on three conditions, in order of decreasing severity of what we
5253
+ * cannot prove:
5254
+ * 1. **No provenance** — zero linked source entities (raw SQL / un-analyzed query): we cannot inspect any
5255
+ * source's RLS, so we cannot prove safety.
5256
+ * 2. **Unresolvable link** — a linked EntityID that doesn't resolve to a known `EntityInfo`: if it secretly
5257
+ * carried a read RLS filter we'd never see it.
5258
+ * 3. **Row-restricted source** — any resolved source protected by role RLS *or* an API-key row filter (see
5259
+ * {@link entityHasRowLevelRestriction}; the API-key layer matters because such an entity carries no
5260
+ * `ReadRLSFilterID`, and the minted entity's NEW EntityID would not match the key's EntityID binding).
5261
+ * Over-restriction here is harmless (the query stays live-only, or an existing materialization is held); the
5262
+ * reverse — serving a protected source's rows unscoped — is the leak this guard exists to prevent.
5263
+ */
5264
+ assessQuerySourceRLSSafety(md, sourceEntityIds, sql) {
5265
+ if (sourceEntityIds.length === 0) {
5266
+ return { safe: false, reason: 'no source-entity provenance is recorded (its QueryEntity links are empty), so RLS-safety cannot be verified' };
5267
+ }
5268
+ const resolved = sourceEntityIds.map((id) => ({ id, entity: md.EntityByID(id) }));
5269
+ const unresolved = resolved.filter((x) => !x.entity);
5270
+ if (unresolved.length > 0) {
5271
+ return { safe: false, reason: `${unresolved.length} source-entity link(s) [${unresolved.map((u) => u.id).join(', ')}] do not resolve to a known entity, so their RLS status can't be verified` };
5272
+ }
5273
+ const rlsProtected = resolved
5274
+ .map((x) => x.entity)
5275
+ .filter((e) => this.entityHasRowLevelRestriction(e));
5276
+ if (rlsProtected.length > 0) {
5277
+ const names = rlsProtected.map((e) => `"${e.Name}"`).join(', ');
5278
+ return { safe: false, reason: `source entit${rlsProtected.length === 1 ? 'y' : 'ies'} ${names} ${rlsProtected.length === 1 ? 'is' : 'are'} read-RLS-protected (role RLS and/or an API-key row filter), and a materialized query entity does NOT inherit source row restrictions (plan §6.2)` };
5279
+ }
5280
+ // P1 hardening — catch RLS sources the LINKER MISSED. The checks above inspect only the LINKED sources
5281
+ // (vwQueryEntities); a source reached via a wrapping view / CTE / function / aliased columns that
5282
+ // query-analysis under-linked would be invisible to them. Parse the query SQL directly, map each table ref
5283
+ // to an entity, and fail closed if a referenced entity is read-RLS-protected but NOT in the linked set —
5284
+ // the precise leak (an unverified RLS source). An unlinked NON-RLS table is not a leak, so it doesn't trip
5285
+ // this (avoids over-refusing legitimate materializations). Parse failure is ignored (the linked-only checks
5286
+ // above still stand); only a POSITIVE detection of an unlinked RLS source refuses.
5287
+ if (sql && sql.trim().length > 0) {
5288
+ const linkedSet = new Set(sourceEntityIds.map((id) => id.trim().toLowerCase()));
5289
+ let refs = [];
5290
+ try {
5291
+ refs = SQLParser.ExtractTableRefs(sql, this.dialect) ?? [];
5292
+ }
5293
+ catch {
5294
+ refs = [];
5295
+ }
5296
+ // CTE self-references are emitted by ExtractTableRefs as unqualified (dbo-defaulted) refs, but they are
5297
+ // NOT base-table sources. Since findEntityByBaseObject now falls back to a schema-agnostic name match for
5298
+ // unqualified refs (to catch under-linked __mj/app-schema sources), a CTE that happens to share an
5299
+ // entity's base-table name would otherwise be misread as an under-linked source and over-refuse. Collect
5300
+ // the CTE names and skip any unqualified ref that matches one. (Parse failure → no exclusion; the guard
5301
+ // still fails closed, only slightly more conservatively.)
5302
+ const cteNames = new Set();
5303
+ try {
5304
+ // Extract each CTE's name from its "name AS (...)" definition. Handle all three quotings SQLParser can
5305
+ // emit ([bracket], "double-quote", bare) plus an optional column list — mirroring the parser's own CTE
5306
+ // header regex. A missed name only means a (safe) over-refusal, but bracket names are the common T-SQL
5307
+ // form, so cover them.
5308
+ for (const def of SQLParser.ExtractCTEs(sql, this.dialect)?.CTEDefinitions ?? []) {
5309
+ const m = /^\s*(?:\[([^\]]+)\]|"([^"]+)"|([A-Za-z_][\w$]*))\s*(?:\([^)]*\))?\s+AS\b/i.exec(def);
5310
+ const name = m?.[1] ?? m?.[2] ?? m?.[3];
5311
+ if (name)
5312
+ cteNames.add(name.trim().toLowerCase());
5313
+ }
5314
+ }
5315
+ catch { /* no CTE exclusion available — guard stays fail-closed */ }
5316
+ for (const ref of refs) {
5317
+ if (!ref.TableName)
5318
+ continue;
5319
+ const refSchema = (ref.SchemaName ?? '').trim().toLowerCase();
5320
+ // Skip CTE self-references (only unqualified / dbo-defaulted refs can be a CTE name).
5321
+ if ((refSchema === '' || refSchema === 'dbo') && cteNames.has(ref.TableName.trim().toLowerCase()))
5322
+ continue;
5323
+ // Refuse ANY under-linked entity source, not only RLS-protected ones. Both the RLS-safety check above
5324
+ // AND the read-grant role intersection (addMaterializedQueryEntityPermissions) are computed over the
5325
+ // LINKED set only, so a source query-analysis did NOT link is invisible to both: an RLS source would
5326
+ // leak its rows unscoped, and a merely CanRead-restricted (non-RLS) source would let the minted
5327
+ // entity's read grant exceed "can read every source" — a privilege escalation. A parsed ref maps to an
5328
+ // entity when it IS that entity's base view/table; derived-table aliases map to nothing, and CTE
5329
+ // self-references are excluded above, so a mapped-but-unlinked source is genuine under-linking and
5330
+ // refusing is correct; over-refusal is harmless (the query stays live-only — link the source via full
5331
+ // query analysis, or use a base-view materialization which inherits source read access).
5332
+ // Consider ALL candidate entities the ref can map to and fail closed if ANY is unlinked: an unqualified
5333
+ // ref is schema-ambiguous (the parser collapses it to 'dbo'), so a same-base-name entity in another
5334
+ // schema could be the true referent — picking only the first match could silently pass an unlinked one.
5335
+ const candidates = this.findEntitiesByBaseObject(md, ref.SchemaName, ref.TableName);
5336
+ const unlinked = candidates.find((e) => !linkedSet.has(e.ID.trim().toLowerCase()));
5337
+ if (unlinked) {
5338
+ const rlsNote = this.entityHasRowLevelRestriction(unlinked) ? ' row-restricted (role RLS and/or an API-key row filter)' : ' read-restricted';
5339
+ return { safe: false, reason: `query references${rlsNote} source "${unlinked.Name}" (${ref.SchemaName ?? ''}.${ref.TableName}) that query-analysis did NOT link — an unscoped snapshot cannot prove its per-source read access is honored (P1 under-linking guard)` };
5340
+ }
5341
+ }
5342
+ }
5343
+ return { safe: true };
5344
+ }
5345
+ /** Maps a physical (schema, table/view) reference to the MJ entities backed by it — by BaseView or BaseTable,
5346
+ * case/whitespace-insensitive. Returns ALL candidates so the P1 guard can fail closed on an AMBIGUOUS ref.
5347
+ * Resolution: an explicit, non-default schema yields only that schema's exact matches; an empty schema OR the
5348
+ * parser-default `'dbo'` — which {@link SQLParser.ExtractTableRefs} assigns to every UNQUALIFIED ref, while MJ
5349
+ * core/app entities live in `__mj`/app schemas (never `dbo`) — is treated as unqualified and yields every
5350
+ * schema-agnostic name match. That way an unqualified reference to a `__mj`/app-schema entity is still caught,
5351
+ * and a same-base-name collision across schemas surfaces every candidate rather than just the first (so the
5352
+ * guard refuses if any of them is under-linked). Used by the P1 under-linking guard. */
5353
+ findEntitiesByBaseObject(md, schema, table) {
5354
+ const t = table.trim().toLowerCase();
5355
+ const s = (schema ?? '').trim().toLowerCase();
5356
+ const nameMatch = (e) => {
5357
+ const bv = (e.BaseView ?? '').trim().toLowerCase();
5358
+ const bt = (e.BaseTable ?? '').trim().toLowerCase();
5359
+ return bv === t || bt === t;
5360
+ };
5361
+ if (s && s !== 'dbo') {
5362
+ // Explicit, non-default schema → only that schema's entity qualifies (no cross-schema ambiguity).
5363
+ return md.Entities.filter((e) => (e.SchemaName ?? '').trim().toLowerCase() === s && nameMatch(e));
5364
+ }
5365
+ // Empty schema / parser-default 'dbo' (i.e. UNQUALIFIED) → every name match across schemas is a candidate.
5366
+ return md.Entities.filter(nameMatch);
5367
+ }
5368
+ /**
5369
+ * Grants read permissions to a newly-minted materialized QUERY entity scoped to the INTERSECTION of source
5370
+ * read access (C2). A precomputed snapshot has no per-row scoping, so a role may read it only if it can read
5371
+ * EVERY source entity — otherwise a user who cannot read a source could read its rows through the snapshot.
5372
+ * Starts from the same config-default role list as {@link addDefaultPermissionsForEntity} (never grants a
5373
+ * role the defaults wouldn't) and drops any role that lacks explicit read on every source. Always read-only
5374
+ * (the entity is a virtual entity with no CRUD sprocs). If the intersection is empty the entity receives no
5375
+ * role-based read grants — the fail-closed direction (§10); a human can grant read after review.
5376
+ */
5377
+ async addMaterializedQueryEntityPermissions(pool, entityId, entityName, sourceEntities) {
5378
+ if (!configInfo.newEntityDefaults.PermissionDefaults?.AutoAddPermissionsForNewEntities) {
5379
+ return;
5380
+ }
5381
+ // Fail-closed (C2): with no resolved source entities we cannot prove any role can read EVERY source, so
5382
+ // grant nothing. `sourceEntities.every(...)` is vacuously true on an empty array and would otherwise grant
5383
+ // read to every default role — the exact "read the snapshot without read on a source" leak this method
5384
+ // exists to prevent. Callers already refuse on empty provenance, but guard here too so the security
5385
+ // invariant holds in isolation, not only by a distant caller's gate.
5386
+ if (sourceEntities.length === 0) {
5387
+ logStatus(` > Materialized entity "${entityName}": no resolved source entities — granting no role-based read (fail-closed).`);
5388
+ return;
5389
+ }
5390
+ const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
5391
+ const roleCanReadAllSources = (roleId) => sourceEntities.every((e) => e.Permissions.some((p) => UUIDsEqual(p.RoleID, roleId) && p.CanRead));
5392
+ for (const p of configInfo.newEntityDefaults.PermissionDefaults.Permissions) {
5393
+ const roleId = md.Roles.find((r) => r.Name.trim().toLowerCase() === p.RoleName.trim().toLowerCase())?.ID;
5394
+ if (!roleId) {
5395
+ LogError(` >>>> ERROR: Unable to find Role ID for role ${p.RoleName} to add permissions for materialized entity ${entityName}`);
5396
+ continue;
5397
+ }
5398
+ if (!p.CanRead || !roleCanReadAllSources(roleId)) {
5399
+ logStatus(` > Materialized entity "${entityName}": role "${p.RoleName}" NOT granted read (it cannot read every source entity; the snapshot has no row-level scoping).`);
5400
+ continue;
5401
+ }
5402
+ const sSQLInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityPermission')}
5403
+ (${this.qi('EntityID')}, ${this.qi('RoleID')}, ${this.qi('CanRead')}, ${this.qi('CanCreate')}, ${this.qi('CanUpdate')}, ${this.qi('CanDelete')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
5404
+ ('${entityId}', '${roleId}', ${this.boolLit(true)}, ${this.boolLit(false)}, ${this.boolLit(false)}, ${this.boolLit(false)}, ${this.utcNow()}, ${this.utcNow()})`;
5405
+ await this.LogSQLAndExecute(pool, sSQLInsert, `SQL generated to add read permission for materialized entity ${entityName} for role ${p.RoleName}`);
5406
+ }
5407
+ }
5408
+ /**
5409
+ * Revokes read access on a minted materialized entity by setting `CanRead=0` on all its EntityPermission
5410
+ * rows (the __mj_UpdatedAt trigger stamps the timestamp). Used by the drift re-check when a query source
5411
+ * gains RLS after minting: the snapshot can no longer be safely served, so it is made unreadable until a
5412
+ * human authors protection. Non-destructive (rows are kept, just flipped) so the grant can be restored.
5413
+ */
5414
+ async revokeMaterializedEntityReadAccess(pool, entityId, entityLabel, reason) {
5415
+ const sql = `UPDATE ${this.qs(mj_core_schema(), 'EntityPermission')} SET ${this.qi('CanRead')}=${this.boolLit(false)} WHERE ${this.qi('EntityID')}='${entityId}' AND ${this.qi('CanRead')}=${this.boolLit(true)}`;
5416
+ await this.LogSQLAndExecute(pool, sql, `Revoke read access on materialized entity "${entityLabel}" (${reason})`);
5417
+ }
5418
+ /**
5419
+ * ROLE-RLS LAYER ONLY — true if any of the entity's role permissions carries a non-empty `ReadRLSFilterID`.
5420
+ *
5421
+ * This is deliberately NOT a gate on its own, and no materialization gate may call it directly. MJ enforces
5422
+ * row restrictions in TWO layers (see `EntityInfo.GetEffectiveRowFilterWhereClause`, the sanctioned composer):
5423
+ * role RLS *and* API-key row filters, AND-composed. An entity bound ONLY by an API-key row filter carries no
5424
+ * `ReadRLSFilterID` at all, so a gate that stopped at this layer would judge it unprotected and fail OPEN —
5425
+ * minting a materialized entity with a NEW EntityID, which the API-key binding (which binds by EntityID) no
5426
+ * longer matches, handing a filtered principal a full unscoped snapshot of rows it cannot read live.
5427
+ *
5428
+ * `EntityInfo` exposes no user-free accessor for either layer (`GetEffectiveRowFilterWhereClause` needs a
5429
+ * session `UserInfo`, and CodeGen has no session), so the composition is done explicitly here — by
5430
+ * {@link entityHasRowLevelRestriction}, which is what every gate calls.
5431
+ */
5432
+ entityHasRowLevelSecurity(entity) {
5433
+ return ManageMetadataBase.EntityHasRoleReadRLS(entity);
5434
+ }
5435
+ /** Pure form of the role-RLS layer (see {@link entityHasRowLevelSecurity}). IO-free so it can be reused
5436
+ * verbatim by the runtime refresher's equivalent gate. */
5437
+ static EntityHasRoleReadRLS(entity) {
5438
+ return entity.Permissions.some((p) => !!p.ReadRLSFilterID && p.ReadRLSFilterID.trim().length > 0);
5439
+ }
5440
+ /**
5441
+ * THE row-restriction gate for materialization. True when the entity is protected by ANY row-level
5442
+ * restriction — role RLS **or** an API-key / API-application row filter — because a materialized snapshot
5443
+ * reproduces neither. Fails closed when the API-key layer could not be enumerated (see
5444
+ * {@link apiKeyRowFilterTargets}).
5445
+ */
5446
+ entityHasRowLevelRestriction(entity) {
5447
+ return ManageMetadataBase.EntityHasRowLevelRestriction(entity, this.apiKeyRowFilterTargets);
5448
+ }
5449
+ /**
5450
+ * Pure, IO-free core of {@link entityHasRowLevelRestriction} — the predicate any other layer (e.g. the
5451
+ * runtime `MaterializationRefresher`) should mirror, supplying the API-key target set however it can obtain
5452
+ * it. `'unknown'` for the target set ⇒ true (fail closed).
5453
+ */
5454
+ static EntityHasRowLevelRestriction(entity, apiKeyRowFilterTargets) {
5455
+ if (ManageMetadataBase.EntityHasRoleReadRLS(entity))
5456
+ return true;
5457
+ if (apiKeyRowFilterTargets === 'unknown')
5458
+ return true; // cannot prove the key layer is empty → fail closed
5459
+ return apiKeyRowFilterTargets.has((entity.Name ?? '').trim().toLowerCase());
5460
+ }
5461
+ /**
5462
+ * Loads {@link apiKeyRowFilterTargets} once per CodeGen run. Called at the top of every materialization entry
5463
+ * point, BEFORE any gate runs, so the gates never evaluate against an unloaded cache in production.
5464
+ *
5465
+ * Resolution rules (all biased fail-closed):
5466
+ * - The `RowFilterID` column absent on a scope table ⇒ that binding layer cannot exist on this database
5467
+ * (pre-v6 schema / the PostgreSQL parallel world) ⇒ contributes nothing. This is CORRECT, not fail-open.
5468
+ * - A filtered rule's `ResourcePattern` must name ONE exact entity (enforced at rule save). Mappability is
5469
+ * decided by {@link ResolveSingleEntityResourceTarget}, shared verbatim with the runtime refresher's
5470
+ * identical gate; a rule it cannot resolve collapses the whole set to `'unknown'` — every entity is then
5471
+ * treated as restricted.
5472
+ * - Any error enumerating the rules ⇒ `'unknown'` (never a silently-empty set).
5473
+ * Permission type is deliberately NOT narrowed to `Read`: mapping a scope rule to a permission type requires
5474
+ * the APIScope path taxonomy that lives outside CodeGen, and over-restriction here is harmless.
5475
+ */
5476
+ async loadAPIKeyRowFilterTargets(pool) {
5477
+ if (this.apiKeyRowFilterTargets !== 'unknown')
5478
+ return; // already loaded this run
5479
+ try {
5480
+ const targets = new Set();
5481
+ for (const table of ['APIKeyScope', 'APIApplicationScope']) {
5482
+ if (!(await this.columnExistsInCoreSchema(pool, table, 'RowFilterID')))
5483
+ continue;
5484
+ const res = await this.runQuery(pool, `SELECT DISTINCT ${this.qi('ResourcePattern')} AS ResourcePattern FROM ${this.qs(mj_core_schema(), table)} WHERE ${this.qi('RowFilterID')} IS NOT NULL`);
5485
+ for (const row of res.recordset ?? []) {
5486
+ const r = row;
5487
+ const pattern = (r.ResourcePattern ?? r.resourcepattern) ?? '';
5488
+ // The mappability rule is SHARED with the runtime refresher's identical gate
5489
+ // (ResolveSingleEntityResourceTarget in @memberjunction/global) rather than copied. The two
5490
+ // gates must agree exactly: a copy that drifts open here silently re-opens the leak the
5491
+ // runtime gate closes, and vice versa — with nothing in the build to notice.
5492
+ const target = ResolveSingleEntityResourceTarget(pattern);
5493
+ if (target === null) {
5494
+ logError(` > API-key row-filter enumeration: a filtered scope rule in ${table} has an unmappable ResourcePattern ` +
5495
+ `("${pattern.trim()}") — it cannot be resolved to a single entity, so EVERY entity is treated as row-restricted ` +
5496
+ `for materialization this run (fail closed). Fix the rule to name one exact entity.`);
5497
+ this.apiKeyRowFilterTargets = 'unknown';
5498
+ return;
5499
+ }
5500
+ targets.add(target);
5501
+ }
5502
+ }
5503
+ this.apiKeyRowFilterTargets = targets;
5504
+ }
5505
+ catch (err) {
5506
+ this.apiKeyRowFilterTargets = 'unknown';
5507
+ logError(` > API-key row-filter enumeration FAILED — every entity will be treated as row-restricted for materialization ` +
5508
+ `this run (fail closed): ${err instanceof Error ? err.message : String(err)}`);
5509
+ }
5510
+ }
5511
+ async columnExistsInCoreSchema(pool, table, column) {
5512
+ const key = `${table}.${column}`.toLowerCase();
5513
+ const cached = this._coreSchemaColumnExists.get(key);
5514
+ if (cached !== undefined)
5515
+ return cached;
5516
+ const sql = `SELECT COUNT(*) AS ColExists FROM INFORMATION_SCHEMA.COLUMNS ` +
5517
+ `WHERE TABLE_SCHEMA = '${mj_core_schema()}' AND TABLE_NAME = '${table}' AND COLUMN_NAME = '${column}'`;
5518
+ const result = await this.runQuery(pool, sql);
5519
+ const row = (result.recordset?.[0] ?? {});
5520
+ const cnt = row.ColExists ?? row.colexists ?? 0;
5521
+ const exists = Number(cnt) > 0;
5522
+ this._coreSchemaColumnExists.set(key, exists);
5523
+ return exists;
5524
+ }
5525
+ /**
5526
+ * Leak-2 (C2 ongoing): re-narrows a minted materialized QUERY entity's read grants to the CURRENT source-read
5527
+ * intersection. The mint-time grant ({@link addMaterializedQueryEntityPermissions}) is computed once; if a role
5528
+ * later LOSES plain `CanRead` on a source, its grant on the snapshot must be revoked too — otherwise it keeps
5529
+ * reading snapshot rows it can no longer read live. Called from the drift pass on every codegen run. Only ever
5530
+ * REVOKES (the safe direction); re-granting a role that regained access is a human decision after review.
5531
+ * Returns the number of grants revoked.
5532
+ */
5533
+ async reconcileMaterializedQueryEntityReadGrants(pool, entityId, entityLabel, sourceEntities) {
5534
+ if (sourceEntities.length === 0)
5535
+ return 0; // no sources to intersect against — the RLS/empty gate handles this
5536
+ const roleCanReadAllSources = (roleId) => sourceEntities.every((e) => e.Permissions.some((p) => UUIDsEqual(p.RoleID, roleId) && p.CanRead));
5537
+ const grants = await this.runQuery(pool, `SELECT ${this.qi('RoleID')} FROM ${this.qs(mj_core_schema(), 'EntityPermission')} WHERE ${this.qi('EntityID')}='${entityId}' AND ${this.qi('CanRead')}=${this.boolLit(true)}`);
5538
+ let revoked = 0;
5539
+ for (const g of grants.recordset) {
5540
+ const roleId = g.RoleID;
5541
+ if (!roleId)
5542
+ continue;
5543
+ if (!roleCanReadAllSources(roleId)) {
5544
+ await this.LogSQLAndExecute(pool, `UPDATE ${this.qs(mj_core_schema(), 'EntityPermission')} SET ${this.qi('CanRead')}=${this.boolLit(false)} WHERE ${this.qi('EntityID')}='${entityId}' AND ${this.qi('RoleID')}='${roleId}' AND ${this.qi('CanRead')}=${this.boolLit(true)}`, `Narrow read grant on materialized entity "${entityLabel}": role ${roleId} can no longer read every source (C2 intersection re-narrowed)`);
5545
+ revoked++;
5546
+ }
5547
+ }
5548
+ return revoked;
5549
+ }
4419
5550
  createNewEntityInsertSQL(newEntityUUID, newEntityName, newEntity, newEntitySuffix, newEntityDisplayName) {
4420
5551
  const newEntityDefaults = configInfo.newEntityDefaults;
4421
5552
  const newEntityDescriptionEscaped = newEntity.EntityDescription ? `'${newEntity.EntityDescription.replace(/'/g, "''")}'` : null;