@memberjunction/codegen-lib 6.1.2 → 6.2.0-edge.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/Config/config.d.ts +27 -0
  2. package/dist/Config/config.d.ts.map +1 -1
  3. package/dist/Config/config.js +11 -1
  4. package/dist/Config/config.js.map +1 -1
  5. package/dist/Database/codeGenDatabaseProvider.d.ts +24 -0
  6. package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -1
  7. package/dist/Database/codeGenDatabaseProvider.js +30 -0
  8. package/dist/Database/codeGenDatabaseProvider.js.map +1 -1
  9. package/dist/Database/manage-metadata.d.ts +108 -1
  10. package/dist/Database/manage-metadata.d.ts.map +1 -1
  11. package/dist/Database/manage-metadata.js +364 -30
  12. package/dist/Database/manage-metadata.js.map +1 -1
  13. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +18 -0
  14. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -1
  15. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +46 -0
  16. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -1
  17. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +9 -0
  18. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -1
  19. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +14 -0
  20. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -1
  21. package/dist/Database/search-guardrails.d.ts +10 -0
  22. package/dist/Database/search-guardrails.d.ts.map +1 -1
  23. package/dist/Database/search-guardrails.js +20 -1
  24. package/dist/Database/search-guardrails.js.map +1 -1
  25. package/dist/Misc/graphql_server_codegen.d.ts +4 -0
  26. package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
  27. package/dist/Misc/graphql_server_codegen.js +5 -1
  28. package/dist/Misc/graphql_server_codegen.js.map +1 -1
  29. package/dist/Misc/runCommand.d.ts.map +1 -1
  30. package/dist/Misc/runCommand.js +57 -5
  31. package/dist/Misc/runCommand.js.map +1 -1
  32. package/dist/Misc/sql_logging.d.ts +13 -2
  33. package/dist/Misc/sql_logging.d.ts.map +1 -1
  34. package/dist/Misc/sql_logging.js +44 -7
  35. package/dist/Misc/sql_logging.js.map +1 -1
  36. package/dist/Misc/sql_text.d.ts +18 -0
  37. package/dist/Misc/sql_text.d.ts.map +1 -0
  38. package/dist/Misc/sql_text.js +38 -0
  39. package/dist/Misc/sql_text.js.map +1 -0
  40. package/package.json +29 -29
@@ -19,7 +19,7 @@ import { applyIncludeSchemaScope } from "./schema-scope.js";
19
19
  import { buildHealSchemaRoutineParams, getAuthoredExcludeSchemas, snapshotAuthoredExcludeSchemas } from "./heal-schema-params.js";
20
20
  import { AdvancedGeneration, isPlausibleEntityName } from "../Misc/advanced_generation.js";
21
21
  import { CodeGenReporter } from "../Misc/codegen-reporter.js";
22
- import { applySearchableFieldsCap, entityLevelEnableBlockedReason, isNarrativeFieldName, normalizePredicate, normalizeSmartFieldResultShape, } from "./search-guardrails.js";
22
+ import { applySearchableFieldsCap, defaultPredicateFor, entityLevelEnableBlockedReason, isNarrativeFieldName, MAX_SEARCHABLE_FIELDS_PER_ENTITY, NAME_LIKE_FIELD_NAMES, normalizePredicate, normalizeSmartFieldResultShape, } from "./search-guardrails.js";
23
23
  import { mapExternalNativeTypeToMJ } from "../Misc/externalTypeMapping.js";
24
24
  import { SQLParser } from "@memberjunction/sql-parser";
25
25
  import { createDisplayName, generatePluralName, MJGlobal, ResolveSingleEntityResourceTarget, stripTrailingChars, UUIDsEqual } from "@memberjunction/global";
@@ -27,6 +27,7 @@ import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
27
27
  import * as fs from 'fs';
28
28
  import path from 'path';
29
29
  import { canonicalJSONStringify, deepEqualJSON } from "../Misc/util.js";
30
+ import { trimTrailingStatementTerminators } from "../Misc/sql_text.js";
30
31
  import { SQLLogging } from "../Misc/sql_logging.js";
31
32
  import { computeFieldMetadataUpdate } from "./field-metadata-lock.js";
32
33
  import { TRACKED_FIELD_COLUMNS, DISPLAYNAME_REOPEN_REASONS, TYPE_REOPEN_REASONS, diffEntityFieldSnapshots, } from './entity-field-change-tracking.js';
@@ -668,35 +669,47 @@ export class ManageMetadataBase {
668
669
  async processOrganicKeyConfig(pool) {
669
670
  const config = ManageMetadataBase.getSoftPKFKConfig();
670
671
  if (!config)
671
- return { success: true, createdCount: 0, updatedCount: 0 };
672
+ return { success: true, createdCount: 0, updatedCount: 0, failedCount: 0 };
672
673
  const allOrganicKeys = this.extractOrganicKeysFromConfig(config);
673
674
  if (allOrganicKeys.length === 0)
674
- return { success: true, createdCount: 0, updatedCount: 0 };
675
+ return { success: true, createdCount: 0, updatedCount: 0, failedCount: 0 };
675
676
  const schema = mj_core_schema();
676
677
  let createdCount = 0;
677
678
  let updatedCount = 0;
679
+ let failedCount = 0;
678
680
  for (const tableConfig of allOrganicKeys) {
679
681
  // Resolve the owning entity
680
- const ownerResult = await this.runQueryWithParams(pool, `
681
- ${this.selectTop(1, 'ID, Name', `FROM ${this.qs(schema, 'vwEntities')}
682
- WHERE (BaseTable = @TableName AND SchemaName = @SchemaName)
683
- OR Name = @TableName`, 'CASE WHEN BaseTable = @TableName AND SchemaName = @SchemaName THEN 0 ELSE 1 END')}
684
- `, { 'TableName': tableConfig.TableName, 'SchemaName': tableConfig.SchemaName });
685
- if (ownerResult.recordset.length === 0) {
686
- logError(` > Organic keys config: entity "${tableConfig.SchemaName}.${tableConfig.TableName}" not found — skipping`);
682
+ const owner = await this.findOrganicKeyEntity(pool, tableConfig.SchemaName, tableConfig.TableName);
683
+ if (!owner) {
684
+ // Every key declared on this table goes unapplied, so each one counts as a failure.
685
+ failedCount += tableConfig.OrganicKeys.length;
686
+ logError(` > Organic keys config: entity "${tableConfig.SchemaName}.${tableConfig.TableName}" not found — skipping its ${tableConfig.OrganicKeys.length} organic key(s)`);
687
687
  continue;
688
688
  }
689
- const ownerEntityId = ownerResult.recordset[0].ID;
690
- const ownerEntityName = ownerResult.recordset[0].Name;
689
+ const ownerEntityId = owner.ID;
690
+ const ownerEntityName = owner.Name;
691
691
  for (const okConfig of tableConfig.OrganicKeys) {
692
692
  try {
693
+ // Resolve every related entity BEFORE writing anything for this key. Step 1 creates
694
+ // bridge views (in the database and the migration log) and step 2 records the key, so
695
+ // discovering a missing related entity in step 3 would leave an orphan view and a key
696
+ // with a mapping missing.
697
+ const relatedEntities = await this.resolveOrganicKeyRelatedEntities(pool, okConfig);
698
+ if (!relatedEntities) {
699
+ failedCount++;
700
+ continue;
701
+ }
693
702
  // Step 1: Create transitive views if defined
694
703
  for (const re of okConfig.RelatedEntities) {
695
704
  if (re.TransitiveView) {
696
705
  const viewSchema = re.TransitiveView.SchemaName || re.SchemaName;
697
706
  const viewFullName = `${viewSchema}.${re.TransitiveView.Name}`;
698
- const viewSQL = `CREATE OR ALTER VIEW ${this.qs(viewSchema, re.TransitiveView.Name)} AS\n${re.TransitiveView.SQL}`;
699
- await this.LogSQLAndExecute(pool, viewSQL, `Create transitive bridge view ${viewFullName} for organic key "${okConfig.Name}" on ${ownerEntityName}`);
707
+ const viewSQL = this.dbProvider.generateCreateOrReplaceViewSQL(viewSchema, re.TransitiveView.Name, re.TransitiveView.SQL);
708
+ // T-SQL requires CREATE [OR ALTER] VIEW to be the ONLY statement in its batch — first as
709
+ // well as last — and the metadata DML logged just before it (entity-config UPDATEs, the
710
+ // previous key's INSERTs) has no trailing GO. requiresOwnBatch puts the provider's
711
+ // separator on both sides in the migration file ('' on PostgreSQL: nothing is added).
712
+ await this.LogSQLAndExecute(pool, viewSQL, `Create transitive bridge view ${viewFullName} for organic key "${okConfig.Name}" on ${ownerEntityName}`, false, true, this.dbProvider.BatchSeparator, true);
700
713
  // Auto-populate TransitiveObject from the view definition
701
714
  re.TransitiveObject = viewFullName;
702
715
  }
@@ -734,18 +747,9 @@ export class ManageMetadataBase {
734
747
  organicKeyId = newKey.recordset[0].ID;
735
748
  }
736
749
  // Step 3: Upsert EntityOrganicKeyRelatedEntity for each related entity
737
- for (const reConfig of okConfig.RelatedEntities) {
738
- const relResult = await this.runQueryWithParams(pool, `
739
- ${this.selectTop(1, 'ID, Name', `FROM ${this.qs(schema, 'vwEntities')}
740
- WHERE (BaseTable = @TableName AND SchemaName = @SchemaName)
741
- OR Name = @TableName`, 'CASE WHEN BaseTable = @TableName AND SchemaName = @SchemaName THEN 0 ELSE 1 END')}
742
- `, { 'TableName': reConfig.TableName, 'SchemaName': reConfig.SchemaName });
743
- if (relResult.recordset.length === 0) {
744
- logError(` > Organic key "${okConfig.Name}": related entity "${reConfig.SchemaName}.${reConfig.TableName}" not found — skipping`);
745
- continue;
746
- }
747
- const relEntityId = relResult.recordset[0].ID;
748
- const relEntityName = relResult.recordset[0].Name;
750
+ for (const { config: reConfig, entity: relEntity } of relatedEntities) {
751
+ const relEntityId = relEntity.ID;
752
+ const relEntityName = relEntity.Name;
749
753
  // Check if this related entity mapping already exists
750
754
  const existingRel = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(schema, 'EntityOrganicKeyRelatedEntity')} WHERE EntityOrganicKeyID = @KeyID AND RelatedEntityID = @RelEntityID`, { 'KeyID': organicKeyId, 'RelEntityID': relEntityId });
751
755
  // Build field values
@@ -791,12 +795,49 @@ export class ManageMetadataBase {
791
795
  }
792
796
  }
793
797
  catch (err) {
798
+ // Keep going so one bad key doesn't block the others — but count it: a key that failed
799
+ // here (bridge-view DDL the database rejected, a refused view drop, …) is missing from
800
+ // the database, and the run must not report success for it.
801
+ failedCount++;
794
802
  const errMessage = err instanceof Error ? err.message : String(err);
795
803
  logError(` > Organic key config: Failed to process "${okConfig.Name}" on ${ownerEntityName}: ${errMessage}`);
796
804
  }
797
805
  }
798
806
  }
799
- return { success: true, createdCount, updatedCount };
807
+ return { success: failedCount === 0, createdCount, updatedCount, failedCount };
808
+ }
809
+ /**
810
+ * Finds the entity an organic-key config names: by base table + schema, else by entity name.
811
+ * Returns `null` when neither matches.
812
+ */
813
+ async findOrganicKeyEntity(pool, schemaName, tableName) {
814
+ const result = await this.runQueryWithParams(pool, `
815
+ ${this.selectTop(1, 'ID, Name', `FROM ${this.qs(mj_core_schema(), 'vwEntities')}
816
+ WHERE (BaseTable = @TableName AND SchemaName = @SchemaName)
817
+ OR Name = @TableName`, 'CASE WHEN BaseTable = @TableName AND SchemaName = @SchemaName THEN 0 ELSE 1 END')}
818
+ `, { 'TableName': tableName, 'SchemaName': schemaName });
819
+ const row = result.recordset[0];
820
+ return row ? { ID: row.ID, Name: row.Name } : null;
821
+ }
822
+ /**
823
+ * Resolves every related entity of an organic key, in config order. Logs each one that doesn't
824
+ * resolve and returns `null` if any is missing, so the caller can skip the key before writing any
825
+ * of it.
826
+ */
827
+ async resolveOrganicKeyRelatedEntities(pool, okConfig) {
828
+ const resolved = [];
829
+ let missing = 0;
830
+ for (const reConfig of okConfig.RelatedEntities) {
831
+ const entity = await this.findOrganicKeyEntity(pool, reConfig.SchemaName, reConfig.TableName);
832
+ if (entity) {
833
+ resolved.push({ config: reConfig, entity });
834
+ }
835
+ else {
836
+ missing++;
837
+ logError(` > Organic key "${okConfig.Name}": related entity "${reConfig.SchemaName}.${reConfig.TableName}" not found — the key is not applied`);
838
+ }
839
+ }
840
+ return missing === 0 ? resolved : null;
800
841
  }
801
842
  /**
802
843
  * Builds the entity lookup behind {@link processISARelationshipConfig}: match on Name first, else
@@ -2062,6 +2103,10 @@ export class ManageMetadataBase {
2062
2103
  if (organicKeyResult.createdCount > 0 || organicKeyResult.updatedCount > 0) {
2063
2104
  logStatus(` > Organic keys: ${organicKeyResult.createdCount} created, ${organicKeyResult.updatedCount} updated from config`);
2064
2105
  }
2106
+ if (!organicKeyResult.success) {
2107
+ logError(` Error processing organic keys: ${organicKeyResult.failedCount} key(s) failed and were not applied — see the errors above`);
2108
+ bSuccess = false;
2109
+ }
2065
2110
  // Config-driven base-view materialization — emit the physical table + wrapper view and
2066
2111
  // attach an "MJ: Materialized Results" row to the existing entity. Runs AFTER entity fields
2067
2112
  // are managed so the snapshot shape reflects the current base view (plan §4.1).
@@ -3657,6 +3702,22 @@ export class ManageMetadataBase {
3657
3702
  const step7Elapsed = ((new Date().getTime() - step7StartTime.getTime()) / 1000).toFixed(1);
3658
3703
  succeedSpinner(`Advanced generation completed (${step7Elapsed}s)`);
3659
3704
  }
3705
+ // Deterministic search-flag hygiene. Deliberately OUTSIDE the `skipAdvancedGeneration`
3706
+ // guard above: advanced generation is off by default, and an entity created under that
3707
+ // default is precisely the one that ends up flagged searchable with nothing searchable on
3708
+ // it. Ordered after it so the model's choices, where it did run, are already applied and
3709
+ // the seed only fills a genuine gap. See buildSearchFlagHygieneSQL.
3710
+ if (!await this.applySearchFlagHygiene(pool, excludeSchemas)) {
3711
+ logError('Error applying search flag hygiene');
3712
+ // FATAL, unlike advanced generation. This pass compares before it writes, so a failure
3713
+ // means the probes could not run — and because nothing is written in that case, the pass
3714
+ // failing is indistinguishable from the pass having nothing to do: no SQL, no artifact,
3715
+ // every gate green. Advanced generation can degrade quietly because it only ever ADDS
3716
+ // model-suggested metadata; this one turns search OFF on entities and is the only thing
3717
+ // keeping AllowUserSearchAPI honest, so a run where it did not execute must not be
3718
+ // reported as a clean run.
3719
+ bSuccess = false;
3720
+ }
3660
3721
  logStatus(` Total time to manage entity fields: ${(new Date().getTime() - startTime.getTime()) / 1000} seconds`);
3661
3722
  return bSuccess;
3662
3723
  }
@@ -6643,6 +6704,279 @@ export class ManageMetadataBase {
6643
6704
  }
6644
6705
  }
6645
6706
  }
6707
+ /**
6708
+ * Build the two deterministic search-flag hygiene statements.
6709
+ *
6710
+ * These exist because the LLM-driven smart-field pipeline cannot be relied on to run at all:
6711
+ * `AdvancedGeneration.enabled` reads `enableAdvancedGeneration ?? false`, so on a default
6712
+ * configuration none of `applySearchableFieldUpdates` / `applyEntitySearchConfig` ever
6713
+ * executes — while every new entity is still INSERTed with `AllowUserSearchAPI = 1`
6714
+ * (see `createNewEntityInsertSQL`). The result is an entity flagged searchable with nothing
6715
+ * searchable on it, which is not a harmless default: `UserSearchString` against such an entity
6716
+ * is a documented no-op (MJ#4581/#4582), so the data provider ignores the term and returns the
6717
+ * UNFILTERED table. Global search then fans out to it on every keystroke and discards every row.
6718
+ *
6719
+ * Both statements are deterministic, need no model, and are safe to run on every pass:
6720
+ *
6721
+ * 1. **Seed** — an entity with NO searchable field gets its name-like columns flagged
6722
+ * (`NAME_LIKE_FIELD_NAMES`: Name, Title, FirstName, LastName, …). Those are what a person
6723
+ * types into a search box, so they are the one defensible default. Only fires when the
6724
+ * entity has nothing flagged at all, so it fills a gap rather than overriding anyone —
6725
+ * including the LLM, which has already run by this point when it is enabled.
6726
+ *
6727
+ * Note this keys on the field's NAME, not on `IsNameField`. That flag looks like the
6728
+ * obvious source and is the wrong one: measured against a real database, seeding from it
6729
+ * would flag 54 fields of which 41 are VIRTUAL — the denormalized FK display columns
6730
+ * CodeGen puts on views (`Action`, `Agent`, `Artifact`). Those are computed by JOIN, so a
6731
+ * LIKE against them cannot seek any index, and flagging them would push 49 junction
6732
+ * entities into the global-search fan-out with unindexable predicates — the exact cost
6733
+ * `search-guardrails.ts` exists to prevent. It also picked up identifiers (`RecordID`,
6734
+ * `Token`, `ExternalSystemRecordID`) and a `Description`, which `isNarrativeFieldName`
6735
+ * rejects on the LLM path. And it would still not have fixed `MJ: Employees`, whose only
6736
+ * `IsNameField` is the virtual `FirstLast`; keying on the name reaches its real
6737
+ * `FirstName` / `LastName` columns, which is what the reported bug needed.
6738
+ * 2. **Clear** — an entity STILL left with no searchable field has `AllowUserSearchAPI` turned
6739
+ * off, so it drops out of the search fan-out instead of contributing noise.
6740
+ *
6741
+ * Both honor the `AutoUpdate*` opt-outs, which is how an operator pins a hand-made decision —
6742
+ * and is exactly how the curated entries in `metadata/entities/.entity-search-exclusions.json`
6743
+ * protect themselves (they set `AutoUpdateAllowUserSearchAPI` to false alongside the flag).
6744
+ *
6745
+ * Full-text-search entities are exempt from both. An FTS entity is searchable through its
6746
+ * INDEX: `createViewUserSearchSQL` takes the full-text branch before it ever reads
6747
+ * `IncludeInUserSearchAPI`, so those flags are dead metadata there and the entity is a
6748
+ * perfectly valid search target without them.
6749
+ *
6750
+ * Written as `UPDATE ... WHERE <key> IN (subquery)` rather than `UPDATE ... FROM ... JOIN`,
6751
+ * which is T-SQL-only. The subquery form is ANSI and runs unchanged on both platforms.
6752
+ */
6753
+ buildSearchFlagHygieneSQL(excludeSchemas) {
6754
+ const coreSchema = mj_core_schema();
6755
+ const entity = this.qs(coreSchema, 'Entity');
6756
+ const entityField = this.qs(coreSchema, 'EntityField');
6757
+ const yes = this.boolLit(true);
6758
+ const no = this.boolLit(false);
6759
+ // Same set the LLM path's isNameLikeFieldName() tests, lowered for a case-insensitive
6760
+ // comparison that does not depend on the database collation.
6761
+ const nameLikeList = NAME_LIKE_FIELD_NAMES.map(n => `'${n.toLowerCase()}'`).join(',');
6762
+ // Every name in NAME_LIKE_FIELD_NAMES resolves to the same predicate, so one literal covers
6763
+ // the whole seed. This MUST be set explicitly: `EntityField.UserSearchPredicateAPI` defaults
6764
+ // to 'Contains' in the database, which is `LIKE '%term%'` — the unindexable scan the
6765
+ // guardrails exist to prevent. Seeding the flag without the predicate would have made every
6766
+ // seeded entity a full scan on every keystroke.
6767
+ const seedPredicate = defaultPredicateFor(NAME_LIKE_FIELD_NAMES[0]);
6768
+ // The entity-shape guardrails the LLM path applies (`entityLevelEnableBlockedReason`).
6769
+ // A log / audit / run-history table grows without bound and a detail / line-item child is
6770
+ // reached through its parent; neither is a global-search target, whichever columns it has.
6771
+ // Expressed as SQL rather than reusing the regex helpers because this runs in the database.
6772
+ const shapeSuffixes = [
6773
+ 'Logs', 'Log', 'Runs', 'Run', 'Run History', 'Run Steps', 'Run Messages', 'Execution Logs',
6774
+ 'Details', 'Detail', 'Lines', 'Line', 'Items', 'Item', 'Steps', 'Step',
6775
+ 'Params', 'Param', 'Mappings', 'Mapping',
6776
+ ];
6777
+ // WORD boundaries, not raw suffixes. `LIKE '%Lines'` is a case-insensitive endsWith under
6778
+ // the default collation, so it matched `Pipelines`, `Guidelines`, `Timelines`, `Airlines`,
6779
+ // `Deadlines` and `Baselines`; `LIKE '%Logs'` matched `Catalogs`, `Dialogs` and `Blogs`;
6780
+ // `LIKE '%Audit%'` matched `Auditors`. MJ's own metadata was not exempt — `MJ: ML Training
6781
+ // Pipelines` was caught by `'%Lines'`. Those entities were then excluded from the seed and
6782
+ // swept up by the clear (which carries no shape filter, deliberately — see below), so a
6783
+ // guardrail meaning "do not bother seeding these" silently turned search OFF on them.
6784
+ //
6785
+ // Matching the final WORD keeps every intended shape — `Order Details`, `Audit Logs`,
6786
+ // `MJ: AI Agent Runs` — while `Catalogs`, `Pipelines` and `Auditors` fall through to the
6787
+ // seed as ordinary entities.
6788
+ const nameEndsWithWord = (word) => `(e.${this.qi('Name')} = '${word}' OR e.${this.qi('Name')} LIKE '% ${word}')`;
6789
+ const nameContainsWord = (word) => `(e.${this.qi('Name')} = '${word}'`
6790
+ + ` OR e.${this.qi('Name')} LIKE '${word} %'`
6791
+ + ` OR e.${this.qi('Name')} LIKE '% ${word}'`
6792
+ + ` OR e.${this.qi('Name')} LIKE '% ${word} %')`;
6793
+ const shapeClauses = shapeSuffixes
6794
+ .map(sfx => nameEndsWithWord(sfx))
6795
+ .concat([nameContainsWord('Audit'), nameContainsWord('Record Change')])
6796
+ .join(' OR ');
6797
+ const entityShapeFilter = `AND NOT (${shapeClauses})`;
6798
+ // Schema names are configuration, not literals we control: double any apostrophe rather
6799
+ // than interpolating it straight into the statement.
6800
+ const schemaFilter = excludeSchemas.length > 0
6801
+ ? `AND e.${this.qi('SchemaName')} NOT IN (${excludeSchemas.map(sc => `'${sc.replace(/'/g, "''")}'`).join(',')})`
6802
+ : '';
6803
+ // "This entity has nothing a user search can match." Mirrors
6804
+ // `EntityInfo.HasSearchFields` and the runtime screen in EntitySearchProvider.
6805
+ const noSearchableField = `NOT EXISTS (
6806
+ SELECT 1 FROM ${entityField} f2
6807
+ WHERE f2.${this.qi('EntityID')} = e.${this.qi('ID')}
6808
+ AND f2.${this.qi('IncludeInUserSearchAPI')} = ${yes}
6809
+ )`;
6810
+ // An FTS entity is searchable through its index, with no per-field flags involved.
6811
+ const notFullText = `AND ${this.coalesce(`e.${this.qi('FullTextSearchEnabled')}`, no)} = ${no}`;
6812
+ // The eligibility predicate here mirrors `isFieldEligibleForUserSearch` (and the runtime
6813
+ // `isTextSearchableType` it was written against): not the primary key, a bounded text
6814
+ // column. A name field that is neither is left alone rather than flagged uselessly.
6815
+ // The candidate SELECTs are built once and used twice: once as the UPDATE's subquery, once
6816
+ // as the probe `applySearchFlagHygiene` runs to decide whether to emit the UPDATE at all.
6817
+ // Sharing the text is the point — a probe whose predicates could drift from the statement
6818
+ // it guards would either suppress a needed write or emit a no-op one.
6819
+ const seedCandidateSQL = `
6820
+ SELECT ranked.${this.qi('ID')} FROM (
6821
+ SELECT f.${this.qi('ID')},
6822
+ ROW_NUMBER() OVER (
6823
+ PARTITION BY f.${this.qi('EntityID')}
6824
+ ORDER BY f.${this.qi('Sequence')}, f.${this.qi('Name')}
6825
+ ) AS rn
6826
+ FROM ${entityField} f
6827
+ INNER JOIN ${entity} e ON e.${this.qi('ID')} = f.${this.qi('EntityID')}
6828
+ WHERE LOWER(f.${this.qi('Name')}) IN (${nameLikeList})
6829
+ AND f.${this.qi('AutoUpdateIncludeInUserSearchAPI')} = ${yes}
6830
+ AND f.${this.qi('IncludeInUserSearchAPI')} = ${no}
6831
+ AND ${this.coalesce(`f.${this.qi('IsPrimaryKey')}`, no)} = ${no}
6832
+ AND ${this.coalesce(`f.${this.qi('IsVirtual')}`, no)} = ${no}
6833
+ AND LOWER(f.${this.qi('Type')}) IN ('nvarchar','varchar','char','nchar')
6834
+ AND ${this.coalesce(`f.${this.qi('Length')}`, '0')} <> -1
6835
+ AND e.${this.qi('VirtualEntity')} = ${no}
6836
+ AND e.${this.qi('AllowUserSearchAPI')} = ${yes}
6837
+ ${notFullText}
6838
+ ${entityShapeFilter}
6839
+ ${schemaFilter}
6840
+ AND ${noSearchableField}
6841
+ ) ranked
6842
+ WHERE ranked.rn <= ${MAX_SEARCHABLE_FIELDS_PER_ENTITY}`;
6843
+ // The clear carries NO entity-shape filter, and that is deliberate rather than an
6844
+ // oversight: a log / run / detail table is exactly what should drop out of the search
6845
+ // fan-out, so the shapes the seed refuses to touch are meant to fall through to here.
6846
+ //
6847
+ // That composition is load-bearing and worth stating, because it means the shape list
6848
+ // above does not merely withhold help — it DECIDES which entities get search turned off.
6849
+ // A name wrongly matched there is not "left alone", it is disabled. Which is why the
6850
+ // matching is word-boundary (see nameEndsWithWord) and why widening that list is a
6851
+ // destructive change, not a conservative one.
6852
+ const clearWhere = `e.${this.qi('AllowUserSearchAPI')} = ${yes}
6853
+ AND e.${this.qi('AutoUpdateAllowUserSearchAPI')} = ${yes}
6854
+ AND e.${this.qi('VirtualEntity')} = ${no}
6855
+ ${notFullText}
6856
+ ${schemaFilter}
6857
+ AND ${noSearchableField}`;
6858
+ const clearCandidateSQL = `
6859
+ SELECT e.${this.qi('ID')}
6860
+ FROM ${entity} e
6861
+ WHERE ${clearWhere}`;
6862
+ const seedSQL = `
6863
+ UPDATE ${entityField}
6864
+ SET ${this.qi('IncludeInUserSearchAPI')} = ${yes},
6865
+ ${this.qi('UserSearchPredicateAPI')} = '${seedPredicate}'
6866
+ WHERE ${this.qi('ID')} IN (${seedCandidateSQL}
6867
+ )`;
6868
+ const clearSQL = `
6869
+ UPDATE ${entity}
6870
+ SET ${this.qi('AllowUserSearchAPI')} = ${no}
6871
+ WHERE ${this.qi('ID')} IN (${clearCandidateSQL}
6872
+ )`;
6873
+ // Derived-table alias is required by T-SQL and accepted by PostgreSQL, so one form serves
6874
+ // both. COUNT(*) rather than EXISTS because the count also feeds the status line.
6875
+ const seedProbeSQL = `SELECT COUNT(*) AS ${this.qi('Cnt')} FROM (${seedCandidateSQL}
6876
+ ) probe`;
6877
+ // The clear probe returns NAMES, not a count. Disabling search is the destructive half of
6878
+ // this pass and it is effectively one-way: getting it back means flagging a field by hand
6879
+ // AND pinning AutoUpdateAllowUserSearchAPI = 0, or the next run undoes the repair. An
6880
+ // operator told "cleared 14 entities" has no way to learn which 14 without going to the
6881
+ // database; naming them in the run output is the difference between an auditable change
6882
+ // and a silent one. Shares `clearWhere` with the UPDATE so the two cannot disagree.
6883
+ const clearProbeSQL = `
6884
+ SELECT e.${this.qi('Name')} AS ${this.qi('Name')}
6885
+ FROM ${entity} e
6886
+ WHERE ${clearWhere}
6887
+ ORDER BY e.${this.qi('Name')}`;
6888
+ return { seedSQL, clearSQL, seedProbeSQL, clearProbeSQL };
6889
+ }
6890
+ /**
6891
+ * Run the deterministic search-flag hygiene pass (see {@link buildSearchFlagHygieneSQL}).
6892
+ *
6893
+ * Runs OUTSIDE `applyAdvancedGeneration` and therefore regardless of whether advanced
6894
+ * generation is enabled — which is the whole point, since it is off by default and an entity
6895
+ * created under that default is exactly the one that needs this. Ordered after it so the
6896
+ * model's choices, when it did run, are already in place and the seed only fills a real gap.
6897
+ *
6898
+ * COMPARE FIRST, then write — the T20 contract every-run config writers are held to (see
6899
+ * `__tests__/idempotency/config-writers-compare-first.test.ts`). The two UPDATEs converge on
6900
+ * their own, because the first pass makes `noSearchableField` false for every row it touches,
6901
+ * so re-running them changes nothing. That is not sufficient: `LogSQLBatchAndExecute` writes
6902
+ * whatever it is handed into the run's `CodeGen_Run_*.sql` capture whether or not a row moves,
6903
+ * and the drift gate's warm-twice stage fails on ANY capture surviving a second run. Emitting
6904
+ * unconditionally therefore reddened the gate on every PR while the data was perfectly
6905
+ * idempotent — the data converged, the log did not. So each statement is emitted only when its
6906
+ * own candidate probe finds work.
6907
+ */
6908
+ async applySearchFlagHygiene(pool, excludeSchemas) {
6909
+ try {
6910
+ const { seedSQL, clearSQL, seedProbeSQL, clearProbeSQL } = this.buildSearchFlagHygieneSQL(excludeSchemas);
6911
+ // Order matters: seeding first means an entity whose name field was just flagged is no
6912
+ // longer a candidate for having its AllowUserSearchAPI cleared. Reversed, the pass would
6913
+ // disable search on an entity it was about to make searchable.
6914
+ //
6915
+ // 4th arg is `isRecurringScript`, NOT a throw flag — passing `false` here is the default
6916
+ // and is spelled out only to make the intent explicit. Errors are caught below.
6917
+ const seedCount = await this.searchFlagHygieneCandidateCount(pool, seedProbeSQL);
6918
+ if (seedCount > 0) {
6919
+ logStatus(` Search-flag hygiene: seeding ${seedCount} name field(s)`);
6920
+ await this.LogSQLBatchAndExecute(pool, [seedSQL], 'Deterministic search-flag hygiene — seed name fields', false);
6921
+ }
6922
+ // Probed AFTER the seed has run, not alongside it. Seeding changes which entities still
6923
+ // have nothing searchable, so a clear probe taken before it would count entities the seed
6924
+ // was about to repair and emit a statement that then matched nothing — reintroducing the
6925
+ // stray capture file this is here to avoid.
6926
+ const clearNames = await this.searchFlagHygieneClearCandidates(pool, clearProbeSQL);
6927
+ if (clearNames.length > 0) {
6928
+ // NAME them. This is the destructive half and it is effectively one-way for an
6929
+ // operator who does not know it happened.
6930
+ const shown = clearNames.slice(0, 25).join(', ');
6931
+ const more = clearNames.length > 25 ? `, ... and ${clearNames.length - 25} more` : '';
6932
+ logStatus(` Search-flag hygiene: turning AllowUserSearchAPI OFF on ${clearNames.length} entity(ies): ${shown}${more}`);
6933
+ await this.LogSQLBatchAndExecute(pool, [clearSQL], 'Deterministic search-flag hygiene — clear AllowUserSearchAPI', false);
6934
+ }
6935
+ return true;
6936
+ }
6937
+ catch (ex) {
6938
+ // A probe that cannot run is NOT the same as "nothing to do", and must not be allowed to
6939
+ // look like it. Before this pass compared first, a malformed statement was appended to
6940
+ // the CodeGen_Run capture by SQLLogging BEFORE it was executed, so a broken pass left a
6941
+ // surviving artifact and reddened the drift gate. Probing first removes that signal: the
6942
+ // throw now happens before anything is written, so without a loud failure here the pass
6943
+ // would silently do nothing while every gate stayed green — the exact silent no-op this
6944
+ // whole change exists to eliminate, one level up in the fixer. The caller fails the run.
6945
+ logError('Search-flag hygiene FAILED — search flags were NOT reconciled on this run', ex);
6946
+ return false;
6947
+ }
6948
+ }
6949
+ /**
6950
+ * Row count for one of the search-flag hygiene probes (see {@link applySearchFlagHygiene}).
6951
+ *
6952
+ * Reads the first column of the first row positionally rather than by name, because the alias
6953
+ * comes back cased differently across drivers. A probe that returns nothing is treated as no
6954
+ * work, which is the safe direction: the statement is skipped rather than emitted blind.
6955
+ */
6956
+ async searchFlagHygieneCandidateCount(pool, probeSQL) {
6957
+ const result = await this.runQuery(pool, probeSQL);
6958
+ const row = result?.recordset?.[0];
6959
+ if (!row) {
6960
+ return 0;
6961
+ }
6962
+ const count = Number(Object.values(row)[0]);
6963
+ return Number.isFinite(count) ? count : 0;
6964
+ }
6965
+ /**
6966
+ * Names of the entities the clear would disable (see {@link applySearchFlagHygiene}).
6967
+ *
6968
+ * Valid only AFTER the seed has run, since seeding changes which entities still have nothing
6969
+ * searchable. Names rather than a count because this is the destructive half: the run output
6970
+ * is the only place an operator can see which entities lost search, and getting it back is
6971
+ * manual.
6972
+ */
6973
+ async searchFlagHygieneClearCandidates(pool, probeSQL) {
6974
+ const result = await this.runQuery(pool, probeSQL);
6975
+ const rows = result?.recordset ?? [];
6976
+ return rows
6977
+ .map(r => String(Object.values(r)[0] ?? '').trim())
6978
+ .filter(n => n.length > 0);
6979
+ }
6646
6980
  /**
6647
6981
  * Returns true if the field is a sensible target for LIKE-based user search.
6648
6982
  * Mirrors the runtime guard in GenericDatabaseProvider.isTextSearchableType /
@@ -7140,8 +7474,8 @@ WHERE
7140
7474
  * @param isRecurringScript - if set to true tells the logger that the provided SQL represents a recurring script meaning it is something that is executed, generally, for all CodeGen runs. In these cases, the Config settings can result in omitting these recurring scripts from being logged because the configuration environment may have those recurring scripts already set to run after all run-specific migrations get run.
7141
7475
  * @returns - The result of the query execution.
7142
7476
  */
7143
- async LogSQLAndExecute(pool, query, description, isRecurringScript = false, includeBatchSeparator = false, batchSeparator = 'GO') {
7144
- return await SQLLogging.LogSQLAndExecute(pool, this.qsql(query), description, isRecurringScript, includeBatchSeparator, batchSeparator);
7477
+ async LogSQLAndExecute(pool, query, description, isRecurringScript = false, includeBatchSeparator = false, batchSeparator = 'GO', requiresOwnBatch = false) {
7478
+ return await SQLLogging.LogSQLAndExecute(pool, this.qsql(query), description, isRecurringScript, includeBatchSeparator, batchSeparator, requiresOwnBatch);
7145
7479
  }
7146
7480
  /**
7147
7481
  * Logs and executes a sequence of SQL statements as a single batch.
@@ -7162,7 +7496,7 @@ WHERE
7162
7496
  async LogSQLBatchAndExecute(pool, statements, description, isRecurringScript = false, includeBatchSeparator = false, batchSeparator = 'GO') {
7163
7497
  const terminated = [];
7164
7498
  for (const s of statements) {
7165
- const trimmed = (s ?? '').replace(/[\s;]+$/g, '');
7499
+ const trimmed = trimTrailingStatementTerminators(s ?? '');
7166
7500
  if (trimmed.length === 0)
7167
7501
  continue;
7168
7502
  terminated.push(`${trimmed};`);