@memberjunction/generic-database-provider 6.1.0-edge.4 → 6.1.0-edge.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/GenericDatabaseProvider.d.ts +178 -1
- package/dist/GenericDatabaseProvider.d.ts.map +1 -1
- package/dist/GenericDatabaseProvider.js +560 -115
- package/dist/GenericDatabaseProvider.js.map +1 -1
- package/dist/SqlLogger.d.ts +8 -0
- package/dist/SqlLogger.d.ts.map +1 -1
- package/dist/SqlLogger.js +31 -10
- package/dist/SqlLogger.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/saveTypes.d.ts +3 -3
- package/dist/saveTypes.d.ts.map +1 -1
- package/package.json +14 -14
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
* @module @memberjunction/generic-database-provider
|
|
17
17
|
*/
|
|
18
18
|
import { DatabaseProviderBase, EntityFieldTSType, ProjectRowsToFields, ProviderBase, EntityPermissionType, InMemoryLocalStorageProvider, Metadata, RunView, IsMaterializedDataSource, LocalCacheManager, LogError, LogStatus, LogStatusEx, StripStopWords, AfterKeyNotSupportedError, IsKeysetPaginationOrderableType, ExternalDataSourceReadRouter, resolveQueryResultEnricher, } from '@memberjunction/core';
|
|
19
|
-
import { MJGlobal, SQLExpressionValidator, UUIDsEqual } from '@memberjunction/global';
|
|
19
|
+
import { MJGlobal, NormalizeUUID, SQLExpressionValidator, UUIDsEqual } from '@memberjunction/global';
|
|
20
20
|
import { QueryPagingEngine } from './queryPagingEngine.js';
|
|
21
21
|
// QueryParameterProcessor is now called internally by RenderPipeline
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
22
23
|
import { v4 as uuidv4 } from 'uuid';
|
|
23
24
|
import { SqlLoggingSessionImpl } from './SqlLogger.js';
|
|
24
25
|
import { GetDialect } from '@memberjunction/sql-dialect';
|
|
@@ -41,12 +42,41 @@ import { GeoCodeSyncService } from '@memberjunction/geo-core';
|
|
|
41
42
|
* Platform-specific providers should extend this class instead of DatabaseProviderBase
|
|
42
43
|
* to inherit these shared behaviors.
|
|
43
44
|
*/
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Thrown when a nested savepoint fails because the ambient physical transaction
|
|
47
|
+
* was already rolled back by the server (mssql ENOTBEGUN/EABORT, pg 25P01).
|
|
48
|
+
* Opening a second physical TX would commit inner work after the outer writes
|
|
49
|
+
* were gone. Callers must fail the outer unit — `Save()` returns false.
|
|
50
|
+
*/
|
|
51
|
+
export class DoomedTransactionError extends Error {
|
|
52
|
+
constructor(message = 'Ambient transaction was rolled back by the server; outer work is lost', options) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.code = 'DOOMED_TRANSACTION';
|
|
55
|
+
this.name = 'DoomedTransactionError';
|
|
56
|
+
if (options?.cause !== undefined) {
|
|
57
|
+
this.cause = options.cause;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
49
61
|
export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
62
|
+
constructor() {
|
|
63
|
+
// Composition engine is now owned by RenderPipeline
|
|
64
|
+
super(...arguments);
|
|
65
|
+
/** Per transaction-group counts so the same hash twice in one batch gets `_hash_2`. */
|
|
66
|
+
this._saveCallSuffixCounts = new WeakMap();
|
|
67
|
+
// ─── Nested transactions ─────────────────────────────────────────
|
|
68
|
+
//
|
|
69
|
+
// Depth 1 = physical BEGIN. Depth 2+ = a dialect savepoint on that same
|
|
70
|
+
// transaction. Nested begin with no physical TX is corruption, not a
|
|
71
|
+
// chance to start a second physical TX (that commits inner work after the
|
|
72
|
+
// outer transaction was already aborted).
|
|
73
|
+
this._transactionDepth = 0;
|
|
74
|
+
this._savepointCounter = 0;
|
|
75
|
+
this._savepointStack = [];
|
|
76
|
+
/** Physical handle is gone but outer frames still must settle. Queued nested begins must not become outermost. */
|
|
77
|
+
this._doomed = false;
|
|
78
|
+
this._txMutex = Promise.resolve();
|
|
79
|
+
}
|
|
50
80
|
/**
|
|
51
81
|
* Returns the active local storage provider, lazily creating an
|
|
52
82
|
* {@link InMemoryLocalStorageProvider} if none has been set.
|
|
@@ -440,12 +470,24 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
440
470
|
await this.HandleEntityActions(entity, 'save', true, user, options.OriginatingEntityActionIDs);
|
|
441
471
|
if (options.SkipEntityAIActions !== true)
|
|
442
472
|
await this.HandleEntityAIActions(entity, 'save', true, user);
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
473
|
+
// GeoCodeSyncService is the WRITE path. SupportsGeoCoding also means maps/distance
|
|
474
|
+
// (read). The service only runs when there is at least one writable Geo* field.
|
|
475
|
+
// Virtual PrimaryAddress* / __mj_Latitude never invoke the provider.
|
|
476
|
+
// SkipGeoCoding: per-save (mj-sync push.skipGeoCoding, integration sync).
|
|
477
|
+
// Native lat/lng already populated (sample data, pasted coords) → do not call the API.
|
|
478
|
+
if (entity.EntityInfo.SupportsGeoCoding &&
|
|
479
|
+
options.SkipGeoCoding !== true &&
|
|
480
|
+
entity.EntityInfo.HasWritableGeoSourceFields) {
|
|
481
|
+
const lat = entity.EntityInfo.Fields.find(f => f.IsNativeLatitudeField);
|
|
482
|
+
const lng = entity.EntityInfo.Fields.find(f => f.IsNativeLongitudeField);
|
|
483
|
+
const latVal = lat ? entity.Get(lat.Name) : null;
|
|
484
|
+
const lngVal = lng ? entity.Get(lng.Name) : null;
|
|
485
|
+
const coordsAlreadySet = latVal != null && latVal !== '' && lngVal != null && lngVal !== '';
|
|
486
|
+
if (!coordsAlreadySet) {
|
|
487
|
+
const needsGeoSync = context.IsNew || context.Fields.some((f) => f.WasDirty && f.FieldInfo.IsWritableGeoField);
|
|
488
|
+
if (needsGeoSync) {
|
|
489
|
+
context.State['geoSyncNeeded'] = true;
|
|
490
|
+
}
|
|
449
491
|
}
|
|
450
492
|
}
|
|
451
493
|
}
|
|
@@ -781,6 +823,91 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
781
823
|
UseJsonArgShape(entity, sprocType) {
|
|
782
824
|
return useJsonArgShape(entity, sprocType, this.ProcedureParamLimit);
|
|
783
825
|
}
|
|
826
|
+
/**
|
|
827
|
+
* Hex characters kept from the sha1 digest in {@link SaveCallVariableHash}. 12 hex = 48 bits:
|
|
828
|
+
* expected sha1-prefix collisions across 120k distinct save calls (a cheese-scale MetadataSync
|
|
829
|
+
* capture) fall from ~1.7 at 8 hex to ~3e-5, so `_n` disambiguation is a same-record safety net
|
|
830
|
+
* rather than something a large capture exercises. SQL Server identifiers allow 128 characters;
|
|
831
|
+
* `@CodeName_` plus 12 hex fits every generated name.
|
|
832
|
+
*/
|
|
833
|
+
static { this.SaveCallVariableHashLength = 12; }
|
|
834
|
+
static { this.uuidShape = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; }
|
|
835
|
+
/**
|
|
836
|
+
* First {@link SaveCallVariableHashLength} hex of sha1(`${schema}.${table}|${pk values}`).
|
|
837
|
+
* Dialect-agnostic identity of a save call so sqlLogging recaptures of an unchanged tree
|
|
838
|
+
* are byte-identical. A random uuidv4 slice (previously only in
|
|
839
|
+
* SQLServerDataProvider.RenderSaveCallBinding) made every MetadataSync recapture a 250 MB
|
|
840
|
+
* diff and, inside a batched TransactionGroup, collided under the birthday paradox at
|
|
841
|
+
* ~120k variables (loom #12 WP3 / F-D).
|
|
842
|
+
*
|
|
843
|
+
* sha1 is an identity hash here, not a security primitive: it only has to be stable and
|
|
844
|
+
* well distributed. Key values are normalized first so the same record hashes the same
|
|
845
|
+
* wherever its key came from: UUID-shaped strings are lower-cased (SQL Server returns
|
|
846
|
+
* upper-case, PostgreSQL and hand-authored JSON are usually lower-case), Dates use
|
|
847
|
+
* ISO-8601, null/undefined are empty. A create with no client-side PK therefore hashes
|
|
848
|
+
* `schema.table|`, a per-table constant, and inside a group the `_n` ordinal is what
|
|
849
|
+
* tells those inserts apart.
|
|
850
|
+
*/
|
|
851
|
+
static SaveCallVariableHash(schemaName, baseTable, pkValues) {
|
|
852
|
+
const pk = pkValues.map((v) => GenericDatabaseProvider.normalizeSaveCallKeyValue(v)).join('|');
|
|
853
|
+
return createHash('sha1')
|
|
854
|
+
.update(`${schemaName}.${baseTable}|${pk}`)
|
|
855
|
+
.digest('hex')
|
|
856
|
+
.slice(0, GenericDatabaseProvider.SaveCallVariableHashLength);
|
|
857
|
+
}
|
|
858
|
+
static normalizeSaveCallKeyValue(value) {
|
|
859
|
+
if (value === null || value === undefined) {
|
|
860
|
+
return '';
|
|
861
|
+
}
|
|
862
|
+
if (value instanceof Date) {
|
|
863
|
+
return value.toISOString();
|
|
864
|
+
}
|
|
865
|
+
const text = String(value);
|
|
866
|
+
return GenericDatabaseProvider.uuidShape.test(text.trim()) ? NormalizeUUID(text) : text;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Variable suffix for DECLARE/SET (or any named-local dialect) in a save call.
|
|
870
|
+
*
|
|
871
|
+
* Naming contract: `_<12 lowercase hex>` from
|
|
872
|
+
* `sha1(\`${schema}.${table}|${normalized pk values joined by |}\`)`, plus an optional
|
|
873
|
+
* `_<n>` with n ≥ 2 when that hash repeats inside one `TransactionGroup`
|
|
874
|
+
* (`_abc123456789`, `_abc123456789_2`, …). Outside a group there is no ordinal: each
|
|
875
|
+
* `Save()` is its own batch, or the SQL logger separates redeclarations. `mj sync push`
|
|
876
|
+
* captures put a batch separator after every statement; threshold-mode sessions (Explorer
|
|
877
|
+
* logging, `mj sync watch`) concatenate saves into one batch, so `SqlLoggingSessionImpl`
|
|
878
|
+
* emits the separator before any statement that would redeclare a name already declared in
|
|
879
|
+
* the current batch. Either way equal suffixes never share a scope.
|
|
880
|
+
*
|
|
881
|
+
* Inside a `BatchedSubmit` group the ordinal is load-bearing: two items whose hashes
|
|
882
|
+
* repeat (same record twice, or PK-less inserts) would otherwise declare the same locals in
|
|
883
|
+
* one batch. `SQLServerTransactionGroup.scopeItemVariables` also appends `_mjb<i>` per item.
|
|
884
|
+
*
|
|
885
|
+
* Ordinals are consumed at RENDER time, not at submit. An item whose SQL is regenerated
|
|
886
|
+
* (the transaction-variables path re-renders `Use` items in `HandleSubmit`) carries `_n`
|
|
887
|
+
* for a record rendered once before — deterministic run to run, but "same record → same
|
|
888
|
+
* suffix" holds only for items rendered exactly once.
|
|
889
|
+
*
|
|
890
|
+
* SQL Server's RenderSaveCallBinding consumes this; PostgreSQL positional/json-arg
|
|
891
|
+
* bindings do not name locals today but share the same GenerateSaveSQL orchestrator.
|
|
892
|
+
*/
|
|
893
|
+
allocateSaveCallSuffixForPk(group, schemaName, baseTable, pkValues) {
|
|
894
|
+
const hash = GenericDatabaseProvider.SaveCallVariableHash(schemaName, baseTable, pkValues);
|
|
895
|
+
if (!group) {
|
|
896
|
+
return `_${hash}`;
|
|
897
|
+
}
|
|
898
|
+
let counts = this._saveCallSuffixCounts.get(group);
|
|
899
|
+
if (!counts) {
|
|
900
|
+
counts = new Map();
|
|
901
|
+
this._saveCallSuffixCounts.set(group, counts);
|
|
902
|
+
}
|
|
903
|
+
const n = (counts.get(hash) ?? 0) + 1;
|
|
904
|
+
counts.set(hash, n);
|
|
905
|
+
return n === 1 ? `_${hash}` : `_${hash}_${n}`;
|
|
906
|
+
}
|
|
907
|
+
allocateSaveCallSuffix(entity) {
|
|
908
|
+
const pkValues = entity.PrimaryKey?.KeyValuePairs?.map((p) => p.Value) ?? [];
|
|
909
|
+
return this.allocateSaveCallSuffixForPk(entity.TransactionGroup, entity.EntityInfo.SchemaName, entity.EntityInfo.BaseTable, pkValues);
|
|
910
|
+
}
|
|
784
911
|
/**
|
|
785
912
|
* Concrete implementation of the abstract save-SQL builder defined on
|
|
786
913
|
* `DatabaseProviderBase`. Iterates fields via the single `IsSPParameter`
|
|
@@ -791,7 +918,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
791
918
|
* See `plans/sp-save-builder-generic-layer-refactor.md` (rev 4) for
|
|
792
919
|
* the design and the rev-3 lesson that motivated this shape.
|
|
793
920
|
*/
|
|
794
|
-
async GenerateSaveSQL(entity, isNew, user) {
|
|
921
|
+
async GenerateSaveSQL(entity, isNew, user, options) {
|
|
795
922
|
const isUpdate = !isNew;
|
|
796
923
|
const spName = this.GetCreateUpdateSPName(entity, isNew);
|
|
797
924
|
// 1. Iterate fields, apply IsSPParameter + PK rules, coerce values.
|
|
@@ -832,7 +959,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
832
959
|
const simpleSQL = baseSaveSQL.sql;
|
|
833
960
|
// 5. Optionally wrap with record-change emission.
|
|
834
961
|
let overlappingChangeData;
|
|
835
|
-
|
|
962
|
+
// Per-save suppression: a high-volume machine writer (integration sync) opts out of the
|
|
963
|
+
// audit row for ITS writes only — the entity keeps TrackRecordChanges for everyone else.
|
|
964
|
+
if (this.ShouldTrackRecordChanges(entity.EntityInfo) && options?.SkipRecordChanges !== true) {
|
|
836
965
|
const newData = entity.GetAll(false);
|
|
837
966
|
const oldData = isUpdate ? entity.GetAll(true) : null;
|
|
838
967
|
// ISA propagation hook: capture the diff for SS subtype propagation.
|
|
@@ -1285,6 +1414,18 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1285
1414
|
// 1. View where clause
|
|
1286
1415
|
if (viewEntity?.WhereClause && viewEntity.WhereClause.length > 0) {
|
|
1287
1416
|
const renderedWhere = await this.RenderViewWhereClause(viewEntity, user);
|
|
1417
|
+
// SECURITY: a stored view WhereClause originates from a client save, so pass the
|
|
1418
|
+
// rendered clause through the same screen ExtraFilter gets. The one exemption is
|
|
1419
|
+
// CustomWhereClause views: those are admin-authored (MJUserViewEntityServer's save
|
|
1420
|
+
// gate restricts setting/changing them to Owner-type users) and may legitimately
|
|
1421
|
+
// contain constructs the screen blocks. Auto-generated clauses (FilterState /
|
|
1422
|
+
// SmartFilter / nested {%UserView%} templates) always pass — the screen permits
|
|
1423
|
+
// plain SELECT subqueries and blocks only stacked statements, DML, comments,
|
|
1424
|
+
// UNION and WAITFOR.
|
|
1425
|
+
const isCustomWhereClause = !!viewEntity.CustomWhereClause; // truthy — the DB may hand back true or 1
|
|
1426
|
+
if (!isCustomWhereClause && !this.ValidateUserProvidedSQLClause(renderedWhere)) {
|
|
1427
|
+
throw new Error(`Invalid view WhereClause for view '${viewEntity.Name ?? viewEntity.ID}': contains one or more forbidden keywords`);
|
|
1428
|
+
}
|
|
1288
1429
|
whereSQL = `(${renderedWhere})`;
|
|
1289
1430
|
bHasWhere = true;
|
|
1290
1431
|
}
|
|
@@ -1307,7 +1448,13 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1307
1448
|
}
|
|
1308
1449
|
// 4. Exclude UserViewRunID
|
|
1309
1450
|
if ((excludeUserViewRunID.length > 0) || params.ExcludeDataFromAllPriorViewRuns === true) {
|
|
1310
|
-
|
|
1451
|
+
// vwUserViewRunDetails.RecordID holds ONE bare primary-key value per row (see
|
|
1452
|
+
// executeSQLForUserViewRunLogging, which fills it from a single-column SELECT), so the
|
|
1453
|
+
// `<pk> NOT IN (SELECT RecordID ...)` exclusion is only meaningful for a single-column key.
|
|
1454
|
+
// For a composite key a one-column NOT IN would silently drop every row that shares the
|
|
1455
|
+
// first column's value with a prior run — refuse rather than return the wrong rows.
|
|
1456
|
+
this.assertSingleColumnPrimaryKey(entityInfo, 'ExcludeUserViewRunID / ExcludeDataFromAllPriorViewRuns');
|
|
1457
|
+
let sExcludeSQL = `${this.QuoteIdentifier(entityInfo.FirstPrimaryKey.Name)} NOT IN (SELECT RecordID FROM ${this.QuoteSchemaAndView(this.MJCoreSchemaName, 'vwUserViewRunDetails')} WHERE EntityID='${viewEntity?.EntityID}' AND`; // first-pk-ok: guarded above — view-run RecordID is a single bare key value, PrimaryKeys.length === 1 enforced
|
|
1311
1458
|
if (params.ExcludeDataFromAllPriorViewRuns === true)
|
|
1312
1459
|
sExcludeSQL += ` UserViewID=${viewEntity?.ID})`;
|
|
1313
1460
|
else {
|
|
@@ -1358,7 +1505,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1358
1505
|
}
|
|
1359
1506
|
else {
|
|
1360
1507
|
rawOrderBy = params.OrderBy ? params.OrderBy : (viewEntity ? viewEntity.OrderByClause ?? '' : '');
|
|
1361
|
-
if (rawOrderBy.trim().length === 0 && maxRowsForQuery > 0 && entityInfo.
|
|
1508
|
+
if (rawOrderBy.trim().length === 0 && maxRowsForQuery > 0 && entityInfo.PrimaryKeys.length > 0) {
|
|
1362
1509
|
// ── DETERMINISM FALLBACK ──
|
|
1363
1510
|
// A row-LIMITED query with no ORDER BY returns an ARBITRARY subset: `TOP N` /
|
|
1364
1511
|
// `LIMIT N` without an ordering is undefined by definition, and the engine is
|
|
@@ -1374,7 +1521,12 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1374
1521
|
// OFFSET pagination already had exactly this fallback (see the pagination block
|
|
1375
1522
|
// below); it was simply never applied to the TOP/LIMIT path. Same PK, so a
|
|
1376
1523
|
// keyset walk's page 1 now agrees with every later page.
|
|
1377
|
-
|
|
1524
|
+
//
|
|
1525
|
+
// Every PK column is listed: for a composite key, ordering by the first column
|
|
1526
|
+
// alone leaves rows that share that value in undefined order — the same
|
|
1527
|
+
// arbitrary-subset problem this fallback exists to remove. Single-column keys
|
|
1528
|
+
// produce exactly `ORDER BY <pk>` as before.
|
|
1529
|
+
rawOrderBy = this.buildPrimaryKeyOrderBy(entityInfo);
|
|
1378
1530
|
orderByIsPkFallback = true;
|
|
1379
1531
|
}
|
|
1380
1532
|
}
|
|
@@ -1404,13 +1556,13 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1404
1556
|
viewSQL += ` ORDER BY ${orderBy}`;
|
|
1405
1557
|
}
|
|
1406
1558
|
// ── Pagination / Non-paginated limit ──
|
|
1407
|
-
if (usingPagination && entityInfo.
|
|
1559
|
+
if (usingPagination && entityInfo.PrimaryKeys.length > 0) {
|
|
1408
1560
|
// Belt-and-braces: the determinism fallback above already supplies ORDER BY <PK>
|
|
1409
1561
|
// for every row-limited query (pagination included), so `orderBy` is normally
|
|
1410
1562
|
// non-empty here. Kept because OFFSET/FETCH is a hard SYNTAX error without an
|
|
1411
1563
|
// ORDER BY — if the fallback above is ever narrowed, this must still hold.
|
|
1412
1564
|
if (!orderBy) {
|
|
1413
|
-
viewSQL += ` ORDER BY ${this.
|
|
1565
|
+
viewSQL += ` ORDER BY ${this.buildPrimaryKeyOrderBy(entityInfo)}`;
|
|
1414
1566
|
}
|
|
1415
1567
|
viewSQL += ' ' + this.BuildPaginationSQL(params.MaxRows, params.StartRow);
|
|
1416
1568
|
}
|
|
@@ -1640,7 +1792,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1640
1792
|
u = u.replace(/ /g, ' AND ');
|
|
1641
1793
|
}
|
|
1642
1794
|
}
|
|
1643
|
-
|
|
1795
|
+
// A full-text index is keyed on a single-column unique index (an engine requirement), and
|
|
1796
|
+
// the generated search function returns that one key column — single-column by design.
|
|
1797
|
+
const pkName = this.QuoteIdentifier(entityInfo.FirstPrimaryKey.Name); // first-pk-ok: full-text search functions key on the single-column unique index the engine requires
|
|
1644
1798
|
sUserSearchSQL = `${pkName} IN (SELECT ${pkName} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.FullTextSearchFunction ?? '')}('${u}'))`;
|
|
1645
1799
|
}
|
|
1646
1800
|
else {
|
|
@@ -1660,6 +1814,27 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1660
1814
|
}
|
|
1661
1815
|
return sUserSearchSQL;
|
|
1662
1816
|
}
|
|
1817
|
+
/**
|
|
1818
|
+
* ORDER BY column list covering EVERY primary key column of the entity, quoted for the dialect
|
|
1819
|
+
* — `[ID]` for a single-column key, `[OrderID], [LineNo]` for a composite one. Used as the
|
|
1820
|
+
* determinism fallback for row-limited queries with no caller ORDER BY: a composite key ordered
|
|
1821
|
+
* by its first column alone leaves rows sharing that value in undefined order.
|
|
1822
|
+
*/
|
|
1823
|
+
buildPrimaryKeyOrderBy(entityInfo) {
|
|
1824
|
+
return entityInfo.PrimaryKeys.map(pk => this.QuoteIdentifier(pk.Name)).join(', ');
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Throws unless `entityInfo` has exactly one primary key column. For the few view features that
|
|
1828
|
+
* store or compare ONE bare key value per row (user view run logging / exclusion, the
|
|
1829
|
+
* `{%UserView%}` template's `IN (subquery)`), a composite key has no single column to use and
|
|
1830
|
+
* silently truncating it to the first column would return the wrong rows — so refuse loudly.
|
|
1831
|
+
*/
|
|
1832
|
+
assertSingleColumnPrimaryKey(entityInfo, feature) {
|
|
1833
|
+
if (entityInfo.PrimaryKeys.length === 1)
|
|
1834
|
+
return;
|
|
1835
|
+
const columns = entityInfo.PrimaryKeys.map(pk => pk.Name).join(', ');
|
|
1836
|
+
throw new Error(`${feature} requires a single-column primary key. Entity "${entityInfo.Name}" has ${entityInfo.PrimaryKeys.length} primary key columns (${columns}).`);
|
|
1837
|
+
}
|
|
1663
1838
|
/**
|
|
1664
1839
|
* Build the SQL fragment that compares one EntityField against a user search term.
|
|
1665
1840
|
*
|
|
@@ -1750,7 +1925,11 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1750
1925
|
const innerViewEntity = variableValue ? await ViewInfo.GetViewEntity(variableValue, user) : null;
|
|
1751
1926
|
if (innerViewEntity) {
|
|
1752
1927
|
const innerWhere = await this.RenderViewWhereClause(innerViewEntity, user, stack);
|
|
1753
|
-
|
|
1928
|
+
// The template is substituted into a `<column> IN ({%UserView "x"%})` predicate, which
|
|
1929
|
+
// takes a one-column subquery — an entity with a composite key has no single column to return.
|
|
1930
|
+
const innerEntityInfo = innerViewEntity.ViewEntityInfo;
|
|
1931
|
+
this.assertSingleColumnPrimaryKey(innerEntityInfo, `The {%UserView%} template variable ${match}`);
|
|
1932
|
+
const innerSQL = `SELECT ${this.QuoteIdentifier(innerEntityInfo.FirstPrimaryKey.Name)} FROM ${this.QuoteSchemaAndView(innerEntityInfo.SchemaName, innerEntityInfo.BaseView)} WHERE (${innerWhere})`; // first-pk-ok: guarded above — IN (subquery) takes one column, PrimaryKeys.length === 1 enforced
|
|
1754
1933
|
// Function replacement — `innerSQL` is generated SQL that can
|
|
1755
1934
|
// legitimately contain `$`. See issue #3171.
|
|
1756
1935
|
sWhere = sWhere.replace(match, () => innerSQL);
|
|
@@ -3704,8 +3883,10 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
3704
3883
|
LogStatus(`[GenericDatabaseProvider] Skipping relationship '${rel}' on '${entityInfo.Name}': related entity '${relEntityInfo.Name}' is external-data-source-backed (relationship loading not supported for external entities).`);
|
|
3705
3884
|
continue;
|
|
3706
3885
|
}
|
|
3707
|
-
|
|
3708
|
-
|
|
3886
|
+
// An EntityRelationship joins on ONE column (RelatedEntityJoinField / JoinEntityJoinField)
|
|
3887
|
+
// that references this entity's key — a single-column foreign-key target by metadata design.
|
|
3888
|
+
const quotes = entity.FirstPrimaryKey.NeedsQuotes ? "'" : ''; // first-pk-ok: relationship join field is a single-column FK target
|
|
3889
|
+
const pkValue = ret[entity.FirstPrimaryKey.Name]; // first-pk-ok: relationship join field is a single-column FK target
|
|
3709
3890
|
let relSql;
|
|
3710
3891
|
if (relInfo.Type.trim().toLowerCase() === 'one to many') {
|
|
3711
3892
|
relSql = `SELECT * FROM ${this.QuoteSchemaAndView(relEntityInfo.SchemaName, relInfo.RelatedEntityBaseView)} WHERE ${this.QuoteIdentifier(relInfo.RelatedEntityJoinField)} = ${quotes}${pkValue}${quotes}`;
|
|
@@ -4143,8 +4324,6 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
4143
4324
|
const overallStart = performance.now();
|
|
4144
4325
|
const provider = (providerToUse ?? this);
|
|
4145
4326
|
const schema = provider.MJCoreSchemaName;
|
|
4146
|
-
const cache = LocalCacheManager.Instance;
|
|
4147
|
-
const cacheAvailable = cache.IsInitialized && this.TrustLocalCacheCompletely;
|
|
4148
4327
|
// Fetch dataset items metadata (lightweight — just the dataset definition, not entity data)
|
|
4149
4328
|
const sSQL = `SELECT di.*, ` +
|
|
4150
4329
|
`e.${provider.QuoteIdentifier('BaseView')} AS ${provider.QuoteIdentifier('EntityBaseView')}, ` +
|
|
@@ -4166,21 +4345,34 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
4166
4345
|
EntityUpdateDates: [],
|
|
4167
4346
|
};
|
|
4168
4347
|
}
|
|
4169
|
-
//
|
|
4348
|
+
// Status ALWAYS comes from SQL — never from cached dataset slots. This method is the
|
|
4349
|
+
// staleness ORACLE: RefreshIfNeeded/CheckToSeeIfRefreshNeeded compare its answer against
|
|
4350
|
+
// locally held metadata to decide whether that metadata is stale, and the client's
|
|
4351
|
+
// smart-cache checks ride it over the wire. Deriving the answer from the very cache whose
|
|
4352
|
+
// freshness is in question closes a loop: a slot the write path failed to maintain
|
|
4353
|
+
// reports itself current forever, and stale permission metadata is then served until
|
|
4354
|
+
// process restart (the FLS over-the-wire leak). The queries are cheap — one batched
|
|
4355
|
+
// MAX/COUNT aggregate per item — and the cache remains fully in play for the DATA reads
|
|
4356
|
+
// in GetDatasetByName.
|
|
4170
4357
|
const updateDates = [];
|
|
4171
4358
|
let overallLatestDate = new Date(1900, 1, 1);
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
const uncachedItemMeta = [];
|
|
4177
|
-
for (const item of items) {
|
|
4178
|
-
const entityID = String(item['EntityID']);
|
|
4179
|
-
const entityName = String(item['Entity']);
|
|
4359
|
+
const itemMeta = [];
|
|
4360
|
+
const queries = items.map((item) => {
|
|
4361
|
+
const entitySchemaName = String(item['EntitySchemaName'] ?? schema);
|
|
4362
|
+
const entityBaseView = String(item['EntityBaseView']);
|
|
4180
4363
|
const code = String(item['Code']);
|
|
4181
4364
|
const dateFieldToCheck = String(item['DateFieldToCheck'] ?? '__mj_UpdatedAt');
|
|
4182
4365
|
const whereClause = item['WhereClause'] ? String(item['WhereClause']) : '';
|
|
4183
|
-
|
|
4366
|
+
itemMeta.push({ entityID: String(item['EntityID']), entityName: String(item['Entity']) });
|
|
4367
|
+
// The floor for the reported timestamp: an edit to the dataset DEFINITION itself
|
|
4368
|
+
// (item added, filter changed) must read as a change even when no entity row moved.
|
|
4369
|
+
const itemUpdatedAt = new Date(String(item['DatasetItemUpdatedAt']));
|
|
4370
|
+
const datasetUpdatedAt = new Date(String(item['DatasetUpdatedAt']));
|
|
4371
|
+
const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime())).toISOString();
|
|
4372
|
+
// Same filter composition as GetDatasetByName's data read — the stored item
|
|
4373
|
+
// WhereClause AND'd with any runtime filter — so status and data describe the same
|
|
4374
|
+
// row set. (Every shipped MJ_Metadata item has a NULL WhereClause, so for metadata
|
|
4375
|
+
// this is identical to an unfiltered aggregate.)
|
|
4184
4376
|
let effectiveFilter = whereClause;
|
|
4185
4377
|
if (itemFilters && itemFilters.length > 0) {
|
|
4186
4378
|
const filter = itemFilters.find(f => f.ItemCode === code);
|
|
@@ -4190,91 +4382,41 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
4190
4382
|
: filter.Filter;
|
|
4191
4383
|
}
|
|
4192
4384
|
}
|
|
4193
|
-
const
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
}
|
|
4223
|
-
}
|
|
4224
|
-
// Cache miss — need SQL fallback
|
|
4225
|
-
cacheMissCount++;
|
|
4226
|
-
uncachedItems.push(item);
|
|
4227
|
-
uncachedItemMeta.push({ entityID, entityName, datasetMaxUpdatedAt: datasetMaxUpdatedAt.toISOString() });
|
|
4228
|
-
}
|
|
4229
|
-
// Phase 2: Execute SQL only for cache misses
|
|
4230
|
-
if (uncachedItems.length > 0) {
|
|
4231
|
-
const queries = uncachedItems.map((item, idx) => {
|
|
4232
|
-
const entitySchemaName = String(item['EntitySchemaName'] ?? schema);
|
|
4233
|
-
const entityBaseView = String(item['EntityBaseView']);
|
|
4234
|
-
const code = String(item['Code']);
|
|
4235
|
-
const dateFieldToCheck = String(item['DateFieldToCheck'] ?? '__mj_UpdatedAt');
|
|
4236
|
-
const meta = uncachedItemMeta[idx];
|
|
4237
|
-
let filterSQL = '';
|
|
4238
|
-
if (itemFilters && itemFilters.length > 0) {
|
|
4239
|
-
const filter = itemFilters.find(f => f.ItemCode === code);
|
|
4240
|
-
if (filter)
|
|
4241
|
-
filterSQL = ' WHERE ' + filter.Filter;
|
|
4242
|
-
}
|
|
4243
|
-
return `SELECT ` +
|
|
4244
|
-
`CASE ` +
|
|
4245
|
-
`WHEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) > '${meta.datasetMaxUpdatedAt}' THEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) ` +
|
|
4246
|
-
`ELSE '${meta.datasetMaxUpdatedAt}' ` +
|
|
4247
|
-
`END AS ${provider.QuoteIdentifier('UpdateDate')}, ` +
|
|
4248
|
-
`COUNT(*) AS ${provider.QuoteIdentifier('TheRowCount')} ` +
|
|
4249
|
-
`FROM ${provider.QuoteSchemaAndView(entitySchemaName, entityBaseView)}${filterSQL}`;
|
|
4250
|
-
});
|
|
4251
|
-
let batchResults = [];
|
|
4252
|
-
try {
|
|
4253
|
-
batchResults = await provider.ExecuteSQLBatch(queries, undefined, undefined, contextUser);
|
|
4254
|
-
}
|
|
4255
|
-
catch (err) {
|
|
4256
|
-
LogError(`GetDatasetStatusByName: Batch execution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4257
|
-
}
|
|
4258
|
-
for (let i = 0; i < uncachedItemMeta.length; i++) {
|
|
4259
|
-
const meta = uncachedItemMeta[i];
|
|
4260
|
-
const statusRows = batchResults[i];
|
|
4261
|
-
if (statusRows && statusRows.length > 0) {
|
|
4262
|
-
const updateDate = new Date(String(statusRows[0]['UpdateDate']));
|
|
4263
|
-
updateDates.push({
|
|
4264
|
-
EntityID: meta.entityID,
|
|
4265
|
-
EntityName: meta.entityName,
|
|
4266
|
-
RowCount: Number(statusRows[0]['TheRowCount']),
|
|
4267
|
-
UpdateDate: updateDate,
|
|
4268
|
-
});
|
|
4269
|
-
if (updateDate > overallLatestDate) {
|
|
4270
|
-
overallLatestDate = updateDate;
|
|
4271
|
-
}
|
|
4385
|
+
const filterSQL = effectiveFilter ? ' WHERE ' + effectiveFilter : '';
|
|
4386
|
+
return `SELECT ` +
|
|
4387
|
+
`CASE ` +
|
|
4388
|
+
`WHEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) > '${datasetMaxUpdatedAt}' THEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) ` +
|
|
4389
|
+
`ELSE '${datasetMaxUpdatedAt}' ` +
|
|
4390
|
+
`END AS ${provider.QuoteIdentifier('UpdateDate')}, ` +
|
|
4391
|
+
`COUNT(*) AS ${provider.QuoteIdentifier('TheRowCount')} ` +
|
|
4392
|
+
`FROM ${provider.QuoteSchemaAndView(entitySchemaName, entityBaseView)}${filterSQL}`;
|
|
4393
|
+
});
|
|
4394
|
+
let batchResults = [];
|
|
4395
|
+
try {
|
|
4396
|
+
batchResults = await provider.ExecuteSQLBatch(queries, undefined, undefined, contextUser);
|
|
4397
|
+
}
|
|
4398
|
+
catch (err) {
|
|
4399
|
+
LogError(`GetDatasetStatusByName: Batch execution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4400
|
+
}
|
|
4401
|
+
for (let i = 0; i < itemMeta.length; i++) {
|
|
4402
|
+
const meta = itemMeta[i];
|
|
4403
|
+
const statusRows = batchResults[i];
|
|
4404
|
+
if (statusRows && statusRows.length > 0) {
|
|
4405
|
+
const updateDate = new Date(String(statusRows[0]['UpdateDate']));
|
|
4406
|
+
updateDates.push({
|
|
4407
|
+
EntityID: meta.entityID,
|
|
4408
|
+
EntityName: meta.entityName,
|
|
4409
|
+
RowCount: Number(statusRows[0]['TheRowCount']),
|
|
4410
|
+
UpdateDate: updateDate,
|
|
4411
|
+
});
|
|
4412
|
+
if (updateDate > overallLatestDate) {
|
|
4413
|
+
overallLatestDate = updateDate;
|
|
4272
4414
|
}
|
|
4273
4415
|
}
|
|
4274
4416
|
}
|
|
4275
4417
|
const elapsedMs = (performance.now() - overallStart).toFixed(1);
|
|
4276
4418
|
LogStatusEx({
|
|
4277
|
-
message: `📊 [Dataset Status] GetDatasetStatusByName("${datasetName}"): ${
|
|
4419
|
+
message: `📊 [Dataset Status] GetDatasetStatusByName("${datasetName}"): ${items.length} SQL status queries — ${elapsedMs}ms`,
|
|
4278
4420
|
verboseOnly: true
|
|
4279
4421
|
});
|
|
4280
4422
|
if (updateDates.length === 0) {
|
|
@@ -4476,5 +4618,308 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
4476
4618
|
}
|
|
4477
4619
|
return out;
|
|
4478
4620
|
}
|
|
4621
|
+
get CurrentTransactionDepth() {
|
|
4622
|
+
return this._transactionDepth;
|
|
4623
|
+
}
|
|
4624
|
+
/** Copy of the savepoint stack, outermost first. */
|
|
4625
|
+
get SavepointStack() {
|
|
4626
|
+
return [...this._savepointStack];
|
|
4627
|
+
}
|
|
4628
|
+
/** @deprecated Use {@link SavepointStack}. */
|
|
4629
|
+
get savepointStack() {
|
|
4630
|
+
return this.SavepointStack;
|
|
4631
|
+
}
|
|
4632
|
+
/** True after the ambient physical TX was abandoned and frames are still settling. */
|
|
4633
|
+
get IsDoomed() {
|
|
4634
|
+
return this._doomed;
|
|
4635
|
+
}
|
|
4636
|
+
/**
|
|
4637
|
+
* Throw if a statement would run on the pool while frames are still open
|
|
4638
|
+
* after a server abort. Keyed on `_doomed`, not "depth > 0 with no handle"
|
|
4639
|
+
* — outermost begin has depth 1 before the handle is published, and
|
|
4640
|
+
* concurrent reads on SQL Server legitimately use the pool in that window.
|
|
4641
|
+
*/
|
|
4642
|
+
AssertAmbientTransactionUsable() {
|
|
4643
|
+
if (this._doomed) {
|
|
4644
|
+
throw new DoomedTransactionError(`SQL issued at depth ${this._transactionDepth} while the ambient transaction is doomed would autocommit on the pool`);
|
|
4645
|
+
}
|
|
4646
|
+
}
|
|
4647
|
+
/**
|
|
4648
|
+
* Drop a dead physical handle without going through public RollbackTransaction.
|
|
4649
|
+
* Default is RollbackPhysicalTransaction if one is open. Subclasses override
|
|
4650
|
+
* to unpublish even when the driver rollback itself rejects (EABORT).
|
|
4651
|
+
*/
|
|
4652
|
+
async AbandonPhysicalTransaction() {
|
|
4653
|
+
if (!this.HasPhysicalTransaction) {
|
|
4654
|
+
return;
|
|
4655
|
+
}
|
|
4656
|
+
try {
|
|
4657
|
+
await this.RollbackPhysicalTransaction();
|
|
4658
|
+
}
|
|
4659
|
+
catch (e) {
|
|
4660
|
+
LogError('AbandonPhysicalTransaction: rollback of doomed handle failed', undefined, e);
|
|
4661
|
+
}
|
|
4662
|
+
}
|
|
4663
|
+
SavepointName(n) {
|
|
4664
|
+
return `SavePoint_${n}`;
|
|
4665
|
+
}
|
|
4666
|
+
/** Serialize begin/commit/rollback so depth/stack mutations cannot interleave. */
|
|
4667
|
+
async WithTransactionLock(fn) {
|
|
4668
|
+
const previous = this._txMutex;
|
|
4669
|
+
let release;
|
|
4670
|
+
this._txMutex = new Promise((resolve) => { release = resolve; });
|
|
4671
|
+
try {
|
|
4672
|
+
await previous;
|
|
4673
|
+
return await fn();
|
|
4674
|
+
}
|
|
4675
|
+
finally {
|
|
4676
|
+
release();
|
|
4677
|
+
}
|
|
4678
|
+
}
|
|
4679
|
+
/**
|
|
4680
|
+
* After a successful outermost commit, once depth is 0 and the transaction lock is released.
|
|
4681
|
+
* SQL Server drains deferred tasks here — those saves must be able to BeginTransaction.
|
|
4682
|
+
*/
|
|
4683
|
+
async AfterPhysicalCommit() {
|
|
4684
|
+
/* no-op */
|
|
4685
|
+
}
|
|
4686
|
+
/** Called when a begin fails and depth is back to 0 — unpublish any leftover driver object. */
|
|
4687
|
+
async OnBeginFailedAtDepthZero() {
|
|
4688
|
+
/* subclasses clear the physical TX handle */
|
|
4689
|
+
}
|
|
4690
|
+
/**
|
|
4691
|
+
* Nested savepoint rollback failed. Abandon the physical handle and keep
|
|
4692
|
+
* frames until the outer settle.
|
|
4693
|
+
*/
|
|
4694
|
+
async HandleFailedSavepointRollback(savepointName, error) {
|
|
4695
|
+
LogError(`Savepoint rollback to ${savepointName} failed`, undefined, error);
|
|
4696
|
+
await this.AbandonPhysicalTransaction();
|
|
4697
|
+
this.markDoomed();
|
|
4698
|
+
}
|
|
4699
|
+
markDoomed() {
|
|
4700
|
+
this._doomed = true;
|
|
4701
|
+
}
|
|
4702
|
+
/**
|
|
4703
|
+
* Drop a dead physical handle and reset depth/stack. Safe to call when
|
|
4704
|
+
* already at depth 0. Does not go through {@link RollbackTransaction}
|
|
4705
|
+
* (that would re-enter the mutex).
|
|
4706
|
+
*/
|
|
4707
|
+
async ResetTransactionState() {
|
|
4708
|
+
await this.WithTransactionLock(() => this.abandonDoomedTransaction());
|
|
4709
|
+
}
|
|
4710
|
+
async BeginTransaction() {
|
|
4711
|
+
return this.WithTransactionLock(() => this.beginTransactionCore());
|
|
4712
|
+
}
|
|
4713
|
+
async CommitTransaction() {
|
|
4714
|
+
let runAfter = false;
|
|
4715
|
+
await this.WithTransactionLock(async () => {
|
|
4716
|
+
const outermost = this._transactionDepth === 1;
|
|
4717
|
+
await this.commitTransactionCore();
|
|
4718
|
+
runAfter = outermost;
|
|
4719
|
+
});
|
|
4720
|
+
if (runAfter) {
|
|
4721
|
+
await this.AfterPhysicalCommit();
|
|
4722
|
+
}
|
|
4723
|
+
}
|
|
4724
|
+
async RollbackTransaction() {
|
|
4725
|
+
return this.WithTransactionLock(() => this.rollbackTransactionCore());
|
|
4726
|
+
}
|
|
4727
|
+
async beginTransactionCore() {
|
|
4728
|
+
if (this._doomed) {
|
|
4729
|
+
throw new DoomedTransactionError();
|
|
4730
|
+
}
|
|
4731
|
+
this._transactionDepth++;
|
|
4732
|
+
try {
|
|
4733
|
+
if (this._transactionDepth === 1) {
|
|
4734
|
+
await this.BeginPhysicalTransaction();
|
|
4735
|
+
return;
|
|
4736
|
+
}
|
|
4737
|
+
if (!this.HasPhysicalTransaction) {
|
|
4738
|
+
throw new Error(`Transaction state corrupted: nested BeginTransaction at depth ${this._transactionDepth} with no physical transaction`);
|
|
4739
|
+
}
|
|
4740
|
+
const savepointName = this.SavepointName(++this._savepointCounter);
|
|
4741
|
+
this._savepointStack.push(savepointName);
|
|
4742
|
+
try {
|
|
4743
|
+
await this.createSavepoint(savepointName);
|
|
4744
|
+
}
|
|
4745
|
+
catch (savepointError) {
|
|
4746
|
+
this._savepointStack.pop();
|
|
4747
|
+
this._savepointCounter--;
|
|
4748
|
+
throw savepointError;
|
|
4749
|
+
}
|
|
4750
|
+
}
|
|
4751
|
+
catch (e) {
|
|
4752
|
+
if (this._transactionDepth > 0) {
|
|
4753
|
+
this._transactionDepth--;
|
|
4754
|
+
}
|
|
4755
|
+
if (e instanceof DoomedTransactionError || this._doomed) {
|
|
4756
|
+
throw e;
|
|
4757
|
+
}
|
|
4758
|
+
if (this._transactionDepth === 0 || !this.HasPhysicalTransaction) {
|
|
4759
|
+
this.clearTransactionState();
|
|
4760
|
+
await this.OnBeginFailedAtDepthZero();
|
|
4761
|
+
}
|
|
4762
|
+
LogError(e);
|
|
4763
|
+
throw e;
|
|
4764
|
+
}
|
|
4765
|
+
}
|
|
4766
|
+
async createSavepoint(savepointName) {
|
|
4767
|
+
const sql = this.Dialect.CreateSavepointSQL(savepointName);
|
|
4768
|
+
const options = {
|
|
4769
|
+
description: `Creating savepoint ${savepointName} at depth ${this._transactionDepth}`,
|
|
4770
|
+
ignoreLogging: true,
|
|
4771
|
+
};
|
|
4772
|
+
try {
|
|
4773
|
+
await this.ExecuteSQL(sql, undefined, options);
|
|
4774
|
+
}
|
|
4775
|
+
catch (savepointError) {
|
|
4776
|
+
if (this.HasPhysicalTransaction && this.isDoomedPhysicalTransactionError(savepointError)) {
|
|
4777
|
+
await this.AbandonPhysicalTransaction();
|
|
4778
|
+
this.markDoomed();
|
|
4779
|
+
throw new DoomedTransactionError(undefined, { cause: savepointError });
|
|
4780
|
+
}
|
|
4781
|
+
throw savepointError;
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
/**
|
|
4785
|
+
* Driver codes for a server-aborted ambient TX. Walk `cause` because some
|
|
4786
|
+
* wrappers nest the original error. Do not match English message text.
|
|
4787
|
+
*/
|
|
4788
|
+
isDoomedPhysicalTransactionError(error) {
|
|
4789
|
+
let current = error;
|
|
4790
|
+
for (let i = 0; i < 5 && current; i++) {
|
|
4791
|
+
if (current && typeof current === 'object' && 'code' in current) {
|
|
4792
|
+
const code = String(current.code);
|
|
4793
|
+
if (code === 'ENOTBEGUN' || code === 'EABORT' || code === '25P01') {
|
|
4794
|
+
return true;
|
|
4795
|
+
}
|
|
4796
|
+
}
|
|
4797
|
+
current =
|
|
4798
|
+
current && typeof current === 'object' && 'cause' in current
|
|
4799
|
+
? current.cause
|
|
4800
|
+
: undefined;
|
|
4801
|
+
}
|
|
4802
|
+
return false;
|
|
4803
|
+
}
|
|
4804
|
+
async abandonDoomedTransaction() {
|
|
4805
|
+
await this.AbandonPhysicalTransaction();
|
|
4806
|
+
this.clearTransactionState();
|
|
4807
|
+
await this.OnBeginFailedAtDepthZero();
|
|
4808
|
+
}
|
|
4809
|
+
async commitTransactionCore() {
|
|
4810
|
+
if (this._doomed) {
|
|
4811
|
+
this.popDoomedFrame();
|
|
4812
|
+
if (this._transactionDepth === 0) {
|
|
4813
|
+
throw new DoomedTransactionError();
|
|
4814
|
+
}
|
|
4815
|
+
return;
|
|
4816
|
+
}
|
|
4817
|
+
if (!this.HasPhysicalTransaction) {
|
|
4818
|
+
throw new Error('No active transaction to commit');
|
|
4819
|
+
}
|
|
4820
|
+
if (this._transactionDepth === 0) {
|
|
4821
|
+
throw new Error('Transaction depth mismatch - no transaction to commit');
|
|
4822
|
+
}
|
|
4823
|
+
if (this._transactionDepth === 1) {
|
|
4824
|
+
try {
|
|
4825
|
+
await this.CommitPhysicalTransaction();
|
|
4826
|
+
}
|
|
4827
|
+
catch (e) {
|
|
4828
|
+
await this.AbandonPhysicalTransaction();
|
|
4829
|
+
this.clearTransactionState();
|
|
4830
|
+
LogError(e);
|
|
4831
|
+
throw e;
|
|
4832
|
+
}
|
|
4833
|
+
this.clearTransactionState();
|
|
4834
|
+
return;
|
|
4835
|
+
}
|
|
4836
|
+
const savepointName = this._savepointStack[this._savepointStack.length - 1];
|
|
4837
|
+
if (!savepointName) {
|
|
4838
|
+
throw new Error(`Savepoint stack mismatch — expected savepoint at depth ${this._transactionDepth}.`);
|
|
4839
|
+
}
|
|
4840
|
+
const releaseSQL = this.Dialect.ReleaseSavepointSQL(savepointName);
|
|
4841
|
+
if (releaseSQL) {
|
|
4842
|
+
try {
|
|
4843
|
+
await this.ExecuteSQL(releaseSQL, undefined, {
|
|
4844
|
+
description: `Releasing savepoint ${savepointName}`,
|
|
4845
|
+
ignoreLogging: true,
|
|
4846
|
+
});
|
|
4847
|
+
}
|
|
4848
|
+
catch (e) {
|
|
4849
|
+
await this.AbandonPhysicalTransaction();
|
|
4850
|
+
this.markDoomed();
|
|
4851
|
+
this.popDoomedFrame();
|
|
4852
|
+
throw new DoomedTransactionError(undefined, { cause: e });
|
|
4853
|
+
}
|
|
4854
|
+
}
|
|
4855
|
+
this._savepointStack.pop();
|
|
4856
|
+
this._transactionDepth--;
|
|
4857
|
+
}
|
|
4858
|
+
async rollbackTransactionCore() {
|
|
4859
|
+
if (this._doomed) {
|
|
4860
|
+
this.popDoomedFrame();
|
|
4861
|
+
return;
|
|
4862
|
+
}
|
|
4863
|
+
if (!this.HasPhysicalTransaction) {
|
|
4864
|
+
throw new Error('No active transaction to rollback');
|
|
4865
|
+
}
|
|
4866
|
+
if (this._transactionDepth === 0) {
|
|
4867
|
+
throw new Error('Transaction depth mismatch - no transaction to rollback');
|
|
4868
|
+
}
|
|
4869
|
+
if (this._transactionDepth === 1) {
|
|
4870
|
+
try {
|
|
4871
|
+
await this.RollbackPhysicalTransaction();
|
|
4872
|
+
}
|
|
4873
|
+
finally {
|
|
4874
|
+
this.clearTransactionState();
|
|
4875
|
+
}
|
|
4876
|
+
return;
|
|
4877
|
+
}
|
|
4878
|
+
const savepointName = this._savepointStack[this._savepointStack.length - 1];
|
|
4879
|
+
if (!savepointName) {
|
|
4880
|
+
throw new Error('Savepoint stack mismatch - no savepoint to rollback to');
|
|
4881
|
+
}
|
|
4882
|
+
try {
|
|
4883
|
+
await this.ExecuteSQL(this.Dialect.RollbackToSavepointSQL(savepointName), undefined, {
|
|
4884
|
+
description: `Rolling back to savepoint ${savepointName}`,
|
|
4885
|
+
ignoreLogging: true,
|
|
4886
|
+
});
|
|
4887
|
+
const releaseSQL = this.Dialect.ReleaseSavepointSQL(savepointName);
|
|
4888
|
+
if (releaseSQL) {
|
|
4889
|
+
await this.ExecuteSQL(releaseSQL, undefined, {
|
|
4890
|
+
description: `Releasing savepoint ${savepointName} after rollback`,
|
|
4891
|
+
ignoreLogging: true,
|
|
4892
|
+
});
|
|
4893
|
+
}
|
|
4894
|
+
this._savepointStack.pop();
|
|
4895
|
+
this._transactionDepth--;
|
|
4896
|
+
}
|
|
4897
|
+
catch (savepointError) {
|
|
4898
|
+
await this.HandleFailedSavepointRollback(savepointName, savepointError);
|
|
4899
|
+
this.popDoomedFrame();
|
|
4900
|
+
return;
|
|
4901
|
+
}
|
|
4902
|
+
}
|
|
4903
|
+
/**
|
|
4904
|
+
* Drop one doomed frame without SQL. Depth 1 clears the flag so the next
|
|
4905
|
+
* begin is a real outermost. Nested commit-while-doomed uses this too.
|
|
4906
|
+
*/
|
|
4907
|
+
popDoomedFrame() {
|
|
4908
|
+
if (this._transactionDepth <= 1) {
|
|
4909
|
+
this.clearTransactionState();
|
|
4910
|
+
return;
|
|
4911
|
+
}
|
|
4912
|
+
this._savepointStack.pop();
|
|
4913
|
+
this._transactionDepth--;
|
|
4914
|
+
}
|
|
4915
|
+
clearSavepointState() {
|
|
4916
|
+
this._savepointStack = [];
|
|
4917
|
+
this._savepointCounter = 0;
|
|
4918
|
+
}
|
|
4919
|
+
clearTransactionState() {
|
|
4920
|
+
this._transactionDepth = 0;
|
|
4921
|
+
this._doomed = false;
|
|
4922
|
+
this.clearSavepointState();
|
|
4923
|
+
}
|
|
4479
4924
|
}
|
|
4480
4925
|
//# sourceMappingURL=GenericDatabaseProvider.js.map
|