mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 22:03:36 +00:00
[PM-5829] Add disk-local option for web (#7669)
* Add `disk-local` option for web * Fix `web` DI * Update libs/common/src/platform/state/state-definition.ts Co-authored-by: Matt Gibson <mgibson@bitwarden.com> * Rely On Default Implementation for Most of Cache Key --------- Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
This commit is contained in:
@@ -10,9 +10,11 @@ import {
|
|||||||
MEMORY_STORAGE,
|
MEMORY_STORAGE,
|
||||||
OBSERVABLE_MEMORY_STORAGE,
|
OBSERVABLE_MEMORY_STORAGE,
|
||||||
OBSERVABLE_DISK_STORAGE,
|
OBSERVABLE_DISK_STORAGE,
|
||||||
|
OBSERVABLE_DISK_LOCAL_STORAGE,
|
||||||
} from "@bitwarden/angular/services/injection-tokens";
|
} from "@bitwarden/angular/services/injection-tokens";
|
||||||
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
||||||
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service";
|
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 as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service";
|
||||||
import { LoginService } from "@bitwarden/common/auth/services/login.service";
|
import { LoginService } from "@bitwarden/common/auth/services/login.service";
|
||||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.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 { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||||
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
|
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
|
||||||
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
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 { PolicyListService } from "../admin-console/core/policy-list.service";
|
||||||
import { HtmlStorageService } from "../core/html-storage.service";
|
import { HtmlStorageService } from "../core/html-storage.service";
|
||||||
import { I18nService } from "../core/i18n.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 { CollectionAdminService } from "../vault/core/collection-admin.service";
|
||||||
|
|
||||||
import { BroadcasterMessagingService } from "./broadcaster-messaging.service";
|
import { BroadcasterMessagingService } from "./broadcaster-messaging.service";
|
||||||
@@ -77,7 +88,10 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service";
|
|||||||
useClass: MemoryStorageService,
|
useClass: MemoryStorageService,
|
||||||
},
|
},
|
||||||
{ provide: OBSERVABLE_MEMORY_STORAGE, useExisting: MEMORY_STORAGE },
|
{ provide: OBSERVABLE_MEMORY_STORAGE, useExisting: MEMORY_STORAGE },
|
||||||
{ provide: OBSERVABLE_DISK_STORAGE, useExisting: AbstractStorageService },
|
{
|
||||||
|
provide: OBSERVABLE_DISK_STORAGE,
|
||||||
|
useFactory: () => new WindowStorageService(window.sessionStorage),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: PlatformUtilsServiceAbstraction,
|
provide: PlatformUtilsServiceAbstraction,
|
||||||
useClass: WebPlatformUtilsService,
|
useClass: WebPlatformUtilsService,
|
||||||
@@ -99,6 +113,30 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service";
|
|||||||
deps: [StateService],
|
deps: [StateService],
|
||||||
},
|
},
|
||||||
CollectionAdminService,
|
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 {
|
export class CoreModule {
|
||||||
|
|||||||
44
apps/web/src/app/platform/web-active-user-state.provider.ts
Normal file
44
apps/web/src/app/platform/web-active-user-state.provider.ts
Normal file
@@ -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<unknown>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
apps/web/src/app/platform/web-global-state.provider.ts
Normal file
42
apps/web/src/app/platform/web-global-state.provider.ts
Normal file
@@ -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<unknown>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/web/src/app/platform/web-single-user-state.provider.ts
Normal file
43
apps/web/src/app/platform/web-single-user-state.provider.ts
Normal file
@@ -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<unknown>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/web/src/app/platform/window-storage.service.ts
Normal file
53
apps/web/src/app/platform/window-storage.service.ts
Normal file
@@ -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<StorageUpdate>();
|
||||||
|
|
||||||
|
updates$: Observable<StorageUpdate>;
|
||||||
|
constructor(private readonly storage: Storage) {
|
||||||
|
this.updates$ = this.updatesSubject.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(key: string, options?: StorageOptions): Promise<T> {
|
||||||
|
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<boolean> {
|
||||||
|
return (await this.get(key, options)) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
save<T>(key: string, obj: T, options?: StorageOptions): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
this.storage.removeItem(key);
|
||||||
|
this.updatesSubject.next({ key, updateType: "remove" });
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ export const OBSERVABLE_MEMORY_STORAGE = new InjectionToken<
|
|||||||
export const OBSERVABLE_DISK_STORAGE = new InjectionToken<
|
export const OBSERVABLE_DISK_STORAGE = new InjectionToken<
|
||||||
AbstractStorageService & ObservableStorageService
|
AbstractStorageService & ObservableStorageService
|
||||||
>("OBSERVABLE_DISK_STORAGE");
|
>("OBSERVABLE_DISK_STORAGE");
|
||||||
|
export const OBSERVABLE_DISK_LOCAL_STORAGE = new InjectionToken<
|
||||||
|
AbstractStorageService & ObservableStorageService
|
||||||
|
>("OBSERVABLE_DISK_LOCAL_STORAGE");
|
||||||
export const MEMORY_STORAGE = new InjectionToken<AbstractMemoryStorageService>("MEMORY_STORAGE");
|
export const MEMORY_STORAGE = new InjectionToken<AbstractMemoryStorageService>("MEMORY_STORAGE");
|
||||||
export const SECURE_STORAGE = new InjectionToken<AbstractStorageService>("SECURE_STORAGE");
|
export const SECURE_STORAGE = new InjectionToken<AbstractStorageService>("SECURE_STORAGE");
|
||||||
export const STATE_FACTORY = new InjectionToken<StateFactory>("STATE_FACTORY");
|
export const STATE_FACTORY = new InjectionToken<StateFactory>("STATE_FACTORY");
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class FakeGlobalStateProvider implements GlobalStateProvider {
|
|||||||
states: Map<string, GlobalState<unknown>> = new Map();
|
states: Map<string, GlobalState<unknown>> = new Map();
|
||||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||||
this.mock.get(keyDefinition);
|
this.mock.get(keyDefinition);
|
||||||
let result = this.states.get(keyDefinition.buildCacheKey("global"));
|
let result = this.states.get(keyDefinition.fullName);
|
||||||
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
let fake: FakeGlobalState<T>;
|
let fake: FakeGlobalState<T>;
|
||||||
@@ -43,10 +43,10 @@ export class FakeGlobalStateProvider implements GlobalStateProvider {
|
|||||||
}
|
}
|
||||||
fake.keyDefinition = keyDefinition;
|
fake.keyDefinition = keyDefinition;
|
||||||
result = fake;
|
result = fake;
|
||||||
this.states.set(keyDefinition.buildCacheKey("global"), result);
|
this.states.set(keyDefinition.fullName, result);
|
||||||
|
|
||||||
result = new FakeGlobalState<T>();
|
result = new FakeGlobalState<T>();
|
||||||
this.states.set(keyDefinition.buildCacheKey("global"), result);
|
this.states.set(keyDefinition.fullName, result);
|
||||||
}
|
}
|
||||||
return result as GlobalState<T>;
|
return result as GlobalState<T>;
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ export class FakeSingleUserStateProvider implements SingleUserStateProvider {
|
|||||||
states: Map<string, SingleUserState<unknown>> = new Map();
|
states: Map<string, SingleUserState<unknown>> = new Map();
|
||||||
get<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T> {
|
get<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T> {
|
||||||
this.mock.get(userId, keyDefinition);
|
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) {
|
if (result == null) {
|
||||||
let fake: FakeSingleUserState<T>;
|
let fake: FakeSingleUserState<T>;
|
||||||
@@ -81,7 +81,7 @@ export class FakeSingleUserStateProvider implements SingleUserStateProvider {
|
|||||||
}
|
}
|
||||||
fake.keyDefinition = keyDefinition;
|
fake.keyDefinition = keyDefinition;
|
||||||
result = fake;
|
result = fake;
|
||||||
this.states.set(keyDefinition.buildCacheKey("user", userId), result);
|
this.states.set(`${keyDefinition.fullName}_${userId}`, result);
|
||||||
}
|
}
|
||||||
return result as SingleUserState<T>;
|
return result as SingleUserState<T>;
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
|
|||||||
constructor(public accountService: FakeAccountService) {}
|
constructor(public accountService: FakeAccountService) {}
|
||||||
|
|
||||||
get<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
|
get<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
|
||||||
let result = this.states.get(keyDefinition.buildCacheKey("user", "active"));
|
let result = this.states.get(keyDefinition.fullName);
|
||||||
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
// Look for established mock
|
// Look for established mock
|
||||||
@@ -116,7 +116,7 @@ export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
|
|||||||
result = new FakeActiveUserState<T>(this.accountService);
|
result = new FakeActiveUserState<T>(this.accountService);
|
||||||
}
|
}
|
||||||
result.keyDefinition = keyDefinition;
|
result.keyDefinition = keyDefinition;
|
||||||
this.states.set(keyDefinition.buildCacheKey("user", "active"), result);
|
this.states.set(keyDefinition.fullName, result);
|
||||||
}
|
}
|
||||||
return result as ActiveUserState<T>;
|
return result as ActiveUserState<T>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
ObservableStorageService,
|
ObservableStorageService,
|
||||||
} from "../../abstractions/storage.service";
|
} from "../../abstractions/storage.service";
|
||||||
import { KeyDefinition } from "../key-definition";
|
import { KeyDefinition } from "../key-definition";
|
||||||
import { StorageLocation } from "../state-definition";
|
import { StateDefinition } from "../state-definition";
|
||||||
import { ActiveUserState } from "../user-state";
|
import { ActiveUserState } from "../user-state";
|
||||||
import { ActiveUserStateProvider } from "../user-state.provider";
|
import { ActiveUserStateProvider } from "../user-state.provider";
|
||||||
|
|
||||||
@@ -15,13 +15,13 @@ export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
|
|||||||
private cache: Record<string, ActiveUserState<unknown>> = {};
|
private cache: Record<string, ActiveUserState<unknown>> = {};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected accountService: AccountService,
|
protected readonly accountService: AccountService,
|
||||||
protected memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
|
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
|
||||||
protected diskStorage: AbstractStorageService & ObservableStorageService,
|
protected readonly diskStorage: AbstractStorageService & ObservableStorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
|
get<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
|
||||||
const cacheKey = keyDefinition.buildCacheKey("user", "active");
|
const cacheKey = this.buildCacheKey(keyDefinition);
|
||||||
const existingUserState = this.cache[cacheKey];
|
const existingUserState = this.cache[cacheKey];
|
||||||
if (existingUserState != null) {
|
if (existingUserState != null) {
|
||||||
// I have to cast out of the unknown generic but this should be safe if rules
|
// 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;
|
return newUserState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildCacheKey(keyDefinition: KeyDefinition<unknown>) {
|
||||||
|
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}`;
|
||||||
|
}
|
||||||
|
|
||||||
protected buildActiveUserState<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
|
protected buildActiveUserState<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
|
||||||
return new DefaultActiveUserState<T>(
|
return new DefaultActiveUserState<T>(
|
||||||
keyDefinition,
|
keyDefinition,
|
||||||
this.accountService,
|
this.accountService,
|
||||||
this.getLocation(keyDefinition.stateDefinition.storageLocation),
|
this.getLocation(keyDefinition.stateDefinition),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLocation(location: StorageLocation) {
|
protected getLocationString(keyDefinition: KeyDefinition<unknown>): 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) {
|
switch (location) {
|
||||||
case "disk":
|
case "disk":
|
||||||
return this.diskStorage;
|
return this.diskStorage;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import { GlobalState } from "../global-state";
|
import { GlobalState } from "../global-state";
|
||||||
import { GlobalStateProvider } from "../global-state.provider";
|
import { GlobalStateProvider } from "../global-state.provider";
|
||||||
import { KeyDefinition } from "../key-definition";
|
import { KeyDefinition } from "../key-definition";
|
||||||
import { StorageLocation } from "../state-definition";
|
import { StateDefinition } from "../state-definition";
|
||||||
|
|
||||||
import { DefaultGlobalState } from "./default-global-state";
|
import { DefaultGlobalState } from "./default-global-state";
|
||||||
|
|
||||||
@@ -14,12 +14,12 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider {
|
|||||||
private globalStateCache: Record<string, GlobalState<unknown>> = {};
|
private globalStateCache: Record<string, GlobalState<unknown>> = {};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
|
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
|
||||||
private diskStorage: AbstractStorageService & ObservableStorageService,
|
protected readonly diskStorage: AbstractStorageService & ObservableStorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||||
const cacheKey = keyDefinition.buildCacheKey("global");
|
const cacheKey = this.buildCacheKey(keyDefinition);
|
||||||
const existingGlobalState = this.globalStateCache[cacheKey];
|
const existingGlobalState = this.globalStateCache[cacheKey];
|
||||||
if (existingGlobalState != null) {
|
if (existingGlobalState != null) {
|
||||||
// The cast into the actual generic is safe because of rules around key definitions
|
// 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<T>(
|
const newGlobalState = new DefaultGlobalState<T>(
|
||||||
keyDefinition,
|
keyDefinition,
|
||||||
this.getLocation(keyDefinition.stateDefinition.storageLocation),
|
this.getLocation(keyDefinition.stateDefinition),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.globalStateCache[cacheKey] = newGlobalState;
|
this.globalStateCache[cacheKey] = newGlobalState;
|
||||||
return newGlobalState;
|
return newGlobalState;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLocation(location: StorageLocation) {
|
private buildCacheKey(keyDefinition: KeyDefinition<unknown>) {
|
||||||
|
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getLocationString(keyDefinition: KeyDefinition<unknown>): string {
|
||||||
|
return keyDefinition.stateDefinition.defaultStorageLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getLocation(stateDefinition: StateDefinition) {
|
||||||
|
const location = stateDefinition.defaultStorageLocation;
|
||||||
switch (location) {
|
switch (location) {
|
||||||
case "disk":
|
case "disk":
|
||||||
return this.diskStorage;
|
return this.diskStorage;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
ObservableStorageService,
|
ObservableStorageService,
|
||||||
} from "../../abstractions/storage.service";
|
} from "../../abstractions/storage.service";
|
||||||
import { KeyDefinition } from "../key-definition";
|
import { KeyDefinition } from "../key-definition";
|
||||||
import { StorageLocation } from "../state-definition";
|
import { StateDefinition } from "../state-definition";
|
||||||
import { SingleUserState } from "../user-state";
|
import { SingleUserState } from "../user-state";
|
||||||
import { SingleUserStateProvider } from "../user-state.provider";
|
import { SingleUserStateProvider } from "../user-state.provider";
|
||||||
|
|
||||||
@@ -15,12 +15,12 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider {
|
|||||||
private cache: Record<string, SingleUserState<unknown>> = {};
|
private cache: Record<string, SingleUserState<unknown>> = {};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
|
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
|
||||||
protected diskStorage: AbstractStorageService & ObservableStorageService,
|
protected readonly diskStorage: AbstractStorageService & ObservableStorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T> {
|
get<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T> {
|
||||||
const cacheKey = keyDefinition.buildCacheKey("user", userId);
|
const cacheKey = this.buildCacheKey(userId, keyDefinition);
|
||||||
const existingUserState = this.cache[cacheKey];
|
const existingUserState = this.cache[cacheKey];
|
||||||
if (existingUserState != null) {
|
if (existingUserState != null) {
|
||||||
// I have to cast out of the unknown generic but this should be safe if rules
|
// 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;
|
return newUserState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildCacheKey(userId: UserId, keyDefinition: KeyDefinition<unknown>) {
|
||||||
|
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}_${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
protected buildSingleUserState<T>(
|
protected buildSingleUserState<T>(
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
keyDefinition: KeyDefinition<T>,
|
keyDefinition: KeyDefinition<T>,
|
||||||
@@ -40,12 +44,18 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider {
|
|||||||
return new DefaultSingleUserState<T>(
|
return new DefaultSingleUserState<T>(
|
||||||
userId,
|
userId,
|
||||||
keyDefinition,
|
keyDefinition,
|
||||||
this.getLocation(keyDefinition.stateDefinition.storageLocation),
|
this.getLocation(keyDefinition.stateDefinition),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLocation(location: StorageLocation) {
|
protected getLocationString(keyDefinition: KeyDefinition<unknown>): string {
|
||||||
switch (location) {
|
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":
|
case "disk":
|
||||||
return this.diskStorage;
|
return this.diskStorage;
|
||||||
case "memory":
|
case "memory":
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Opaque } from "type-fest";
|
import { Opaque } from "type-fest";
|
||||||
|
|
||||||
import { UserId } from "../../types/guid";
|
|
||||||
|
|
||||||
import { KeyDefinition } from "./key-definition";
|
import { KeyDefinition } from "./key-definition";
|
||||||
import { StateDefinition } from "./state-definition";
|
import { StateDefinition } from "./state-definition";
|
||||||
|
|
||||||
@@ -111,24 +109,4 @@ describe("KeyDefinition", () => {
|
|||||||
expect(deserializedValue[1]).toBeFalsy();
|
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -142,19 +142,8 @@ export class KeyDefinition<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
get fullName() {
|
||||||
* Create a string that should be unique across the entire application.
|
return `${this.stateDefinition.name}_${this.key}`;
|
||||||
* @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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private get errorKeyName() {
|
private get errorKeyName() {
|
||||||
|
|||||||
@@ -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";
|
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.
|
* Defines the base location and instruction of where this state is expected to be located.
|
||||||
*/
|
*/
|
||||||
export class StateDefinition {
|
export class StateDefinition {
|
||||||
|
readonly storageLocationOverrides: Partial<ClientLocations>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new instance of {@link StateDefinition}, the creation of which is owned by the platform team.
|
* 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 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(
|
constructor(
|
||||||
readonly name: string,
|
readonly name: string,
|
||||||
readonly storageLocation: StorageLocation,
|
readonly defaultStorageLocation: StorageLocation,
|
||||||
) {}
|
storageLocationOverrides?: Partial<ClientLocations>,
|
||||||
|
) {
|
||||||
|
this.storageLocationOverrides = storageLocationOverrides ?? {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,60 @@
|
|||||||
import { StateDefinition } from "./state-definition";
|
import { ClientLocations, StateDefinition } from "./state-definition";
|
||||||
import * as stateDefinitionsRecord from "./state-definitions";
|
import * as stateDefinitionsRecord from "./state-definitions";
|
||||||
|
|
||||||
describe("state definitions", () => {
|
describe.each(["web", "cli", "desktop", "browser"])(
|
||||||
const trackedNames: [string, string][] = [];
|
"state definitions follow rules for client %s",
|
||||||
|
(clientType: keyof ClientLocations) => {
|
||||||
|
const trackedNames: [string, string][] = [];
|
||||||
|
|
||||||
test.each(Object.entries(stateDefinitionsRecord))(
|
test.each(Object.entries(stateDefinitionsRecord))(
|
||||||
"that export %s follows all rules",
|
"that export %s follows all rules",
|
||||||
(exportName, stateDefinition) => {
|
(exportName, stateDefinition) => {
|
||||||
// All exports from state-definitions are expected to be StateDefinition's
|
// All exports from state-definitions are expected to be StateDefinition's
|
||||||
if (!(stateDefinition instanceof StateDefinition)) {
|
if (!(stateDefinition instanceof StateDefinition)) {
|
||||||
throw new Error(`export ${exportName} is expected to be a 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(
|
const fullName = `${stateDefinition.name}_${storageLocation}`;
|
||||||
([_, trackedName]) => trackedName === fullName,
|
|
||||||
);
|
const exactConflictingExport = trackedNames.find(
|
||||||
if (exactConflictingExport !== undefined) {
|
([_, trackedName]) => trackedName === fullName,
|
||||||
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.`,
|
|
||||||
);
|
);
|
||||||
}
|
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(
|
const roughConflictingExport = trackedNames.find(
|
||||||
([_, trackedName]) => trackedName.toLowerCase() === fullName.toLowerCase(),
|
([_, 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.`,
|
|
||||||
);
|
);
|
||||||
}
|
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.toBeUndefined(); // undefined in an invalid name
|
||||||
expect(name).not.toBeNull(); // null is in 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.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[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(" "); // 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.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]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user