diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 806c3d2a9e..58e1020ed1 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -10,9 +10,11 @@ import { MEMORY_STORAGE, OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE, + OBSERVABLE_DISK_LOCAL_STORAGE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service"; import { LoginService } from "@bitwarden/common/auth/services/login.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; @@ -23,10 +25,19 @@ import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/p import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; +import { + ActiveUserStateProvider, + GlobalStateProvider, + SingleUserStateProvider, +} from "@bitwarden/common/platform/state"; import { PolicyListService } from "../admin-console/core/policy-list.service"; import { HtmlStorageService } from "../core/html-storage.service"; import { I18nService } from "../core/i18n.service"; +import { WebActiveUserStateProvider } from "../platform/web-active-user-state.provider"; +import { WebGlobalStateProvider } from "../platform/web-global-state.provider"; +import { WebSingleUserStateProvider } from "../platform/web-single-user-state.provider"; +import { WindowStorageService } from "../platform/window-storage.service"; import { CollectionAdminService } from "../vault/core/collection-admin.service"; import { BroadcasterMessagingService } from "./broadcaster-messaging.service"; @@ -77,7 +88,10 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service"; useClass: MemoryStorageService, }, { provide: OBSERVABLE_MEMORY_STORAGE, useExisting: MEMORY_STORAGE }, - { provide: OBSERVABLE_DISK_STORAGE, useExisting: AbstractStorageService }, + { + provide: OBSERVABLE_DISK_STORAGE, + useFactory: () => new WindowStorageService(window.sessionStorage), + }, { provide: PlatformUtilsServiceAbstraction, useClass: WebPlatformUtilsService, @@ -99,6 +113,30 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service"; deps: [StateService], }, CollectionAdminService, + { + provide: OBSERVABLE_DISK_LOCAL_STORAGE, + useFactory: () => new WindowStorageService(window.localStorage), + }, + { + provide: SingleUserStateProvider, + useClass: WebSingleUserStateProvider, + deps: [MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE, OBSERVABLE_DISK_LOCAL_STORAGE], + }, + { + provide: ActiveUserStateProvider, + useClass: WebActiveUserStateProvider, + deps: [ + AccountService, + MEMORY_STORAGE, + OBSERVABLE_DISK_STORAGE, + OBSERVABLE_DISK_LOCAL_STORAGE, + ], + }, + { + provide: GlobalStateProvider, + useClass: WebGlobalStateProvider, + deps: [MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE, OBSERVABLE_DISK_LOCAL_STORAGE], + }, ], }) export class CoreModule { diff --git a/apps/web/src/app/platform/web-active-user-state.provider.ts b/apps/web/src/app/platform/web-active-user-state.provider.ts new file mode 100644 index 0000000000..89a58eefe6 --- /dev/null +++ b/apps/web/src/app/platform/web-active-user-state.provider.ts @@ -0,0 +1,44 @@ +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { + AbstractMemoryStorageService, + AbstractStorageService, + ObservableStorageService, +} from "@bitwarden/common/platform/abstractions/storage.service"; +import { KeyDefinition } from "@bitwarden/common/platform/state"; +/* eslint-disable import/no-restricted-paths -- Needed to extend class & in platform owned code */ +import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-active-user-state.provider"; +import { StateDefinition } from "@bitwarden/common/platform/state/state-definition"; +/* eslint-enable import/no-restricted-paths */ + +export class WebActiveUserStateProvider extends DefaultActiveUserStateProvider { + constructor( + accountService: AccountService, + memoryStorage: AbstractMemoryStorageService & ObservableStorageService, + sessionStorage: AbstractStorageService & ObservableStorageService, + private readonly diskLocalStorage: AbstractStorageService & ObservableStorageService, + ) { + super(accountService, memoryStorage, sessionStorage); + } + + protected override getLocationString(keyDefinition: KeyDefinition): string { + return ( + keyDefinition.stateDefinition.storageLocationOverrides["web"] ?? + keyDefinition.stateDefinition.defaultStorageLocation + ); + } + + protected override getLocation( + stateDefinition: StateDefinition, + ): AbstractStorageService & ObservableStorageService { + const location = + stateDefinition.storageLocationOverrides["web"] ?? stateDefinition.defaultStorageLocation; + switch (location) { + case "disk": + return this.diskStorage; + case "memory": + return this.memoryStorage; + case "disk-local": + return this.diskLocalStorage; + } + } +} diff --git a/apps/web/src/app/platform/web-global-state.provider.ts b/apps/web/src/app/platform/web-global-state.provider.ts new file mode 100644 index 0000000000..07025864cc --- /dev/null +++ b/apps/web/src/app/platform/web-global-state.provider.ts @@ -0,0 +1,42 @@ +import { + AbstractMemoryStorageService, + AbstractStorageService, + ObservableStorageService, +} from "@bitwarden/common/platform/abstractions/storage.service"; +import { KeyDefinition } from "@bitwarden/common/platform/state"; +/* eslint-disable import/no-restricted-paths -- Needed to extend class & in platform owned code*/ +import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider"; +import { StateDefinition } from "@bitwarden/common/platform/state/state-definition"; +/* eslint-enable import/no-restricted-paths */ + +export class WebGlobalStateProvider extends DefaultGlobalStateProvider { + constructor( + memoryStorage: AbstractMemoryStorageService & ObservableStorageService, + sessionStorage: AbstractStorageService & ObservableStorageService, + private readonly diskLocalStorage: AbstractStorageService & ObservableStorageService, + ) { + super(memoryStorage, sessionStorage); + } + + protected getLocationString(keyDefinition: KeyDefinition): string { + return ( + keyDefinition.stateDefinition.storageLocationOverrides["web"] ?? + keyDefinition.stateDefinition.defaultStorageLocation + ); + } + + protected override getLocation( + stateDefinition: StateDefinition, + ): AbstractStorageService & ObservableStorageService { + const location = + stateDefinition.storageLocationOverrides["web"] ?? stateDefinition.defaultStorageLocation; + switch (location) { + case "disk": + return this.diskStorage; + case "memory": + return this.memoryStorage; + case "disk-local": + return this.diskLocalStorage; + } + } +} diff --git a/apps/web/src/app/platform/web-single-user-state.provider.ts b/apps/web/src/app/platform/web-single-user-state.provider.ts new file mode 100644 index 0000000000..0625b8545b --- /dev/null +++ b/apps/web/src/app/platform/web-single-user-state.provider.ts @@ -0,0 +1,43 @@ +import { + AbstractMemoryStorageService, + AbstractStorageService, + ObservableStorageService, +} from "@bitwarden/common/platform/abstractions/storage.service"; +import { KeyDefinition } from "@bitwarden/common/platform/state"; +/* eslint-disable import/no-restricted-paths -- Needed to extend service & and in platform owned file */ +import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-single-user-state.provider"; +import { StateDefinition } from "@bitwarden/common/platform/state/state-definition"; +/* eslint-enable import/no-restricted-paths */ + +export class WebSingleUserStateProvider extends DefaultSingleUserStateProvider { + constructor( + memoryStorageService: AbstractMemoryStorageService & ObservableStorageService, + sessionStorageService: AbstractStorageService & ObservableStorageService, + private readonly diskLocalStorageService: AbstractStorageService & ObservableStorageService, + ) { + super(memoryStorageService, sessionStorageService); + } + + protected override getLocationString(keyDefinition: KeyDefinition): string { + return ( + keyDefinition.stateDefinition.storageLocationOverrides["web"] ?? + keyDefinition.stateDefinition.defaultStorageLocation + ); + } + + protected override getLocation( + stateDefinition: StateDefinition, + ): AbstractStorageService & ObservableStorageService { + const location = + stateDefinition.storageLocationOverrides["web"] ?? stateDefinition.defaultStorageLocation; + + switch (location) { + case "disk": + return this.diskStorage; + case "memory": + return this.memoryStorage; + case "disk-local": + return this.diskLocalStorageService; + } + } +} diff --git a/apps/web/src/app/platform/window-storage.service.ts b/apps/web/src/app/platform/window-storage.service.ts new file mode 100644 index 0000000000..e011b45538 --- /dev/null +++ b/apps/web/src/app/platform/window-storage.service.ts @@ -0,0 +1,53 @@ +import { Observable, Subject } from "rxjs"; + +import { + AbstractStorageService, + ObservableStorageService, + StorageUpdate, +} from "@bitwarden/common/platform/abstractions/storage.service"; +import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; + +export class WindowStorageService implements AbstractStorageService, ObservableStorageService { + private readonly updatesSubject = new Subject(); + + updates$: Observable; + constructor(private readonly storage: Storage) { + this.updates$ = this.updatesSubject.asObservable(); + } + + get valuesRequireDeserialization(): boolean { + return true; + } + + get(key: string, options?: StorageOptions): Promise { + const jsonValue = this.storage.getItem(key); + if (jsonValue != null) { + return Promise.resolve(JSON.parse(jsonValue) as T); + } + + return Promise.resolve(null); + } + + async has(key: string, options?: StorageOptions): Promise { + return (await this.get(key, options)) != null; + } + + save(key: string, obj: T, options?: StorageOptions): Promise { + if (obj == null) { + return this.remove(key, options); + } + + if (obj instanceof Set) { + obj = Array.from(obj) as T; + } + + this.storage.setItem(key, JSON.stringify(obj)); + this.updatesSubject.next({ key, updateType: "save" }); + } + + remove(key: string, options?: StorageOptions): Promise { + this.storage.removeItem(key); + this.updatesSubject.next({ key, updateType: "remove" }); + return Promise.resolve(); + } +} diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 10895b5452..f069d9b5f3 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -14,6 +14,9 @@ export const OBSERVABLE_MEMORY_STORAGE = new InjectionToken< export const OBSERVABLE_DISK_STORAGE = new InjectionToken< AbstractStorageService & ObservableStorageService >("OBSERVABLE_DISK_STORAGE"); +export const OBSERVABLE_DISK_LOCAL_STORAGE = new InjectionToken< + AbstractStorageService & ObservableStorageService +>("OBSERVABLE_DISK_LOCAL_STORAGE"); export const MEMORY_STORAGE = new InjectionToken("MEMORY_STORAGE"); export const SECURE_STORAGE = new InjectionToken("SECURE_STORAGE"); export const STATE_FACTORY = new InjectionToken("STATE_FACTORY"); diff --git a/libs/common/spec/fake-state-provider.ts b/libs/common/spec/fake-state-provider.ts index 5daeb14cb1..558114890e 100644 --- a/libs/common/spec/fake-state-provider.ts +++ b/libs/common/spec/fake-state-provider.ts @@ -31,7 +31,7 @@ export class FakeGlobalStateProvider implements GlobalStateProvider { states: Map> = new Map(); get(keyDefinition: KeyDefinition): GlobalState { this.mock.get(keyDefinition); - let result = this.states.get(keyDefinition.buildCacheKey("global")); + let result = this.states.get(keyDefinition.fullName); if (result == null) { let fake: FakeGlobalState; @@ -43,10 +43,10 @@ export class FakeGlobalStateProvider implements GlobalStateProvider { } fake.keyDefinition = keyDefinition; result = fake; - this.states.set(keyDefinition.buildCacheKey("global"), result); + this.states.set(keyDefinition.fullName, result); result = new FakeGlobalState(); - this.states.set(keyDefinition.buildCacheKey("global"), result); + this.states.set(keyDefinition.fullName, result); } return result as GlobalState; } @@ -69,7 +69,7 @@ export class FakeSingleUserStateProvider implements SingleUserStateProvider { states: Map> = new Map(); get(userId: UserId, keyDefinition: KeyDefinition): SingleUserState { this.mock.get(userId, keyDefinition); - let result = this.states.get(keyDefinition.buildCacheKey("user", userId)); + let result = this.states.get(`${keyDefinition.fullName}_${userId}`); if (result == null) { let fake: FakeSingleUserState; @@ -81,7 +81,7 @@ export class FakeSingleUserStateProvider implements SingleUserStateProvider { } fake.keyDefinition = keyDefinition; result = fake; - this.states.set(keyDefinition.buildCacheKey("user", userId), result); + this.states.set(`${keyDefinition.fullName}_${userId}`, result); } return result as SingleUserState; } @@ -106,7 +106,7 @@ export class FakeActiveUserStateProvider implements ActiveUserStateProvider { constructor(public accountService: FakeAccountService) {} get(keyDefinition: KeyDefinition): ActiveUserState { - let result = this.states.get(keyDefinition.buildCacheKey("user", "active")); + let result = this.states.get(keyDefinition.fullName); if (result == null) { // Look for established mock @@ -116,7 +116,7 @@ export class FakeActiveUserStateProvider implements ActiveUserStateProvider { result = new FakeActiveUserState(this.accountService); } result.keyDefinition = keyDefinition; - this.states.set(keyDefinition.buildCacheKey("user", "active"), result); + this.states.set(keyDefinition.fullName, result); } return result as ActiveUserState; } diff --git a/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts b/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts index ca2137efc1..7bb5b44113 100644 --- a/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts @@ -5,7 +5,7 @@ import { ObservableStorageService, } from "../../abstractions/storage.service"; import { KeyDefinition } from "../key-definition"; -import { StorageLocation } from "../state-definition"; +import { StateDefinition } from "../state-definition"; import { ActiveUserState } from "../user-state"; import { ActiveUserStateProvider } from "../user-state.provider"; @@ -15,13 +15,13 @@ export class DefaultActiveUserStateProvider implements ActiveUserStateProvider { private cache: Record> = {}; constructor( - protected accountService: AccountService, - protected memoryStorage: AbstractMemoryStorageService & ObservableStorageService, - protected diskStorage: AbstractStorageService & ObservableStorageService, + protected readonly accountService: AccountService, + protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService, + protected readonly diskStorage: AbstractStorageService & ObservableStorageService, ) {} get(keyDefinition: KeyDefinition): ActiveUserState { - const cacheKey = keyDefinition.buildCacheKey("user", "active"); + const cacheKey = this.buildCacheKey(keyDefinition); const existingUserState = this.cache[cacheKey]; if (existingUserState != null) { // I have to cast out of the unknown generic but this should be safe if rules @@ -34,15 +34,26 @@ export class DefaultActiveUserStateProvider implements ActiveUserStateProvider { return newUserState; } + private buildCacheKey(keyDefinition: KeyDefinition) { + return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}`; + } + protected buildActiveUserState(keyDefinition: KeyDefinition): ActiveUserState { return new DefaultActiveUserState( keyDefinition, this.accountService, - this.getLocation(keyDefinition.stateDefinition.storageLocation), + this.getLocation(keyDefinition.stateDefinition), ); } - private getLocation(location: StorageLocation) { + protected getLocationString(keyDefinition: KeyDefinition): string { + return keyDefinition.stateDefinition.defaultStorageLocation; + } + + protected getLocation(stateDefinition: StateDefinition) { + // The default implementations don't support the client overrides + // it is up to the client to extend this class and add that support + const location = stateDefinition.defaultStorageLocation; switch (location) { case "disk": return this.diskStorage; diff --git a/libs/common/src/platform/state/implementations/default-global-state.provider.ts b/libs/common/src/platform/state/implementations/default-global-state.provider.ts index 1d055e2f62..f1b03e36a7 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-global-state.provider.ts @@ -6,7 +6,7 @@ import { import { GlobalState } from "../global-state"; import { GlobalStateProvider } from "../global-state.provider"; import { KeyDefinition } from "../key-definition"; -import { StorageLocation } from "../state-definition"; +import { StateDefinition } from "../state-definition"; import { DefaultGlobalState } from "./default-global-state"; @@ -14,12 +14,12 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider { private globalStateCache: Record> = {}; constructor( - private memoryStorage: AbstractMemoryStorageService & ObservableStorageService, - private diskStorage: AbstractStorageService & ObservableStorageService, + protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService, + protected readonly diskStorage: AbstractStorageService & ObservableStorageService, ) {} get(keyDefinition: KeyDefinition): GlobalState { - const cacheKey = keyDefinition.buildCacheKey("global"); + const cacheKey = this.buildCacheKey(keyDefinition); const existingGlobalState = this.globalStateCache[cacheKey]; if (existingGlobalState != null) { // The cast into the actual generic is safe because of rules around key definitions @@ -29,14 +29,23 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider { const newGlobalState = new DefaultGlobalState( keyDefinition, - this.getLocation(keyDefinition.stateDefinition.storageLocation), + this.getLocation(keyDefinition.stateDefinition), ); this.globalStateCache[cacheKey] = newGlobalState; return newGlobalState; } - private getLocation(location: StorageLocation) { + private buildCacheKey(keyDefinition: KeyDefinition) { + return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}`; + } + + protected getLocationString(keyDefinition: KeyDefinition): string { + return keyDefinition.stateDefinition.defaultStorageLocation; + } + + protected getLocation(stateDefinition: StateDefinition) { + const location = stateDefinition.defaultStorageLocation; switch (location) { case "disk": return this.diskStorage; diff --git a/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts b/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts index 5b998ed6c8..28b33a22ae 100644 --- a/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts @@ -5,7 +5,7 @@ import { ObservableStorageService, } from "../../abstractions/storage.service"; import { KeyDefinition } from "../key-definition"; -import { StorageLocation } from "../state-definition"; +import { StateDefinition } from "../state-definition"; import { SingleUserState } from "../user-state"; import { SingleUserStateProvider } from "../user-state.provider"; @@ -15,12 +15,12 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider { private cache: Record> = {}; constructor( - protected memoryStorage: AbstractMemoryStorageService & ObservableStorageService, - protected diskStorage: AbstractStorageService & ObservableStorageService, + protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService, + protected readonly diskStorage: AbstractStorageService & ObservableStorageService, ) {} get(userId: UserId, keyDefinition: KeyDefinition): SingleUserState { - const cacheKey = keyDefinition.buildCacheKey("user", userId); + const cacheKey = this.buildCacheKey(userId, keyDefinition); const existingUserState = this.cache[cacheKey]; if (existingUserState != null) { // I have to cast out of the unknown generic but this should be safe if rules @@ -33,6 +33,10 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider { return newUserState; } + private buildCacheKey(userId: UserId, keyDefinition: KeyDefinition) { + return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}_${userId}`; + } + protected buildSingleUserState( userId: UserId, keyDefinition: KeyDefinition, @@ -40,12 +44,18 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider { return new DefaultSingleUserState( userId, keyDefinition, - this.getLocation(keyDefinition.stateDefinition.storageLocation), + this.getLocation(keyDefinition.stateDefinition), ); } - private getLocation(location: StorageLocation) { - switch (location) { + protected getLocationString(keyDefinition: KeyDefinition): string { + return keyDefinition.stateDefinition.defaultStorageLocation; + } + + protected getLocation(stateDefinition: StateDefinition) { + // The default implementations don't support the client overrides + // it is up to the client to extend this class and add that support + switch (stateDefinition.defaultStorageLocation) { case "disk": return this.diskStorage; case "memory": diff --git a/libs/common/src/platform/state/key-definition.spec.ts b/libs/common/src/platform/state/key-definition.spec.ts index ba9a056c52..ee926bccd8 100644 --- a/libs/common/src/platform/state/key-definition.spec.ts +++ b/libs/common/src/platform/state/key-definition.spec.ts @@ -1,7 +1,5 @@ import { Opaque } from "type-fest"; -import { UserId } from "../../types/guid"; - import { KeyDefinition } from "./key-definition"; import { StateDefinition } from "./state-definition"; @@ -111,24 +109,4 @@ describe("KeyDefinition", () => { expect(deserializedValue[1]).toBeFalsy(); }); }); - - describe("buildCacheKey", () => { - const keyDefinition = new KeyDefinition(fakeStateDefinition, "fake", { - deserializer: (s) => s, - }); - - it("builds unique cache key for each user", () => { - const cacheKeys: string[] = []; - - // single user keys - cacheKeys.push(keyDefinition.buildCacheKey("user", "1" as UserId)); - cacheKeys.push(keyDefinition.buildCacheKey("user", "2" as UserId)); - - expect(new Set(cacheKeys).size).toBe(cacheKeys.length); - }); - - it("throws with bad usage", () => { - expect(() => keyDefinition.buildCacheKey("user", null)).toThrow(); - }); - }); }); diff --git a/libs/common/src/platform/state/key-definition.ts b/libs/common/src/platform/state/key-definition.ts index 64bd0acd66..a421c50185 100644 --- a/libs/common/src/platform/state/key-definition.ts +++ b/libs/common/src/platform/state/key-definition.ts @@ -142,19 +142,8 @@ export class KeyDefinition { }); } - /** - * Create a string that should be unique across the entire application. - * @returns A string that can be used to cache instances created via this key. - */ - buildCacheKey(scope: "user" | "global", userId?: "active" | UserId): string { - if (scope === "user" && userId == null) { - throw new Error( - "You must provide a userId or 'active' when building a user scoped cache key.", - ); - } - return userId === null - ? `${this.stateDefinition.storageLocation}_${scope}_${this.stateDefinition.name}_${this.key}` - : `${this.stateDefinition.storageLocation}_${scope}_${userId}_${this.stateDefinition.name}_${this.key}`; + get fullName() { + return `${this.stateDefinition.name}_${this.key}`; } private get errorKeyName() { diff --git a/libs/common/src/platform/state/state-definition.ts b/libs/common/src/platform/state/state-definition.ts index c9c52bb6c1..858be39855 100644 --- a/libs/common/src/platform/state/state-definition.ts +++ b/libs/common/src/platform/state/state-definition.ts @@ -1,16 +1,57 @@ +/** + * Default storage location options. + * + * `disk` generally means state that is accessible between restarts of the application, + * with the exception of the web client. In web this means `sessionStorage`. The data is + * through refreshes of the page but not available once that tab is closed or from any + * other tabs. + * + * `memory` means that the information stored there goes away during application + * restarts. + */ export type StorageLocation = "disk" | "memory"; +/** + * *Note*: The property names of this object should match exactly with the string values of the {@link ClientType} enum + */ +export type ClientLocations = { + /** + * Overriding storage location for the web client. + * + * Includes an extra storage location to store data in `localStorage` + * that is available from different tabs and after a tab has closed. + */ + web: StorageLocation | "disk-local"; + /** + * Overriding storage location for browser clients. + */ + //browser: StorageLocation; + /** + * Overriding storage location for desktop clients. + */ + //desktop: StorageLocation; + /** + * Overriding storage location for CLI clients. + */ + //cli: StorageLocation; +}; + /** * Defines the base location and instruction of where this state is expected to be located. */ export class StateDefinition { + readonly storageLocationOverrides: Partial; + /** * Creates a new instance of {@link StateDefinition}, the creation of which is owned by the platform team. * @param name The name of the state, this needs to be unique from all other {@link StateDefinition}'s. - * @param storageLocation The location of where this state should be stored. + * @param defaultStorageLocation The location of where this state should be stored. */ constructor( readonly name: string, - readonly storageLocation: StorageLocation, - ) {} + readonly defaultStorageLocation: StorageLocation, + storageLocationOverrides?: Partial, + ) { + this.storageLocationOverrides = storageLocationOverrides ?? {}; + } } diff --git a/libs/common/src/platform/state/state-definitions.spec.ts b/libs/common/src/platform/state/state-definitions.spec.ts index 7caa22cd74..d0e6eb3082 100644 --- a/libs/common/src/platform/state/state-definitions.spec.ts +++ b/libs/common/src/platform/state/state-definitions.spec.ts @@ -1,53 +1,60 @@ -import { StateDefinition } from "./state-definition"; +import { ClientLocations, StateDefinition } from "./state-definition"; import * as stateDefinitionsRecord from "./state-definitions"; -describe("state definitions", () => { - const trackedNames: [string, string][] = []; +describe.each(["web", "cli", "desktop", "browser"])( + "state definitions follow rules for client %s", + (clientType: keyof ClientLocations) => { + const trackedNames: [string, string][] = []; - test.each(Object.entries(stateDefinitionsRecord))( - "that export %s follows all rules", - (exportName, stateDefinition) => { - // All exports from state-definitions are expected to be StateDefinition's - if (!(stateDefinition instanceof StateDefinition)) { - throw new Error(`export ${exportName} is expected to be a StateDefinition`); - } + test.each(Object.entries(stateDefinitionsRecord))( + "that export %s follows all rules", + (exportName, stateDefinition) => { + // All exports from state-definitions are expected to be StateDefinition's + if (!(stateDefinition instanceof StateDefinition)) { + throw new Error(`export ${exportName} is expected to be a StateDefinition`); + } - const fullName = `${stateDefinition.name}_${stateDefinition.storageLocation}`; + const storageLocation = + stateDefinition.storageLocationOverrides[clientType] ?? + stateDefinition.defaultStorageLocation; - const exactConflictingExport = trackedNames.find( - ([_, trackedName]) => trackedName === fullName, - ); - if (exactConflictingExport !== undefined) { - const [conflictingExportName] = exactConflictingExport; - throw new Error( - `The export '${exportName}' has a conflicting state name and storage location with export ` + - `'${conflictingExportName}' please ensure that you choose a unique name and location.`, + const fullName = `${stateDefinition.name}_${storageLocation}`; + + const exactConflictingExport = trackedNames.find( + ([_, trackedName]) => trackedName === fullName, ); - } + if (exactConflictingExport !== undefined) { + const [conflictingExportName] = exactConflictingExport; + throw new Error( + `The export '${exportName}' has a conflicting state name and storage location with export ` + + `'${conflictingExportName}' please ensure that you choose a unique name and location for all clients.`, + ); + } - const roughConflictingExport = trackedNames.find( - ([_, trackedName]) => trackedName.toLowerCase() === fullName.toLowerCase(), - ); - if (roughConflictingExport !== undefined) { - const [conflictingExportName] = roughConflictingExport; - throw new Error( - `The export '${exportName}' differs its state name and storage location ` + - `only by casing with export '${conflictingExportName}' please ensure it differs by more than casing.`, + const roughConflictingExport = trackedNames.find( + ([_, trackedName]) => trackedName.toLowerCase() === fullName.toLowerCase(), ); - } + if (roughConflictingExport !== undefined) { + const [conflictingExportName] = roughConflictingExport; + throw new Error( + `The export '${exportName}' differs its state name and storage location ` + + `only by casing with export '${conflictingExportName}' please ensure it differs by more than casing.`, + ); + } - const name = stateDefinition.name; + const name = stateDefinition.name; - expect(name).not.toBeUndefined(); // undefined in an invalid name - expect(name).not.toBeNull(); // null is in invalid name - expect(name.length).toBeGreaterThan(3); // A 3 characters or less name is not descriptive enough - expect(name[0]).toEqual(name[0].toLowerCase()); // First character should be lower case since camelCase is required - expect(name).not.toContain(" "); // There should be no spaces in a state name - expect(name).not.toContain("_"); // We should not be doing snake_case for state name + expect(name).not.toBeUndefined(); // undefined in an invalid name + expect(name).not.toBeNull(); // null is in invalid name + expect(name.length).toBeGreaterThan(3); // A 3 characters or less name is not descriptive enough + expect(name[0]).toEqual(name[0].toLowerCase()); // First character should be lower case since camelCase is required + expect(name).not.toContain(" "); // There should be no spaces in a state name + expect(name).not.toContain("_"); // We should not be doing snake_case for state name - // NOTE: We could expect some details about the export name as well + // NOTE: We could expect some details about the export name as well - trackedNames.push([exportName, fullName]); - }, - ); -}); + trackedNames.push([exportName, fullName]); + }, + ); + }, +);