@memberjunction/generic-database-provider 6.1.0-edge.5 → 6.1.0-edge.7
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/DatabaseWellKnownUserSource.d.ts +48 -0
- package/dist/DatabaseWellKnownUserSource.d.ts.map +1 -0
- package/dist/DatabaseWellKnownUserSource.js +101 -0
- package/dist/DatabaseWellKnownUserSource.js.map +1 -0
- package/dist/GenericDatabaseProvider.d.ts +215 -3
- package/dist/GenericDatabaseProvider.d.ts.map +1 -1
- package/dist/GenericDatabaseProvider.js +793 -136
- 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/SystemUserFieldAccessCheck.d.ts +64 -0
- package/dist/SystemUserFieldAccessCheck.d.ts.map +1 -0
- package/dist/SystemUserFieldAccessCheck.js +137 -0
- package/dist/SystemUserFieldAccessCheck.js.map +1 -0
- package/dist/UserCache.d.ts.map +1 -1
- package/dist/UserCache.js +2 -2
- package/dist/UserCache.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/queryPagingEngine.d.ts +19 -0
- package/dist/queryPagingEngine.d.ts.map +1 -1
- package/dist/queryPagingEngine.js +45 -0
- package/dist/queryPagingEngine.js.map +1 -1
- package/dist/saveTypes.d.ts +3 -3
- package/dist/saveTypes.d.ts.map +1 -1
- package/dist/systemUser.d.ts +27 -0
- package/dist/systemUser.d.ts.map +1 -0
- package/dist/systemUser.js +29 -0
- package/dist/systemUser.js.map +1 -0
- package/dist/systemUserFieldAccess.d.ts +114 -0
- package/dist/systemUserFieldAccess.d.ts.map +1 -0
- package/dist/systemUserFieldAccess.js +198 -0
- package/dist/systemUserFieldAccess.js.map +1 -0
- 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,14 +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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
+
}
|
|
451
491
|
}
|
|
452
492
|
}
|
|
453
493
|
}
|
|
@@ -783,6 +823,91 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
783
823
|
UseJsonArgShape(entity, sprocType) {
|
|
784
824
|
return useJsonArgShape(entity, sprocType, this.ProcedureParamLimit);
|
|
785
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
|
+
}
|
|
786
911
|
/**
|
|
787
912
|
* Concrete implementation of the abstract save-SQL builder defined on
|
|
788
913
|
* `DatabaseProviderBase`. Iterates fields via the single `IsSPParameter`
|
|
@@ -811,6 +936,21 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
811
936
|
// entity.Get(), which would assert active status (deprecation warning / disabled throw)
|
|
812
937
|
// for what is NOT user use of the field. (EntityField.Value itself never asserts.)
|
|
813
938
|
const theField = entity.GetFieldByName(f.Name);
|
|
939
|
+
// Not-loaded fields (the hydration source omitted them — e.g. field-security
|
|
940
|
+
// stripping) are OMITTED from the SP call entirely: every generated param has a
|
|
941
|
+
// default, the update procs' ISNULL(@p, [Col]) merge preserves the stored value,
|
|
942
|
+
// and the create procs substitute the column default. Skipping here also
|
|
943
|
+
// suppresses the _Clear companion, which RenderSaveCallBinding derives from the
|
|
944
|
+
// fieldValueMap this loop builds — so a not-loaded nullable field can never be
|
|
945
|
+
// wiped to NULL by its own construction state.
|
|
946
|
+
if (theField?.NotLoaded)
|
|
947
|
+
continue;
|
|
948
|
+
// Field security on INSERT: a field this user may not create is omitted so the
|
|
949
|
+
// column takes its database default. Distinct from NotLoaded — the value here is
|
|
950
|
+
// real, it is simply not one this user is permitted to supply — which is why the
|
|
951
|
+
// flag is separate and why validation still ran against it normally.
|
|
952
|
+
if (!isUpdate && theField?.CreateSuppressed)
|
|
953
|
+
continue;
|
|
814
954
|
const rawValue = theField?.Value;
|
|
815
955
|
// PK-on-CREATE with no explicit value: omit so the SP default fires.
|
|
816
956
|
const isPKOnCreate = !isUpdate && f.IsPrimaryKey && !f.AutoIncrement;
|
|
@@ -1270,7 +1410,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1270
1410
|
maxRowsForQuery = entityInfo.UserViewMaxRows;
|
|
1271
1411
|
}
|
|
1272
1412
|
// ── Field selection ──
|
|
1273
|
-
const fields = this.getRunTimeViewFieldString(params, viewEntity);
|
|
1413
|
+
const fields = this.getRunTimeViewFieldString(params, viewEntity, user);
|
|
1274
1414
|
// ── Build SELECT and COUNT SQL ──
|
|
1275
1415
|
// DataSource:'Materialized' routes the read to the entity's materialized wrapper view
|
|
1276
1416
|
// (same shape, so RLS/paging/fields all apply identically); default stays the live base view.
|
|
@@ -1289,6 +1429,18 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1289
1429
|
// 1. View where clause
|
|
1290
1430
|
if (viewEntity?.WhereClause && viewEntity.WhereClause.length > 0) {
|
|
1291
1431
|
const renderedWhere = await this.RenderViewWhereClause(viewEntity, user);
|
|
1432
|
+
// SECURITY: a stored view WhereClause originates from a client save, so pass the
|
|
1433
|
+
// rendered clause through the same screen ExtraFilter gets. The one exemption is
|
|
1434
|
+
// CustomWhereClause views: those are admin-authored (MJUserViewEntityServer's save
|
|
1435
|
+
// gate restricts setting/changing them to Owner-type users) and may legitimately
|
|
1436
|
+
// contain constructs the screen blocks. Auto-generated clauses (FilterState /
|
|
1437
|
+
// SmartFilter / nested {%UserView%} templates) always pass — the screen permits
|
|
1438
|
+
// plain SELECT subqueries and blocks only stacked statements, DML, comments,
|
|
1439
|
+
// UNION and WAITFOR.
|
|
1440
|
+
const isCustomWhereClause = !!viewEntity.CustomWhereClause; // truthy — the DB may hand back true or 1
|
|
1441
|
+
if (!isCustomWhereClause && !this.ValidateUserProvidedSQLClause(renderedWhere)) {
|
|
1442
|
+
throw new Error(`Invalid view WhereClause for view '${viewEntity.Name ?? viewEntity.ID}': contains one or more forbidden keywords`);
|
|
1443
|
+
}
|
|
1292
1444
|
whereSQL = `(${renderedWhere})`;
|
|
1293
1445
|
bHasWhere = true;
|
|
1294
1446
|
}
|
|
@@ -1300,10 +1452,25 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1300
1452
|
bHasWhere = true;
|
|
1301
1453
|
}
|
|
1302
1454
|
// 3. User search string
|
|
1455
|
+
//
|
|
1456
|
+
// 🚨 NOT screened by ValidateUserProvidedSQLClause (#4392). That denylist exists for
|
|
1457
|
+
// caller-supplied SQL FRAGMENTS (ExtraFilter / OrderBy / OverrideExcludeFilter, both
|
|
1458
|
+
// still screened above and below). UserSearchString is not a fragment — it is the free
|
|
1459
|
+
// text a person typed into a search box, and createViewUserSearchSQL never splices it
|
|
1460
|
+
// into SQL as one: it builds every predicate itself from IncludeInUserSearchAPI
|
|
1461
|
+
// metadata and lands the text only INSIDE a string literal, with single quotes doubled
|
|
1462
|
+
// and LIKE metacharacters escaped under an explicit ESCAPE.
|
|
1463
|
+
//
|
|
1464
|
+
// Screening it as SQL rejected ordinary searches. The denylist word-boundary-matches
|
|
1465
|
+
// keywords against the raw text, so "Union Pacific", "Update Request" and "drop
|
|
1466
|
+
// shipment" were all refused — and the grid surfaced a null error message, so the
|
|
1467
|
+
// search box simply appeared broken.
|
|
1468
|
+
//
|
|
1469
|
+
// Quote-doubling, not keyword matching, is the correct protection for a value landing
|
|
1470
|
+
// in a literal: it is what keeps the text inside the quotes, where no keyword it
|
|
1471
|
+
// contains can mean anything to the parser.
|
|
1303
1472
|
if (userSearchString.length > 0) {
|
|
1304
|
-
|
|
1305
|
-
throw new Error(`Invalid User Search SQL clause: ${userSearchString}, contains one more for forbidden keywords`);
|
|
1306
|
-
const sUserSearchSQL = this.createViewUserSearchSQL(entityInfo, userSearchString);
|
|
1473
|
+
const sUserSearchSQL = this.createViewUserSearchSQL(entityInfo, userSearchString, user);
|
|
1307
1474
|
if (sUserSearchSQL.length > 0) {
|
|
1308
1475
|
whereSQL = bHasWhere ? `${whereSQL} AND (${sUserSearchSQL})` : `(${sUserSearchSQL})`;
|
|
1309
1476
|
bHasWhere = true;
|
|
@@ -1311,13 +1478,20 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1311
1478
|
}
|
|
1312
1479
|
// 4. Exclude UserViewRunID
|
|
1313
1480
|
if ((excludeUserViewRunID.length > 0) || params.ExcludeDataFromAllPriorViewRuns === true) {
|
|
1314
|
-
|
|
1481
|
+
// vwUserViewRunDetails.RecordID holds ONE bare primary-key value per row (see
|
|
1482
|
+
// executeSQLForUserViewRunLogging, which fills it from a single-column SELECT), so the
|
|
1483
|
+
// `<pk> NOT IN (SELECT RecordID ...)` exclusion is only meaningful for a single-column key.
|
|
1484
|
+
// For a composite key a one-column NOT IN would silently drop every row that shares the
|
|
1485
|
+
// first column's value with a prior run — refuse rather than return the wrong rows.
|
|
1486
|
+
this.assertSingleColumnPrimaryKey(entityInfo, 'ExcludeUserViewRunID / ExcludeDataFromAllPriorViewRuns');
|
|
1487
|
+
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
|
|
1315
1488
|
if (params.ExcludeDataFromAllPriorViewRuns === true)
|
|
1316
1489
|
sExcludeSQL += ` UserViewID=${viewEntity?.ID})`;
|
|
1317
1490
|
else {
|
|
1318
1491
|
// SECURITY: excludeUserViewRunID is user-supplied (GraphQL input) and is
|
|
1319
|
-
// interpolated directly into SQL here. Unlike ExtraFilter/
|
|
1320
|
-
// OverrideExcludeFilter (all passed through ValidateUserProvidedSQLClause
|
|
1492
|
+
// interpolated directly into SQL here. Unlike ExtraFilter/OrderBy/
|
|
1493
|
+
// OverrideExcludeFilter (all passed through ValidateUserProvidedSQLClause —
|
|
1494
|
+
// UserSearchString is free text and is escaped into a literal instead, #4392),
|
|
1321
1495
|
// this value historically had NO validation — allowing SQL injection into the
|
|
1322
1496
|
// view WHERE clause. It is only ever a UserViewRun.ID (a GUID), so reject
|
|
1323
1497
|
// anything that is not a well-formed GUID before it reaches the query.
|
|
@@ -1362,7 +1536,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1362
1536
|
}
|
|
1363
1537
|
else {
|
|
1364
1538
|
rawOrderBy = params.OrderBy ? params.OrderBy : (viewEntity ? viewEntity.OrderByClause ?? '' : '');
|
|
1365
|
-
if (rawOrderBy.trim().length === 0 && maxRowsForQuery > 0 && entityInfo.
|
|
1539
|
+
if (rawOrderBy.trim().length === 0 && maxRowsForQuery > 0 && entityInfo.PrimaryKeys.length > 0) {
|
|
1366
1540
|
// ── DETERMINISM FALLBACK ──
|
|
1367
1541
|
// A row-LIMITED query with no ORDER BY returns an ARBITRARY subset: `TOP N` /
|
|
1368
1542
|
// `LIMIT N` without an ordering is undefined by definition, and the engine is
|
|
@@ -1378,7 +1552,12 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1378
1552
|
// OFFSET pagination already had exactly this fallback (see the pagination block
|
|
1379
1553
|
// below); it was simply never applied to the TOP/LIMIT path. Same PK, so a
|
|
1380
1554
|
// keyset walk's page 1 now agrees with every later page.
|
|
1381
|
-
|
|
1555
|
+
//
|
|
1556
|
+
// Every PK column is listed: for a composite key, ordering by the first column
|
|
1557
|
+
// alone leaves rows that share that value in undefined order — the same
|
|
1558
|
+
// arbitrary-subset problem this fallback exists to remove. Single-column keys
|
|
1559
|
+
// produce exactly `ORDER BY <pk>` as before.
|
|
1560
|
+
rawOrderBy = this.buildPrimaryKeyOrderBy(entityInfo);
|
|
1382
1561
|
orderByIsPkFallback = true;
|
|
1383
1562
|
}
|
|
1384
1563
|
}
|
|
@@ -1408,13 +1587,13 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1408
1587
|
viewSQL += ` ORDER BY ${orderBy}`;
|
|
1409
1588
|
}
|
|
1410
1589
|
// ── Pagination / Non-paginated limit ──
|
|
1411
|
-
if (usingPagination && entityInfo.
|
|
1590
|
+
if (usingPagination && entityInfo.PrimaryKeys.length > 0) {
|
|
1412
1591
|
// Belt-and-braces: the determinism fallback above already supplies ORDER BY <PK>
|
|
1413
1592
|
// for every row-limited query (pagination included), so `orderBy` is normally
|
|
1414
1593
|
// non-empty here. Kept because OFFSET/FETCH is a hard SYNTAX error without an
|
|
1415
1594
|
// ORDER BY — if the fallback above is ever narrowed, this must still hold.
|
|
1416
1595
|
if (!orderBy) {
|
|
1417
|
-
viewSQL += ` ORDER BY ${this.
|
|
1596
|
+
viewSQL += ` ORDER BY ${this.buildPrimaryKeyOrderBy(entityInfo)}`;
|
|
1418
1597
|
}
|
|
1419
1598
|
viewSQL += ' ' + this.BuildPaginationSQL(params.MaxRows, params.StartRow);
|
|
1420
1599
|
}
|
|
@@ -1549,12 +1728,36 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1549
1728
|
/**************************************************************************/
|
|
1550
1729
|
// InternalRunView Helpers
|
|
1551
1730
|
/**************************************************************************/
|
|
1731
|
+
/**
|
|
1732
|
+
* Returns the SELECT list for a single-record load: `*` normally, or an explicit list of
|
|
1733
|
+
* the columns this user is allowed to read when field security denies them any.
|
|
1734
|
+
*
|
|
1735
|
+
* Deliberately NOT routed through RunView, which is the other way to get this behavior.
|
|
1736
|
+
* That reroute is the tidier long-term shape and is planned separately (it must pass
|
|
1737
|
+
* `BypassCache` — a PK load has never been cache-served and must not silently start being
|
|
1738
|
+
* — and it changes relationship loading, which stays on its current path). Filtering the
|
|
1739
|
+
* column list here buys the same protection without touching either.
|
|
1740
|
+
*/
|
|
1741
|
+
buildFieldSecuritySelectList(entityInfo, user) {
|
|
1742
|
+
if (!user || !entityInfo.EnableFieldLevelSecurity) {
|
|
1743
|
+
return '*'; // the overwhelmingly common case — one boolean, unchanged SQL
|
|
1744
|
+
}
|
|
1745
|
+
const denied = entityInfo.GetDeniedReadFields(user);
|
|
1746
|
+
if (denied.size === 0) {
|
|
1747
|
+
return '*';
|
|
1748
|
+
}
|
|
1749
|
+
const allowed = entityInfo.Fields.filter(f => !denied.has(f.Name.trim().toLowerCase()));
|
|
1750
|
+
if (allowed.length === 0) {
|
|
1751
|
+
return '*'; // degenerate; PKs are unrestrictable so this should be unreachable
|
|
1752
|
+
}
|
|
1753
|
+
return allowed.map(f => this.QuoteIdentifier(f.Name)).join(', ');
|
|
1754
|
+
}
|
|
1552
1755
|
/**
|
|
1553
1756
|
* Builds the SQL field list string for a view query, using dialect-neutral quoting.
|
|
1554
1757
|
* Returns '*' if no specific fields are resolved.
|
|
1555
1758
|
*/
|
|
1556
|
-
getRunTimeViewFieldString(params, viewEntity) {
|
|
1557
|
-
const fieldList = this.getRunTimeViewFieldArray(params, viewEntity);
|
|
1759
|
+
getRunTimeViewFieldString(params, viewEntity, contextUser) {
|
|
1760
|
+
const fieldList = this.getRunTimeViewFieldArray(params, viewEntity, contextUser);
|
|
1558
1761
|
if (fieldList.length === 0)
|
|
1559
1762
|
return '*';
|
|
1560
1763
|
return fieldList
|
|
@@ -1567,8 +1770,19 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1567
1770
|
/**
|
|
1568
1771
|
* Resolves the list of EntityFieldInfo objects for a view query.
|
|
1569
1772
|
* Priority: params.Fields > view columns > all entity fields (wildcard).
|
|
1773
|
+
*
|
|
1774
|
+
* Field-level security intersects every resolution path with the user's ALLOWED set, so a
|
|
1775
|
+
* denied column never appears in the SELECT list and its values never leave the database:
|
|
1776
|
+
* - explicit `params.Fields` and saved-view columns are silently narrowed (a denied entry
|
|
1777
|
+
* is dropped without the "Field not found" error — `Fields` describes output shape, not
|
|
1778
|
+
* a predicate, and the caller learns nothing projection didn't already show them);
|
|
1779
|
+
* - an empty field list, which otherwise emits `SELECT *`, becomes the explicit
|
|
1780
|
+
* allowed-column list;
|
|
1781
|
+
* - `entity_object` requests are EXEMPT — entities must hydrate from every column (see
|
|
1782
|
+
* ApplyFieldSecurityProjection) and their enforcement stays at the output boundary;
|
|
1783
|
+
* - PKs are unrestrictable and always survive (the existing force-add).
|
|
1570
1784
|
*/
|
|
1571
|
-
getRunTimeViewFieldArray(params, viewEntity) {
|
|
1785
|
+
getRunTimeViewFieldArray(params, viewEntity, contextUser) {
|
|
1572
1786
|
const fieldList = [];
|
|
1573
1787
|
try {
|
|
1574
1788
|
let entityInfo = null;
|
|
@@ -1580,12 +1794,18 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1580
1794
|
if (!entityInfo)
|
|
1581
1795
|
throw new Error(`Entity ${params.EntityName} not found in metadata`);
|
|
1582
1796
|
}
|
|
1797
|
+
const flsUser = contextUser ?? this.CurrentUser;
|
|
1798
|
+
const denied = params.ResultType !== 'entity_object' && flsUser && entityInfo.EnableFieldLevelSecurity
|
|
1799
|
+
? entityInfo.GetDeniedReadFields(flsUser)
|
|
1800
|
+
: new Set();
|
|
1583
1801
|
if (params.Fields) {
|
|
1584
1802
|
for (const ef of entityInfo.PrimaryKeys) {
|
|
1585
1803
|
if (!params.Fields.find((f) => f.trim().toLowerCase() === ef.Name.toLowerCase()))
|
|
1586
1804
|
fieldList.push(ef);
|
|
1587
1805
|
}
|
|
1588
1806
|
params.Fields.forEach((f) => {
|
|
1807
|
+
if (denied.has(f.trim().toLowerCase()))
|
|
1808
|
+
return; // silent narrowing — deliberately NOT the "not found" error
|
|
1589
1809
|
const field = entityInfo.FieldByName(f); // O(1) index (was O(F) Fields.find → O(F²) over the loop)
|
|
1590
1810
|
if (field)
|
|
1591
1811
|
fieldList.push(field);
|
|
@@ -1597,6 +1817,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1597
1817
|
viewEntity.Columns.forEach((c) => {
|
|
1598
1818
|
if (!c.hidden) {
|
|
1599
1819
|
if (c.EntityField) {
|
|
1820
|
+
if (denied.has(c.EntityField.Name.trim().toLowerCase()))
|
|
1821
|
+
return; // silent narrowing
|
|
1600
1822
|
fieldList.push(c.EntityField);
|
|
1601
1823
|
}
|
|
1602
1824
|
else {
|
|
@@ -1609,6 +1831,15 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1609
1831
|
fieldList.push(ef);
|
|
1610
1832
|
}
|
|
1611
1833
|
}
|
|
1834
|
+
else if (denied.size > 0) {
|
|
1835
|
+
// No explicit fields and no saved view would emit `SELECT *` — for a restricted
|
|
1836
|
+
// user that pulls denied columns out of the database, so emit the explicit
|
|
1837
|
+
// allowed-column list instead (PKs are unrestrictable and always included).
|
|
1838
|
+
for (const ef of entityInfo.Fields) {
|
|
1839
|
+
if (!denied.has(ef.Name.trim().toLowerCase()))
|
|
1840
|
+
fieldList.push(ef);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1612
1843
|
}
|
|
1613
1844
|
catch (e) {
|
|
1614
1845
|
LogError(e);
|
|
@@ -1626,16 +1857,37 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1626
1857
|
* - skips fields that are not sensible text-search targets (non-text types,
|
|
1627
1858
|
* unbounded text columns when FTX is off).
|
|
1628
1859
|
*/
|
|
1629
|
-
createViewUserSearchSQL(entityInfo, userSearchString) {
|
|
1860
|
+
createViewUserSearchSQL(entityInfo, userSearchString, contextUser) {
|
|
1630
1861
|
let sUserSearchSQL = '';
|
|
1631
1862
|
const safeUserSearchString = userSearchString.replace(/'/g, "''");
|
|
1632
|
-
|
|
1863
|
+
// Field-level security: never search fields the user cannot read. Matching against a
|
|
1864
|
+
// denied column is a value oracle — "search for 250000 → the row comes back" probes the
|
|
1865
|
+
// secret one term at a time. UserSearchString is not rejected (the term is not a
|
|
1866
|
+
// caller-authored predicate; see AssertPredicatesRespectFieldSecurity) — the denied
|
|
1867
|
+
// fields are simply excluded from the searched set. Computed once per call, never per
|
|
1868
|
+
// field (the per-request precompute contract).
|
|
1869
|
+
const user = contextUser ?? this.CurrentUser;
|
|
1870
|
+
const deniedSearchFields = entityInfo.EnableFieldLevelSecurity && user
|
|
1871
|
+
? entityInfo.GetDeniedReadFields(user)
|
|
1872
|
+
: new Set();
|
|
1873
|
+
// The full-text index spans its indexed columns as one unit — a denied FTS-indexed
|
|
1874
|
+
// column cannot be excluded from the index function. When (and only when) a denied
|
|
1875
|
+
// field is FTS-indexed, fall back to the per-field LIKE path, which CAN exclude it.
|
|
1876
|
+
const ftsCoversDeniedField = deniedSearchFields.size > 0 &&
|
|
1877
|
+
entityInfo.Fields.some(f => f.FullTextSearchEnabled && deniedSearchFields.has(f.Name.trim().toLowerCase()));
|
|
1878
|
+
if (entityInfo.FullTextSearchEnabled && !ftsCoversDeniedField) {
|
|
1633
1879
|
let u = safeUserSearchString;
|
|
1634
1880
|
const uUpper = u.toUpperCase();
|
|
1635
|
-
|
|
1881
|
+
// WORD-boundary tests, not substring tests (#4392). As substrings, `OR` matches
|
|
1882
|
+
// "C-OR-PORATE" and `AND` matches "ST-AND-ARD", so ordinary two-word searches —
|
|
1883
|
+
// "Corporate Office", "Standard Rate", "North America" — fell into the branch below
|
|
1884
|
+
// and were emitted as `Corporate%Office`. `%` is not a full-text operator, so those
|
|
1885
|
+
// searches produced a syntax error instead of results. Only a STANDALONE AND/OR/NOT
|
|
1886
|
+
// is a boolean operator the caller meant.
|
|
1887
|
+
if (/ (AND|OR|NOT) /.test(uUpper)) {
|
|
1636
1888
|
u = uUpper.replace(/ /g, '%').replace(/%AND%/g, ' AND ').replace(/%OR%/g, ' OR ').replace(/%NOT%/g, ' NOT ');
|
|
1637
1889
|
}
|
|
1638
|
-
else if (
|
|
1890
|
+
else if (/\b(AND|OR|NOT)\b/.test(uUpper)) {
|
|
1639
1891
|
u = u.replace(/ /g, '%');
|
|
1640
1892
|
}
|
|
1641
1893
|
else if (u.includes(' ')) {
|
|
@@ -1644,14 +1896,37 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1644
1896
|
u = u.replace(/ /g, ' AND ');
|
|
1645
1897
|
}
|
|
1646
1898
|
}
|
|
1647
|
-
|
|
1899
|
+
// A full-text index is keyed on a single-column unique index (an engine requirement), and
|
|
1900
|
+
// the generated search function returns that one key column — single-column by design.
|
|
1901
|
+
const pkName = this.QuoteIdentifier(entityInfo.FirstPrimaryKey.Name); // first-pk-ok: full-text search functions key on the single-column unique index the engine requires
|
|
1648
1902
|
sUserSearchSQL = `${pkName} IN (SELECT ${pkName} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.FullTextSearchFunction ?? '')}('${u}'))`;
|
|
1649
1903
|
}
|
|
1650
1904
|
else {
|
|
1905
|
+
// 🚨 SECURITY (#4392): the search term is free text, and on every predicate this
|
|
1906
|
+
// method builds itself it is CONFINED — wrapped in a string literal with single
|
|
1907
|
+
// quotes doubled — so no keyword it contains can reach the parser and the SQL
|
|
1908
|
+
// fragment denylist is neither needed nor appropriate.
|
|
1909
|
+
//
|
|
1910
|
+
// `UserSearchParamFormatAPI` is the ONE exception. That format is admin-authored and
|
|
1911
|
+
// may splice `{0}` in UNQUOTED: ` = {0}` on a numeric field is a documented, tested
|
|
1912
|
+
// case (see createViewUserSearchSQL.test.ts). There the term IS SQL, with no quote
|
|
1913
|
+
// keeping it contained, so the fragment denylist still has to apply. Screen only when
|
|
1914
|
+
// such a field actually participates — ordinary entities keep accepting the ordinary
|
|
1915
|
+
// searches that #4392 was about.
|
|
1916
|
+
//
|
|
1917
|
+
// Note this restores the pre-#4392 screen for this path; it does not close the
|
|
1918
|
+
// unquoted-format hole, which the denylist never covered (`1) OR 1=1` carries no
|
|
1919
|
+
// forbidden keyword). Quoting `{0}` in the format is what actually closes that.
|
|
1920
|
+
if (this.userSearchFieldsUseCustomFormat(entityInfo, deniedSearchFields) && !this.ValidateUserProvidedSQLClause(userSearchString)) {
|
|
1921
|
+
throw new Error(`Invalid User Search string: this entity has a field using UserSearchParamFormatAPI, ` +
|
|
1922
|
+
`which splices the term directly into SQL, and the term contains forbidden keywords.`);
|
|
1923
|
+
}
|
|
1651
1924
|
const escapedTerm = this.escapeLikeTerm(safeUserSearchString);
|
|
1652
1925
|
for (const field of entityInfo.Fields) {
|
|
1653
1926
|
if (!field.IncludeInUserSearchAPI)
|
|
1654
1927
|
continue;
|
|
1928
|
+
if (deniedSearchFields.has(field.Name.trim().toLowerCase()))
|
|
1929
|
+
continue; // field security: not searchable
|
|
1655
1930
|
const sParam = this.buildPerFieldSearchPredicate(field, escapedTerm, safeUserSearchString);
|
|
1656
1931
|
if (!sParam)
|
|
1657
1932
|
continue;
|
|
@@ -1664,6 +1939,44 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1664
1939
|
}
|
|
1665
1940
|
return sUserSearchSQL;
|
|
1666
1941
|
}
|
|
1942
|
+
/**
|
|
1943
|
+
* ORDER BY column list covering EVERY primary key column of the entity, quoted for the dialect
|
|
1944
|
+
* — `[ID]` for a single-column key, `[OrderID], [LineNo]` for a composite one. Used as the
|
|
1945
|
+
* determinism fallback for row-limited queries with no caller ORDER BY: a composite key ordered
|
|
1946
|
+
* by its first column alone leaves rows sharing that value in undefined order.
|
|
1947
|
+
*/
|
|
1948
|
+
buildPrimaryKeyOrderBy(entityInfo) {
|
|
1949
|
+
return entityInfo.PrimaryKeys.map(pk => this.QuoteIdentifier(pk.Name)).join(', ');
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Throws unless `entityInfo` has exactly one primary key column. For the few view features that
|
|
1953
|
+
* store or compare ONE bare key value per row (user view run logging / exclusion, the
|
|
1954
|
+
* `{%UserView%}` template's `IN (subquery)`), a composite key has no single column to use and
|
|
1955
|
+
* silently truncating it to the first column would return the wrong rows — so refuse loudly.
|
|
1956
|
+
*/
|
|
1957
|
+
assertSingleColumnPrimaryKey(entityInfo, feature) {
|
|
1958
|
+
if (entityInfo.PrimaryKeys.length === 1)
|
|
1959
|
+
return;
|
|
1960
|
+
const columns = entityInfo.PrimaryKeys.map(pk => pk.Name).join(', ');
|
|
1961
|
+
throw new Error(`${feature} requires a single-column primary key. Entity "${entityInfo.Name}" has ${entityInfo.PrimaryKeys.length} primary key columns (${columns}).`);
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* True when a field that will ACTUALLY PARTICIPATE in this search carries a
|
|
1965
|
+
* `UserSearchParamFormatAPI`. Such a format is admin-authored and may place `{0}` outside
|
|
1966
|
+
* quotes, so for those entities the search term is not guaranteed to land inside a literal
|
|
1967
|
+
* and still needs the SQL-fragment denylist (#4392).
|
|
1968
|
+
*
|
|
1969
|
+
* `deniedFields` must be the same field-level-security exclusion set the predicate loop
|
|
1970
|
+
* applies. A field the caller may not read is skipped there, so its format never reaches the
|
|
1971
|
+
* SQL — screening the term on its behalf would refuse ordinary searches ("Union Pacific") for
|
|
1972
|
+
* exactly the users with the LEAST access. Participation, not mere configuration, is what
|
|
1973
|
+
* makes the denylist necessary.
|
|
1974
|
+
*/
|
|
1975
|
+
userSearchFieldsUseCustomFormat(entityInfo, deniedFields) {
|
|
1976
|
+
return entityInfo.Fields.some(f => f.IncludeInUserSearchAPI &&
|
|
1977
|
+
!!f.UserSearchParamFormatAPI && f.UserSearchParamFormatAPI.length > 0 &&
|
|
1978
|
+
!deniedFields?.has(f.Name.trim().toLowerCase()));
|
|
1979
|
+
}
|
|
1667
1980
|
/**
|
|
1668
1981
|
* Build the SQL fragment that compares one EntityField against a user search term.
|
|
1669
1982
|
*
|
|
@@ -1754,7 +2067,11 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1754
2067
|
const innerViewEntity = variableValue ? await ViewInfo.GetViewEntity(variableValue, user) : null;
|
|
1755
2068
|
if (innerViewEntity) {
|
|
1756
2069
|
const innerWhere = await this.RenderViewWhereClause(innerViewEntity, user, stack);
|
|
1757
|
-
|
|
2070
|
+
// The template is substituted into a `<column> IN ({%UserView "x"%})` predicate, which
|
|
2071
|
+
// takes a one-column subquery — an entity with a composite key has no single column to return.
|
|
2072
|
+
const innerEntityInfo = innerViewEntity.ViewEntityInfo;
|
|
2073
|
+
this.assertSingleColumnPrimaryKey(innerEntityInfo, `The {%UserView%} template variable ${match}`);
|
|
2074
|
+
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
|
|
1758
2075
|
// Function replacement — `innerSQL` is generated SQL that can
|
|
1759
2076
|
// legitimately contain `$`. See issue #3171.
|
|
1760
2077
|
sWhere = sWhere.replace(match, () => innerSQL);
|
|
@@ -1846,6 +2163,14 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1846
2163
|
// served cached rows unprojected — one client's shape poisoned the slot
|
|
1847
2164
|
// for every subsequent caller.
|
|
1848
2165
|
const callerFieldsByIndex = new Map();
|
|
2166
|
+
// Field-security predicate rejections, per item. This operation is directly
|
|
2167
|
+
// client-invokable over GraphQL and its legs call InternalRunView / the cache
|
|
2168
|
+
// directly — the AssertPredicatesRespectFieldSecurity gate that PreRunView/
|
|
2169
|
+
// PreRunViews run is otherwise SKIPPED here, leaving ExtraFilter/OrderBy/
|
|
2170
|
+
// Aggregates over denied fields as a live reconstruction channel on this path.
|
|
2171
|
+
// Rejection is per-item (the response shape has per-item error status), with
|
|
2172
|
+
// the same deliberately ambiguous message as every other rejection path.
|
|
2173
|
+
const gateRejectedByIndex = new Map();
|
|
1849
2174
|
for (let i = 0; i < params.length; i++) {
|
|
1850
2175
|
// Shallow-clone: widening must never leak into the caller's objects
|
|
1851
2176
|
params[i] = { ...params[i], params: { ...params[i].params } };
|
|
@@ -1864,12 +2189,23 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1864
2189
|
this.ResolvePlatformSQLInParams(params[i].params);
|
|
1865
2190
|
params[i].params = await this.RunPreRunViewHooks(params[i].params, user);
|
|
1866
2191
|
const p = params[i].params;
|
|
2192
|
+
// Gate AFTER hooks (injected filters are scanned too), same as PreRunViews.
|
|
2193
|
+
try {
|
|
2194
|
+
this.AssertPredicatesRespectFieldSecurity(p, user);
|
|
2195
|
+
}
|
|
2196
|
+
catch (gateError) {
|
|
2197
|
+
gateRejectedByIndex.set(i, gateError instanceof Error ? gateError.message : String(gateError));
|
|
2198
|
+
continue;
|
|
2199
|
+
}
|
|
1867
2200
|
const widenEntity = p.EntityName ? this.EntityByName(p.EntityName) : null;
|
|
1868
2201
|
if (widenEntity && this.runViewCacheEligible(p)) {
|
|
1869
2202
|
const requested = p.Fields && p.Fields.length > 0
|
|
1870
2203
|
? p.Fields.map(f => f.trim().toLowerCase())
|
|
1871
2204
|
: null;
|
|
1872
|
-
|
|
2205
|
+
// ALL fields, for every user: server slots are full-width and shared, and
|
|
2206
|
+
// field security narrows per request at read time via
|
|
2207
|
+
// ApplyFieldSecurityProjection rather than at fetch time.
|
|
2208
|
+
p.Fields = this.ComputeRunViewFetchFields(widenEntity);
|
|
1873
2209
|
if (requested) {
|
|
1874
2210
|
callerFieldsByIndex.set(i, ProviderBase.UnionFieldsWithPrimaryKeys(requested, widenEntity));
|
|
1875
2211
|
}
|
|
@@ -1888,6 +2224,13 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1888
2224
|
const errorResults = [];
|
|
1889
2225
|
for (let i = 0; i < params.length; i++) {
|
|
1890
2226
|
const item = params[i];
|
|
2227
|
+
// Field-security gate rejections short-circuit every leg for this item —
|
|
2228
|
+
// no cache consult, no currency check, no execution.
|
|
2229
|
+
const gateRejection = gateRejectedByIndex.get(i);
|
|
2230
|
+
if (gateRejection !== undefined) {
|
|
2231
|
+
errorResults.push({ viewIndex: i, status: 'error', errorMessage: gateRejection });
|
|
2232
|
+
continue;
|
|
2233
|
+
}
|
|
1891
2234
|
// Keyset queries bypass the cache entirely (per the AfterKey API contract):
|
|
1892
2235
|
// each call uses a different seek key, so cached entries would never be
|
|
1893
2236
|
// reusable. Route directly to standard execution path.
|
|
@@ -1988,7 +2331,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1988
2331
|
for (const entry of itemsWithoutCacheCheck) {
|
|
1989
2332
|
if (LocalCacheManager.Instance.IsInitialized) {
|
|
1990
2333
|
const rlsWhereClause = this.ComputeRunViewRLSWhereClause(entry.item.params, contextUser);
|
|
1991
|
-
const
|
|
2334
|
+
const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(entry.item.params);
|
|
2335
|
+
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(entry.item.params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
|
|
1992
2336
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
1993
2337
|
if (cached) {
|
|
1994
2338
|
const entityLabel = entry.item.params.EntityName || 'unknown';
|
|
@@ -2030,6 +2374,41 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
2030
2374
|
// cache, which was masked when an earlier 'stale'/'differential'
|
|
2031
2375
|
// response populated it. Uses params[viewIndex] — the same hooked
|
|
2032
2376
|
// params object the Pre hooks produced.
|
|
2377
|
+
// ── Field-level security projection (OUTPUT boundary of this path) ──
|
|
2378
|
+
// Every row-bearing leg above (serve-from-cache, full query, differential)
|
|
2379
|
+
// returns rows WITHOUT traversing PostRunView, so the projections that guard
|
|
2380
|
+
// the standard pipeline never run here — and unrestricted users' slots and
|
|
2381
|
+
// full-width DB reads can carry denied columns. Apply them per item, for BOTH
|
|
2382
|
+
// full results and differential updatedRows, before the Post hooks run.
|
|
2383
|
+
//
|
|
2384
|
+
// BOTH projections, in this order — they are siblings, not alternatives, and
|
|
2385
|
+
// `PostRunView` calls them as a pair at all four of its projection points. Applying
|
|
2386
|
+
// only the first leaves the audit trail leaking: `ApplyFieldSecurityProjection`
|
|
2387
|
+
// short-circuits on the RunView entity's own `EnableFieldLevelSecurity`, and
|
|
2388
|
+
// `MJ: Record Changes` has that flag OFF by design, so it is a no-op on exactly the
|
|
2389
|
+
// rows that matter. The denied values there are INSIDE the `ChangesJSON` /
|
|
2390
|
+
// `FullRecordJSON` payload columns, which no amount of column stripping reaches —
|
|
2391
|
+
// `ApplyRecordChangeFieldSecurityProjection` is what projects them against the entity
|
|
2392
|
+
// each row is about. Reachable from a browser: this transport is selected when
|
|
2393
|
+
// `params.some(p => p.CacheLocal)` (providerBase.ts), so a Record Changes view
|
|
2394
|
+
// batched alongside any cache-local view rides onto it.
|
|
2395
|
+
for (const item of allResults) {
|
|
2396
|
+
const flsBearing = item;
|
|
2397
|
+
const flsParams = params[flsBearing.viewIndex]?.params;
|
|
2398
|
+
if (!flsParams) {
|
|
2399
|
+
continue;
|
|
2400
|
+
}
|
|
2401
|
+
if (Array.isArray(flsBearing.results)) {
|
|
2402
|
+
flsBearing.results = this.ApplyFieldSecurityProjection(flsBearing.results, flsParams, user);
|
|
2403
|
+
flsBearing.results = this.ApplyRecordChangeFieldSecurityProjection(flsBearing.results, flsParams, user);
|
|
2404
|
+
}
|
|
2405
|
+
if (Array.isArray(flsBearing.differentialData?.updatedRows)) {
|
|
2406
|
+
flsBearing.differentialData.updatedRows =
|
|
2407
|
+
this.ApplyFieldSecurityProjection(flsBearing.differentialData.updatedRows, flsParams, user);
|
|
2408
|
+
flsBearing.differentialData.updatedRows =
|
|
2409
|
+
this.ApplyRecordChangeFieldSecurityProjection(flsBearing.differentialData.updatedRows, flsParams, user);
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2033
2412
|
for (const item of allResults) {
|
|
2034
2413
|
const rowBearing = item;
|
|
2035
2414
|
if (!Array.isArray(rowBearing.results)) {
|
|
@@ -2076,10 +2455,10 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
2076
2455
|
whereSQL = `(${extraFilter})`;
|
|
2077
2456
|
bHasWhere = true;
|
|
2078
2457
|
}
|
|
2458
|
+
// Free text, not a SQL fragment — deliberately NOT run through
|
|
2459
|
+
// ValidateUserProvidedSQLClause. See the equivalent note on the view path above (#4392).
|
|
2079
2460
|
if (params.UserSearchString && params.UserSearchString.length > 0) {
|
|
2080
|
-
|
|
2081
|
-
throw new Error(`Invalid User Search SQL clause: ${params.UserSearchString}`);
|
|
2082
|
-
const sUserSearchSQL = this.createViewUserSearchSQL(entityInfo, params.UserSearchString);
|
|
2461
|
+
const sUserSearchSQL = this.createViewUserSearchSQL(entityInfo, params.UserSearchString, user);
|
|
2083
2462
|
if (sUserSearchSQL.length > 0) {
|
|
2084
2463
|
whereSQL = bHasWhere ? `${whereSQL} AND (${sUserSearchSQL})` : `(${sUserSearchSQL})`;
|
|
2085
2464
|
bHasWhere = true;
|
|
@@ -2164,7 +2543,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
2164
2543
|
const ttlMs = await this.resolveExternalCacheTTLMs(params, contextUser);
|
|
2165
2544
|
if (ttlMs !== 0) {
|
|
2166
2545
|
const rlsWhereClause = this.ComputeRunViewRLSWhereClause(params, contextUser);
|
|
2167
|
-
const
|
|
2546
|
+
const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(params);
|
|
2547
|
+
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
|
|
2168
2548
|
const maxUpdatedAt = result.maxUpdatedAt || new Date().toISOString();
|
|
2169
2549
|
// Pass the aggregates (B38-family omission #4). This slot is ALSO written by
|
|
2170
2550
|
// InternalRunView's normal PostRunView path WITH aggregates — two writers,
|
|
@@ -2310,7 +2690,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
2310
2690
|
if (!LocalCacheManager.Instance.IsInitialized)
|
|
2311
2691
|
return null;
|
|
2312
2692
|
const rlsWhereClause = this.ComputeRunViewRLSWhereClause(item.params, contextUser);
|
|
2313
|
-
const
|
|
2693
|
+
const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(item.params);
|
|
2694
|
+
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(item.params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
|
|
2314
2695
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
2315
2696
|
if (!cached)
|
|
2316
2697
|
return null;
|
|
@@ -3675,7 +4056,17 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
3675
4056
|
fullWhere = `${where} AND (${rlsWhereClause})`;
|
|
3676
4057
|
}
|
|
3677
4058
|
}
|
|
3678
|
-
|
|
4059
|
+
// Field security: a user with denied columns gets an explicit allowed-column list
|
|
4060
|
+
// instead of `SELECT *`, so denied values never leave the database — not even into
|
|
4061
|
+
// server memory. This is the single-record counterpart of the SELECT-list filtering
|
|
4062
|
+
// RunView already does, and it is what stops an agent running under a restricted
|
|
4063
|
+
// service account from holding values its user may not read. The columns that ARE
|
|
4064
|
+
// omitted come back as absent keys, which marks them not-loaded on the entity, so the
|
|
4065
|
+
// next save skips them and the stored values survive.
|
|
4066
|
+
//
|
|
4067
|
+
// Unrestricted users keep the literal `SELECT *` — byte-identical SQL, no plan churn.
|
|
4068
|
+
const selectList = this.buildFieldSecuritySelectList(entityInfo, user);
|
|
4069
|
+
const sql = `SELECT ${selectList} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)} WHERE ${fullWhere}`;
|
|
3679
4070
|
const rawData = await this.ExecuteSQL(sql, undefined, undefined, user);
|
|
3680
4071
|
const d = await this.PostProcessRows(rawData, entityInfo, user);
|
|
3681
4072
|
if (d && d.length > 0) {
|
|
@@ -3708,8 +4099,10 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
3708
4099
|
LogStatus(`[GenericDatabaseProvider] Skipping relationship '${rel}' on '${entityInfo.Name}': related entity '${relEntityInfo.Name}' is external-data-source-backed (relationship loading not supported for external entities).`);
|
|
3709
4100
|
continue;
|
|
3710
4101
|
}
|
|
3711
|
-
|
|
3712
|
-
|
|
4102
|
+
// An EntityRelationship joins on ONE column (RelatedEntityJoinField / JoinEntityJoinField)
|
|
4103
|
+
// that references this entity's key — a single-column foreign-key target by metadata design.
|
|
4104
|
+
const quotes = entity.FirstPrimaryKey.NeedsQuotes ? "'" : ''; // first-pk-ok: relationship join field is a single-column FK target
|
|
4105
|
+
const pkValue = ret[entity.FirstPrimaryKey.Name]; // first-pk-ok: relationship join field is a single-column FK target
|
|
3713
4106
|
let relSql;
|
|
3714
4107
|
if (relInfo.Type.trim().toLowerCase() === 'one to many') {
|
|
3715
4108
|
relSql = `SELECT * FROM ${this.QuoteSchemaAndView(relEntityInfo.SchemaName, relInfo.RelatedEntityBaseView)} WHERE ${this.QuoteIdentifier(relInfo.RelatedEntityJoinField)} = ${quotes}${pkValue}${quotes}`;
|
|
@@ -4147,8 +4540,6 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
4147
4540
|
const overallStart = performance.now();
|
|
4148
4541
|
const provider = (providerToUse ?? this);
|
|
4149
4542
|
const schema = provider.MJCoreSchemaName;
|
|
4150
|
-
const cache = LocalCacheManager.Instance;
|
|
4151
|
-
const cacheAvailable = cache.IsInitialized && this.TrustLocalCacheCompletely;
|
|
4152
4543
|
// Fetch dataset items metadata (lightweight — just the dataset definition, not entity data)
|
|
4153
4544
|
const sSQL = `SELECT di.*, ` +
|
|
4154
4545
|
`e.${provider.QuoteIdentifier('BaseView')} AS ${provider.QuoteIdentifier('EntityBaseView')}, ` +
|
|
@@ -4170,21 +4561,34 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
4170
4561
|
EntityUpdateDates: [],
|
|
4171
4562
|
};
|
|
4172
4563
|
}
|
|
4173
|
-
//
|
|
4564
|
+
// Status ALWAYS comes from SQL — never from cached dataset slots. This method is the
|
|
4565
|
+
// staleness ORACLE: RefreshIfNeeded/CheckToSeeIfRefreshNeeded compare its answer against
|
|
4566
|
+
// locally held metadata to decide whether that metadata is stale, and the client's
|
|
4567
|
+
// smart-cache checks ride it over the wire. Deriving the answer from the very cache whose
|
|
4568
|
+
// freshness is in question closes a loop: a slot the write path failed to maintain
|
|
4569
|
+
// reports itself current forever, and stale permission metadata is then served until
|
|
4570
|
+
// process restart (the FLS over-the-wire leak). The queries are cheap — one batched
|
|
4571
|
+
// MAX/COUNT aggregate per item — and the cache remains fully in play for the DATA reads
|
|
4572
|
+
// in GetDatasetByName.
|
|
4174
4573
|
const updateDates = [];
|
|
4175
4574
|
let overallLatestDate = new Date(1900, 1, 1);
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
const uncachedItemMeta = [];
|
|
4181
|
-
for (const item of items) {
|
|
4182
|
-
const entityID = String(item['EntityID']);
|
|
4183
|
-
const entityName = String(item['Entity']);
|
|
4575
|
+
const itemMeta = [];
|
|
4576
|
+
const queries = items.map((item) => {
|
|
4577
|
+
const entitySchemaName = String(item['EntitySchemaName'] ?? schema);
|
|
4578
|
+
const entityBaseView = String(item['EntityBaseView']);
|
|
4184
4579
|
const code = String(item['Code']);
|
|
4185
4580
|
const dateFieldToCheck = String(item['DateFieldToCheck'] ?? '__mj_UpdatedAt');
|
|
4186
4581
|
const whereClause = item['WhereClause'] ? String(item['WhereClause']) : '';
|
|
4187
|
-
|
|
4582
|
+
itemMeta.push({ entityID: String(item['EntityID']), entityName: String(item['Entity']) });
|
|
4583
|
+
// The floor for the reported timestamp: an edit to the dataset DEFINITION itself
|
|
4584
|
+
// (item added, filter changed) must read as a change even when no entity row moved.
|
|
4585
|
+
const itemUpdatedAt = new Date(String(item['DatasetItemUpdatedAt']));
|
|
4586
|
+
const datasetUpdatedAt = new Date(String(item['DatasetUpdatedAt']));
|
|
4587
|
+
const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime())).toISOString();
|
|
4588
|
+
// Same filter composition as GetDatasetByName's data read — the stored item
|
|
4589
|
+
// WhereClause AND'd with any runtime filter — so status and data describe the same
|
|
4590
|
+
// row set. (Every shipped MJ_Metadata item has a NULL WhereClause, so for metadata
|
|
4591
|
+
// this is identical to an unfiltered aggregate.)
|
|
4188
4592
|
let effectiveFilter = whereClause;
|
|
4189
4593
|
if (itemFilters && itemFilters.length > 0) {
|
|
4190
4594
|
const filter = itemFilters.find(f => f.ItemCode === code);
|
|
@@ -4194,91 +4598,41 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
4194
4598
|
: filter.Filter;
|
|
4195
4599
|
}
|
|
4196
4600
|
}
|
|
4197
|
-
const
|
|
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
|
-
|
|
4225
|
-
|
|
4226
|
-
}
|
|
4227
|
-
}
|
|
4228
|
-
// Cache miss — need SQL fallback
|
|
4229
|
-
cacheMissCount++;
|
|
4230
|
-
uncachedItems.push(item);
|
|
4231
|
-
uncachedItemMeta.push({ entityID, entityName, datasetMaxUpdatedAt: datasetMaxUpdatedAt.toISOString() });
|
|
4232
|
-
}
|
|
4233
|
-
// Phase 2: Execute SQL only for cache misses
|
|
4234
|
-
if (uncachedItems.length > 0) {
|
|
4235
|
-
const queries = uncachedItems.map((item, idx) => {
|
|
4236
|
-
const entitySchemaName = String(item['EntitySchemaName'] ?? schema);
|
|
4237
|
-
const entityBaseView = String(item['EntityBaseView']);
|
|
4238
|
-
const code = String(item['Code']);
|
|
4239
|
-
const dateFieldToCheck = String(item['DateFieldToCheck'] ?? '__mj_UpdatedAt');
|
|
4240
|
-
const meta = uncachedItemMeta[idx];
|
|
4241
|
-
let filterSQL = '';
|
|
4242
|
-
if (itemFilters && itemFilters.length > 0) {
|
|
4243
|
-
const filter = itemFilters.find(f => f.ItemCode === code);
|
|
4244
|
-
if (filter)
|
|
4245
|
-
filterSQL = ' WHERE ' + filter.Filter;
|
|
4246
|
-
}
|
|
4247
|
-
return `SELECT ` +
|
|
4248
|
-
`CASE ` +
|
|
4249
|
-
`WHEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) > '${meta.datasetMaxUpdatedAt}' THEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) ` +
|
|
4250
|
-
`ELSE '${meta.datasetMaxUpdatedAt}' ` +
|
|
4251
|
-
`END AS ${provider.QuoteIdentifier('UpdateDate')}, ` +
|
|
4252
|
-
`COUNT(*) AS ${provider.QuoteIdentifier('TheRowCount')} ` +
|
|
4253
|
-
`FROM ${provider.QuoteSchemaAndView(entitySchemaName, entityBaseView)}${filterSQL}`;
|
|
4254
|
-
});
|
|
4255
|
-
let batchResults = [];
|
|
4256
|
-
try {
|
|
4257
|
-
batchResults = await provider.ExecuteSQLBatch(queries, undefined, undefined, contextUser);
|
|
4258
|
-
}
|
|
4259
|
-
catch (err) {
|
|
4260
|
-
LogError(`GetDatasetStatusByName: Batch execution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4261
|
-
}
|
|
4262
|
-
for (let i = 0; i < uncachedItemMeta.length; i++) {
|
|
4263
|
-
const meta = uncachedItemMeta[i];
|
|
4264
|
-
const statusRows = batchResults[i];
|
|
4265
|
-
if (statusRows && statusRows.length > 0) {
|
|
4266
|
-
const updateDate = new Date(String(statusRows[0]['UpdateDate']));
|
|
4267
|
-
updateDates.push({
|
|
4268
|
-
EntityID: meta.entityID,
|
|
4269
|
-
EntityName: meta.entityName,
|
|
4270
|
-
RowCount: Number(statusRows[0]['TheRowCount']),
|
|
4271
|
-
UpdateDate: updateDate,
|
|
4272
|
-
});
|
|
4273
|
-
if (updateDate > overallLatestDate) {
|
|
4274
|
-
overallLatestDate = updateDate;
|
|
4275
|
-
}
|
|
4601
|
+
const filterSQL = effectiveFilter ? ' WHERE ' + effectiveFilter : '';
|
|
4602
|
+
return `SELECT ` +
|
|
4603
|
+
`CASE ` +
|
|
4604
|
+
`WHEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) > '${datasetMaxUpdatedAt}' THEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) ` +
|
|
4605
|
+
`ELSE '${datasetMaxUpdatedAt}' ` +
|
|
4606
|
+
`END AS ${provider.QuoteIdentifier('UpdateDate')}, ` +
|
|
4607
|
+
`COUNT(*) AS ${provider.QuoteIdentifier('TheRowCount')} ` +
|
|
4608
|
+
`FROM ${provider.QuoteSchemaAndView(entitySchemaName, entityBaseView)}${filterSQL}`;
|
|
4609
|
+
});
|
|
4610
|
+
let batchResults = [];
|
|
4611
|
+
try {
|
|
4612
|
+
batchResults = await provider.ExecuteSQLBatch(queries, undefined, undefined, contextUser);
|
|
4613
|
+
}
|
|
4614
|
+
catch (err) {
|
|
4615
|
+
LogError(`GetDatasetStatusByName: Batch execution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4616
|
+
}
|
|
4617
|
+
for (let i = 0; i < itemMeta.length; i++) {
|
|
4618
|
+
const meta = itemMeta[i];
|
|
4619
|
+
const statusRows = batchResults[i];
|
|
4620
|
+
if (statusRows && statusRows.length > 0) {
|
|
4621
|
+
const updateDate = new Date(String(statusRows[0]['UpdateDate']));
|
|
4622
|
+
updateDates.push({
|
|
4623
|
+
EntityID: meta.entityID,
|
|
4624
|
+
EntityName: meta.entityName,
|
|
4625
|
+
RowCount: Number(statusRows[0]['TheRowCount']),
|
|
4626
|
+
UpdateDate: updateDate,
|
|
4627
|
+
});
|
|
4628
|
+
if (updateDate > overallLatestDate) {
|
|
4629
|
+
overallLatestDate = updateDate;
|
|
4276
4630
|
}
|
|
4277
4631
|
}
|
|
4278
4632
|
}
|
|
4279
4633
|
const elapsedMs = (performance.now() - overallStart).toFixed(1);
|
|
4280
4634
|
LogStatusEx({
|
|
4281
|
-
message: `📊 [Dataset Status] GetDatasetStatusByName("${datasetName}"): ${
|
|
4635
|
+
message: `📊 [Dataset Status] GetDatasetStatusByName("${datasetName}"): ${items.length} SQL status queries — ${elapsedMs}ms`,
|
|
4282
4636
|
verboseOnly: true
|
|
4283
4637
|
});
|
|
4284
4638
|
if (updateDates.length === 0) {
|
|
@@ -4480,5 +4834,308 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
4480
4834
|
}
|
|
4481
4835
|
return out;
|
|
4482
4836
|
}
|
|
4837
|
+
get CurrentTransactionDepth() {
|
|
4838
|
+
return this._transactionDepth;
|
|
4839
|
+
}
|
|
4840
|
+
/** Copy of the savepoint stack, outermost first. */
|
|
4841
|
+
get SavepointStack() {
|
|
4842
|
+
return [...this._savepointStack];
|
|
4843
|
+
}
|
|
4844
|
+
/** @deprecated Use {@link SavepointStack}. */
|
|
4845
|
+
get savepointStack() {
|
|
4846
|
+
return this.SavepointStack;
|
|
4847
|
+
}
|
|
4848
|
+
/** True after the ambient physical TX was abandoned and frames are still settling. */
|
|
4849
|
+
get IsDoomed() {
|
|
4850
|
+
return this._doomed;
|
|
4851
|
+
}
|
|
4852
|
+
/**
|
|
4853
|
+
* Throw if a statement would run on the pool while frames are still open
|
|
4854
|
+
* after a server abort. Keyed on `_doomed`, not "depth > 0 with no handle"
|
|
4855
|
+
* — outermost begin has depth 1 before the handle is published, and
|
|
4856
|
+
* concurrent reads on SQL Server legitimately use the pool in that window.
|
|
4857
|
+
*/
|
|
4858
|
+
AssertAmbientTransactionUsable() {
|
|
4859
|
+
if (this._doomed) {
|
|
4860
|
+
throw new DoomedTransactionError(`SQL issued at depth ${this._transactionDepth} while the ambient transaction is doomed would autocommit on the pool`);
|
|
4861
|
+
}
|
|
4862
|
+
}
|
|
4863
|
+
/**
|
|
4864
|
+
* Drop a dead physical handle without going through public RollbackTransaction.
|
|
4865
|
+
* Default is RollbackPhysicalTransaction if one is open. Subclasses override
|
|
4866
|
+
* to unpublish even when the driver rollback itself rejects (EABORT).
|
|
4867
|
+
*/
|
|
4868
|
+
async AbandonPhysicalTransaction() {
|
|
4869
|
+
if (!this.HasPhysicalTransaction) {
|
|
4870
|
+
return;
|
|
4871
|
+
}
|
|
4872
|
+
try {
|
|
4873
|
+
await this.RollbackPhysicalTransaction();
|
|
4874
|
+
}
|
|
4875
|
+
catch (e) {
|
|
4876
|
+
LogError('AbandonPhysicalTransaction: rollback of doomed handle failed', undefined, e);
|
|
4877
|
+
}
|
|
4878
|
+
}
|
|
4879
|
+
SavepointName(n) {
|
|
4880
|
+
return `SavePoint_${n}`;
|
|
4881
|
+
}
|
|
4882
|
+
/** Serialize begin/commit/rollback so depth/stack mutations cannot interleave. */
|
|
4883
|
+
async WithTransactionLock(fn) {
|
|
4884
|
+
const previous = this._txMutex;
|
|
4885
|
+
let release;
|
|
4886
|
+
this._txMutex = new Promise((resolve) => { release = resolve; });
|
|
4887
|
+
try {
|
|
4888
|
+
await previous;
|
|
4889
|
+
return await fn();
|
|
4890
|
+
}
|
|
4891
|
+
finally {
|
|
4892
|
+
release();
|
|
4893
|
+
}
|
|
4894
|
+
}
|
|
4895
|
+
/**
|
|
4896
|
+
* After a successful outermost commit, once depth is 0 and the transaction lock is released.
|
|
4897
|
+
* SQL Server drains deferred tasks here — those saves must be able to BeginTransaction.
|
|
4898
|
+
*/
|
|
4899
|
+
async AfterPhysicalCommit() {
|
|
4900
|
+
/* no-op */
|
|
4901
|
+
}
|
|
4902
|
+
/** Called when a begin fails and depth is back to 0 — unpublish any leftover driver object. */
|
|
4903
|
+
async OnBeginFailedAtDepthZero() {
|
|
4904
|
+
/* subclasses clear the physical TX handle */
|
|
4905
|
+
}
|
|
4906
|
+
/**
|
|
4907
|
+
* Nested savepoint rollback failed. Abandon the physical handle and keep
|
|
4908
|
+
* frames until the outer settle.
|
|
4909
|
+
*/
|
|
4910
|
+
async HandleFailedSavepointRollback(savepointName, error) {
|
|
4911
|
+
LogError(`Savepoint rollback to ${savepointName} failed`, undefined, error);
|
|
4912
|
+
await this.AbandonPhysicalTransaction();
|
|
4913
|
+
this.markDoomed();
|
|
4914
|
+
}
|
|
4915
|
+
markDoomed() {
|
|
4916
|
+
this._doomed = true;
|
|
4917
|
+
}
|
|
4918
|
+
/**
|
|
4919
|
+
* Drop a dead physical handle and reset depth/stack. Safe to call when
|
|
4920
|
+
* already at depth 0. Does not go through {@link RollbackTransaction}
|
|
4921
|
+
* (that would re-enter the mutex).
|
|
4922
|
+
*/
|
|
4923
|
+
async ResetTransactionState() {
|
|
4924
|
+
await this.WithTransactionLock(() => this.abandonDoomedTransaction());
|
|
4925
|
+
}
|
|
4926
|
+
async BeginTransaction() {
|
|
4927
|
+
return this.WithTransactionLock(() => this.beginTransactionCore());
|
|
4928
|
+
}
|
|
4929
|
+
async CommitTransaction() {
|
|
4930
|
+
let runAfter = false;
|
|
4931
|
+
await this.WithTransactionLock(async () => {
|
|
4932
|
+
const outermost = this._transactionDepth === 1;
|
|
4933
|
+
await this.commitTransactionCore();
|
|
4934
|
+
runAfter = outermost;
|
|
4935
|
+
});
|
|
4936
|
+
if (runAfter) {
|
|
4937
|
+
await this.AfterPhysicalCommit();
|
|
4938
|
+
}
|
|
4939
|
+
}
|
|
4940
|
+
async RollbackTransaction() {
|
|
4941
|
+
return this.WithTransactionLock(() => this.rollbackTransactionCore());
|
|
4942
|
+
}
|
|
4943
|
+
async beginTransactionCore() {
|
|
4944
|
+
if (this._doomed) {
|
|
4945
|
+
throw new DoomedTransactionError();
|
|
4946
|
+
}
|
|
4947
|
+
this._transactionDepth++;
|
|
4948
|
+
try {
|
|
4949
|
+
if (this._transactionDepth === 1) {
|
|
4950
|
+
await this.BeginPhysicalTransaction();
|
|
4951
|
+
return;
|
|
4952
|
+
}
|
|
4953
|
+
if (!this.HasPhysicalTransaction) {
|
|
4954
|
+
throw new Error(`Transaction state corrupted: nested BeginTransaction at depth ${this._transactionDepth} with no physical transaction`);
|
|
4955
|
+
}
|
|
4956
|
+
const savepointName = this.SavepointName(++this._savepointCounter);
|
|
4957
|
+
this._savepointStack.push(savepointName);
|
|
4958
|
+
try {
|
|
4959
|
+
await this.createSavepoint(savepointName);
|
|
4960
|
+
}
|
|
4961
|
+
catch (savepointError) {
|
|
4962
|
+
this._savepointStack.pop();
|
|
4963
|
+
this._savepointCounter--;
|
|
4964
|
+
throw savepointError;
|
|
4965
|
+
}
|
|
4966
|
+
}
|
|
4967
|
+
catch (e) {
|
|
4968
|
+
if (this._transactionDepth > 0) {
|
|
4969
|
+
this._transactionDepth--;
|
|
4970
|
+
}
|
|
4971
|
+
if (e instanceof DoomedTransactionError || this._doomed) {
|
|
4972
|
+
throw e;
|
|
4973
|
+
}
|
|
4974
|
+
if (this._transactionDepth === 0 || !this.HasPhysicalTransaction) {
|
|
4975
|
+
this.clearTransactionState();
|
|
4976
|
+
await this.OnBeginFailedAtDepthZero();
|
|
4977
|
+
}
|
|
4978
|
+
LogError(e);
|
|
4979
|
+
throw e;
|
|
4980
|
+
}
|
|
4981
|
+
}
|
|
4982
|
+
async createSavepoint(savepointName) {
|
|
4983
|
+
const sql = this.Dialect.CreateSavepointSQL(savepointName);
|
|
4984
|
+
const options = {
|
|
4985
|
+
description: `Creating savepoint ${savepointName} at depth ${this._transactionDepth}`,
|
|
4986
|
+
ignoreLogging: true,
|
|
4987
|
+
};
|
|
4988
|
+
try {
|
|
4989
|
+
await this.ExecuteSQL(sql, undefined, options);
|
|
4990
|
+
}
|
|
4991
|
+
catch (savepointError) {
|
|
4992
|
+
if (this.HasPhysicalTransaction && this.isDoomedPhysicalTransactionError(savepointError)) {
|
|
4993
|
+
await this.AbandonPhysicalTransaction();
|
|
4994
|
+
this.markDoomed();
|
|
4995
|
+
throw new DoomedTransactionError(undefined, { cause: savepointError });
|
|
4996
|
+
}
|
|
4997
|
+
throw savepointError;
|
|
4998
|
+
}
|
|
4999
|
+
}
|
|
5000
|
+
/**
|
|
5001
|
+
* Driver codes for a server-aborted ambient TX. Walk `cause` because some
|
|
5002
|
+
* wrappers nest the original error. Do not match English message text.
|
|
5003
|
+
*/
|
|
5004
|
+
isDoomedPhysicalTransactionError(error) {
|
|
5005
|
+
let current = error;
|
|
5006
|
+
for (let i = 0; i < 5 && current; i++) {
|
|
5007
|
+
if (current && typeof current === 'object' && 'code' in current) {
|
|
5008
|
+
const code = String(current.code);
|
|
5009
|
+
if (code === 'ENOTBEGUN' || code === 'EABORT' || code === '25P01') {
|
|
5010
|
+
return true;
|
|
5011
|
+
}
|
|
5012
|
+
}
|
|
5013
|
+
current =
|
|
5014
|
+
current && typeof current === 'object' && 'cause' in current
|
|
5015
|
+
? current.cause
|
|
5016
|
+
: undefined;
|
|
5017
|
+
}
|
|
5018
|
+
return false;
|
|
5019
|
+
}
|
|
5020
|
+
async abandonDoomedTransaction() {
|
|
5021
|
+
await this.AbandonPhysicalTransaction();
|
|
5022
|
+
this.clearTransactionState();
|
|
5023
|
+
await this.OnBeginFailedAtDepthZero();
|
|
5024
|
+
}
|
|
5025
|
+
async commitTransactionCore() {
|
|
5026
|
+
if (this._doomed) {
|
|
5027
|
+
this.popDoomedFrame();
|
|
5028
|
+
if (this._transactionDepth === 0) {
|
|
5029
|
+
throw new DoomedTransactionError();
|
|
5030
|
+
}
|
|
5031
|
+
return;
|
|
5032
|
+
}
|
|
5033
|
+
if (!this.HasPhysicalTransaction) {
|
|
5034
|
+
throw new Error('No active transaction to commit');
|
|
5035
|
+
}
|
|
5036
|
+
if (this._transactionDepth === 0) {
|
|
5037
|
+
throw new Error('Transaction depth mismatch - no transaction to commit');
|
|
5038
|
+
}
|
|
5039
|
+
if (this._transactionDepth === 1) {
|
|
5040
|
+
try {
|
|
5041
|
+
await this.CommitPhysicalTransaction();
|
|
5042
|
+
}
|
|
5043
|
+
catch (e) {
|
|
5044
|
+
await this.AbandonPhysicalTransaction();
|
|
5045
|
+
this.clearTransactionState();
|
|
5046
|
+
LogError(e);
|
|
5047
|
+
throw e;
|
|
5048
|
+
}
|
|
5049
|
+
this.clearTransactionState();
|
|
5050
|
+
return;
|
|
5051
|
+
}
|
|
5052
|
+
const savepointName = this._savepointStack[this._savepointStack.length - 1];
|
|
5053
|
+
if (!savepointName) {
|
|
5054
|
+
throw new Error(`Savepoint stack mismatch — expected savepoint at depth ${this._transactionDepth}.`);
|
|
5055
|
+
}
|
|
5056
|
+
const releaseSQL = this.Dialect.ReleaseSavepointSQL(savepointName);
|
|
5057
|
+
if (releaseSQL) {
|
|
5058
|
+
try {
|
|
5059
|
+
await this.ExecuteSQL(releaseSQL, undefined, {
|
|
5060
|
+
description: `Releasing savepoint ${savepointName}`,
|
|
5061
|
+
ignoreLogging: true,
|
|
5062
|
+
});
|
|
5063
|
+
}
|
|
5064
|
+
catch (e) {
|
|
5065
|
+
await this.AbandonPhysicalTransaction();
|
|
5066
|
+
this.markDoomed();
|
|
5067
|
+
this.popDoomedFrame();
|
|
5068
|
+
throw new DoomedTransactionError(undefined, { cause: e });
|
|
5069
|
+
}
|
|
5070
|
+
}
|
|
5071
|
+
this._savepointStack.pop();
|
|
5072
|
+
this._transactionDepth--;
|
|
5073
|
+
}
|
|
5074
|
+
async rollbackTransactionCore() {
|
|
5075
|
+
if (this._doomed) {
|
|
5076
|
+
this.popDoomedFrame();
|
|
5077
|
+
return;
|
|
5078
|
+
}
|
|
5079
|
+
if (!this.HasPhysicalTransaction) {
|
|
5080
|
+
throw new Error('No active transaction to rollback');
|
|
5081
|
+
}
|
|
5082
|
+
if (this._transactionDepth === 0) {
|
|
5083
|
+
throw new Error('Transaction depth mismatch - no transaction to rollback');
|
|
5084
|
+
}
|
|
5085
|
+
if (this._transactionDepth === 1) {
|
|
5086
|
+
try {
|
|
5087
|
+
await this.RollbackPhysicalTransaction();
|
|
5088
|
+
}
|
|
5089
|
+
finally {
|
|
5090
|
+
this.clearTransactionState();
|
|
5091
|
+
}
|
|
5092
|
+
return;
|
|
5093
|
+
}
|
|
5094
|
+
const savepointName = this._savepointStack[this._savepointStack.length - 1];
|
|
5095
|
+
if (!savepointName) {
|
|
5096
|
+
throw new Error('Savepoint stack mismatch - no savepoint to rollback to');
|
|
5097
|
+
}
|
|
5098
|
+
try {
|
|
5099
|
+
await this.ExecuteSQL(this.Dialect.RollbackToSavepointSQL(savepointName), undefined, {
|
|
5100
|
+
description: `Rolling back to savepoint ${savepointName}`,
|
|
5101
|
+
ignoreLogging: true,
|
|
5102
|
+
});
|
|
5103
|
+
const releaseSQL = this.Dialect.ReleaseSavepointSQL(savepointName);
|
|
5104
|
+
if (releaseSQL) {
|
|
5105
|
+
await this.ExecuteSQL(releaseSQL, undefined, {
|
|
5106
|
+
description: `Releasing savepoint ${savepointName} after rollback`,
|
|
5107
|
+
ignoreLogging: true,
|
|
5108
|
+
});
|
|
5109
|
+
}
|
|
5110
|
+
this._savepointStack.pop();
|
|
5111
|
+
this._transactionDepth--;
|
|
5112
|
+
}
|
|
5113
|
+
catch (savepointError) {
|
|
5114
|
+
await this.HandleFailedSavepointRollback(savepointName, savepointError);
|
|
5115
|
+
this.popDoomedFrame();
|
|
5116
|
+
return;
|
|
5117
|
+
}
|
|
5118
|
+
}
|
|
5119
|
+
/**
|
|
5120
|
+
* Drop one doomed frame without SQL. Depth 1 clears the flag so the next
|
|
5121
|
+
* begin is a real outermost. Nested commit-while-doomed uses this too.
|
|
5122
|
+
*/
|
|
5123
|
+
popDoomedFrame() {
|
|
5124
|
+
if (this._transactionDepth <= 1) {
|
|
5125
|
+
this.clearTransactionState();
|
|
5126
|
+
return;
|
|
5127
|
+
}
|
|
5128
|
+
this._savepointStack.pop();
|
|
5129
|
+
this._transactionDepth--;
|
|
5130
|
+
}
|
|
5131
|
+
clearSavepointState() {
|
|
5132
|
+
this._savepointStack = [];
|
|
5133
|
+
this._savepointCounter = 0;
|
|
5134
|
+
}
|
|
5135
|
+
clearTransactionState() {
|
|
5136
|
+
this._transactionDepth = 0;
|
|
5137
|
+
this._doomed = false;
|
|
5138
|
+
this.clearSavepointState();
|
|
5139
|
+
}
|
|
4483
5140
|
}
|
|
4484
5141
|
//# sourceMappingURL=GenericDatabaseProvider.js.map
|