@memberjunction/sqlserver-dataprovider 6.2.0-edge.4 → 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) {
@@ -891,9 +909,11 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
891
909
  // SQLServerTransactionGroup-batched saves regenerate per-item SQL when
892
910
  // transaction variables get rebound. `_spName` is preserved for back-compat
893
911
  // with that caller; the dialect-driven pipeline derives it from entity
894
- // metadata via `GetCreateUpdateSPName`.
912
+ // metadata via `GetCreateUpdateSPName`. Outside a TransactionGroup,
913
+ // `fullSQL` holds `@p$0` parameter placeholders, so return `literalSQL`
914
+ // for standalone callers.
895
915
  const result = await this.GenerateSaveSQL(entity, bNewRecord, user);
896
- return result.fullSQL;
916
+ return result.literalSQL ?? result.fullSQL;
897
917
  }
898
918
  /**
899
919
  * Queue the AI action task only once the save is durable: through {@link RunAfterCommit}, so it
@@ -906,6 +926,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
906
926
  await QueueManager.AddTask('Entity AI Action', params, null, user);
907
927
  }, 'Entity AI Action', postCommitToken);
908
928
  }
929
+ static { this._entityColumnsSQLCache = new WeakMap(); }
909
930
  getAllEntityColumnsSQL(entityInfo) {
910
931
  // This builds the column list for the @ResultTable/@ResultChangesTable that captures
911
932
  // `SELECT * FROM <BaseView>` via a POSITIONAL `INSERT INTO @table EXEC ...`. SQL Server maps the
@@ -930,10 +951,17 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
930
951
  // — the only case where a base-column-after-a-join could mis-route by position — we WARN loudly
931
952
  // (warnOnViewOrderFallback), because the metadata order can no longer be checked against the view and
932
953
  // a silent fallback there would let the very bug this fix prevents persist undetected.
933
- let orderedFields;
934
- let fallbackReason;
935
954
  const viewKey = `${entityInfo.SchemaName}.${entityInfo.BaseView}`.toLowerCase();
936
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;
937
965
  if (!viewOrder) {
938
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)`;
939
967
  }
@@ -941,16 +969,16 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
941
969
  fallbackReason = `base view exposes ${viewOrder.length} column(s) but the entity declares ${entityInfo.Fields.length} field(s) — cannot align 1:1 by position`;
942
970
  }
943
971
  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)) {
972
+ const mapped = viewOrder.map((colName) => entityInfo.FieldByName(colName));
973
+ if (mapped.every((f) => f !== null && f !== undefined)) {
947
974
  orderedFields = mapped; // every view column resolved 1:1 to a field
948
975
  }
949
976
  else {
950
- const unmatched = viewOrder.filter((colName) => !fieldByName.has(colName.toLowerCase()));
977
+ const unmatched = viewOrder.filter((colName) => !entityInfo.FieldByName(colName));
951
978
  fallbackReason = `view column(s) without a matching EntityField: ${unmatched.join(', ')}`;
952
979
  }
953
980
  }
981
+ const usedFallback = !orderedFields;
954
982
  if (!orderedFields) {
955
983
  this.warnIfBaseFieldSequencedAfterVirtual(entityInfo);
956
984
  this.warnOnViewOrderFallback(entityInfo, fallbackReason);
@@ -963,8 +991,35 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
963
991
  sRet += '[' + f.Name + '] ' + f.SQLFullType + ' ' + (f.AllowsNull || f.IsVirtual ? 'NULL' : 'NOT NULL');
964
992
  outputCount++;
965
993
  }
994
+ SQLServerDataProvider._entityColumnsSQLCache.set(entityInfo, {
995
+ viewOrderRef: viewOrder,
996
+ fieldCount: entityInfo.Fields.length,
997
+ usedFallback,
998
+ sql: sRet,
999
+ });
966
1000
  return sRet;
967
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
+ }
968
1023
  /** Entities already warned about base-after-virtual field-order skew — so we warn once, not every save. */
969
1024
  static { this._fieldOrderWarnedEntities = new Set(); }
970
1025
  /**
@@ -1063,7 +1118,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1063
1118
  if (value && field.Type.trim().toLowerCase() === 'datetimeoffset') {
1064
1119
  return { kind: 'use', value: new Date(value).toISOString() };
1065
1120
  }
1066
- if (!isUpdate && field.Type.trim().toLowerCase() === 'uniqueidentifier' && !field.IsPrimaryKey) {
1121
+ if (!isUpdate && field.IsUniqueIdentifier && !field.IsPrimaryKey) {
1067
1122
  if (typeof value === 'string' && value.includes('()')) {
1068
1123
  return { kind: 'skip' };
1069
1124
  }
@@ -1083,14 +1138,53 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1083
1138
  * `EntityFieldInfo.NeedsClearCompanion`.
1084
1139
  */
1085
1140
  RenderSaveCallBinding(entity, fieldValues, isUpdate, _spName) {
1086
- return this.renderSaveCallBindingWithSuffix(entity, fieldValues, isUpdate, 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;
1087
1154
  }
1088
1155
  /**
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).
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.
1092
1162
  */
1093
- renderSaveCallBindingWithSuffix(entity, fieldValues, isUpdate, uniqueSuffix) {
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) {
1094
1188
  const declarations = [];
1095
1189
  const setStatements = [];
1096
1190
  const execParams = [];
@@ -1104,7 +1198,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1104
1198
  const hasValue = value !== null && value !== undefined;
1105
1199
  const binaryLiteral = hasValue && f.IsBinaryFieldType ? this.FormatBinaryLiteral(f, value) : undefined;
1106
1200
  if (hasValue) {
1107
- setStatements.push(`SET ${varName} = ${binaryLiteral ?? this.generateSetStatementValue(f, value)}`);
1201
+ setStatements.push(`SET ${varName} = ${params.AddField(f, binaryLiteral ?? this.generateSetStatementValue(f, value))}`);
1108
1202
  }
1109
1203
  execParams.push(`@${f.CodeName}=${varName}`);
1110
1204
  simpleParams += this.generateSingleSPParam(f, value, bFirst, binaryLiteral);
@@ -1117,12 +1211,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1117
1211
  // UPDATE: tail-append PK declarations from the loaded entity's PrimaryKey.
1118
1212
  if (isUpdate && execParams.length > 0) {
1119
1213
  for (const pkv of entity.PrimaryKey.KeyValuePairs) {
1120
- const f = entity.EntityInfo.Fields.find((x) => x.Name.trim().toLowerCase() === pkv.FieldName.trim().toLowerCase());
1214
+ const f = entity.EntityInfo.FieldByName(pkv.FieldName);
1121
1215
  if (!f)
1122
1216
  continue;
1123
1217
  const varName = `@${f.CodeName}${uniqueSuffix}`;
1124
1218
  declarations.push(`${varName} ${f.SQLFullType.toUpperCase()}`);
1125
- setStatements.push(`SET ${varName} = ${this.generateSetStatementValue(f, pkv.Value)}`);
1219
+ setStatements.push(`SET ${varName} = ${params.AddField(f, this.generateSetStatementValue(f, pkv.Value))}`);
1126
1220
  execParams.push(`@${f.CodeName}=${varName}`);
1127
1221
  const pkeyQuotes = f.NeedsQuotes ? "'" : '';
1128
1222
  simpleParams += `, @${f.CodeName} = ${pkeyQuotes}${pkv.Value}${pkeyQuotes}`;
@@ -1165,23 +1259,21 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1165
1259
  * there is nothing to look up).
1166
1260
  */
1167
1261
  RenderReplaySaveSQL(binding, entity, fieldValues) {
1168
- if (binding.kind !== 'mssql-declare-exec') {
1169
- throw new Error(`SQLServerDataProvider.RenderReplaySaveSQL: unexpected binding kind '${binding.kind}'`);
1170
- }
1262
+ const saveBinding = this.asSaveBinding(binding, 'RenderReplaySaveSQL');
1171
1263
  const info = entity.EntityInfo;
1172
1264
  const pks = info.PrimaryKeys;
1173
1265
  if (pks.length === 0 || !pks.every((pk) => fieldValues.has(pk))) {
1174
1266
  return undefined;
1175
1267
  }
1176
1268
  const schema = info.SchemaName;
1177
- 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 ');
1178
1270
  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`;
1271
+ const createBranch = `IF NOT EXISTS (SELECT 1 FROM [${schema}].[${info.BaseTable}] WHERE ${where})\nBEGIN\n EXEC [${schema}].${createSpName} ${saveBinding.callArgsSQL}\nEND`;
1180
1272
  const hasUpdateProc = info.AllowUpdateAPI && info.spUpdateGenerated && !info.VirtualEntity;
1181
1273
  const updateBranch = hasUpdateProc
1182
- ? `\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`
1183
1275
  : '';
1184
- return `${this.renderDeclareSetHead(binding)}${createBranch}${updateBranch}`;
1276
+ return RenderSaveSQL(`${this.renderDeclareSetHead(saveBinding)}${createBranch}${updateBranch}`, saveBinding.parameters, saveBinding.recordSuffix);
1185
1277
  }
1186
1278
  /**
1187
1279
  * Replay form of an UPDATE for the SQL log (never executed): `EXEC spUpdate` with only the
@@ -1194,9 +1286,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1194
1286
  * (spUpdateGenerated off) makes no ISNULL promise, so it keeps the full-row form.
1195
1287
  */
1196
1288
  RenderReplayUpdateSQL(binding, entity, changedFieldValues) {
1197
- if (binding.kind !== 'mssql-declare-exec') {
1198
- throw new Error(`SQLServerDataProvider.RenderReplayUpdateSQL: unexpected binding kind '${binding.kind}'`);
1199
- }
1289
+ const saveBinding = this.asSaveBinding(binding, 'RenderReplayUpdateSQL');
1200
1290
  const info = entity.EntityInfo;
1201
1291
  if (!info.spUpdateGenerated) {
1202
1292
  return undefined;
@@ -1205,9 +1295,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1205
1295
  const key = entity.PrimaryKey.ToString().replace(/[\r\n]+/g, ' ');
1206
1296
  return `-- ${info.Name} (${key}): saved with no field changes; nothing to replay`;
1207
1297
  }
1208
- const changed = this.renderSaveCallBindingWithSuffix(entity, changedFieldValues, true, binding.suffix);
1298
+ const params = new SaveParameterCollector();
1299
+ const changed = this.renderSaveCallBindingWithSuffix(entity, changedFieldValues, true, saveBinding.suffix, params);
1209
1300
  const spName = this.GetCreateUpdateSPName(entity, false);
1210
- return `${this.renderDeclareSetHead(changed)}EXEC [${info.SchemaName}].${spName} ${changed.callArgsSQL}`;
1301
+ return RenderSaveSQL(`${this.renderDeclareSetHead(changed)}EXEC [${info.SchemaName}].${spName} ${changed.callArgsSQL}`, params.Parameters, saveBinding.recordSuffix);
1211
1302
  }
1212
1303
  /** `DECLARE ...\n\nSET ...\n\n` when the binding declares variables, else empty. */
1213
1304
  renderDeclareSetHead(binding) {
@@ -1218,58 +1309,66 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1218
1309
  * shape: `DECLARE ... ; SET ... ; EXEC [schema].sp callArgs`. When
1219
1310
  * record-change tracking is on, this output is further wrapped by
1220
1311
  * `WrapSaveCallWithRecordChange`.
1312
+ *
1313
+ * When `returnContext.needsDatestampProjection` is true (either passed by
1314
+ * `GenericDatabaseProvider.GenerateSaveSQL` or resolved from `options`),
1315
+ * delegates to {@link ProjectSaveCallToDatestamps}.
1221
1316
  */
1222
- WrapSaveCallForResult(binding, entity, spName) {
1223
- if (binding.kind !== 'mssql-declare-exec') {
1224
- 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);
1225
1321
  }
1226
- const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${binding.callArgsSQL}`;
1227
- 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);
1228
1338
  }
1229
1339
  /**
1230
1340
  * Wraps the save SQL with SQL Server's @ResultTable + inline
1231
1341
  * `spCreateRecordChange_Internal` EXEC pattern. The CONCAT-built `@ID`
1232
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`.
1233
1348
  */
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');
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');
1239
1353
  const spName = this.GetCreateUpdateSPName(entity, payload.type === 'Create');
1240
1354
  const concatPKIDString = `CONCAT(${entity.EntityInfo.PrimaryKeys
1241
1355
  .map((pk) => `'${pk.CodeName}','${CompositeKey.DefaultValueDelimiter}',[${pk.Name}]`)
1242
1356
  .join(`,'${CompositeKey.DefaultFieldDelimiter}',`)})`;
1243
1357
  // Build the inline `EXEC spCreateRecordChange_Internal` from the payload,
1244
1358
  // 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}`;
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;`;
1265
1364
  const sql = `
1266
- ${binding.preambleSQL}
1365
+ ${saveBinding.preambleSQL}
1267
1366
 
1268
1367
  DECLARE @ResultTable TABLE (
1269
- ${this.getAllEntityColumnsSQL(entity.EntityInfo)}
1368
+ ${resultTableColumnsSQL}
1270
1369
  );
1271
1370
 
1272
- ${binding.setSQL}
1371
+ ${saveBinding.setSQL}
1273
1372
 
1274
1373
  INSERT INTO @ResultTable
1275
1374
  ${execSQL};
@@ -1288,8 +1387,44 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1288
1387
  ${recordChangeEXEC};
1289
1388
  END;
1290
1389
 
1291
- SELECT * FROM @ResultTable;`;
1292
- 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;
1293
1428
  }
1294
1429
  /**
1295
1430
  * Generates the value portion of a SET statement for a field
@@ -1460,32 +1595,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1460
1595
  return unicodePrefix + quoteString + pVal + quoteString;
1461
1596
  }
1462
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) {
1463
1605
  // Dialect-agnostic payload assembly is hoisted into DatabaseProviderBase
1464
1606
  // so SQL Server and PostgreSQL share one implementation. We only render
1465
1607
  // the T-SQL EXEC wrapper here.
1466
1608
  const payload = this.BuildRecordChangePayload(newData, oldData, String(recordID), entityInfo, type, user, restoreContext, "'", cloneContext);
1467
1609
  if (!payload)
1468
1610
  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}`;
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);
1489
1614
  }
1490
1615
  /**
1491
1616
  * Implements the abstract BuildRecordChangeSQL from DatabaseProviderBase.
@@ -1510,19 +1635,30 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1510
1635
  return result.fullSQL;
1511
1636
  }
1512
1637
  /**
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
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).
1515
1643
  */
1516
- getDeleteSQLWithDetails(entity, user, skipRecordChanges = false) {
1517
- let sSQL = '';
1644
+ getDeleteSQLWithDetails(entity, user, skipRecordChanges = false, params) {
1518
1645
  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(', ');
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(', ');
1524
1659
  const sSimpleSQL = `EXEC [${entity.EntityInfo.SchemaName}].[${spName}] ${sParams}`;
1525
- const recordChangesEntityInfo = this.Entities.find((e) => e.Name === 'MJ: Record Changes');
1660
+ const recordChangesEntityInfo = this.EntityByName('MJ: Record Changes');
1661
+ let sSQL;
1526
1662
  if (entity.EntityInfo.TrackRecordChanges && !skipRecordChanges && entity.EntityInfo.Name.trim().toLowerCase() !== 'record changes') {
1527
1663
  // don't track changes for the record changes entity
1528
1664
  const oldData = entity.GetAll(true); // get all the OLD values
@@ -1542,6 +1678,40 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1542
1678
  const sReturnList = entity.PrimaryKeys.map((pk) => {
1543
1679
  return `@${pk.CodeName} AS [${pk.Name}]`;
1544
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
+ }
1545
1715
  sSQL = `
1546
1716
  IF OBJECT_ID('tempdb..#ResultTable') IS NOT NULL
1547
1717
  DROP TABLE #ResultTable
@@ -1562,7 +1732,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1562
1732
  )
1563
1733
 
1564
1734
  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)}
1735
+ ${recordChangeSQL}
1566
1736
  END
1567
1737
 
1568
1738
  SELECT ${sReturnList}`;
@@ -1585,10 +1755,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1585
1755
  // WrapSaveCallWithRecordChange (see this file's "Save Grammar" section
1586
1756
  // above). See plans/sp-save-builder-generic-layer-refactor.md (rev 4).
1587
1757
  GenerateDeleteSQL(entity, user, options) {
1588
- 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;
1589
1762
  return {
1590
- fullSQL: sqlDetails.fullSQL,
1591
- 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
+ },
1592
1774
  };
1593
1775
  }
1594
1776
  OnSuspendRefresh() {
@@ -1713,6 +1895,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1713
1895
  }
1714
1896
  // For transactional queries, use the instance queue to ensure serialization
1715
1897
  // This prevents EREQINPROG errors when multiple queries try to use the same transaction
1898
+ this.initializeQueueProcessor();
1716
1899
  return new Promise((resolve, reject) => {
1717
1900
  this._sqlQueue$.next({
1718
1901
  kind: 'query',
@@ -1774,21 +1957,35 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1774
1957
  this.AssertAmbientTransactionUsable();
1775
1958
  transaction = this._transaction;
1776
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
+ }
1777
1971
  // Create the execution context
1778
1972
  const context = {
1779
1973
  pool: this._pool,
1780
1974
  transaction: transaction,
1781
- logSqlStatement: this._logSqlStatement.bind(this),
1782
- clearTransaction: () => {
1783
- this._transaction = null;
1784
- }
1975
+ logSqlStatement: shouldLogSql ? this._boundLogSqlStatement : undefined,
1976
+ clearTransaction: this._boundClearTransaction
1785
1977
  };
1786
1978
  // Convert logging options to internal format
1787
1979
  const options = loggingOptions ? {
1788
1980
  description: loggingOptions.description,
1789
1981
  ignoreLogging: loggingOptions.ignoreLogging,
1790
1982
  isMutation: loggingOptions.isMutation,
1791
- simpleSQLFallback: loggingOptions.simpleSQLFallback,
1983
+ get simpleSQLFallback() {
1984
+ return loggingOptions.simpleSQLFallback;
1985
+ },
1986
+ get literalSQL() {
1987
+ return loggingOptions.literalSQL;
1988
+ },
1792
1989
  contextUser: loggingOptions.contextUser,
1793
1990
  // A statement inside a transaction follows the transaction's limits (see ExecuteSQLOptions).
1794
1991
  timeoutMs: transaction ? undefined : loggingOptions.timeoutMs
@@ -1811,7 +2008,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
1811
2008
  description: options?.description,
1812
2009
  ignoreLogging: options?.ignoreLogging,
1813
2010
  isMutation: options?.isMutation,
1814
- simpleSQLFallback: options?.simpleSQLFallback,
2011
+ get simpleSQLFallback() {
2012
+ return options?.simpleSQLFallback;
2013
+ },
2014
+ get literalSQL() {
2015
+ return options?.literalSQL;
2016
+ },
1815
2017
  contextUser: contextUser,
1816
2018
  ignoreAmbientTransaction: options?.ignoreAmbientTransaction,
1817
2019
  timeoutMs: options?.timeoutMs,
@@ -2118,8 +2320,19 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
2118
2320
  if (this._datetimeOffsetTestComplete) {
2119
2321
  return this._needsDatetimeOffsetAdjustment;
2120
2322
  }
2121
- // Run the test
2122
- 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;
2123
2336
  return this._needsDatetimeOffsetAdjustment;
2124
2337
  }
2125
2338
  /**
@@ -2164,13 +2377,15 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
2164
2377
  }
2165
2378
  // Mark the test as complete
2166
2379
  this._datetimeOffsetTestComplete = true;
2380
+ SQLServerDataProvider._datetimeOffsetTestFailedAt = null;
2167
2381
  }
2168
2382
  catch (e) {
2169
2383
  // If the test fails, log the error but don't prevent initialization
2170
2384
  console.error('SQLServerDataProvider: Failed to test datetimeoffset handling:', e);
2171
- // 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.
2172
2387
  this._needsDatetimeOffsetAdjustment = true;
2173
- this._datetimeOffsetTestComplete = true;
2388
+ SQLServerDataProvider._datetimeOffsetTestFailedAt = Date.now();
2174
2389
  }
2175
2390
  }
2176
2391
  /**
@@ -2247,12 +2462,12 @@ DECLARE ${varName} NVARCHAR(MAX) = (
2247
2462
  );
2248
2463
  IF ${varName} IS NOT NULL
2249
2464
  EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal
2250
- @EntityName='${safeEntityName}',
2251
- @RecordID='${recordID}',
2465
+ @EntityName=N'${safeEntityName}',
2466
+ @RecordID=N'${recordID}',
2252
2467
  @UserID='${safeUserId}',
2253
2468
  @Type='Update',
2254
- @ChangesJSON='${safeChangesJSON}',
2255
- @ChangesDescription='${safeChangesDesc}',
2469
+ @ChangesJSON=N'${safeChangesJSON}',
2470
+ @ChangesDescription=N'${safeChangesDesc}',
2256
2471
  @FullRecordJSON=${varName},
2257
2472
  @Status='Complete',
2258
2473
  @Comments=NULL${lineageClause};`;
@@ -2305,6 +2520,7 @@ IF ${varName} IS NOT NULL
2305
2520
  * that queued ambient queries behind it are rejected rather than run on the doomed handle.
2306
2521
  */
2307
2522
  enqueueTransactionAction(description, handle, run) {
2523
+ this.initializeQueueProcessor();
2308
2524
  return new Promise((resolve, reject) => {
2309
2525
  this._sqlQueue$.next({ kind: 'action', description, handle, run, resolve, reject });
2310
2526
  });