diff --git a/packages/atlas-service/src/atlas-service.ts b/packages/atlas-service/src/atlas-service.ts index b3d29318879..b4d9154a13b 100644 --- a/packages/atlas-service/src/atlas-service.ts +++ b/packages/atlas-service/src/atlas-service.ts @@ -128,6 +128,7 @@ export class AtlasService { const authHeaders = await this.authService.getAuthHeaders(); return this.fetch(url, { ...init, + credentials: 'include', // Include cookies for cross-origin requests (needed for local dev) headers: { ...init?.headers, ...authHeaders, diff --git a/packages/compass-preferences-model/src/preferences-schema.tsx b/packages/compass-preferences-model/src/preferences-schema.tsx index 8c8b7ba24f8..9b956dad644 100644 --- a/packages/compass-preferences-model/src/preferences-schema.tsx +++ b/packages/compass-preferences-model/src/preferences-schema.tsx @@ -112,6 +112,7 @@ export type InternalUserPreferences = { id: string; cloudFeatureRolloutAccess?: { GEN_AI_COMPASS?: boolean; + MY_QUERIES_DATA_EXPLORER?: boolean; }; lastKnownVersion: string; highestInstalledVersion?: string; @@ -470,6 +471,7 @@ export const storedUserPreferencesProps: Required<{ validator: z .object({ GEN_AI_COMPASS: z.boolean().optional(), + MY_QUERIES_DATA_EXPLORER: z.boolean().optional(), }) .optional(), type: 'object', diff --git a/packages/compass-query-bar/src/components/query-bar.tsx b/packages/compass-query-bar/src/components/query-bar.tsx index 0b067ff8be9..0d45e9522f5 100644 --- a/packages/compass-query-bar/src/components/query-bar.tsx +++ b/packages/compass-query-bar/src/components/query-bar.tsx @@ -14,7 +14,10 @@ import { createAIPlaceholderHTMLPlaceholder, } from '@mongodb-js/compass-generative-ai'; import { connect } from '../stores/context'; -import { useIsAIFeatureEnabled } from 'compass-preferences-model/provider'; +import { + useIsAIFeatureEnabled, + usePreference, +} from 'compass-preferences-model/provider'; import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; import { @@ -202,8 +205,13 @@ export const QueryBar: React.FunctionComponent = ({ const favoriteQueryStorageAvailable = !!useFavoriteQueryStorageAccess(); const recentQueryStorageAvailable = !!useRecentQueryStorageAccess(); + const cloudFeatureRolloutAccess = usePreference('cloudFeatureRolloutAccess'); + const isMyQueriesEnabled = + cloudFeatureRolloutAccess?.MY_QUERIES_DATA_EXPLORER ?? false; const enableSavedAggregationsQueries = - favoriteQueryStorageAvailable && recentQueryStorageAvailable; + favoriteQueryStorageAvailable && + recentQueryStorageAvailable && + isMyQueriesEnabled; return (
- {hasWorkspacePlugin('My Queries') && ( + {hasWorkspacePlugin('My Queries') && isMyQueriesEnabled && ( = (content: I) => string; type DeserializeContent = (content: string) => unknown; +type GetFileName = (id: string) => string; type GetResourceUrl = (path?: string) => Promise; type AuthenticatedFetch = ( url: RequestInfo | URL, @@ -20,13 +21,10 @@ export type FileUserDataOptions = { basePath?: string; serialize?: SerializeContent; deserialize?: DeserializeContent; + getFileName?: GetFileName; }; export type AtlasUserDataOptions = { - orgId: string; - projectId: string; - getResourceUrl: GetResourceUrl; - authenticatedFetch: AuthenticatedFetch; serialize?: SerializeContent; deserialize?: DeserializeContent; }; @@ -35,11 +33,45 @@ type ReadOptions = { ignoreErrors: boolean; }; +// Copied from the Node.js fs module. +export interface Stats { + isFile(): boolean; + isDirectory(): boolean; + isBlockDevice(): boolean; + isCharacterDevice(): boolean; + isSymbolicLink(): boolean; + isFIFO(): boolean; + isSocket(): boolean; + dev: number; + ino: number; + mode: number; + nlink: number; + uid: number; + gid: number; + rdev: number; + size: number; + blksize: number; + blocks: number; + atimeMs: number; + mtimeMs: number; + ctimeMs: number; + birthtimeMs: number; + atime: Date; + mtime: Date; + ctime: Date; + birthtime: Date; +} + export interface ReadAllResult { data: z.output[]; errors: Error[]; } +export interface ReadAllWithStatsResult { + data: [z.output, Stats][]; + errors: Error[]; +} + export abstract class IUserData { protected readonly validator: T; protected readonly dataType: string; @@ -73,15 +105,95 @@ export abstract class IUserData { export class FileUserData extends IUserData { private readonly basePath?: string; + private readonly getFileName: GetFileName; protected readonly semaphore = new Semaphore(100); constructor( validator: T, dataType: string, - { basePath, serialize, deserialize }: FileUserDataOptions> + { + basePath, + serialize, + deserialize, + getFileName = (id) => `${id}.json`, + }: FileUserDataOptions> ) { super(validator, dataType, { serialize, deserialize }); this.basePath = basePath; + this.getFileName = getFileName; + } + + private async getEnsuredBasePath(): Promise { + const basepath = this.basePath ? this.basePath : getStoragePath(); + + const root = path.join(basepath, this.dataType); + + await fs.mkdir(root, { recursive: true }); + + return root; + } + + private async getFileAbsolutePath(filepath?: string): Promise { + const root = await this.getEnsuredBasePath(); + const pathRelativeToRoot = path.relative( + root, + path.join(root, filepath ?? '') + ); + + if ( + pathRelativeToRoot.startsWith('..') || + path.isAbsolute(pathRelativeToRoot) + ) { + throw new Error( + `Invalid file path: '${filepath}' is not a subpath of ${root}.` + ); + } + + return path.resolve(root, pathRelativeToRoot); + } + + private async readAndParseFileWithStats( + absolutePath: string, + options: ReadOptions + ): Promise<[z.output, Stats] | undefined> { + let data: string; + let stats: Stats; + let handle: fs.FileHandle | undefined = undefined; + let release: (() => void) | undefined = undefined; + try { + release = await this.semaphore.waitForRelease(); + handle = await fs.open(absolutePath, 'r'); + [stats, data] = await Promise.all([ + handle.stat(), + handle.readFile('utf-8'), + ]); + } catch (error) { + log.error(mongoLogId(1_001_000_234), 'Filesystem', 'Error reading file', { + path: absolutePath, + error: (error as Error).message, + }); + if (options.ignoreErrors) { + return undefined; + } + throw error; + } finally { + await handle?.close(); + release?.(); + } + + try { + const content = this.deserialize(data); + return [this.validator.parse(content), stats]; + } catch (error) { + log.error(mongoLogId(1_001_000_235), 'Filesystem', 'Error parsing data', { + path: absolutePath, + error: (error as Error).message, + }); + if (options.ignoreErrors) { + return undefined; + } + throw error; + } } private getFileName(id: string) { @@ -197,37 +309,70 @@ export class FileUserData extends IUserData { } } - async readAll( + async readAllWithStats( options: ReadOptions = { ignoreErrors: true, } - ): Promise> { - const result: ReadAllResult = { + ): Promise> { + const absolutePath = await this.getFileAbsolutePath(); + const filePathList = await fs.readdir(absolutePath); + + const data = await Promise.allSettled( + filePathList.map((x) => + this.readAndParseFileWithStats(path.join(absolutePath, x), options) + ) + ); + + const result: ReadAllWithStatsResult = { data: [], errors: [], }; - try { - const absolutePath = await this.getFileAbsolutePath(); - const filePathList = await fs.readdir(absolutePath); - for (const settled of await Promise.allSettled( - filePathList.map((x) => { - return this.readAndParseFile(path.join(absolutePath, x), options); - }) - )) { - if (settled.status === 'fulfilled' && settled.value) { - result.data.push(settled.value); - } - if (settled.status === 'rejected') { - result.errors.push(settled.reason); - } + + for (const item of data) { + if (item.status === 'fulfilled' && item.value) { + result.data.push(item.value); } - return result; - } catch (err) { - if (options.ignoreErrors) { - return result; + if (item.status === 'rejected') { + result.errors.push(item.reason); } - throw err; } + + return result; + } + + async readOneWithStats( + id: string, + options?: { ignoreErrors: false } + ): Promise<[z.output, Stats]>; + async readOneWithStats( + id: string, + options?: { ignoreErrors: true } + ): Promise<[z.output, Stats] | undefined>; + async readOneWithStats( + id: string, + options?: ReadOptions + ): Promise<[z.output, Stats] | undefined>; + async readOneWithStats( + id: string, + options: ReadOptions = { + ignoreErrors: true, + } + ) { + const filepath = this.getFileName(id); + const absolutePath = await this.getFileAbsolutePath(filepath); + return await this.readAndParseFileWithStats(absolutePath, options); + } + + async readAll( + options: ReadOptions = { + ignoreErrors: true, + } + ): Promise> { + const result = await this.readAllWithStats(options); + return { + data: result.data.map(([data]) => data), + errors: result.errors, + }; } async readOne( @@ -248,9 +393,7 @@ export class FileUserData extends IUserData { ignoreErrors: true, } ) { - const filepath = this.getFileName(id); - const absolutePath = await this.getFileAbsolutePath(filepath); - return await this.readAndParseFile(absolutePath, options); + return (await this.readOneWithStats(id, options))?.[0]; } async updateAttributes( @@ -278,14 +421,11 @@ export class AtlasUserData extends IUserData { constructor( validator: T, dataType: string, - { - orgId, - projectId, - getResourceUrl, - authenticatedFetch, - serialize, - deserialize, - }: AtlasUserDataOptions> + orgId: string, + projectId: string, + getResourceUrl: GetResourceUrl, + authenticatedFetch: AuthenticatedFetch, + { serialize, deserialize }: AtlasUserDataOptions> ) { super(validator, dataType, { serialize, deserialize }); this.authenticatedFetch = authenticatedFetch; @@ -295,34 +435,43 @@ export class AtlasUserData extends IUserData { } async write(id: string, content: z.input): Promise { - const url = await this.getResourceUrl( - `${this.dataType}/${this.orgId}/${this.projectId}` - ); - try { this.validator.parse(content); - await this.authenticatedFetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - id: id, - data: this.serialize(content), - createdAt: new Date(), - projectId: this.projectId, - }), - }); + const response = await this.authenticatedFetch( + await this.getResourceUrl( + `${this.dataType}/${this.orgId}/${this.projectId}` + ), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: id, + data: this.serialize(content), + createdAt: new Date(), + projectId: this.projectId, + }), + } + ); + + if (!response.ok) { + throw new Error( + `Failed to post data: ${response.status} ${response.statusText}` + ); + } return true; } catch (error) { log.error( - mongoLogId(1_001_000_366), + mongoLogId(1_001_000_362), 'Atlas Backend', 'Error writing data', { - url, + url: await this.getResourceUrl( + `${this.dataType}/${this.orgId}/${this.projectId}` + ), error: (error as Error).message, } ); @@ -331,22 +480,30 @@ export class AtlasUserData extends IUserData { } async delete(id: string): Promise { - const url = await this.getResourceUrl( - `${this.dataType}/${this.orgId}/${this.projectId}/${id}` - ); - try { - await this.authenticatedFetch(url, { - method: 'DELETE', - }); + const response = await this.authenticatedFetch( + await this.getResourceUrl( + `${this.dataType}/${this.orgId}/${this.projectId}/${id}` + ), + { + method: 'DELETE', + } + ); + if (!response.ok) { + throw new Error( + `Failed to delete data: ${response.status} ${response.statusText}` + ); + } return true; } catch (error) { log.error( - mongoLogId(1_001_000_367), + mongoLogId(1_001_000_363), 'Atlas Backend', 'Error deleting data', { - url, + url: await this.getResourceUrl( + `${this.dataType}/${this.orgId}/${this.projectId}/${id}` + ), error: (error as Error).message, } ); @@ -368,6 +525,11 @@ export class AtlasUserData extends IUserData { method: 'GET', } ); + if (!response.ok) { + throw new Error( + `Failed to get data: ${response.status} ${response.statusText}` + ); + } const json = await response.json(); for (const item of json) { try { @@ -389,13 +551,7 @@ export class AtlasUserData extends IUserData { data: Partial> ): Promise { try { - const prevData = await this.readOne(id); - const newData: z.input = { - ...prevData, - ...data, - }; - - await this.authenticatedFetch( + const response = await this.authenticatedFetch( await this.getResourceUrl( `${this.dataType}/${this.orgId}/${this.projectId}/${id}` ), @@ -404,13 +560,18 @@ export class AtlasUserData extends IUserData { headers: { 'Content-Type': 'application/json', }, - body: this.serialize(newData), + body: this.serialize(data), } ); + if (!response.ok) { + throw new Error( + `Failed to update data: ${response.status} ${response.statusText}` + ); + } return true; } catch (error) { log.error( - mongoLogId(1_001_000_368), + mongoLogId(1_001_000_364), 'Atlas Backend', 'Error updating data', { @@ -420,34 +581,7 @@ export class AtlasUserData extends IUserData { error: (error as Error).message, } ); - throw error; - } - } - - // TODO: change this depending on whether or not updateAttributes can provide all current data - async readOne(id: string): Promise> { - const url = await this.getResourceUrl( - `${this.dataType}/${this.orgId}/${this.projectId}/${id}` - ); - - try { - const getResponse = await this.authenticatedFetch(url, { - method: 'GET', - }); - const json = await getResponse.json(); - const data = this.validator.parse(this.deserialize(json.data as string)); - return data; - } catch (error) { - log.error( - mongoLogId(1_001_000_369), - 'Atlas Backend', - 'Error reading data', - { - url, - error: (error as Error).message, - } - ); - throw error; + return false; } } } diff --git a/packages/compass-web/sandbox/index.tsx b/packages/compass-web/sandbox/index.tsx index 17f676edf2b..864fc0f42a5 100644 --- a/packages/compass-web/sandbox/index.tsx +++ b/packages/compass-web/sandbox/index.tsx @@ -1,10 +1,10 @@ import React, { useCallback, useLayoutEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; import { - resetGlobalCSS, - css, Body, + css, openToast, + resetGlobalCSS, } from '@mongodb-js/compass-components'; import type { AllPreferences } from 'compass-preferences-model'; import { CompassWeb } from '../src/index'; @@ -137,6 +137,9 @@ const App = () => { isAtlas && !!enableGenAIFeaturesAtlasOrg, optInGenAIFeatures: isAtlas && !!optInGenAIFeatures, enableDataModeling: true, + cloudFeatureRolloutAccess: { + MY_QUERIES_DATA_EXPLORER: true, // Enabled for testing implementation + }, }} onTrack={sandboxTelemetry.track} onDebug={sandboxLogger.debug} diff --git a/packages/compass-web/src/entrypoint.tsx b/packages/compass-web/src/entrypoint.tsx index d224c38426c..5ae6b820134 100644 --- a/packages/compass-web/src/entrypoint.tsx +++ b/packages/compass-web/src/entrypoint.tsx @@ -11,17 +11,20 @@ import WorkspacesPlugin, { WorkspacesProvider, } from '@mongodb-js/compass-workspaces'; import { - DatabasesWorkspaceTab, CollectionsWorkspaceTab, + CreateNamespacePlugin, + DatabasesWorkspaceTab, + DropNamespacePlugin, + RenameCollectionPlugin, } from '@mongodb-js/compass-databases-collections'; import { CompassComponentsProvider, css } from '@mongodb-js/compass-components'; import { - WorkspaceTab as CollectionWorkspace, CollectionTabsProvider, + WorkspaceTab as CollectionWorkspace, } from '@mongodb-js/compass-collection'; import { - CompassSidebarPlugin, AtlasClusterConnectionsOnlyProvider, + CompassSidebarPlugin, } from '@mongodb-js/compass-sidebar'; import CompassQueryBarPlugin from '@mongodb-js/compass-query-bar'; import { CompassDocumentsPlugin } from '@mongodb-js/compass-crud'; @@ -36,13 +39,8 @@ import { CompassGlobalWritesPlugin } from '@mongodb-js/compass-global-writes'; import { CompassGenerativeAIPlugin } from '@mongodb-js/compass-generative-ai'; import ExplainPlanCollectionTabModal from '@mongodb-js/compass-explain-plan'; import ExportToLanguageCollectionTabModal from '@mongodb-js/compass-export-to-language'; -import { - CreateNamespacePlugin, - DropNamespacePlugin, - RenameCollectionPlugin, -} from '@mongodb-js/compass-databases-collections'; -import { PreferencesProvider } from 'compass-preferences-model/provider'; import type { AllPreferences } from 'compass-preferences-model/provider'; +import { PreferencesProvider } from 'compass-preferences-model/provider'; import FieldStorePlugin from '@mongodb-js/compass-field-store'; import { AtlasServiceProvider } from '@mongodb-js/atlas-service/provider'; import { AtlasAiServiceProvider } from '@mongodb-js/compass-generative-ai/provider'; @@ -51,7 +49,7 @@ import { TelemetryProvider } from '@mongodb-js/compass-telemetry/provider'; import CompassConnections from '@mongodb-js/compass-connections'; import { AtlasCloudConnectionStorageProvider } from './connection-storage'; import { AtlasCloudAuthServiceProvider } from './atlas-auth-service'; -import type { LogFunction, DebugFunction } from './logger'; +import type { DebugFunction, LogFunction } from './logger'; import { useCompassWebLogger } from './logger'; import { type TelemetryServiceOptions } from '@mongodb-js/compass-telemetry'; import { WebWorkspaceTab as WelcomeWorkspaceTab } from '@mongodb-js/compass-welcome'; @@ -62,6 +60,7 @@ import { CompassAssistantDrawer, CompassAssistantProvider, } from '@mongodb-js/compass-assistant'; +import { useMyQueriesFeature } from './hooks/use-my-queries-feature'; export type TrackFunction = ( event: string, @@ -280,6 +279,10 @@ const CompassWeb = ({ onDebug, }); const preferencesAccess = useCompassWebPreferences(initialPreferences); + const isMyQueriesEnabled = useMyQueriesFeature(); + // FORCE ENABLE for testing - remove this line in production + const forceEnabledForTesting = true; + const finalMyQueriesEnabled = forceEnabledForTesting || isMyQueriesEnabled; const initialWorkspaceRef = useRef(initialWorkspace); const initialWorkspaceTabsRef = useRef( initialWorkspaceRef.current ? [initialWorkspaceRef.current] : [] diff --git a/packages/compass-web/src/hooks/use-my-queries-feature.ts b/packages/compass-web/src/hooks/use-my-queries-feature.ts new file mode 100644 index 00000000000..4083639d64a --- /dev/null +++ b/packages/compass-web/src/hooks/use-my-queries-feature.ts @@ -0,0 +1,18 @@ +import { usePreference } from 'compass-preferences-model/provider'; + +/** + * Hook to check if the My Queries Data Explorer feature is enabled. + * This controls access to: + * - Saved queries and aggregations + * - Recent queries autocomplete + * - Favorite queries/aggregations + */ +export function useMyQueriesFeature(): boolean { + const cloudFeatureRolloutAccess = usePreference('cloudFeatureRolloutAccess'); + // return cloudFeatureRolloutAccess?.MY_QUERIES_DATA_EXPLORER ?? false; + const isEnabled = + cloudFeatureRolloutAccess?.MY_QUERIES_DATA_EXPLORER ?? false; + + // FORCE ENABLE for testing - remove this in production + return true || isEnabled; +} diff --git a/packages/compass-web/src/preferences.tsx b/packages/compass-web/src/preferences.tsx index 7347cdd53b4..1701215ae05 100644 --- a/packages/compass-web/src/preferences.tsx +++ b/packages/compass-web/src/preferences.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useRef, useEffect } from 'react'; +import React, { useContext, useEffect, useRef } from 'react'; import type { AllPreferences } from 'compass-preferences-model/provider'; import { CompassWebPreferencesAccess } from 'compass-preferences-model/provider'; @@ -49,6 +49,7 @@ export function useCompassWebPreferences( enablePerformanceAdvisorBanner: true, cloudFeatureRolloutAccess: { GEN_AI_COMPASS: false, + MY_QUERIES_DATA_EXPLORER: true, // Enabled for testing implementation }, maximumNumberOfActiveConnections: 10, trackUsageStatistics: true,