diff --git a/packages/atlas-service/src/atlas-service.ts b/packages/atlas-service/src/atlas-service.ts index d2a659327b8..2a6ab2cdc7f 100644 --- a/packages/atlas-service/src/atlas-service.ts +++ b/packages/atlas-service/src/atlas-service.ts @@ -9,6 +9,7 @@ import { import type { Logger } from '@mongodb-js/compass-logging'; import type { PreferencesAccess } from 'compass-preferences-model'; import type { AtlasClusterMetadata } from '@mongodb-js/connection-info'; +import { type UserDataType } from '@mongodb-js/compass-user-data'; export type AtlasServiceOptions = { defaultHeaders?: Record; @@ -81,12 +82,7 @@ export class AtlasService { userDataEndpoint( orgId: string, groupId: string, - type: - | 'favoriteQueries' - | 'recentQueries' - | 'favoriteAggregations' - | 'savedWorkspaces' - | 'dataModelDescriptions', + type: UserDataType, id?: string ): string { const encodedOrgId = encodeURIComponent(orgId); diff --git a/packages/compass-data-modeling/src/services/data-model-storage-web.tsx b/packages/compass-data-modeling/src/services/data-model-storage-web.tsx index 04638a75868..98fe98a5332 100644 --- a/packages/compass-data-modeling/src/services/data-model-storage-web.tsx +++ b/packages/compass-data-modeling/src/services/data-model-storage-web.tsx @@ -21,7 +21,7 @@ class DataModelStorageAtlas implements DataModelStorage { constructor(orgId: string, projectId: string, atlasService: AtlasService) { this.userData = new AtlasUserData( MongoDBDataModelDescriptionSchema, - 'dataModelDescriptions', + 'DataModelDescriptions', { orgId, projectId, @@ -37,7 +37,7 @@ class DataModelStorageAtlas implements DataModelStorage { !type || !pathOrgId || !pathProjectId || - type !== 'dataModelDescriptions' + type !== 'DataModelDescriptions' ) { throw new Error( 'DataModelStorageAtlas is used outside of Atlas Cloud context' diff --git a/packages/compass-e2e-tests/tests/collection-import.test.ts b/packages/compass-e2e-tests/tests/collection-import.test.ts index 4b8e542efbb..8876c34f5b5 100644 --- a/packages/compass-e2e-tests/tests/collection-import.test.ts +++ b/packages/compass-e2e-tests/tests/collection-import.test.ts @@ -643,7 +643,6 @@ describe('Collection import', function () { // Find the log file const logFilePath = path.resolve( compass.userDataPath || '', - compass.appName || '', 'ImportErrorLogs', `import-${filename}.log` ); @@ -1293,7 +1292,6 @@ describe('Collection import', function () { const logFilePath = path.resolve( compass.userDataPath || '', - compass.appName || '', 'ImportErrorLogs', `import-${fileName}.log` ); diff --git a/packages/compass-import-export/src/modules/import.ts b/packages/compass-import-export/src/modules/import.ts index 0357dfd2feb..968adc6b6f3 100644 --- a/packages/compass-import-export/src/modules/import.ts +++ b/packages/compass-import-export/src/modules/import.ts @@ -21,7 +21,7 @@ import { analyzeCSVFields } from '../import/analyze-csv-fields'; import type { AnalyzeCSVFieldsResult } from '../import/analyze-csv-fields'; import { importCSV } from '../import/import-csv'; import { importJSON } from '../import/import-json'; -import { getUserDataFolderPath } from '../utils/get-user-data-file-path'; +import { getStoragePath } from '@mongodb-js/compass-utils'; import { showBloatedDocumentSignalToast, showUnboundArraySignalToast, @@ -177,6 +177,14 @@ const onFileSelectError = (error: Error) => ({ error, }); +export function getUserDataFolderPath() { + const basepath = getStoragePath(); + if (basepath === undefined) { + throw new Error('cannot access user data folder path'); + } + return basepath; +} + async function getErrorLogPath(fileName: string) { // Create the error log output file. const userDataPath = getUserDataFolderPath(); diff --git a/packages/compass-import-export/src/utils/get-user-data-file-path.ts b/packages/compass-import-export/src/utils/get-user-data-file-path.ts deleted file mode 100644 index 09cd921bc2f..00000000000 --- a/packages/compass-import-export/src/utils/get-user-data-file-path.ts +++ /dev/null @@ -1,13 +0,0 @@ -import path from 'path'; -import { getAppName, getStoragePath } from '@mongodb-js/compass-utils'; - -export function getUserDataFolderPath() { - const appName = getAppName(); - const basepath = getStoragePath(); - if (appName === undefined || basepath === undefined) { - throw new Error('cannot access user data folder path'); - } - // Todo: https://jira.mongodb.org/browse/COMPASS-7080 - // It creates nested folder with appName as folder name. - return path.join(basepath, appName); -} diff --git a/packages/compass-shell/src/modules/history-storage.spec.ts b/packages/compass-shell/src/modules/history-storage.spec.ts index 053042c766a..36cc0ab6c5b 100644 --- a/packages/compass-shell/src/modules/history-storage.spec.ts +++ b/packages/compass-shell/src/modules/history-storage.spec.ts @@ -6,14 +6,14 @@ import fs from 'fs/promises'; import { HistoryStorage } from './history-storage'; describe('HistoryStorage', function () { - let tmpDir; - let historyFilePath; + let tmpDir: string; + let historyFilePath: string; - let historyStorage; + let historyStorage: HistoryStorage; beforeEach(async function () { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'compass-shell-test')); - historyFilePath = path.join(tmpDir, 'shell-history.json'); + historyFilePath = path.join(tmpDir, 'ShellHistory', 'shell-history.json'); historyStorage = new HistoryStorage(tmpDir); }); diff --git a/packages/compass-shell/src/modules/history-storage.ts b/packages/compass-shell/src/modules/history-storage.ts index a7a28875a06..2979f2a4891 100644 --- a/packages/compass-shell/src/modules/history-storage.ts +++ b/packages/compass-shell/src/modules/history-storage.ts @@ -1,17 +1,33 @@ -import { getAppName } from '@mongodb-js/compass-utils'; import { FileUserData, z } from '@mongodb-js/compass-user-data'; +import { getAppName } from '@mongodb-js/compass-utils'; export class HistoryStorage { fileName = 'shell-history'; userData; + private migrationChecked = false; constructor(basePath?: string) { - // TODO: https://jira.mongodb.org/browse/COMPASS-7080 - this.userData = new FileUserData(z.string().array(), getAppName() ?? '', { + this.userData = new FileUserData(z.string().array(), 'ShellHistory', { basePath, }); } + /** + * Migrates history from old app-name-based folder to new ShellHistory folder. + * Only runs once per instance and only if old folder exists. + */ + private async migrateIfNeeded(): Promise { + if (this.migrationChecked) { + return; + } + this.migrationChecked = true; + + const oldAppName = getAppName(); + if (oldAppName) { + await this.userData.migrateFromOldFolder(oldAppName); + } + } + /** * Saves the history to disk, it creates the directory and the file if * not existing and replaces the file content. @@ -20,6 +36,7 @@ export class HistoryStorage { * newest to oldest. */ async save(history: string[]) { + await this.migrateIfNeeded(); await this.userData.write(this.fileName, history); } @@ -31,6 +48,7 @@ export class HistoryStorage { * newest to oldest. */ async load(): Promise { + await this.migrateIfNeeded(); try { return (await this.userData.readOne(this.fileName)) ?? []; } catch { diff --git a/packages/compass-user-data/src/index.ts b/packages/compass-user-data/src/index.ts index d7f7b5dd09b..b458a5b4cff 100644 --- a/packages/compass-user-data/src/index.ts +++ b/packages/compass-user-data/src/index.ts @@ -1,3 +1,8 @@ -export type { ReadAllResult } from './user-data'; -export { IUserData, FileUserData, AtlasUserData } from './user-data'; +export type { ReadAllResult, UserDataType } from './user-data'; +export { + IUserData, + FileUserData, + AtlasUserData, + assertsUserDataType, +} from './user-data'; export { z } from 'zod'; diff --git a/packages/compass-user-data/src/user-data.spec.ts b/packages/compass-user-data/src/user-data.spec.ts index d9d58d01165..a0e8cc07f5e 100644 --- a/packages/compass-user-data/src/user-data.spec.ts +++ b/packages/compass-user-data/src/user-data.spec.ts @@ -6,6 +6,7 @@ import { FileUserData, AtlasUserData, type FileUserDataOptions, + type UserDataType, } from './user-data'; import { z, type ZodError } from 'zod'; import sinon from 'sinon'; @@ -340,6 +341,174 @@ describe('user-data', function () { }); }); }); + + context('FileUserData.migrateFromOldFolder', function () { + it('migrates data from old folder to new folder', async function () { + // Create FileUserData with old type name and write data + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const oldUserData = new FileUserData( + getTestSchema(), + 'iLoveJavaScript' as any, + { + basePath: tmpDir, + } + ); + + await oldUserData.write('test-file', { name: 'Old Data' }); + + // Verify old data exists + const oldData = await oldUserData.readOne('test-file'); + expect(oldData).to.deep.equal({ + ...defaultValues(), + name: 'Old Data', + }); + + // Create new FileUserData with new type name + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newUserData = new FileUserData( + getTestSchema(), + 'iReallyLoveJavaScript' as any, + { + basePath: tmpDir, + } + ); + + // Perform migration + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const migrated = await (newUserData as any).migrateFromOldFolder( + 'iLoveJavaScript' + ); + expect(migrated).to.be.true; + + // Verify data is now accessible from new folder + const newData = await newUserData.readOne('test-file'); + expect(newData).to.deep.equal({ + ...defaultValues(), + name: 'Old Data', + }); + + // Verify old folder no longer exists by checking filesystem directly + const oldFolderPath = path.join(tmpDir, 'iLoveJavaScript'); + try { + await fs.access(oldFolderPath); + expect.fail('Old folder should not exist'); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((error as any).code).to.equal('ENOENT'); + } + }); + + it('returns false when old folder does not exist', async function () { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const userData = new FileUserData( + getTestSchema(), + 'iReallyLoveJavaScript' as any, + { + basePath: tmpDir, + } + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const migrated = await (userData as any).migrateFromOldFolder( + 'nonExistentFolder' + ); + expect(migrated).to.be.false; + }); + + it('returns false when new folder already exists', async function () { + // Create data in both old and new folders + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const oldUserData = new FileUserData( + getTestSchema(), + 'iLoveJavaScript' as any, + { + basePath: tmpDir, + } + ); + await oldUserData.write('old-file', { name: 'Old Data' }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newUserData = new FileUserData( + getTestSchema(), + 'iReallyLoveJavaScript' as any, + { + basePath: tmpDir, + } + ); + await newUserData.write('new-file', { name: 'New Data' }); + + // Attempt migration - should return false because new folder exists + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const migrated = await (newUserData as any).migrateFromOldFolder( + 'iLoveJavaScript' + ); + expect(migrated).to.be.false; + + // Verify both folders still have their data + const oldData = await oldUserData.readOne('old-file'); + expect(oldData?.name).to.equal('Old Data'); + + const newData = await newUserData.readOne('new-file'); + expect(newData?.name).to.equal('New Data'); + }); + + it('returns false when old and new names are the same', async function () { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const userData = new FileUserData( + getTestSchema(), + 'iLoveJavaScript' as any, + { + basePath: tmpDir, + } + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const migrated = await (userData as any).migrateFromOldFolder( + 'iLoveJavaScript' + ); + expect(migrated).to.be.false; + }); + + it('handles migration idempotently', async function () { + // Create data in old folder + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const oldUserData = new FileUserData( + getTestSchema(), + 'iLoveJavaScript' as any, + { + basePath: tmpDir, + } + ); + await oldUserData.write('test-file', { name: 'Migrated Data' }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newUserData = new FileUserData( + getTestSchema(), + 'iReallyLoveJavaScript' as any, + { + basePath: tmpDir, + } + ); + + // First migration should succeed + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const firstMigration = await (newUserData as any).migrateFromOldFolder( + 'iLoveJavaScript' + ); + expect(firstMigration).to.be.true; + + // Second migration attempt should return false (old folder doesn't exist) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const secondMigration = await (newUserData as any).migrateFromOldFolder( + 'iLoveJavaScript' + ); + expect(secondMigration).to.be.false; + + // Data should still be accessible + const data = await newUserData.readOne('test-file'); + expect(data?.name).to.equal('Migrated Data'); + }); + }); }); describe('AtlasUserData', function () { @@ -361,10 +530,7 @@ describe('AtlasUserData', function () { validatorOpts: ValidatorOptions = {}, orgId = 'test-org', projectId = 'test-proj', - type: - | 'recentQueries' - | 'favoriteQueries' - | 'favoriteAggregations' = 'favoriteQueries' + type: UserDataType = 'FavoriteQueries' ) => { return new AtlasUserData(getTestSchema(validatorOpts), type, { orgId, @@ -387,7 +553,7 @@ describe('AtlasUserData', function () { it('writes data successfully', async function () { authenticatedFetchStub.resolves(mockResponse({})); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); const userData = getAtlasUserData(); @@ -398,7 +564,7 @@ describe('AtlasUserData', function () { const [url, options] = authenticatedFetchStub.firstCall.args; expect(url).to.equal( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); expect(options.method).to.equal('PUT'); expect(options.headers['Content-Type']).to.equal('application/json'); @@ -418,7 +584,7 @@ describe('AtlasUserData', function () { new Error('HTTP 500: Internal Server Error') ); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); const userData = getAtlasUserData(); @@ -430,7 +596,7 @@ describe('AtlasUserData', function () { it('validator removes unknown props', async function () { authenticatedFetchStub.resolves(mockResponse({})); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); const userData = getAtlasUserData(); @@ -446,7 +612,7 @@ describe('AtlasUserData', function () { it('uses custom serializer when provided', async function () { authenticatedFetchStub.resolves(mockResponse({})); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); const userData = new AtlasUserData(getTestSchema(), 'FavoriteQueries', { @@ -470,7 +636,7 @@ describe('AtlasUserData', function () { it('deletes data successfully', async function () { authenticatedFetchStub.resolves(mockResponse({})); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj/test-id' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj/test-id' ); const userData = getAtlasUserData(); @@ -481,7 +647,7 @@ describe('AtlasUserData', function () { const [url, options] = authenticatedFetchStub.firstCall.args; expect(url).to.equal( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj/test-id' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj/test-id' ); expect(options.method).to.equal('DELETE'); }); @@ -489,7 +655,7 @@ describe('AtlasUserData', function () { it('returns false when authenticatedFetch throws an error', async function () { authenticatedFetchStub.rejects(new Error('HTTP 404: Not Found')); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); const userData = getAtlasUserData(); @@ -507,7 +673,7 @@ describe('AtlasUserData', function () { ]; authenticatedFetchStub.resolves(mockResponse(responseData)); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); const userData = getAtlasUserData(); @@ -535,7 +701,7 @@ describe('AtlasUserData', function () { expect(authenticatedFetchStub).to.have.been.calledOnce; const [url, options] = authenticatedFetchStub.firstCall.args; expect(url).to.equal( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); expect(options.method).to.equal('GET'); }); @@ -543,7 +709,7 @@ describe('AtlasUserData', function () { it('handles empty response', async function () { authenticatedFetchStub.resolves(mockResponse([])); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); const userData = getAtlasUserData(); @@ -556,7 +722,7 @@ describe('AtlasUserData', function () { it('handles non-array response', async function () { authenticatedFetchStub.resolves(mockResponse({ notAnArray: true })); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); const userData = getAtlasUserData(); @@ -569,7 +735,7 @@ describe('AtlasUserData', function () { it('handles errors gracefully', async function () { authenticatedFetchStub.rejects(new Error('Unknown error')); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); const userData = getAtlasUserData(); @@ -585,7 +751,7 @@ describe('AtlasUserData', function () { new Error('HTTP 500: Internal Server Error') ); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); const userData = getAtlasUserData(); @@ -602,7 +768,7 @@ describe('AtlasUserData', function () { const responseData = [{ data: 'custom:{"name":"Custom"}' }]; authenticatedFetchStub.resolves(mockResponse(responseData)); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); const userData = new AtlasUserData(getTestSchema(), 'FavoriteQueries', { @@ -639,7 +805,7 @@ describe('AtlasUserData', function () { ]; authenticatedFetchStub.resolves(mockResponse(responseData)); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ); const userData = getAtlasUserData(); @@ -671,11 +837,11 @@ describe('AtlasUserData', function () { getResourceUrlStub .onFirstCall() .returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj/test-id' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj/test-id' ) .onSecondCall() .returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj/test-id' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj/test-id' ); const userData = getAtlasUserData(); @@ -690,13 +856,13 @@ describe('AtlasUserData', function () { const [getUrl, getOptions] = authenticatedFetchStub.firstCall.args; expect(getUrl).to.equal( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj/test-id' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj/test-id' ); expect(getOptions.method).to.equal('GET'); const [putUrl, putOptions] = authenticatedFetchStub.secondCall.args; expect(putUrl).to.equal( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj/test-id' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj/test-id' ); expect(putOptions.method).to.equal('PUT'); expect(putOptions.headers['Content-Type']).to.equal('application/json'); @@ -714,7 +880,7 @@ describe('AtlasUserData', function () { .rejects(new Error('HTTP 400: Bad Request')); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj/test-id' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj/test-id' ); const userData = getAtlasUserData(); @@ -739,11 +905,11 @@ describe('AtlasUserData', function () { getResourceUrlStub .onFirstCall() .returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj' ) .onSecondCall() .returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/test-org/test-proj/test-id' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/test-org/test-proj/test-id' ); const userData = new AtlasUserData(getTestSchema(), 'FavoriteQueries', { @@ -769,7 +935,7 @@ describe('AtlasUserData', function () { it('constructs URL correctly for write operation', async function () { authenticatedFetchStub.resolves(mockResponse({})); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/custom-org/custom-proj/test-id' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/custom-org/custom-proj/test-id' ); const userData = getAtlasUserData({}, 'custom-org', 'custom-proj'); @@ -777,14 +943,14 @@ describe('AtlasUserData', function () { const [url] = authenticatedFetchStub.firstCall.args; expect(url).to.equal( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/custom-org/custom-proj/test-id' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/custom-org/custom-proj/test-id' ); }); it('constructs URL correctly for delete operation', async function () { authenticatedFetchStub.resolves(mockResponse({})); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/org123/proj456/item789' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/org123/proj456/item789' ); const userData = getAtlasUserData({}, 'org123', 'proj456'); @@ -792,14 +958,14 @@ describe('AtlasUserData', function () { const [url] = authenticatedFetchStub.firstCall.args; expect(url).to.equal( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/org123/proj456/item789' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/org123/proj456/item789' ); }); it('constructs URL correctly for read operation', async function () { authenticatedFetchStub.resolves(mockResponse({})); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/org456/proj123' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/org456/proj123' ); const userData = getAtlasUserData({}, 'org456', 'proj123'); @@ -808,7 +974,7 @@ describe('AtlasUserData', function () { const [url] = authenticatedFetchStub.firstCall.args; expect(url).to.equal( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/org456/proj123' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/org456/proj123' ); }); @@ -827,11 +993,11 @@ describe('AtlasUserData', function () { getResourceUrlStub .onFirstCall() .returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/org123/proj456' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/org123/proj456' ) .onSecondCall() .returns( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/org123/proj456/item789' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/org123/proj456/item789' ); const userData = getAtlasUserData({}, 'org123', 'proj456'); @@ -841,32 +1007,32 @@ describe('AtlasUserData', function () { const [getUrl] = authenticatedFetchStub.firstCall.args; expect(getUrl).to.equal( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/org123/proj456' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/org123/proj456' ); const [putUrl] = authenticatedFetchStub.secondCall.args; expect(putUrl).to.equal( - 'cluster-connection.cloud.mongodb.com/favoriteQueries/org123/proj456/item789' + 'cluster-connection.cloud.mongodb.com/FavoriteQueries/org123/proj456/item789' ); }); it('constructs URL correctly for different types', async function () { authenticatedFetchStub.resolves(mockResponse({})); getResourceUrlStub.returns( - 'cluster-connection.cloud.mongodb.com/recentQueries/org123/proj456' + 'cluster-connection.cloud.mongodb.com/RecentQueries/org123/proj456' ); const userData = getAtlasUserData( {}, 'org123', 'proj456', - 'recentQueries' + 'RecentQueries' ); await userData.write('item789', { name: 'Recent Item' }); const [url] = authenticatedFetchStub.firstCall.args; expect(url).to.equal( - 'cluster-connection.cloud.mongodb.com/recentQueries/org123/proj456' + 'cluster-connection.cloud.mongodb.com/RecentQueries/org123/proj456' ); }); }); diff --git a/packages/compass-user-data/src/user-data.ts b/packages/compass-user-data/src/user-data.ts index 40ca8397a13..08e78dd4cd1 100644 --- a/packages/compass-user-data/src/user-data.ts +++ b/packages/compass-user-data/src/user-data.ts @@ -8,6 +8,29 @@ import { Semaphore } from './semaphore'; const { log, mongoLogId } = createLogger('COMPASS-USER-STORAGE'); +const validUserDataTypes = [ + 'RecentQueries', + 'FavoriteQueries', + 'SavedPipelines', + 'DataModelDescriptions', + 'WorkspacesState', + 'AppPreferences', + 'Users', + 'Connections', + 'AtlasState', + 'ShellHistory', +] as const; + +export type UserDataType = (typeof validUserDataTypes)[number]; + +export function assertsUserDataType( + value: unknown +): asserts value is UserDataType { + if (!validUserDataTypes.includes(value as UserDataType)) { + throw new Error(`Invalid UserDataType: ${String(value)}`); + } +} + type SerializeContent = (content: I) => string; type DeserializeContent = (content: string) => unknown; type GetResourceUrl = (path?: string) => string; @@ -42,12 +65,12 @@ export interface ReadAllResult { export abstract class IUserData { protected readonly validator: T; - protected readonly dataType: string; + protected readonly dataType: UserDataType; protected readonly serialize: SerializeContent>; protected readonly deserialize: DeserializeContent; constructor( validator: T, - dataType: string, + dataType: UserDataType, { serialize = (content: z.input) => JSON.stringify(content, null, 2), deserialize = JSON.parse, @@ -81,7 +104,7 @@ export class FileUserData extends IUserData { constructor( validator: T, - dataType: string, + dataType: UserDataType, { basePath, serialize, deserialize }: FileUserDataOptions> ) { super(validator, dataType, { serialize, deserialize }); @@ -102,6 +125,66 @@ export class FileUserData extends IUserData { return root; } + /** + * Migrates data from an old folder name to the current dataType folder. + * This is useful when renaming a dataType while preserving existing user data. + * + * @param oldDataType - The old folder name to migrate from + * @returns Promise - true if migration was performed, false if not needed or failed + */ + async migrateFromOldFolder(oldDataType: string): Promise { + if (oldDataType === this.dataType) { + return false; + } + + try { + const basepath = this.basePath ? this.basePath : getStoragePath(); + const oldFolderPath = path.join(basepath, oldDataType); + const newFolderPath = path.join(basepath, this.dataType); + + // Attempt to rename directly - if it succeeds, migration happened + // If it fails, we handle the specific error cases + await fs.rename(oldFolderPath, newFolderPath); + + log.info( + mongoLogId(1_001_000_382), + 'Filesystem', + 'Successfully migrated data folder', + { + from: oldDataType, + to: this.dataType, + } + ); + return true; + } catch (error) { + const err = error as NodeJS.ErrnoException; + + // ENOENT means old folder doesn't exist - nothing to migrate + if (err.code === 'ENOENT') { + return false; + } + + // EEXIST or ENOTEMPTY means new folder already exists + // This could mean migration already happened or user has new data + if (err.code === 'EEXIST' || err.code === 'ENOTEMPTY') { + return false; + } + + // Any other error is a real failure + log.error( + mongoLogId(1_001_000_383), + 'Filesystem', + 'Failed to migrate data folder', + { + from: oldDataType, + to: this.dataType, + error: err.message, + } + ); + return false; + } + } + private async getFileAbsolutePath(filepath?: string): Promise { const root = await this.getEnsuredBasePath(); const pathRelativeToRoot = path.relative( @@ -281,7 +364,7 @@ export class AtlasUserData extends IUserData { private projectId: string = ''; constructor( validator: T, - dataType: string, + dataType: UserDataType, { orgId, projectId, diff --git a/packages/compass-web/package.json b/packages/compass-web/package.json index 2a9f6fb1971..bd95b630b73 100644 --- a/packages/compass-web/package.json +++ b/packages/compass-web/package.json @@ -94,6 +94,7 @@ "@mongodb-js/compass-schema-validation": "^6.86.0", "@mongodb-js/compass-sidebar": "^5.86.0", "@mongodb-js/compass-telemetry": "^1.19.1", + "@mongodb-js/compass-user-data": "^0.11.0", "@mongodb-js/compass-welcome": "^0.84.0", "@mongodb-js/compass-workspaces": "^0.67.0", "@mongodb-js/connection-info": "^0.22.0", diff --git a/packages/compass-web/src/entrypoint.tsx b/packages/compass-web/src/entrypoint.tsx index 5cf5a395641..e57f1447044 100644 --- a/packages/compass-web/src/entrypoint.tsx +++ b/packages/compass-web/src/entrypoint.tsx @@ -91,6 +91,7 @@ import { createServiceProvider } from '@mongodb-js/compass-app-registry'; import { CompassAssistantProvider } from '@mongodb-js/compass-assistant'; import { CompassAssistantDrawerWithConnections } from './compass-assistant-drawer'; import { APP_NAMES_FOR_PROMPT } from '@mongodb-js/compass-assistant'; +import { assertsUserDataType } from '@mongodb-js/compass-user-data'; /** @public */ export type TrackFunction = ( @@ -129,11 +130,8 @@ const WithStorageProviders = createServiceProvider( atlasService.authenticatedFetch.bind(atlasService); const getResourceUrl = (path?: string) => { const pathParts = path?.split('/').filter(Boolean) || []; - const type = pathParts[0] as - | 'favoriteQueries' - | 'recentQueries' - | 'favoriteAggregations' - | 'savedWorkspaces'; + const type = pathParts[0]; + assertsUserDataType(type); const pathOrgId = pathParts[1]; const pathProjectId = pathParts[2]; const id = pathParts[3]; diff --git a/packages/my-queries-storage/src/storage-factories.ts b/packages/my-queries-storage/src/storage-factories.ts index 9c537319762..9236e491a53 100644 --- a/packages/my-queries-storage/src/storage-factories.ts +++ b/packages/my-queries-storage/src/storage-factories.ts @@ -20,7 +20,7 @@ export type WebStorageOptions = { }; export function createWebRecentQueryStorage(options: WebStorageOptions) { - const userData = new AtlasUserData(RecentQuerySchema, 'recentQueries', { + const userData = new AtlasUserData(RecentQuerySchema, 'RecentQueries', { orgId: options.orgId, projectId: options.projectId, getResourceUrl: options.getResourceUrl, @@ -32,7 +32,7 @@ export function createWebRecentQueryStorage(options: WebStorageOptions) { } export function createWebFavoriteQueryStorage(options: WebStorageOptions) { - const userData = new AtlasUserData(FavoriteQuerySchema, 'favoriteQueries', { + const userData = new AtlasUserData(FavoriteQuerySchema, 'FavoriteQueries', { orgId: options.orgId, projectId: options.projectId, getResourceUrl: options.getResourceUrl, @@ -44,7 +44,7 @@ export function createWebFavoriteQueryStorage(options: WebStorageOptions) { } export function createWebPipelineStorage(options: WebStorageOptions) { - const userData = new AtlasUserData(PipelineSchema, 'favoriteAggregations', { + const userData = new AtlasUserData(PipelineSchema, 'SavedPipelines', { orgId: options.orgId, projectId: options.projectId, getResourceUrl: options.getResourceUrl,