@memberjunction/server 6.2.0-edge.1 → 6.2.0-edge.3
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 +30 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/config.d.ts +2189 -71
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +123 -3
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +11 -23
- package/dist/context.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +2143 -205
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +24441 -14433
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts +15 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.js +16 -2
- package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
- package/dist/generic/OrphanedConversationDetailReconciler.d.ts +31 -0
- package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -0
- package/dist/generic/OrphanedConversationDetailReconciler.js +215 -0
- package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -0
- package/dist/generic/PushStatusResolver.d.ts +38 -0
- package/dist/generic/PushStatusResolver.d.ts.map +1 -1
- package/dist/generic/PushStatusResolver.js +84 -1
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +56 -4
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +191 -74
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +4 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +33 -1
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +204 -50
- package/dist/index.js.map +1 -1
- package/dist/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts +23 -18
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +77 -167
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/AgentRunStatusPublisher.d.ts +55 -0
- package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -0
- package/dist/resolvers/AgentRunStatusPublisher.js +141 -0
- package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -0
- package/dist/resolvers/ConversationRunEventsResolver.d.ts +90 -0
- package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -0
- package/dist/resolvers/ConversationRunEventsResolver.js +283 -0
- package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -0
- package/dist/resolvers/DuplicateEntryCheckResolver.d.ts +98 -0
- package/dist/resolvers/DuplicateEntryCheckResolver.d.ts.map +1 -0
- package/dist/resolvers/DuplicateEntryCheckResolver.js +227 -0
- package/dist/resolvers/DuplicateEntryCheckResolver.js.map +1 -0
- package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +9 -47
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +2 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +9 -0
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
- package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
- package/dist/resolvers/HumanHandoffResolver.js +324 -0
- package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
- package/dist/resolvers/MeetingResolver.d.ts +69 -0
- package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
- package/dist/resolvers/MeetingResolver.js +381 -0
- package/dist/resolvers/MeetingResolver.js.map +1 -0
- package/dist/resolvers/MergeRecordsResolver.d.ts +7 -0
- package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
- package/dist/resolvers/MergeRecordsResolver.js +16 -2
- package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +34 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.js +202 -7
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +45 -89
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +94 -240
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +0 -16
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +23 -153
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunDecisionResolver.d.ts +149 -0
- package/dist/resolvers/RunDecisionResolver.d.ts.map +1 -0
- package/dist/resolvers/RunDecisionResolver.js +396 -0
- package/dist/resolvers/RunDecisionResolver.js.map +1 -0
- package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.js +14 -10
- package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
- package/dist/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.js.map +1 -0
- package/dist/resolvers/meetings/index.d.ts +5 -0
- package/dist/resolvers/meetings/index.d.ts.map +1 -0
- package/dist/resolvers/meetings/index.js +5 -0
- package/dist/resolvers/meetings/index.js.map +1 -0
- package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
- package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingHelpers.js +212 -0
- package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
- package/dist/resolvers/meetings/meetingMutations.d.ts +12 -0
- package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingMutations.js +418 -0
- package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
- package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
- package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingQueries.js +113 -0
- package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
- package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
- package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingTypes.js +345 -0
- package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +8 -5
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/services/TaskGraphActionRunner.d.ts +8 -0
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +27 -1
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/dist/services/WorkQueueHostService.d.ts +36 -0
- package/dist/services/WorkQueueHostService.d.ts.map +1 -0
- package/dist/services/WorkQueueHostService.js +97 -0
- package/dist/services/WorkQueueHostService.js.map +1 -0
- package/dist/services/workQueueConfig.d.ts +103 -0
- package/dist/services/workQueueConfig.d.ts.map +1 -0
- package/dist/services/workQueueConfig.js +48 -0
- package/dist/services/workQueueConfig.js.map +1 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +95 -92
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
- package/src/__tests__/AgentRunStatusPublisher.test.ts +175 -0
- package/src/__tests__/DuplicateEntryCheckResolver.test.ts +300 -0
- package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
- package/src/__tests__/HumanHandoffResolver.test.ts +233 -0
- package/src/__tests__/MeetingResolver.test.ts +374 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +240 -2
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
- package/src/__tests__/RemoteBrowserActionRecovery.test.ts +7 -1
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +7 -1
- package/src/__tests__/RemoteBrowserSnapshot.test.ts +7 -1
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
- package/src/__tests__/ResolverBase.clientOldValues.test.ts +52 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
- package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
- package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +182 -12
- package/src/__tests__/RunAIAgentResolver.streaming.test.ts +18 -27
- package/src/__tests__/RunDecisionResolver.test.ts +699 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +43 -0
- package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
- package/src/__tests__/WorkQueueHostService.test.ts +133 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/conversationRunEvents.test.ts +399 -0
- package/src/__tests__/orphanedConversationDetailReconciler.test.ts +283 -0
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/pushStatusFanOut.test.ts +206 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/auth/__tests__/backward-compatibility.test.ts +1 -0
- package/src/config.ts +130 -3
- package/src/context.ts +11 -24
- package/src/generated/graphql-schemas/__mj.ts +24183 -17182
- package/src/generic/FireAndForgetHeartbeat.ts +16 -2
- package/src/generic/OrphanedConversationDetailReconciler.ts +267 -0
- package/src/generic/PushStatusResolver.ts +97 -1
- package/src/generic/ResolverBase.ts +220 -76
- package/src/generic/RunViewResolver.ts +33 -2
- package/src/index.ts +228 -51
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/resolvers/AdhocQueryResolver.ts +88 -188
- package/src/resolvers/AgentRunStatusPublisher.ts +175 -0
- package/src/resolvers/ConversationRunEventsResolver.ts +308 -0
- package/src/resolvers/DuplicateEntryCheckResolver.ts +232 -0
- package/src/resolvers/EntityRecordNameResolver.ts +9 -51
- package/src/resolvers/FetchEntityVectorsResolver.ts +2 -1
- package/src/resolvers/GetDataResolver.ts +10 -0
- package/src/resolvers/HumanHandoffResolver.ts +304 -0
- package/src/resolvers/MeetingResolver.ts +438 -0
- package/src/resolvers/MergeRecordsResolver.ts +14 -2
- package/src/resolvers/RealtimeBridgeResolver.ts +227 -13
- package/src/resolvers/RealtimeClientSessionResolver.ts +99 -287
- package/src/resolvers/RunAIAgentResolver.ts +28 -174
- package/src/resolvers/RunDecisionResolver.ts +435 -0
- package/src/resolvers/VectorizeEntityResolver.ts +16 -11
- package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +93 -0
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/resolvers/meetings/index.ts +4 -0
- package/src/resolvers/meetings/meetingHelpers.ts +307 -0
- package/src/resolvers/meetings/meetingMutations.ts +535 -0
- package/src/resolvers/meetings/meetingQueries.ts +172 -0
- package/src/resolvers/meetings/meetingTypes.ts +238 -0
- package/src/rest/ViewOperationsHandler.ts +8 -5
- package/src/services/TaskGraphActionRunner.ts +29 -1
- package/src/services/WorkQueueHostService.ts +129 -0
- package/src/services/workQueueConfig.ts +52 -0
- package/src/types.ts +5 -0
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -50
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- package/src/resolvers/adhoc-query-helpers.ts +0 -67
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
BaseEntityEvent,
|
|
5
5
|
CompositeKey,
|
|
6
6
|
DatabaseProviderBase,
|
|
7
|
+
EntityFieldInfo,
|
|
7
8
|
EntityFieldTSType,
|
|
8
9
|
EntityInfo,
|
|
9
10
|
EntityPermissionType,
|
|
@@ -36,7 +37,7 @@ import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
|
|
|
36
37
|
import { RunViewGenericParams, UserPayload } from '../types.js';
|
|
37
38
|
import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './RunViewResolver.js';
|
|
38
39
|
import { DeleteOptionsInput } from './DeleteOptionsInput.js';
|
|
39
|
-
import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
40
|
+
import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, ReplaceByteArraysWithBase64, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
40
41
|
import { SQLParser } from '@memberjunction/sql-parser';
|
|
41
42
|
import { PostgreSQLDialect, SQLServerDialect, type SQLParserDialect } from '@memberjunction/sql-dialect';
|
|
42
43
|
import { EncryptionEngine } from '@memberjunction/encryption';
|
|
@@ -98,7 +99,10 @@ export class ResolverBase {
|
|
|
98
99
|
return null;
|
|
99
100
|
}
|
|
100
101
|
// Shallow copy up front so every write below lands on our object, never the caller's.
|
|
101
|
-
|
|
102
|
+
// Binary values are base64 strings everywhere above the providers; any byte array that still
|
|
103
|
+
// reaches a resolver (custom code, an external driver) is converted here, because GraphQL's
|
|
104
|
+
// String scalar cannot serialize a Buffer and would fail the whole response.
|
|
105
|
+
dataObject = ReplaceByteArraysWithBase64({ ...dataObject });
|
|
102
106
|
|
|
103
107
|
// for the given entity name provided, check to see if there are any fields
|
|
104
108
|
// where the code name is different from the field name, and for just those
|
|
@@ -565,7 +569,8 @@ export class ResolverBase {
|
|
|
565
569
|
? CompositeKey.FromKeyValuePairs((viewInput.AfterKey as { KeyValuePairs: { FieldName: string; Value: string }[] }).KeyValuePairs)
|
|
566
570
|
: undefined,
|
|
567
571
|
viewInput.BypassCache,
|
|
568
|
-
viewInput.DataSource
|
|
572
|
+
viewInput.DataSource,
|
|
573
|
+
viewInput.IncludeBinaryFields
|
|
569
574
|
);
|
|
570
575
|
}
|
|
571
576
|
else {
|
|
@@ -609,7 +614,8 @@ export class ResolverBase {
|
|
|
609
614
|
viewInput.Aggregates,
|
|
610
615
|
undefined,
|
|
611
616
|
viewInput.BypassCache,
|
|
612
|
-
viewInput.DataSource
|
|
617
|
+
viewInput.DataSource,
|
|
618
|
+
viewInput.IncludeBinaryFields
|
|
613
619
|
);
|
|
614
620
|
} catch (err) {
|
|
615
621
|
console.log(err);
|
|
@@ -656,7 +662,8 @@ export class ResolverBase {
|
|
|
656
662
|
viewInput.Aggregates,
|
|
657
663
|
undefined,
|
|
658
664
|
viewInput.BypassCache,
|
|
659
|
-
viewInput.DataSource
|
|
665
|
+
viewInput.DataSource,
|
|
666
|
+
viewInput.IncludeBinaryFields
|
|
660
667
|
);
|
|
661
668
|
} catch (err) {
|
|
662
669
|
console.log(err);
|
|
@@ -731,6 +738,7 @@ export class ResolverBase {
|
|
|
731
738
|
aggregates: viewInput.Aggregates,
|
|
732
739
|
bypassCache: viewInput.BypassCache,
|
|
733
740
|
dataSource: viewInput.DataSource,
|
|
741
|
+
includeBinaryFields: viewInput.IncludeBinaryFields,
|
|
734
742
|
});
|
|
735
743
|
} catch (err) {
|
|
736
744
|
LogError(err);
|
|
@@ -923,6 +931,7 @@ export class ResolverBase {
|
|
|
923
931
|
clause: string | undefined | null,
|
|
924
932
|
label: string,
|
|
925
933
|
provider?: IMetadataProvider,
|
|
934
|
+
user?: UserInfo,
|
|
926
935
|
): void {
|
|
927
936
|
if (!clause?.trim()) return;
|
|
928
937
|
|
|
@@ -951,13 +960,85 @@ export class ResolverBase {
|
|
|
951
960
|
const table = this.stripSqlIdent(t.TableName);
|
|
952
961
|
const schema = this.stripSqlIdent(t.SchemaName);
|
|
953
962
|
if (table.toLowerCase() === '__mj_clause_screen') continue;
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
963
|
+
this.assertTableRefReadable(allowed, schema, table, label, user);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
|
|
969
|
+
* to the SELECT-only validation the caller performs, every table reference must resolve to an
|
|
970
|
+
* entity BaseView the acting user can read UNSCOPED. CTE names defined by the statement itself
|
|
971
|
+
* are excluded from the check. Fails closed: no user, or an unresolvable reference, refuses.
|
|
972
|
+
*
|
|
973
|
+
* Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
|
|
974
|
+
* row-level security and denied-field projection, and raw SQL applies neither. Rather than try
|
|
975
|
+
* to rewrite arbitrary SQL (joins, CTEs) to compose that policy, this refuses any entity that
|
|
976
|
+
* carries a row filter or a denied field for the caller — those reads belong on RunView. For
|
|
977
|
+
* the remaining entities CanRead genuinely means "every row and field of the view".
|
|
978
|
+
*/
|
|
979
|
+
protected assertFullQueryUsesReadableEntityViews(
|
|
980
|
+
sqlText: string,
|
|
981
|
+
provider: IMetadataProvider | undefined,
|
|
982
|
+
user: UserInfo | undefined,
|
|
983
|
+
label = 'SQL',
|
|
984
|
+
): void {
|
|
985
|
+
if (!user) {
|
|
986
|
+
throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
|
|
987
|
+
}
|
|
988
|
+
const dialect = this.dialectForProvider(provider);
|
|
989
|
+
const parser = new SQLParser(sqlText, dialect);
|
|
990
|
+
if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
|
|
991
|
+
throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
|
|
992
|
+
}
|
|
993
|
+
if (this.astContainsWriteNode(parser.AST)) {
|
|
994
|
+
throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
|
|
995
|
+
}
|
|
996
|
+
const cteNames = new Set<string>();
|
|
997
|
+
this.collectCTENames(parser.AST, cteNames);
|
|
998
|
+
const allowed = this.entityBaseViewAllowList(provider);
|
|
999
|
+
const tables = SQLParser.ExtractTableRefs(sqlText, dialect);
|
|
1000
|
+
for (const t of tables) {
|
|
1001
|
+
const table = this.stripSqlIdent(t.TableName);
|
|
1002
|
+
const schema = this.stripSqlIdent(t.SchemaName);
|
|
1003
|
+
if (this.isUnqualifiedSchema(schema) && cteNames.has(table.toLowerCase())) continue;
|
|
1004
|
+
const entity = this.assertTableRefReadable(allowed, schema, table, label, user);
|
|
1005
|
+
this.assertUnscopedRead(entity, user, label);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** Refuses an entity whose read is narrowed for this user by row-level security or field-level denials. */
|
|
1010
|
+
private assertUnscopedRead(entity: EntityInfo, user: UserInfo, label: string): void {
|
|
1011
|
+
if (entity.GetEffectiveRowFilterWhereClause(user, EntityPermissionType.Read, '').length > 0) {
|
|
957
1012
|
throw new Error(
|
|
958
|
-
`Invalid ${label}:
|
|
1013
|
+
`Invalid ${label}: entity '${entity.Name}' is row-level-security filtered for you — ad-hoc SQL cannot apply that filter; use RunView`,
|
|
959
1014
|
);
|
|
960
1015
|
}
|
|
1016
|
+
if (entity.GetDeniedReadFields(user).size > 0) {
|
|
1017
|
+
throw new Error(
|
|
1018
|
+
`Invalid ${label}: entity '${entity.Name}' has fields you are not permitted to read — ad-hoc SQL cannot project them away; use RunView`,
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** Walks a parsed AST collecting the names of CTEs the statement itself defines. */
|
|
1024
|
+
private collectCTENames(node: unknown, names: Set<string>): void {
|
|
1025
|
+
if (!node || typeof node !== 'object') return;
|
|
1026
|
+
if (Array.isArray(node)) {
|
|
1027
|
+
for (const n of node) this.collectCTENames(n, names);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const obj = node as Record<string, unknown>;
|
|
1031
|
+
const withClause = obj.with;
|
|
1032
|
+
if (Array.isArray(withClause)) {
|
|
1033
|
+
for (const cte of withClause) {
|
|
1034
|
+
const name = (cte as Record<string, unknown>)?.name;
|
|
1035
|
+
if (typeof name === 'string') names.add(name.toLowerCase());
|
|
1036
|
+
else if (name && typeof name === 'object' && typeof (name as Record<string, unknown>).value === 'string') {
|
|
1037
|
+
names.add(((name as Record<string, unknown>).value as string).toLowerCase());
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
for (const v of Object.values(obj)) this.collectCTENames(v, names);
|
|
961
1042
|
}
|
|
962
1043
|
|
|
963
1044
|
/**
|
|
@@ -990,10 +1071,11 @@ export class ResolverBase {
|
|
|
990
1071
|
overrideExcludeFilter?: string | null;
|
|
991
1072
|
},
|
|
992
1073
|
provider?: IMetadataProvider,
|
|
1074
|
+
user?: UserInfo,
|
|
993
1075
|
): void {
|
|
994
|
-
this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider);
|
|
995
|
-
this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider);
|
|
996
|
-
this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider);
|
|
1076
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider, user);
|
|
1077
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider, user);
|
|
1078
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider, user);
|
|
997
1079
|
}
|
|
998
1080
|
|
|
999
1081
|
/** Same write-node walk as EDS `sqlReadOnlyScreen.astContainsWriteNode`. */
|
|
@@ -1019,28 +1101,84 @@ export class ResolverBase {
|
|
|
1019
1101
|
return new SQLServerDialect();
|
|
1020
1102
|
}
|
|
1021
1103
|
|
|
1104
|
+
/** True for a reference with no schema — which `SQLParser.ExtractTableRefs` reports as `dbo`. */
|
|
1105
|
+
private isUnqualifiedSchema(schema: string): boolean {
|
|
1106
|
+
return !schema || schema.toLowerCase() === 'dbo';
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1022
1109
|
private stripSqlIdent(name: string | null | undefined): string {
|
|
1023
1110
|
if (!name) return '';
|
|
1024
1111
|
return name.replace(/^\[|\]$/g, '').replace(/^"|"$/g, '').replace(/^`|`$/g, '');
|
|
1025
1112
|
}
|
|
1026
1113
|
|
|
1114
|
+
/**
|
|
1115
|
+
* `bare` maps an unqualified view name to its entity, or to `null` when the name exists in
|
|
1116
|
+
* more than one schema — the database, not this screen, would decide which one runs.
|
|
1117
|
+
*/
|
|
1027
1118
|
private entityBaseViewAllowList(provider?: IMetadataProvider): {
|
|
1028
|
-
qualified:
|
|
1029
|
-
bare:
|
|
1119
|
+
qualified: Map<string, EntityInfo>;
|
|
1120
|
+
bare: Map<string, EntityInfo | null>;
|
|
1030
1121
|
} {
|
|
1031
|
-
const qualified = new
|
|
1032
|
-
const bare = new
|
|
1122
|
+
const qualified = new Map<string, EntityInfo>();
|
|
1123
|
+
const bare = new Map<string, EntityInfo | null>();
|
|
1033
1124
|
const entities = provider?.Entities ?? [];
|
|
1034
1125
|
for (const e of entities) {
|
|
1035
1126
|
const view = this.stripSqlIdent(e.BaseView);
|
|
1036
1127
|
if (!view) continue;
|
|
1037
1128
|
const schema = this.stripSqlIdent(e.SchemaName);
|
|
1038
|
-
|
|
1039
|
-
|
|
1129
|
+
const bareKey = view.toLowerCase();
|
|
1130
|
+
bare.set(bareKey, bare.has(bareKey) ? null : e);
|
|
1131
|
+
if (schema) qualified.set(`${schema}.${view}`.toLowerCase(), e);
|
|
1040
1132
|
}
|
|
1041
1133
|
return { qualified, bare };
|
|
1042
1134
|
}
|
|
1043
1135
|
|
|
1136
|
+
/**
|
|
1137
|
+
* SECURITY — resolves a table reference against the entity BaseView allow-list and, when an
|
|
1138
|
+
* acting user is supplied, additionally requires that user to hold CanRead on the referenced
|
|
1139
|
+
* entity. Base views do not embed RLS and entity permissions are otherwise checked only on
|
|
1140
|
+
* the TOP entity of a request, so without this check a subquery (or ad-hoc query) could read
|
|
1141
|
+
* entities the caller has no read grant on.
|
|
1142
|
+
*
|
|
1143
|
+
* A schema-qualified reference must match that exact schema — `secret.vwFoo` never resolves
|
|
1144
|
+
* through another schema's `vwFoo`. An unqualified reference resolves only when the view name
|
|
1145
|
+
* is unique across schemas; an ambiguous one must be qualified, because the database's
|
|
1146
|
+
* default-schema resolution, not this screen, would pick which view actually runs.
|
|
1147
|
+
*
|
|
1148
|
+
* `SQLParser.ExtractTableRefs` reports an unqualified reference as schema `dbo`, so `dbo` is
|
|
1149
|
+
* indistinguishable from "no schema" here and both take the unqualified path (an exact
|
|
1150
|
+
* `dbo.<view>` entity wins first).
|
|
1151
|
+
*/
|
|
1152
|
+
private assertTableRefReadable(
|
|
1153
|
+
allowed: { qualified: Map<string, EntityInfo>; bare: Map<string, EntityInfo | null> },
|
|
1154
|
+
schema: string,
|
|
1155
|
+
table: string,
|
|
1156
|
+
label: string,
|
|
1157
|
+
user?: UserInfo,
|
|
1158
|
+
): EntityInfo {
|
|
1159
|
+
const qualified = allowed.qualified.get(`${schema}.${table}`.toLowerCase());
|
|
1160
|
+
const entity = this.isUnqualifiedSchema(schema) ? qualified ?? allowed.bare.get(table.toLowerCase()) : qualified;
|
|
1161
|
+
if (entity === null) {
|
|
1162
|
+
throw new Error(
|
|
1163
|
+
`Invalid ${label}: '${table}' is a base view in more than one schema — qualify it with its schema`,
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
if (!entity) {
|
|
1167
|
+
throw new Error(
|
|
1168
|
+
`Invalid ${label}: subquery must use an entity base view, not '${schema ? schema + '.' : ''}${table}'`,
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
if (user) {
|
|
1172
|
+
const perms = entity.GetUserPermisions(user);
|
|
1173
|
+
if (!perms.CanRead) {
|
|
1174
|
+
throw new Error(
|
|
1175
|
+
`Invalid ${label}: you do not have read permission on entity '${entity.Name}' referenced by '${schema ? schema + '.' : ''}${table}'`,
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
return entity;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1044
1182
|
/**
|
|
1045
1183
|
* Optimized RunViewGenericInternal implementation with:
|
|
1046
1184
|
* - Field filtering at source (Fix #7)
|
|
@@ -1067,29 +1205,33 @@ export class ResolverBase {
|
|
|
1067
1205
|
aggregates?: AggregateExpression[],
|
|
1068
1206
|
afterKey?: CompositeKey,
|
|
1069
1207
|
bypassCache?: boolean,
|
|
1070
|
-
dataSource?: 'Live' | 'Materialized'
|
|
1208
|
+
dataSource?: 'Live' | 'Materialized',
|
|
1209
|
+
includeBinaryFields?: boolean
|
|
1071
1210
|
) {
|
|
1072
1211
|
try {
|
|
1073
1212
|
if (!viewInfo || !userPayload) return null;
|
|
1074
1213
|
|
|
1214
|
+
// Prefer the authenticated session's payload user — it is the authoritative per-request
|
|
1215
|
+
// identity and (for magic-link sessions) carries the per-session resource scope / synthesized
|
|
1216
|
+
// roles that drive RLS. The cached lookup is a fallback for paths where the payload user
|
|
1217
|
+
// isn't populated. For normal users the two are the same instance, so this is a no-op.
|
|
1218
|
+
const user = this.GetUserFromPayload(userPayload)
|
|
1219
|
+
?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
|
|
1220
|
+
if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
|
|
1221
|
+
|
|
1075
1222
|
// SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
|
|
1076
|
-
// Screen at this boundary: parse, reject writes, allow only entity BaseViews
|
|
1223
|
+
// Screen at this boundary: parse, reject writes, allow only entity BaseViews the
|
|
1224
|
+
// acting user can read (subqueries do not inherit the top entity's permission check).
|
|
1077
1225
|
this.screenClientViewClauses(
|
|
1078
1226
|
{ extraFilter, orderBy, userSearchString, overrideExcludeFilter },
|
|
1079
1227
|
provider as unknown as IMetadataProvider,
|
|
1228
|
+
user,
|
|
1080
1229
|
);
|
|
1081
1230
|
|
|
1082
1231
|
// Check API key scope authorization for view operations
|
|
1083
1232
|
await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
|
|
1084
1233
|
|
|
1085
1234
|
const md = provider
|
|
1086
|
-
// Prefer the authenticated session's payload user — it is the authoritative per-request
|
|
1087
|
-
// identity and (for magic-link sessions) carries the per-session resource scope / synthesized
|
|
1088
|
-
// roles that drive RLS. The cached lookup is a fallback for paths where the payload user
|
|
1089
|
-
// isn't populated. For normal users the two are the same instance, so this is a no-op.
|
|
1090
|
-
const user = this.GetUserFromPayload(userPayload)
|
|
1091
|
-
?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
|
|
1092
|
-
if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
|
|
1093
1235
|
|
|
1094
1236
|
const entityInfo = md.Entities.find((e) => e.Name === viewInfo.Entity);
|
|
1095
1237
|
if (!entityInfo) throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
|
|
@@ -1145,6 +1287,7 @@ export class ResolverBase {
|
|
|
1145
1287
|
Aggregates: aggregates,
|
|
1146
1288
|
BypassCache: bypassCache,
|
|
1147
1289
|
DataSource: dataSource,
|
|
1290
|
+
IncludeBinaryFields: includeBinaryFields,
|
|
1148
1291
|
},
|
|
1149
1292
|
user
|
|
1150
1293
|
);
|
|
@@ -1249,6 +1392,7 @@ export class ResolverBase {
|
|
|
1249
1392
|
overrideExcludeFilter: param.overrideExcludeFilter,
|
|
1250
1393
|
},
|
|
1251
1394
|
md,
|
|
1395
|
+
contextUser ?? undefined,
|
|
1252
1396
|
);
|
|
1253
1397
|
|
|
1254
1398
|
if (param.viewInfo) {
|
|
@@ -1292,6 +1436,7 @@ export class ResolverBase {
|
|
|
1292
1436
|
Aggregates: param.aggregates,
|
|
1293
1437
|
BypassCache: param.bypassCache,
|
|
1294
1438
|
DataSource: param.dataSource,
|
|
1439
|
+
IncludeBinaryFields: param.includeBinaryFields,
|
|
1295
1440
|
});
|
|
1296
1441
|
}
|
|
1297
1442
|
|
|
@@ -1692,7 +1837,7 @@ export class ResolverBase {
|
|
|
1692
1837
|
await this.TestAndSetClientOldValuesToDBValues(input, clientNewValues, entityObject, userInfo);
|
|
1693
1838
|
} else {
|
|
1694
1839
|
// no OldValues, so we can just set the new values from input
|
|
1695
|
-
entityObject.SetMany(
|
|
1840
|
+
entityObject.SetMany(clientNewValues);
|
|
1696
1841
|
}
|
|
1697
1842
|
} else {
|
|
1698
1843
|
// Use a generic message to avoid leaking whether a record exists — distinguishing
|
|
@@ -1704,8 +1849,11 @@ export class ResolverBase {
|
|
|
1704
1849
|
} else {
|
|
1705
1850
|
// we get here if we are NOT tracking changes and we DO have OldValues, so we can load from them
|
|
1706
1851
|
const oldValues = {};
|
|
1707
|
-
// for each item in the oldValues array, add it to the oldValues object
|
|
1708
|
-
input.OldValues___?.forEach((item) =>
|
|
1852
|
+
// for each item in the oldValues array, add it to the oldValues object, typed like the field
|
|
1853
|
+
input.OldValues___?.forEach((item) => {
|
|
1854
|
+
const field = entityObject.EntityInfo.Fields.find((f) => f.CodeName === item.Key);
|
|
1855
|
+
oldValues[item.Key] = field ? this.ClientOldValueToFieldValue(field, item.Value) : item.Value;
|
|
1856
|
+
});
|
|
1709
1857
|
|
|
1710
1858
|
// 1) load the old values, this will be the initial state of the object
|
|
1711
1859
|
await entityObject.LoadFromData(oldValues);
|
|
@@ -1772,6 +1920,11 @@ export class ResolverBase {
|
|
|
1772
1920
|
*
|
|
1773
1921
|
* Ordered so the boolean flag is evaluated last: the extra load lands only on entities that have
|
|
1774
1922
|
* the feature switched on, which is almost none of them.
|
|
1923
|
+
*
|
|
1924
|
+
* `MJ: Record Changes` always loads from the database: its server class allows an update only when
|
|
1925
|
+
* Comments is the one dirty field, and "dirty" compared against client-supplied OldValues lets a
|
|
1926
|
+
* caller pin forged audit columns as both old and new values. It doesn't track its own changes, so
|
|
1927
|
+
* nothing else forces the load.
|
|
1775
1928
|
*/
|
|
1776
1929
|
protected MustLoadTruthFromDatabase(
|
|
1777
1930
|
entityInfo: EntityInfo,
|
|
@@ -1781,6 +1934,7 @@ export class ResolverBase {
|
|
|
1781
1934
|
): boolean {
|
|
1782
1935
|
return (
|
|
1783
1936
|
entityInfo.TrackRecordChanges ||
|
|
1937
|
+
entityInfo.Name.trim().toLowerCase() === 'mj: record changes' ||
|
|
1784
1938
|
!input.OldValues___ ||
|
|
1785
1939
|
hasDeniedReadFields ||
|
|
1786
1940
|
hasNarrowedAuditPayload ||
|
|
@@ -1917,6 +2071,41 @@ export class ResolverBase {
|
|
|
1917
2071
|
return true;
|
|
1918
2072
|
}
|
|
1919
2073
|
|
|
2074
|
+
/**
|
|
2075
|
+
* Converts one client-sent old value (always a string on the wire; dates as epoch milliseconds,
|
|
2076
|
+
* the GraphQL Timestamp form) to the field's TypeScript type, so it compares equal to the stored
|
|
2077
|
+
* value. Both the OldValues comparison and the load-from-OldValues path use it: without it a date
|
|
2078
|
+
* old value becomes an Invalid Date and an unchanged date field reads as edited.
|
|
2079
|
+
*/
|
|
2080
|
+
protected ClientOldValueToFieldValue(field: EntityFieldInfo | undefined, raw: unknown): string | number | boolean | Date | null {
|
|
2081
|
+
let val: unknown = raw;
|
|
2082
|
+
if ((val === null || val === undefined) && field && field.DefaultValue !== null && field.DefaultValue !== undefined && !field.AllowsNull)
|
|
2083
|
+
val = field.DefaultValue; // set default value as the field was never set and it does NOT allow nulls
|
|
2084
|
+
if (val === undefined) val = null;
|
|
2085
|
+
// A null old value stays null, except a boolean, which the comparison has always read as false.
|
|
2086
|
+
if (field?.TSType === EntityFieldTSType.Boolean && val === null) return false;
|
|
2087
|
+
if (val === null) return null;
|
|
2088
|
+
if (!field) {
|
|
2089
|
+
// No field metadata to convert by: pass scalars through, stringify anything else.
|
|
2090
|
+
return typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean' || val instanceof Date ? val : String(val);
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
const text = String(val);
|
|
2094
|
+
switch (field.TSType) {
|
|
2095
|
+
case EntityFieldTSType.Number: {
|
|
2096
|
+
const integer = ['int', 'smallint', 'bigint', 'tinyint'].includes((field.Type as string).toLowerCase());
|
|
2097
|
+
return integer ? parseInt(text) : parseFloat(text);
|
|
2098
|
+
}
|
|
2099
|
+
case EntityFieldTSType.Boolean:
|
|
2100
|
+
return !(text === 'false' || text === '0' || parseInt(text) === 0);
|
|
2101
|
+
case EntityFieldTSType.Date:
|
|
2102
|
+
// Epoch milliseconds (the GraphQL Timestamp form) arrive as a numeric string.
|
|
2103
|
+
return new Date(text.trim() !== '' && !isNaN(Number(text)) ? parseInt(text) : text);
|
|
2104
|
+
default:
|
|
2105
|
+
return text; // already a string
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
|
|
1920
2109
|
/**
|
|
1921
2110
|
* This routine compares the OldValues property in the input object to the values in the DB that we just loaded. If there are differences, we need to check to see if the client
|
|
1922
2111
|
* is trying to update any of those fields (e.g. overlap). If there is overlap, we throw an error. If there is no overlap, we can proceed with the update even if the DB Values
|
|
@@ -1931,52 +2120,7 @@ export class ResolverBase {
|
|
|
1931
2120
|
input.OldValues___.forEach((item) => {
|
|
1932
2121
|
// we need to do a quick transform on the values to make sure they match the TS Type for the given field because item.Value will always be a string
|
|
1933
2122
|
const field = entityObject.EntityInfo.Fields.find((f) => f.CodeName === item.Key);
|
|
1934
|
-
|
|
1935
|
-
if ((val === null || val === undefined) && field.DefaultValue !== null && field.DefaultValue !== undefined && !field.AllowsNull)
|
|
1936
|
-
val = field.DefaultValue; // set default value as the field was never set and it does NOT allow nulls
|
|
1937
|
-
|
|
1938
|
-
if (field) {
|
|
1939
|
-
switch (field.TSType) {
|
|
1940
|
-
case EntityFieldTSType.Number:
|
|
1941
|
-
if (val == null && val == undefined) {
|
|
1942
|
-
val = null;
|
|
1943
|
-
}
|
|
1944
|
-
else {
|
|
1945
|
-
let typeLowered = (field.Type as string).toLowerCase();
|
|
1946
|
-
|
|
1947
|
-
switch (typeLowered) {
|
|
1948
|
-
case 'int':
|
|
1949
|
-
case 'smallint':
|
|
1950
|
-
case 'bigint':
|
|
1951
|
-
case 'tinyint':
|
|
1952
|
-
val = parseInt(val);
|
|
1953
|
-
break;
|
|
1954
|
-
case 'money':
|
|
1955
|
-
case 'smallmoney':
|
|
1956
|
-
case 'decimal':
|
|
1957
|
-
case 'numeric':
|
|
1958
|
-
case 'float':
|
|
1959
|
-
val = parseFloat(val);
|
|
1960
|
-
break;
|
|
1961
|
-
default:
|
|
1962
|
-
val = parseFloat(val);
|
|
1963
|
-
break;
|
|
1964
|
-
}
|
|
1965
|
-
}
|
|
1966
|
-
break;
|
|
1967
|
-
case EntityFieldTSType.Boolean:
|
|
1968
|
-
val = val === null || val === undefined || val === 'false' || val === '0' || parseInt(val) === 0 ? false : true;
|
|
1969
|
-
break;
|
|
1970
|
-
case EntityFieldTSType.Date:
|
|
1971
|
-
// first, if val is a string and it is actually a number (milliseconds since epoch), convert it to a number.
|
|
1972
|
-
if (val !== null && val !== undefined && val.toString().trim() !== '' && !isNaN(val)) val = parseInt(val);
|
|
1973
|
-
|
|
1974
|
-
val = val !== null && val !== undefined ? new Date(val) : null;
|
|
1975
|
-
break;
|
|
1976
|
-
default:
|
|
1977
|
-
break; // already a string
|
|
1978
|
-
}
|
|
1979
|
-
}
|
|
2123
|
+
const val = this.ClientOldValueToFieldValue(field, item.Value);
|
|
1980
2124
|
clientOldValues[item.Key] = val;
|
|
1981
2125
|
});
|
|
1982
2126
|
|
|
@@ -3,7 +3,7 @@ import { AppContext } from '../types.js';
|
|
|
3
3
|
import { ResolverBase } from './ResolverBase.js';
|
|
4
4
|
import { LogError, LogStatus, EntityInfo, FieldSecurityError, RunViewWithCacheCheckResult, RunViewsWithCacheCheckResponse, RunViewWithCacheCheckParams, AggregateResult, CompositeKey } from '@memberjunction/core';
|
|
5
5
|
|
|
6
|
-
import { UUIDsEqual } from '@memberjunction/global';
|
|
6
|
+
import { ReplaceByteArraysWithBase64, UUIDsEqual } from '@memberjunction/global';
|
|
7
7
|
import { RequireSystemUser } from '../directives/RequireSystemUser.js';
|
|
8
8
|
import { GetReadOnlyProvider } from '../util.js';
|
|
9
9
|
import { MJUserViewEntityExtended } from '@memberjunction/core-entities';
|
|
@@ -200,6 +200,13 @@ export class RunViewByIDInput {
|
|
|
200
200
|
"Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
|
|
201
201
|
})
|
|
202
202
|
DataSource?: 'Live' | 'Materialized';
|
|
203
|
+
|
|
204
|
+
@Field(() => Boolean, {
|
|
205
|
+
nullable: true,
|
|
206
|
+
description:
|
|
207
|
+
'Optional, when true the binary fields of the entity (varbinary / bytea columns) are returned, as base64 strings. They are omitted by default because they are usually large and lists rarely read them. A binary field named explicitly in Fields is always returned.',
|
|
208
|
+
})
|
|
209
|
+
IncludeBinaryFields?: boolean;
|
|
203
210
|
}
|
|
204
211
|
|
|
205
212
|
@InputType()
|
|
@@ -321,6 +328,13 @@ export class RunViewByNameInput {
|
|
|
321
328
|
"Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
|
|
322
329
|
})
|
|
323
330
|
DataSource?: 'Live' | 'Materialized';
|
|
331
|
+
|
|
332
|
+
@Field(() => Boolean, {
|
|
333
|
+
nullable: true,
|
|
334
|
+
description:
|
|
335
|
+
'Optional, when true the binary fields of the entity (varbinary / bytea columns) are returned, as base64 strings. They are omitted by default because they are usually large and lists rarely read them. A binary field named explicitly in Fields is always returned.',
|
|
336
|
+
})
|
|
337
|
+
IncludeBinaryFields?: boolean;
|
|
324
338
|
}
|
|
325
339
|
|
|
326
340
|
@InputType()
|
|
@@ -428,6 +442,13 @@ export class RunDynamicViewInput {
|
|
|
428
442
|
"Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
|
|
429
443
|
})
|
|
430
444
|
DataSource?: 'Live' | 'Materialized';
|
|
445
|
+
|
|
446
|
+
@Field(() => Boolean, {
|
|
447
|
+
nullable: true,
|
|
448
|
+
description:
|
|
449
|
+
'Optional, when true the binary fields of the entity (varbinary / bytea columns) are returned, as base64 strings. They are omitted by default because they are usually large and lists rarely read them. A binary field named explicitly in Fields is always returned.',
|
|
450
|
+
})
|
|
451
|
+
IncludeBinaryFields?: boolean;
|
|
431
452
|
}
|
|
432
453
|
|
|
433
454
|
@InputType()
|
|
@@ -564,6 +585,13 @@ export class RunViewGenericInput {
|
|
|
564
585
|
"Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
|
|
565
586
|
})
|
|
566
587
|
DataSource?: 'Live' | 'Materialized';
|
|
588
|
+
|
|
589
|
+
@Field(() => Boolean, {
|
|
590
|
+
nullable: true,
|
|
591
|
+
description:
|
|
592
|
+
'Optional, when true the binary fields of the entity (varbinary / bytea columns) are returned, as base64 strings. They are omitted by default because they are usually large and lists rarely read them. A binary field named explicitly in Fields is always returned.',
|
|
593
|
+
})
|
|
594
|
+
IncludeBinaryFields?: boolean;
|
|
567
595
|
}
|
|
568
596
|
|
|
569
597
|
//****************************************************************************
|
|
@@ -1219,6 +1247,7 @@ export class RunViewResolver extends ResolverBase {
|
|
|
1219
1247
|
// DataSource must be forwarded on this transport too (same as the InternalRunView/RunViews maps):
|
|
1220
1248
|
// otherwise a CacheLocal batch's DataSource:'Materialized' request silently reads the live view.
|
|
1221
1249
|
DataSource: item.params.DataSource,
|
|
1250
|
+
IncludeBinaryFields: item.params.IncludeBinaryFields,
|
|
1222
1251
|
AfterKey: item.params.AfterKey
|
|
1223
1252
|
? CompositeKey.FromKeyValuePairs(item.params.AfterKey.KeyValuePairs)
|
|
1224
1253
|
: undefined,
|
|
@@ -1322,7 +1351,9 @@ export class RunViewResolver extends ResolverBase {
|
|
|
1322
1351
|
returnResult.push({
|
|
1323
1352
|
PrimaryKey: primaryKey,
|
|
1324
1353
|
EntityID: entityId,
|
|
1325
|
-
|
|
1354
|
+
// Binary columns arrive as base64 from the providers; this is the safety net for any
|
|
1355
|
+
// row that still holds a byte array, which JSON.stringify would emit as {type,data}.
|
|
1356
|
+
Data: JSON.stringify(ReplaceByteArraysWithBase64(row)),
|
|
1326
1357
|
});
|
|
1327
1358
|
}
|
|
1328
1359
|
return returnResult;
|