@memberjunction/sqlserver-dataprovider 2.53.0 → 2.55.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.
@@ -51,6 +51,82 @@ const SQLServerTransactionGroup_1 = require("./SQLServerTransactionGroup");
51
51
  const SqlLogger_js_1 = require("./SqlLogger.js");
52
52
  const actions_1 = require("@memberjunction/actions");
53
53
  const uuid_1 = require("uuid");
54
+ /**
55
+ * Core SQL execution function - handles the actual database query execution
56
+ * This is outside the class to allow both static and instance methods to use it
57
+ * without creating circular dependencies or forcing everything to be static
58
+ */
59
+ async function executeSQLCore(query, parameters, context, options) {
60
+ // Determine which connection source to use
61
+ let connectionSource;
62
+ if (context.transaction) {
63
+ // Use the transaction if provided
64
+ // Note: We no longer test the transaction validity here because:
65
+ // 1. It could cause race conditions with concurrent queries
66
+ // 2. If the transaction is invalid, we'll get a proper error when trying to use it
67
+ // 3. We should never silently fall back to the pool when a transaction is expected
68
+ connectionSource = context.transaction;
69
+ }
70
+ else {
71
+ connectionSource = context.pool;
72
+ }
73
+ // Check if the pool is connected before attempting to execute
74
+ if (connectionSource === context.pool && !context.pool.connected) {
75
+ const errorMessage = 'Connection pool is closed. Cannot execute SQL query.';
76
+ const error = new Error(errorMessage);
77
+ error.code = 'POOL_CLOSED';
78
+ throw error;
79
+ }
80
+ // Handle logging
81
+ let logPromise;
82
+ if (options && !options.ignoreLogging && context.logSqlStatement) {
83
+ logPromise = context.logSqlStatement(query, parameters, options.description, options.ignoreLogging, options.isMutation, options.simpleSQLFallback, options.contextUser);
84
+ }
85
+ else {
86
+ logPromise = Promise.resolve();
87
+ }
88
+ try {
89
+ // Create a new request object for this query
90
+ let request;
91
+ if (connectionSource instanceof sql.Transaction) {
92
+ request = new sql.Request(connectionSource);
93
+ }
94
+ else {
95
+ request = new sql.Request(connectionSource);
96
+ }
97
+ // Add parameters if provided
98
+ let processedQuery = query;
99
+ if (parameters) {
100
+ if (Array.isArray(parameters)) {
101
+ // Handle positional parameters (legacy TypeORM style)
102
+ parameters.forEach((value, index) => {
103
+ request.input(`p${index}`, value);
104
+ });
105
+ // Replace ? with @p0, @p1, etc. in the query
106
+ let paramIndex = 0;
107
+ processedQuery = query.replace(/\?/g, () => `@p${paramIndex++}`);
108
+ }
109
+ else if (typeof parameters === 'object') {
110
+ // Handle named parameters
111
+ for (const [key, value] of Object.entries(parameters)) {
112
+ request.input(key, value);
113
+ }
114
+ }
115
+ }
116
+ // Execute query and logging in parallel
117
+ const [result] = await Promise.all([
118
+ request.query(processedQuery),
119
+ logPromise
120
+ ]);
121
+ return result;
122
+ }
123
+ catch (error) {
124
+ // Log all errors
125
+ (0, core_1.LogError)(error);
126
+ // Re-throw all errors
127
+ throw error;
128
+ }
129
+ }
54
130
  /**
55
131
  * SQL Server implementation of the MemberJunction data provider interfaces.
56
132
  *
@@ -70,13 +146,19 @@ const uuid_1 = require("uuid");
70
146
  * await provider.Config();
71
147
  * ```
72
148
  */
73
- class SQLServerDataProvider extends core_1.ProviderBase {
149
+ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
74
150
  constructor() {
75
151
  super(...arguments);
152
+ this._transactionDepth = 0;
153
+ this._savepointCounter = 0;
154
+ this._savepointStack = [];
76
155
  this._bAllowRefresh = true;
77
156
  this._needsDatetimeOffsetAdjustment = false;
78
157
  this._datetimeOffsetTestComplete = false;
79
158
  this._sqlLoggingSessions = new Map();
159
+ // Instance SQL execution queue for serializing transaction queries
160
+ // Non-transactional queries bypass this queue for maximum parallelism
161
+ this._sqlQueue$ = new rxjs_1.Subject();
80
162
  // Transaction state management
81
163
  this._transactionState$ = new rxjs_1.BehaviorSubject(false);
82
164
  this._deferredTasks = [];
@@ -92,6 +174,32 @@ class SQLServerDataProvider extends core_1.ProviderBase {
92
174
  get transactionState$() {
93
175
  return this._transactionState$.asObservable();
94
176
  }
177
+ /**
178
+ * Gets the current transaction nesting depth
179
+ * 0 = no transaction, 1 = first level, 2+ = nested transactions
180
+ */
181
+ get transactionDepth() {
182
+ return this._transactionDepth;
183
+ }
184
+ /**
185
+ * Checks if we're currently in a transaction (at any depth)
186
+ */
187
+ get inTransaction() {
188
+ return this._transactionDepth > 0;
189
+ }
190
+ /**
191
+ * Checks if we're currently in a nested transaction (depth > 1)
192
+ */
193
+ get inNestedTransaction() {
194
+ return this._transactionDepth > 1;
195
+ }
196
+ /**
197
+ * Gets the current savepoint names in the stack (for debugging)
198
+ * Returns a copy to prevent external modification
199
+ */
200
+ get savepointStack() {
201
+ return [...this._savepointStack];
202
+ }
95
203
  /**
96
204
  * Gets whether a transaction is currently active
97
205
  */
@@ -113,6 +221,8 @@ class SQLServerDataProvider extends core_1.ProviderBase {
113
221
  async Config(configData) {
114
222
  try {
115
223
  this._pool = configData.DataSource; // Now expects a ConnectionPool instead of DataSource
224
+ // Initialize the instance queue processor
225
+ this.initializeQueueProcessor();
116
226
  return super.Config(configData); // now parent class can do it's config
117
227
  }
118
228
  catch (e) {
@@ -120,6 +230,21 @@ class SQLServerDataProvider extends core_1.ProviderBase {
120
230
  throw e;
121
231
  }
122
232
  }
233
+ /**
234
+ * Initialize the SQL queue processor for this instance
235
+ * This ensures all queries within a transaction execute sequentially
236
+ */
237
+ initializeQueueProcessor() {
238
+ // Each instance gets its own queue processor
239
+ this._queueSubscription = this._sqlQueue$.pipe((0, rxjs_1.concatMap)(item => (0, rxjs_1.from)(executeSQLCore(item.query, item.parameters, item.context, item.options)).pipe(
240
+ // Handle success
241
+ (0, rxjs_1.tap)(result => item.resolve(result)),
242
+ // Handle errors
243
+ (0, rxjs_1.catchError)(error => {
244
+ item.reject(error);
245
+ return (0, rxjs_1.of)(null); // Continue processing queue even on errors
246
+ })))).subscribe();
247
+ }
123
248
  /**
124
249
  * Gets the underlying SQL Server connection pool
125
250
  * @returns The mssql ConnectionPool object
@@ -236,6 +361,25 @@ class SQLServerDataProvider extends core_1.ProviderBase {
236
361
  await Promise.all(disposePromises);
237
362
  this._sqlLoggingSessions.clear();
238
363
  }
364
+ /**
365
+ * Dispose of this provider instance and clean up resources.
366
+ * This should be called when the provider is no longer needed.
367
+ */
368
+ async Dispose() {
369
+ // Dispose all SQL logging sessions
370
+ await this.DisposeAllSqlLoggingSessions();
371
+ // Unsubscribe from the SQL queue
372
+ if (this._queueSubscription) {
373
+ this._queueSubscription.unsubscribe();
374
+ this._queueSubscription = null;
375
+ }
376
+ // Complete the queue subject
377
+ if (this._sqlQueue$) {
378
+ this._sqlQueue$.complete();
379
+ }
380
+ // Note: We don't close the pool here as it might be shared
381
+ // The caller is responsible for closing the pool when appropriate
382
+ }
239
383
  /**
240
384
  * Internal method to log SQL statement to all active logging sessions.
241
385
  * This is called automatically by ExecuteSQL methods.
@@ -688,8 +832,13 @@ class SQLServerDataProvider extends core_1.ProviderBase {
688
832
  return Promise.all(promises);
689
833
  }
690
834
  validateUserProvidedSQLClause(clause) {
835
+ // First, remove all string literals from the clause to avoid false positives
836
+ // This regex matches both single and double quoted strings, handling escaped quotes
837
+ const stringLiteralPattern = /(['"])(?:(?=(\\?))\2[\s\S])*?\1/g;
838
+ // Replace all string literals with empty strings for validation purposes
839
+ const clauseWithoutStrings = clause.replace(stringLiteralPattern, '');
691
840
  // convert the clause to lower case to make the keyword search case-insensitive
692
- const lowerClause = clause.toLowerCase();
841
+ const lowerClause = clauseWithoutStrings.toLowerCase();
693
842
  // Define forbidden keywords and characters as whole words using regular expressions
694
843
  const forbiddenPatterns = [
695
844
  /\binsert\b/,
@@ -2581,100 +2730,42 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2581
2730
  * @returns Promise<sql.IResult<any>> - Query result
2582
2731
  * @private
2583
2732
  */
2733
+ /**
2734
+ * Internal SQL execution method for instance calls - routes queries based on transaction state
2735
+ * - Queries without transactions execute directly (allowing parallelism)
2736
+ * - Queries within transactions go through the instance queue (ensuring serialization)
2737
+ */
2738
+ async _internalExecuteSQLInstance(query, parameters, context, options) {
2739
+ // If no transaction is active, execute directly without queuing
2740
+ // This allows maximum parallelism for non-transactional queries
2741
+ if (!context.transaction) {
2742
+ return executeSQLCore(query, parameters, context, options);
2743
+ }
2744
+ // For transactional queries, use the instance queue to ensure serialization
2745
+ // This prevents EREQINPROG errors when multiple queries try to use the same transaction
2746
+ return new Promise((resolve, reject) => {
2747
+ this._sqlQueue$.next({
2748
+ id: (0, uuid_1.v4)(),
2749
+ query,
2750
+ parameters,
2751
+ context,
2752
+ options,
2753
+ resolve,
2754
+ reject
2755
+ });
2756
+ });
2757
+ }
2758
+ /**
2759
+ * Static SQL execution method - for static methods like ExecuteSQLWithPool
2760
+ * Static methods don't have access to instance queues, so they execute directly
2761
+ * Transactions are not supported in static context
2762
+ */
2584
2763
  static async _internalExecuteSQLStatic(query, parameters, context, options) {
2585
- // Determine which connection source to use
2586
- let connectionSource;
2587
2764
  if (context.transaction) {
2588
- // Try to use the transaction if provided
2589
- try {
2590
- // Test if the transaction is still valid by creating a request
2591
- const testRequest = new sql.Request(context.transaction);
2592
- connectionSource = context.transaction;
2593
- }
2594
- catch (error) {
2595
- // Transaction is no longer valid, clear it and use the pool
2596
- if (context.clearTransaction) {
2597
- context.clearTransaction();
2598
- }
2599
- connectionSource = context.pool;
2600
- }
2601
- }
2602
- else {
2603
- connectionSource = context.pool;
2604
- }
2605
- // Check if the pool is connected before attempting to execute
2606
- if (connectionSource === context.pool && !context.pool.connected) {
2607
- const errorMessage = 'Connection pool is closed. Cannot execute SQL query.';
2608
- const error = new Error(errorMessage);
2609
- error.code = 'POOL_CLOSED';
2610
- throw error;
2611
- }
2612
- // Handle logging
2613
- let logPromise;
2614
- if (options && !options.ignoreLogging && context.logSqlStatement) {
2615
- logPromise = context.logSqlStatement(query, parameters, options.description, options.ignoreLogging, options.isMutation, options.simpleSQLFallback, options.contextUser);
2616
- }
2617
- else {
2618
- logPromise = Promise.resolve();
2619
- }
2620
- try {
2621
- // Create a new request object for this query
2622
- let request;
2623
- if (connectionSource instanceof sql.Transaction) {
2624
- request = new sql.Request(connectionSource);
2625
- }
2626
- else {
2627
- request = new sql.Request(connectionSource);
2628
- }
2629
- // Add parameters if provided
2630
- let processedQuery = query;
2631
- if (parameters) {
2632
- if (Array.isArray(parameters)) {
2633
- // Handle positional parameters (legacy TypeORM style)
2634
- parameters.forEach((value, index) => {
2635
- request.input(`p${index}`, value);
2636
- });
2637
- // Replace ? with @p0, @p1, etc. in the query
2638
- let paramIndex = 0;
2639
- processedQuery = query.replace(/\?/g, () => `@p${paramIndex++}`);
2640
- }
2641
- else if (typeof parameters === 'object') {
2642
- // Handle named parameters
2643
- for (const [key, value] of Object.entries(parameters)) {
2644
- request.input(key, value);
2645
- }
2646
- }
2647
- }
2648
- // Execute query and logging in parallel
2649
- const [result] = await Promise.all([
2650
- request.query(processedQuery),
2651
- logPromise
2652
- ]);
2653
- return result;
2654
- }
2655
- catch (error) {
2656
- // If we get an EREQINPROG error and we were using a transaction, retry with the pool
2657
- if (error?.code === 'EREQINPROG' && connectionSource === context.transaction) {
2658
- // Silently retry with pool connection - this is expected behavior during concurrent operations
2659
- // LogDebug('Transaction connection busy (EREQINPROG) - retrying with pool connection');
2660
- // Clear the transaction reference
2661
- if (context.clearTransaction) {
2662
- context.clearTransaction();
2663
- }
2664
- // Retry using the pool connection
2665
- return SQLServerDataProvider._internalExecuteSQLStatic(query, parameters, {
2666
- ...context,
2667
- transaction: null // Force use of pool
2668
- }, options ? {
2669
- ...options,
2670
- description: options.description + ' (retry with pool)'
2671
- } : undefined);
2672
- }
2673
- // Log other errors
2674
- (0, core_1.LogError)(error);
2675
- // Re-throw all errors
2676
- throw error;
2765
+ throw new Error('Transactions are not supported in static SQL execution context. Use instance methods for transactional queries.');
2677
2766
  }
2767
+ // Static calls always execute directly (no queue needed since no transactions)
2768
+ return executeSQLCore(query, parameters, context, options);
2678
2769
  }
2679
2770
  /**
2680
2771
  * Internal centralized method for executing SQL queries with consistent transaction and connection handling.
@@ -2724,8 +2815,8 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2724
2815
  simpleSQLFallback: loggingOptions.simpleSQLFallback,
2725
2816
  contextUser: loggingOptions.contextUser
2726
2817
  } : undefined;
2727
- // Delegate to static method
2728
- return SQLServerDataProvider._internalExecuteSQLStatic(query, parameters, context, options);
2818
+ // Delegate to instance method
2819
+ return this._internalExecuteSQLInstance(query, parameters, context, options);
2729
2820
  }
2730
2821
  /**
2731
2822
  * This method can be used to execute raw SQL statements outside of the MJ infrastructure.
@@ -2840,31 +2931,43 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2840
2931
  batchSQL += ';\n';
2841
2932
  }
2842
2933
  });
2843
- // Execute the batch SQL directly (static method can't use instance _internalExecuteSQL)
2844
- let request;
2934
+ // Create execution context for batch SQL
2935
+ let pool;
2936
+ let transaction = null;
2845
2937
  if (connectionSource instanceof sql.Request) {
2846
- request = connectionSource;
2938
+ throw new Error('Request objects are not supported for batch execution. Use ConnectionPool or Transaction.');
2847
2939
  }
2848
2940
  else if (connectionSource instanceof sql.Transaction) {
2849
- request = new sql.Request(connectionSource);
2941
+ transaction = connectionSource;
2942
+ // Get pool from transaction's internal connection
2943
+ pool = connectionSource._pool || connectionSource.parent;
2944
+ if (!pool) {
2945
+ throw new Error('Unable to get connection pool from transaction');
2946
+ }
2850
2947
  }
2851
2948
  else if (connectionSource instanceof sql.ConnectionPool) {
2852
- request = new sql.Request(connectionSource);
2949
+ pool = connectionSource;
2853
2950
  }
2854
2951
  else {
2855
2952
  throw new Error('Invalid connection source type');
2856
2953
  }
2857
- // Add all batch parameters to the request
2858
- for (const [key, value] of Object.entries(batchParameters)) {
2859
- request.input(key, value);
2860
- }
2861
- // Log the batch SQL
2862
- const logPromise = SQLServerDataProvider.LogSQLStatement(batchSQL, batchParameters, 'Batch execution', false, undefined, contextUser);
2863
- // Execute batch SQL and logging in parallel
2864
- const [result] = await Promise.all([
2865
- request.query(batchSQL),
2866
- logPromise
2867
- ]);
2954
+ // Create context for executeSQLCore
2955
+ const context = {
2956
+ pool: pool,
2957
+ transaction: transaction,
2958
+ logSqlStatement: async (q, p, d, i, m, s, u) => {
2959
+ await SQLServerDataProvider.LogSQLStatement(q, p, d || 'Batch execution', m || false, s, u);
2960
+ }
2961
+ };
2962
+ // Use named parameters for batch SQL
2963
+ const namedParams = batchParameters;
2964
+ // Execute using the centralized core function
2965
+ const result = await executeSQLCore(batchSQL, namedParams, context, {
2966
+ description: 'Batch execution',
2967
+ ignoreLogging: false,
2968
+ isMutation: false,
2969
+ contextUser: contextUser
2970
+ });
2868
2971
  // Return array of recordsets - one for each query
2869
2972
  // Handle both single and multiple recordsets
2870
2973
  if (result.recordsets && Array.isArray(result.recordsets)) {
@@ -2895,17 +2998,66 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2895
2998
  */
2896
2999
  async ExecuteSQLBatch(queries, parameters, options, contextUser) {
2897
3000
  try {
2898
- let connectionSource;
2899
- if (this._transaction) {
2900
- // Use transaction if we have one
2901
- connectionSource = this._transaction;
3001
+ // Build combined batch SQL and parameters (same as static method)
3002
+ let batchSQL = '';
3003
+ const batchParameters = {};
3004
+ let globalParamIndex = 0;
3005
+ queries.forEach((query, queryIndex) => {
3006
+ let processedQuery = query;
3007
+ // Add parameters for this query if provided
3008
+ if (parameters && parameters[queryIndex]) {
3009
+ const queryParams = parameters[queryIndex];
3010
+ if (Array.isArray(queryParams)) {
3011
+ // Handle positional parameters
3012
+ queryParams.forEach((value, localIndex) => {
3013
+ const paramName = `p${globalParamIndex}`;
3014
+ batchParameters[paramName] = value;
3015
+ globalParamIndex++;
3016
+ });
3017
+ // Replace ? placeholders with parameter names
3018
+ let localParamIndex = globalParamIndex - queryParams.length;
3019
+ processedQuery = processedQuery.replace(/\?/g, () => `@p${localParamIndex++}`);
3020
+ }
3021
+ else if (typeof queryParams === 'object') {
3022
+ // Handle named parameters - prefix with query index to avoid conflicts
3023
+ for (const [key, value] of Object.entries(queryParams)) {
3024
+ const paramName = `q${queryIndex}_${key}`;
3025
+ batchParameters[paramName] = value;
3026
+ // Replace parameter references in query
3027
+ processedQuery = processedQuery.replace(new RegExp(`@${key}\\b`, 'g'), `@${paramName}`);
3028
+ }
3029
+ }
3030
+ }
3031
+ batchSQL += processedQuery;
3032
+ if (queryIndex < queries.length - 1) {
3033
+ batchSQL += ';\n';
3034
+ }
3035
+ });
3036
+ // Create execution context
3037
+ const context = {
3038
+ pool: this._pool,
3039
+ transaction: this._transaction,
3040
+ logSqlStatement: this._logSqlStatement.bind(this),
3041
+ clearTransaction: () => { this._transaction = null; }
3042
+ };
3043
+ // Execute using instance method (which handles queue for transactions)
3044
+ const result = await this._internalExecuteSQLInstance(batchSQL, batchParameters, context, {
3045
+ description: options?.description || 'Batch execution',
3046
+ ignoreLogging: options?.ignoreLogging || false,
3047
+ isMutation: options?.isMutation || false,
3048
+ contextUser: contextUser
3049
+ });
3050
+ // Return array of recordsets - one for each query
3051
+ // Handle both single and multiple recordsets
3052
+ if (result.recordsets && Array.isArray(result.recordsets)) {
3053
+ return result.recordsets;
3054
+ }
3055
+ else if (result.recordset) {
3056
+ return [result.recordset];
2902
3057
  }
2903
3058
  else {
2904
- // Use pool for non-transactional queries
2905
- connectionSource = this._pool;
3059
+ return [];
2906
3060
  }
2907
- // ExecuteSQLBatchStatic handles its own logging, so we don't need to duplicate it here
2908
- return await SQLServerDataProvider.ExecuteSQLBatchStatic(connectionSource, queries, parameters, contextUser);
2909
3061
  }
2910
3062
  catch (e) {
2911
3063
  (0, core_1.LogError)(e);
@@ -2995,29 +3147,57 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2995
3147
  }
2996
3148
  async BeginTransaction() {
2997
3149
  try {
2998
- // Create a new transaction from the pool
2999
- this._transaction = new sql.Transaction(this._pool);
3000
- await this._transaction.begin();
3001
- // Transaction created successfully
3002
- // Note: We create new Request objects for each query to avoid concurrency issues
3003
- // Emit transaction state change
3004
- this._transactionState$.next(true);
3150
+ this._transactionDepth++;
3151
+ if (this._transactionDepth === 1) {
3152
+ // First transaction - actually begin using mssql Transaction object
3153
+ this._transaction = new sql.Transaction(this._pool);
3154
+ await this._transaction.begin();
3155
+ // Emit transaction state change
3156
+ this._transactionState$.next(true);
3157
+ }
3158
+ else {
3159
+ // Nested transaction - create a savepoint
3160
+ const savepointName = `SavePoint_${++this._savepointCounter}`;
3161
+ this._savepointStack.push(savepointName);
3162
+ // Create savepoint for nested transaction
3163
+ await this.ExecuteSQL(`SAVE TRANSACTION ${savepointName}`, null, {
3164
+ description: `Creating savepoint ${savepointName} at depth ${this._transactionDepth}`,
3165
+ ignoreLogging: true
3166
+ });
3167
+ }
3005
3168
  }
3006
3169
  catch (e) {
3170
+ this._transactionDepth--; // Restore depth on error
3007
3171
  (0, core_1.LogError)(e);
3008
3172
  throw e; // force caller to handle
3009
3173
  }
3010
3174
  }
3011
3175
  async CommitTransaction() {
3012
3176
  try {
3013
- if (this._transaction) {
3177
+ if (!this._transaction) {
3178
+ throw new Error('No active transaction to commit');
3179
+ }
3180
+ if (this._transactionDepth === 0) {
3181
+ throw new Error('Transaction depth mismatch - no transaction to commit');
3182
+ }
3183
+ this._transactionDepth--;
3184
+ if (this._transactionDepth === 0) {
3185
+ // Outermost transaction - use mssql Transaction object to commit
3014
3186
  await this._transaction.commit();
3015
3187
  this._transaction = null;
3188
+ // Clear savepoint tracking
3189
+ this._savepointStack = [];
3190
+ this._savepointCounter = 0;
3016
3191
  // Emit transaction state change
3017
3192
  this._transactionState$.next(false);
3018
3193
  // Process any deferred tasks after successful commit
3019
3194
  await this.processDeferredTasks();
3020
3195
  }
3196
+ else {
3197
+ // Nested transaction - just remove the savepoint from stack
3198
+ // The savepoint remains valid in SQL Server until the outer transaction commits or rolls back
3199
+ this._savepointStack.pop();
3200
+ }
3021
3201
  }
3022
3202
  catch (e) {
3023
3203
  (0, core_1.LogError)(e);
@@ -3026,9 +3206,20 @@ class SQLServerDataProvider extends core_1.ProviderBase {
3026
3206
  }
3027
3207
  async RollbackTransaction() {
3028
3208
  try {
3029
- if (this._transaction) {
3209
+ if (!this._transaction) {
3210
+ throw new Error('No active transaction to rollback');
3211
+ }
3212
+ if (this._transactionDepth === 0) {
3213
+ throw new Error('Transaction depth mismatch - no transaction to rollback');
3214
+ }
3215
+ if (this._transactionDepth === 1) {
3216
+ // Outermost transaction - use mssql Transaction object to rollback everything
3030
3217
  await this._transaction.rollback();
3031
3218
  this._transaction = null;
3219
+ this._transactionDepth = 0;
3220
+ // Clear savepoint tracking
3221
+ this._savepointStack = [];
3222
+ this._savepointCounter = 0;
3032
3223
  // Emit transaction state change
3033
3224
  this._transactionState$.next(false);
3034
3225
  // Clear deferred tasks after rollback (don't process them)
@@ -3038,8 +3229,33 @@ class SQLServerDataProvider extends core_1.ProviderBase {
3038
3229
  (0, core_1.LogStatus)(`Cleared ${deferredCount} deferred tasks after transaction rollback`);
3039
3230
  }
3040
3231
  }
3232
+ else {
3233
+ // Nested transaction - rollback to the last savepoint
3234
+ const savepointName = this._savepointStack[this._savepointStack.length - 1];
3235
+ if (!savepointName) {
3236
+ throw new Error('Savepoint stack mismatch - no savepoint to rollback to');
3237
+ }
3238
+ // Rollback to the savepoint (this preserves the outer transaction)
3239
+ // Note: We use ROLLBACK TRANSACTION SavePointName (without TO) as per SQL Server syntax
3240
+ await this.ExecuteSQL(`ROLLBACK TRANSACTION ${savepointName}`, null, {
3241
+ description: `Rolling back to savepoint ${savepointName}`,
3242
+ ignoreLogging: true
3243
+ });
3244
+ // Remove the savepoint from stack and decrement depth
3245
+ this._savepointStack.pop();
3246
+ this._transactionDepth--;
3247
+ }
3041
3248
  }
3042
3249
  catch (e) {
3250
+ // On error in nested transaction, maintain state
3251
+ // On error in outer transaction, reset everything
3252
+ if (this._transactionDepth === 1 || !this._transaction) {
3253
+ this._transaction = null;
3254
+ this._transactionDepth = 0;
3255
+ this._savepointStack = [];
3256
+ this._savepointCounter = 0;
3257
+ this._transactionState$.next(false);
3258
+ }
3043
3259
  (0, core_1.LogError)(e);
3044
3260
  throw e; // force caller to handle
3045
3261
  }