@memberjunction/core 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.
Files changed (99) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +7 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +22 -4
  4. package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +33 -0
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  7. package/dist/generic/RegisterForStartup.d.ts +44 -0
  8. package/dist/generic/RegisterForStartup.d.ts.map +1 -1
  9. package/dist/generic/RegisterForStartup.js +124 -48
  10. package/dist/generic/RegisterForStartup.js.map +1 -1
  11. package/dist/generic/baseEngine.d.ts +222 -7
  12. package/dist/generic/baseEngine.d.ts.map +1 -1
  13. package/dist/generic/baseEngine.js +555 -37
  14. package/dist/generic/baseEngine.js.map +1 -1
  15. package/dist/generic/baseEngineSweeper.d.ts +35 -0
  16. package/dist/generic/baseEngineSweeper.d.ts.map +1 -0
  17. package/dist/generic/baseEngineSweeper.js +97 -0
  18. package/dist/generic/baseEngineSweeper.js.map +1 -0
  19. package/dist/generic/baseEntity.d.ts +45 -1
  20. package/dist/generic/baseEntity.d.ts.map +1 -1
  21. package/dist/generic/baseEntity.js +308 -187
  22. package/dist/generic/baseEntity.js.map +1 -1
  23. package/dist/generic/baseInfo.d.ts +31 -3
  24. package/dist/generic/baseInfo.d.ts.map +1 -1
  25. package/dist/generic/baseInfo.js +138 -40
  26. package/dist/generic/baseInfo.js.map +1 -1
  27. package/dist/generic/databaseProviderBase.d.ts +57 -19
  28. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  29. package/dist/generic/databaseProviderBase.js +203 -80
  30. package/dist/generic/databaseProviderBase.js.map +1 -1
  31. package/dist/generic/entityConfiguration.d.ts +8 -2
  32. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  33. package/dist/generic/entityConfiguration.js +9 -0
  34. package/dist/generic/entityConfiguration.js.map +1 -1
  35. package/dist/generic/entityEventBatch.d.ts +124 -0
  36. package/dist/generic/entityEventBatch.d.ts.map +1 -0
  37. package/dist/generic/entityEventBatch.js +194 -0
  38. package/dist/generic/entityEventBatch.js.map +1 -0
  39. package/dist/generic/entityInfo.d.ts +103 -8
  40. package/dist/generic/entityInfo.d.ts.map +1 -1
  41. package/dist/generic/entityInfo.js +376 -60
  42. package/dist/generic/entityInfo.js.map +1 -1
  43. package/dist/generic/infoProviderResolution.d.ts +16 -0
  44. package/dist/generic/infoProviderResolution.d.ts.map +1 -0
  45. package/dist/generic/infoProviderResolution.js +17 -0
  46. package/dist/generic/infoProviderResolution.js.map +1 -0
  47. package/dist/generic/interfaces.d.ts +143 -3
  48. package/dist/generic/interfaces.d.ts.map +1 -1
  49. package/dist/generic/interfaces.js.map +1 -1
  50. package/dist/generic/localCacheManager.d.ts +505 -56
  51. package/dist/generic/localCacheManager.d.ts.map +1 -1
  52. package/dist/generic/localCacheManager.js +1185 -175
  53. package/dist/generic/localCacheManager.js.map +1 -1
  54. package/dist/generic/logging.d.ts +15 -0
  55. package/dist/generic/logging.d.ts.map +1 -1
  56. package/dist/generic/logging.js +17 -0
  57. package/dist/generic/logging.js.map +1 -1
  58. package/dist/generic/metadata.d.ts +1 -1
  59. package/dist/generic/metadata.d.ts.map +1 -1
  60. package/dist/generic/metadata.js.map +1 -1
  61. package/dist/generic/mjapiUrl.d.ts +21 -0
  62. package/dist/generic/mjapiUrl.d.ts.map +1 -0
  63. package/dist/generic/mjapiUrl.js +25 -0
  64. package/dist/generic/mjapiUrl.js.map +1 -0
  65. package/dist/generic/providerBase.d.ts +301 -5
  66. package/dist/generic/providerBase.d.ts.map +1 -1
  67. package/dist/generic/providerBase.js +750 -168
  68. package/dist/generic/providerBase.js.map +1 -1
  69. package/dist/generic/queryInfo.d.ts +10 -9
  70. package/dist/generic/queryInfo.d.ts.map +1 -1
  71. package/dist/generic/queryInfo.js +60 -33
  72. package/dist/generic/queryInfo.js.map +1 -1
  73. package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
  74. package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
  75. package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
  76. package/dist/generic/securityInfo.d.ts +11 -6
  77. package/dist/generic/securityInfo.d.ts.map +1 -1
  78. package/dist/generic/securityInfo.js +15 -10
  79. package/dist/generic/securityInfo.js.map +1 -1
  80. package/dist/generic/telemetryManager.d.ts.map +1 -1
  81. package/dist/generic/telemetryManager.js +4 -1
  82. package/dist/generic/telemetryManager.js.map +1 -1
  83. package/dist/generic/transactionGroup.d.ts +27 -0
  84. package/dist/generic/transactionGroup.d.ts.map +1 -1
  85. package/dist/generic/transactionGroup.js +58 -5
  86. package/dist/generic/transactionGroup.js.map +1 -1
  87. package/dist/generic/util.d.ts.map +1 -1
  88. package/dist/generic/util.js +3 -0
  89. package/dist/generic/util.js.map +1 -1
  90. package/dist/generic/wellKnownUserSource.d.ts +3 -2
  91. package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
  92. package/dist/generic/wellKnownUserSource.js +18 -9
  93. package/dist/generic/wellKnownUserSource.js.map +1 -1
  94. package/dist/index.d.ts +2 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +2 -0
  97. package/dist/index.js.map +1 -1
  98. package/package.json +3 -3
  99. package/readme.md +29 -4
@@ -6,7 +6,8 @@ import { DispatchRemoteOperationInProcess } from "./remoteOperationDispatch.js";
6
6
  import { TransactionItem } from "./transactionGroup.js";
7
7
  import { CompositeKey } from "./compositeKey.js";
8
8
  import { LogError } from "./logging.js";
9
- import { EscapeSQLString, FormatBinaryChangeValue, SQLExpressionValidator, StripSQLStringLiterals, uuidv4 } from "@memberjunction/global";
9
+ import { LocalCacheManager } from "./localCacheManager.js";
10
+ import { FormatBinaryChangeValue, ScanSQLForScreening, SQLExpressionValidator, uuidv4 } from "@memberjunction/global";
10
11
  import { GetDialect } from "@memberjunction/sql-dialect";
11
12
  // Re-export PlatformSQL types from their canonical location for backward compatibility
12
13
  export { IsPlatformSQL } from "./platformSQL.js";
@@ -82,10 +83,13 @@ export class DatabaseProviderBase extends ProviderBase {
82
83
  return 0;
83
84
  }
84
85
  /**
85
- * Public nesting depth. 0 = no ambient TX. Join-TX callers (accounting
86
- * CreateJournalEntries) must read this, not `IsInTransaction` (SQL Server
87
- * leaves that false). Deprecated camelCase `transactionDepth` alias ships
88
- * for one release.
86
+ * Public nesting depth of the transaction open on this instance; 0 = none. Read this, not
87
+ * `IsInTransaction` (SQL Server leaves that false), when you need to know whether a transaction
88
+ * is open at all. The depth is instance-wide: on a shared provider `TransactionDepth > 0` does
89
+ * NOT mean the caller's statements run in that transaction — only the async scope that began it
90
+ * is part of it (#4786). Code that needs to be inside a transaction should simply call
91
+ * {@link BeginTransaction} / {@link BeginEntityTransaction}: joining one the caller is already in
92
+ * is automatic. Deprecated camelCase `transactionDepth` alias ships for one release.
89
93
  */
90
94
  get TransactionDepth() {
91
95
  return this.CurrentTransactionDepth;
@@ -114,6 +118,11 @@ export class DatabaseProviderBase extends ProviderBase {
114
118
  await this.ResetTransactionState();
115
119
  }
116
120
  }
121
+ // Belt and braces: whatever route the rollback took, this instance is going away, so it
122
+ // must not leave an entity-event batch behind. A batch whose owner is collected is
123
+ // unreachable while its entities keep counting as pending, which makes every cached read
124
+ // of them miss for the life of the process.
125
+ await LocalCacheManager.Instance.AbandonEntityEventBatch(this);
117
126
  }
118
127
  /** @deprecated Use {@link TransactionDepth}. */
119
128
  get transactionDepth() {
@@ -128,15 +137,15 @@ export class DatabaseProviderBase extends ProviderBase {
128
137
  /* no-op */
129
138
  }
130
139
  /**
131
- * Run `task` once the ambient transaction on this provider has committed, or now if there is
132
- * no ambient transaction.
140
+ * Run `task` once the caller's transaction on this provider (the one its async scope belongs
141
+ * to) has committed, or now if the caller is in no transaction on this provider.
133
142
  *
134
143
  * Use it for side effects that must only happen for work that is actually durable — queueing
135
144
  * background jobs, firing deferred entity actions — and must not run *inside* the caller's
136
145
  * transaction. Providers that track transactions (see `GenericDatabaseProvider`) queue the task
137
- * while a transaction is open, run queued tasks in registration order after the **outermost**
138
- * commit succeeds (once the transaction lock is released, so a task may open its own
139
- * transaction), and discard them — without running them — when the transaction rolls back,
146
+ * while the caller's transaction is open, run queued tasks in registration order after the
147
+ * **outermost** commit succeeds (once the transaction lock is released, so a task may open its
148
+ * own transaction), and discard them — without running them — when the transaction rolls back,
140
149
  * fails to commit, or is abandoned.
141
150
  *
142
151
  * This default is for providers that do not track transactions: the task starts immediately.
@@ -163,8 +172,9 @@ export class DatabaseProviderBase extends ProviderBase {
163
172
  void this.RunPostCommitTaskSafely(task, description);
164
173
  }
165
174
  /**
166
- * Snapshot of the transaction frames open right now, for a later {@link RunAfterCommit}.
167
- * Synchronous by contract — call it before the first `await` of the code that causes the work.
175
+ * Snapshot of the caller's transaction frames on this provider (the transaction its async scope
176
+ * belongs to), for a later {@link RunAfterCommit}. Synchronous by contract — call it before the
177
+ * first `await` of the code that causes the work.
168
178
  *
169
179
  * @returns `undefined` when no transaction is open. This default never tracks transactions, so
170
180
  * it always returns `undefined`.
@@ -207,6 +217,11 @@ export class DatabaseProviderBase extends ProviderBase {
207
217
  * physical transaction on the same pool, blind to any transaction the caller had already
208
218
  * started.
209
219
  *
220
+ * The transaction belongs to the async scope that calls this: that code and everything it starts
221
+ * run on the transaction; unrelated callers on the same provider instance run on the pool
222
+ * (GenericDatabaseProvider, #4786). Call it from the function that performs the writes — a begin
223
+ * reached only after an `await` inside a helper does not include the helper's caller.
224
+ *
210
225
  * The returned scope is **settle-once**: the first `Commit()` or `Rollback()` wins and later
211
226
  * calls are no-ops, so `try { ...; Commit() } catch { Rollback() }` is safe even when the work
212
227
  * already unwound its own scope.
@@ -222,6 +237,10 @@ export class DatabaseProviderBase extends ProviderBase {
222
237
  const isNested = this.IsInTransaction || this.CurrentTransactionDepth > 0;
223
238
  await this.BeginTransaction();
224
239
  const depthAtBegin = this.CurrentTransactionDepth;
240
+ // Cache maintenance for the saves in this scope waits for the outermost settle: one rewrite
241
+ // per cached slot on commit instead of one per save, and nothing written for work that is
242
+ // rolled back. Nested scopes join the same batch.
243
+ LocalCacheManager.Instance.BeginEntityEventBatch(this);
225
244
  let settled = false;
226
245
  const settle = async (commit) => {
227
246
  if (settled) {
@@ -244,11 +263,18 @@ export class DatabaseProviderBase extends ProviderBase {
244
263
  `work. Do not run concurrent transactional saves on a shared provider instance — use ` +
245
264
  `per-request providers (as MJServer does) or serialize the units of work.`);
246
265
  }
247
- if (commit) {
248
- await this.CommitTransaction();
266
+ let settledAsCommit = false;
267
+ try {
268
+ if (commit) {
269
+ await this.CommitTransaction();
270
+ settledAsCommit = true;
271
+ }
272
+ else {
273
+ await this.RollbackTransaction();
274
+ }
249
275
  }
250
- else {
251
- await this.RollbackTransaction();
276
+ finally {
277
+ await LocalCacheManager.Instance.EndEntityEventBatch(this, settledAsCommit);
252
278
  }
253
279
  };
254
280
  return {
@@ -907,8 +933,23 @@ export class DatabaseProviderBase extends ProviderBase {
907
933
  // Always offer the record-change-free form to the SQL logger, not only when the
908
934
  // entity tracks record changes: it is also the replay-safe form of a create for
909
935
  // migration recordings (MemberJunction/MJ#4503). Execution still uses fullSQL.
910
- if (sqlDetails.simpleSQL) {
911
- opts.simpleSQLFallback = sqlDetails.simpleSQL;
936
+ if ('simpleSQL' in sqlDetails) {
937
+ Object.defineProperty(opts, 'simpleSQLFallback', {
938
+ enumerable: true,
939
+ configurable: true,
940
+ get() {
941
+ return sqlDetails.simpleSQL || undefined;
942
+ },
943
+ });
944
+ }
945
+ if ('literalSQL' in sqlDetails) {
946
+ Object.defineProperty(opts, 'literalSQL', {
947
+ enumerable: true,
948
+ configurable: true,
949
+ get() {
950
+ return sqlDetails.literalSQL || undefined;
951
+ },
952
+ });
912
953
  }
913
954
  return opts;
914
955
  }
@@ -920,8 +961,23 @@ export class DatabaseProviderBase extends ProviderBase {
920
961
  isMutation: true,
921
962
  description: `Delete ${entity.EntityInfo.Name}`,
922
963
  };
923
- if (entity.EntityInfo.TrackRecordChanges && sqlDetails.simpleSQL) {
924
- opts.simpleSQLFallback = sqlDetails.simpleSQL;
964
+ if (entity.EntityInfo.TrackRecordChanges && 'simpleSQL' in sqlDetails) {
965
+ Object.defineProperty(opts, 'simpleSQLFallback', {
966
+ enumerable: true,
967
+ configurable: true,
968
+ get() {
969
+ return sqlDetails.simpleSQL || undefined;
970
+ },
971
+ });
972
+ }
973
+ if ('literalSQL' in sqlDetails) {
974
+ Object.defineProperty(opts, 'literalSQL', {
975
+ enumerable: true,
976
+ configurable: true,
977
+ get() {
978
+ return sqlDetails.literalSQL || undefined;
979
+ },
980
+ });
925
981
  }
926
982
  return opts;
927
983
  }
@@ -950,26 +1006,27 @@ export class DatabaseProviderBase extends ProviderBase {
950
1006
  /**************************************************************************/
951
1007
  /**
952
1008
  * Validates a user-provided SQL clause (WHERE, ORDER BY, etc.) to prevent SQL injection.
953
- * Checks for forbidden keywords (INSERT, UPDATE, DELETE, EXEC, DROP, UNION, etc.)
954
- * and dangerous patterns (comments, semicolons, xp_ prefix).
955
- * String literals are stripped before validation to avoid false positives.
1009
+ * The clause is read with this provider's SQL dialect. It is rejected when it contains a
1010
+ * comment, text that cannot be read the way the database reads it, a forbidden keyword
1011
+ * (INSERT, UPDATE, DELETE, EXEC, DROP, UNION, etc.), a semicolon, or an `xp_` name.
1012
+ * String literals and quoted identifiers are not checked for keywords.
956
1013
  *
957
1014
  * @param clause The SQL clause to validate
958
1015
  * @returns true if the clause is safe, false if it contains forbidden patterns
959
1016
  */
960
1017
  ValidateUserProvidedSQLClause(clause) {
961
- // Remove string literals to avoid false positives.
962
- //
963
- // 🚨 SECURITY: this uses the SHARED stripper in @memberjunction/global. It must match how
964
- // SQL Server / PostgreSQL actually parse literals — see StripSQLStringLiterals for the full
965
- // rationale and the backslash-escape bypass it exists to prevent. Do NOT inline a regex here;
966
- // an inline copy is exactly how this screen and SQLExpressionValidator drifted apart before.
967
- const clauseWithoutStrings = StripSQLStringLiterals(clause);
968
- const lowerClause = clauseWithoutStrings.toLowerCase();
1018
+ // 🚨 SECURITY: this uses the SHARED scanner in @memberjunction/global, which reads quotes,
1019
+ // [bracket] and "double-quoted" identifiers, comments, and PostgreSQL E'…' and $$…$$ strings
1020
+ // the way this provider's database does, and refuses text it cannot classify. Do NOT inline
1021
+ // a regex here: this screen and SQLExpressionValidator must read SQL the same way.
1022
+ const scan = ScanSQLForScreening(clause, this.PlatformKey);
1023
+ if (!scan.Classified || scan.CommentCount > 0)
1024
+ return false;
1025
+ const lowerClause = scan.Code.toLowerCase();
969
1026
  const forbiddenPatterns = [
970
1027
  /\binsert\b/, /\bupdate\b/, /\bdelete\b/,
971
1028
  /\bexec\b/, /\bexecute\b/, /\bdrop\b/,
972
- /--/, /\/\*/, /\*\//, /\bunion\b/, /\bxp_/, /;/,
1029
+ /--/, /\/\*/, /\*\//, /\bunion\b/, /;/,
973
1030
  // Time-based blind injection vector — no legitimate filter/order-by clause uses WAITFOR.
974
1031
  /\bwaitfor\b/,
975
1032
  ];
@@ -977,7 +1034,8 @@ export class DatabaseProviderBase extends ProviderBase {
977
1034
  if (pattern.test(lowerClause))
978
1035
  return false;
979
1036
  }
980
- return true;
1037
+ // Extended stored procedures are names, so a quoted name such as [xp_cmdshell] is checked too.
1038
+ return !/\bxp_/.test(scan.CodeWithIdentifiers.toLowerCase());
981
1039
  }
982
1040
  /**
983
1041
  * Checks that the given user has read permissions on the specified entity.
@@ -1026,6 +1084,7 @@ export class DatabaseProviderBase extends ProviderBase {
1026
1084
  const result = validator.validate(agg.expression, {
1027
1085
  context: 'aggregate',
1028
1086
  entityFields: fieldNames,
1087
+ Dialect: this.PlatformKey,
1029
1088
  });
1030
1089
  if (!result.valid) {
1031
1090
  validationErrors.push({
@@ -1112,14 +1171,20 @@ export class DatabaseProviderBase extends ProviderBase {
1112
1171
  }
1113
1172
  }
1114
1173
  /**
1115
- * Builds the SQL to retrieve the "name" field value for a specific entity record.
1116
- * Uses QuoteIdentifier/QuoteSchemaAndView for dialect-neutral SQL generation.
1174
+ * Builds the query that reads a record's name fields by its primary key.
1175
+ *
1176
+ * Key columns are written from the entity's metadata and key values are bound as parameters,
1177
+ * so nothing the caller supplies reaches the SQL text. When a user is given, their effective
1178
+ * read row filter (role RLS and API-key row filters) is ANDed in. A caller that gives no user
1179
+ * is trusted server code, as for a single-record Load.
1117
1180
  *
1118
1181
  * @param entityName The entity name
1119
1182
  * @param compositeKey The record's primary key
1120
- * @returns The SQL query string, or null if the entity has no name field
1183
+ * @param contextUser The acting user, whose read row filter applies
1184
+ * @returns The SQL and its parameter values, or null if the entity has no name field or the
1185
+ * key does not name the entity's primary key fields
1121
1186
  */
1122
- BuildEntityRecordNameSQL(entityName, compositeKey) {
1187
+ BuildEntityRecordNameSQL(entityName, compositeKey, contextUser) {
1123
1188
  const e = this.EntityByName(entityName);
1124
1189
  if (!e)
1125
1190
  throw new Error('Entity ' + entityName + ' not found');
@@ -1128,30 +1193,44 @@ export class DatabaseProviderBase extends ProviderBase {
1128
1193
  LogError('Entity ' + entityName + ' does not have a NameField, returning null');
1129
1194
  return null;
1130
1195
  }
1131
- let where = '';
1132
- for (const pkv of compositeKey.KeyValuePairs) {
1133
- const pk = e.PrimaryKeys.find((pk) => pk.Name === pkv.FieldName);
1134
- if (where.length > 0)
1135
- where += ' AND ';
1136
- if (pk && pk.NeedsQuotes) {
1137
- // Key values arrive from remote callers — escape so a quote in the value cannot
1138
- // break out of the literal (same discipline as CompositeKey.ToWhereClause).
1139
- where += this.QuoteIdentifier(pkv.FieldName) + "='" + EscapeSQLString(String(pkv.Value)) + "'";
1140
- }
1141
- else {
1142
- // Unquoted (numeric) key column: the value is spliced in bare, so refuse anything
1143
- // that is not a plain number rather than letting it reach the SQL text.
1144
- const raw = String(pkv.Value);
1145
- if (!/^-?\d+(\.\d+)?$/.test(raw)) {
1146
- LogError(`BuildEntityRecordNameSQL: non-numeric value provided for numeric key field ${pkv.FieldName} on entity ${entityName}`);
1147
- return null;
1148
- }
1149
- where += this.QuoteIdentifier(pkv.FieldName) + '=' + raw;
1150
- }
1196
+ const keyFields = this.RecordNameKeyFieldsOf(e, compositeKey);
1197
+ if (!keyFields) {
1198
+ return null;
1199
+ }
1200
+ const conditions = keyFields.map((f, i) => this.QuoteIdentifier(f.Name) + '=' + this.BuildParameterPlaceholder(i));
1201
+ const rowFilter = contextUser ? e.GetEffectiveRowFilterWhereClause(contextUser, EntityPermissionType.Read, '') : '';
1202
+ if (rowFilter) {
1203
+ conditions.push('(' + rowFilter + ')');
1151
1204
  }
1152
1205
  // SELECT all name fields so InternalGetEntityRecordName can concatenate them
1153
1206
  const selectFields = nameFields.map(f => this.QuoteIdentifier(f.Name)).join(', ');
1154
- return 'SELECT ' + selectFields + ' FROM ' + this.QuoteSchemaAndView(e.SchemaName, e.BaseView) + ' WHERE ' + where;
1207
+ return {
1208
+ SQL: 'SELECT ' + selectFields + ' FROM ' + this.QuoteSchemaAndView(e.SchemaName, e.BaseView) + ' WHERE ' + conditions.join(' AND '),
1209
+ Parameters: compositeKey.KeyValuePairs.map(kv => kv.Value),
1210
+ };
1211
+ }
1212
+ /**
1213
+ * The primary key field each pair of a record-name key names, in key order. Names match
1214
+ * case-insensitively. Null, after logging, when the key is empty or names a field that is not
1215
+ * one of the entity's primary keys.
1216
+ */
1217
+ RecordNameKeyFieldsOf(entity, compositeKey) {
1218
+ const pairs = compositeKey?.KeyValuePairs ?? [];
1219
+ if (pairs.length === 0) {
1220
+ LogError(`Record name lookup on ${entity.Name}: the key names no fields`);
1221
+ return null;
1222
+ }
1223
+ const keyFields = [];
1224
+ for (const pair of pairs) {
1225
+ const name = String(pair.FieldName ?? '').trim().toLowerCase();
1226
+ const field = entity.PrimaryKeys.find(pk => pk.Name.trim().toLowerCase() === name);
1227
+ if (!field) {
1228
+ LogError(`Record name lookup on ${entity.Name}: ${JSON.stringify(pair.FieldName)} is not a primary key field`);
1229
+ return null;
1230
+ }
1231
+ keyFields.push(field);
1232
+ }
1233
+ return keyFields;
1155
1234
  }
1156
1235
  /**
1157
1236
  * The fields a record's display name is built from, in order: every `IsNameField` field by
@@ -1184,7 +1263,8 @@ export class DatabaseProviderBase extends ProviderBase {
1184
1263
  }
1185
1264
  /**
1186
1265
  * Retrieves the display name for a single entity record.
1187
- * Uses BuildEntityRecordNameSQL for dialect-neutral SQL generation.
1266
+ * Uses BuildEntityRecordNameSQL for dialect-neutral SQL generation, which limits the lookup to
1267
+ * rows the acting user's read row filter allows.
1188
1268
  *
1189
1269
  * Answers with an empty string, without querying, when field-level security withholds any of
1190
1270
  * the name fields from the acting user — the same answer as a record that does not exist, so
@@ -1196,9 +1276,9 @@ export class DatabaseProviderBase extends ProviderBase {
1196
1276
  if (entity && !this.CanUserReadRecordName(entity, contextUser)) {
1197
1277
  return '';
1198
1278
  }
1199
- const sql = this.BuildEntityRecordNameSQL(entityName, compositeKey);
1200
- if (sql) {
1201
- const data = await this.ExecuteSQL(sql, undefined, undefined, contextUser);
1279
+ const query = this.BuildEntityRecordNameSQL(entityName, compositeKey, contextUser);
1280
+ if (query) {
1281
+ const data = await this.ExecuteSQL(query.SQL, query.Parameters, undefined, contextUser);
1202
1282
  if (data && data.length === 1) {
1203
1283
  // Concatenate all returned fields with spaces (supports multi-name entities)
1204
1284
  const values = Object.values(data[0])
@@ -1381,22 +1461,45 @@ export class DatabaseProviderBase extends ProviderBase {
1381
1461
  }
1382
1462
  // Step 4: Generate provider-specific SQL
1383
1463
  const sqlDetails = await this.GenerateSaveSQL(entity, bNewRecord, user, options);
1464
+ const effectiveReturnMode = options?.SaveReturnMode ?? entity.EntityInfo.SaveReturnMode;
1465
+ // Snapshot the entity's in-memory field values at the exact point SQL parameters were bound
1466
+ // (before awaiting ExecuteSQL). If the caller mutates the entity in memory while an async
1467
+ // save is in flight (e.g. BaseEntitySaveQueue pipelining an INSERT + UPDATE), merging
1468
+ // post-await entity.GetAll() would falsely mark those in-flight mutations as clean/saved.
1469
+ const preSaveValues = effectiveReturnMode === 'datestamps_only'
1470
+ ? entity.GetAll()
1471
+ : undefined;
1472
+ if (preSaveValues && bNewRecord) {
1473
+ // On a new record saved with 'datestamps_only', the SP does not query BaseView,
1474
+ // so virtual non-PK fields (e.g. joined name columns) that were never set stay
1475
+ // null. Omit them from preSaveValues so finalizeSave marks them NotLoaded=true.
1476
+ for (const f of entity.EntityInfo.Fields) {
1477
+ if (f.IsVirtual && !f.IsPrimaryKey && (preSaveValues[f.Name] === null || preSaveValues[f.Name] === undefined)) {
1478
+ delete preSaveValues[f.Name];
1479
+ }
1480
+ }
1481
+ }
1384
1482
  if (entity.TransactionGroup && !bReplay) {
1385
1483
  // ---- Transaction Group path ----
1386
1484
  entity.RaiseReadyForTransaction();
1387
1485
  suspendRefresh();
1388
1486
  const extraData = this.GetTransactionExtraData(entity);
1389
- if (sqlDetails.simpleSQL) {
1390
- extraData.simpleSQLFallback = sqlDetails.simpleSQL;
1391
- }
1487
+ Object.defineProperty(extraData, 'simpleSQLFallback', {
1488
+ get: () => sqlDetails.simpleSQL,
1489
+ enumerable: true,
1490
+ configurable: true,
1491
+ });
1392
1492
  extraData.entityName = entity.EntityInfo.Name;
1393
- entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.fullSQL, sqlDetails.parameters ?? null, extraData, (transactionResult, success) => {
1493
+ entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, (transactionResult, success) => {
1394
1494
  resumeRefresh();
1395
1495
  entityResult.EndedAt = new Date();
1396
1496
  if (success && transactionResult) {
1397
1497
  this.OnAfterSaveExecute(entity, user, options, saveContext);
1398
1498
  entityResult.Success = true;
1399
- entityResult.NewValues = this.MapTransactionResultToNewValues(transactionResult);
1499
+ const mergedResult = preSaveValues
1500
+ ? { ...preSaveValues, ...transactionResult }
1501
+ : transactionResult;
1502
+ entityResult.NewValues = this.MapTransactionResultToNewValues(mergedResult);
1400
1503
  }
1401
1504
  else {
1402
1505
  entityResult.Success = false;
@@ -1422,20 +1525,36 @@ export class DatabaseProviderBase extends ProviderBase {
1422
1525
  if (result && result.length > 0) {
1423
1526
  this.OnAfterSaveExecute(entity, user, options, saveContext);
1424
1527
  entityResult.Success = true;
1528
+ // In 'datestamps_only' mode, the SP/query returned only the PK and
1529
+ // __mj_CreatedAt/__mj_UpdatedAt columns. Merge those returned values
1530
+ // onto the pre-save snapshot so caller-set fields remain intact without
1531
+ // falsely marking concurrent in-flight mutations as clean.
1532
+ let mergedRow;
1533
+ if (preSaveValues) {
1534
+ mergedRow = { ...preSaveValues, ...result[0] };
1535
+ Object.defineProperty(mergedRow, '__mj_preMerged', {
1536
+ value: true,
1537
+ enumerable: false,
1538
+ configurable: true,
1539
+ });
1540
+ }
1541
+ else {
1542
+ mergedRow = result[0];
1543
+ }
1425
1544
  // OnSaveCompleted may return patch fields (e.g., updated lat/lng
1426
1545
  // from geocoding) that need to be merged onto the SP result before
1427
1546
  // the entity loads the data via finalizeSave().
1428
1547
  const patches = await this.OnSaveCompleted(entity, sqlDetails, user, options, saveContext);
1429
1548
  if (patches) {
1430
- Object.assign(result[0], patches);
1549
+ Object.assign(mergedRow, patches);
1431
1550
  }
1432
- return result[0];
1551
+ return mergedRow;
1433
1552
  }
1434
1553
  else {
1435
1554
  if (bNewRecord)
1436
- throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.fullSQL}`);
1555
+ throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
1437
1556
  else
1438
- throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.fullSQL}`);
1557
+ throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
1439
1558
  }
1440
1559
  }
1441
1560
  }
@@ -1505,20 +1624,24 @@ export class DatabaseProviderBase extends ProviderBase {
1505
1624
  // ---- Transaction Group path ----
1506
1625
  entity.RaiseReadyForTransaction();
1507
1626
  const extraData = this.GetTransactionExtraData(entity);
1508
- if (entity.EntityInfo.TrackRecordChanges && sqlDetails.simpleSQL) {
1509
- extraData.simpleSQLFallback = sqlDetails.simpleSQL;
1627
+ if (entity.EntityInfo.TrackRecordChanges) {
1628
+ Object.defineProperty(extraData, 'simpleSQLFallback', {
1629
+ get: () => sqlDetails.simpleSQL,
1630
+ enumerable: true,
1631
+ configurable: true,
1632
+ });
1510
1633
  }
1511
1634
  extraData.entityName = entity.EntityInfo.Name;
1512
- entity.TransactionGroup.AddTransaction(new TransactionItem(entity, 'Delete', sqlDetails.fullSQL, sqlDetails.parameters ?? null, extraData, (transactionResult, success) => {
1635
+ entity.TransactionGroup.AddTransaction(new TransactionItem(entity, 'Delete', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, (transactionResult, success) => {
1513
1636
  entityResult.EndedAt = new Date();
1514
1637
  if (success && transactionResult) {
1515
- this.OnAfterDeleteExecute(entity, user, options);
1516
- for (const key of entity.PrimaryKeys) {
1517
- if (key.Value !== transactionResult[key.Name]) {
1518
- entityResult.Success = false;
1519
- entityResult.Message = 'Transaction failed to commit';
1520
- }
1638
+ // Same key check as the direct path, through the provider's own override
1639
+ // (PostgreSQL accepts its legacy `_result_id` shape there).
1640
+ if (!this.ValidateDeleteResult(entity, [transactionResult], entityResult)) {
1641
+ entityResult.Success = false;
1642
+ return;
1521
1643
  }
1644
+ this.OnAfterDeleteExecute(entity, user, options);
1522
1645
  entityResult.NewValues = this.MapTransactionResultToNewValues(transactionResult);
1523
1646
  entityResult.Success = true;
1524
1647
  }