@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.
- package/README.md +2 -2
- package/dist/SQLServerDataProvider.d.ts +37 -52
- package/dist/SQLServerDataProvider.d.ts.map +1 -1
- package/dist/SQLServerDataProvider.js +182 -270
- package/dist/SQLServerDataProvider.js.map +1 -1
- package/dist/SQLServerTransactionGroup.d.ts +71 -0
- package/dist/SQLServerTransactionGroup.d.ts.map +1 -1
- package/dist/SQLServerTransactionGroup.js +301 -0
- package/dist/SQLServerTransactionGroup.js.map +1 -1
- package/package.json +16 -16
|
@@ -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
|
-
*
|
|
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
|
|
280
|
-
|
|
281
|
-
return this._transactionDepth;
|
|
251
|
+
get InTransaction() {
|
|
252
|
+
return this.TransactionDepth > 0;
|
|
282
253
|
}
|
|
283
254
|
/**
|
|
284
|
-
*
|
|
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
|
|
289
|
-
return this.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
464
|
-
//
|
|
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:
|
|
469
|
-
port:
|
|
470
|
-
instanceName:
|
|
471
|
-
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 ${
|
|
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 ${
|
|
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}] = ${
|
|
683
|
-
[${f.Name}] = ${
|
|
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
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
|
|
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
|
-
*
|
|
931
|
-
* (
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
//
|
|
1964
|
-
//
|
|
1965
|
-
|
|
1966
|
-
|
|
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
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
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
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
if (
|
|
2020
|
-
|
|
2021
|
-
|
|
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
|
-
|
|
2024
|
-
throw e; // force caller to handle
|
|
2014
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2025
2015
|
}
|
|
2026
2016
|
}
|
|
2027
|
-
async
|
|
2017
|
+
async CommitPhysicalTransaction() {
|
|
2018
|
+
if (!this._transaction) {
|
|
2019
|
+
throw new Error('No active transaction to commit');
|
|
2020
|
+
}
|
|
2028
2021
|
try {
|
|
2029
|
-
|
|
2030
|
-
|
|
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
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
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
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
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
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
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
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
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
|