@memberjunction/sqlserver-dataprovider 6.2.0-edge.3 → 6.2.0-edge.5

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
@@ -255,8 +260,9 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
255
260
  * (batch separator, quoted identifiers, etc.) are sourced from
256
261
  * `@memberjunction/sql-dialect` rather than hardcoded strings.
257
262
  */
263
+ static { this._dialectInstance = new SQLServerDialect(); }
258
264
  getDialect() {
259
- return new SQLServerDialect();
265
+ return SQLServerDataProvider._dialectInstance;
260
266
  }
261
267
  QuoteIdentifier(name) {
262
268
  // Double embedded closing brackets so a name containing `]` cannot terminate the
@@ -276,6 +282,29 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
276
282
  }
277
283
  /** Longest an explicit Refresh() waits for in-flight saves before giving up (returning false). */
278
284
  static { this.REFRESH_WAIT_TIMEOUT_MS = 30_000; }
285
+ static { this._needsDatetimeOffsetAdjustment = false; }
286
+ static { this._datetimeOffsetTestComplete = false; }
287
+ static { this._datetimeOffsetTestPromise = null; }
288
+ /** When the last datetimeoffset probe failed (ms since epoch), or null if it hasn't failed since the last success. */
289
+ static { this._datetimeOffsetTestFailedAt = null; }
290
+ /** After a failed probe, callers use the safe default for this long before probing again. */
291
+ static { this.DATETIMEOFFSET_PROBE_RETRY_MS = 30_000; }
292
+ get _needsDatetimeOffsetAdjustment() {
293
+ return SQLServerDataProvider._needsDatetimeOffsetAdjustment;
294
+ }
295
+ set _needsDatetimeOffsetAdjustment(value) {
296
+ SQLServerDataProvider._needsDatetimeOffsetAdjustment = value;
297
+ }
298
+ get _datetimeOffsetTestComplete() {
299
+ return SQLServerDataProvider._datetimeOffsetTestComplete;
300
+ }
301
+ set _datetimeOffsetTestComplete(value) {
302
+ SQLServerDataProvider._datetimeOffsetTestComplete = value;
303
+ if (!value) {
304
+ SQLServerDataProvider._datetimeOffsetTestPromise = null;
305
+ SQLServerDataProvider._datetimeOffsetTestFailedAt = null;
306
+ }
307
+ }
279
308
  /**
280
309
  * Process-wide view-column-order cache, scoped PER CONNECTION POOL and shared across provider
281
310
  * instances. MJServer creates 1–2 fresh, fully-Config()'d provider instances per GraphQL request
@@ -370,8 +399,6 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
370
399
  async Config(configData, providerToUse) {
371
400
  try {
372
401
  this._pool = configData.ConnectionPool; // Now expects a ConnectionPool instead of DataSource
373
- // Initialize the instance queue processor
374
- this.initializeQueueProcessor();
375
402
  const configured = await super.Config(configData, providerToUse); // now parent class can do it's config
376
403
  // After metadata is loaded, capture each base view's REAL column order so the save-capture
377
404
  // @ResultTable is declared to match it (see _viewColumnOrderCache + getAllEntityColumnsSQL).
@@ -600,7 +627,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
600
627
  // Build array of SQL statements for batch execution
601
628
  const sqlStatements = [];
602
629
  for (const { query } of items) {
603
- // CacheValidationSQL should return MaxUpdatedAt and RowCount
630
+ // CacheValidationSQL returns MaxUpdatedAt and a row count (TotalRows or RowCount)
604
631
  sqlStatements.push(query.CacheValidationSQL);
605
632
  }
606
633
  try {
@@ -610,17 +637,8 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
610
637
  for (let i = 0; i < items.length; i++) {
611
638
  const { index } = items[i];
612
639
  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
- }
640
+ // Shared reader: accepts the documented `TotalRows` as well as `RowCount`.
641
+ results.set(index, this.QueryCacheStatusFromRow(resultSet?.[0]));
624
642
  }
625
643
  }
626
644
  catch (e) {
@@ -669,8 +687,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
669
687
  // unconditional GetEffectiveBaseView) so a Building/DriftHold/Disabled/never-minted snapshot probes the
670
688
  // LIVE base view — mirroring the read path — instead of a held or missing materialized_vw wrapper.
671
689
  const effectiveView = await this.resolveEffectiveBaseView(entityInfo, item.params, contextUser);
672
- const statusSQL = `SELECT COUNT(*) AS TotalRows, MAX(__mj_UpdatedAt) AS MaxUpdatedAt FROM [${entityInfo.SchemaName}].${effectiveView}${whereSQL ? ' WHERE ' + whereSQL : ''}`;
673
- sqlStatements.push(statusSQL);
690
+ sqlStatements.push(this.BuildCacheStatusSQL(entityInfo, effectiveView, whereSQL));
674
691
  }
675
692
  try {
676
693
  // Execute the batched SQL using existing ExecuteSQLBatch method
@@ -693,11 +710,15 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
693
710
  }
694
711
  }
695
712
  catch (e) {
696
- // If batch fails, mark all items as failed
697
- const errorMessage = e instanceof Error ? e.message : String(e);
698
- for (const { index } of items) {
699
- results.set(index, { success: false, errorMessage });
700
- }
713
+ // One bad statement fails the WHOLE batch on SQL Server, which used to mark every item
714
+ // failed — so a single problematic entity blinded the sweep for its entire engine. Fall back
715
+ // to the per-item path, which runs each probe on its own and fails only what deserves it
716
+ //.
717
+ LogStatusEx({
718
+ message: `SQLServerDataProvider.getBatchedServerCacheStatus: batched probe failed (${e instanceof Error ? e.message : String(e)}) — retrying ${items.length} probe(s) individually`,
719
+ verboseOnly: true,
720
+ });
721
+ return await super.getBatchedServerCacheStatus(items, contextUser);
701
722
  }
702
723
  return results;
703
724
  }
@@ -888,9 +909,11 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
888
909
  // SQLServerTransactionGroup-batched saves regenerate per-item SQL when
889
910
  // transaction variables get rebound. `_spName` is preserved for back-compat
890
911
  // with that caller; the dialect-driven pipeline derives it from entity
891
- // metadata via `GetCreateUpdateSPName`.
912
+ // metadata via `GetCreateUpdateSPName`. Outside a TransactionGroup,
913
+ // `fullSQL` holds `@p$0` parameter placeholders, so return `literalSQL`
914
+ // for standalone callers.
892
915
  const result = await this.GenerateSaveSQL(entity, bNewRecord, user);
893
- return result.fullSQL;
916
+ return result.literalSQL ?? result.fullSQL;
894
917
  }
895
918
  /**
896
919
  * Queue the AI action task only once the save is durable: through {@link RunAfterCommit}, so it
@@ -903,6 +926,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
903
926
  await QueueManager.AddTask('Entity AI Action', params, null, user);
904
927
  }, 'Entity AI Action', postCommitToken);
905
928
  }
929
+ static { this._entityColumnsSQLCache = new WeakMap(); }
906
930
  getAllEntityColumnsSQL(entityInfo) {
907
931
  // This builds the column list for the @ResultTable/@ResultChangesTable that captures
908
932
  // `SELECT * FROM <BaseView>` via a POSITIONAL `INSERT INTO @table EXEC ...`. SQL Server maps the
@@ -927,10 +951,17 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
927
951
  // — the only case where a base-column-after-a-join could mis-route by position — we WARN loudly
928
952
  // (warnOnViewOrderFallback), because the metadata order can no longer be checked against the view and
929
953
  // a silent fallback there would let the very bug this fix prevents persist undetected.
930
- let orderedFields;
931
- let fallbackReason;
932
954
  const viewKey = `${entityInfo.SchemaName}.${entityInfo.BaseView}`.toLowerCase();
933
955
  const viewOrder = this._viewColumnOrderCache.get(viewKey);
956
+ const cached = SQLServerDataProvider._entityColumnsSQLCache.get(entityInfo);
957
+ if (cached &&
958
+ cached.viewOrderRef === viewOrder &&
959
+ cached.fieldCount === entityInfo.Fields.length &&
960
+ !cached.usedFallback) {
961
+ return cached.sql;
962
+ }
963
+ let orderedFields;
964
+ let fallbackReason;
934
965
  if (!viewOrder) {
935
966
  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)`;
936
967
  }
@@ -938,16 +969,16 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
938
969
  fallbackReason = `base view exposes ${viewOrder.length} column(s) but the entity declares ${entityInfo.Fields.length} field(s) — cannot align 1:1 by position`;
939
970
  }
940
971
  else {
941
- const fieldByName = new Map(entityInfo.Fields.map((f) => [f.Name.toLowerCase(), f]));
942
- const mapped = viewOrder.map((colName) => fieldByName.get(colName.toLowerCase()));
943
- if (mapped.every((f) => f !== undefined)) {
972
+ const mapped = viewOrder.map((colName) => entityInfo.FieldByName(colName));
973
+ if (mapped.every((f) => f !== null && f !== undefined)) {
944
974
  orderedFields = mapped; // every view column resolved 1:1 to a field
945
975
  }
946
976
  else {
947
- const unmatched = viewOrder.filter((colName) => !fieldByName.has(colName.toLowerCase()));
977
+ const unmatched = viewOrder.filter((colName) => !entityInfo.FieldByName(colName));
948
978
  fallbackReason = `view column(s) without a matching EntityField: ${unmatched.join(', ')}`;
949
979
  }
950
980
  }
981
+ const usedFallback = !orderedFields;
951
982
  if (!orderedFields) {
952
983
  this.warnIfBaseFieldSequencedAfterVirtual(entityInfo);
953
984
  this.warnOnViewOrderFallback(entityInfo, fallbackReason);
@@ -960,8 +991,35 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
960
991
  sRet += '[' + f.Name + '] ' + f.SQLFullType + ' ' + (f.AllowsNull || f.IsVirtual ? 'NULL' : 'NOT NULL');
961
992
  outputCount++;
962
993
  }
994
+ SQLServerDataProvider._entityColumnsSQLCache.set(entityInfo, {
995
+ viewOrderRef: viewOrder,
996
+ fieldCount: entityInfo.Fields.length,
997
+ usedFallback,
998
+ sql: sRet,
999
+ });
963
1000
  return sRet;
964
1001
  }
1002
+ /**
1003
+ * Builds the column declarations for `@ResultTable` when `entityInfo.SaveReturnMode === 'datestamps_only'`.
1004
+ *
1005
+ * Matches the exact column order emitted by CodeGen's `buildCRUDReturnSelect` via
1006
+ * {@link EntityInfo.DatestampReturnFields} (primary key(s), followed by `__mj_CreatedAt` and `__mj_UpdatedAt`),
1007
+ * ensuring positional `INSERT INTO @ResultTable EXEC spCreate/spUpdate` alignment.
1008
+ */
1009
+ getDatestampEntityColumnsSQL(entityInfo) {
1010
+ return entityInfo.DatestampReturnFields
1011
+ .map((f) => `${this.QuoteIdentifier(f.Name)} ${f.SQLFullType} ${f.AllowsNull || f.IsVirtual ? 'NULL' : 'NOT NULL'}`)
1012
+ .join(',\n');
1013
+ }
1014
+ /**
1015
+ * Selects the `@ResultTable` column declarations matching the compiled stored procedure's
1016
+ * return shape (`returnContext.spReturnsDatestampsOnly`).
1017
+ */
1018
+ getSaveResultTableColumnsSQL(entityInfo, returnContext) {
1019
+ return returnContext.spReturnsDatestampsOnly
1020
+ ? this.getDatestampEntityColumnsSQL(entityInfo)
1021
+ : this.getAllEntityColumnsSQL(entityInfo);
1022
+ }
965
1023
  /** Entities already warned about base-after-virtual field-order skew — so we warn once, not every save. */
966
1024
  static { this._fieldOrderWarnedEntities = new Set(); }
967
1025
  /**
@@ -1060,7 +1118,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1060
1118
  if (value && field.Type.trim().toLowerCase() === 'datetimeoffset') {
1061
1119
  return { kind: 'use', value: new Date(value).toISOString() };
1062
1120
  }
1063
- if (!isUpdate && field.Type.trim().toLowerCase() === 'uniqueidentifier' && !field.IsPrimaryKey) {
1121
+ if (!isUpdate && field.IsUniqueIdentifier && !field.IsPrimaryKey) {
1064
1122
  if (typeof value === 'string' && value.includes('()')) {
1065
1123
  return { kind: 'skip' };
1066
1124
  }
@@ -1080,7 +1138,53 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1080
1138
  * `EntityFieldInfo.NeedsClearCompanion`.
1081
1139
  */
1082
1140
  RenderSaveCallBinding(entity, fieldValues, isUpdate, _spName) {
1083
- const uniqueSuffix = this.allocateSaveCallSuffix(entity);
1141
+ // The statement that runs uses one constant variable suffix and a parameter per value, so its
1142
+ // text repeats across records. The per-record suffix is kept for the literal form.
1143
+ const params = new SaveParameterCollector();
1144
+ const binding = this.renderSaveCallBindingWithSuffix(entity, fieldValues, isUpdate, EXECUTED_VARIABLE_SUFFIX, params);
1145
+ const saveBinding = { ...binding, parameters: params.Parameters, recordSuffix: this.allocateSaveCallSuffix(entity) };
1146
+ return saveBinding;
1147
+ }
1148
+ /** The binding as RenderSaveCallBinding built it; throws for any other shape. */
1149
+ asSaveBinding(binding, caller) {
1150
+ if (binding.kind !== 'mssql-declare-exec' || !('recordSuffix' in binding)) {
1151
+ throw new Error(`SQLServerDataProvider.${caller}: unexpected binding kind '${binding.kind}'`);
1152
+ }
1153
+ return binding;
1154
+ }
1155
+ /**
1156
+ * The fragment for a statement built with typed parameters. A transaction-group item gets the
1157
+ * literal form only, as before: the group combines and regenerates its items' SQL as text. A
1158
+ * statement with no values to bind runs as built, which is already valid SQL.
1159
+ *
1160
+ * `literalSQL` is exposed as a lazy memoized getter so saves with SQL logging off avoid running
1161
+ * the `RenderSaveSQL` regex substitution pass on every write.
1162
+ */
1163
+ toSaveFragment(entity, executedSQL, parameters, recordSuffix) {
1164
+ if (entity.TransactionGroup) {
1165
+ return { sql: RenderSaveSQL(executedSQL, parameters, recordSuffix) };
1166
+ }
1167
+ let cachedLiteralSQL;
1168
+ const fragment = parameters.length > 0 ? { sql: executedSQL, parameters: [...parameters] } : { sql: executedSQL };
1169
+ Object.defineProperty(fragment, 'literalSQL', {
1170
+ enumerable: true,
1171
+ configurable: true,
1172
+ get() {
1173
+ if (cachedLiteralSQL === undefined) {
1174
+ cachedLiteralSQL = RenderSaveSQL(executedSQL, parameters, recordSuffix);
1175
+ }
1176
+ return cachedLiteralSQL;
1177
+ },
1178
+ });
1179
+ return fragment;
1180
+ }
1181
+ /**
1182
+ * The binding for a given variable suffix, with each value bound through `params`. Split out of
1183
+ * RenderSaveCallBinding so the update replay form can re-render a subset of the fields under the
1184
+ * SAME suffix without allocating another one (inside a TransactionGroup that would consume a `_n`
1185
+ * ordinal).
1186
+ */
1187
+ renderSaveCallBindingWithSuffix(entity, fieldValues, isUpdate, uniqueSuffix, params) {
1084
1188
  const declarations = [];
1085
1189
  const setStatements = [];
1086
1190
  const execParams = [];
@@ -1094,7 +1198,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1094
1198
  const hasValue = value !== null && value !== undefined;
1095
1199
  const binaryLiteral = hasValue && f.IsBinaryFieldType ? this.FormatBinaryLiteral(f, value) : undefined;
1096
1200
  if (hasValue) {
1097
- setStatements.push(`SET ${varName} = ${binaryLiteral ?? this.generateSetStatementValue(f, value)}`);
1201
+ setStatements.push(`SET ${varName} = ${params.AddField(f, binaryLiteral ?? this.generateSetStatementValue(f, value))}`);
1098
1202
  }
1099
1203
  execParams.push(`@${f.CodeName}=${varName}`);
1100
1204
  simpleParams += this.generateSingleSPParam(f, value, bFirst, binaryLiteral);
@@ -1107,12 +1211,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1107
1211
  // UPDATE: tail-append PK declarations from the loaded entity's PrimaryKey.
1108
1212
  if (isUpdate && execParams.length > 0) {
1109
1213
  for (const pkv of entity.PrimaryKey.KeyValuePairs) {
1110
- const f = entity.EntityInfo.Fields.find((x) => x.Name.trim().toLowerCase() === pkv.FieldName.trim().toLowerCase());
1214
+ const f = entity.EntityInfo.FieldByName(pkv.FieldName);
1111
1215
  if (!f)
1112
1216
  continue;
1113
1217
  const varName = `@${f.CodeName}${uniqueSuffix}`;
1114
1218
  declarations.push(`${varName} ${f.SQLFullType.toUpperCase()}`);
1115
- setStatements.push(`SET ${varName} = ${this.generateSetStatementValue(f, pkv.Value)}`);
1219
+ setStatements.push(`SET ${varName} = ${params.AddField(f, this.generateSetStatementValue(f, pkv.Value))}`);
1116
1220
  execParams.push(`@${f.CodeName}=${varName}`);
1117
1221
  const pkeyQuotes = f.NeedsQuotes ? "'" : '';
1118
1222
  simpleParams += `, @${f.CodeName} = ${pkeyQuotes}${pkv.Value}${pkeyQuotes}`;
@@ -1155,23 +1259,46 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1155
1259
  * there is nothing to look up).
1156
1260
  */
1157
1261
  RenderReplaySaveSQL(binding, entity, fieldValues) {
1158
- if (binding.kind !== 'mssql-declare-exec') {
1159
- throw new Error(`SQLServerDataProvider.RenderReplaySaveSQL: unexpected binding kind '${binding.kind}'`);
1160
- }
1262
+ const saveBinding = this.asSaveBinding(binding, 'RenderReplaySaveSQL');
1161
1263
  const info = entity.EntityInfo;
1162
1264
  const pks = info.PrimaryKeys;
1163
1265
  if (pks.length === 0 || !pks.every((pk) => fieldValues.has(pk))) {
1164
1266
  return undefined;
1165
1267
  }
1166
1268
  const schema = info.SchemaName;
1167
- const where = pks.map((pk) => `[${pk.Name}] = @${pk.CodeName}${binding.suffix}`).join(' AND ');
1269
+ const where = pks.map((pk) => `[${pk.Name}] = @${pk.CodeName}${saveBinding.suffix}`).join(' AND ');
1168
1270
  const createSpName = this.GetCreateUpdateSPName(entity, true);
1169
- const createBranch = `IF NOT EXISTS (SELECT 1 FROM [${schema}].[${info.BaseTable}] WHERE ${where})\nBEGIN\n EXEC [${schema}].${createSpName} ${binding.callArgsSQL}\nEND`;
1271
+ const createBranch = `IF NOT EXISTS (SELECT 1 FROM [${schema}].[${info.BaseTable}] WHERE ${where})\nBEGIN\n EXEC [${schema}].${createSpName} ${saveBinding.callArgsSQL}\nEND`;
1170
1272
  const hasUpdateProc = info.AllowUpdateAPI && info.spUpdateGenerated && !info.VirtualEntity;
1171
1273
  const updateBranch = hasUpdateProc
1172
- ? `\nELSE\nBEGIN\n EXEC [${schema}].${this.GetCreateUpdateSPName(entity, false)} ${binding.callArgsSQL}\nEND`
1274
+ ? `\nELSE\nBEGIN\n EXEC [${schema}].${this.GetCreateUpdateSPName(entity, false)} ${saveBinding.callArgsSQL}\nEND`
1173
1275
  : '';
1174
- return `${this.renderDeclareSetHead(binding)}${createBranch}${updateBranch}`;
1276
+ return RenderSaveSQL(`${this.renderDeclareSetHead(saveBinding)}${createBranch}${updateBranch}`, saveBinding.parameters, saveBinding.recordSuffix);
1277
+ }
1278
+ /**
1279
+ * Replay form of an UPDATE for the SQL log (never executed): `EXEC spUpdate` with only the
1280
+ * fields this save changed, plus the primary key. The update procs keep every column whose
1281
+ * parameter is not passed (`ISNULL(@p, [Col])`), so replaying the recording applies exactly
1282
+ * the change that was made and leaves the target's other columns — including settings a
1283
+ * consumer tuned on an MJ-owned row — alone. A field changed to NULL still carries its
1284
+ * `_Clear` companion (derived from the same map). A save whose changes touch no proc
1285
+ * parameter has nothing to replay, so it is logged as a comment. A hand-written update proc
1286
+ * (spUpdateGenerated off) makes no ISNULL promise, so it keeps the full-row form.
1287
+ */
1288
+ RenderReplayUpdateSQL(binding, entity, changedFieldValues) {
1289
+ const saveBinding = this.asSaveBinding(binding, 'RenderReplayUpdateSQL');
1290
+ const info = entity.EntityInfo;
1291
+ if (!info.spUpdateGenerated) {
1292
+ return undefined;
1293
+ }
1294
+ if (changedFieldValues.size === 0) {
1295
+ const key = entity.PrimaryKey.ToString().replace(/[\r\n]+/g, ' ');
1296
+ return `-- ${info.Name} (${key}): saved with no field changes; nothing to replay`;
1297
+ }
1298
+ const params = new SaveParameterCollector();
1299
+ const changed = this.renderSaveCallBindingWithSuffix(entity, changedFieldValues, true, saveBinding.suffix, params);
1300
+ const spName = this.GetCreateUpdateSPName(entity, false);
1301
+ return RenderSaveSQL(`${this.renderDeclareSetHead(changed)}EXEC [${info.SchemaName}].${spName} ${changed.callArgsSQL}`, params.Parameters, saveBinding.recordSuffix);
1175
1302
  }
1176
1303
  /** `DECLARE ...\n\nSET ...\n\n` when the binding declares variables, else empty. */
1177
1304
  renderDeclareSetHead(binding) {
@@ -1182,58 +1309,66 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1182
1309
  * shape: `DECLARE ... ; SET ... ; EXEC [schema].sp callArgs`. When
1183
1310
  * record-change tracking is on, this output is further wrapped by
1184
1311
  * `WrapSaveCallWithRecordChange`.
1312
+ *
1313
+ * When `returnContext.needsDatestampProjection` is true (either passed by
1314
+ * `GenericDatabaseProvider.GenerateSaveSQL` or resolved from `options`),
1315
+ * delegates to {@link ProjectSaveCallToDatestamps}.
1185
1316
  */
1186
- WrapSaveCallForResult(binding, entity, spName) {
1187
- if (binding.kind !== 'mssql-declare-exec') {
1188
- throw new Error(`SQLServerDataProvider.WrapSaveCallForResult: unexpected binding kind '${binding.kind}'`);
1317
+ WrapSaveCallForResult(binding, entity, spName, options, returnContext) {
1318
+ const resolvedReturn = returnContext ?? this.ResolveSaveReturnContext(entity, options);
1319
+ if (resolvedReturn.needsDatestampProjection) {
1320
+ return this.ProjectSaveCallToDatestamps(binding, entity, spName, resolvedReturn);
1189
1321
  }
1190
- const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${binding.callArgsSQL}`;
1191
- return { sql: `${this.renderDeclareSetHead(binding)}${execSQL}` };
1322
+ const saveBinding = this.asSaveBinding(binding, 'WrapSaveCallForResult');
1323
+ const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${saveBinding.callArgsSQL}`;
1324
+ return this.toSaveFragment(entity, `${this.renderDeclareSetHead(saveBinding)}${execSQL}`, saveBinding.parameters, saveBinding.recordSuffix);
1325
+ }
1326
+ /**
1327
+ * Captures the stored procedure's output into `@ResultTable` and projects only
1328
+ * `returnContext.datestampSelectColumnsSQL` (`SELECT [ID], [__mj_CreatedAt], [__mj_UpdatedAt] FROM @ResultTable;`)
1329
+ * when a caller requests `SaveReturnMode: 'datestamps_only'` at runtime on an entity whose
1330
+ * compiled stored procedure returns the full row.
1331
+ */
1332
+ ProjectSaveCallToDatestamps(binding, entity, spName, returnContext) {
1333
+ const saveBinding = this.asSaveBinding(binding, 'ProjectSaveCallToDatestamps');
1334
+ const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${saveBinding.callArgsSQL}`;
1335
+ const resultTableColumnsSQL = this.getSaveResultTableColumnsSQL(entity.EntityInfo, returnContext);
1336
+ const sql = `${this.renderDeclareSetHead(saveBinding)}DECLARE @ResultTable TABLE (\n ${resultTableColumnsSQL}\n);\n\nINSERT INTO @ResultTable\n${execSQL};\n\nSELECT ${returnContext.datestampSelectColumnsSQL} FROM @ResultTable;`;
1337
+ return this.toSaveFragment(entity, sql, saveBinding.parameters, saveBinding.recordSuffix);
1192
1338
  }
1193
1339
  /**
1194
1340
  * Wraps the save SQL with SQL Server's @ResultTable + inline
1195
1341
  * `spCreateRecordChange_Internal` EXEC pattern. The CONCAT-built `@ID`
1196
1342
  * inline supports composite-key entities.
1343
+ *
1344
+ * Consumes {@link SaveReturnContext} resolved by `GenericDatabaseProvider`:
1345
+ * - `@ResultTable` column declarations match the compiled stored procedure's return mode
1346
+ * (`returnContext.spReturnsDatestampsOnly`).
1347
+ * - The final `SELECT` from `@ResultTable` uses `returnContext.finalSelectColumnsSQL`.
1197
1348
  */
1198
- WrapSaveCallWithRecordChange(_saveSQL, binding, payload, entity) {
1199
- if (binding.kind !== 'mssql-declare-exec') {
1200
- throw new Error(`SQLServerDataProvider.WrapSaveCallWithRecordChange: unexpected binding kind '${binding.kind}'`);
1201
- }
1202
- const recordChangesEntityInfo = this.Entities.find((e) => e.Name === 'MJ: Record Changes');
1349
+ WrapSaveCallWithRecordChange(_saveSQL, binding, payload, entity, options, returnContext) {
1350
+ const saveBinding = this.asSaveBinding(binding, 'WrapSaveCallWithRecordChange');
1351
+ const params = new SaveParameterCollector(saveBinding.parameters);
1352
+ const recordChangesEntityInfo = this.EntityByName('MJ: Record Changes');
1203
1353
  const spName = this.GetCreateUpdateSPName(entity, payload.type === 'Create');
1204
1354
  const concatPKIDString = `CONCAT(${entity.EntityInfo.PrimaryKeys
1205
1355
  .map((pk) => `'${pk.CodeName}','${CompositeKey.DefaultValueDelimiter}',[${pk.Name}]`)
1206
1356
  .join(`,'${CompositeKey.DefaultFieldDelimiter}',`)})`;
1207
1357
  // Build the inline `EXEC spCreateRecordChange_Internal` from the payload,
1208
1358
  // referencing `@ID` (set below from the result table).
1209
- const lineageClause = payload.source === 'Restore'
1210
- ? `,
1211
- @Source='Restore',
1212
- @RestoredFromID='${payload.restoredFromID}',
1213
- @RestoreReason=${payload.restoreReason ? `N'${payload.restoreReason.replace(/'/g, "''")}'` : 'NULL'}`
1214
- : payload.source === 'Clone'
1215
- ? `,
1216
- @Source='Clone',
1217
- @ChangeContext=N'${EscapeSQLString(payload.changeContext)}'`
1218
- : '';
1219
- const recordChangeEXEC = `EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal @EntityName='${entity.EntityInfo.Name}',
1220
- @RecordID=@ID,
1221
- @UserID='${payload.userID}',
1222
- @Type='${payload.type}',
1223
- @ChangesJSON='${payload.changesJSON}',
1224
- @ChangesDescription='${payload.changesDescription}',
1225
- @FullRecordJSON='${payload.fullRecordJSON}',
1226
- @Status='Complete',
1227
- @Comments=null${lineageClause}`;
1228
- const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${binding.callArgsSQL}`;
1359
+ const recordChangeEXEC = this.renderRecordChangeEXEC(entity.EntityInfo.Name, '@ID', payload, params);
1360
+ const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${saveBinding.callArgsSQL}`;
1361
+ const resolvedReturn = returnContext ?? this.ResolveSaveReturnContext(entity, options);
1362
+ const resultTableColumnsSQL = this.getSaveResultTableColumnsSQL(entity.EntityInfo, resolvedReturn);
1363
+ const finalSelectSQL = `SELECT ${resolvedReturn.finalSelectColumnsSQL} FROM @ResultTable;`;
1229
1364
  const sql = `
1230
- ${binding.preambleSQL}
1365
+ ${saveBinding.preambleSQL}
1231
1366
 
1232
1367
  DECLARE @ResultTable TABLE (
1233
- ${this.getAllEntityColumnsSQL(entity.EntityInfo)}
1368
+ ${resultTableColumnsSQL}
1234
1369
  );
1235
1370
 
1236
- ${binding.setSQL}
1371
+ ${saveBinding.setSQL}
1237
1372
 
1238
1373
  INSERT INTO @ResultTable
1239
1374
  ${execSQL};
@@ -1252,8 +1387,44 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1252
1387
  ${recordChangeEXEC};
1253
1388
  END;
1254
1389
 
1255
- SELECT * FROM @ResultTable;`;
1256
- return { sql };
1390
+ ${finalSelectSQL}`;
1391
+ return this.toSaveFragment(entity, sql, params.Parameters, saveBinding.recordSuffix);
1392
+ }
1393
+ /**
1394
+ * `EXEC spCreateRecordChange_Internal …` for a payload. With `params`, each per-change value is
1395
+ * bound as a parameter typed from its Record Changes column, and the literal it replaces is kept
1396
+ * for the rendered form; without, the literals are written in directly. The entity name and change
1397
+ * type stay literal: they are constant for an entity and operation.
1398
+ */
1399
+ renderRecordChangeEXEC(entityName, recordIDSQL, payload, params) {
1400
+ const bind = (fieldName, literal) => (params ? params.AddField(this.recordChangeField(fieldName), literal) : literal);
1401
+ const lineageClause = payload.source === 'Restore'
1402
+ ? `,
1403
+ @Source='Restore',
1404
+ @RestoredFromID=${bind('RestoredFromID', `'${payload.restoredFromID}'`)},
1405
+ @RestoreReason=${payload.restoreReason ? bind('RestoreReason', `N'${payload.restoreReason.replace(/'/g, "''")}'`) : 'NULL'}`
1406
+ : payload.source === 'Clone'
1407
+ ? `,
1408
+ @Source='Clone',
1409
+ @ChangeContext=${bind('ChangeContext', `N'${EscapeSQLString(payload.changeContext)}'`)}`
1410
+ : '';
1411
+ return `EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal @EntityName=N'${entityName}',
1412
+ @RecordID=${recordIDSQL},
1413
+ @UserID=${bind('UserID', `'${payload.userID}'`)},
1414
+ @Type='${payload.type}',
1415
+ @ChangesJSON=${bind('ChangesJSON', `N'${payload.changesJSON}'`)},
1416
+ @ChangesDescription=${bind('ChangesDescription', `N'${payload.changesDescription}'`)},
1417
+ @FullRecordJSON=${bind('FullRecordJSON', `N'${payload.fullRecordJSON}'`)},
1418
+ @Status='Complete',
1419
+ @Comments=null${lineageClause}`;
1420
+ }
1421
+ /** A field of `MJ: Record Changes`, whose column types type the Record Change parameters. */
1422
+ recordChangeField(fieldName) {
1423
+ const field = this.EntityByName('MJ: Record Changes')?.FieldByName(fieldName);
1424
+ if (!field) {
1425
+ throw new Error(`SQLServerDataProvider: field "${fieldName}" of "MJ: Record Changes" is not in the metadata`);
1426
+ }
1427
+ return field;
1257
1428
  }
1258
1429
  /**
1259
1430
  * Generates the value portion of a SET statement for a field
@@ -1424,32 +1595,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1424
1595
  return unicodePrefix + quoteString + pVal + quoteString;
1425
1596
  }
1426
1597
  GetLogRecordChangeSQL(newData, oldData, entityName, recordID, entityInfo, type, user, wrapRecordIdInQuotes, restoreContext, cloneContext) {
1598
+ return this.renderLogRecordChangeSQL(newData, oldData, entityName, recordID, entityInfo, type, user, wrapRecordIdInQuotes, restoreContext, cloneContext);
1599
+ }
1600
+ /**
1601
+ * Renders the `spCreateRecordChange_Internal` call for `GetLogRecordChangeSQL` (literal form) and
1602
+ * for the delete batch (`params` supplied, so the payload values bind as typed inputs).
1603
+ */
1604
+ renderLogRecordChangeSQL(newData, oldData, entityName, recordID, entityInfo, type, user, wrapRecordIdInQuotes, restoreContext, cloneContext, params) {
1427
1605
  // Dialect-agnostic payload assembly is hoisted into DatabaseProviderBase
1428
1606
  // so SQL Server and PostgreSQL share one implementation. We only render
1429
1607
  // the T-SQL EXEC wrapper here.
1430
1608
  const payload = this.BuildRecordChangePayload(newData, oldData, String(recordID), entityInfo, type, user, restoreContext, "'", cloneContext);
1431
1609
  if (!payload)
1432
1610
  return null;
1433
- const quotes = wrapRecordIdInQuotes ? "'" : '';
1434
- const lineageClause = payload.source === 'Restore'
1435
- ? `,
1436
- @Source='Restore',
1437
- @RestoredFromID='${payload.restoredFromID}',
1438
- @RestoreReason=${payload.restoreReason ? `N'${payload.restoreReason.replace(/'/g, "''")}'` : 'NULL'}`
1439
- : payload.source === 'Clone'
1440
- ? `,
1441
- @Source='Clone',
1442
- @ChangeContext=N'${EscapeSQLString(payload.changeContext)}'`
1443
- : '';
1444
- return `EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal @EntityName='${entityName}',
1445
- @RecordID=${quotes}${recordID}${quotes},
1446
- @UserID='${payload.userID}',
1447
- @Type='${payload.type}',
1448
- @ChangesJSON='${payload.changesJSON}',
1449
- @ChangesDescription='${payload.changesDescription}',
1450
- @FullRecordJSON='${payload.fullRecordJSON}',
1451
- @Status='Complete',
1452
- @Comments=null${lineageClause}`;
1611
+ const recordIDSQL = wrapRecordIdInQuotes ? `N'${EscapeSQLString(String(recordID))}'` : String(recordID);
1612
+ const boundRecordID = params && wrapRecordIdInQuotes ? params.AddField(this.recordChangeField('RecordID'), recordIDSQL) : recordIDSQL;
1613
+ return this.renderRecordChangeEXEC(entityName, boundRecordID, payload, params);
1453
1614
  }
1454
1615
  /**
1455
1616
  * Implements the abstract BuildRecordChangeSQL from DatabaseProviderBase.
@@ -1474,19 +1635,30 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1474
1635
  return result.fullSQL;
1475
1636
  }
1476
1637
  /**
1477
- * This function generates both the full SQL (with record change metadata) and the simple stored procedure call for delete
1478
- * @returns Object with fullSQL and simpleSQL properties
1638
+ * Generates the delete statement with both full SQL (including record changes) and simple SQL.
1639
+ * With `params`, primary-key and Record Change values are bound as `@p0, @p1, …` and their
1640
+ * literals are kept on the parameters for `RenderSaveSQL`.
1641
+ * For a TrackRecordChanges entity with CascadeDeletes, the full SQL runs the proc uncaptured and selects the key twice:
1642
+ * first whether the row existed (read by transaction groups), last whether it was deleted (read by direct execution).
1479
1643
  */
1480
- getDeleteSQLWithDetails(entity, user, skipRecordChanges = false) {
1481
- let sSQL = '';
1644
+ getDeleteSQLWithDetails(entity, user, skipRecordChanges = false, params) {
1482
1645
  const spName = entity.EntityInfo.spDelete ? entity.EntityInfo.spDelete : `spDelete${entity.EntityInfo.BaseTableCodeName}`;
1483
- const sParams = entity.PrimaryKey.KeyValuePairs.map((kv) => {
1484
- const f = entity.EntityInfo.Fields.find((f) => f.Name.trim().toLowerCase() === kv.FieldName.trim().toLowerCase());
1485
- const quotes = f.NeedsQuotes ? "'" : '';
1486
- return `@${f.CodeName}=${quotes}${kv.Value}${quotes}`;
1487
- }).join(', ');
1646
+ const pkRefs = entity.PrimaryKey.KeyValuePairs.map((kv) => {
1647
+ const f = entity.EntityInfo.FieldByName(kv.FieldName);
1648
+ if (!f) {
1649
+ throw new Error(`SQLServerDataProvider.getDeleteSQLWithDetails: primary key field "${kv.FieldName}" not found on "${entity.EntityInfo.Name}"`);
1650
+ }
1651
+ // Quoted keys are escaped by generateSetStatementValue; an unquoted (numeric) key must be a plain number.
1652
+ const literal = f.NeedsQuotes
1653
+ ? this.generateSetStatementValue(f, kv.Value)
1654
+ : SQLServerDataProvider.RenderKeyValueLiteral(kv.Value, false, f.Name, entity.EntityInfo.Name);
1655
+ // One reference per key, reused by every statement below that names the key.
1656
+ return { Field: f, Ref: params ? params.AddField(f, literal) : literal };
1657
+ });
1658
+ const sParams = pkRefs.map((pk) => `@${pk.Field.CodeName}=${pk.Ref}`).join(', ');
1488
1659
  const sSimpleSQL = `EXEC [${entity.EntityInfo.SchemaName}].[${spName}] ${sParams}`;
1489
- const recordChangesEntityInfo = this.Entities.find((e) => e.Name === 'MJ: Record Changes');
1660
+ const recordChangesEntityInfo = this.EntityByName('MJ: Record Changes');
1661
+ let sSQL;
1490
1662
  if (entity.EntityInfo.TrackRecordChanges && !skipRecordChanges && entity.EntityInfo.Name.trim().toLowerCase() !== 'record changes') {
1491
1663
  // don't track changes for the record changes entity
1492
1664
  const oldData = entity.GetAll(true); // get all the OLD values
@@ -1506,6 +1678,40 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1506
1678
  const sReturnList = entity.PrimaryKeys.map((pk) => {
1507
1679
  return `@${pk.CodeName} AS [${pk.Name}]`;
1508
1680
  }).join(', ');
1681
+ 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);
1682
+ if (entity.EntityInfo.CascadeDeletes) {
1683
+ // A cascading spDelete EXECs other procs (spUpdate* to null references, spDelete* for
1684
+ // children) and each emits its own result set, spUpdate*'s being the full row. Capturing the
1685
+ // call with INSERT INTO @ResultTable EXEC takes every one of them, so the first full row fails
1686
+ // with error 213. Run the proc uncaptured instead and judge success by whether the row is
1687
+ // gone. The key is selected first (transaction groups read the first result set) and last
1688
+ // (direct execution reads the last, see ValidateDeleteResult); NULL when nothing was deleted.
1689
+ // The checks read the base view, not the table: runtime logins hold SELECT on base views
1690
+ // only, and for a soft-delete entity the view hides the row spDelete just soft-deleted.
1691
+ const sView = `[${entity.EntityInfo.SchemaName}].[${entity.EntityInfo.BaseView}]`;
1692
+ const sWhere = pkRefs.map((pk) => `[${pk.Field.Name}]=${pk.Ref}`).join(' AND ');
1693
+ const sKeyWhen = (flag) => pkRefs.map((pk) => `CASE WHEN ${flag} = 1 THEN ${pk.Ref} END AS [${pk.Field.Name}]`).join(', ');
1694
+ sSQL = `
1695
+ DECLARE @MJDeleteRowExisted BIT = CASE WHEN EXISTS (SELECT 1 FROM ${sView} WHERE ${sWhere}) THEN 1 ELSE 0 END
1696
+ SELECT ${sKeyWhen('@MJDeleteRowExisted')}
1697
+
1698
+ IF @MJDeleteRowExisted = 1
1699
+ ${sSimpleSQL}
1700
+
1701
+ DECLARE @MJDeleteRowDeleted BIT = CASE WHEN @MJDeleteRowExisted = 1 AND NOT EXISTS (SELECT 1 FROM ${sView} WHERE ${sWhere}) THEN 1 ELSE 0 END
1702
+ IF @MJDeleteRowDeleted = 1
1703
+ BEGIN
1704
+ DECLARE @ResultChangesTable TABLE (
1705
+ ${this.getAllEntityColumnsSQL(recordChangesEntityInfo)}
1706
+ )
1707
+
1708
+ INSERT INTO @ResultChangesTable
1709
+ ${recordChangeSQL}
1710
+ END
1711
+
1712
+ SELECT ${sKeyWhen('@MJDeleteRowDeleted')}`;
1713
+ return { fullSQL: sSQL, simpleSQL: sSimpleSQL };
1714
+ }
1509
1715
  sSQL = `
1510
1716
  IF OBJECT_ID('tempdb..#ResultTable') IS NOT NULL
1511
1717
  DROP TABLE #ResultTable
@@ -1526,7 +1732,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1526
1732
  )
1527
1733
 
1528
1734
  INSERT INTO @ResultChangesTable
1529
- ${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)}
1735
+ ${recordChangeSQL}
1530
1736
  END
1531
1737
 
1532
1738
  SELECT ${sReturnList}`;
@@ -1549,10 +1755,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1549
1755
  // WrapSaveCallWithRecordChange (see this file's "Save Grammar" section
1550
1756
  // above). See plans/sp-save-builder-generic-layer-refactor.md (rev 4).
1551
1757
  GenerateDeleteSQL(entity, user, options) {
1552
- const sqlDetails = this.getDeleteSQLWithDetails(entity, user, options?.SkipRecordChanges === true);
1758
+ const params = new SaveParameterCollector();
1759
+ const sqlDetails = this.getDeleteSQLWithDetails(entity, user, options?.SkipRecordChanges === true, params);
1760
+ const fragment = this.toSaveFragment(entity, sqlDetails.fullSQL, params.Parameters, '');
1761
+ let cachedSimpleSQL;
1553
1762
  return {
1554
- fullSQL: sqlDetails.fullSQL,
1555
- simpleSQL: sqlDetails.simpleSQL,
1763
+ fullSQL: fragment.sql,
1764
+ get simpleSQL() {
1765
+ if (cachedSimpleSQL === undefined) {
1766
+ cachedSimpleSQL = RenderSaveSQL(sqlDetails.simpleSQL, params.Parameters, '');
1767
+ }
1768
+ return cachedSimpleSQL;
1769
+ },
1770
+ parameters: fragment.parameters ?? null,
1771
+ get literalSQL() {
1772
+ return fragment.literalSQL;
1773
+ },
1556
1774
  };
1557
1775
  }
1558
1776
  OnSuspendRefresh() {
@@ -1677,6 +1895,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1677
1895
  }
1678
1896
  // For transactional queries, use the instance queue to ensure serialization
1679
1897
  // This prevents EREQINPROG errors when multiple queries try to use the same transaction
1898
+ this.initializeQueueProcessor();
1680
1899
  return new Promise((resolve, reject) => {
1681
1900
  this._sqlQueue$.next({
1682
1901
  kind: 'query',
@@ -1719,7 +1938,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1719
1938
  * @remarks
1720
1939
  * - Always creates a new Request object for each query to avoid "EREQINPROG" errors
1721
1940
  * - Handles both positional (?) and named (@param) parameter styles
1722
- * - Automatically uses active transaction if one exists, otherwise uses connection pool
1941
+ * - Uses the ambient transaction when the caller is part of it (#4786), otherwise uses connection pool
1723
1942
  * - Handles SQL logging in parallel with query execution
1724
1943
  * - Provides automatic retry with pool connection if transaction fails
1725
1944
  *
@@ -1733,25 +1952,40 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1733
1952
  if (connectionSource instanceof sql.Transaction) {
1734
1953
  transaction = connectionSource;
1735
1954
  }
1736
- else if (!connectionSource && !loggingOptions?.ignoreAmbientTransaction) {
1955
+ else if (!connectionSource && !loggingOptions?.ignoreAmbientTransaction && this.IsCallerInAmbientTransaction()) {
1956
+ // A caller outside the open transaction's scope runs on the pool as if none were open (#4786).
1737
1957
  this.AssertAmbientTransactionUsable();
1738
1958
  transaction = this._transaction;
1739
1959
  }
1960
+ const shouldLogSql = !loggingOptions?.ignoreLogging && (this.HasActiveSqlLoggingSessions ||
1961
+ Object.prototype.hasOwnProperty.call(this, '_logSqlStatement') ||
1962
+ Object.getPrototypeOf(this) !== SQLServerDataProvider.prototype);
1963
+ if (shouldLogSql && !this._boundLogSqlStatement) {
1964
+ this._boundLogSqlStatement = this._logSqlStatement.bind(this);
1965
+ }
1966
+ if (!this._boundClearTransaction) {
1967
+ this._boundClearTransaction = () => {
1968
+ this._transaction = null;
1969
+ };
1970
+ }
1740
1971
  // Create the execution context
1741
1972
  const context = {
1742
1973
  pool: this._pool,
1743
1974
  transaction: transaction,
1744
- logSqlStatement: this._logSqlStatement.bind(this),
1745
- clearTransaction: () => {
1746
- this._transaction = null;
1747
- }
1975
+ logSqlStatement: shouldLogSql ? this._boundLogSqlStatement : undefined,
1976
+ clearTransaction: this._boundClearTransaction
1748
1977
  };
1749
1978
  // Convert logging options to internal format
1750
1979
  const options = loggingOptions ? {
1751
1980
  description: loggingOptions.description,
1752
1981
  ignoreLogging: loggingOptions.ignoreLogging,
1753
1982
  isMutation: loggingOptions.isMutation,
1754
- simpleSQLFallback: loggingOptions.simpleSQLFallback,
1983
+ get simpleSQLFallback() {
1984
+ return loggingOptions.simpleSQLFallback;
1985
+ },
1986
+ get literalSQL() {
1987
+ return loggingOptions.literalSQL;
1988
+ },
1755
1989
  contextUser: loggingOptions.contextUser,
1756
1990
  // A statement inside a transaction follows the transaction's limits (see ExecuteSQLOptions).
1757
1991
  timeoutMs: transaction ? undefined : loggingOptions.timeoutMs
@@ -1774,7 +2008,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1774
2008
  description: options?.description,
1775
2009
  ignoreLogging: options?.ignoreLogging,
1776
2010
  isMutation: options?.isMutation,
1777
- simpleSQLFallback: options?.simpleSQLFallback,
2011
+ get simpleSQLFallback() {
2012
+ return options?.simpleSQLFallback;
2013
+ },
2014
+ get literalSQL() {
2015
+ return options?.literalSQL;
2016
+ },
1778
2017
  contextUser: contextUser,
1779
2018
  ignoreAmbientTransaction: options?.ignoreAmbientTransaction,
1780
2019
  timeoutMs: options?.timeoutMs,
@@ -1970,9 +2209,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1970
2209
  */
1971
2210
  async ExecuteSQLBatch(queries, parameters, options, contextUser) {
1972
2211
  try {
1973
- // A read that does not join the ambient transaction cannot autocommit anything on the pool,
1974
- // which is what the doomed-transaction assert protects; skip it for that case (#4514).
1975
- if (!options?.ignoreAmbientTransaction) {
2212
+ // A batch joins the ambient transaction only when the caller opted in (default) AND is in
2213
+ // its scope (#4786) — a caller outside that scope runs on the pool as if none were open. A
2214
+ // read that does not join it cannot autocommit anything on the pool, which is what the
2215
+ // doomed-transaction assert protects; skip the assert for that case too (#4514).
2216
+ const joinsAmbient = !options?.ignoreAmbientTransaction && this.IsCallerInAmbientTransaction();
2217
+ if (joinsAmbient) {
1976
2218
  this.AssertAmbientTransactionUsable();
1977
2219
  }
1978
2220
  // Build combined batch SQL and parameters (same as static method)
@@ -2022,7 +2264,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
2022
2264
  // Create execution context
2023
2265
  const context = {
2024
2266
  pool: this._pool,
2025
- transaction: options?.ignoreAmbientTransaction ? null : this._transaction,
2267
+ transaction: joinsAmbient ? this._transaction : null,
2026
2268
  logSqlStatement: this._logSqlStatement.bind(this),
2027
2269
  clearTransaction: () => {
2028
2270
  this._transaction = null;
@@ -2078,8 +2320,19 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
2078
2320
  if (this._datetimeOffsetTestComplete) {
2079
2321
  return this._needsDatetimeOffsetAdjustment;
2080
2322
  }
2081
- // Run the test
2082
- await this.testDatetimeOffsetHandling();
2323
+ // A recent probe failed: keep its safe default until the backoff passes, rather than re-probing
2324
+ // (and logging the failure again) on every call while the database is unreachable.
2325
+ const failedAt = SQLServerDataProvider._datetimeOffsetTestFailedAt;
2326
+ if (failedAt !== null && Date.now() - failedAt < SQLServerDataProvider.DATETIMEOFFSET_PROBE_RETRY_MS) {
2327
+ return this._needsDatetimeOffsetAdjustment;
2328
+ }
2329
+ // Deduplicate concurrent tests across per-request provider instances
2330
+ if (!SQLServerDataProvider._datetimeOffsetTestPromise) {
2331
+ SQLServerDataProvider._datetimeOffsetTestPromise = this.testDatetimeOffsetHandling().finally(() => {
2332
+ SQLServerDataProvider._datetimeOffsetTestPromise = null;
2333
+ });
2334
+ }
2335
+ await SQLServerDataProvider._datetimeOffsetTestPromise;
2083
2336
  return this._needsDatetimeOffsetAdjustment;
2084
2337
  }
2085
2338
  /**
@@ -2124,13 +2377,15 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
2124
2377
  }
2125
2378
  // Mark the test as complete
2126
2379
  this._datetimeOffsetTestComplete = true;
2380
+ SQLServerDataProvider._datetimeOffsetTestFailedAt = null;
2127
2381
  }
2128
2382
  catch (e) {
2129
2383
  // If the test fails, log the error but don't prevent initialization
2130
2384
  console.error('SQLServerDataProvider: Failed to test datetimeoffset handling:', e);
2131
- // Default to assuming adjustment is needed for safety
2385
+ // Default to assuming adjustment is needed for safety on this provider,
2386
+ // but leave _datetimeOffsetTestComplete false so a call after the backoff retries.
2132
2387
  this._needsDatetimeOffsetAdjustment = true;
2133
- this._datetimeOffsetTestComplete = true;
2388
+ SQLServerDataProvider._datetimeOffsetTestFailedAt = Date.now();
2134
2389
  }
2135
2390
  }
2136
2391
  /**
@@ -2207,12 +2462,12 @@ DECLARE ${varName} NVARCHAR(MAX) = (
2207
2462
  );
2208
2463
  IF ${varName} IS NOT NULL
2209
2464
  EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal
2210
- @EntityName='${safeEntityName}',
2211
- @RecordID='${recordID}',
2465
+ @EntityName=N'${safeEntityName}',
2466
+ @RecordID=N'${recordID}',
2212
2467
  @UserID='${safeUserId}',
2213
2468
  @Type='Update',
2214
- @ChangesJSON='${safeChangesJSON}',
2215
- @ChangesDescription='${safeChangesDesc}',
2469
+ @ChangesJSON=N'${safeChangesJSON}',
2470
+ @ChangesDescription=N'${safeChangesDesc}',
2216
2471
  @FullRecordJSON=${varName},
2217
2472
  @Status='Complete',
2218
2473
  @Comments=NULL${lineageClause};`;
@@ -2265,6 +2520,7 @@ IF ${varName} IS NOT NULL
2265
2520
  * that queued ambient queries behind it are rejected rather than run on the doomed handle.
2266
2521
  */
2267
2522
  enqueueTransactionAction(description, handle, run) {
2523
+ this.initializeQueueProcessor();
2268
2524
  return new Promise((resolve, reject) => {
2269
2525
  this._sqlQueue$.next({ kind: 'action', description, handle, run, resolve, reject });
2270
2526
  });
@@ -2340,16 +2596,20 @@ IF ${varName} IS NOT NULL
2340
2596
  /**
2341
2597
  * Override RefreshIfNeeded to skip refresh when a transaction is active
2342
2598
  * This prevents conflicts between metadata refresh operations and active transactions
2599
+ * @param providerToUse - passed through to the base implementation
2600
+ * @param bypassMinCheckInterval - passed through; event-driven callers (a metadata change notice
2601
+ * from another server) set it so the check throttle cannot drop their check. Dropping the
2602
+ * arguments here used to discard it.
2343
2603
  * @returns Promise<boolean> - true if refresh was performed, false if skipped or no refresh needed
2344
2604
  */
2345
- async RefreshIfNeeded() {
2605
+ async RefreshIfNeeded(providerToUse, bypassMinCheckInterval) {
2346
2606
  // Skip refresh if a transaction is active
2347
2607
  if (this.IsTransactionActive) {
2348
2608
  LogStatus('Skipping metadata refresh - transaction is active');
2349
2609
  return false;
2350
2610
  }
2351
2611
  // Call parent implementation if no transaction
2352
- return super.RefreshIfNeeded();
2612
+ return super.RefreshIfNeeded(providerToUse, bypassMinCheckInterval);
2353
2613
  }
2354
2614
  get FileSystemProvider() {
2355
2615
  if (!this._fileSystemProvider)