@memberjunction/sqlserver-dataprovider 6.2.0-edge.4 → 6.2.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, catchError, of } from 'rxjs';
24
24
  import { SQLServerTransactionGroup } from './SQLServerTransactionGroup.js';
25
+ import { EXECUTED_VARIABLE_SUFFIX, IsSaveParameterList, RenderSaveSQL, SaveParameterCollector, } from './saveParameters.js';
25
26
  import { SQLServerProviderConfigData } from './types.js';
26
27
  import { EscapeSQLString, IsByteArray, TryBase64ToBytes } from '@memberjunction/global';
27
28
  import { SQLServerDialect } from '@memberjunction/sql-dialect';
@@ -72,6 +73,13 @@ function buildRequest(connectionSource, query, parameters) {
72
73
  else {
73
74
  request = new sql.Request(connectionSource);
74
75
  }
76
+ if (IsSaveParameterList(parameters)) {
77
+ // A save or delete statement (saveParameters.ts): named inputs typed from the columns.
78
+ for (const p of parameters) {
79
+ request.input(p.Name, p.Type, p.Value);
80
+ }
81
+ return { request, processedQuery: query };
82
+ }
75
83
  let processedQuery = query;
76
84
  if (parameters) {
77
85
  if (Array.isArray(parameters)) {
@@ -150,22 +158,20 @@ async function executeSQLCore(query, parameters, context, options, isRetry = fal
150
158
  error.code = 'POOL_CLOSED';
151
159
  throw error;
152
160
  }
153
- // Handle logging
154
- let logPromise;
155
- if (options && !options.ignoreLogging && context.logSqlStatement) {
156
- logPromise = context.logSqlStatement(query, parameters, options.description, options.ignoreLogging, options.isMutation, options.simpleSQLFallback, options.contextUser);
157
- }
158
- else {
159
- logPromise = Promise.resolve();
160
- }
161
161
  try {
162
162
  const { request, processedQuery } = buildRequest(connectionSource, query, parameters);
163
- // Execute query and logging in parallel
164
- const [result] = await Promise.all([
165
- options?.timeoutMs ? QueryWithTimeout(request, processedQuery, options.timeoutMs) : request.query(processedQuery),
166
- logPromise
167
- ]);
168
- return result;
163
+ const queryPromise = options?.timeoutMs
164
+ ? QueryWithTimeout(request, processedQuery, options.timeoutMs)
165
+ : request.query(processedQuery);
166
+ if (options && !options.ignoreLogging && context.logSqlStatement) {
167
+ const literalSQL = options.literalSQL;
168
+ const [result] = await Promise.all([
169
+ queryPromise,
170
+ context.logSqlStatement(literalSQL ?? query, literalSQL ? undefined : parameters, options.description, options.ignoreLogging, options.isMutation, options.simpleSQLFallback, options.contextUser)
171
+ ]);
172
+ return result;
173
+ }
174
+ return await queryPromise;
169
175
  }
170
176
  catch (error) {
171
177
  // Retry once for stale connection errors on non-transactional queries.
@@ -185,10 +191,11 @@ async function executeSQLCore(query, parameters, context, options, isRetry = fal
185
191
  .map((e) => e?.message ?? String(e))
186
192
  .join(' | ')}`
187
193
  : '';
194
+ const literalSQL = options?.literalSQL;
188
195
  const errorMessage = `Error executing SQL
189
196
  Error: ${error?.message ? error.message : error}${precedingMsgs}
190
- Query: ${query}
191
- Parameters: ${parameters ? JSON.stringify(parameters) : 'None'}`;
197
+ Query: ${literalSQL ?? query}
198
+ Parameters: ${literalSQL || !parameters ? 'None' : JSON.stringify(parameters)}`;
192
199
  // Preserve the original error type (ConnectionError vs RequestError) so callers
193
200
  // can structurally distinguish infrastructure failures from query-level errors.
194
201
  // Attach the detailed message while keeping the error's class identity and code.
@@ -206,8 +213,6 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
206
213
  this._refreshSuspendCount = 0;
207
214
  /** Refresh() calls waiting for `_refreshSuspendCount` to reach zero. */
208
215
  this._refreshResumeWaiters = [];
209
- this._needsDatetimeOffsetAdjustment = false;
210
- this._datetimeOffsetTestComplete = false;
211
216
  /**
212
217
  * Per-entity base-view column order — the view's column NAMES in physical (`column_id`) order,
213
218
  * keyed by `'<schema>.<baseview>'` lowercased. Populated in {@link Config} (after metadata
@@ -239,7 +244,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
239
244
  * ENOTBEGUN on a finished handle. A query on an explicit handle a caller passed in — an IS-A chain
240
245
  * sharing its own transaction — is never subject to that check.
241
246
  */
242
- this._sqlQueue$ = new Subject();
247
+ this._sqlQueue$ = null;
243
248
  /**
244
249
  * Handles whose commit or rollback has run — or whose commit FAILED, which dooms them just the same.
245
250
  * The ambient check in runQueueItem consults this as well as `_transaction`, because a failed
@@ -247,16 +252,19 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
247
252
  * already being dequeued; without the marker that item would run on the doomed handle.
248
253
  */
249
254
  this._endedHandles = new WeakSet();
250
- // Transaction state management
251
- this._transactionState$ = new BehaviorSubject(false);
255
+ // Transaction state management — lazy-allocated so read-only per-request providers
256
+ // avoid allocating RxJS Subject/BehaviorSubject instances when no transaction is opened.
257
+ this._isTransactionActive = false;
258
+ this._transactionState$ = null;
252
259
  }
253
260
  /**
254
261
  * Returns the SQL Server dialect instance so that platform-specific tokens
255
262
  * (batch separator, quoted identifiers, etc.) are sourced from
256
263
  * `@memberjunction/sql-dialect` rather than hardcoded strings.
257
264
  */
265
+ static { this._dialectInstance = new SQLServerDialect(); }
258
266
  getDialect() {
259
- return new SQLServerDialect();
267
+ return SQLServerDataProvider._dialectInstance;
260
268
  }
261
269
  QuoteIdentifier(name) {
262
270
  // Double embedded closing brackets so a name containing `]` cannot terminate the
@@ -276,6 +284,29 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
276
284
  }
277
285
  /** Longest an explicit Refresh() waits for in-flight saves before giving up (returning false). */
278
286
  static { this.REFRESH_WAIT_TIMEOUT_MS = 30_000; }
287
+ static { this._needsDatetimeOffsetAdjustment = false; }
288
+ static { this._datetimeOffsetTestComplete = false; }
289
+ static { this._datetimeOffsetTestPromise = null; }
290
+ /** When the last datetimeoffset probe failed (ms since epoch), or null if it hasn't failed since the last success. */
291
+ static { this._datetimeOffsetTestFailedAt = null; }
292
+ /** After a failed probe, callers use the safe default for this long before probing again. */
293
+ static { this.DATETIMEOFFSET_PROBE_RETRY_MS = 30_000; }
294
+ get _needsDatetimeOffsetAdjustment() {
295
+ return SQLServerDataProvider._needsDatetimeOffsetAdjustment;
296
+ }
297
+ set _needsDatetimeOffsetAdjustment(value) {
298
+ SQLServerDataProvider._needsDatetimeOffsetAdjustment = value;
299
+ }
300
+ get _datetimeOffsetTestComplete() {
301
+ return SQLServerDataProvider._datetimeOffsetTestComplete;
302
+ }
303
+ set _datetimeOffsetTestComplete(value) {
304
+ SQLServerDataProvider._datetimeOffsetTestComplete = value;
305
+ if (!value) {
306
+ SQLServerDataProvider._datetimeOffsetTestPromise = null;
307
+ SQLServerDataProvider._datetimeOffsetTestFailedAt = null;
308
+ }
309
+ }
279
310
  /**
280
311
  * Process-wide view-column-order cache, scoped PER CONNECTION POOL and shared across provider
281
312
  * instances. MJServer creates 1–2 fresh, fully-Config()'d provider instances per GraphQL request
@@ -296,6 +327,24 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
296
327
  * migrations/CodeGen are picked up without a server restart.
297
328
  */
298
329
  static { this._viewColumnOrderCacheByPool = new WeakMap(); }
330
+ ensureSqlQueue() {
331
+ if (!this._sqlQueue$) {
332
+ this._sqlQueue$ = new Subject();
333
+ }
334
+ return this._sqlQueue$;
335
+ }
336
+ ensureTransactionStateSubject() {
337
+ if (!this._transactionState$) {
338
+ this._transactionState$ = new BehaviorSubject(this._isTransactionActive);
339
+ }
340
+ return this._transactionState$;
341
+ }
342
+ setTransactionActiveState(active) {
343
+ this._isTransactionActive = active;
344
+ if (this._transactionState$) {
345
+ this._transactionState$.next(active);
346
+ }
347
+ }
299
348
  /**
300
349
  * Observable that emits the current transaction state (true when active, false when not)
301
350
  * External code can subscribe to this to know when transactions start and end
@@ -306,7 +355,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
306
355
  * });
307
356
  */
308
357
  get TransactionState$() {
309
- return this._transactionState$.asObservable();
358
+ return this.ensureTransactionStateSubject().asObservable();
310
359
  }
311
360
  /** @deprecated Use {@link TransactionState$}. */
312
361
  get transactionState$() {
@@ -338,7 +387,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
338
387
  get IsTransactionActive() {
339
388
  // Always return instance-level state
340
389
  // Request-specific state should be accessed via getTransactionContext
341
- return this._transactionState$.value;
390
+ return this._isTransactionActive;
342
391
  }
343
392
  /** @deprecated Use {@link IsTransactionActive}. */
344
393
  get isTransactionActive() {
@@ -370,8 +419,6 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
370
419
  async Config(configData, providerToUse) {
371
420
  try {
372
421
  this._pool = configData.ConnectionPool; // Now expects a ConnectionPool instead of DataSource
373
- // Initialize the instance queue processor
374
- this.initializeQueueProcessor();
375
422
  const configured = await super.Config(configData, providerToUse); // now parent class can do it's config
376
423
  // After metadata is loaded, capture each base view's REAL column order so the save-capture
377
424
  // @ResultTable is declared to match it (see _viewColumnOrderCache + getAllEntityColumnsSQL).
@@ -500,7 +547,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
500
547
  // Each instance gets its own queue processor, but only do this ONCE if we get this method called more than once we don't need to reinit
501
548
  // the sub, taht would cause duplicate rprocessing.
502
549
  if (!this._queueSubscription) {
503
- this._queueSubscription = this._sqlQueue$.pipe(concatMap(item => from(this.runQueueItem(item)).pipe(
550
+ this._queueSubscription = this.ensureSqlQueue().pipe(concatMap(item => from(this.runQueueItem(item)).pipe(
504
551
  // runQueueItem settles the item's own promise on success; only failure is handled here
505
552
  catchError(error => {
506
553
  item.reject(error);
@@ -600,7 +647,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
600
647
  // Build array of SQL statements for batch execution
601
648
  const sqlStatements = [];
602
649
  for (const { query } of items) {
603
- // CacheValidationSQL should return MaxUpdatedAt and RowCount
650
+ // CacheValidationSQL returns MaxUpdatedAt and a row count (TotalRows or RowCount)
604
651
  sqlStatements.push(query.CacheValidationSQL);
605
652
  }
606
653
  try {
@@ -610,17 +657,8 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
610
657
  for (let i = 0; i < items.length; i++) {
611
658
  const { index } = items[i];
612
659
  const resultSet = resultSets[i];
613
- if (resultSet && resultSet.length > 0) {
614
- const row = resultSet[0];
615
- results.set(index, {
616
- success: true,
617
- rowCount: row.RowCount,
618
- maxUpdatedAt: row.MaxUpdatedAt ? new Date(row.MaxUpdatedAt).toISOString() : undefined,
619
- });
620
- }
621
- else {
622
- results.set(index, { success: true, rowCount: 0, maxUpdatedAt: undefined });
623
- }
660
+ // Shared reader: accepts the documented `TotalRows` as well as `RowCount`.
661
+ results.set(index, this.QueryCacheStatusFromRow(resultSet?.[0]));
624
662
  }
625
663
  }
626
664
  catch (e) {
@@ -656,9 +694,11 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
656
694
  if (items.length === 0) {
657
695
  return results;
658
696
  }
659
- // Build array of SQL statements for batch execution
697
+ // Build array of SQL statements for batch execution (recording per-item resolution errors
698
+ // in `results` without aborting valid sibling items in the batch)
660
699
  const sqlStatements = [];
661
- for (const { entityInfo, whereSQL, item } of items) {
700
+ const validItems = [];
701
+ for (const { index, entityInfo, whereSQL, item } of items) {
662
702
  // The freshness probe MUST target the same physical view the read targets. For a
663
703
  // DataSource:'Materialized' read that's the materialized_vw<CodeName> wrapper (a full SELECT *
664
704
  // snapshot of the base view, so it carries __mj_UpdatedAt) — NOT the live base view. Probing the
@@ -668,15 +708,27 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
668
708
  // still supplies a materialized cacheStatus.) Use the status-gated resolveEffectiveBaseView (not the
669
709
  // unconditional GetEffectiveBaseView) so a Building/DriftHold/Disabled/never-minted snapshot probes the
670
710
  // LIVE base view — mirroring the read path — instead of a held or missing materialized_vw wrapper.
671
- const effectiveView = await this.resolveEffectiveBaseView(entityInfo, item.params, contextUser);
672
- sqlStatements.push(this.BuildCacheStatusSQL(entityInfo, effectiveView, whereSQL));
711
+ try {
712
+ const effectiveTarget = await this.resolveEffectiveBaseViewTarget(entityInfo, item.params, contextUser);
713
+ sqlStatements.push(this.BuildCacheStatusSQL(entityInfo, effectiveTarget.viewName, whereSQL, effectiveTarget.schemaName));
714
+ validItems.push({ index });
715
+ }
716
+ catch (err) {
717
+ results.set(index, {
718
+ success: false,
719
+ errorMessage: err instanceof Error ? err.message : String(err),
720
+ });
721
+ }
722
+ }
723
+ if (sqlStatements.length === 0) {
724
+ return results;
673
725
  }
674
726
  try {
675
727
  // Execute the batched SQL using existing ExecuteSQLBatch method
676
728
  const resultSets = await this.ExecuteSQLBatch(sqlStatements, undefined, undefined, contextUser);
677
729
  // Process each result set and map to the corresponding item index
678
- for (let i = 0; i < items.length; i++) {
679
- const { index } = items[i];
730
+ for (let i = 0; i < validItems.length; i++) {
731
+ const { index } = validItems[i];
680
732
  const resultSet = resultSets[i];
681
733
  if (resultSet && resultSet.length > 0) {
682
734
  const row = resultSet[0];
@@ -704,24 +756,25 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
704
756
  }
705
757
  return results;
706
758
  }
707
- async executeSQLForUserViewRunLogging(viewId, entityInfo, effectiveBaseView, whereSQL, orderBySQL, user) {
759
+ async executeSQLForUserViewRunLogging(viewId, entityInfo, effectiveBaseView, whereSQL, orderBySQL, user, schemaNameOverride) {
708
760
  // entityInfo + effectiveBaseView are passed in (no reverse-lookup by base-view name) so the logged
709
- // read honors DataSource:'Materialized' — effectiveBaseView is the materialized wrapper view then,
710
- // and the entity's live base view otherwise.
761
+ // read honors DataSource:'Materialized' and AlternateBaseView — effectiveBaseView is the materialized
762
+ // or alternate view then, and the entity's live base view otherwise.
711
763
  //
712
764
  // UserViewRunDetail.RecordID is a single NVARCHAR(255) holding ONE bare key value per row, and the
713
765
  // read-back below is `<pk> IN (SELECT RecordID ...)` — a one-column predicate. A composite key has
714
766
  // no single column to log or match on, so refuse rather than record (and re-read) a truncated key.
715
767
  this.assertSingleColumnPrimaryKey(entityInfo, 'SaveViewResults (user view run logging)');
716
768
  const pkName = entityInfo.FirstPrimaryKey.Name; // first-pk-ok: guarded above — view-run RecordID holds one bare key value, PrimaryKeys.length === 1 enforced
769
+ const targetSchema = schemaNameOverride ?? entityInfo.SchemaName;
717
770
  const sSQL = `
718
771
  DECLARE @ViewIDList TABLE ( ID NVARCHAR(255) );
719
- INSERT INTO @ViewIDList (ID) (SELECT ${pkName} FROM [${entityInfo.SchemaName}].${effectiveBaseView} WHERE (${whereSQL}))
772
+ INSERT INTO @ViewIDList (ID) (SELECT ${pkName} FROM [${targetSchema}].${effectiveBaseView} WHERE (${whereSQL}))
720
773
  EXEC [${this.MJCoreSchemaName}].spCreateUserViewRunWithDetail(${viewId},${user.Email}, @ViewIDLIst)
721
774
  `;
722
775
  const runIDResult = await this.ExecuteSQL(sSQL, undefined, undefined, user);
723
776
  const runID = runIDResult[0].UserViewRunID;
724
- const sRetSQL = `SELECT * FROM [${entityInfo.SchemaName}].${effectiveBaseView} WHERE ${pkName} IN
777
+ const sRetSQL = `SELECT * FROM [${targetSchema}].${effectiveBaseView} WHERE ${pkName} IN
725
778
  (SELECT RecordID FROM [${this.MJCoreSchemaName}].vwUserViewRunDetails WHERE UserViewRunID=${runID})
726
779
  ${orderBySQL && orderBySQL.length > 0 ? ` ORDER BY ${orderBySQL}` : ''}`;
727
780
  return { executeViewSQL: sRetSQL, runID };
@@ -742,7 +795,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
742
795
  * @param entityName
743
796
  * @param compositeKey
744
797
  */
745
- BuildSoftLinkDependencySQL(entityName, compositeKey) {
798
+ BuildSoftLinkDependencySQL(entityName, compositeKey, options, contextUser) {
746
799
  // we need to go through ALL of the entities in the system and find all of the EntityFields that have a non-null EntityIDFieldName
747
800
  // 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
748
801
  //
@@ -753,11 +806,17 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
753
806
  if (!targetEntity) {
754
807
  throw new Error(`Entity ${entityName} not found in metadata`);
755
808
  }
756
- // The canonical stored encoding of the target record's key - `ID|<guid>` (see CompositeKey.ToRecordID).
757
- // A RecordID column holds this, not the bare primary key value.
758
- const targetRecordID = compositeKey.ToRecordID();
809
+ // Single-primary-key records referenced by polymorphic tables may store either the canonical
810
+ // `ID|<guid>` encoding (`CompositeKey.ToRecordID()`) OR the bare primary key value (`<guid>`).
811
+ const targetRecordID = this.escapeSQLLiteral(compositeKey.ToRecordID());
812
+ const bareValue = compositeKey.KeyValuePairs.length === 1
813
+ ? this.escapeSQLLiteral(String(compositeKey.KeyValuePairs[0].Value ?? ''))
814
+ : '';
759
815
  let sSQL = '';
760
816
  this.Entities.forEach((entity) => {
817
+ if (!this.shouldIncludeDependencyEntity(entity, options, contextUser)) {
818
+ return;
819
+ }
761
820
  // 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
762
821
  // 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
763
822
  // 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
@@ -767,6 +826,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
767
826
  // each field in f must be processed
768
827
  if (sSQL.length > 0)
769
828
  sSQL += ' UNION ALL ';
829
+ const sourceObject = this.resolveDependencySourceObject(entity, [f.Name, f.EntityIDFieldName]);
830
+ const recordIDPredicate = bareValue && bareValue !== targetRecordID
831
+ ? `[${f.Name}] IN ('${targetRecordID}', '${bareValue}')`
832
+ : `[${f.Name}] = '${targetRecordID}'`;
770
833
  // 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
771
834
  // 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
772
835
  // based on that.
@@ -782,10 +845,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
782
845
  1 AS IsSoftLink,
783
846
  '${f.EntityIDFieldName}' AS EntityIDFieldName
784
847
  FROM
785
- [${entity.SchemaName}].[${entity.BaseView}]
848
+ [${entity.SchemaName}].[${sourceObject}]
786
849
  WHERE
787
850
  [${f.EntityIDFieldName}] = '${targetEntity.ID}' AND
788
- [${f.Name}] = '${this.escapeSQLLiteral(targetRecordID)}'`;
851
+ ${recordIDPredicate}`;
789
852
  });
790
853
  });
791
854
  return sSQL;
@@ -798,13 +861,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
798
861
  escapeSQLLiteral(value) {
799
862
  return value.replace(/'/g, "''");
800
863
  }
801
- BuildHardLinkDependencySQL(entityDependencies, compositeKey) {
864
+ BuildHardLinkDependencySQL(entityDependenciesOrName, compositeKey, options, contextUser) {
865
+ const entityDependencies = this.resolveEntityDependencies(entityDependenciesOrName);
802
866
  let sSQL = '';
803
867
  for (const entityDependency of entityDependencies) {
804
868
  const entityInfo = this.Entities.find((e) => e.Name.trim().toLowerCase() === entityDependency.EntityName?.trim().toLowerCase());
805
- const quotes = entityInfo.FirstPrimaryKey.NeedsQuotes ? "'" : ''; // first-pk-ok: a foreign key targets a single column; quoting follows that FK target's type
806
869
  const relatedEntityInfo = this.Entities.find((e) => e.Name.trim().toLowerCase() === entityDependency.RelatedEntityName?.trim().toLowerCase());
807
- const primaryKeySelectString = `CONCAT(${entityInfo.PrimaryKeys.map((pk) => `'${pk.Name}|', CAST([${pk.Name}] AS NVARCHAR(MAX))`).join(`,'${CompositeKey.DefaultFieldDelimiter}',`)})`;
870
+ if (!entityInfo || !relatedEntityInfo)
871
+ continue;
872
+ if (!this.shouldIncludeDependencyEntity(relatedEntityInfo, options, contextUser))
873
+ continue;
874
+ const fkField = relatedEntityInfo.Fields.find((f) => f.Name.trim().toLowerCase() === entityDependency.FieldName?.trim().toLowerCase());
875
+ if (fkField?.EntityIDFieldName && fkField.EntityIDFieldName.trim().length > 0)
876
+ continue;
877
+ const pksToProject = relatedEntityInfo.PrimaryKeys?.length ? relatedEntityInfo.PrimaryKeys : entityInfo.PrimaryKeys;
878
+ const primaryKeySelectString = `CONCAT(${pksToProject.map((pk) => `'${pk.Name}|', CAST([${pk.Name}] AS NVARCHAR(MAX))`).join(`,'${CompositeKey.DefaultFieldDelimiter}',`)})`;
879
+ const sourceObject = this.resolveDependencySourceObject(relatedEntityInfo, [entityDependency.FieldName]);
808
880
  if (sSQL.length > 0)
809
881
  sSQL += ' UNION ALL ';
810
882
  sSQL += `SELECT
@@ -815,7 +887,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
815
887
  0 AS IsSoftLink,
816
888
  NULL AS EntityIDFieldName
817
889
  FROM
818
- [${relatedEntityInfo.SchemaName}].[${relatedEntityInfo.BaseView}]
890
+ [${relatedEntityInfo.SchemaName}].[${sourceObject}]
819
891
  WHERE
820
892
  [${entityDependency.FieldName}] = ${this.GetRecordDependencyLinkSQL(entityDependency, entityInfo, relatedEntityInfo, compositeKey)}`;
821
893
  }
@@ -826,7 +898,8 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
826
898
  if (!f) {
827
899
  throw new Error(`Field ${dep.FieldName} not found in Entity ${relatedEntity.Name}`);
828
900
  }
829
- if (f.RelatedEntityFieldName?.trim().toLowerCase() === 'id') {
901
+ const relFieldName = f.RelatedEntityFieldName?.trim().toLowerCase();
902
+ if (!relFieldName || relFieldName === 'id' || relFieldName === entity.FirstPrimaryKey?.Name?.trim().toLowerCase()) { // first-pk-ok: a foreign key targets a single column; default FK target is the first primary key
830
903
  // simple link to first primary key, most common scenario for linkages
831
904
  // Key values arrive from remote callers — render through the shared sanitizer.
832
905
  return SQLServerDataProvider.RenderKeyValueLiteral(CompositeKey.GetValueByIndex(0), entity.FirstPrimaryKey.NeedsQuotes, entity.FirstPrimaryKey.Name, entity.Name); // first-pk-ok: a foreign key targets a single column; quoting follows that FK target's type
@@ -881,19 +954,24 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
881
954
  * @param bNewRecord - Whether this is a new record (create) or existing record (update)
882
955
  * @param spName - The stored procedure name to call
883
956
  * @param user - The user context for the operation
957
+ * @param transaction - The transaction the save will run in, when one is already open. A read
958
+ * the save's Record Change snapshot needs runs in it, so it sees that transaction's earlier
959
+ * writes and never waits on the locks they hold.
884
960
  * @returns The full SQL statement for the save operation
885
961
  *
886
962
  * @security This method handles field-level encryption transparently.
887
963
  * Fields marked with Encrypt=true will have their values encrypted
888
964
  * before being included in the SQL statement.
889
965
  */
890
- async GetSaveSQL(entity, bNewRecord, _spName, user) {
966
+ async GetSaveSQL(entity, bNewRecord, _spName, user, transaction) {
891
967
  // SQLServerTransactionGroup-batched saves regenerate per-item SQL when
892
968
  // transaction variables get rebound. `_spName` is preserved for back-compat
893
969
  // with that caller; the dialect-driven pipeline derives it from entity
894
- // metadata via `GetCreateUpdateSPName`.
895
- const result = await this.GenerateSaveSQL(entity, bNewRecord, user);
896
- return result.fullSQL;
970
+ // metadata via `GetCreateUpdateSPName`. Outside a TransactionGroup,
971
+ // `fullSQL` holds `@p$0` parameter placeholders, so return `literalSQL`
972
+ // for standalone callers.
973
+ const result = await this.GenerateSaveSQLReadingOn(entity, bNewRecord, user, transaction);
974
+ return result.literalSQL ?? result.fullSQL;
897
975
  }
898
976
  /**
899
977
  * Queue the AI action task only once the save is durable: through {@link RunAfterCommit}, so it
@@ -906,6 +984,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
906
984
  await QueueManager.AddTask('Entity AI Action', params, null, user);
907
985
  }, 'Entity AI Action', postCommitToken);
908
986
  }
987
+ static { this._entityColumnsSQLCache = new WeakMap(); }
909
988
  getAllEntityColumnsSQL(entityInfo) {
910
989
  // This builds the column list for the @ResultTable/@ResultChangesTable that captures
911
990
  // `SELECT * FROM <BaseView>` via a POSITIONAL `INSERT INTO @table EXEC ...`. SQL Server maps the
@@ -930,10 +1009,17 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
930
1009
  // — the only case where a base-column-after-a-join could mis-route by position — we WARN loudly
931
1010
  // (warnOnViewOrderFallback), because the metadata order can no longer be checked against the view and
932
1011
  // a silent fallback there would let the very bug this fix prevents persist undetected.
933
- let orderedFields;
934
- let fallbackReason;
935
1012
  const viewKey = `${entityInfo.SchemaName}.${entityInfo.BaseView}`.toLowerCase();
936
1013
  const viewOrder = this._viewColumnOrderCache.get(viewKey);
1014
+ const cached = SQLServerDataProvider._entityColumnsSQLCache.get(entityInfo);
1015
+ if (cached &&
1016
+ cached.viewOrderRef === viewOrder &&
1017
+ cached.fieldCount === entityInfo.Fields.length &&
1018
+ !cached.usedFallback) {
1019
+ return cached.sql;
1020
+ }
1021
+ let orderedFields;
1022
+ let fallbackReason;
937
1023
  if (!viewOrder) {
938
1024
  fallbackReason = `base view not captured in the column-order cache (cache load failed at Config, or a custom/overridden sproc whose view was not read)`;
939
1025
  }
@@ -941,16 +1027,16 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
941
1027
  fallbackReason = `base view exposes ${viewOrder.length} column(s) but the entity declares ${entityInfo.Fields.length} field(s) — cannot align 1:1 by position`;
942
1028
  }
943
1029
  else {
944
- const fieldByName = new Map(entityInfo.Fields.map((f) => [f.Name.toLowerCase(), f]));
945
- const mapped = viewOrder.map((colName) => fieldByName.get(colName.toLowerCase()));
946
- if (mapped.every((f) => f !== undefined)) {
1030
+ const mapped = viewOrder.map((colName) => entityInfo.FieldByName(colName));
1031
+ if (mapped.every((f) => f !== null && f !== undefined)) {
947
1032
  orderedFields = mapped; // every view column resolved 1:1 to a field
948
1033
  }
949
1034
  else {
950
- const unmatched = viewOrder.filter((colName) => !fieldByName.has(colName.toLowerCase()));
1035
+ const unmatched = viewOrder.filter((colName) => !entityInfo.FieldByName(colName));
951
1036
  fallbackReason = `view column(s) without a matching EntityField: ${unmatched.join(', ')}`;
952
1037
  }
953
1038
  }
1039
+ const usedFallback = !orderedFields;
954
1040
  if (!orderedFields) {
955
1041
  this.warnIfBaseFieldSequencedAfterVirtual(entityInfo);
956
1042
  this.warnOnViewOrderFallback(entityInfo, fallbackReason);
@@ -963,8 +1049,35 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
963
1049
  sRet += '[' + f.Name + '] ' + f.SQLFullType + ' ' + (f.AllowsNull || f.IsVirtual ? 'NULL' : 'NOT NULL');
964
1050
  outputCount++;
965
1051
  }
1052
+ SQLServerDataProvider._entityColumnsSQLCache.set(entityInfo, {
1053
+ viewOrderRef: viewOrder,
1054
+ fieldCount: entityInfo.Fields.length,
1055
+ usedFallback,
1056
+ sql: sRet,
1057
+ });
966
1058
  return sRet;
967
1059
  }
1060
+ /**
1061
+ * Builds the column declarations for `@ResultTable` when `entityInfo.SaveReturnMode === 'datestamps_only'`.
1062
+ *
1063
+ * Matches the exact column order emitted by CodeGen's `buildCRUDReturnSelect` via
1064
+ * {@link EntityInfo.DatestampReturnFields} (primary key(s), followed by `__mj_CreatedAt` and `__mj_UpdatedAt`),
1065
+ * ensuring positional `INSERT INTO @ResultTable EXEC spCreate/spUpdate` alignment.
1066
+ */
1067
+ getDatestampEntityColumnsSQL(entityInfo) {
1068
+ return entityInfo.DatestampReturnFields
1069
+ .map((f) => `${this.QuoteIdentifier(f.Name)} ${f.SQLFullType} ${f.AllowsNull || f.IsVirtual ? 'NULL' : 'NOT NULL'}`)
1070
+ .join(',\n');
1071
+ }
1072
+ /**
1073
+ * Selects the `@ResultTable` column declarations matching the compiled stored procedure's
1074
+ * return shape (`returnContext.spReturnsDatestampsOnly`).
1075
+ */
1076
+ getSaveResultTableColumnsSQL(entityInfo, returnContext) {
1077
+ return returnContext.spReturnsDatestampsOnly
1078
+ ? this.getDatestampEntityColumnsSQL(entityInfo)
1079
+ : this.getAllEntityColumnsSQL(entityInfo);
1080
+ }
968
1081
  /** Entities already warned about base-after-virtual field-order skew — so we warn once, not every save. */
969
1082
  static { this._fieldOrderWarnedEntities = new Set(); }
970
1083
  /**
@@ -1063,7 +1176,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1063
1176
  if (value && field.Type.trim().toLowerCase() === 'datetimeoffset') {
1064
1177
  return { kind: 'use', value: new Date(value).toISOString() };
1065
1178
  }
1066
- if (!isUpdate && field.Type.trim().toLowerCase() === 'uniqueidentifier' && !field.IsPrimaryKey) {
1179
+ if (!isUpdate && field.IsUniqueIdentifier && !field.IsPrimaryKey) {
1067
1180
  if (typeof value === 'string' && value.includes('()')) {
1068
1181
  return { kind: 'skip' };
1069
1182
  }
@@ -1083,14 +1196,53 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1083
1196
  * `EntityFieldInfo.NeedsClearCompanion`.
1084
1197
  */
1085
1198
  RenderSaveCallBinding(entity, fieldValues, isUpdate, _spName) {
1086
- return this.renderSaveCallBindingWithSuffix(entity, fieldValues, isUpdate, this.allocateSaveCallSuffix(entity));
1199
+ // The statement that runs uses one constant variable suffix and a parameter per value, so its
1200
+ // text repeats across records. The per-record suffix is kept for the literal form.
1201
+ const params = new SaveParameterCollector();
1202
+ const binding = this.renderSaveCallBindingWithSuffix(entity, fieldValues, isUpdate, EXECUTED_VARIABLE_SUFFIX, params);
1203
+ const saveBinding = { ...binding, parameters: params.Parameters, recordSuffix: this.allocateSaveCallSuffix(entity) };
1204
+ return saveBinding;
1205
+ }
1206
+ /** The binding as RenderSaveCallBinding built it; throws for any other shape. */
1207
+ asSaveBinding(binding, caller) {
1208
+ if (binding.kind !== 'mssql-declare-exec' || !('recordSuffix' in binding)) {
1209
+ throw new Error(`SQLServerDataProvider.${caller}: unexpected binding kind '${binding.kind}'`);
1210
+ }
1211
+ return binding;
1212
+ }
1213
+ /**
1214
+ * The fragment for a statement built with typed parameters. A transaction-group item gets the
1215
+ * literal form only, as before: the group combines and regenerates its items' SQL as text. A
1216
+ * statement with no values to bind runs as built, which is already valid SQL.
1217
+ *
1218
+ * `literalSQL` is exposed as a lazy memoized getter so saves with SQL logging off avoid running
1219
+ * the `RenderSaveSQL` regex substitution pass on every write.
1220
+ */
1221
+ toSaveFragment(entity, executedSQL, parameters, recordSuffix) {
1222
+ if (entity.TransactionGroup) {
1223
+ return { sql: RenderSaveSQL(executedSQL, parameters, recordSuffix) };
1224
+ }
1225
+ let cachedLiteralSQL;
1226
+ const fragment = parameters.length > 0 ? { sql: executedSQL, parameters: [...parameters] } : { sql: executedSQL };
1227
+ Object.defineProperty(fragment, 'literalSQL', {
1228
+ enumerable: true,
1229
+ configurable: true,
1230
+ get() {
1231
+ if (cachedLiteralSQL === undefined) {
1232
+ cachedLiteralSQL = RenderSaveSQL(executedSQL, parameters, recordSuffix);
1233
+ }
1234
+ return cachedLiteralSQL;
1235
+ },
1236
+ });
1237
+ return fragment;
1087
1238
  }
1088
1239
  /**
1089
- * The binding for a given variable suffix. Split out of RenderSaveCallBinding so the update
1090
- * replay form can re-render a subset of the fields under the SAME suffix without allocating
1091
- * another one (inside a TransactionGroup that would consume a `_n` ordinal).
1240
+ * The binding for a given variable suffix, with each value bound through `params`. Split out of
1241
+ * RenderSaveCallBinding so the update replay form can re-render a subset of the fields under the
1242
+ * SAME suffix without allocating another one (inside a TransactionGroup that would consume a `_n`
1243
+ * ordinal).
1092
1244
  */
1093
- renderSaveCallBindingWithSuffix(entity, fieldValues, isUpdate, uniqueSuffix) {
1245
+ renderSaveCallBindingWithSuffix(entity, fieldValues, isUpdate, uniqueSuffix, params) {
1094
1246
  const declarations = [];
1095
1247
  const setStatements = [];
1096
1248
  const execParams = [];
@@ -1104,7 +1256,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1104
1256
  const hasValue = value !== null && value !== undefined;
1105
1257
  const binaryLiteral = hasValue && f.IsBinaryFieldType ? this.FormatBinaryLiteral(f, value) : undefined;
1106
1258
  if (hasValue) {
1107
- setStatements.push(`SET ${varName} = ${binaryLiteral ?? this.generateSetStatementValue(f, value)}`);
1259
+ setStatements.push(`SET ${varName} = ${params.AddField(f, binaryLiteral ?? this.generateSetStatementValue(f, value))}`);
1108
1260
  }
1109
1261
  execParams.push(`@${f.CodeName}=${varName}`);
1110
1262
  simpleParams += this.generateSingleSPParam(f, value, bFirst, binaryLiteral);
@@ -1117,12 +1269,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1117
1269
  // UPDATE: tail-append PK declarations from the loaded entity's PrimaryKey.
1118
1270
  if (isUpdate && execParams.length > 0) {
1119
1271
  for (const pkv of entity.PrimaryKey.KeyValuePairs) {
1120
- const f = entity.EntityInfo.Fields.find((x) => x.Name.trim().toLowerCase() === pkv.FieldName.trim().toLowerCase());
1272
+ const f = entity.EntityInfo.FieldByName(pkv.FieldName);
1121
1273
  if (!f)
1122
1274
  continue;
1123
1275
  const varName = `@${f.CodeName}${uniqueSuffix}`;
1124
1276
  declarations.push(`${varName} ${f.SQLFullType.toUpperCase()}`);
1125
- setStatements.push(`SET ${varName} = ${this.generateSetStatementValue(f, pkv.Value)}`);
1277
+ setStatements.push(`SET ${varName} = ${params.AddField(f, this.generateSetStatementValue(f, pkv.Value))}`);
1126
1278
  execParams.push(`@${f.CodeName}=${varName}`);
1127
1279
  const pkeyQuotes = f.NeedsQuotes ? "'" : '';
1128
1280
  simpleParams += `, @${f.CodeName} = ${pkeyQuotes}${pkv.Value}${pkeyQuotes}`;
@@ -1165,23 +1317,21 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1165
1317
  * there is nothing to look up).
1166
1318
  */
1167
1319
  RenderReplaySaveSQL(binding, entity, fieldValues) {
1168
- if (binding.kind !== 'mssql-declare-exec') {
1169
- throw new Error(`SQLServerDataProvider.RenderReplaySaveSQL: unexpected binding kind '${binding.kind}'`);
1170
- }
1320
+ const saveBinding = this.asSaveBinding(binding, 'RenderReplaySaveSQL');
1171
1321
  const info = entity.EntityInfo;
1172
1322
  const pks = info.PrimaryKeys;
1173
1323
  if (pks.length === 0 || !pks.every((pk) => fieldValues.has(pk))) {
1174
1324
  return undefined;
1175
1325
  }
1176
1326
  const schema = info.SchemaName;
1177
- const where = pks.map((pk) => `[${pk.Name}] = @${pk.CodeName}${binding.suffix}`).join(' AND ');
1327
+ const where = pks.map((pk) => `[${pk.Name}] = @${pk.CodeName}${saveBinding.suffix}`).join(' AND ');
1178
1328
  const createSpName = this.GetCreateUpdateSPName(entity, true);
1179
- const createBranch = `IF NOT EXISTS (SELECT 1 FROM [${schema}].[${info.BaseTable}] WHERE ${where})\nBEGIN\n EXEC [${schema}].${createSpName} ${binding.callArgsSQL}\nEND`;
1329
+ const createBranch = `IF NOT EXISTS (SELECT 1 FROM [${schema}].[${info.BaseTable}] WHERE ${where})\nBEGIN\n EXEC [${schema}].${createSpName} ${saveBinding.callArgsSQL}\nEND`;
1180
1330
  const hasUpdateProc = info.AllowUpdateAPI && info.spUpdateGenerated && !info.VirtualEntity;
1181
1331
  const updateBranch = hasUpdateProc
1182
- ? `\nELSE\nBEGIN\n EXEC [${schema}].${this.GetCreateUpdateSPName(entity, false)} ${binding.callArgsSQL}\nEND`
1332
+ ? `\nELSE\nBEGIN\n EXEC [${schema}].${this.GetCreateUpdateSPName(entity, false)} ${saveBinding.callArgsSQL}\nEND`
1183
1333
  : '';
1184
- return `${this.renderDeclareSetHead(binding)}${createBranch}${updateBranch}`;
1334
+ return RenderSaveSQL(`${this.renderDeclareSetHead(saveBinding)}${createBranch}${updateBranch}`, saveBinding.parameters, saveBinding.recordSuffix);
1185
1335
  }
1186
1336
  /**
1187
1337
  * Replay form of an UPDATE for the SQL log (never executed): `EXEC spUpdate` with only the
@@ -1194,9 +1344,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1194
1344
  * (spUpdateGenerated off) makes no ISNULL promise, so it keeps the full-row form.
1195
1345
  */
1196
1346
  RenderReplayUpdateSQL(binding, entity, changedFieldValues) {
1197
- if (binding.kind !== 'mssql-declare-exec') {
1198
- throw new Error(`SQLServerDataProvider.RenderReplayUpdateSQL: unexpected binding kind '${binding.kind}'`);
1199
- }
1347
+ const saveBinding = this.asSaveBinding(binding, 'RenderReplayUpdateSQL');
1200
1348
  const info = entity.EntityInfo;
1201
1349
  if (!info.spUpdateGenerated) {
1202
1350
  return undefined;
@@ -1205,9 +1353,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1205
1353
  const key = entity.PrimaryKey.ToString().replace(/[\r\n]+/g, ' ');
1206
1354
  return `-- ${info.Name} (${key}): saved with no field changes; nothing to replay`;
1207
1355
  }
1208
- const changed = this.renderSaveCallBindingWithSuffix(entity, changedFieldValues, true, binding.suffix);
1356
+ const params = new SaveParameterCollector();
1357
+ const changed = this.renderSaveCallBindingWithSuffix(entity, changedFieldValues, true, saveBinding.suffix, params);
1209
1358
  const spName = this.GetCreateUpdateSPName(entity, false);
1210
- return `${this.renderDeclareSetHead(changed)}EXEC [${info.SchemaName}].${spName} ${changed.callArgsSQL}`;
1359
+ return RenderSaveSQL(`${this.renderDeclareSetHead(changed)}EXEC [${info.SchemaName}].${spName} ${changed.callArgsSQL}`, params.Parameters, saveBinding.recordSuffix);
1211
1360
  }
1212
1361
  /** `DECLARE ...\n\nSET ...\n\n` when the binding declares variables, else empty. */
1213
1362
  renderDeclareSetHead(binding) {
@@ -1218,58 +1367,66 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1218
1367
  * shape: `DECLARE ... ; SET ... ; EXEC [schema].sp callArgs`. When
1219
1368
  * record-change tracking is on, this output is further wrapped by
1220
1369
  * `WrapSaveCallWithRecordChange`.
1370
+ *
1371
+ * When `returnContext.needsDatestampProjection` is true (either passed by
1372
+ * `GenericDatabaseProvider.GenerateSaveSQL` or resolved from `options`),
1373
+ * delegates to {@link ProjectSaveCallToDatestamps}.
1221
1374
  */
1222
- WrapSaveCallForResult(binding, entity, spName) {
1223
- if (binding.kind !== 'mssql-declare-exec') {
1224
- throw new Error(`SQLServerDataProvider.WrapSaveCallForResult: unexpected binding kind '${binding.kind}'`);
1375
+ WrapSaveCallForResult(binding, entity, spName, options, returnContext) {
1376
+ const resolvedReturn = returnContext ?? this.ResolveSaveReturnContext(entity, options);
1377
+ if (resolvedReturn.needsDatestampProjection) {
1378
+ return this.ProjectSaveCallToDatestamps(binding, entity, spName, resolvedReturn);
1225
1379
  }
1226
- const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${binding.callArgsSQL}`;
1227
- return { sql: `${this.renderDeclareSetHead(binding)}${execSQL}` };
1380
+ const saveBinding = this.asSaveBinding(binding, 'WrapSaveCallForResult');
1381
+ const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${saveBinding.callArgsSQL}`;
1382
+ return this.toSaveFragment(entity, `${this.renderDeclareSetHead(saveBinding)}${execSQL}`, saveBinding.parameters, saveBinding.recordSuffix);
1383
+ }
1384
+ /**
1385
+ * Captures the stored procedure's output into `@ResultTable` and projects only
1386
+ * `returnContext.datestampSelectColumnsSQL` (`SELECT [ID], [__mj_CreatedAt], [__mj_UpdatedAt] FROM @ResultTable;`)
1387
+ * when a caller requests `SaveReturnMode: 'datestamps_only'` at runtime on an entity whose
1388
+ * compiled stored procedure returns the full row.
1389
+ */
1390
+ ProjectSaveCallToDatestamps(binding, entity, spName, returnContext) {
1391
+ const saveBinding = this.asSaveBinding(binding, 'ProjectSaveCallToDatestamps');
1392
+ const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${saveBinding.callArgsSQL}`;
1393
+ const resultTableColumnsSQL = this.getSaveResultTableColumnsSQL(entity.EntityInfo, returnContext);
1394
+ const sql = `${this.renderDeclareSetHead(saveBinding)}DECLARE @ResultTable TABLE (\n ${resultTableColumnsSQL}\n);\n\nINSERT INTO @ResultTable\n${execSQL};\n\nSELECT ${returnContext.datestampSelectColumnsSQL} FROM @ResultTable;`;
1395
+ return this.toSaveFragment(entity, sql, saveBinding.parameters, saveBinding.recordSuffix);
1228
1396
  }
1229
1397
  /**
1230
1398
  * Wraps the save SQL with SQL Server's @ResultTable + inline
1231
1399
  * `spCreateRecordChange_Internal` EXEC pattern. The CONCAT-built `@ID`
1232
1400
  * inline supports composite-key entities.
1401
+ *
1402
+ * Consumes {@link SaveReturnContext} resolved by `GenericDatabaseProvider`:
1403
+ * - `@ResultTable` column declarations match the compiled stored procedure's return mode
1404
+ * (`returnContext.spReturnsDatestampsOnly`).
1405
+ * - The final `SELECT` from `@ResultTable` uses `returnContext.finalSelectColumnsSQL`.
1233
1406
  */
1234
- WrapSaveCallWithRecordChange(_saveSQL, binding, payload, entity) {
1235
- if (binding.kind !== 'mssql-declare-exec') {
1236
- throw new Error(`SQLServerDataProvider.WrapSaveCallWithRecordChange: unexpected binding kind '${binding.kind}'`);
1237
- }
1238
- const recordChangesEntityInfo = this.Entities.find((e) => e.Name === 'MJ: Record Changes');
1407
+ WrapSaveCallWithRecordChange(_saveSQL, binding, payload, entity, options, returnContext) {
1408
+ const saveBinding = this.asSaveBinding(binding, 'WrapSaveCallWithRecordChange');
1409
+ const params = new SaveParameterCollector(saveBinding.parameters);
1410
+ const recordChangesEntityInfo = this.EntityByName('MJ: Record Changes');
1239
1411
  const spName = this.GetCreateUpdateSPName(entity, payload.type === 'Create');
1240
1412
  const concatPKIDString = `CONCAT(${entity.EntityInfo.PrimaryKeys
1241
1413
  .map((pk) => `'${pk.CodeName}','${CompositeKey.DefaultValueDelimiter}',[${pk.Name}]`)
1242
1414
  .join(`,'${CompositeKey.DefaultFieldDelimiter}',`)})`;
1243
1415
  // Build the inline `EXEC spCreateRecordChange_Internal` from the payload,
1244
1416
  // referencing `@ID` (set below from the result table).
1245
- const lineageClause = payload.source === 'Restore'
1246
- ? `,
1247
- @Source='Restore',
1248
- @RestoredFromID='${payload.restoredFromID}',
1249
- @RestoreReason=${payload.restoreReason ? `N'${payload.restoreReason.replace(/'/g, "''")}'` : 'NULL'}`
1250
- : payload.source === 'Clone'
1251
- ? `,
1252
- @Source='Clone',
1253
- @ChangeContext=N'${EscapeSQLString(payload.changeContext)}'`
1254
- : '';
1255
- const recordChangeEXEC = `EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal @EntityName='${entity.EntityInfo.Name}',
1256
- @RecordID=@ID,
1257
- @UserID='${payload.userID}',
1258
- @Type='${payload.type}',
1259
- @ChangesJSON='${payload.changesJSON}',
1260
- @ChangesDescription='${payload.changesDescription}',
1261
- @FullRecordJSON='${payload.fullRecordJSON}',
1262
- @Status='Complete',
1263
- @Comments=null${lineageClause}`;
1264
- const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${binding.callArgsSQL}`;
1417
+ const recordChangeEXEC = this.renderRecordChangeEXEC(entity.EntityInfo.Name, '@ID', payload, params);
1418
+ const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${saveBinding.callArgsSQL}`;
1419
+ const resolvedReturn = returnContext ?? this.ResolveSaveReturnContext(entity, options);
1420
+ const resultTableColumnsSQL = this.getSaveResultTableColumnsSQL(entity.EntityInfo, resolvedReturn);
1421
+ const finalSelectSQL = `SELECT ${resolvedReturn.finalSelectColumnsSQL} FROM @ResultTable;`;
1265
1422
  const sql = `
1266
- ${binding.preambleSQL}
1423
+ ${saveBinding.preambleSQL}
1267
1424
 
1268
1425
  DECLARE @ResultTable TABLE (
1269
- ${this.getAllEntityColumnsSQL(entity.EntityInfo)}
1426
+ ${resultTableColumnsSQL}
1270
1427
  );
1271
1428
 
1272
- ${binding.setSQL}
1429
+ ${saveBinding.setSQL}
1273
1430
 
1274
1431
  INSERT INTO @ResultTable
1275
1432
  ${execSQL};
@@ -1288,8 +1445,44 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1288
1445
  ${recordChangeEXEC};
1289
1446
  END;
1290
1447
 
1291
- SELECT * FROM @ResultTable;`;
1292
- return { sql };
1448
+ ${finalSelectSQL}`;
1449
+ return this.toSaveFragment(entity, sql, params.Parameters, saveBinding.recordSuffix);
1450
+ }
1451
+ /**
1452
+ * `EXEC spCreateRecordChange_Internal …` for a payload. With `params`, each per-change value is
1453
+ * bound as a parameter typed from its Record Changes column, and the literal it replaces is kept
1454
+ * for the rendered form; without, the literals are written in directly. The entity name and change
1455
+ * type stay literal: they are constant for an entity and operation.
1456
+ */
1457
+ renderRecordChangeEXEC(entityName, recordIDSQL, payload, params) {
1458
+ const bind = (fieldName, literal) => (params ? params.AddField(this.recordChangeField(fieldName), literal) : literal);
1459
+ const lineageClause = payload.source === 'Restore'
1460
+ ? `,
1461
+ @Source='Restore',
1462
+ @RestoredFromID=${bind('RestoredFromID', `'${payload.restoredFromID}'`)},
1463
+ @RestoreReason=${payload.restoreReason ? bind('RestoreReason', `N'${payload.restoreReason.replace(/'/g, "''")}'`) : 'NULL'}`
1464
+ : payload.source === 'Clone'
1465
+ ? `,
1466
+ @Source='Clone',
1467
+ @ChangeContext=${bind('ChangeContext', `N'${EscapeSQLString(payload.changeContext)}'`)}`
1468
+ : '';
1469
+ return `EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal @EntityName=N'${entityName}',
1470
+ @RecordID=${recordIDSQL},
1471
+ @UserID=${bind('UserID', `'${payload.userID}'`)},
1472
+ @Type='${payload.type}',
1473
+ @ChangesJSON=${bind('ChangesJSON', `N'${payload.changesJSON}'`)},
1474
+ @ChangesDescription=${bind('ChangesDescription', `N'${payload.changesDescription}'`)},
1475
+ @FullRecordJSON=${bind('FullRecordJSON', `N'${payload.fullRecordJSON}'`)},
1476
+ @Status='Complete',
1477
+ @Comments=null${lineageClause}`;
1478
+ }
1479
+ /** A field of `MJ: Record Changes`, whose column types type the Record Change parameters. */
1480
+ recordChangeField(fieldName) {
1481
+ const field = this.EntityByName('MJ: Record Changes')?.FieldByName(fieldName);
1482
+ if (!field) {
1483
+ throw new Error(`SQLServerDataProvider: field "${fieldName}" of "MJ: Record Changes" is not in the metadata`);
1484
+ }
1485
+ return field;
1293
1486
  }
1294
1487
  /**
1295
1488
  * Generates the value portion of a SET statement for a field
@@ -1460,32 +1653,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1460
1653
  return unicodePrefix + quoteString + pVal + quoteString;
1461
1654
  }
1462
1655
  GetLogRecordChangeSQL(newData, oldData, entityName, recordID, entityInfo, type, user, wrapRecordIdInQuotes, restoreContext, cloneContext) {
1656
+ return this.renderLogRecordChangeSQL(newData, oldData, entityName, recordID, entityInfo, type, user, wrapRecordIdInQuotes, restoreContext, cloneContext);
1657
+ }
1658
+ /**
1659
+ * Renders the `spCreateRecordChange_Internal` call for `GetLogRecordChangeSQL` (literal form) and
1660
+ * for the delete batch (`params` supplied, so the payload values bind as typed inputs).
1661
+ */
1662
+ renderLogRecordChangeSQL(newData, oldData, entityName, recordID, entityInfo, type, user, wrapRecordIdInQuotes, restoreContext, cloneContext, params) {
1463
1663
  // Dialect-agnostic payload assembly is hoisted into DatabaseProviderBase
1464
1664
  // so SQL Server and PostgreSQL share one implementation. We only render
1465
1665
  // the T-SQL EXEC wrapper here.
1466
1666
  const payload = this.BuildRecordChangePayload(newData, oldData, String(recordID), entityInfo, type, user, restoreContext, "'", cloneContext);
1467
1667
  if (!payload)
1468
1668
  return null;
1469
- const quotes = wrapRecordIdInQuotes ? "'" : '';
1470
- const lineageClause = payload.source === 'Restore'
1471
- ? `,
1472
- @Source='Restore',
1473
- @RestoredFromID='${payload.restoredFromID}',
1474
- @RestoreReason=${payload.restoreReason ? `N'${payload.restoreReason.replace(/'/g, "''")}'` : 'NULL'}`
1475
- : payload.source === 'Clone'
1476
- ? `,
1477
- @Source='Clone',
1478
- @ChangeContext=N'${EscapeSQLString(payload.changeContext)}'`
1479
- : '';
1480
- return `EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal @EntityName='${entityName}',
1481
- @RecordID=${quotes}${recordID}${quotes},
1482
- @UserID='${payload.userID}',
1483
- @Type='${payload.type}',
1484
- @ChangesJSON='${payload.changesJSON}',
1485
- @ChangesDescription='${payload.changesDescription}',
1486
- @FullRecordJSON='${payload.fullRecordJSON}',
1487
- @Status='Complete',
1488
- @Comments=null${lineageClause}`;
1669
+ const recordIDSQL = wrapRecordIdInQuotes ? `N'${EscapeSQLString(String(recordID))}'` : String(recordID);
1670
+ const boundRecordID = params && wrapRecordIdInQuotes ? params.AddField(this.recordChangeField('RecordID'), recordIDSQL) : recordIDSQL;
1671
+ return this.renderRecordChangeEXEC(entityName, boundRecordID, payload, params);
1489
1672
  }
1490
1673
  /**
1491
1674
  * Implements the abstract BuildRecordChangeSQL from DatabaseProviderBase.
@@ -1510,22 +1693,33 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1510
1693
  return result.fullSQL;
1511
1694
  }
1512
1695
  /**
1513
- * This function generates both the full SQL (with record change metadata) and the simple stored procedure call for delete
1514
- * @returns Object with fullSQL and simpleSQL properties
1696
+ * Generates the delete statement with both full SQL (including record changes) and simple SQL.
1697
+ * With `params`, primary-key and Record Change values are bound as `@p0, @p1, …` and their
1698
+ * literals are kept on the parameters for `RenderSaveSQL`.
1699
+ * For a TrackRecordChanges entity with CascadeDeletes, the full SQL runs the proc uncaptured and selects the key twice:
1700
+ * first whether the row existed (read by transaction groups), last whether it was deleted (read by direct execution).
1515
1701
  */
1516
- getDeleteSQLWithDetails(entity, user, skipRecordChanges = false) {
1517
- let sSQL = '';
1702
+ getDeleteSQLWithDetails(entity, user, skipRecordChanges = false, params) {
1518
1703
  const spName = entity.EntityInfo.spDelete ? entity.EntityInfo.spDelete : `spDelete${entity.EntityInfo.BaseTableCodeName}`;
1519
- const sParams = entity.PrimaryKey.KeyValuePairs.map((kv) => {
1520
- const f = entity.EntityInfo.Fields.find((f) => f.Name.trim().toLowerCase() === kv.FieldName.trim().toLowerCase());
1521
- const quotes = f.NeedsQuotes ? "'" : '';
1522
- return `@${f.CodeName}=${quotes}${kv.Value}${quotes}`;
1523
- }).join(', ');
1704
+ const pkRefs = entity.PrimaryKey.KeyValuePairs.map((kv) => {
1705
+ const f = entity.EntityInfo.FieldByName(kv.FieldName);
1706
+ if (!f) {
1707
+ throw new Error(`SQLServerDataProvider.getDeleteSQLWithDetails: primary key field "${kv.FieldName}" not found on "${entity.EntityInfo.Name}"`);
1708
+ }
1709
+ // Quoted keys are escaped by generateSetStatementValue; an unquoted (numeric) key must be a plain number.
1710
+ const literal = f.NeedsQuotes
1711
+ ? this.generateSetStatementValue(f, kv.Value)
1712
+ : SQLServerDataProvider.RenderKeyValueLiteral(kv.Value, false, f.Name, entity.EntityInfo.Name);
1713
+ // One reference per key, reused by every statement below that names the key.
1714
+ return { Field: f, Ref: params ? params.AddField(f, literal) : literal };
1715
+ });
1716
+ const sParams = pkRefs.map((pk) => `@${pk.Field.CodeName}=${pk.Ref}`).join(', ');
1524
1717
  const sSimpleSQL = `EXEC [${entity.EntityInfo.SchemaName}].[${spName}] ${sParams}`;
1525
- const recordChangesEntityInfo = this.Entities.find((e) => e.Name === 'MJ: Record Changes');
1718
+ const recordChangesEntityInfo = this.EntityByName('MJ: Record Changes');
1719
+ let sSQL;
1526
1720
  if (entity.EntityInfo.TrackRecordChanges && !skipRecordChanges && entity.EntityInfo.Name.trim().toLowerCase() !== 'record changes') {
1527
1721
  // don't track changes for the record changes entity
1528
- const oldData = entity.GetAll(true); // get all the OLD values
1722
+ const oldData = this.RecordChangeDeleteSnapshot(entity, entity.GetAll(true)); // the OLD values, full width
1529
1723
  const sTableDeclare = entity.PrimaryKeys.map((pk) => {
1530
1724
  return `${pk.CodeName} ${pk.EntityFieldInfo.SQLFullType}`;
1531
1725
  }).join(', ');
@@ -1542,6 +1736,40 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1542
1736
  const sReturnList = entity.PrimaryKeys.map((pk) => {
1543
1737
  return `@${pk.CodeName} AS [${pk.Name}]`;
1544
1738
  }).join(', ');
1739
+ const recordChangeSQL = this.renderLogRecordChangeSQL(null /*pass in null for new data for deleted records*/, oldData, entity.EntityInfo.Name, sCombinedPrimaryKey, entity.EntityInfo, 'Delete', user, true, entity.RestoreContext, entity.CloneContext, params);
1740
+ if (entity.EntityInfo.CascadeDeletes) {
1741
+ // A cascading spDelete EXECs other procs (spUpdate* to null references, spDelete* for
1742
+ // children) and each emits its own result set, spUpdate*'s being the full row. Capturing the
1743
+ // call with INSERT INTO @ResultTable EXEC takes every one of them, so the first full row fails
1744
+ // with error 213. Run the proc uncaptured instead and judge success by whether the row is
1745
+ // gone. The key is selected first (transaction groups read the first result set) and last
1746
+ // (direct execution reads the last, see ValidateDeleteResult); NULL when nothing was deleted.
1747
+ // The checks read the base view, not the table: runtime logins hold SELECT on base views
1748
+ // only, and for a soft-delete entity the view hides the row spDelete just soft-deleted.
1749
+ const sView = `[${entity.EntityInfo.SchemaName}].[${entity.EntityInfo.BaseView}]`;
1750
+ const sWhere = pkRefs.map((pk) => `[${pk.Field.Name}]=${pk.Ref}`).join(' AND ');
1751
+ const sKeyWhen = (flag) => pkRefs.map((pk) => `CASE WHEN ${flag} = 1 THEN ${pk.Ref} END AS [${pk.Field.Name}]`).join(', ');
1752
+ sSQL = `
1753
+ DECLARE @MJDeleteRowExisted BIT = CASE WHEN EXISTS (SELECT 1 FROM ${sView} WHERE ${sWhere}) THEN 1 ELSE 0 END
1754
+ SELECT ${sKeyWhen('@MJDeleteRowExisted')}
1755
+
1756
+ IF @MJDeleteRowExisted = 1
1757
+ ${sSimpleSQL}
1758
+
1759
+ DECLARE @MJDeleteRowDeleted BIT = CASE WHEN @MJDeleteRowExisted = 1 AND NOT EXISTS (SELECT 1 FROM ${sView} WHERE ${sWhere}) THEN 1 ELSE 0 END
1760
+ IF @MJDeleteRowDeleted = 1
1761
+ BEGIN
1762
+ DECLARE @ResultChangesTable TABLE (
1763
+ ${this.getAllEntityColumnsSQL(recordChangesEntityInfo)}
1764
+ )
1765
+
1766
+ INSERT INTO @ResultChangesTable
1767
+ ${recordChangeSQL}
1768
+ END
1769
+
1770
+ SELECT ${sKeyWhen('@MJDeleteRowDeleted')}`;
1771
+ return { fullSQL: sSQL, simpleSQL: sSimpleSQL };
1772
+ }
1545
1773
  sSQL = `
1546
1774
  IF OBJECT_ID('tempdb..#ResultTable') IS NOT NULL
1547
1775
  DROP TABLE #ResultTable
@@ -1562,7 +1790,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1562
1790
  )
1563
1791
 
1564
1792
  INSERT INTO @ResultChangesTable
1565
- ${this.GetLogRecordChangeSQL(null /*pass in null for new data for deleted records*/, oldData, entity.EntityInfo.Name, sCombinedPrimaryKey, entity.EntityInfo, 'Delete', user, true, entity.RestoreContext, entity.CloneContext)}
1793
+ ${recordChangeSQL}
1566
1794
  END
1567
1795
 
1568
1796
  SELECT ${sReturnList}`;
@@ -1585,10 +1813,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1585
1813
  // WrapSaveCallWithRecordChange (see this file's "Save Grammar" section
1586
1814
  // above). See plans/sp-save-builder-generic-layer-refactor.md (rev 4).
1587
1815
  GenerateDeleteSQL(entity, user, options) {
1588
- const sqlDetails = this.getDeleteSQLWithDetails(entity, user, options?.SkipRecordChanges === true);
1816
+ const params = new SaveParameterCollector();
1817
+ const sqlDetails = this.getDeleteSQLWithDetails(entity, user, options?.SkipRecordChanges === true, params);
1818
+ const fragment = this.toSaveFragment(entity, sqlDetails.fullSQL, params.Parameters, '');
1819
+ let cachedSimpleSQL;
1589
1820
  return {
1590
- fullSQL: sqlDetails.fullSQL,
1591
- simpleSQL: sqlDetails.simpleSQL,
1821
+ fullSQL: fragment.sql,
1822
+ get simpleSQL() {
1823
+ if (cachedSimpleSQL === undefined) {
1824
+ cachedSimpleSQL = RenderSaveSQL(sqlDetails.simpleSQL, params.Parameters, '');
1825
+ }
1826
+ return cachedSimpleSQL;
1827
+ },
1828
+ parameters: fragment.parameters ?? null,
1829
+ get literalSQL() {
1830
+ return fragment.literalSQL;
1831
+ },
1592
1832
  };
1593
1833
  }
1594
1834
  OnSuspendRefresh() {
@@ -1713,8 +1953,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1713
1953
  }
1714
1954
  // For transactional queries, use the instance queue to ensure serialization
1715
1955
  // This prevents EREQINPROG errors when multiple queries try to use the same transaction
1956
+ this.initializeQueueProcessor();
1957
+ const queue = this.ensureSqlQueue();
1716
1958
  return new Promise((resolve, reject) => {
1717
- this._sqlQueue$.next({
1959
+ queue.next({
1718
1960
  kind: 'query',
1719
1961
  query,
1720
1962
  parameters,
@@ -1774,21 +2016,35 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1774
2016
  this.AssertAmbientTransactionUsable();
1775
2017
  transaction = this._transaction;
1776
2018
  }
2019
+ const shouldLogSql = !loggingOptions?.ignoreLogging && (this.HasActiveSqlLoggingSessions ||
2020
+ Object.prototype.hasOwnProperty.call(this, '_logSqlStatement') ||
2021
+ Object.getPrototypeOf(this) !== SQLServerDataProvider.prototype);
2022
+ if (shouldLogSql && !this._boundLogSqlStatement) {
2023
+ this._boundLogSqlStatement = this._logSqlStatement.bind(this);
2024
+ }
2025
+ if (!this._boundClearTransaction) {
2026
+ this._boundClearTransaction = () => {
2027
+ this._transaction = null;
2028
+ };
2029
+ }
1777
2030
  // Create the execution context
1778
2031
  const context = {
1779
2032
  pool: this._pool,
1780
2033
  transaction: transaction,
1781
- logSqlStatement: this._logSqlStatement.bind(this),
1782
- clearTransaction: () => {
1783
- this._transaction = null;
1784
- }
2034
+ logSqlStatement: shouldLogSql ? this._boundLogSqlStatement : undefined,
2035
+ clearTransaction: this._boundClearTransaction
1785
2036
  };
1786
2037
  // Convert logging options to internal format
1787
2038
  const options = loggingOptions ? {
1788
2039
  description: loggingOptions.description,
1789
2040
  ignoreLogging: loggingOptions.ignoreLogging,
1790
2041
  isMutation: loggingOptions.isMutation,
1791
- simpleSQLFallback: loggingOptions.simpleSQLFallback,
2042
+ get simpleSQLFallback() {
2043
+ return loggingOptions.simpleSQLFallback;
2044
+ },
2045
+ get literalSQL() {
2046
+ return loggingOptions.literalSQL;
2047
+ },
1792
2048
  contextUser: loggingOptions.contextUser,
1793
2049
  // A statement inside a transaction follows the transaction's limits (see ExecuteSQLOptions).
1794
2050
  timeoutMs: transaction ? undefined : loggingOptions.timeoutMs
@@ -1811,7 +2067,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1811
2067
  description: options?.description,
1812
2068
  ignoreLogging: options?.ignoreLogging,
1813
2069
  isMutation: options?.isMutation,
1814
- simpleSQLFallback: options?.simpleSQLFallback,
2070
+ get simpleSQLFallback() {
2071
+ return options?.simpleSQLFallback;
2072
+ },
2073
+ get literalSQL() {
2074
+ return options?.literalSQL;
2075
+ },
1815
2076
  contextUser: contextUser,
1816
2077
  ignoreAmbientTransaction: options?.ignoreAmbientTransaction,
1817
2078
  timeoutMs: options?.timeoutMs,
@@ -2118,8 +2379,19 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
2118
2379
  if (this._datetimeOffsetTestComplete) {
2119
2380
  return this._needsDatetimeOffsetAdjustment;
2120
2381
  }
2121
- // Run the test
2122
- await this.testDatetimeOffsetHandling();
2382
+ // A recent probe failed: keep its safe default until the backoff passes, rather than re-probing
2383
+ // (and logging the failure again) on every call while the database is unreachable.
2384
+ const failedAt = SQLServerDataProvider._datetimeOffsetTestFailedAt;
2385
+ if (failedAt !== null && Date.now() - failedAt < SQLServerDataProvider.DATETIMEOFFSET_PROBE_RETRY_MS) {
2386
+ return this._needsDatetimeOffsetAdjustment;
2387
+ }
2388
+ // Deduplicate concurrent tests across per-request provider instances
2389
+ if (!SQLServerDataProvider._datetimeOffsetTestPromise) {
2390
+ SQLServerDataProvider._datetimeOffsetTestPromise = this.testDatetimeOffsetHandling().finally(() => {
2391
+ SQLServerDataProvider._datetimeOffsetTestPromise = null;
2392
+ });
2393
+ }
2394
+ await SQLServerDataProvider._datetimeOffsetTestPromise;
2123
2395
  return this._needsDatetimeOffsetAdjustment;
2124
2396
  }
2125
2397
  /**
@@ -2164,13 +2436,15 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
2164
2436
  }
2165
2437
  // Mark the test as complete
2166
2438
  this._datetimeOffsetTestComplete = true;
2439
+ SQLServerDataProvider._datetimeOffsetTestFailedAt = null;
2167
2440
  }
2168
2441
  catch (e) {
2169
2442
  // If the test fails, log the error but don't prevent initialization
2170
2443
  console.error('SQLServerDataProvider: Failed to test datetimeoffset handling:', e);
2171
- // Default to assuming adjustment is needed for safety
2444
+ // Default to assuming adjustment is needed for safety on this provider,
2445
+ // but leave _datetimeOffsetTestComplete false so a call after the backoff retries.
2172
2446
  this._needsDatetimeOffsetAdjustment = true;
2173
- this._datetimeOffsetTestComplete = true;
2447
+ SQLServerDataProvider._datetimeOffsetTestFailedAt = Date.now();
2174
2448
  }
2175
2449
  }
2176
2450
  /**
@@ -2247,12 +2521,12 @@ DECLARE ${varName} NVARCHAR(MAX) = (
2247
2521
  );
2248
2522
  IF ${varName} IS NOT NULL
2249
2523
  EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal
2250
- @EntityName='${safeEntityName}',
2251
- @RecordID='${recordID}',
2524
+ @EntityName=N'${safeEntityName}',
2525
+ @RecordID=N'${recordID}',
2252
2526
  @UserID='${safeUserId}',
2253
2527
  @Type='Update',
2254
- @ChangesJSON='${safeChangesJSON}',
2255
- @ChangesDescription='${safeChangesDesc}',
2528
+ @ChangesJSON=N'${safeChangesJSON}',
2529
+ @ChangesDescription=N'${safeChangesDesc}',
2256
2530
  @FullRecordJSON=${varName},
2257
2531
  @Status='Complete',
2258
2532
  @Comments=NULL${lineageClause};`;
@@ -2270,7 +2544,7 @@ IF ${varName} IS NOT NULL
2270
2544
  const transaction = new sql.Transaction(this._pool);
2271
2545
  await transaction.begin();
2272
2546
  this._transaction = transaction;
2273
- this._transactionState$.next(true);
2547
+ this.setTransactionActiveState(true);
2274
2548
  }
2275
2549
  /**
2276
2550
  * Internal mssql transaction interface to safely inspect `_activeRequest` without `any`.
@@ -2305,8 +2579,10 @@ IF ${varName} IS NOT NULL
2305
2579
  * that queued ambient queries behind it are rejected rather than run on the doomed handle.
2306
2580
  */
2307
2581
  enqueueTransactionAction(description, handle, run) {
2582
+ this.initializeQueueProcessor();
2583
+ const queue = this.ensureSqlQueue();
2308
2584
  return new Promise((resolve, reject) => {
2309
- this._sqlQueue$.next({ kind: 'action', description, handle, run, resolve, reject });
2585
+ queue.next({ kind: 'action', description, handle, run, resolve, reject });
2310
2586
  });
2311
2587
  }
2312
2588
  async CommitPhysicalTransaction() {
@@ -2331,13 +2607,13 @@ IF ${varName} IS NOT NULL
2331
2607
  // AbandonPhysicalTransaction can roll the doomed handle back — nulling it first, as the old
2332
2608
  // `finally` did, made that abandon a no-op and leaked the server-side transaction (#4447).
2333
2609
  this._transaction = null;
2334
- this._transactionState$.next(false);
2610
+ this.setTransactionActiveState(false);
2335
2611
  });
2336
2612
  }
2337
2613
  async AbandonPhysicalTransaction() {
2338
2614
  const stale = this._transaction;
2339
2615
  this._transaction = null;
2340
- this._transactionState$.next(false);
2616
+ this.setTransactionActiveState(false);
2341
2617
  if (stale) {
2342
2618
  try {
2343
2619
  // Through the queue, like commit and rollback: the handle is already nulled above, so any
@@ -2371,7 +2647,7 @@ IF ${varName} IS NOT NULL
2371
2647
  }
2372
2648
  finally {
2373
2649
  this._transaction = null;
2374
- this._transactionState$.next(false);
2650
+ this.setTransactionActiveState(false);
2375
2651
  }
2376
2652
  }
2377
2653
  async OnBeginFailedAtDepthZero() {