@memberjunction/sqlserver-dataprovider 2.53.0 → 2.54.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.
@@ -2581,100 +2725,42 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2581
2725
  * @returns Promise<sql.IResult<any>> - Query result
2582
2726
  * @private
2583
2727
  */
2728
+ /**
2729
+ * Internal SQL execution method for instance calls - routes queries based on transaction state
2730
+ * - Queries without transactions execute directly (allowing parallelism)
2731
+ * - Queries within transactions go through the instance queue (ensuring serialization)
2732
+ */
2733
+ async _internalExecuteSQLInstance(query, parameters, context, options) {
2734
+ // If no transaction is active, execute directly without queuing
2735
+ // This allows maximum parallelism for non-transactional queries
2736
+ if (!context.transaction) {
2737
+ return executeSQLCore(query, parameters, context, options);
2738
+ }
2739
+ // For transactional queries, use the instance queue to ensure serialization
2740
+ // This prevents EREQINPROG errors when multiple queries try to use the same transaction
2741
+ return new Promise((resolve, reject) => {
2742
+ this._sqlQueue$.next({
2743
+ id: (0, uuid_1.v4)(),
2744
+ query,
2745
+ parameters,
2746
+ context,
2747
+ options,
2748
+ resolve,
2749
+ reject
2750
+ });
2751
+ });
2752
+ }
2753
+ /**
2754
+ * Static SQL execution method - for static methods like ExecuteSQLWithPool
2755
+ * Static methods don't have access to instance queues, so they execute directly
2756
+ * Transactions are not supported in static context
2757
+ */
2584
2758
  static async _internalExecuteSQLStatic(query, parameters, context, options) {
2585
- // Determine which connection source to use
2586
- let connectionSource;
2587
2759
  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;
2760
+ throw new Error('Transactions are not supported in static SQL execution context. Use instance methods for transactional queries.');
2677
2761
  }
2762
+ // Static calls always execute directly (no queue needed since no transactions)
2763
+ return executeSQLCore(query, parameters, context, options);
2678
2764
  }
2679
2765
  /**
2680
2766
  * Internal centralized method for executing SQL queries with consistent transaction and connection handling.
@@ -2724,8 +2810,8 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2724
2810
  simpleSQLFallback: loggingOptions.simpleSQLFallback,
2725
2811
  contextUser: loggingOptions.contextUser
2726
2812
  } : undefined;
2727
- // Delegate to static method
2728
- return SQLServerDataProvider._internalExecuteSQLStatic(query, parameters, context, options);
2813
+ // Delegate to instance method
2814
+ return this._internalExecuteSQLInstance(query, parameters, context, options);
2729
2815
  }
2730
2816
  /**
2731
2817
  * This method can be used to execute raw SQL statements outside of the MJ infrastructure.
@@ -2840,31 +2926,43 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2840
2926
  batchSQL += ';\n';
2841
2927
  }
2842
2928
  });
2843
- // Execute the batch SQL directly (static method can't use instance _internalExecuteSQL)
2844
- let request;
2929
+ // Create execution context for batch SQL
2930
+ let pool;
2931
+ let transaction = null;
2845
2932
  if (connectionSource instanceof sql.Request) {
2846
- request = connectionSource;
2933
+ throw new Error('Request objects are not supported for batch execution. Use ConnectionPool or Transaction.');
2847
2934
  }
2848
2935
  else if (connectionSource instanceof sql.Transaction) {
2849
- request = new sql.Request(connectionSource);
2936
+ transaction = connectionSource;
2937
+ // Get pool from transaction's internal connection
2938
+ pool = connectionSource._pool || connectionSource.parent;
2939
+ if (!pool) {
2940
+ throw new Error('Unable to get connection pool from transaction');
2941
+ }
2850
2942
  }
2851
2943
  else if (connectionSource instanceof sql.ConnectionPool) {
2852
- request = new sql.Request(connectionSource);
2944
+ pool = connectionSource;
2853
2945
  }
2854
2946
  else {
2855
2947
  throw new Error('Invalid connection source type');
2856
2948
  }
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
- ]);
2949
+ // Create context for executeSQLCore
2950
+ const context = {
2951
+ pool: pool,
2952
+ transaction: transaction,
2953
+ logSqlStatement: async (q, p, d, i, m, s, u) => {
2954
+ await SQLServerDataProvider.LogSQLStatement(q, p, d || 'Batch execution', m || false, s, u);
2955
+ }
2956
+ };
2957
+ // Use named parameters for batch SQL
2958
+ const namedParams = batchParameters;
2959
+ // Execute using the centralized core function
2960
+ const result = await executeSQLCore(batchSQL, namedParams, context, {
2961
+ description: 'Batch execution',
2962
+ ignoreLogging: false,
2963
+ isMutation: false,
2964
+ contextUser: contextUser
2965
+ });
2868
2966
  // Return array of recordsets - one for each query
2869
2967
  // Handle both single and multiple recordsets
2870
2968
  if (result.recordsets && Array.isArray(result.recordsets)) {
@@ -2895,17 +2993,66 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2895
2993
  */
2896
2994
  async ExecuteSQLBatch(queries, parameters, options, contextUser) {
2897
2995
  try {
2898
- let connectionSource;
2899
- if (this._transaction) {
2900
- // Use transaction if we have one
2901
- connectionSource = this._transaction;
2996
+ // Build combined batch SQL and parameters (same as static method)
2997
+ let batchSQL = '';
2998
+ const batchParameters = {};
2999
+ let globalParamIndex = 0;
3000
+ queries.forEach((query, queryIndex) => {
3001
+ let processedQuery = query;
3002
+ // Add parameters for this query if provided
3003
+ if (parameters && parameters[queryIndex]) {
3004
+ const queryParams = parameters[queryIndex];
3005
+ if (Array.isArray(queryParams)) {
3006
+ // Handle positional parameters
3007
+ queryParams.forEach((value, localIndex) => {
3008
+ const paramName = `p${globalParamIndex}`;
3009
+ batchParameters[paramName] = value;
3010
+ globalParamIndex++;
3011
+ });
3012
+ // Replace ? placeholders with parameter names
3013
+ let localParamIndex = globalParamIndex - queryParams.length;
3014
+ processedQuery = processedQuery.replace(/\?/g, () => `@p${localParamIndex++}`);
3015
+ }
3016
+ else if (typeof queryParams === 'object') {
3017
+ // Handle named parameters - prefix with query index to avoid conflicts
3018
+ for (const [key, value] of Object.entries(queryParams)) {
3019
+ const paramName = `q${queryIndex}_${key}`;
3020
+ batchParameters[paramName] = value;
3021
+ // Replace parameter references in query
3022
+ processedQuery = processedQuery.replace(new RegExp(`@${key}\\b`, 'g'), `@${paramName}`);
3023
+ }
3024
+ }
3025
+ }
3026
+ batchSQL += processedQuery;
3027
+ if (queryIndex < queries.length - 1) {
3028
+ batchSQL += ';\n';
3029
+ }
3030
+ });
3031
+ // Create execution context
3032
+ const context = {
3033
+ pool: this._pool,
3034
+ transaction: this._transaction,
3035
+ logSqlStatement: this._logSqlStatement.bind(this),
3036
+ clearTransaction: () => { this._transaction = null; }
3037
+ };
3038
+ // Execute using instance method (which handles queue for transactions)
3039
+ const result = await this._internalExecuteSQLInstance(batchSQL, batchParameters, context, {
3040
+ description: options?.description || 'Batch execution',
3041
+ ignoreLogging: options?.ignoreLogging || false,
3042
+ isMutation: options?.isMutation || false,
3043
+ contextUser: contextUser
3044
+ });
3045
+ // Return array of recordsets - one for each query
3046
+ // Handle both single and multiple recordsets
3047
+ if (result.recordsets && Array.isArray(result.recordsets)) {
3048
+ return result.recordsets;
3049
+ }
3050
+ else if (result.recordset) {
3051
+ return [result.recordset];
2902
3052
  }
2903
3053
  else {
2904
- // Use pool for non-transactional queries
2905
- connectionSource = this._pool;
3054
+ return [];
2906
3055
  }
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
3056
  }
2910
3057
  catch (e) {
2911
3058
  (0, core_1.LogError)(e);
@@ -2995,29 +3142,57 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2995
3142
  }
2996
3143
  async BeginTransaction() {
2997
3144
  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);
3145
+ this._transactionDepth++;
3146
+ if (this._transactionDepth === 1) {
3147
+ // First transaction - actually begin using mssql Transaction object
3148
+ this._transaction = new sql.Transaction(this._pool);
3149
+ await this._transaction.begin();
3150
+ // Emit transaction state change
3151
+ this._transactionState$.next(true);
3152
+ }
3153
+ else {
3154
+ // Nested transaction - create a savepoint
3155
+ const savepointName = `SavePoint_${++this._savepointCounter}`;
3156
+ this._savepointStack.push(savepointName);
3157
+ // Create savepoint for nested transaction
3158
+ await this.ExecuteSQL(`SAVE TRANSACTION ${savepointName}`, null, {
3159
+ description: `Creating savepoint ${savepointName} at depth ${this._transactionDepth}`,
3160
+ ignoreLogging: true
3161
+ });
3162
+ }
3005
3163
  }
3006
3164
  catch (e) {
3165
+ this._transactionDepth--; // Restore depth on error
3007
3166
  (0, core_1.LogError)(e);
3008
3167
  throw e; // force caller to handle
3009
3168
  }
3010
3169
  }
3011
3170
  async CommitTransaction() {
3012
3171
  try {
3013
- if (this._transaction) {
3172
+ if (!this._transaction) {
3173
+ throw new Error('No active transaction to commit');
3174
+ }
3175
+ if (this._transactionDepth === 0) {
3176
+ throw new Error('Transaction depth mismatch - no transaction to commit');
3177
+ }
3178
+ this._transactionDepth--;
3179
+ if (this._transactionDepth === 0) {
3180
+ // Outermost transaction - use mssql Transaction object to commit
3014
3181
  await this._transaction.commit();
3015
3182
  this._transaction = null;
3183
+ // Clear savepoint tracking
3184
+ this._savepointStack = [];
3185
+ this._savepointCounter = 0;
3016
3186
  // Emit transaction state change
3017
3187
  this._transactionState$.next(false);
3018
3188
  // Process any deferred tasks after successful commit
3019
3189
  await this.processDeferredTasks();
3020
3190
  }
3191
+ else {
3192
+ // Nested transaction - just remove the savepoint from stack
3193
+ // The savepoint remains valid in SQL Server until the outer transaction commits or rolls back
3194
+ this._savepointStack.pop();
3195
+ }
3021
3196
  }
3022
3197
  catch (e) {
3023
3198
  (0, core_1.LogError)(e);
@@ -3026,9 +3201,20 @@ class SQLServerDataProvider extends core_1.ProviderBase {
3026
3201
  }
3027
3202
  async RollbackTransaction() {
3028
3203
  try {
3029
- if (this._transaction) {
3204
+ if (!this._transaction) {
3205
+ throw new Error('No active transaction to rollback');
3206
+ }
3207
+ if (this._transactionDepth === 0) {
3208
+ throw new Error('Transaction depth mismatch - no transaction to rollback');
3209
+ }
3210
+ if (this._transactionDepth === 1) {
3211
+ // Outermost transaction - use mssql Transaction object to rollback everything
3030
3212
  await this._transaction.rollback();
3031
3213
  this._transaction = null;
3214
+ this._transactionDepth = 0;
3215
+ // Clear savepoint tracking
3216
+ this._savepointStack = [];
3217
+ this._savepointCounter = 0;
3032
3218
  // Emit transaction state change
3033
3219
  this._transactionState$.next(false);
3034
3220
  // Clear deferred tasks after rollback (don't process them)
@@ -3038,8 +3224,33 @@ class SQLServerDataProvider extends core_1.ProviderBase {
3038
3224
  (0, core_1.LogStatus)(`Cleared ${deferredCount} deferred tasks after transaction rollback`);
3039
3225
  }
3040
3226
  }
3227
+ else {
3228
+ // Nested transaction - rollback to the last savepoint
3229
+ const savepointName = this._savepointStack[this._savepointStack.length - 1];
3230
+ if (!savepointName) {
3231
+ throw new Error('Savepoint stack mismatch - no savepoint to rollback to');
3232
+ }
3233
+ // Rollback to the savepoint (this preserves the outer transaction)
3234
+ // Note: We use ROLLBACK TRANSACTION SavePointName (without TO) as per SQL Server syntax
3235
+ await this.ExecuteSQL(`ROLLBACK TRANSACTION ${savepointName}`, null, {
3236
+ description: `Rolling back to savepoint ${savepointName}`,
3237
+ ignoreLogging: true
3238
+ });
3239
+ // Remove the savepoint from stack and decrement depth
3240
+ this._savepointStack.pop();
3241
+ this._transactionDepth--;
3242
+ }
3041
3243
  }
3042
3244
  catch (e) {
3245
+ // On error in nested transaction, maintain state
3246
+ // On error in outer transaction, reset everything
3247
+ if (this._transactionDepth === 1 || !this._transaction) {
3248
+ this._transaction = null;
3249
+ this._transactionDepth = 0;
3250
+ this._savepointStack = [];
3251
+ this._savepointCounter = 0;
3252
+ this._transactionState$.next(false);
3253
+ }
3043
3254
  (0, core_1.LogError)(e);
3044
3255
  throw e; // force caller to handle
3045
3256
  }