@memberjunction/sqlserver-dataprovider 6.1.0-edge.4 → 6.1.0-edge.6

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.
@@ -22,6 +22,7 @@ import { GenericDatabaseProvider } from '@memberjunction/generic-database-provid
22
22
  import sql from 'mssql';
23
23
  import { BehaviorSubject, Subject, concatMap, from, tap, catchError, of } from 'rxjs';
24
24
  import { SQLServerTransactionGroup } from './SQLServerTransactionGroup.js';
25
+ import { SQLServerProviderConfigData } from './types.js';
25
26
  import { v4 as uuidv4 } from 'uuid';
26
27
  import { SQLServerDialect } from '@memberjunction/sql-dialect';
27
28
  /**
@@ -163,40 +164,12 @@ async function executeSQLCore(query, parameters, context, options, isRetry = fal
163
164
  throw error;
164
165
  }
165
166
  }
166
- /**
167
- * SQL Server implementation of the MemberJunction data provider interfaces.
168
- *
169
- * This class provides comprehensive database functionality including:
170
- * - CRUD operations for entities
171
- * - Metadata management and caching
172
- * - View and query execution
173
- * - Transaction support
174
- * - SQL logging capabilities
175
- * - Record change tracking
176
- * - AI integration hooks
177
- *
178
- * @example
179
- * ```typescript
180
- * const config = new SQLServerProviderConfigData(dataSource);
181
- * const provider = new SQLServerDataProvider(config);
182
- * await provider.Config();
183
- * ```
184
- */
185
167
  export class SQLServerDataProvider extends GenericDatabaseProvider {
186
168
  constructor() {
187
169
  /**************************************************************************/
188
170
  // SQL Dialect Implementations (override abstract methods from DatabaseProviderBase)
189
171
  /**************************************************************************/
190
172
  super(...arguments);
191
- this._transactionDepth = 0;
192
- /**
193
- * Set while an OUTERMOST `BeginTransaction` is awaiting `sql.Transaction.begin()`. Concurrent
194
- * `BeginTransaction` callers await this first so they cannot take the nested-savepoint branch
195
- * before `_transaction` exists. Null whenever no begin is in flight.
196
- */
197
- this._beginInFlight = null;
198
- this._savepointCounter = 0;
199
- this._savepointStack = [];
200
173
  this._bAllowRefresh = true;
201
174
  this._needsDatetimeOffsetAdjustment = false;
202
175
  this._datetimeOffsetTestComplete = false;
@@ -273,39 +246,24 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
273
246
  return this._transactionState$.asObservable();
274
247
  }
275
248
  /**
276
- * Gets the current transaction nesting depth
277
- * 0 = no transaction, 1 = first level, 2+ = nested transactions
249
+ * Checks if we're currently in a transaction (at any depth)
278
250
  */
279
- get transactionDepth() {
280
- // Request-specific depth should be accessed via getTransactionContext
281
- return this._transactionDepth;
251
+ get InTransaction() {
252
+ return this.TransactionDepth > 0;
282
253
  }
283
254
  /**
284
- * Feeds real nesting depth to the entity-transaction machinery (`IsNested`, out-of-order settle
285
- * detection). Deliberately separate from `IsInTransaction`, which this provider leaves `false`
286
- * so `RunMaybeSerial` keeps fanning out — see the note in `packages/MJCore/src/generic/util.ts`.
255
+ * Checks if we're currently in a nested transaction (depth > 1)
287
256
  */
288
- get CurrentTransactionDepth() {
289
- return this._transactionDepth;
257
+ get InNestedTransaction() {
258
+ return this.TransactionDepth > 1;
290
259
  }
291
- /**
292
- * Checks if we're currently in a transaction (at any depth)
293
- */
260
+ /** @deprecated Use {@link InTransaction}. */
294
261
  get inTransaction() {
295
- return this.transactionDepth > 0;
262
+ return this.InTransaction;
296
263
  }
297
- /**
298
- * Checks if we're currently in a nested transaction (depth > 1)
299
- */
264
+ /** @deprecated Use {@link InNestedTransaction}. */
300
265
  get inNestedTransaction() {
301
- return this.transactionDepth > 1;
302
- }
303
- /**
304
- * Gets the current savepoint names in the stack (for debugging)
305
- * Returns a copy to prevent external modification
306
- */
307
- get savepointStack() {
308
- return [...this._savepointStack];
266
+ return this.InNestedTransaction;
309
267
  }
310
268
  /**
311
269
  * Gets whether a transaction is currently active
@@ -321,6 +279,17 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
321
279
  get ConfigData() {
322
280
  return super.ConfigData;
323
281
  }
282
+ /**
283
+ * Share this instance's pool + metadata; own transaction stack.
284
+ * Used by mj sync push parallelism (MJAPI per-request pattern).
285
+ */
286
+ async CreateIndependentInstance() {
287
+ const child = new SQLServerDataProvider();
288
+ const parent = this.ConfigData;
289
+ const cfg = new SQLServerProviderConfigData(this._pool, parent.MJCoreSchemaName, 0, parent.IncludeSchemas, parent.ExcludeSchemas, false);
290
+ await child.Config(cfg, this);
291
+ return child;
292
+ }
324
293
  /**
325
294
  * Configures the SQL Server data provider with connection settings and initializes the connection pool
326
295
  *
@@ -460,15 +429,20 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
460
429
  * instanceName is only inserted if it is provided in the options
461
430
  */
462
431
  get InstanceConnectionString() {
463
- // For mssql, we need to access the pool's internal connection options
464
- // Since mssql v11 doesn't expose config directly, we'll construct from what we know
432
+ // mssql exposes the pool's connection options as the public `config` property.
433
+ // Do NOT read the private `_config` member — in mssql v11+ it is a method, so
434
+ // every `_config?.x` access silently returned undefined and this getter
435
+ // degenerated to 'mssql://localhost:1433/' for every connection. Anything
436
+ // keyed by this string (e.g. shared Redis result caches) then collided
437
+ // across processes connected to entirely different databases.
465
438
  const pool = this._pool;
439
+ const config = pool.config;
466
440
  const options = {
467
441
  type: 'mssql',
468
- host: pool._config?.server || 'localhost',
469
- port: pool._config?.port || 1433,
470
- instanceName: pool._config?.options?.instanceName ? '/' + pool._config.options.instanceName : '',
471
- database: pool._config?.database || '',
442
+ host: config?.server || 'localhost',
443
+ port: config?.port || 1433,
444
+ instanceName: config?.options?.instanceName ? '/' + config.options.instanceName : '',
445
+ database: config?.database || '',
472
446
  };
473
447
  return options.type + '://' + options.host + ':' + options.port + options.instanceName + '/' + options.database;
474
448
  }
@@ -625,14 +599,20 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
625
599
  // entityInfo + effectiveBaseView are passed in (no reverse-lookup by base-view name) so the logged
626
600
  // read honors DataSource:'Materialized' — effectiveBaseView is the materialized wrapper view then,
627
601
  // and the entity's live base view otherwise.
602
+ //
603
+ // UserViewRunDetail.RecordID is a single NVARCHAR(255) holding ONE bare key value per row, and the
604
+ // read-back below is `<pk> IN (SELECT RecordID ...)` — a one-column predicate. A composite key has
605
+ // no single column to log or match on, so refuse rather than record (and re-read) a truncated key.
606
+ this.assertSingleColumnPrimaryKey(entityInfo, 'SaveViewResults (user view run logging)');
607
+ const pkName = entityInfo.FirstPrimaryKey.Name; // first-pk-ok: guarded above — view-run RecordID holds one bare key value, PrimaryKeys.length === 1 enforced
628
608
  const sSQL = `
629
609
  DECLARE @ViewIDList TABLE ( ID NVARCHAR(255) );
630
- INSERT INTO @ViewIDList (ID) (SELECT ${entityInfo.FirstPrimaryKey.Name} FROM [${entityInfo.SchemaName}].${effectiveBaseView} WHERE (${whereSQL}))
610
+ INSERT INTO @ViewIDList (ID) (SELECT ${pkName} FROM [${entityInfo.SchemaName}].${effectiveBaseView} WHERE (${whereSQL}))
631
611
  EXEC [${this.MJCoreSchemaName}].spCreateUserViewRunWithDetail(${viewId},${user.Email}, @ViewIDLIst)
632
612
  `;
633
613
  const runIDResult = await this.ExecuteSQL(sSQL, undefined, undefined, user);
634
614
  const runID = runIDResult[0].UserViewRunID;
635
- const sRetSQL = `SELECT * FROM [${entityInfo.SchemaName}].${effectiveBaseView} WHERE ${entityInfo.FirstPrimaryKey.Name} IN
615
+ const sRetSQL = `SELECT * FROM [${entityInfo.SchemaName}].${effectiveBaseView} WHERE ${pkName} IN
636
616
  (SELECT RecordID FROM [${this.MJCoreSchemaName}].vwUserViewRunDetails WHERE UserViewRunID=${runID})
637
617
  ${orderBySQL && orderBySQL.length > 0 ? ` ORDER BY ${orderBySQL}` : ''}`;
638
618
  return { executeViewSQL: sRetSQL, runID };
@@ -656,12 +636,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
656
636
  BuildSoftLinkDependencySQL(entityName, compositeKey) {
657
637
  // we need to go through ALL of the entities in the system and find all of the EntityFields that have a non-null EntityIDFieldName
658
638
  // for each of these, we generate a SQL Statement that will return the EntityName, RelatedEntityName, FieldName, and the primary key values of the related entity
639
+ //
640
+ // The entity we are finding dependents OF is `entityName` - the target. Every WHERE clause below
641
+ // filters on THAT entity's ID and THAT record's key; `entity` in the loop is the *holder* of the
642
+ // link (e.g. `MJ: Task Links`), which is a different thing entirely.
643
+ const targetEntity = this.EntityByName(entityName);
644
+ if (!targetEntity) {
645
+ throw new Error(`Entity ${entityName} not found in metadata`);
646
+ }
647
+ // The canonical stored encoding of the target record's key - `ID|<guid>` (see CompositeKey.ToRecordID).
648
+ // A RecordID column holds this, not the bare primary key value.
649
+ const targetRecordID = compositeKey.ToRecordID();
659
650
  let sSQL = '';
660
651
  this.Entities.forEach((entity) => {
661
652
  // we build a string that will concatenate all of the primary key values into a single string, this is because the primary key could be a composite key
662
653
  // we do this in SQL by combining the pirmary key name and value for each row using the default separator defined by the CompositeKey class
663
654
  // the output of this should be like the following 'Field1|Value1||Field2|Value2||Field3|Value3' where the || is the CompositeKey.DefaultFieldDelimiter and the | is the CompositeKey.DefaultValueDelimiter
664
- const quotes = entity.FirstPrimaryKey.NeedsQuotes ? "'" : '';
665
655
  const primaryKeySelectString = `CONCAT(${entity.PrimaryKeys.map((pk) => `'${pk.Name}|', CAST([${pk.Name}] AS NVARCHAR(MAX))`).join(`,'${CompositeKey.DefaultFieldDelimiter}',`)})`;
666
656
  // for this entity, check to see if it has any fields that are soft links, and for each of those, generate the SQL
667
657
  entity.Fields.filter((f) => f.EntityIDFieldName && f.EntityIDFieldName.length > 0).forEach((f) => {
@@ -671,25 +661,39 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
671
661
  // there is a layer of indirection here because each ROW in each of the entity records for this entity/field combination could point to a DIFFERENT
672
662
  // entity. We find out which entity it is pointed to via the EntityIDFieldName in the field definition, so we have to filter the rows in the entity
673
663
  // based on that.
664
+ //
665
+ // Both literals are always quoted regardless of any primary key type: the discriminator column
666
+ // is a uniqueidentifier FK to __mj.Entity, and the payload column is nvarchar. Deriving quoting
667
+ // from the holder's primary key type emitted unquoted literals for an integer-keyed holder.
674
668
  sSQL += `SELECT
675
669
  '${entityName}' AS EntityName,
676
670
  '${entity.Name}' AS RelatedEntityName,
677
671
  ${primaryKeySelectString} AS PrimaryKeyValue,
678
- '${f.Name}' AS FieldName
672
+ '${f.Name}' AS FieldName,
673
+ 1 AS IsSoftLink,
674
+ '${f.EntityIDFieldName}' AS EntityIDFieldName
679
675
  FROM
680
676
  [${entity.SchemaName}].[${entity.BaseView}]
681
677
  WHERE
682
- [${f.EntityIDFieldName}] = ${quotes}${entity.ID}${quotes} AND
683
- [${f.Name}] = ${quotes}${compositeKey.GetValueByIndex(0)}${quotes}`; // we only use the first primary key value, this is because we don't yet support composite primary keys
678
+ [${f.EntityIDFieldName}] = '${targetEntity.ID}' AND
679
+ [${f.Name}] = '${this.escapeSQLLiteral(targetRecordID)}'`;
684
680
  });
685
681
  });
686
682
  return sSQL;
687
683
  }
684
+ /**
685
+ * Escapes a value for interpolation into a single-quoted T-SQL string literal. The soft-link and
686
+ * hard-link dependency queries are assembled as SQL text rather than parameterized, so a value
687
+ * carrying an apostrophe has to be doubled or it terminates the literal.
688
+ */
689
+ escapeSQLLiteral(value) {
690
+ return value.replace(/'/g, "''");
691
+ }
688
692
  BuildHardLinkDependencySQL(entityDependencies, compositeKey) {
689
693
  let sSQL = '';
690
694
  for (const entityDependency of entityDependencies) {
691
695
  const entityInfo = this.Entities.find((e) => e.Name.trim().toLowerCase() === entityDependency.EntityName?.trim().toLowerCase());
692
- const quotes = entityInfo.FirstPrimaryKey.NeedsQuotes ? "'" : '';
696
+ const quotes = entityInfo.FirstPrimaryKey.NeedsQuotes ? "'" : ''; // first-pk-ok: a foreign key targets a single column; quoting follows that FK target's type
693
697
  const relatedEntityInfo = this.Entities.find((e) => e.Name.trim().toLowerCase() === entityDependency.RelatedEntityName?.trim().toLowerCase());
694
698
  const primaryKeySelectString = `CONCAT(${entityInfo.PrimaryKeys.map((pk) => `'${pk.Name}|', CAST([${pk.Name}] AS NVARCHAR(MAX))`).join(`,'${CompositeKey.DefaultFieldDelimiter}',`)})`;
695
699
  if (sSQL.length > 0)
@@ -698,7 +702,9 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
698
702
  '${entityDependency.EntityName}' AS EntityName,
699
703
  '${entityDependency.RelatedEntityName}' AS RelatedEntityName,
700
704
  ${primaryKeySelectString} AS PrimaryKeyValue,
701
- '${entityDependency.FieldName}' AS FieldName
705
+ '${entityDependency.FieldName}' AS FieldName,
706
+ 0 AS IsSoftLink,
707
+ NULL AS EntityIDFieldName
702
708
  FROM
703
709
  [${relatedEntityInfo.SchemaName}].[${relatedEntityInfo.BaseView}]
704
710
  WHERE
@@ -708,7 +714,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
708
714
  }
709
715
  GetRecordDependencyLinkSQL(dep, entity, relatedEntity, CompositeKey) {
710
716
  const f = relatedEntity.Fields.find((f) => f.Name.trim().toLowerCase() === dep.FieldName?.trim().toLowerCase());
711
- const quotes = entity.FirstPrimaryKey.NeedsQuotes ? "'" : '';
717
+ const quotes = entity.FirstPrimaryKey.NeedsQuotes ? "'" : ''; // first-pk-ok: a foreign key targets a single column; quoting follows that FK target's type
712
718
  if (!f) {
713
719
  throw new Error(`Field ${dep.FieldName} not found in Entity ${relatedEntity.Name}`);
714
720
  }
@@ -717,12 +723,28 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
717
723
  return `${quotes}${CompositeKey.GetValueByIndex(0)}${quotes}`;
718
724
  }
719
725
  else {
720
- // linking to something else, so we need to use that field in a sub-query
721
- // NOTICE - we are only using the FIRST primary key in our current implementation, this is because we don't yet support composite foreign keys
722
- // if we do start to support composite foreign keys, we'll need to update this code to handle that
723
- return `(SELECT ${f.RelatedEntityFieldName} FROM [${entity.SchemaName}].${entity.BaseView} WHERE ${entity.FirstPrimaryKey.Name}=${quotes}${CompositeKey.GetValueByIndex(0)}${quotes})`;
726
+ // The FK points at a non-key column of `entity`, so resolve that column for THE record being
727
+ // checked. The record is identified by its full key — every PK column, not just the first —
728
+ // otherwise a composite-key entity would match every row sharing the first column's value and
729
+ // the scalar sub-query would fail (or silently pick the wrong row).
730
+ return `(SELECT ${f.RelatedEntityFieldName} FROM [${entity.SchemaName}].${entity.BaseView} WHERE ${this.BuildFullPrimaryKeyPredicate(entity, CompositeKey)})`;
724
731
  }
725
732
  }
733
+ /**
734
+ * `pk1=v1 AND pk2=v2 ...` for every primary key column of `entity`, each value taken from the
735
+ * matching key/value pair (by field name, falling back to position) and quoted per that column's
736
+ * type. Single-column keys render exactly `ID='v'` — the same shape the pre-composite code emitted.
737
+ */
738
+ BuildFullPrimaryKeyPredicate(entity, compositeKey) {
739
+ return entity.PrimaryKeys
740
+ .map((pk, index) => {
741
+ const q = pk.NeedsQuotes ? "'" : '';
742
+ const byName = compositeKey.KeyValuePairs.find((kv) => kv.FieldName?.trim().toLowerCase() === pk.Name.trim().toLowerCase());
743
+ const value = byName ? byName.Value : compositeKey.GetValueByIndex(index);
744
+ return `${pk.Name}=${q}${value}${q}`;
745
+ })
746
+ .join(' AND ');
747
+ }
726
748
  /**
727
749
  * Generates the SQL Statement that will Save a record to the database.
728
750
  *
@@ -927,8 +949,8 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
927
949
  }
928
950
  /**
929
951
  * Renders the SQL Server DECLARE/SET/EXEC binding for a save call.
930
- * Emits per-field uuid-suffixed variables to keep batched saves
931
- * (`SQLServerTransactionGroup`) collision-free. PKs on UPDATE are
952
+ * Suffixes come from GenericDatabaseProvider.allocateSaveCallSuffix
953
+ * (PK hash, not uuidv4). PKs on UPDATE are
932
954
  * tail-appended from `entity.PrimaryKey.KeyValuePairs`.
933
955
  *
934
956
  * Emits `_Clear` companion args when a nullable column carrying a
@@ -938,7 +960,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
938
960
  * `EntityFieldInfo.NeedsClearCompanion`.
939
961
  */
940
962
  RenderSaveCallBinding(entity, fieldValues, isUpdate, _spName) {
941
- const uniqueSuffix = '_' + uuidv4().substring(0, 8).replace(/-/g, '');
963
+ const uniqueSuffix = this.allocateSaveCallSuffix(entity);
942
964
  const declarations = [];
943
965
  const setStatements = [];
944
966
  const execParams = [];
@@ -1231,7 +1253,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1231
1253
  * This function generates both the full SQL (with record change metadata) and the simple stored procedure call for delete
1232
1254
  * @returns Object with fullSQL and simpleSQL properties
1233
1255
  */
1234
- GetDeleteSQLWithDetails(entity, user) {
1256
+ GetDeleteSQLWithDetails(entity, user, skipRecordChanges = false) {
1235
1257
  let sSQL = '';
1236
1258
  const spName = entity.EntityInfo.spDelete ? entity.EntityInfo.spDelete : `spDelete${entity.EntityInfo.BaseTableCodeName}`;
1237
1259
  const sParams = entity.PrimaryKey.KeyValuePairs.map((kv) => {
@@ -1241,7 +1263,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1241
1263
  }).join(', ');
1242
1264
  const sSimpleSQL = `EXEC [${entity.EntityInfo.SchemaName}].[${spName}] ${sParams}`;
1243
1265
  const recordChangesEntityInfo = this.Entities.find((e) => e.Name === 'MJ: Record Changes');
1244
- if (entity.EntityInfo.TrackRecordChanges && entity.EntityInfo.Name.trim().toLowerCase() !== 'record changes') {
1266
+ if (entity.EntityInfo.TrackRecordChanges && !skipRecordChanges && entity.EntityInfo.Name.trim().toLowerCase() !== 'record changes') {
1245
1267
  // don't track changes for the record changes entity
1246
1268
  const oldData = entity.GetAll(true); // get all the OLD values
1247
1269
  const sTableDeclare = entity.PrimaryKeys.map((pk) => {
@@ -1302,8 +1324,8 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1302
1324
  // RenderSaveCallBinding / WrapSaveCallForResult /
1303
1325
  // WrapSaveCallWithRecordChange (see this file's "Save Grammar" section
1304
1326
  // above). See plans/sp-save-builder-generic-layer-refactor.md (rev 4).
1305
- GenerateDeleteSQL(entity, user) {
1306
- const sqlDetails = this.GetDeleteSQLWithDetails(entity, user);
1327
+ GenerateDeleteSQL(entity, user, options) {
1328
+ const sqlDetails = this.GetDeleteSQLWithDetails(entity, user, options?.SkipRecordChanges === true);
1307
1329
  return {
1308
1330
  fullSQL: sqlDetails.fullSQL,
1309
1331
  simpleSQL: sqlDetails.simpleSQL,
@@ -1487,7 +1509,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1487
1509
  transaction = connectionSource;
1488
1510
  }
1489
1511
  else if (!connectionSource) {
1490
- // Use instance transaction
1512
+ this.AssertAmbientTransactionUsable();
1491
1513
  transaction = this._transaction;
1492
1514
  }
1493
1515
  // Create the execution context
@@ -1719,6 +1741,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1719
1741
  */
1720
1742
  async ExecuteSQLBatch(queries, parameters, options, contextUser) {
1721
1743
  try {
1744
+ this.AssertAmbientTransactionUsable();
1722
1745
  // Build combined batch SQL and parameters (same as static method)
1723
1746
  let batchSQL = '';
1724
1747
  const batchParameters = {};
@@ -1897,7 +1920,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1897
1920
  .map(child => {
1898
1921
  const schema = child.SchemaName || '__mj';
1899
1922
  const sourceRef = dialect.QuoteSchema(schema, child.BaseView);
1900
- const pkRef = dialect.QuoteIdentifier(child.PrimaryKeys[0].Name);
1923
+ const pkRef = dialect.QuoteIdentifier(child.FirstPrimaryKey.Name); // first-pk-ok: IS-A child shares its parent's single-column key by design
1901
1924
  const nameLit = dialect.QuoteStringLiteral(child.Name);
1902
1925
  return `SELECT ${nameLit} AS ${aliasName} FROM ${sourceRef} WHERE ${pkRef} = ${pkValueLit}`;
1903
1926
  });
@@ -1934,7 +1957,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1934
1957
  BuildSiblingRecordChangeSQL(varName, entityInfo, safeChangesJSON, safeChangesDesc, safePKValue, safeUserId) {
1935
1958
  const schema = entityInfo.SchemaName || '__mj';
1936
1959
  const view = entityInfo.BaseView;
1937
- const pkName = entityInfo.PrimaryKeys[0]?.Name ?? 'ID';
1960
+ const pkName = entityInfo.FirstPrimaryKey.Name; // first-pk-ok: IS-A sibling shares the parent's single-column key; safePKValue is that one value
1938
1961
  const safeEntityName = entityInfo.Name.replace(/'/g, "''");
1939
1962
  const recordID = entityInfo.PrimaryKeys
1940
1963
  .map(pk => `${pk.CodeName}${CompositeKey.DefaultValueDelimiter}${safePKValue}`)
@@ -1956,210 +1979,99 @@ IF ${varName} IS NOT NULL
1956
1979
  @Status='Complete',
1957
1980
  @Comments=NULL;`;
1958
1981
  }
1959
- async BeginTransaction() {
1960
- // Serialize against an outermost begin that is still in flight. Without this, a second caller
1961
- // arriving during that window takes the depth-2 savepoint branch and issues
1962
- // `SAVE TRANSACTION` while `this._transaction` is still null — which silently runs it on the
1963
- // POOL, outside the transaction it is supposed to be marking. Swallow the in-flight begin's
1964
- // own rejection: if it failed, the depth is back to 0 and this caller must try its own begin.
1965
- while (this._beginInFlight) {
1966
- await this._beginInFlight.catch(() => undefined);
1982
+ get HasPhysicalTransaction() {
1983
+ return !!this._transaction;
1984
+ }
1985
+ async BeginPhysicalTransaction() {
1986
+ // Assign `_transaction` only after begin() resolves so concurrent ExecuteSQL
1987
+ // never sees an un-begun handle. Begin/commit/rollback themselves serialize
1988
+ // on GenericDatabaseProvider.WithTransactionLock.
1989
+ if (this._transaction) {
1990
+ throw new Error('Transaction state corrupted: BeginPhysicalTransaction with an existing handle');
1967
1991
  }
1968
- try {
1969
- this._transactionDepth++;
1970
- if (this._transactionDepth === 1) {
1971
- // First transaction - actually begin using mssql Transaction object.
1972
- //
1973
- // 🚨 BEGIN LOCALLY, PUBLISH AFTER. `this._transaction` is a SHARED provider field that
1974
- // every ExecuteSQL call with no explicit connectionSource picks up (see ~1768). Assigning
1975
- // it before `begin()` resolves publishes an UN-BEGUN transaction to the whole process, and
1976
- // any concurrent query in that window dies with mssql's
1977
- // "Transaction has not begun. Call begin() first."
1978
- // Worse, if `begin()` THROWS, the old code's catch block restored the depth but left the
1979
- // un-begun object assigned — poisoning the provider PERMANENTLY, so every later save on it
1980
- // failed with that same message until the process restarted.
1981
- //
1982
- // Found during the 6.1 release: it silently destroyed AI agent run persistence. Agent-run,
1983
- // step, prompt-run and heartbeat saves all failed ("Failed to create agent run record",
1984
- // "N step record save(s) failed"), leaving IT56/IT57's live checks with no steps to read.
1985
- // They therefore reported `model-noncompliance:` — byte-identically across every run and
1986
- // every model tier — for a defect that had nothing to do with the model.
1987
- const begun = (async () => {
1988
- const transaction = new sql.Transaction(this._pool);
1989
- await transaction.begin();
1990
- this._transaction = transaction;
1991
- // Emit transaction state change
1992
- this._transactionState$.next(true);
1993
- })();
1994
- this._beginInFlight = begun;
1995
- try {
1996
- await begun;
1997
- }
1998
- finally {
1999
- this._beginInFlight = null;
2000
- }
2001
- }
2002
- else {
2003
- // Nested transaction - create a savepoint
2004
- const savepointName = `SavePoint_${++this._savepointCounter}`;
2005
- this._savepointStack.push(savepointName);
2006
- // Create savepoint for nested transaction
2007
- await this.ExecuteSQL(`SAVE TRANSACTION ${savepointName}`, null, {
2008
- description: `Creating savepoint ${savepointName} at depth ${this._transactionDepth}`,
2009
- ignoreLogging: true
2010
- });
2011
- }
1992
+ const transaction = new sql.Transaction(this._pool);
1993
+ await transaction.begin();
1994
+ this._transaction = transaction;
1995
+ this._transactionState$.next(true);
1996
+ }
1997
+ /**
1998
+ * Internal mssql transaction interface to safely inspect `_activeRequest` without `any`.
1999
+ */
2000
+ async waitForActiveRequest(timeoutMs = 2000) {
2001
+ if (!this._transaction) {
2002
+ return;
2012
2003
  }
2013
- catch (e) {
2014
- this._transactionDepth--; // Restore depth on error
2015
- // Never leave a transaction object published once the depth is back to 0 — a non-null
2016
- // `_transaction` with no live transaction behind it poisons every subsequent ExecuteSQL on
2017
- // this provider. The publish-after-begin above already prevents the common case; this is the
2018
- // backstop that keeps the invariant true no matter how the begin failed.
2019
- if (this._transactionDepth === 0) {
2020
- this._transaction = null;
2021
- this._transactionState$.next(false);
2004
+ const tx = this._transaction;
2005
+ if (!tx._activeRequest) {
2006
+ return;
2007
+ }
2008
+ const start = Date.now();
2009
+ while (tx._activeRequest) {
2010
+ if (Date.now() - start > timeoutMs) {
2011
+ LogError(`waitForActiveRequest: timed out after ${timeoutMs}ms waiting for active request on transaction`);
2012
+ break;
2022
2013
  }
2023
- LogError(e);
2024
- throw e; // force caller to handle
2014
+ await new Promise((resolve) => setTimeout(resolve, 10));
2025
2015
  }
2026
2016
  }
2027
- async CommitTransaction() {
2017
+ async CommitPhysicalTransaction() {
2018
+ if (!this._transaction) {
2019
+ throw new Error('No active transaction to commit');
2020
+ }
2028
2021
  try {
2029
- if (!this._transaction) {
2030
- throw new Error('No active transaction to commit');
2031
- }
2032
- if (this._transactionDepth === 0) {
2033
- throw new Error('Transaction depth mismatch - no transaction to commit');
2034
- }
2035
- this._transactionDepth--;
2036
- if (this._transactionDepth === 0) {
2037
- // Outermost transaction - use mssql Transaction object to commit
2038
- await this._transaction.commit();
2039
- this._transaction = null;
2040
- // Clear savepoint tracking
2041
- this._savepointStack = [];
2042
- this._savepointCounter = 0;
2043
- // Emit transaction state change
2044
- this._transactionState$.next(false);
2045
- // Process any deferred tasks after successful commit
2046
- await this.processDeferredTasks();
2047
- }
2048
- else {
2049
- // Nested transaction - just remove the savepoint from stack
2050
- this._savepointStack.pop();
2051
- }
2022
+ await this.waitForActiveRequest();
2023
+ await this._transaction.commit();
2052
2024
  }
2053
- catch (e) {
2054
- // If commit() threw after we already decremented _transactionDepth to 0,
2055
- // the caller's RollbackTransaction() will see depth===0 and refuse to act,
2056
- // leaving the mssql transaction permanently open on SQL Server and holding
2057
- // row locks until the TCP connection drops (requires MJAPI restart).
2058
- // Detect this state and force a direct rollback to release the locks.
2059
- if (this._transactionDepth === 0 && this._transaction) {
2060
- try {
2061
- await this._transaction.rollback();
2062
- }
2063
- catch (rollbackError) {
2064
- LogError('Rollback after commit failure also failed:', undefined, rollbackError);
2065
- }
2066
- finally {
2067
- this._transaction = null;
2068
- this._savepointStack = [];
2069
- this._savepointCounter = 0;
2070
- this._transactionState$.next(false);
2071
- }
2072
- }
2073
- LogError(e);
2074
- throw e; // force caller to handle
2025
+ finally {
2026
+ this._transaction = null;
2027
+ this._transactionState$.next(false);
2075
2028
  }
2076
2029
  }
2077
- async RollbackTransaction() {
2078
- try {
2079
- if (!this._transaction) {
2080
- throw new Error('No active transaction to rollback');
2081
- }
2082
- if (this._transactionDepth === 0) {
2083
- throw new Error('Transaction depth mismatch - no transaction to rollback');
2084
- }
2085
- if (this._transactionDepth === 1) {
2086
- // Outermost transaction - rollback everything
2087
- await this._transaction.rollback();
2088
- this._transaction = null;
2089
- this._transactionDepth = 0;
2090
- // Clear savepoint tracking
2091
- this._savepointStack = [];
2092
- this._savepointCounter = 0;
2093
- // Emit transaction state change
2094
- this._transactionState$.next(false);
2095
- // Clear deferred tasks after rollback
2096
- const deferredCount = this._deferredTasks.length;
2097
- this._deferredTasks = [];
2098
- if (deferredCount > 0) {
2099
- LogStatus(`Cleared ${deferredCount} deferred tasks after transaction rollback`);
2100
- }
2030
+ async AfterPhysicalCommit() {
2031
+ await this.processDeferredTasks();
2032
+ }
2033
+ async AbandonPhysicalTransaction() {
2034
+ const stale = this._transaction;
2035
+ this._transaction = null;
2036
+ this._transactionState$.next(false);
2037
+ const deferredCount = this._deferredTasks.length;
2038
+ this._deferredTasks = [];
2039
+ if (stale) {
2040
+ try {
2041
+ await stale.rollback();
2101
2042
  }
2102
- else {
2103
- // Nested transaction - rollback to savepoint
2104
- const savepointName = this._savepointStack[this._savepointStack.length - 1];
2105
- if (!savepointName) {
2106
- throw new Error('Savepoint stack mismatch - no savepoint to rollback to');
2107
- }
2108
- try {
2109
- await this.ExecuteSQL(`ROLLBACK TRANSACTION ${savepointName}`, null, {
2110
- description: `Rolling back to savepoint ${savepointName}`,
2111
- ignoreLogging: true
2112
- });
2113
- this._savepointStack.pop();
2114
- this._transactionDepth--;
2115
- }
2116
- catch (savepointError) {
2117
- // SQL Server dooms the ENTIRE transaction (XACT_STATE() = -1) on deadlock-victim,
2118
- // batch-aborting and XACT_ABORT errors. In that state `ROLLBACK TRANSACTION <savepoint>`
2119
- // is illegal (Msg 3931) and throws — and leaving the depth/stack untouched would strand
2120
- // a dead physical transaction that every subsequent statement on this provider silently
2121
- // joins, with nested Commit calls "succeeding" against a transaction that can never
2122
- // commit. The only recovery is a FULL rollback plus a complete state reset; the outer
2123
- // scopes' own settles then fail loudly against depth 0 instead of pretending to work.
2124
- // (The old per-chain BeginISATransaction never hit this — sql.Transaction.rollback() is
2125
- // legal on an aborted transaction — so this hazard is specific to savepoint joining.)
2126
- LogError(`Savepoint rollback to ${savepointName} failed — the transaction is likely doomed ` +
2127
- `(XACT_STATE() = -1). Performing a full rollback and resetting transaction state.`);
2128
- try {
2129
- await this._transaction.rollback();
2130
- }
2131
- catch (fullRollbackError) {
2132
- LogError('Full rollback after savepoint rollback failure also failed:', undefined, fullRollbackError);
2133
- }
2134
- finally {
2135
- this._transaction = null;
2136
- this._transactionDepth = 0;
2137
- this._savepointStack = [];
2138
- this._savepointCounter = 0;
2139
- this._transactionState$.next(false);
2140
- const deferredCount = this._deferredTasks.length;
2141
- this._deferredTasks = [];
2142
- if (deferredCount > 0) {
2143
- LogStatus(`Cleared ${deferredCount} deferred tasks after doomed-transaction recovery`);
2144
- }
2145
- }
2146
- throw savepointError; // the caller's unit of work still failed — report it
2043
+ catch (e) {
2044
+ const code = e && typeof e === 'object' && 'code' in e ? String(e.code) : '';
2045
+ if (code !== 'EABORT') {
2046
+ LogError('AbandonPhysicalTransaction: rollback of doomed handle failed', undefined, e);
2147
2047
  }
2148
2048
  }
2149
2049
  }
2150
- catch (e) {
2151
- // On error in outer transaction, reset everything
2152
- if (this._transactionDepth === 1 || !this._transaction) {
2153
- this._transaction = null;
2154
- this._transactionDepth = 0;
2155
- this._savepointStack = [];
2156
- this._savepointCounter = 0;
2157
- this._transactionState$.next(false);
2050
+ if (deferredCount > 0) {
2051
+ LogStatus(`Cleared ${deferredCount} deferred tasks after abandoning a doomed transaction`);
2052
+ }
2053
+ }
2054
+ async RollbackPhysicalTransaction() {
2055
+ if (!this._transaction) {
2056
+ throw new Error('No active transaction to rollback');
2057
+ }
2058
+ try {
2059
+ await this.waitForActiveRequest();
2060
+ await this._transaction.rollback();
2061
+ }
2062
+ finally {
2063
+ this._transaction = null;
2064
+ this._transactionState$.next(false);
2065
+ const deferredCount = this._deferredTasks.length;
2066
+ this._deferredTasks = [];
2067
+ if (deferredCount > 0) {
2068
+ LogStatus(`Cleared ${deferredCount} deferred tasks after transaction rollback`);
2158
2069
  }
2159
- LogError(e);
2160
- throw e; // force caller to handle
2161
2070
  }
2162
2071
  }
2072
+ async OnBeginFailedAtDepthZero() {
2073
+ await this.AbandonPhysicalTransaction();
2074
+ }
2163
2075
  /**
2164
2076
  * Override RefreshIfNeeded to skip refresh when a transaction is active
2165
2077
  * This prevents conflicts between metadata refresh operations and active transactions