@memberjunction/server 5.36.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.
@@ -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
  */
@@ -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';
@@ -19,10 +20,10 @@ class AdhocQueryInput {
19
20
  @Field(() => Int, { nullable: true, description: 'Query timeout in seconds. Defaults to 30.' })
20
21
  TimeoutSeconds?: number;
21
22
 
22
- @Field(() => Int, { nullable: true, description: 'Maximum number of rows to return. Applied in-memory after SQL execution; SQL still runs unbounded server-side.' })
23
+ @Field(() => Int, { nullable: true, description: 'Maximum number of rows to return; applied at the database via the render pipeline.' })
23
24
  MaxRows?: number;
24
25
 
25
- @Field(() => Int, { nullable: true, description: 'Zero-based offset for pagination. Used in conjunction with MaxRows.' })
26
+ @Field(() => Int, { nullable: true, description: 'Zero-based offset for pagination. When > 0, the row cap switches to OFFSET/FETCH pagination.' })
26
27
  StartRow?: number;
27
28
  }
28
29
 
@@ -62,25 +63,45 @@ export class AdhocQueryResolver extends ResolverBase {
62
63
  return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
63
64
  }
64
65
 
65
- // 3. Build executable SQL. When MaxRows is provided, wrap in a derived table
66
- // with outer TOP so the engine can short-circuit at the source instead of
67
- // scanning the full result. Skipped for SQL that begins with WITH/CTE — those
68
- // can't be nested in a derived table on SQL Server and fall through to the
69
- // in-memory slice below.
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).
70
79
  const startRow = input.StartRow ?? 0;
71
80
  const maxRows = input.MaxRows;
72
- const canWrap =
81
+ const usePaging =
73
82
  maxRows != null &&
74
83
  Number.isInteger(maxRows) &&
75
84
  maxRows > 0 &&
76
85
  Number.isInteger(startRow) &&
77
- startRow >= 0 &&
78
- !/^\s*WITH\b/i.test(input.SQL);
79
- const executableSql = canWrap
80
- ? `SELECT TOP ${startRow + maxRows} * FROM (\n${input.SQL}\n) AS _adhoc_capped`
81
- : input.SQL;
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
+ }
82
103
 
83
- // 4. Execute with timeout
104
+ // 5. Execute with timeout
84
105
  const timeoutMs = (input.TimeoutSeconds ?? 30) * 1000;
85
106
  const request = new sql.Request(readOnlyDS);
86
107
 
@@ -92,23 +113,16 @@ export class AdhocQueryResolver extends ResolverBase {
92
113
  ]);
93
114
  const executionTimeMs = Date.now() - startTime;
94
115
 
95
- // 5. Apply in-memory pagination. With the wrap applied this is a no-op for
96
- // first-page reads; for StartRow > 0 (or CTE-headed SQL where the wrap was
97
- // skipped) it carves out the requested page.
98
- const fullRecordset = result.recordset ?? [];
99
- const totalRowCount = fullRecordset.length;
100
- let paginated = fullRecordset;
101
- if (startRow > 0) paginated = paginated.slice(startRow);
102
- if (maxRows != null && maxRows > 0) paginated = paginated.slice(0, maxRows);
103
-
104
116
  // 6. Return as RunQueryResultType
117
+ const recordset = result.recordset ?? [];
118
+
105
119
  return {
106
120
  QueryID: '',
107
121
  QueryName: 'Ad-Hoc Query',
108
122
  Success: true,
109
- Results: JSON.stringify(paginated),
110
- RowCount: paginated.length,
111
- TotalRowCount: totalRowCount,
123
+ Results: JSON.stringify(recordset),
124
+ RowCount: recordset.length,
125
+ TotalRowCount: recordset.length,
112
126
  PageNumber: maxRows != null && maxRows > 0 ? Math.floor(startRow / maxRows) + 1 : undefined,
113
127
  PageSize: maxRows ?? undefined,
114
128
  ExecutionTime: executionTimeMs,