diff --git a/package-lock.json b/package-lock.json index 61867e48abe..aab539e579a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11445,9 +11445,9 @@ } }, "node_modules/@sideway/address": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.2.tgz", - "integrity": "sha512-idTz8ibqWFrPU8kMirL0CoPH/A29XOzzAzpyN3zQ4kAWnzmNfFmRaoMNN6VI8ske5M73HZyhIaW4OuSFIdM4oA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", + "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", "dependencies": { "@hapi/hoek": "^9.0.0" } @@ -27213,14 +27213,14 @@ } }, "node_modules/joi": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.4.0.tgz", - "integrity": "sha512-F4WiW2xaV6wc1jxete70Rw4V/VuMd6IN+a5ilZsxG4uYtUXWu2kq9W5P2dz30e7Gmw8RCbY/u/uk+dMPma9tAg==", + "version": "17.9.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.9.2.tgz", + "integrity": "sha512-Itk/r+V4Dx0V3c7RLFdRh12IOjySm2/WGPMubBT92cQvRfYZhPM2W0hZlctjj72iES8jsRCwp7S/cRmWBnJ4nw==", "dependencies": { "@hapi/hoek": "^9.0.0", "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.0", - "@sideway/formula": "^3.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, @@ -44128,13 +44128,12 @@ "@mongodb-js/compass-logging": "^1.1.6", "@mongodb-js/compass-utils": "^0.3.1", "ampersand-collection-filterable": "^0.3.0", - "ampersand-model": "^8.0.1", "ampersand-rest-collection": "^6.0.0", "ampersand-state": "5.0.3", "bson": "^5.2.0", + "joi": "^17.9.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", - "storage-mixin": "^5.1.5", "yargs-parser": "^21.1.1" }, "devDependencies": { @@ -65368,9 +65367,9 @@ } }, "@sideway/address": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.2.tgz", - "integrity": "sha512-idTz8ibqWFrPU8kMirL0CoPH/A29XOzzAzpyN3zQ4kAWnzmNfFmRaoMNN6VI8ske5M73HZyhIaW4OuSFIdM4oA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", + "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", "requires": { "@hapi/hoek": "^9.0.0" } @@ -71441,7 +71440,6 @@ "@types/js-yaml": "^4.0.5", "@types/yargs-parser": "21.0.0", "ampersand-collection-filterable": "^0.3.0", - "ampersand-model": "^8.0.1", "ampersand-rest-collection": "^6.0.0", "ampersand-state": "5.0.3", "bson": "^5.2.0", @@ -71449,12 +71447,12 @@ "depcheck": "^1.4.1", "eslint": "^7.25.0", "hadron-ipc": "^3.1.3", + "joi": "^17.9.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mocha": "^10.2.0", "react": "^17.0.2", "sinon": "^9.2.3", - "storage-mixin": "^5.1.5", "yargs-parser": "^21.1.1" }, "dependencies": { @@ -81041,14 +81039,14 @@ "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" }, "joi": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.4.0.tgz", - "integrity": "sha512-F4WiW2xaV6wc1jxete70Rw4V/VuMd6IN+a5ilZsxG4uYtUXWu2kq9W5P2dz30e7Gmw8RCbY/u/uk+dMPma9tAg==", + "version": "17.9.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.9.2.tgz", + "integrity": "sha512-Itk/r+V4Dx0V3c7RLFdRh12IOjySm2/WGPMubBT92cQvRfYZhPM2W0hZlctjj72iES8jsRCwp7S/cRmWBnJ4nw==", "requires": { "@hapi/hoek": "^9.0.0", "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.0", - "@sideway/formula": "^3.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, diff --git a/packages/compass-preferences-model/package.json b/packages/compass-preferences-model/package.json index ab1ca5430f3..5f66a8273c1 100644 --- a/packages/compass-preferences-model/package.json +++ b/packages/compass-preferences-model/package.json @@ -47,13 +47,12 @@ "@mongodb-js/compass-logging": "^1.1.6", "@mongodb-js/compass-utils": "^0.3.1", "ampersand-collection-filterable": "^0.3.0", - "ampersand-model": "^8.0.1", "ampersand-rest-collection": "^6.0.0", "ampersand-state": "5.0.3", "bson": "^5.2.0", + "joi": "^17.9.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", - "storage-mixin": "^5.1.5", "yargs-parser": "^21.1.1" }, "devDependencies": { diff --git a/packages/compass-preferences-model/src/global-config.spec.ts b/packages/compass-preferences-model/src/global-config.spec.ts index 89eca791a1b..00511516ecb 100644 --- a/packages/compass-preferences-model/src/global-config.spec.ts +++ b/packages/compass-preferences-model/src/global-config.spec.ts @@ -190,7 +190,7 @@ forceConnectionOptions: global: {}, cli: {}, preferenceParseErrors: [ - `Type for option "enableMaps" mismatches: expected boolean, received string (while validating preferences from: Global config file: ${file})`, + `enableMaps: "value" must be a boolean (while validating preferences from: Global config file: ${file})`, ], }); }); diff --git a/packages/compass-preferences-model/src/global-config.ts b/packages/compass-preferences-model/src/global-config.ts index 2a95d477a55..e808198eb49 100644 --- a/packages/compass-preferences-model/src/global-config.ts +++ b/packages/compass-preferences-model/src/global-config.ts @@ -5,8 +5,9 @@ import yaml from 'js-yaml'; import type { Options as YargsOptions } from 'yargs-parser'; import yargsParser from 'yargs-parser'; import { kebabCase } from 'lodash'; -import type { AmpersandType, AllPreferences } from './preferences'; +import type { AllPreferences } from './preferences'; import { allPreferencesProps } from './preferences'; +import type { Types as JoiTypes } from 'joi'; import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging'; const { log, mongoLogId } = createLoggerAndTelemetry('COMPASS-PREFERENCES'); @@ -91,11 +92,11 @@ async function loadGlobalPreferences( const cliProps = Object.entries(allPreferencesProps).filter( ([, definition]) => definition.cli ); -function getCliPropNamesByType(type: AmpersandType): string[] { +function getCliPropNamesByType(type: JoiTypes): string[] { return [ ...new Set( cliProps - .filter(([, definition]) => definition.type === type) + .filter(([, definition]) => definition.validator.type === type) .flatMap(([key]) => [key, kebabCase(key)]) ), ]; @@ -170,30 +171,15 @@ function validatePreferences( // as an option value, e.g. an object into an array of key-value pairs const process = allPreferencesProps[key].customPostProcess; const value = process ? process(rawValue, error) : rawValue; - // `typeof` + `isArray` is good enough for everything we need right now, but we can of course expand this check over time - if ( - (Array.isArray(value) ? 'array' : typeof value) !== - allPreferencesProps[key].type - ) { - error( - `Type for option "${key}" mismatches: expected ${ - allPreferencesProps[key].type - }, received ${typeof value}` - ); - continue; - } - if ( - allPreferencesProps[key].values && - !(allPreferencesProps[key].values as unknown[])?.includes(value) - ) { - error( - `Value for option "${key}" is not allowed: expected one of [${String( - allPreferencesProps[key].values?.join(', ') - )}], received ${String(value)}` - ); + + const validationResults = + allPreferencesProps[key].validator.validate(value); + if (validationResults.error) { + error(`${key}: ${validationResults.error.message}`); continue; } - obj[key] = value as any; + + obj[key] = validationResults.value as any; } return [obj, errors]; } diff --git a/packages/compass-preferences-model/src/preferences.spec.ts b/packages/compass-preferences-model/src/preferences.spec.ts index 6a1e1071f5e..753c8f71fc7 100644 --- a/packages/compass-preferences-model/src/preferences.spec.ts +++ b/packages/compass-preferences-model/src/preferences.spec.ts @@ -13,6 +13,14 @@ const expectedReleasedFeatureFlagsStates = Object.fromEntries( releasedFeatureFlags.map((ff) => [ff, 'hardcoded']) ); +const setupPreferences = async ( + ...args: ConstructorParameters +) => { + const preferences = new Preferences(...args); + await preferences.setupStorage(); + return preferences; +}; + describe('Preferences class', function () { let tmpdir: string; let i = 0; @@ -27,23 +35,33 @@ describe('Preferences class', function () { }); it('allows providing default preferences', async function () { - const preferences = new Preferences(tmpdir); - const result = await preferences.fetchPreferences(); + const preferences = await setupPreferences(tmpdir); + const result = preferences.getPreferences(); expect(result.id).to.equal('General'); expect(result.enableMaps).to.equal(false); expect(result.enableShell).to.equal(true); }); it('allows saving preferences', async function () { - const preferences = new Preferences(tmpdir); + const preferences = await setupPreferences(tmpdir); await preferences.savePreferences({ enableMaps: true }); - const result = await preferences.fetchPreferences(); + const result = preferences.getPreferences(); expect(result.id).to.equal('General'); expect(result.enableMaps).to.equal(true); }); + it('throws when saving invalid data', async function () { + const preferences = await setupPreferences(tmpdir); + expect( + async () => + await preferences.savePreferences({ + telemetryAnonymousId: 'not-a-uuid', + }) + ).to.throw; + }); + it('forbids saving non-model preferences', async function () { - const preferences = new Preferences(tmpdir); + const preferences = await setupPreferences(tmpdir); try { // @ts-expect-error That this doesn't work is part of the test await preferences.savePreferences({ help: true }); @@ -56,84 +74,33 @@ describe('Preferences class', function () { }); it('stores preferences across instances', async function () { - const preferences1 = new Preferences(tmpdir); + const preferences1 = await setupPreferences(tmpdir); await preferences1.savePreferences({ enableMaps: true }); - const preferences2 = new Preferences(tmpdir); - const result = await preferences2.fetchPreferences(); + const preferences2 = await setupPreferences(tmpdir); + const result = preferences2.getPreferences(); expect(result.id).to.equal('General'); expect(result.enableMaps).to.equal(true); }); it('notifies callers of preferences changes after savePreferences', async function () { - const preferences = new Preferences(tmpdir); + const preferences = await setupPreferences(tmpdir); const calls: any[] = []; preferences.onPreferencesChanged((prefs) => calls.push(prefs)); await preferences.savePreferences({ enableMaps: true }); expect(calls).to.deep.equal([{ enableMaps: true }]); }); - it('notifies callers of preferences changes after fetchPreferences', async function () { - const calls: any[] = []; - - const preferences1 = new Preferences(tmpdir); - preferences1.onPreferencesChanged((prefs) => calls.push(1, prefs)); - await preferences1.fetchPreferences(); - const preferences2 = new Preferences(tmpdir); - preferences2.onPreferencesChanged((prefs) => calls.push(2, prefs)); - await preferences2.fetchPreferences(); - - await preferences1.savePreferences({ enableMaps: true }); - await preferences2.fetchPreferences(); - - expect(calls).to.deep.equal([ - 1, - { enableMaps: true }, - 2, - { enableMaps: true }, - ]); - }); - - it('handles concurrent modifications to different preferences', async function () { - const calls: any[] = []; - - const preferences1 = new Preferences(tmpdir); - preferences1.onPreferencesChanged((prefs) => calls.push(1, prefs)); - await preferences1.fetchPreferences(); - const preferences2 = new Preferences(tmpdir); - preferences2.onPreferencesChanged((prefs) => calls.push(2, prefs)); - await preferences2.fetchPreferences(); - - await preferences1.savePreferences({ enableMaps: true }); - await preferences2.savePreferences({ autoUpdates: true }); - const result1 = await preferences1.fetchPreferences(); - const result2 = await preferences2.fetchPreferences(); - expect(result1).to.deep.equal(result2); - expect(result1.enableMaps).to.equal(true); - expect(result1.autoUpdates).to.equal(true); - - expect(calls).to.deep.equal([ - 1, - { enableMaps: true }, - 2, - { enableMaps: true }, - 2, - { autoUpdates: true }, - 1, - { autoUpdates: true }, - ]); - }); - it('can return user-configurable preferences after setting their defaults', async function () { - const preferences = new Preferences(tmpdir); + const preferences = await setupPreferences(tmpdir); await preferences.ensureDefaultConfigurableUserPreferences(); - const result = await preferences.getConfigurableUserPreferences(); + const result = preferences.getConfigurableUserPreferences(); expect(result).not.to.have.property('id'); expect(result.enableMaps).to.equal(true); expect(result.enableShell).to.equal(true); }); it('allows providing cli- and global-config-provided options', async function () { - const preferences = new Preferences(tmpdir, { + const preferences = await setupPreferences(tmpdir, { cli: { enableMaps: false, trackUsageStatistics: true, @@ -143,7 +110,7 @@ describe('Preferences class', function () { }, }); await preferences.ensureDefaultConfigurableUserPreferences(); - const result = await preferences.getConfigurableUserPreferences(); + const result = preferences.getConfigurableUserPreferences(); expect(result).not.to.have.property('id'); expect(result.autoUpdates).to.equal(true); expect(result.enableMaps).to.equal(false); @@ -158,7 +125,7 @@ describe('Preferences class', function () { }); it('allows providing true options that influence the values of other options', async function () { - const preferences = new Preferences(tmpdir, { + const preferences = await setupPreferences(tmpdir, { cli: { enableMaps: true, enableShell: true, @@ -169,7 +136,7 @@ describe('Preferences class', function () { readOnly: true, }, }); - const result = await preferences.fetchPreferences(); + const result = preferences.getPreferences(); expect(result.autoUpdates).to.equal(false); expect(result.enableMaps).to.equal(false); expect(result.trackUsageStatistics).to.equal(false); @@ -192,12 +159,12 @@ describe('Preferences class', function () { }); it('allows providing false options that should not influence the values of other options', async function () { - const preferences = new Preferences(tmpdir, { + const preferences = await setupPreferences(tmpdir, { global: { readOnly: false, }, }); - const result = await preferences.fetchPreferences(); + const result = preferences.getPreferences(); expect(result.readOnly).to.equal(false); expect(result.enableShell).to.equal(true); @@ -210,7 +177,7 @@ describe('Preferences class', function () { }); it('accounts for derived preference values in save calls', async function () { - const preferences = new Preferences(tmpdir, { + const preferences = await setupPreferences(tmpdir, { global: { networkTraffic: false, }, @@ -218,23 +185,23 @@ describe('Preferences class', function () { const calls: any[] = []; preferences.onPreferencesChanged((prefs) => calls.push(prefs)); - const fetchResult = await preferences.fetchPreferences(); + const fetchResult = preferences.getPreferences(); expect(fetchResult.autoUpdates).to.equal(false); const saveResult = await preferences.savePreferences({ autoUpdates: true }); expect(saveResult.autoUpdates).to.equal(false); // (!) expect(calls).to.have.lengthOf(0); // no updates, networkTraffic overrides change - const preferences2 = new Preferences(tmpdir); - const fetchResult2 = await preferences2.fetchPreferences(); + const preferences2 = await setupPreferences(tmpdir); + const fetchResult2 = preferences2.getPreferences(); expect(fetchResult2.autoUpdates).to.equal(true); // (!) }); it('includes changes to derived preference values in change listeners', async function () { - const preferences = new Preferences(tmpdir); + const preferences = await setupPreferences(tmpdir); const calls: any[] = []; preferences.onPreferencesChanged((prefs) => calls.push(prefs)); await preferences.ensureDefaultConfigurableUserPreferences(); - await preferences.getConfigurableUserPreferences(); // set defaults + preferences.getConfigurableUserPreferences(); // set defaults await preferences.savePreferences({ networkTraffic: false }); await preferences.savePreferences({ readOnly: true }); expect(calls).to.deep.equal([ @@ -260,7 +227,7 @@ describe('Preferences class', function () { }); it('allows hardcoding some options and derive other option values based on that', async function () { - const preferences = new Preferences(tmpdir, { + const preferences = await setupPreferences(tmpdir, { cli: { enableMaps: true, }, @@ -271,7 +238,7 @@ describe('Preferences class', function () { networkTraffic: false, }, }); - const result = await preferences.fetchPreferences(); + const result = preferences.getPreferences(); expect(result.autoUpdates).to.equal(false); expect(result.enableMaps).to.equal(false); expect(result.enableDevTools).to.equal(true); @@ -290,7 +257,7 @@ describe('Preferences class', function () { }); it('can create sandbox preferences instances that do not affect the main preference instance', async function () { - const mainPreferences = new Preferences(tmpdir, { + const mainPreferences = await setupPreferences(tmpdir, { cli: { enableMaps: true, }, @@ -304,8 +271,8 @@ describe('Preferences class', function () { ); await sandbox.savePreferences({ readOnly: true }); - expect((await sandbox.fetchPreferences()).readOnly).to.equal(true); - expect((await mainPreferences.fetchPreferences()).readOnly).to.equal(false); + expect(sandbox.getPreferences().readOnly).to.equal(true); + expect(mainPreferences.getPreferences().readOnly).to.equal(false); const mainPreferencesStates = mainPreferences.getPreferenceStates(); diff --git a/packages/compass-preferences-model/src/preferences.ts b/packages/compass-preferences-model/src/preferences.ts index 741fe877da9..aa3bca05816 100644 --- a/packages/compass-preferences-model/src/preferences.ts +++ b/packages/compass-preferences-model/src/preferences.ts @@ -1,18 +1,17 @@ -import storageMixin from 'storage-mixin'; -import { promisifyAmpersandMethod } from '@mongodb-js/compass-utils'; -import type { AmpersandMethodOptions } from '@mongodb-js/compass-utils'; import type { ParsedGlobalPreferencesResult } from './global-config'; - +import { + SandboxPreferences, + StoragePreferences, + type BasePreferencesStorage, +} from './storage'; import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging'; import { parseRecord } from './parse-record'; import type { FeatureFlagDefinition, FeatureFlags } from './feature-flags'; import { featureFlags } from './feature-flags'; +import Joi from 'joi'; const { log, mongoLogId } = createLoggerAndTelemetry('COMPASS-PREFERENCES'); -// eslint-disable-next-line @typescript-eslint/no-var-requires -const Model = require('ampersand-model'); - export const THEMES_VALUES = ['DARK', 'LIGHT', 'OS_THEME'] as const; export type THEMES = typeof THEMES_VALUES[number]; @@ -52,12 +51,11 @@ export type InternalUserPreferences = { showedNetworkOptIn: boolean; // Has the settings dialog been shown before. id: string; lastKnownVersion: string; - currentUserId: string; + currentUserId?: string; telemetryAnonymousId: string; }; -// UserPreferences contains all preferences stored to disk in the -// per-user preferences model (currently the Ampersand model). +// UserPreferences contains all preferences stored to disk. export type UserPreferences = UserConfigurablePreferences & InternalUserPreferences; @@ -87,46 +85,12 @@ type OnPreferencesChangedCallback = ( changedPreferencesValues: Partial ) => void; -declare class PreferencesAmpersandModel { - fetch: () => void; - save: ( - attributes?: AmpersandMethodOptions, - options?: AmpersandMethodOptions - ) => void; - getAttributes: (options?: { - props?: boolean; - derived?: boolean; - }) => UserPreferences; -} - -export type AmpersandType = T extends string - ? 'string' - : T extends boolean - ? 'boolean' - : T extends number - ? 'number' - : T extends any[] - ? 'array' - : T extends Date - ? 'date' - : T extends object - ? 'object' - : never; - type PostProcessFunction = ( input: unknown, error: (message: string) => void ) => T; type PreferenceDefinition = { - /** The type of the preference value, in Ampersand naming */ - type: AmpersandType; - /** An optional default value for the preference */ - default?: AllPreferences[K]; - /** Whether the preference is required in the Ampersand model */ - required: boolean; - /** An exhaustive list of possible values for this preference (also an Ampersand feature) */ - values?: readonly AllPreferences[K][]; /** Whether the preference can be modified through the Settings UI */ ui: K extends keyof UserConfigurablePreferences ? true : false; /** Whether the preference can be set on the command line */ @@ -156,6 +120,7 @@ type PreferenceDefinition = { ? boolean : false : boolean; + validator: Joi.Schema; }; type DeriveValueFunction = ( @@ -214,9 +179,6 @@ function featureFlagToPreferenceDefinition( featureFlag: FeatureFlagDefinition ): PreferenceDefinition { return { - type: 'boolean', - required: false, - default: false, cli: true, global: true, ui: true, @@ -227,6 +189,7 @@ function featureFlagToPreferenceDefinition( featureFlag.stage === 'released' ? () => ({ value: true, state: 'hardcoded' }) : undefined, + validator: Joi.boolean().default(false), }; } @@ -246,9 +209,6 @@ const allFeatureFlagsProps: Required<{ }> = { /** Meta-feature-flag! Whether to show the dev flags of the feature flag settings modal */ showDevFeatureFlags: { - type: 'boolean', - required: false, - default: undefined, ui: true, cli: true, global: true, @@ -256,6 +216,7 @@ const allFeatureFlagsProps: Required<{ description: { short: 'Show Developer Feature Flags', }, + validator: Joi.boolean().optional(), }, /** @@ -264,75 +225,66 @@ const allFeatureFlagsProps: Required<{ * officially support the CSFLE schemaMap property. */ enableDebugUseCsfleSchemaMap: { - type: 'boolean', - required: false, - default: undefined, ui: true, cli: true, global: true, description: { short: 'CSFLE Schema Map Debugging', }, + validator: Joi.boolean().optional(), }, ...featureFlagsProps, }; -const modelPreferencesProps: Required<{ +const storedUserPreferencesProps: Required<{ [K in keyof UserPreferences]: PreferenceDefinition; }> = { /** * String identifier for this set of preferences. Default is `General`. */ id: { - type: 'string', - default: 'General', - required: true, ui: false, cli: false, global: false, description: null, + validator: Joi.string().default('General'), }, /** * Stores the last version compass was run as, e.g. `1.0.5`. */ lastKnownVersion: { - type: 'string', - required: false, - default: '0.0.0', ui: false, cli: false, global: false, description: null, + validator: Joi.string().default('0.0.0'), }, /** * Stores whether or not the network opt-in screen has been shown to * the user already. */ showedNetworkOptIn: { - type: 'boolean', - required: true, - default: false, ui: false, cli: true, global: false, description: null, omitFromHelp: true, + validator: Joi.boolean().default(false), }, /** * Stores the theme preference for the user. */ theme: { - type: 'string', - required: true, - default: 'LIGHT', - values: THEMES_VALUES, ui: true, cli: true, global: true, description: { short: 'Compass UI Theme', }, + validator: Joi.string() + .valid(...THEMES_VALUES) + .default('LIGHT'), }, /** * Stores a unique MongoDB ID for the current user. @@ -341,24 +293,21 @@ const modelPreferencesProps: Required<{ * The telemetryAnonymousId should be used instead. */ currentUserId: { - type: 'string', - required: false, ui: false, cli: false, global: false, description: null, + validator: Joi.string().optional(), }, /** * Stores a unique telemetry anonymous ID (uuid) for the current user. */ telemetryAnonymousId: { - type: 'string', - required: true, - default: '', ui: false, cli: false, global: false, description: null, + validator: Joi.string().uuid(), }, /** * Master switch to disable all network traffic @@ -367,23 +316,18 @@ const modelPreferencesProps: Required<{ * (which includes maps, telemetry, auto-updates). */ networkTraffic: { - type: 'boolean', - required: true, - default: true, ui: true, cli: true, global: true, description: { short: 'Enable network traffic other than to the MongoDB database', }, + validator: Joi.boolean().default(true), }, /** * Removes features that write to the database from the UI. */ readOnly: { - type: 'boolean', - required: true, - default: false, ui: true, cli: true, global: true, @@ -391,14 +335,12 @@ const modelPreferencesProps: Required<{ short: 'Set Read-Only Mode', long: 'Limit Compass strictly to read operations, with all write and delete capabilities removed.', }, + validator: Joi.boolean().default(false), }, /** * Switch to enable/disable the embedded shell. */ enableShell: { - type: 'boolean', - required: true, - default: true, ui: true, cli: true, global: true, @@ -407,14 +349,12 @@ const modelPreferencesProps: Required<{ long: 'Allow Compass to interacting with MongoDB deployments via the embedded shell.', }, deriveValue: deriveReadOnlyOptionState('enableShell'), + validator: Joi.boolean().default(true), }, /** * Switch to enable/disable maps rendering. */ enableMaps: { - type: 'boolean', - required: true, - default: false, ui: true, cli: true, global: true, @@ -423,14 +363,12 @@ const modelPreferencesProps: Required<{ long: 'Allow Compass to make requests to a 3rd party mapping service.', }, deriveValue: deriveNetworkTrafficOptionState('enableMaps'), + validator: Joi.boolean().default(false), }, /** * Switch to enable/disable Intercom panel (renamed from `intercom`). */ enableFeedbackPanel: { - type: 'boolean', - required: true, - default: false, ui: true, cli: true, global: true, @@ -439,15 +377,13 @@ const modelPreferencesProps: Required<{ long: 'Enables a tool that our Product team can use to occasionally reach out for feedback about Compass.', }, deriveValue: deriveNetworkTrafficOptionState('enableFeedbackPanel'), + validator: Joi.boolean().default(false), }, /** * Switch to enable/disable usage statistics collection * (renamed from `googleAnalytics`). */ trackUsageStatistics: { - type: 'boolean', - required: true, - default: false, ui: true, cli: true, global: true, @@ -456,14 +392,12 @@ const modelPreferencesProps: Required<{ long: 'Allow Compass to send anonymous usage statistics.', }, deriveValue: deriveNetworkTrafficOptionState('trackUsageStatistics'), + validator: Joi.boolean().default(false), }, /** * Switch to enable/disable automatic updates. */ autoUpdates: { - type: 'boolean', - required: true, - default: false, ui: true, cli: true, global: true, @@ -472,14 +406,12 @@ const modelPreferencesProps: Required<{ long: 'Allow Compass to periodically check for new updates.', }, deriveValue: deriveNetworkTrafficOptionState('autoUpdates'), + validator: Joi.boolean().default(false), }, /** * Switch to hide credentials in connection strings from users. */ protectConnectionStrings: { - type: 'boolean', - required: false, - default: false, ui: true, cli: true, global: true, @@ -487,14 +419,12 @@ const modelPreferencesProps: Required<{ short: 'Protect Connection String Secrets', long: 'Hide credentials in connection strings from users.', }, + validator: Joi.boolean().default(false), }, /** * Switch to enable DevTools in Electron. */ enableDevTools: { - type: 'boolean', - required: false, - default: process.env.APP_ENV === 'webdriverio', ui: true, cli: true, global: true, @@ -503,14 +433,12 @@ const modelPreferencesProps: Required<{ long: `Enable the Chromium Developer Tools that can be used to debug Electron's process.`, }, deriveValue: deriveFeatureRestrictingOptionsState('enableDevTools'), + validator: Joi.boolean().default(process.env.APP_ENV === 'webdriverio'), }, /** * Switch to show the Kerberos password field in the connection form. */ showKerberosPasswordField: { - type: 'boolean', - required: false, - default: false, ui: true, cli: true, global: true, @@ -518,14 +446,12 @@ const modelPreferencesProps: Required<{ short: 'Show Kerberos Password Field', long: 'Show a password field for Kerberos authentication. Typically only useful when attempting to authenticate as another user than the current system user.', }, + validator: Joi.boolean().default(false), }, /** * Switch to show the OIDC device auth flow option in the connection form. */ showOIDCDeviceAuthFlow: { - type: 'boolean', - required: false, - default: false, ui: true, cli: true, global: true, @@ -533,14 +459,12 @@ const modelPreferencesProps: Required<{ short: 'Show Device Auth Flow Checkbox', long: 'Show a checkbox on the connection form to enable device auth flow authentication. This enables a less secure authentication flow that can be used as a fallback when browser-based authentication is unavailable.', }, + validator: Joi.boolean().default(false), }, /** * Input to change the browser command used for OIDC authentication. */ browserCommandForOIDCAuth: { - type: 'string', - required: false, - default: undefined, ui: true, cli: true, global: true, @@ -548,14 +472,12 @@ const modelPreferencesProps: Required<{ short: 'Browser command to use for OIDC Authentication', long: 'Specify a shell command that is run to start the browser for authenticating with the OIDC identity provider. Leave this empty for default browser.', }, + validator: Joi.string().optional(), }, /** * Input to change the browser command used for OIDC authentication. */ persistOIDCTokens: { - type: 'boolean', - required: false, - default: true, ui: true, cli: true, global: true, @@ -563,14 +485,12 @@ const modelPreferencesProps: Required<{ short: 'Stay logged in with OIDC', long: 'Remain logged in when using the MONGODB-OIDC authentication mechanism. Access tokens are encrypted using the system keychain before being stored.', }, + validator: Joi.boolean().default(true), }, /** * Override certain connection string properties. */ forceConnectionOptions: { - type: 'array', - required: false, - default: undefined, ui: true, cli: true, global: true, @@ -579,28 +499,26 @@ const modelPreferencesProps: Required<{ long: 'Force connection string properties to take specific values', }, customPostProcess: parseRecord, + validator: Joi.array() + .items(Joi.array().length(2).items(Joi.string())) + .optional(), }, /** * Set an upper limit for maxTimeMS for operations started by Compass. */ maxTimeMS: { - type: 'number', - required: false, - default: undefined, ui: true, cli: true, global: true, description: { short: 'Upper Limit for maxTimeMS for Compass Database Operations', }, + validator: Joi.number().optional(), }, /** * Do not handle mongodb:// and mongodb+srv:// URLs via Compass */ installURLHandlers: { - type: 'boolean', - required: true, - default: true, ui: true, cli: true, global: true, @@ -608,15 +526,13 @@ const modelPreferencesProps: Required<{ short: 'Install Compass as URL Protocol Handler', long: 'Register Compass as a handler for mongodb:// and mongodb+srv:// URLs', }, + validator: Joi.boolean().default(true), }, /** * Determines if the toggle to edit connection string for new connections * should be in the off state or in the on state by default */ protectConnectionStringsForNewConnections: { - type: 'boolean', - required: false, - default: false, ui: true, cli: true, global: true, @@ -624,6 +540,7 @@ const modelPreferencesProps: Required<{ short: 'If true, "Edit connection string" is disabled for new connections by default', }, + validator: Joi.boolean().default(false), }, ...allFeatureFlagsProps, }; @@ -632,8 +549,6 @@ const cliOnlyPreferencesProps: Required<{ [K in keyof CliOnlyPreferences]: PreferenceDefinition; }> = { exportConnections: { - type: 'string', - required: false, ui: false, cli: true, global: false, @@ -641,10 +556,9 @@ const cliOnlyPreferencesProps: Required<{ short: 'Export Favorite Connections', long: 'Export Compass favorite connections. Can be used with --passphrase.', }, + validator: Joi.string().optional(), }, importConnections: { - type: 'string', - required: false, ui: false, cli: true, global: false, @@ -652,10 +566,9 @@ const cliOnlyPreferencesProps: Required<{ short: 'Import Favorite Connections', long: 'Import Compass favorite connections. Can be used with --passphrase.', }, + validator: Joi.string().optional(), }, passphrase: { - type: 'string', - required: false, ui: false, cli: true, global: false, @@ -663,36 +576,34 @@ const cliOnlyPreferencesProps: Required<{ short: 'Connection Export/Import Passphrase', long: 'Specify a passphrase for encrypting/decrypting secrets.', }, + validator: Joi.string().optional(), }, help: { - type: 'boolean', - required: false, ui: false, cli: true, global: false, description: { short: 'Show Compass Options', }, + validator: Joi.boolean().optional(), }, version: { - type: 'boolean', - required: false, ui: false, cli: true, global: false, description: { short: 'Show Compass Version', }, + validator: Joi.boolean().optional(), }, showExampleConfig: { - type: 'boolean', - required: false, ui: false, cli: true, global: false, description: { short: 'Show Example Config File', }, + validator: Joi.boolean().optional(), }, }; @@ -700,9 +611,6 @@ const nonUserPreferences: Required<{ [K in keyof NonUserPreferences]: PreferenceDefinition; }> = { ignoreAdditionalCommandLineFlags: { - type: 'boolean', - required: false, - default: false, ui: false, cli: true, global: true, @@ -710,10 +618,9 @@ const nonUserPreferences: Required<{ short: 'Allow Additional CLI Flags', long: 'Allow specifying command-line flags that Compass does not understand, e.g. Electron or Chromium flags', }, + validator: Joi.boolean().default(false), }, positionalArguments: { - type: 'array', - required: false, ui: false, cli: true, global: false, @@ -722,43 +629,41 @@ const nonUserPreferences: Required<{ 'Specify a Connection String or Connection ID to Automatically Connect', }, omitFromHelp: true, + validator: Joi.array().items(Joi.string()).optional(), }, file: { - type: 'string', - required: false, ui: false, cli: true, global: true, description: { short: 'Specify a List of Connections for Automatically Connecting', }, + validator: Joi.string().optional(), }, username: { - type: 'string', - required: false, ui: false, cli: true, global: true, description: { short: 'Specify a Username for Automatically Connecting', }, + validator: Joi.string().optional(), }, password: { - type: 'string', - required: false, ui: false, cli: true, global: true, description: { short: 'Specify a Password for Automatically Connecting', }, + validator: Joi.string().optional(), }, }; export const allPreferencesProps: Required<{ [K in keyof AllPreferences]: PreferenceDefinition; }> = { - ...modelPreferencesProps, + ...storedUserPreferencesProps, ...cliOnlyPreferencesProps, ...nonUserPreferences, }; @@ -767,11 +672,14 @@ export function getSettingDescription< Name extends Exclude >( name: Name -): Pick, 'description' | 'type' | 'required'> { - const { description, type, required } = allPreferencesProps[ +): Pick, 'description'> & { type: unknown } { + const { description, validator } = allPreferencesProps[ name ] as PreferenceDefinition; - return { description, type, required }; + return { + description, + type: validator.type, + }; } /* Identifies a source from which the preference was set */ @@ -795,7 +703,7 @@ type PreferenceSandboxPropertiesImpl = { export class Preferences { private _onPreferencesChangedCallbacks: OnPreferencesChangedCallback[]; - private _userPreferencesModel: PreferencesAmpersandModel; + private _preferencesStorage: BasePreferencesStorage; private _globalPreferences: { cli: Partial; global: Partial; @@ -807,28 +715,21 @@ export class Preferences { globalPreferences?: Partial, isSandbox?: boolean ) { - const ampersandModelDefinition = { - props: modelPreferencesProps, - extraProperties: 'ignore', - idAttribute: 'id', - }; - // User preferences are stored to disk via the Ampersand model, - // or not stored externally at all if that was requested. - const PreferencesModel = Model.extend(storageMixin, { - ...ampersandModelDefinition, - namespace: 'AppPreferences', - storage: isSandbox - ? { - backend: 'null', - } - : { - backend: 'disk', - basepath, - }, - }); + const defaultPreferences = Object.fromEntries( + Object.entries(storedUserPreferencesProps) + .map(([key, value]) => [ + key, + value.validator.validate(undefined)?.value, + ]) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([key, value]) => value !== undefined) + ) as UserPreferences; + + this._preferencesStorage = isSandbox + ? new SandboxPreferences(defaultPreferences) + : new StoragePreferences(defaultPreferences, basepath); this._onPreferencesChangedCallbacks = []; - this._userPreferencesModel = new PreferencesModel(); this._globalPreferences = { cli: {}, global: {}, @@ -846,10 +747,14 @@ export class Preferences { } } + setupStorage() { + return this._preferencesStorage.setup(); + } + // Returns a value that can be passed to Preferences.CreateSandbox() - getPreferenceSandboxProperties(): Promise { + async getPreferenceSandboxProperties(): Promise { const value: PreferenceSandboxPropertiesImpl = { - user: this._getUserPreferenceModelValues(), + user: this._getUserPreferenceValues(), global: this._globalPreferences, }; return Promise.resolve(JSON.stringify(value)); @@ -867,40 +772,6 @@ export class Preferences { return instance; } - /** - * Load preferences from the user preference storage. - * The return value also accounts for preferences set from other sources. - * - * @returns The currently active set of preferences. - */ - async fetchPreferences(): Promise { - const originalPreferences = this.getPreferences(); - const userPreferencesModel = this._userPreferencesModel; - - // Fetch user preferences from the Ampersand model. - const fetchUserPreferences = promisifyAmpersandMethod( - userPreferencesModel.fetch.bind(userPreferencesModel) - ); - - try { - await fetchUserPreferences(); - } catch (err) { - log.error( - mongoLogId(1_001_000_156), - 'preferences', - 'Failed to load preferences, error while fetching models', - { - error: (err as Error).message, - } - ); - } - - const newPreferences = this.getPreferences(); - this._afterPreferencesUpdate(originalPreferences, newPreferences); - - return newPreferences; - } - /** * Change preferences in the user's preference storage. * This method validates that the preference is one that is stored in the @@ -917,12 +788,12 @@ export class Preferences { attributes: Partial = {} ): Promise { const keys = Object.keys(attributes) as (keyof UserPreferences)[]; - const originalPreferences = await this.fetchPreferences(); + const originalPreferences = this.getPreferences(); if (keys.length === 0) { return originalPreferences; } - const invalidKey = keys.find((key) => !modelPreferencesProps[key]); + const invalidKey = keys.find((key) => !storedUserPreferencesProps[key]); if (invalidKey !== undefined) { // Guard against accidentally saving non-model settings here. throw new Error( @@ -930,17 +801,17 @@ export class Preferences { ); } - const userPreferencesModel = this._userPreferencesModel; - - // Save user preferences to the Ampersand model. - const saveUserPreferences: ( - attributes: Partial - ) => Promise = promisifyAmpersandMethod( - userPreferencesModel.save.bind(userPreferencesModel) - ); + for (const key of keys) { + const { error } = storedUserPreferencesProps[key].validator.validate( + attributes[key] + ); + if (error) { + throw error; + } + } try { - await saveUserPreferences(attributes); + await this._preferencesStorage.updatePreferences(attributes); } catch (err) { log.error( mongoLogId(1_001_000_157), @@ -982,16 +853,13 @@ export class Preferences { return this._computePreferenceValuesAndStates().values; } - private _getUserPreferenceModelValues(): UserPreferences { - return this._userPreferencesModel.getAttributes({ - props: true, - derived: true, - }); + private _getUserPreferenceValues(): UserPreferences { + return this._preferencesStorage.getPreferences(); } private _getStoredValues(): AllPreferences { return { - ...this._getUserPreferenceModelValues(), + ...this._getUserPreferenceValues(), ...this._globalPreferences.cli, ...this._globalPreferences.global, ...this._globalPreferences.hardcoded, @@ -1054,7 +922,7 @@ export class Preferences { */ async ensureDefaultConfigurableUserPreferences(): Promise { // Set the defaults and also update showedNetworkOptIn flag. - const { showedNetworkOptIn } = await this.fetchPreferences(); + const { showedNetworkOptIn } = this.getPreferences(); if (!showedNetworkOptIn) { await this.savePreferences({ autoUpdates: true, @@ -1072,8 +940,8 @@ export class Preferences { * * @returns The currently active set of UI-modifiable preferences. */ - async getConfigurableUserPreferences(): Promise { - const preferences = await this.fetchPreferences(); + getConfigurableUserPreferences(): UserConfigurablePreferences { + const preferences = this.getPreferences(); return Object.fromEntries( Object.entries(preferences).filter( ([key]) => diff --git a/packages/compass-preferences-model/src/setup-preferences.ts b/packages/compass-preferences-model/src/setup-preferences.ts index f22229cfeb6..c40d19c5765 100644 --- a/packages/compass-preferences-model/src/setup-preferences.ts +++ b/packages/compass-preferences-model/src/setup-preferences.ts @@ -27,7 +27,7 @@ export async function setupPreferences( globalPreferences )); - await preferences.fetchPreferences(); + await preferences.setupStorage(); const { ipcMain } = hadronIpc; preferences.onPreferencesChanged( @@ -85,6 +85,7 @@ const makePreferenceMain = (preferences: () => Preferences | undefined) => ({ async ensureDefaultConfigurableUserPreferences(): Promise { return preferences()?.ensureDefaultConfigurableUserPreferences?.(); }, + // eslint-disable-next-line @typescript-eslint/require-await async getConfigurableUserPreferences(): Promise { return ( preferences()?.getConfigurableUserPreferences?.() ?? diff --git a/packages/compass-preferences-model/src/storage.ts b/packages/compass-preferences-model/src/storage.ts new file mode 100644 index 00000000000..639d1900a36 --- /dev/null +++ b/packages/compass-preferences-model/src/storage.ts @@ -0,0 +1,93 @@ +import { promises as fs } from 'fs'; +import { join } from 'path'; +import type { UserPreferences } from './preferences'; + +export abstract class BasePreferencesStorage { + abstract setup(): Promise; + abstract getPreferences(): UserPreferences; + abstract updatePreferences( + attributes: Partial + ): Promise; +} + +export class SandboxPreferences extends BasePreferencesStorage { + constructor(private preferences: UserPreferences) { + super(); + } + + getPreferences() { + return this.preferences; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async updatePreferences(attributes: Partial) { + this.preferences = { + ...this.preferences, + ...attributes, + }; + } + + async setup() { + // noop + } +} + +export class StoragePreferences extends BasePreferencesStorage { + private readonly folder = 'AppPreferences'; + private readonly file = 'General.json'; + private readonly defaultPreferences: UserPreferences; + + constructor(private preferences: UserPreferences, private basepath?: string) { + super(); + this.defaultPreferences = preferences; + } + + private getFolderPath() { + return join(this.basepath ?? '', this.folder); + } + + private getFilePath() { + return join(this.getFolderPath(), this.file); + } + + async setup() { + // Ensure folder exists + await fs.mkdir(this.getFolderPath(), { recursive: true }); + + try { + this.preferences = await this.readPreferences(); + } catch (e) { + if ((e as any).code !== 'ENOENT') { + throw e; + } + // Create the file for the first time + await fs.writeFile( + this.getFilePath(), + JSON.stringify(this.defaultPreferences, null, 2), + 'utf-8' + ); + } + } + + private async readPreferences(): Promise { + return JSON.parse(await fs.readFile(this.getFilePath(), 'utf8')); + } + + getPreferences() { + return this.preferences; + } + + async updatePreferences(attributes: Partial) { + const newPreferences = { + ...(await this.readPreferences()), + ...attributes, + }; + await fs.writeFile( + this.getFilePath(), + JSON.stringify(newPreferences, null, 2), + 'utf-8' + ); + + this.preferences = newPreferences; + } +} diff --git a/packages/compass-settings/src/components/settings/settings-list.tsx b/packages/compass-settings/src/components/settings/settings-list.tsx index dd0b4bbedc9..9929d3d95bf 100644 --- a/packages/compass-settings/src/components/settings/settings-list.tsx +++ b/packages/compass-settings/src/components/settings/settings-list.tsx @@ -210,7 +210,7 @@ export function SettingsList({ return ( <> {fields.map((name) => { - const { type, required } = getSettingDescription(name); + const { type } = getSettingDescription(name); if (type !== 'boolean' && type !== 'number' && type !== 'string') { throw new Error( `do not know how to render type ${ @@ -235,7 +235,7 @@ export function SettingsList({ value={ currentValues[name as NumericPreferences & PreferenceName] } - required={required} + required={false} disabled={!!preferenceStates[name]} /> ) : type === 'string' ? ( @@ -245,7 +245,7 @@ export function SettingsList({ value={ currentValues[name as StringPreferences & PreferenceName] } - required={required} + required={false} disabled={!!preferenceStates[name]} /> ) : null}