@memberjunction/server 5.35.0 → 5.37.0

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.
@@ -358,13 +358,25 @@ export class SkipProxyAgent extends BaseAgent {
358
358
  const skipMessage = response.messages?.filter(msg => msg.role === 'system').pop();
359
359
  const analysisText = response.analysis?.trim();
360
360
 
361
+ // Skip-Brain attaches actionableCommands as an extension field on the
362
+ // analysis_complete response (e.g. `client:capture-data-snapshot` when
363
+ // the Analysis Agent needs the user's current view). Forward to the
364
+ // next step so AgentRunner persists them to ConversationDetail.ActionableCommands
365
+ // — which the chat UI's actionable-commands.component reads to render buttons.
366
+ const responseExt = response as unknown as { actionableCommands?: unknown[] };
367
+ const actionableCommands = Array.isArray(responseExt.actionableCommands) && responseExt.actionableCommands.length > 0
368
+ ? (responseExt.actionableCommands as BaseAgentNextStep<ComponentSpec>['actionableCommands'])
369
+ : undefined;
370
+ LogStatus(`[SkipProxyAgent DEBUG] handleAnalysisComplete actionableCommands from response: ${JSON.stringify(responseExt.actionableCommands)}`);
371
+
361
372
  if (!hasComponentOptions) {
362
373
  if (analysisText || skipMessage?.content) {
363
374
  return {
364
375
  terminate: true,
365
376
  step: 'Success',
366
377
  message: analysisText || skipMessage!.content,
367
- newPayload: undefined
378
+ newPayload: undefined,
379
+ actionableCommands
368
380
  };
369
381
  }
370
382
 
@@ -387,7 +399,8 @@ export class SkipProxyAgent extends BaseAgent {
387
399
  terminate: true,
388
400
  step: 'Success',
389
401
  message: skipMessage?.content || response.title || 'Analysis complete',
390
- newPayload: componentSpec
402
+ newPayload: componentSpec,
403
+ actionableCommands
391
404
  };
392
405
  }
393
406
 
@@ -24,7 +24,8 @@ import {
24
24
  SkipAPIArtifactType
25
25
  } from '@memberjunction/skip-types';
26
26
  import { DataContext } from '@memberjunction/data-context';
27
- import { IMetadataProvider, UserInfo, LogStatus, LogError, Metadata, RunQuery, EntityInfo, EntityFieldInfo, EntityFieldValueInfo, DatabaseProviderBase } from '@memberjunction/core';
27
+ import { IMetadataProvider, UserInfo, LogStatus, LogError, Metadata, RunQuery, RunView, EntityInfo, EntityFieldInfo, EntityFieldValueInfo, DatabaseProviderBase } from '@memberjunction/core';
28
+ import { MJConversationDetailEntity } from '@memberjunction/core-entities';
28
29
  import { request as httpRequest } from 'http';
29
30
  import { request as httpsRequest } from 'https';
30
31
  import { gzip as gzipCompress, createGunzip } from 'zlib';
@@ -682,6 +683,16 @@ export class SkipSDK {
682
683
  versions: entry.versions
683
684
  }));
684
685
 
686
+ // Also include INPUT artifacts (e.g., user-captured Data Snapshots
687
+ // attached to messages via the Analyze button or
688
+ // client:capture-data-snapshot actionable command). The query above
689
+ // only returns Direction='Output' artifacts produced BY Skip — but
690
+ // Skip also needs to see artifacts the user gave it as input.
691
+ const inputArtifacts = await this.buildInputArtifacts(contextUser, conversationId, artifactMap);
692
+ if (inputArtifacts.length > 0) {
693
+ artifacts.push(...inputArtifacts);
694
+ }
695
+
685
696
  return artifacts;
686
697
  } catch (error) {
687
698
  LogError(`Failed to build artifacts for conversation ${conversationId}: ${error}`);
@@ -689,6 +700,142 @@ export class SkipSDK {
689
700
  }
690
701
  }
691
702
 
703
+ /**
704
+ * Fetch INPUT artifacts attached to user messages in this conversation
705
+ * (Direction='Input' on ConversationDetailArtifact). Skip should see these
706
+ * so it can use captured Data Snapshots, user-uploaded files, etc., in
707
+ * its analysis.
708
+ *
709
+ * Deduplicates against `alreadyLoaded` (the Output artifacts) so the same
710
+ * artifact ID doesn't appear twice if it was both produced and re-attached.
711
+ */
712
+ private async buildInputArtifacts(
713
+ contextUser: UserInfo,
714
+ conversationId: string,
715
+ alreadyLoaded: Map<string, { artifact: any; artifactType: SkipAPIArtifactType; versions: SkipAPIArtifactVersion[] }>
716
+ ): Promise<SkipAPIArtifact[]> {
717
+ try {
718
+ const rv = new RunView();
719
+ // Pull conversation detail IDs in this conversation
720
+ const detailsResult = await rv.RunView<MJConversationDetailEntity>({
721
+ EntityName: 'MJ: Conversation Details',
722
+ ExtraFilter: `ConversationID='${conversationId}'`,
723
+ Fields: ['ID'],
724
+ ResultType: 'simple',
725
+ }, contextUser);
726
+ const detailIds = detailsResult.Success && detailsResult.Results
727
+ ? (detailsResult.Results as { ID: string }[]).map(r => r.ID)
728
+ : [];
729
+ if (detailIds.length === 0) return [];
730
+
731
+ // Junction rows where Direction='Input' for those details
732
+ const junctionResult = await rv.RunView({
733
+ EntityName: 'MJ: Conversation Detail Artifacts',
734
+ ExtraFilter: `ConversationDetailID IN ('${detailIds.join("','")}') AND Direction='Input'`,
735
+ Fields: ['ArtifactVersionID', 'ConversationDetailID'],
736
+ ResultType: 'simple',
737
+ }, contextUser);
738
+ const junctions = junctionResult.Success && junctionResult.Results
739
+ ? (junctionResult.Results as { ArtifactVersionID: string; ConversationDetailID: string }[])
740
+ : [];
741
+ if (junctions.length === 0) return [];
742
+
743
+ // Load each ArtifactVersion + its parent Artifact + ArtifactType
744
+ const versionIds = [...new Set(junctions.map(j => j.ArtifactVersionID))];
745
+ const versionsResult = await rv.RunView({
746
+ EntityName: 'MJ: Artifact Versions',
747
+ ExtraFilter: `ID IN ('${versionIds.join("','")}')`,
748
+ ResultType: 'simple',
749
+ }, contextUser);
750
+ const versions = versionsResult.Success && versionsResult.Results
751
+ ? (versionsResult.Results as Record<string, any>[])
752
+ : [];
753
+ if (versions.length === 0) return [];
754
+
755
+ const artifactIds = [...new Set(versions.map(v => v.ArtifactID as string))];
756
+ const artifactsResult = await rv.RunView({
757
+ EntityName: 'MJ: Artifacts',
758
+ ExtraFilter: `ID IN ('${artifactIds.join("','")}')`,
759
+ ResultType: 'simple',
760
+ }, contextUser);
761
+ const artifactRows = artifactsResult.Success && artifactsResult.Results
762
+ ? (artifactsResult.Results as Record<string, any>[])
763
+ : [];
764
+
765
+ const typeIds = [...new Set(artifactRows.map(a => a.TypeID as string))];
766
+ const typesResult = await rv.RunView({
767
+ EntityName: 'MJ: Artifact Types',
768
+ ExtraFilter: `ID IN ('${typeIds.join("','")}')`,
769
+ ResultType: 'simple',
770
+ }, contextUser);
771
+ const typeRows = typesResult.Success && typesResult.Results
772
+ ? (typesResult.Results as Record<string, any>[])
773
+ : [];
774
+ const typeMap = new Map<string, Record<string, any>>(typeRows.map(t => [t.ID as string, t]));
775
+
776
+ // Build a junction lookup: artifactVersionId -> conversationDetailId
777
+ const versionToDetail = new Map(junctions.map(j => [j.ArtifactVersionID, j.ConversationDetailID]));
778
+
779
+ // Build SkipAPIArtifact entries
780
+ const inputArtifactMap = new Map<string, { artifact: any; artifactType: SkipAPIArtifactType; versions: SkipAPIArtifactVersion[] }>();
781
+ for (const v of versions) {
782
+ const aRow = artifactRows.find(a => UUIDsEqual(a.ID, v.ArtifactID));
783
+ if (!aRow) continue;
784
+ if (alreadyLoaded.has(aRow.ID as string)) continue; // dedup
785
+
786
+ const typeRow = typeMap.get(aRow.TypeID as string);
787
+ if (!typeRow) continue;
788
+
789
+ let entry = inputArtifactMap.get(aRow.ID as string);
790
+ if (!entry) {
791
+ entry = {
792
+ artifact: {
793
+ id: aRow.ID,
794
+ conversationId,
795
+ name: aRow.Name,
796
+ description: aRow.Description || '',
797
+ sharingScope: 'None',
798
+ comments: aRow.Comments || '',
799
+ createdAt: new Date(aRow.__mj_CreatedAt),
800
+ updatedAt: new Date(aRow.__mj_UpdatedAt),
801
+ },
802
+ artifactType: {
803
+ id: typeRow.ID,
804
+ name: typeRow.Name,
805
+ description: typeRow.Description,
806
+ contentType: typeRow.ContentType,
807
+ enabled: true,
808
+ createdAt: new Date(typeRow.__mj_CreatedAt),
809
+ updatedAt: new Date(typeRow.__mj_UpdatedAt),
810
+ },
811
+ versions: [],
812
+ };
813
+ inputArtifactMap.set(aRow.ID as string, entry);
814
+ }
815
+ entry.versions.push({
816
+ id: v.ID,
817
+ artifactId: v.ArtifactID,
818
+ conversationDetailID: versionToDetail.get(v.ID) ?? '',
819
+ version: v.VersionNumber,
820
+ configuration: v.Configuration || '',
821
+ content: v.Content || '',
822
+ comments: v.Comments || '',
823
+ createdAt: new Date(v.__mj_CreatedAt),
824
+ updatedAt: new Date(v.__mj_UpdatedAt),
825
+ });
826
+ }
827
+
828
+ return Array.from(inputArtifactMap.values()).map(entry => ({
829
+ ...entry.artifact,
830
+ artifactType: entry.artifactType,
831
+ versions: entry.versions,
832
+ }));
833
+ } catch (error) {
834
+ LogError(`Failed to load input artifacts for conversation ${conversationId}: ${error}`);
835
+ return [];
836
+ }
837
+ }
838
+
692
839
  /**
693
840
  * Build API keys for AI services
694
841
  */
@@ -21885,9 +21885,8 @@ export class MJArtifactUse_ {
21885
21885
  @Field()
21886
21886
  _mj__UpdatedAt: Date;
21887
21887
 
21888
- @Field({nullable: true})
21889
- @MaxLength(255)
21890
- ArtifactVersion?: string;
21888
+ @Field(() => Int)
21889
+ ArtifactVersion: number;
21891
21890
 
21892
21891
  @Field()
21893
21892
  @MaxLength(100)
@@ -22067,9 +22066,8 @@ export class MJArtifactVersionAttribute_ {
22067
22066
  @Field()
22068
22067
  _mj__UpdatedAt: Date;
22069
22068
 
22070
- @Field({nullable: true})
22071
- @MaxLength(255)
22072
- ArtifactVersion?: string;
22069
+ @Field(() => Int)
22070
+ ArtifactVersion: number;
22073
22071
 
22074
22072
  }
22075
22073
 
@@ -23718,9 +23716,8 @@ export class MJCollectionArtifact_ {
23718
23716
  @MaxLength(255)
23719
23717
  Collection: string;
23720
23718
 
23721
- @Field({nullable: true})
23722
- @MaxLength(255)
23723
- ArtifactVersion?: string;
23719
+ @Field(() => Int)
23720
+ ArtifactVersion: number;
23724
23721
 
23725
23722
  }
23726
23723
 
@@ -32690,9 +32687,8 @@ export class MJConversationDetailArtifact_ {
32690
32687
  @Field()
32691
32688
  ConversationDetail: string;
32692
32689
 
32693
- @Field({nullable: true})
32694
- @MaxLength(255)
32695
- ArtifactVersion?: string;
32690
+ @Field(() => Int)
32691
+ ArtifactVersion: number;
32696
32692
 
32697
32693
  }
32698
32694
 
@@ -32902,9 +32898,8 @@ export class MJConversationDetailAttachment_ {
32902
32898
  @MaxLength(500)
32903
32899
  File?: string;
32904
32900
 
32905
- @Field({nullable: true})
32906
- @MaxLength(255)
32907
- ArtifactVersion?: string;
32901
+ @Field(() => Int, {nullable: true})
32902
+ ArtifactVersion?: number;
32908
32903
 
32909
32904
  }
32910
32905
 
@@ -51945,6 +51940,27 @@ export class MJList_ {
51945
51940
  @Field()
51946
51941
  _mj__UpdatedAt: Date;
51947
51942
 
51943
+ @Field({nullable: true, description: `Optional ID of the User View this list was materialized from. NULL for hand-built lists. When set, the list can be refreshed against this view via ListOperations.RefreshFromSource.`})
51944
+ @MaxLength(36)
51945
+ SourceViewID?: string;
51946
+
51947
+ @Field({nullable: true, description: `JSON snapshot of the source filter at materialization time. When UseSnapshot=1, refreshes re-apply this snapshot rather than re-reading the live source view. Null when no snapshot was captured.`})
51948
+ SourceFilterSnapshot?: string;
51949
+
51950
+ @Field({nullable: true, description: `Timestamp (UTC) of the most recent successful RefreshFromSource. Null when the list has never been refreshed.`})
51951
+ LastRefreshedAt?: Date;
51952
+
51953
+ @Field({nullable: true, description: `User who triggered the most recent successful RefreshFromSource. Null when the list has never been refreshed.`})
51954
+ @MaxLength(36)
51955
+ LastRefreshedByUserID?: string;
51956
+
51957
+ @Field({description: `Default refresh mode for this list. Additive only adds new members; Sync reconciles in both directions (may remove members no longer in the source — requires explicit drop-confirmation).`})
51958
+ @MaxLength(20)
51959
+ RefreshMode: string;
51960
+
51961
+ @Field(() => Boolean, {description: `When 1, RefreshFromSource uses SourceFilterSnapshot as the source. When 0 (default), it re-reads the live SourceView.`})
51962
+ UseSnapshot: boolean;
51963
+
51948
51964
  @Field()
51949
51965
  @MaxLength(255)
51950
51966
  Entity: string;
@@ -51961,6 +51977,14 @@ export class MJList_ {
51961
51977
  @MaxLength(255)
51962
51978
  CompanyIntegration?: string;
51963
51979
 
51980
+ @Field({nullable: true})
51981
+ @MaxLength(100)
51982
+ SourceView?: string;
51983
+
51984
+ @Field({nullable: true})
51985
+ @MaxLength(100)
51986
+ LastRefreshedByUser?: string;
51987
+
51964
51988
  @Field(() => [MJDuplicateRun_])
51965
51989
  MJDuplicateRuns_SourceListIDArray: MJDuplicateRun_[]; // Link to MJDuplicateRuns
51966
51990
 
@@ -52004,6 +52028,24 @@ export class CreateMJListInput {
52004
52028
  @Field({ nullable: true })
52005
52029
  CompanyIntegrationID: string | null;
52006
52030
 
52031
+ @Field({ nullable: true })
52032
+ SourceViewID: string | null;
52033
+
52034
+ @Field({ nullable: true })
52035
+ SourceFilterSnapshot: string | null;
52036
+
52037
+ @Field({ nullable: true })
52038
+ LastRefreshedAt: Date | null;
52039
+
52040
+ @Field({ nullable: true })
52041
+ LastRefreshedByUserID: string | null;
52042
+
52043
+ @Field({ nullable: true })
52044
+ RefreshMode?: string;
52045
+
52046
+ @Field(() => Boolean, { nullable: true })
52047
+ UseSnapshot?: boolean;
52048
+
52007
52049
  @Field(() => RestoreContextInput, { nullable: true })
52008
52050
  RestoreContext___?: RestoreContextInput;
52009
52051
  }
@@ -52038,6 +52080,24 @@ export class UpdateMJListInput {
52038
52080
  @Field({ nullable: true })
52039
52081
  CompanyIntegrationID?: string | null;
52040
52082
 
52083
+ @Field({ nullable: true })
52084
+ SourceViewID?: string | null;
52085
+
52086
+ @Field({ nullable: true })
52087
+ SourceFilterSnapshot?: string | null;
52088
+
52089
+ @Field({ nullable: true })
52090
+ LastRefreshedAt?: Date | null;
52091
+
52092
+ @Field({ nullable: true })
52093
+ LastRefreshedByUserID?: string | null;
52094
+
52095
+ @Field({ nullable: true })
52096
+ RefreshMode?: string;
52097
+
52098
+ @Field(() => Boolean, { nullable: true })
52099
+ UseSnapshot?: boolean;
52100
+
52041
52101
  @Field(() => [KeyValuePairInput], { nullable: true })
52042
52102
  OldValues___?: KeyValuePairInput[];
52043
52103
 
@@ -76926,6 +76986,9 @@ export class MJUserView_ {
76926
76986
  @Field(() => [MJUserViewRun_])
76927
76987
  MJUserViewRuns_UserViewIDArray: MJUserViewRun_[]; // Link to MJUserViewRuns
76928
76988
 
76989
+ @Field(() => [MJList_])
76990
+ MJLists_SourceViewIDArray: MJList_[]; // Link to MJLists
76991
+
76929
76992
  }
76930
76993
 
76931
76994
  //****************************************************************************
@@ -77173,6 +77236,16 @@ export class MJUserViewResolverBase extends ResolverBase {
77173
77236
  return result;
77174
77237
  }
77175
77238
 
77239
+ @FieldResolver(() => [MJList_])
77240
+ async MJLists_SourceViewIDArray(@Root() mjuserview_: MJUserView_, @Ctx() { userPayload, providers }: AppContext, @PubSub() pubSub: PubSubEngine) {
77241
+ this.CheckUserReadPermissions('MJ: Lists', userPayload);
77242
+ const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
77243
+ const sSQL = `SELECT * FROM ${provider.QuoteSchemaAndView(Metadata.Provider.ConfigData.MJCoreSchemaName, 'vwLists')} WHERE ${provider.QuoteIdentifier('SourceViewID')}='${mjuserview_.ID}' ` + this.getRowLevelSecurityWhereClause(provider, 'MJ: Lists', userPayload, EntityPermissionType.Read, 'AND');
77244
+ const rows = await provider.ExecuteSQL(sSQL, undefined, undefined, this.GetUserFromPayload(userPayload));
77245
+ const result = await this.ArrayMapFieldNamesToCodeNames('MJ: Lists', rows, this.GetUserFromPayload(userPayload));
77246
+ return result;
77247
+ }
77248
+
77176
77249
  @Mutation(() => MJUserView_)
77177
77250
  async CreateMJUserView(
77178
77251
  @Arg('input', () => CreateMJUserViewInput) input: CreateMJUserViewInput,
@@ -77579,6 +77652,9 @@ export class MJUser_ {
77579
77652
  @Field(() => [MJAIAgent_])
77580
77653
  MJAIAgents_OwnerUserIDArray: MJAIAgent_[]; // Link to MJAIAgents
77581
77654
 
77655
+ @Field(() => [MJList_])
77656
+ MJLists_LastRefreshedByUserIDArray: MJList_[]; // Link to MJLists
77657
+
77582
77658
  }
77583
77659
 
77584
77660
  //****************************************************************************
@@ -78714,6 +78790,16 @@ export class MJUserResolverBase extends ResolverBase {
78714
78790
  return result;
78715
78791
  }
78716
78792
 
78793
+ @FieldResolver(() => [MJList_])
78794
+ async MJLists_LastRefreshedByUserIDArray(@Root() mjuser_: MJUser_, @Ctx() { userPayload, providers }: AppContext, @PubSub() pubSub: PubSubEngine) {
78795
+ this.CheckUserReadPermissions('MJ: Lists', userPayload);
78796
+ const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
78797
+ const sSQL = `SELECT * FROM ${provider.QuoteSchemaAndView(Metadata.Provider.ConfigData.MJCoreSchemaName, 'vwLists')} WHERE ${provider.QuoteIdentifier('LastRefreshedByUserID')}='${mjuser_.ID}' ` + this.getRowLevelSecurityWhereClause(provider, 'MJ: Lists', userPayload, EntityPermissionType.Read, 'AND');
78798
+ const rows = await provider.ExecuteSQL(sSQL, undefined, undefined, this.GetUserFromPayload(userPayload));
78799
+ const result = await this.ArrayMapFieldNamesToCodeNames('MJ: Lists', rows, this.GetUserFromPayload(userPayload));
78800
+ return result;
78801
+ }
78802
+
78717
78803
  @Mutation(() => MJUser_)
78718
78804
  async CreateMJUser(
78719
78805
  @Arg('input', () => CreateMJUserInput) input: CreateMJUserInput,
@@ -1,8 +1,9 @@
1
1
  import { Arg, Ctx, Query, Resolver, Field, Int, InputType } from 'type-graphql';
2
- import { LogError } from '@memberjunction/core';
2
+ import { DatabasePlatform, LogError } from '@memberjunction/core';
3
3
  import { SQLExpressionValidator } from '@memberjunction/global';
4
+ import { RenderPipeline } from '@memberjunction/generic-database-provider';
4
5
  import { AppContext } from '../types.js';
5
- import { GetReadOnlyDataSource } from '../util.js';
6
+ import { GetReadOnlyDataSource, GetReadOnlyProvider } from '../util.js';
6
7
  import { ResolverBase } from '../generic/ResolverBase.js';
7
8
  import { RunQueryResultType } from './QueryResolver.js';
8
9
  import sql from 'mssql';
@@ -18,6 +19,12 @@ class AdhocQueryInput {
18
19
 
19
20
  @Field(() => Int, { nullable: true, description: 'Query timeout in seconds. Defaults to 30.' })
20
21
  TimeoutSeconds?: number;
22
+
23
+ @Field(() => Int, { nullable: true, description: 'Maximum number of rows to return; applied at the database via the render pipeline.' })
24
+ MaxRows?: number;
25
+
26
+ @Field(() => Int, { nullable: true, description: 'Zero-based offset for pagination. When > 0, the row cap switches to OFFSET/FETCH pagination.' })
27
+ StartRow?: number;
21
28
  }
22
29
 
23
30
  /**
@@ -56,28 +63,68 @@ export class AdhocQueryResolver extends ResolverBase {
56
63
  return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
57
64
  }
58
65
 
59
- // 3. Execute with timeout
66
+ // 3. Resolve platform from the read-only provider for the render pipeline.
67
+ let platform: DatabasePlatform = 'sqlserver';
68
+ try {
69
+ const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: false });
70
+ if (provider?.PlatformKey) platform = provider.PlatformKey;
71
+ } catch {
72
+ // Provider not configured — keep the default platform.
73
+ }
74
+ const contextUser = context.userPayload?.userRecord;
75
+
76
+ // 4. Route the SQL through RenderPipeline so composition tokens
77
+ // resolve, comments and templates are processed, and the row cap
78
+ // is applied at the database (via TOP / LIMIT / OFFSET-FETCH).
79
+ const startRow = input.StartRow ?? 0;
80
+ const maxRows = input.MaxRows;
81
+ const usePaging =
82
+ maxRows != null &&
83
+ Number.isInteger(maxRows) &&
84
+ maxRows > 0 &&
85
+ Number.isInteger(startRow) &&
86
+ startRow > 0;
87
+ let executableSql: string;
88
+ try {
89
+ const rendered = RenderPipeline.Run(input.SQL, {
90
+ Platform: platform,
91
+ ContextUser: contextUser,
92
+ ...(usePaging
93
+ ? { Paging: { StartRow: startRow, MaxRows: maxRows! } }
94
+ : maxRows != null && maxRows > 0
95
+ ? { MaxRows: maxRows }
96
+ : {}),
97
+ });
98
+ executableSql = rendered.FinalSQL;
99
+ } catch (renderErr) {
100
+ const renderMsg = renderErr instanceof Error ? renderErr.message : String(renderErr);
101
+ return this.buildErrorResult(`Ad-hoc query rendering failed: ${renderMsg}`);
102
+ }
103
+
104
+ // 5. Execute with timeout
60
105
  const timeoutMs = (input.TimeoutSeconds ?? 30) * 1000;
61
106
  const request = new sql.Request(readOnlyDS);
62
107
 
63
108
  const result = await Promise.race([
64
- request.query(input.SQL),
109
+ request.query(executableSql),
65
110
  new Promise<never>((_, reject) =>
66
111
  setTimeout(() => reject(new Error('Query timeout exceeded')), timeoutMs)
67
112
  )
68
113
  ]);
69
114
  const executionTimeMs = Date.now() - startTime;
70
115
 
71
- // 4. Return as RunQueryResultType
116
+ // 6. Return as RunQueryResultType
117
+ const recordset = result.recordset ?? [];
118
+
72
119
  return {
73
120
  QueryID: '',
74
121
  QueryName: 'Ad-Hoc Query',
75
122
  Success: true,
76
- Results: JSON.stringify(result.recordset ?? []),
77
- RowCount: result.recordset?.length ?? 0,
78
- TotalRowCount: result.recordset?.length ?? 0,
79
- PageNumber: undefined,
80
- PageSize: undefined,
123
+ Results: JSON.stringify(recordset),
124
+ RowCount: recordset.length,
125
+ TotalRowCount: recordset.length,
126
+ PageNumber: maxRows != null && maxRows > 0 ? Math.floor(startRow / maxRows) + 1 : undefined,
127
+ PageSize: maxRows ?? undefined,
81
128
  ExecutionTime: executionTimeMs,
82
129
  ErrorMessage: ''
83
130
  };