@memberjunction/codegen-lib 6.1.0-edge.2 → 6.1.0-edge.4

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