diff --git a/packages/compass-preferences-model/src/compass-utils.ts b/packages/compass-preferences-model/src/compass-utils.ts new file mode 100644 index 00000000000..3b59051191e --- /dev/null +++ b/packages/compass-preferences-model/src/compass-utils.ts @@ -0,0 +1,20 @@ +import type { ParsedGlobalPreferencesResult } from './global-config'; +import type { PreferencesAccess } from './preferences'; +import type { UserStorage } from './user-storage'; +import { UserStorageImpl } from './user-storage'; +import { getActiveUserId } from './utils'; +import { setupPreferences } from './setup-preferences'; + +export async function setupPreferencesAndUser( + globalPreferences: ParsedGlobalPreferencesResult +): Promise<{ userStorage: UserStorage; preferences: PreferencesAccess }> { + const preferences = await setupPreferences(globalPreferences); + const userStorage = new UserStorageImpl(); + const user = await userStorage.getOrCreate(getActiveUserId(preferences)); + // update user id (telemetryAnonymousId) in preferences if new user was created. + await preferences.savePreferences({ telemetryAnonymousId: user.id }); + await userStorage.updateUser(user.id, { + lastUsed: new Date(), + }); + return { preferences, userStorage }; +} diff --git a/packages/compass-preferences-model/src/index.ts b/packages/compass-preferences-model/src/index.ts index 5a425fe1ca0..333f888eccf 100644 --- a/packages/compass-preferences-model/src/index.ts +++ b/packages/compass-preferences-model/src/index.ts @@ -23,12 +23,9 @@ export { getExampleConfigFile, } from './global-config'; export type { ParsedGlobalPreferencesResult } from './global-config'; -export { - setupPreferencesAndUser, - getActiveUser, - isAIFeatureEnabled, -} from './utils'; -export type { User, UserStorage } from './storage'; +export { getActiveUser, isAIFeatureEnabled } from './utils'; +export { setupPreferencesAndUser } from './compass-utils'; +export type { User, UserStorage } from './user-storage'; export type { PreferencesAccess }; export { setupPreferences }; export const defaultPreferencesInstance: PreferencesAccess = diff --git a/packages/compass-preferences-model/src/preferences-in-memory-storage.ts b/packages/compass-preferences-model/src/preferences-in-memory-storage.ts new file mode 100644 index 00000000000..e434c37c130 --- /dev/null +++ b/packages/compass-preferences-model/src/preferences-in-memory-storage.ts @@ -0,0 +1,30 @@ +import type { PreferencesStorage } from './preferences-storage'; +import { getDefaultsForStoredPreferences } from './preferences-schema'; +import type { AllPreferences, StoredPreferences } from './preferences-schema'; + +export class InMemoryStorage implements PreferencesStorage { + private preferences = getDefaultsForStoredPreferences(); + + constructor(preferencesOverrides?: Partial) { + this.preferences = { + ...this.preferences, + ...preferencesOverrides, + }; + } + + getPreferences() { + return this.preferences; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async updatePreferences(attributes: Partial) { + this.preferences = { + ...this.preferences, + ...attributes, + }; + } + + async setup() { + // noop + } +} diff --git a/packages/compass-preferences-model/src/preferences-persistent-storage.spec.ts b/packages/compass-preferences-model/src/preferences-persistent-storage.spec.ts new file mode 100644 index 00000000000..3583845e371 --- /dev/null +++ b/packages/compass-preferences-model/src/preferences-persistent-storage.spec.ts @@ -0,0 +1,129 @@ +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { PersistentStorage } from './preferences-persistent-storage'; +import { getDefaultsForStoredPreferences } from './preferences-schema'; +import { expect } from 'chai'; + +const getPreferencesFolder = (tmpDir: string) => { + return path.join(tmpDir, 'AppPreferences'); +}; + +const getPreferencesFile = (tmpDir: string) => { + return path.join(getPreferencesFolder(tmpDir), 'General.json'); +}; + +describe('PersistentStorage', function () { + let tmpDir: string; + beforeEach(async function () { + tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'compass-preferences-storage') + ); + }); + + afterEach(async function () { + await fs.rmdir(tmpDir, { recursive: true }); + }); + + it('sets up the storage', async function () { + // When user starts compass first time, it creates AppPreferences folder with + // General.json to store default preferences. + + const storage = new PersistentStorage(tmpDir); + + const preferencesDir = getPreferencesFolder(tmpDir); + const preferencesFile = getPreferencesFile(tmpDir); + + expect(async () => await fs.access(preferencesDir)).to.throw; + expect(async () => await fs.access(preferencesFile)).to.throw; + + await storage.setup(); + + expect(async () => await fs.access(preferencesDir)).to.not.throw; + expect(async () => await fs.access(preferencesFile)).to.not.throw; + + expect( + JSON.parse((await fs.readFile(preferencesFile)).toString()) + ).to.deep.equal(getDefaultsForStoredPreferences()); + }); + + it('when invalid json is stored, it sets the defaults', async function () { + const storage = new PersistentStorage(tmpDir); + + const preferencesFile = getPreferencesFile(tmpDir); + await fs.mkdir(getPreferencesFolder(tmpDir)); + await fs.writeFile(preferencesFile, '{}}', 'utf-8'); + + // Ensure it exists + expect(async () => await fs.access(preferencesFile)).to.not.throw; + + await storage.setup(); + + expect( + JSON.parse((await fs.readFile(preferencesFile)).toString()) + ).to.deep.equal(getDefaultsForStoredPreferences()); + }); + + it('updates preferences', async function () { + const storage = new PersistentStorage(tmpDir); + await storage.setup(); + + await storage.updatePreferences({ currentUserId: '123456789' }); + + const newPreferences = storage.getPreferences(); + + expect(newPreferences).to.deep.equal({ + ...getDefaultsForStoredPreferences(), + currentUserId: '123456789', + }); + }); + + it('returns default preference values if its not stored on disk', async function () { + const storage = new PersistentStorage(tmpDir); + + // manually setup the file with no content + await fs.mkdir(getPreferencesFolder(tmpDir)); + await fs.writeFile(getPreferencesFile(tmpDir), JSON.stringify({}), 'utf-8'); + + await storage.updatePreferences({ + currentUserId: '123456789', + }); + + expect(storage.getPreferences()).to.deep.equal({ + ...getDefaultsForStoredPreferences(), + currentUserId: '123456789', + }); + }); + + it('does not save random props', async function () { + const storage = new PersistentStorage(tmpDir); + await storage.setup(); + + await storage.updatePreferences({ someThingNotSupported: 'abc' } as any); + + const newPreferences = storage.getPreferences(); + + expect(newPreferences).to.deep.equal(getDefaultsForStoredPreferences()); + }); + + it('strips unknown props when reading from disk', async function () { + const storage = new PersistentStorage(tmpDir); + + // manually setup the file with default props and unknown prop + await fs.mkdir(getPreferencesFolder(tmpDir)); + await fs.writeFile( + getPreferencesFile(tmpDir), + JSON.stringify({ + ...getDefaultsForStoredPreferences(), + somethingUnknown: true, + }), + 'utf-8' + ); + + await storage.setup(); + + expect(storage.getPreferences()).to.deep.equal( + getDefaultsForStoredPreferences() + ); + }); +}); diff --git a/packages/compass-preferences-model/src/preferences-persistent-storage.ts b/packages/compass-preferences-model/src/preferences-persistent-storage.ts new file mode 100644 index 00000000000..168f9e720da --- /dev/null +++ b/packages/compass-preferences-model/src/preferences-persistent-storage.ts @@ -0,0 +1,66 @@ +import type { z } from 'zod'; +import { UserData } from '@mongodb-js/compass-user-data'; +import { + getDefaultsForStoredPreferences, + getPreferencesValidator, +} from './preferences-schema'; +import type { + StoredPreferences, + StoredPreferencesValidator, +} from './preferences-schema'; + +import type { PreferencesStorage } from './preferences-storage'; + +export class PersistentStorage implements PreferencesStorage { + private readonly file = 'General'; + private readonly defaultPreferences = getDefaultsForStoredPreferences(); + private readonly userData: UserData; + private preferences: StoredPreferences = getDefaultsForStoredPreferences(); + + constructor(basePath?: string) { + this.userData = new UserData(getPreferencesValidator(), { + subdir: 'AppPreferences', + basePath, + }); + } + + async setup() { + try { + this.preferences = await this.readPreferences(); + } catch (e) { + if ( + (e as any).code === 'ENOENT' || // First time user + e instanceof SyntaxError // Invalid json + ) { + // Create the file for the first time + await this.userData.write(this.file, this.defaultPreferences); + return; + } + throw e; + } + } + + private async readPreferences(): Promise { + return await this.userData.readOne(this.file, { + ignoreErrors: false, + }); + } + + getPreferences(): StoredPreferences { + return { + ...this.defaultPreferences, + ...this.preferences, + }; + } + + async updatePreferences( + attributes: Partial> + ) { + await this.userData.write(this.file, { + ...(await this.readPreferences()), + ...attributes, + }); + + this.preferences = await this.readPreferences(); + } +} diff --git a/packages/compass-preferences-model/src/preferences-storage.ts b/packages/compass-preferences-model/src/preferences-storage.ts new file mode 100644 index 00000000000..4e9ead2e660 --- /dev/null +++ b/packages/compass-preferences-model/src/preferences-storage.ts @@ -0,0 +1,7 @@ +import type { StoredPreferences } from './preferences-schema'; + +export interface PreferencesStorage { + setup(): Promise; + getPreferences(): StoredPreferences; + updatePreferences(attributes: Partial): Promise; +} diff --git a/packages/compass-preferences-model/src/preferences.spec.ts b/packages/compass-preferences-model/src/preferences.spec.ts index f3a82e938c7..93380888794 100644 --- a/packages/compass-preferences-model/src/preferences.spec.ts +++ b/packages/compass-preferences-model/src/preferences.spec.ts @@ -4,7 +4,7 @@ import os from 'os'; import { Preferences } from './preferences'; import { expect } from 'chai'; import { featureFlags } from './feature-flags'; -import { PersistentStorage } from './storage'; +import { PersistentStorage } from './preferences-persistent-storage'; import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging'; const releasedFeatureFlags = Object.entries(featureFlags) diff --git a/packages/compass-preferences-model/src/preferences.ts b/packages/compass-preferences-model/src/preferences.ts index c299e4296ac..3dddff48a43 100644 --- a/packages/compass-preferences-model/src/preferences.ts +++ b/packages/compass-preferences-model/src/preferences.ts @@ -11,7 +11,8 @@ import type { DeriveValueFunction, } from './preferences-schema'; import { allPreferencesProps } from './preferences-schema'; -import { InMemoryStorage, type BasePreferencesStorage } from './storage'; +import { InMemoryStorage } from './preferences-in-memory-storage'; +import type { PreferencesStorage } from './preferences-storage'; export interface PreferencesAccess { savePreferences( @@ -43,7 +44,7 @@ type PreferenceSandboxPropertiesImpl = { export class Preferences { private _logger: LoggerAndTelemetry; private _onPreferencesChangedCallbacks: OnPreferencesChangedCallback[]; - private _preferencesStorage: BasePreferencesStorage; + private _preferencesStorage: PreferencesStorage; private _globalPreferences: { cli: Partial; global: Partial; @@ -56,7 +57,7 @@ export class Preferences { preferencesStorage = new InMemoryStorage(), }: { logger: LoggerAndTelemetry; - preferencesStorage: BasePreferencesStorage; + preferencesStorage: PreferencesStorage; globalPreferences?: Partial; }) { this._logger = logger; diff --git a/packages/compass-preferences-model/src/provider.ts b/packages/compass-preferences-model/src/provider.ts index b719916c245..58e62223cec 100644 --- a/packages/compass-preferences-model/src/provider.ts +++ b/packages/compass-preferences-model/src/provider.ts @@ -1,67 +1,6 @@ -import { createContext, useContext } from 'react'; -import { createNoopLoggerAndTelemetry } from '@mongodb-js/compass-logging/provider'; -import { Preferences, type PreferencesAccess } from './preferences'; -import { type AllPreferences } from './preferences-schema'; -import { InMemoryStorage } from './storage'; -export { usePreference, withPreferences } from './react'; +export * from './react'; +export { ReadOnlyPreferenceAccess } from './read-only-preferences-access'; export { useIsAIFeatureEnabled } from './utils'; export { capMaxTimeMSAtPreferenceLimit } from './maxtimems'; export { featureFlags } from './feature-flags'; export { getSettingDescription } from './preferences-schema'; - -export class ReadOnlyPreferenceAccess implements PreferencesAccess { - private _preferences: Preferences; - constructor(preferencesOverrides?: Partial) { - this._preferences = new Preferences({ - logger: createNoopLoggerAndTelemetry(), - preferencesStorage: new InMemoryStorage(preferencesOverrides), - }); - } - - savePreferences() { - return Promise.resolve(this._preferences.getPreferences()); - } - - refreshPreferences() { - return Promise.resolve(this._preferences.getPreferences()); - } - - getPreferences() { - return this._preferences.getPreferences(); - } - - ensureDefaultConfigurableUserPreferences() { - return this._preferences.ensureDefaultConfigurableUserPreferences(); - } - - getConfigurableUserPreferences() { - return Promise.resolve(this._preferences.getConfigurableUserPreferences()); - } - - getPreferenceStates() { - return Promise.resolve(this._preferences.getPreferenceStates()); - } - - onPreferenceValueChanged() { - return () => { - // noop - }; - } - - createSandbox() { - return Promise.resolve(new ReadOnlyPreferenceAccess(this.getPreferences())); - } -} - -const PreferencesContext = createContext( - // Our context starts with our read-only preference access but we expect - // different runtimes to provide their own access implementation at render. - new ReadOnlyPreferenceAccess() -); - -export const PreferencesProvider = PreferencesContext.Provider; - -export function preferencesLocator(): PreferencesAccess { - return useContext(PreferencesContext); -} -export type { PreferencesAccess }; diff --git a/packages/compass-preferences-model/src/react.ts b/packages/compass-preferences-model/src/react.ts index dc94d598826..9ac317f213b 100644 --- a/packages/compass-preferences-model/src/react.ts +++ b/packages/compass-preferences-model/src/react.ts @@ -1,7 +1,27 @@ import type { FunctionComponent } from 'react'; -import { useState, useEffect, createElement } from 'react'; +import { + useState, + useEffect, + createElement, + createContext, + useContext, +} from 'react'; import { type AllPreferences } from './'; -import { preferencesLocator } from './provider'; +import type { PreferencesAccess } from './preferences'; +import { ReadOnlyPreferenceAccess } from './read-only-preferences-access'; + +const PreferencesContext = createContext( + // Our context starts with our read-only preference access but we expect + // different runtimes to provide their own access implementation at render. + new ReadOnlyPreferenceAccess() +); + +export const PreferencesProvider = PreferencesContext.Provider; + +export function preferencesLocator(): PreferencesAccess { + return useContext(PreferencesContext); +} +export type { PreferencesAccess }; /** Use as: const enableMaps = usePreference('enableMaps', React); */ export function usePreference( diff --git a/packages/compass-preferences-model/src/read-only-preferences-access.ts b/packages/compass-preferences-model/src/read-only-preferences-access.ts new file mode 100644 index 00000000000..0aec1c052a7 --- /dev/null +++ b/packages/compass-preferences-model/src/read-only-preferences-access.ts @@ -0,0 +1,48 @@ +import { createNoopLoggerAndTelemetry } from '@mongodb-js/compass-logging/provider'; +import { Preferences, type PreferencesAccess } from './preferences'; +import { type AllPreferences } from './preferences-schema'; +import { InMemoryStorage } from './preferences-in-memory-storage'; + +export class ReadOnlyPreferenceAccess implements PreferencesAccess { + private _preferences: Preferences; + constructor(preferencesOverrides?: Partial) { + this._preferences = new Preferences({ + logger: createNoopLoggerAndTelemetry(), + preferencesStorage: new InMemoryStorage(preferencesOverrides), + }); + } + + savePreferences() { + return Promise.resolve(this._preferences.getPreferences()); + } + + refreshPreferences() { + return Promise.resolve(this._preferences.getPreferences()); + } + + getPreferences() { + return this._preferences.getPreferences(); + } + + ensureDefaultConfigurableUserPreferences() { + return this._preferences.ensureDefaultConfigurableUserPreferences(); + } + + getConfigurableUserPreferences() { + return Promise.resolve(this._preferences.getConfigurableUserPreferences()); + } + + getPreferenceStates() { + return Promise.resolve(this._preferences.getPreferenceStates()); + } + + onPreferenceValueChanged() { + return () => { + // noop + }; + } + + createSandbox() { + return Promise.resolve(new ReadOnlyPreferenceAccess(this.getPreferences())); + } +} diff --git a/packages/compass-preferences-model/src/setup-preferences.ts b/packages/compass-preferences-model/src/setup-preferences.ts index 44052fd4575..655aa4b54c6 100644 --- a/packages/compass-preferences-model/src/setup-preferences.ts +++ b/packages/compass-preferences-model/src/setup-preferences.ts @@ -10,7 +10,8 @@ import type { PreferenceSandboxProperties } from './preferences'; import type { ParsedGlobalPreferencesResult } from './global-config'; import type { PreferencesAccess } from '.'; -import { InMemoryStorage, PersistentStorage } from './storage'; +import { PersistentStorage } from './preferences-persistent-storage'; +import { InMemoryStorage } from './preferences-in-memory-storage'; import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging'; const compassPreferencesLogger = createLoggerAndTelemetry( diff --git a/packages/compass-preferences-model/src/storage.spec.ts b/packages/compass-preferences-model/src/storage.spec.ts deleted file mode 100644 index 68e70fa227d..00000000000 --- a/packages/compass-preferences-model/src/storage.spec.ts +++ /dev/null @@ -1,224 +0,0 @@ -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; -import type { UserStorage } from './storage'; -import { PersistentStorage, type User, UserStorageImpl } from './storage'; -import { getDefaultsForStoredPreferences } from './preferences-schema'; -import { expect } from 'chai'; -import { z } from 'zod'; -import { users as UserFixtures } from './../test/fixtures'; - -const getPreferencesFolder = (tmpDir: string) => { - return path.join(tmpDir, 'AppPreferences'); -}; - -const getPreferencesFile = (tmpDir: string) => { - return path.join(getPreferencesFolder(tmpDir), 'General.json'); -}; - -describe('storage', function () { - let tmpDir: string; - describe('UserStorage', function () { - let storedUser: User; - let userStorage: UserStorage; - before(async function () { - tmpDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'compass-preferences-storage') - ); - userStorage = new UserStorageImpl(tmpDir); - storedUser = await userStorage.getOrCreate(''); - }); - after(async function () { - await fs.rmdir(tmpDir, { recursive: true }); - }); - - it('creates a new user if user does not exist', async function () { - const nonExistantUserId = '12345678'; - const user = await userStorage.getOrCreate(nonExistantUserId); - expect(user.id).to.not.equal(nonExistantUserId); - }); - - it('gets an existing user if it exists', async function () { - const user = await userStorage.getOrCreate(storedUser.id); - expect(user).to.deep.equal(storedUser); - }); - - it('updates a user', async function () { - const lastUsed = new Date(); - const updatedUser = await userStorage.updateUser(storedUser.id, { - lastUsed, - }); - - expect(updatedUser).to.deep.equal({ - ...storedUser, - lastUsed, - }); - }); - - it('throws validation errors', async function () { - { - try { - await userStorage.updateUser(storedUser.id, { - lastUsed: 'something-unacceptable', - } as any); - expect.fail('Expected lastUsed prop to fail due to date validation'); - } catch (e) { - expect(e).to.be.an.instanceOf(z.ZodError); - } - } - - { - try { - await userStorage.updateUser(storedUser.id, { - createdAt: 'something-unacceptable', - } as any); - expect.fail('Expected createdAt prop to fail due to date validation'); - } catch (e) { - expect(e).to.be.an.instanceOf(z.ZodError); - } - } - - { - try { - await userStorage.updateUser(storedUser.id, { - id: 'something-unacceptable', - }); - expect.fail('Expected id prop to fail due to uuid validation'); - } catch (e) { - expect(e).to.be.an.instanceOf(z.ZodError); - } - } - }); - - for (const { data: user, version } of UserFixtures) { - it(`supports user data from Compass v${version}`, async function () { - const userPath = path.join(tmpDir, 'Users', `${user.id}.json`); - await fs.writeFile(userPath, JSON.stringify(user)); - - const expectedUser = await userStorage.getUser(user.id); - - expect(expectedUser.id).to.equal(user.id); - expect(expectedUser.createdAt).to.deep.equal(new Date(user.createdAt)); - expect(expectedUser.lastUsed).to.deep.equal(new Date(user.lastUsed)); - }); - } - }); - - describe('PersistentStorage', function () { - beforeEach(async function () { - tmpDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'compass-preferences-storage') - ); - }); - - afterEach(async function () { - await fs.rmdir(tmpDir, { recursive: true }); - }); - - it('sets up the storage', async function () { - // When user starts compass first time, it creates AppPreferences folder with - // General.json to store default preferences. - - const storage = new PersistentStorage(tmpDir); - - const preferencesDir = getPreferencesFolder(tmpDir); - const preferencesFile = getPreferencesFile(tmpDir); - - expect(async () => await fs.access(preferencesDir)).to.throw; - expect(async () => await fs.access(preferencesFile)).to.throw; - - await storage.setup(); - - expect(async () => await fs.access(preferencesDir)).to.not.throw; - expect(async () => await fs.access(preferencesFile)).to.not.throw; - - expect( - JSON.parse((await fs.readFile(preferencesFile)).toString()) - ).to.deep.equal(getDefaultsForStoredPreferences()); - }); - - it('when invalid json is stored, it sets the defaults', async function () { - const storage = new PersistentStorage(tmpDir); - - const preferencesFile = getPreferencesFile(tmpDir); - await fs.mkdir(getPreferencesFolder(tmpDir)); - await fs.writeFile(preferencesFile, '{}}', 'utf-8'); - - // Ensure it exists - expect(async () => await fs.access(preferencesFile)).to.not.throw; - - await storage.setup(); - - expect( - JSON.parse((await fs.readFile(preferencesFile)).toString()) - ).to.deep.equal(getDefaultsForStoredPreferences()); - }); - - it('updates preferences', async function () { - const storage = new PersistentStorage(tmpDir); - await storage.setup(); - - await storage.updatePreferences({ currentUserId: '123456789' }); - - const newPreferences = storage.getPreferences(); - - expect(newPreferences).to.deep.equal({ - ...getDefaultsForStoredPreferences(), - currentUserId: '123456789', - }); - }); - - it('returns default preference values if its not stored on disk', async function () { - const storage = new PersistentStorage(tmpDir); - - // manually setup the file with no content - await fs.mkdir(getPreferencesFolder(tmpDir)); - await fs.writeFile( - getPreferencesFile(tmpDir), - JSON.stringify({}), - 'utf-8' - ); - - await storage.updatePreferences({ - currentUserId: '123456789', - }); - - expect(storage.getPreferences()).to.deep.equal({ - ...getDefaultsForStoredPreferences(), - currentUserId: '123456789', - }); - }); - - it('does not save random props', async function () { - const storage = new PersistentStorage(tmpDir); - await storage.setup(); - - await storage.updatePreferences({ someThingNotSupported: 'abc' } as any); - - const newPreferences = storage.getPreferences(); - - expect(newPreferences).to.deep.equal(getDefaultsForStoredPreferences()); - }); - - it('strips unknown props when reading from disk', async function () { - const storage = new PersistentStorage(tmpDir); - - // manually setup the file with default props and unknown prop - await fs.mkdir(getPreferencesFolder(tmpDir)); - await fs.writeFile( - getPreferencesFile(tmpDir), - JSON.stringify({ - ...getDefaultsForStoredPreferences(), - somethingUnknown: true, - }), - 'utf-8' - ); - - await storage.setup(); - - expect(storage.getPreferences()).to.deep.equal( - getDefaultsForStoredPreferences() - ); - }); - }); -}); diff --git a/packages/compass-preferences-model/src/storage.ts b/packages/compass-preferences-model/src/storage.ts deleted file mode 100644 index 0aa2c94a0dd..00000000000 --- a/packages/compass-preferences-model/src/storage.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { z } from 'zod'; -import { UUID } from 'bson'; -import { UserData } from '@mongodb-js/compass-user-data'; - -import { - getDefaultsForStoredPreferences, - getPreferencesValidator, -} from './preferences-schema'; -import type { - AllPreferences, - StoredPreferences, - StoredPreferencesValidator, -} from './preferences-schema'; - -export interface BasePreferencesStorage { - setup(): Promise; - getPreferences(): StoredPreferences; - updatePreferences(attributes: Partial): Promise; -} - -export class InMemoryStorage implements BasePreferencesStorage { - private preferences = getDefaultsForStoredPreferences(); - - constructor(preferencesOverrides?: Partial) { - this.preferences = { - ...this.preferences, - ...preferencesOverrides, - }; - } - - 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 PersistentStorage implements BasePreferencesStorage { - private readonly file = 'General'; - private readonly defaultPreferences = getDefaultsForStoredPreferences(); - private readonly userData: UserData; - private preferences: StoredPreferences = getDefaultsForStoredPreferences(); - - constructor(basePath?: string) { - this.userData = new UserData(getPreferencesValidator(), { - subdir: 'AppPreferences', - basePath, - }); - } - - async setup() { - try { - this.preferences = await this.readPreferences(); - } catch (e) { - if ( - (e as any).code === 'ENOENT' || // First time user - e instanceof SyntaxError // Invalid json - ) { - // Create the file for the first time - await this.userData.write(this.file, this.defaultPreferences); - return; - } - throw e; - } - } - - private async readPreferences(): Promise { - return await this.userData.readOne(this.file, { - ignoreErrors: false, - }); - } - - getPreferences(): StoredPreferences { - return { - ...this.defaultPreferences, - ...this.preferences, - }; - } - - async updatePreferences( - attributes: Partial> - ) { - await this.userData.write(this.file, { - ...(await this.readPreferences()), - ...attributes, - }); - - this.preferences = await this.readPreferences(); - } -} - -const UserSchema = z.object({ - id: z.string().uuid(), - createdAt: z - .union([z.coerce.date(), z.number()]) - .transform((x) => new Date(x)), - lastUsed: z - .union([z.coerce.date(), z.number()]) - .transform((x) => new Date(x)), -}); - -export type User = z.output; - -export interface UserStorage { - getOrCreate(id?: string): Promise; - getUser(id: string): Promise; - updateUser( - id: string, - attributes: Partial> - ): Promise; -} - -export class UserStorageImpl implements UserStorage { - private readonly userData: UserData; - constructor(basePath?: string) { - this.userData = new UserData(UserSchema, { - subdir: 'Users', - basePath, - }); - } - - async getOrCreate(id?: string): Promise { - if (!id) { - return this.createUser(); - } - - try { - return await this.getUser(id); - } catch (e) { - if ((e as any).code !== 'ENOENT') { - throw e; - } - return await this.createUser(); - } - } - - async getUser(id: string): Promise { - return await this.userData.readOne(id, { - ignoreErrors: false, - }); - } - - private async createUser(): Promise { - const id = new UUID().toString(); - const user = { - id, - createdAt: new Date(), - lastUsed: new Date(), - }; - return this.writeUser(user); - } - - async updateUser( - id: string, - attributes: Partial> - ): Promise { - const user = await this.getUser(id); - const newData = { - ...user, - ...attributes, - }; - return this.writeUser(newData); - } - - private async writeUser(user: z.input): Promise { - await this.userData.write(user.id, user); - return this.getUser(user.id); - } - - private getFileName(id: string) { - return `${id}.json`; - } -} diff --git a/packages/compass-preferences-model/src/user-storage.spec.ts b/packages/compass-preferences-model/src/user-storage.spec.ts new file mode 100644 index 00000000000..0b1700b4dfe --- /dev/null +++ b/packages/compass-preferences-model/src/user-storage.spec.ts @@ -0,0 +1,95 @@ +import { z } from 'zod'; +import { expect } from 'chai'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; +import type { UserStorage, User } from './user-storage'; +import { UserStorageImpl } from './user-storage'; +import { users as UserFixtures } from '../test/fixtures'; + +describe('UserStorage', function () { + let tmpDir: string; + let storedUser: User; + let userStorage: UserStorage; + before(async function () { + tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'compass-preferences-storage') + ); + userStorage = new UserStorageImpl(tmpDir); + storedUser = await userStorage.getOrCreate(''); + }); + after(async function () { + await fs.rmdir(tmpDir, { recursive: true }); + }); + + it('creates a new user if user does not exist', async function () { + const nonExistantUserId = '12345678'; + const user = await userStorage.getOrCreate(nonExistantUserId); + expect(user.id).to.not.equal(nonExistantUserId); + }); + + it('gets an existing user if it exists', async function () { + const user = await userStorage.getOrCreate(storedUser.id); + expect(user).to.deep.equal(storedUser); + }); + + it('updates a user', async function () { + const lastUsed = new Date(); + const updatedUser = await userStorage.updateUser(storedUser.id, { + lastUsed, + }); + + expect(updatedUser).to.deep.equal({ + ...storedUser, + lastUsed, + }); + }); + + it('throws validation errors', async function () { + { + try { + await userStorage.updateUser(storedUser.id, { + lastUsed: 'something-unacceptable', + } as any); + expect.fail('Expected lastUsed prop to fail due to date validation'); + } catch (e) { + expect(e).to.be.an.instanceOf(z.ZodError); + } + } + + { + try { + await userStorage.updateUser(storedUser.id, { + createdAt: 'something-unacceptable', + } as any); + expect.fail('Expected createdAt prop to fail due to date validation'); + } catch (e) { + expect(e).to.be.an.instanceOf(z.ZodError); + } + } + + { + try { + await userStorage.updateUser(storedUser.id, { + id: 'something-unacceptable', + }); + expect.fail('Expected id prop to fail due to uuid validation'); + } catch (e) { + expect(e).to.be.an.instanceOf(z.ZodError); + } + } + }); + + for (const { data: user, version } of UserFixtures) { + it(`supports user data from Compass v${version}`, async function () { + const userPath = path.join(tmpDir, 'Users', `${user.id}.json`); + await fs.writeFile(userPath, JSON.stringify(user)); + + const expectedUser = await userStorage.getUser(user.id); + + expect(expectedUser.id).to.equal(user.id); + expect(expectedUser.createdAt).to.deep.equal(new Date(user.createdAt)); + expect(expectedUser.lastUsed).to.deep.equal(new Date(user.lastUsed)); + }); + } +}); diff --git a/packages/compass-preferences-model/src/user-storage.ts b/packages/compass-preferences-model/src/user-storage.ts new file mode 100644 index 00000000000..3252151e0bd --- /dev/null +++ b/packages/compass-preferences-model/src/user-storage.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import { UUID } from 'bson'; +import { UserData } from '@mongodb-js/compass-user-data'; + +const UserSchema = z.object({ + id: z.string().uuid(), + createdAt: z + .union([z.coerce.date(), z.number()]) + .transform((x) => new Date(x)), + lastUsed: z + .union([z.coerce.date(), z.number()]) + .transform((x) => new Date(x)), +}); + +export type User = z.output; + +export interface UserStorage { + getOrCreate(id?: string): Promise; + getUser(id: string): Promise; + updateUser( + id: string, + attributes: Partial> + ): Promise; +} + +export class UserStorageImpl implements UserStorage { + private readonly userData: UserData; + constructor(basePath?: string) { + this.userData = new UserData(UserSchema, { + subdir: 'Users', + basePath, + }); + } + + async getOrCreate(id?: string): Promise { + if (!id) { + return this.createUser(); + } + + try { + return await this.getUser(id); + } catch (e) { + if ((e as any).code !== 'ENOENT') { + throw e; + } + return await this.createUser(); + } + } + + async getUser(id: string): Promise { + return await this.userData.readOne(id, { + ignoreErrors: false, + }); + } + + private async createUser(): Promise { + const id = new UUID().toString(); + const user = { + id, + createdAt: new Date(), + lastUsed: new Date(), + }; + return this.writeUser(user); + } + + async updateUser( + id: string, + attributes: Partial> + ): Promise { + const user = await this.getUser(id); + const newData = { + ...user, + ...attributes, + }; + return this.writeUser(newData); + } + + private async writeUser(user: z.input): Promise { + await this.userData.write(user.id, user); + return this.getUser(user.id); + } + + private getFileName(id: string) { + return `${id}.json`; + } +} diff --git a/packages/compass-preferences-model/src/utils.ts b/packages/compass-preferences-model/src/utils.ts index 0ae559cc08a..4ad0aece029 100644 --- a/packages/compass-preferences-model/src/utils.ts +++ b/packages/compass-preferences-model/src/utils.ts @@ -1,29 +1,10 @@ import { usePreference } from './react'; -import type { - AllPreferences, - ParsedGlobalPreferencesResult, - PreferencesAccess, - User, -} from '.'; -import { setupPreferences } from './setup-preferences'; -import type { UserStorage } from './storage'; -import { UserStorageImpl } from './storage'; +import type { AllPreferences, PreferencesAccess, User } from '.'; +import type { UserStorage } from './user-storage'; -export async function setupPreferencesAndUser( - globalPreferences: ParsedGlobalPreferencesResult -): Promise<{ userStorage: UserStorage; preferences: PreferencesAccess }> { - const preferences = await setupPreferences(globalPreferences); - const userStorage = new UserStorageImpl(); - const user = await userStorage.getOrCreate(getActiveUserId(preferences)); - // update user id (telemetryAnonymousId) in preferences if new user was created. - await preferences.savePreferences({ telemetryAnonymousId: user.id }); - await userStorage.updateUser(user.id, { - lastUsed: new Date(), - }); - return { preferences, userStorage }; -} - -function getActiveUserId(preferences: PreferencesAccess): string | undefined { +export function getActiveUserId( + preferences: PreferencesAccess +): string | undefined { const { currentUserId, telemetryAnonymousId } = preferences.getPreferences(); return currentUserId || telemetryAnonymousId; }