@memberjunction/core 6.2.0-edge.6 → 6.2.0-edge.8

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 (96) hide show
  1. package/dist/generic/EntityFieldRules.d.ts.map +1 -1
  2. package/dist/generic/EntityFieldRules.js +1 -0
  3. package/dist/generic/EntityFieldRules.js.map +1 -1
  4. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +32 -0
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  6. package/dist/generic/baseEngine.d.ts +165 -12
  7. package/dist/generic/baseEngine.d.ts.map +1 -1
  8. package/dist/generic/baseEngine.js +622 -98
  9. package/dist/generic/baseEngine.js.map +1 -1
  10. package/dist/generic/baseEngineRegistry.d.ts +10 -2
  11. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  12. package/dist/generic/baseEngineRegistry.js +33 -5
  13. package/dist/generic/baseEngineRegistry.js.map +1 -1
  14. package/dist/generic/baseEntity.d.ts +121 -6
  15. package/dist/generic/baseEntity.d.ts.map +1 -1
  16. package/dist/generic/baseEntity.js +1144 -64
  17. package/dist/generic/baseEntity.js.map +1 -1
  18. package/dist/generic/baseRemotableOperation.d.ts +95 -6
  19. package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
  20. package/dist/generic/baseRemotableOperation.js +112 -8
  21. package/dist/generic/baseRemotableOperation.js.map +1 -1
  22. package/dist/generic/databaseProviderBase.d.ts +37 -0
  23. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  24. package/dist/generic/databaseProviderBase.js +262 -46
  25. package/dist/generic/databaseProviderBase.js.map +1 -1
  26. package/dist/generic/entityInfo.d.ts +25 -1
  27. package/dist/generic/entityInfo.d.ts.map +1 -1
  28. package/dist/generic/entityInfo.js +70 -12
  29. package/dist/generic/entityInfo.js.map +1 -1
  30. package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
  31. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
  32. package/dist/generic/externalDataSourceReadRouter.js +12 -0
  33. package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
  34. package/dist/generic/fieldSecuritySQLGate.d.ts +11 -2
  35. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
  36. package/dist/generic/fieldSecuritySQLGate.js +6 -2
  37. package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
  38. package/dist/generic/interfaces.d.ts +50 -1
  39. package/dist/generic/interfaces.d.ts.map +1 -1
  40. package/dist/generic/interfaces.js.map +1 -1
  41. package/dist/generic/listMembershipFilter.d.ts +5 -0
  42. package/dist/generic/listMembershipFilter.d.ts.map +1 -0
  43. package/dist/generic/listMembershipFilter.js +23 -0
  44. package/dist/generic/listMembershipFilter.js.map +1 -0
  45. package/dist/generic/localCacheManager.d.ts +69 -5
  46. package/dist/generic/localCacheManager.d.ts.map +1 -1
  47. package/dist/generic/localCacheManager.js +253 -18
  48. package/dist/generic/localCacheManager.js.map +1 -1
  49. package/dist/generic/logging.d.ts +105 -38
  50. package/dist/generic/logging.d.ts.map +1 -1
  51. package/dist/generic/logging.js +196 -5
  52. package/dist/generic/logging.js.map +1 -1
  53. package/dist/generic/providerBase.d.ts +198 -3
  54. package/dist/generic/providerBase.d.ts.map +1 -1
  55. package/dist/generic/providerBase.js +721 -223
  56. package/dist/generic/providerBase.js.map +1 -1
  57. package/dist/generic/relatedRecordCollection.d.ts +5 -0
  58. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  59. package/dist/generic/relatedRecordCollection.js +11 -2
  60. package/dist/generic/relatedRecordCollection.js.map +1 -1
  61. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  62. package/dist/generic/remoteOperationDispatch.js +14 -2
  63. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  64. package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
  65. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  66. package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
  67. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  68. package/dist/generic/sharedLoad.d.ts +42 -0
  69. package/dist/generic/sharedLoad.d.ts.map +1 -0
  70. package/dist/generic/sharedLoad.js +74 -0
  71. package/dist/generic/sharedLoad.js.map +1 -0
  72. package/dist/generic/telemetryManager.d.ts +31 -0
  73. package/dist/generic/telemetryManager.d.ts.map +1 -1
  74. package/dist/generic/telemetryManager.js +85 -9
  75. package/dist/generic/telemetryManager.js.map +1 -1
  76. package/dist/generic/transactionGroup.d.ts +19 -0
  77. package/dist/generic/transactionGroup.d.ts.map +1 -1
  78. package/dist/generic/transactionGroup.js +55 -7
  79. package/dist/generic/transactionGroup.js.map +1 -1
  80. package/dist/generic/transportError.d.ts +41 -0
  81. package/dist/generic/transportError.d.ts.map +1 -0
  82. package/dist/generic/transportError.js +167 -0
  83. package/dist/generic/transportError.js.map +1 -0
  84. package/dist/generic/util.d.ts +13 -1
  85. package/dist/generic/util.d.ts.map +1 -1
  86. package/dist/generic/util.js +53 -4
  87. package/dist/generic/util.js.map +1 -1
  88. package/dist/index.d.ts +3 -0
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +3 -0
  91. package/dist/index.js.map +1 -1
  92. package/dist/views/runView.d.ts +23 -10
  93. package/dist/views/runView.d.ts.map +1 -1
  94. package/dist/views/runView.js +5 -1
  95. package/dist/views/runView.js.map +1 -1
  96. package/package.json +3 -3
@@ -9,7 +9,8 @@ import { CompositeKey } from "./compositeKey.js";
9
9
  import { LogError } from "./logging.js";
10
10
  import { LocalCacheManager } from "./localCacheManager.js";
11
11
  import { AssertClientInputWritableForUser } from "./entityValueFieldSecurity.js";
12
- import { FormatBinaryChangeValue, ScanSQLForScreening, SQLExpressionValidator, uuidv4 } from "@memberjunction/global";
12
+ import { CanonicalPrimaryKeyValue } from "./util.js";
13
+ import { FormatBinaryChangeValue, ScanSQLForScreening, SQLExpressionValidator, uuidv4, EscapeSQLString } from "@memberjunction/global";
13
14
  import { GetDialect } from "@memberjunction/sql-dialect";
14
15
  // Re-export PlatformSQL types from their canonical location for backward compatibility
15
16
  export { IsPlatformSQL } from "./platformSQL.js";
@@ -321,6 +322,28 @@ export class DatabaseProviderBase extends ProviderBase {
321
322
  get MJCoreSchemaName() {
322
323
  return this.ConfigData?.MJCoreSchemaName ?? '__mj';
323
324
  }
325
+ /**
326
+ * Builds a batched UNION ALL query probing all `childEntities`' `BaseView`s for a set
327
+ * of parent primary-key values (`WHERE pk IN (...)`), returning `(EntityName, RecordPK, Priority)`.
328
+ * Dialect providers override this to use platform-native string-literal and CAST syntax.
329
+ */
330
+ BuildChildDiscoveryBatchSQL(childEntities, recordPKValues) {
331
+ if (recordPKValues.length === 0)
332
+ return '';
333
+ const escapedKeys = recordPKValues
334
+ .map(v => `'${EscapeSQLString(String(v))}'`)
335
+ .join(', ');
336
+ const unionParts = childEntities
337
+ .filter(child => child.PrimaryKeys.length > 0)
338
+ .map((child, idx) => {
339
+ const schema = child.SchemaName || this.MJCoreSchemaName || '__mj';
340
+ const sourceRef = this.QuoteSchemaAndView(schema, child.BaseView);
341
+ const pkRef = this.QuoteIdentifier(child.FirstPrimaryKey.Name); // first-pk-ok: IS-A children share the parent's single key
342
+ const escapedName = `'${EscapeSQLString(child.Name)}'`;
343
+ return `SELECT ${escapedName} AS ${this.QuoteIdentifier('EntityName')}, CAST(${pkRef} AS VARCHAR(255)) AS ${this.QuoteIdentifier('RecordPK')}, ${idx + 1} AS ${this.QuoteIdentifier('Priority')} FROM ${sourceRef} WHERE ${pkRef} IN (${escapedKeys})`;
344
+ });
345
+ return unionParts.length > 0 ? unionParts.join(' UNION ALL ') : '';
346
+ }
324
347
  /**
325
348
  * Combined regex covering UUID functions from ALL platforms.
326
349
  * Used by the static convenience method for code that doesn't have a provider instance.
@@ -585,6 +608,91 @@ export class DatabaseProviderBase extends ProviderBase {
585
608
  }
586
609
  return [];
587
610
  }
611
+ static { this.DEFAULT_ISA_DISCOVERY_BATCH_CHUNK_SIZE = 500; }
612
+ /**
613
+ * Maximum number of parent primary keys included in a single `BuildChildDiscoveryBatchSQL`
614
+ * `WHERE PK IN (...)` statement inside {@link FindISAChildEntitiesBatch}.
615
+ */
616
+ get ISADiscoveryBatchChunkSize() {
617
+ return DatabaseProviderBase.DEFAULT_ISA_DISCOVERY_BATCH_CHUNK_SIZE;
618
+ }
619
+ /**
620
+ * Batch-discovers IS-A child entity types for multiple parent primary keys in a single
621
+ * UNION ALL SQL query (chunked at {@link ISADiscoveryBatchChunkSize} keys per statement to
622
+ * stay well within SQL limits).
623
+ */
624
+ async FindISAChildEntitiesBatch(entityInfo, recordPKValues, contextUser) {
625
+ const resultMap = new Map();
626
+ const childEntities = entityInfo.ChildEntities;
627
+ if (childEntities.length === 0 || recordPKValues.length === 0) {
628
+ return resultMap;
629
+ }
630
+ // IS-A children share the parent's single key, so the parent's key type decides how values
631
+ // are canonicalized (UUIDs case-insensitively, anything else by exact text).
632
+ const pkIsUuid = Boolean(entityInfo.FirstPrimaryKey?.IsUniqueIdentifier); // first-pk-ok: IS-A children share the parent's single key
633
+ const uniquePKs = this.deduplicateCanonicalPKs(recordPKValues, pkIsUuid);
634
+ if (uniquePKs.length === 0) {
635
+ return resultMap;
636
+ }
637
+ const priorityByChildName = new Map();
638
+ for (let i = 0; i < childEntities.length; i++) {
639
+ priorityByChildName.set(childEntities[i].Name.toLowerCase(), i + 1);
640
+ }
641
+ const chunkSize = this.ISADiscoveryBatchChunkSize;
642
+ const collected = new Map();
643
+ for (let start = 0; start < uniquePKs.length; start += chunkSize) {
644
+ const chunk = uniquePKs.slice(start, start + chunkSize);
645
+ await this.collectISAChildBatchChunk(childEntities, chunk, pkIsUuid, priorityByChildName, collected, contextUser);
646
+ }
647
+ for (const [pkKey, entries] of collected) {
648
+ entries.sort((a, b) => a.priority - b.priority);
649
+ const names = [];
650
+ for (const entry of entries) {
651
+ if (!names.includes(entry.name)) {
652
+ names.push(entry.name);
653
+ }
654
+ }
655
+ resultMap.set(pkKey, names);
656
+ }
657
+ return resultMap;
658
+ }
659
+ deduplicateCanonicalPKs(recordPKValues, pkIsUuid) {
660
+ const uniquePKs = [];
661
+ const seen = new Set();
662
+ for (const rawPk of recordPKValues) {
663
+ if (!rawPk)
664
+ continue;
665
+ const norm = CanonicalPrimaryKeyValue(String(rawPk), pkIsUuid);
666
+ if (!seen.has(norm)) {
667
+ seen.add(norm);
668
+ uniquePKs.push(String(rawPk).trim());
669
+ }
670
+ }
671
+ return uniquePKs;
672
+ }
673
+ async collectISAChildBatchChunk(childEntities, chunk, pkIsUuid, priorityByChildName, collected, contextUser) {
674
+ const batchSQL = this.BuildChildDiscoveryBatchSQL(childEntities, chunk);
675
+ if (!batchSQL)
676
+ return;
677
+ const rows = await this.ExecuteSQL(batchSQL, undefined, undefined, contextUser);
678
+ if (!rows || rows.length === 0)
679
+ return;
680
+ for (const row of rows) {
681
+ const entityName = row.EntityName ?? row.ChildEntityName;
682
+ if (!entityName || row.RecordPK == null)
683
+ continue;
684
+ const pkKey = CanonicalPrimaryKeyValue(String(row.RecordPK), pkIsUuid);
685
+ const priority = typeof row.Priority === 'number'
686
+ ? row.Priority
687
+ : (priorityByChildName.get(entityName.toLowerCase()) ?? Number.MAX_SAFE_INTEGER);
688
+ let bucket = collected.get(pkKey);
689
+ if (!bucket) {
690
+ bucket = [];
691
+ collected.set(pkKey, bucket);
692
+ }
693
+ bucket.push({ name: entityName, priority });
694
+ }
695
+ }
588
696
  /**
589
697
  * Checks whether a given entity matches the target name, or is an ancestor
590
698
  * of the target (i.e., the target is somewhere in its descendant sub-tree).
@@ -959,6 +1067,27 @@ export class DatabaseProviderBase extends ProviderBase {
959
1067
  async OnBeforeSaveExecute(_entity, _user, _options, _context) {
960
1068
  /* no-op by default */
961
1069
  }
1070
+ /**
1071
+ * Returns true when `Save()` must enlist {@link OnDurableAfterSaveExecute} transactionally
1072
+ * (wrapping direct saves in an {@link EntityTransactionScope}, and awaiting durable action
1073
+ * submission inside `TransactionGroup` save callbacks before group completion).
1074
+ *
1075
+ * Default: `false` (zero extra transaction overhead on ordinary entity saves).
1076
+ */
1077
+ async ShouldEnlistSaveInTransaction(_entity, _user, _options, _isNewRecord) {
1078
+ return false;
1079
+ }
1080
+ /**
1081
+ * Awaited immediately after the save SQL succeeds (inside direct `Save()`'s
1082
+ * {@link EntityTransactionScope} before commit, or inside a `TransactionGroup` item's completion
1083
+ * callback after hydrating DB-generated PKs and before {@link OnAfterSaveExecute}) when
1084
+ * {@link ShouldEnlistSaveInTransaction} returned `true`.
1085
+ * Used by `GenericDatabaseProvider` to enlist durable after-save entity actions atomically in the
1086
+ * same SQL transaction as the entity write.
1087
+ */
1088
+ async OnDurableAfterSaveExecute(_entity, _user, _options, _context) {
1089
+ /* no-op by default */
1090
+ }
962
1091
  /**
963
1092
  * Called after a successful save (both direct and transaction-callback paths).
964
1093
  * Intentionally synchronous (fire-and-forget) — SQL Server overrides to dispatch
@@ -1485,8 +1614,11 @@ export class DatabaseProviderBase extends ProviderBase {
1485
1614
  this.OnResumeRefresh();
1486
1615
  }
1487
1616
  };
1617
+ let transactionPreprocessingRegistered = false;
1618
+ let transactionReadyRaised = false;
1488
1619
  try {
1489
1620
  entity.RegisterTransactionPreprocessing();
1621
+ transactionPreprocessingRegistered = Boolean(entity.TransactionGroup);
1490
1622
  // External-data-source entities are read-only — MJ is never the system of record for
1491
1623
  // remote data. Refuse writes at the provider layer so the guarantee holds regardless of
1492
1624
  // the generated base class (ReadOnlyExternalBaseEntity covers the normal path — returning
@@ -1617,6 +1749,7 @@ export class DatabaseProviderBase extends ProviderBase {
1617
1749
  if (entity.TransactionGroup && !bReplay) {
1618
1750
  // ---- Transaction Group path ----
1619
1751
  entity.RaiseReadyForTransaction();
1752
+ transactionReadyRaised = true;
1620
1753
  suspendRefresh();
1621
1754
  const extraData = this.GetTransactionExtraData(entity);
1622
1755
  Object.defineProperty(extraData, 'simpleSQLFallback', {
@@ -1625,72 +1758,137 @@ export class DatabaseProviderBase extends ProviderBase {
1625
1758
  configurable: true,
1626
1759
  });
1627
1760
  extraData.entityName = entity.EntityInfo.Name;
1628
- entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, (transactionResult, success) => {
1761
+ entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, async (transactionResult, success) => {
1629
1762
  resumeRefresh();
1630
1763
  entityResult.EndedAt = new Date();
1631
1764
  if (success && transactionResult) {
1765
+ if (bNewRecord) {
1766
+ let hydratedPk = false;
1767
+ for (const pkField of entity.EntityInfo.PrimaryKeys) {
1768
+ const fieldObj = entity.Fields.find((f) => f.Name === pkField.Name);
1769
+ if (fieldObj &&
1770
+ (fieldObj.Value === null || fieldObj.Value === undefined) &&
1771
+ transactionResult[pkField.Name] != null) {
1772
+ fieldObj.Value = transactionResult[pkField.Name];
1773
+ hydratedPk = true;
1774
+ }
1775
+ }
1776
+ if (hydratedPk) {
1777
+ entity.PrimaryKey.LoadFromEntityFields(entity.PrimaryKeys);
1778
+ }
1779
+ }
1780
+ if (await this.ShouldEnlistSaveInTransaction(entity, user, options, bNewRecord)) {
1781
+ await this.OnDurableAfterSaveExecute(entity, user, options, saveContext);
1782
+ }
1783
+ if (preSaveValues) {
1784
+ for (const [key, val] of Object.entries(preSaveValues)) {
1785
+ if (!(key in transactionResult)) {
1786
+ transactionResult[key] = val;
1787
+ }
1788
+ }
1789
+ Object.defineProperty(transactionResult, '__mj_preMerged', {
1790
+ value: true,
1791
+ enumerable: false,
1792
+ configurable: true,
1793
+ });
1794
+ }
1795
+ const patches = await this.OnSaveCompleted(entity, sqlDetails, user, options, saveContext);
1796
+ if (patches) {
1797
+ Object.assign(transactionResult, patches);
1798
+ }
1632
1799
  this.OnAfterSaveExecute(entity, user, options, saveContext);
1633
1800
  entityResult.Success = true;
1634
- const mergedResult = preSaveValues
1635
- ? { ...preSaveValues, ...transactionResult }
1636
- : transactionResult;
1637
- entityResult.NewValues = this.MapTransactionResultToNewValues(mergedResult);
1801
+ entityResult.NewValues = this.MapTransactionResultToNewValues(transactionResult);
1638
1802
  }
1639
1803
  else {
1640
1804
  entityResult.Success = false;
1641
- entityResult.Message = 'Transaction Failed';
1805
+ if (!entityResult.Message) {
1806
+ entityResult.Message =
1807
+ transactionResult instanceof Error ? transactionResult.message : 'Transaction Failed';
1808
+ }
1642
1809
  }
1643
1810
  }));
1644
1811
  return true;
1645
1812
  }
1646
1813
  else {
1647
1814
  // ---- Direct execution path ----
1815
+ const enlistInTx = !bReplay && await this.ShouldEnlistSaveInTransaction(entity, user, options, bNewRecord);
1816
+ const saveTxScope = enlistInTx ? await this.BeginEntityTransaction() : null;
1817
+ let saveTxCommitted = false;
1648
1818
  suspendRefresh();
1649
1819
  let result;
1650
- if (bReplay) {
1651
- result = [entity.GetAll()];
1652
- }
1653
- else {
1654
- const execOptions = this.BuildSaveExecuteOptions(entity, sqlDetails);
1655
- const rawResult = await this.ExecuteSQL(sqlDetails.fullSQL, sqlDetails.parameters ?? undefined, execOptions, user);
1656
- result = await this.PostProcessRows(rawResult, entity.EntityInfo, user);
1657
- }
1658
- resumeRefresh();
1659
- entityResult.EndedAt = new Date();
1660
- if (result && result.length > 0) {
1661
- this.OnAfterSaveExecute(entity, user, options, saveContext);
1662
- entityResult.Success = true;
1663
- // In 'datestamps_only' mode, the SP/query returned only the PK and
1664
- // __mj_CreatedAt/__mj_UpdatedAt columns. Merge those returned values
1665
- // onto the pre-save snapshot so caller-set fields remain intact without
1666
- // falsely marking concurrent in-flight mutations as clean.
1667
- let mergedRow;
1668
- if (preSaveValues) {
1669
- mergedRow = { ...preSaveValues, ...result[0] };
1670
- Object.defineProperty(mergedRow, '__mj_preMerged', {
1671
- value: true,
1672
- enumerable: false,
1673
- configurable: true,
1674
- });
1820
+ try {
1821
+ if (bReplay) {
1822
+ result = [entity.GetAll()];
1675
1823
  }
1676
1824
  else {
1677
- mergedRow = result[0];
1825
+ const execOptions = this.BuildSaveExecuteOptions(entity, sqlDetails);
1826
+ const rawResult = await this.ExecuteSQL(sqlDetails.fullSQL, sqlDetails.parameters ?? undefined, execOptions, user);
1827
+ result = await this.PostProcessRows(rawResult, entity.EntityInfo, user);
1678
1828
  }
1679
- // OnSaveCompleted may return patch fields (e.g., updated lat/lng
1680
- // from geocoding) that need to be merged onto the SP result before
1681
- // the entity loads the data via finalizeSave().
1682
- const patches = await this.OnSaveCompleted(entity, sqlDetails, user, options, saveContext);
1683
- if (patches) {
1684
- Object.assign(mergedRow, patches);
1829
+ resumeRefresh();
1830
+ entityResult.EndedAt = new Date();
1831
+ if (result && result.length > 0) {
1832
+ if (bNewRecord) {
1833
+ let hydratedPk = false;
1834
+ for (const pkField of entity.EntityInfo.PrimaryKeys) {
1835
+ const fieldObj = entity.Fields.find(f => f.Name === pkField.Name);
1836
+ if (fieldObj && (fieldObj.Value === null || fieldObj.Value === undefined) && result[0][pkField.Name] != null) {
1837
+ fieldObj.Value = result[0][pkField.Name];
1838
+ hydratedPk = true;
1839
+ }
1840
+ }
1841
+ if (hydratedPk) {
1842
+ entity.PrimaryKey.LoadFromEntityFields(entity.PrimaryKeys);
1843
+ }
1844
+ }
1845
+ if (enlistInTx) {
1846
+ await this.OnDurableAfterSaveExecute(entity, user, options, saveContext);
1847
+ }
1848
+ if (saveTxScope) {
1849
+ await saveTxScope.Commit();
1850
+ saveTxCommitted = true;
1851
+ }
1685
1852
  }
1686
- return mergedRow;
1853
+ else {
1854
+ if (bNewRecord)
1855
+ throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
1856
+ else
1857
+ throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
1858
+ }
1859
+ }
1860
+ catch (innerErr) {
1861
+ if (saveTxScope && !saveTxCommitted) {
1862
+ await saveTxScope.Rollback();
1863
+ }
1864
+ throw innerErr;
1865
+ }
1866
+ this.OnAfterSaveExecute(entity, user, options, saveContext);
1867
+ entityResult.Success = true;
1868
+ // In 'datestamps_only' mode, the SP/query returned only the PK and
1869
+ // __mj_CreatedAt/__mj_UpdatedAt columns. Merge those returned values
1870
+ // onto the pre-save snapshot so caller-set fields remain intact without
1871
+ // falsely marking concurrent in-flight mutations as clean.
1872
+ let mergedRow;
1873
+ if (preSaveValues) {
1874
+ mergedRow = { ...preSaveValues, ...result[0] };
1875
+ Object.defineProperty(mergedRow, '__mj_preMerged', {
1876
+ value: true,
1877
+ enumerable: false,
1878
+ configurable: true,
1879
+ });
1687
1880
  }
1688
1881
  else {
1689
- if (bNewRecord)
1690
- throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
1691
- else
1692
- throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
1882
+ mergedRow = result[0];
1883
+ }
1884
+ // OnSaveCompleted may return patch fields (e.g., updated lat/lng
1885
+ // from geocoding) that need to be merged onto the SP result before
1886
+ // the entity loads the data via finalizeSave().
1887
+ const patches = await this.OnSaveCompleted(entity, sqlDetails, user, options, saveContext);
1888
+ if (patches) {
1889
+ Object.assign(mergedRow, patches);
1693
1890
  }
1891
+ return mergedRow;
1694
1892
  }
1695
1893
  }
1696
1894
  else {
@@ -1704,6 +1902,11 @@ export class DatabaseProviderBase extends ProviderBase {
1704
1902
  LogError(e);
1705
1903
  throw e;
1706
1904
  }
1905
+ finally {
1906
+ if (transactionPreprocessingRegistered && !transactionReadyRaised) {
1907
+ entity.RaiseReadyForTransaction();
1908
+ }
1909
+ }
1707
1910
  }
1708
1911
  /**
1709
1912
  * Deletes an entity record — the full orchestration flow shared by all DB providers.
@@ -1717,8 +1920,11 @@ export class DatabaseProviderBase extends ProviderBase {
1717
1920
  */
1718
1921
  async Delete(entity, options, user) {
1719
1922
  const entityResult = new BaseEntityResult();
1923
+ let transactionPreprocessingRegistered = false;
1924
+ let transactionReadyRaised = false;
1720
1925
  try {
1721
1926
  entity.RegisterTransactionPreprocessing();
1927
+ transactionPreprocessingRegistered = Boolean(entity.TransactionGroup);
1722
1928
  // External-data-source entities are read-only (see Save) — refuse deletes at the provider
1723
1929
  // layer regardless of the generated base class. The normal path (ReadOnlyExternalBaseEntity)
1724
1930
  // returns false before reaching here; this backstop intentionally throws only for the
@@ -1758,6 +1964,7 @@ export class DatabaseProviderBase extends ProviderBase {
1758
1964
  if (entity.TransactionGroup && !bReplay) {
1759
1965
  // ---- Transaction Group path ----
1760
1966
  entity.RaiseReadyForTransaction();
1967
+ transactionReadyRaised = true;
1761
1968
  const extraData = this.GetTransactionExtraData(entity);
1762
1969
  if (entity.EntityInfo.TrackRecordChanges) {
1763
1970
  Object.defineProperty(extraData, 'simpleSQLFallback', {
@@ -1774,7 +1981,8 @@ export class DatabaseProviderBase extends ProviderBase {
1774
1981
  // (PostgreSQL accepts its legacy `_result_id` shape there).
1775
1982
  if (!this.ValidateDeleteResult(entity, [transactionResult], entityResult)) {
1776
1983
  entityResult.Success = false;
1777
- return;
1984
+ throw new Error(entityResult.Message ||
1985
+ `Failed to delete ${entity.EntityInfo.Name}: record not found or primary key mismatch`);
1778
1986
  }
1779
1987
  this.OnAfterDeleteExecute(entity, user, options);
1780
1988
  entityResult.NewValues = this.MapTransactionResultToNewValues(transactionResult);
@@ -1782,7 +1990,10 @@ export class DatabaseProviderBase extends ProviderBase {
1782
1990
  }
1783
1991
  else {
1784
1992
  entityResult.Success = false;
1785
- entityResult.Message = 'Transaction failed to commit';
1993
+ if (!entityResult.Message) {
1994
+ entityResult.Message =
1995
+ transactionResult instanceof Error ? transactionResult.message : 'Transaction failed to commit';
1996
+ }
1786
1997
  }
1787
1998
  }));
1788
1999
  return true;
@@ -1821,6 +2032,11 @@ export class DatabaseProviderBase extends ProviderBase {
1821
2032
  entityResult.EndedAt = new Date();
1822
2033
  return false;
1823
2034
  }
2035
+ finally {
2036
+ if (transactionPreprocessingRegistered && !transactionReadyRaised) {
2037
+ entity.RaiseReadyForTransaction();
2038
+ }
2039
+ }
1824
2040
  }
1825
2041
  /**************************************************************************/
1826
2042
  // END ---- Save/Delete Orchestration