From e045c6b103a0edfafb494600075a4fb0d8e56f02 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Tue, 5 Dec 2023 10:20:16 -0500 Subject: [PATCH] Ps/introduce single user state (#7053) * Specify state provider for currently active user * Split active and single user States UserStateProvider is still the mechanism to build each State object. The SingleUserState is basically a repeat of GlobalState, but with additional scoping. * Fixup global state cache * fix fakers to new interface * Make userId available in single user state * Split providers by dependency requirements This allows usage of the single state provider in contexts that would otherwise form circular dependencies. * Offer convenience wrapper classes for common use * Import for docs * Bind wrapped methods --- .../src/services/jslib-services.module.ts | 33 ++- libs/common/spec/fake-state-provider.ts | 56 +++-- libs/common/spec/fake-state.ts | 18 +- ... => default-active-user-state.provider.ts} | 26 +-- ...c.ts => default-active-user-state.spec.ts} | 8 +- ...-state.ts => default-active-user-state.ts} | 37 +-- .../default-global-state.provider.ts | 2 +- .../default-single-user-state.provider.ts | 58 +++++ .../default-single-user-state.spec.ts | 212 ++++++++++++++++++ .../default-single-user-state.ts | 118 ++++++++++ .../default-state.provider.spec.ts | 56 +++++ .../implementations/default-state.provider.ts | 19 ++ libs/common/src/platform/state/index.ts | 5 +- .../src/platform/state/key-definition.ts | 9 +- .../src/platform/state/state.provider.ts | 21 ++ .../src/platform/state/user-state.provider.ts | 31 ++- libs/common/src/platform/state/user-state.ts | 24 +- 17 files changed, 629 insertions(+), 104 deletions(-) rename libs/common/src/platform/state/implementations/{default-user-state.provider.ts => default-active-user-state.provider.ts} (60%) rename libs/common/src/platform/state/implementations/{default-user-state.spec.ts => default-active-user-state.spec.ts} (97%) rename libs/common/src/platform/state/implementations/{default-user-state.ts => default-active-user-state.ts} (82%) create mode 100644 libs/common/src/platform/state/implementations/default-single-user-state.provider.ts create mode 100644 libs/common/src/platform/state/implementations/default-single-user-state.spec.ts create mode 100644 libs/common/src/platform/state/implementations/default-single-user-state.ts create mode 100644 libs/common/src/platform/state/implementations/default-state.provider.spec.ts create mode 100644 libs/common/src/platform/state/implementations/default-state.provider.ts create mode 100644 libs/common/src/platform/state/state.provider.ts diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 4f8001d1c9..4e5b3f667a 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -107,9 +107,18 @@ import { NoopNotificationsService } from "@bitwarden/common/platform/services/no import { StateService } from "@bitwarden/common/platform/services/state.service"; import { ValidationService } from "@bitwarden/common/platform/services/validation.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; -import { GlobalStateProvider } from "@bitwarden/common/platform/state"; -// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed +import { + ActiveUserStateProvider, + GlobalStateProvider, + SingleUserStateProvider, + StateProvider, +} from "@bitwarden/common/platform/state"; +/* eslint-disable import/no-restricted-paths -- We need the implementations to inject, but generally these should not be accessed */ +import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-active-user-state.provider"; import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider"; +import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-single-user-state.provider"; +import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider"; +/* eslint-enable import/no-restricted-paths */ import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; @@ -785,6 +794,26 @@ import { ModalService } from "./modal.service"; useClass: DefaultGlobalStateProvider, deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE], }, + { + provide: ActiveUserStateProvider, + useClass: DefaultActiveUserStateProvider, + deps: [ + AccountServiceAbstraction, + EncryptService, + OBSERVABLE_MEMORY_STORAGE, + OBSERVABLE_DISK_STORAGE, + ], + }, + { + provide: SingleUserStateProvider, + useClass: DefaultSingleUserStateProvider, + deps: [EncryptService, OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE], + }, + { + provide: StateProvider, + useClass: DefaultStateProvider, + deps: [ActiveUserStateProvider, SingleUserStateProvider, GlobalStateProvider], + }, ], }) export class JslibServicesModule {} diff --git a/libs/common/spec/fake-state-provider.ts b/libs/common/spec/fake-state-provider.ts index 6abf32fad6..d857369f91 100644 --- a/libs/common/spec/fake-state-provider.ts +++ b/libs/common/spec/fake-state-provider.ts @@ -2,48 +2,62 @@ import { GlobalState, GlobalStateProvider, KeyDefinition, - UserState, - UserStateProvider, + ActiveUserState, + SingleUserState, } from "../src/platform/state"; +import { UserId } from "../src/types/guid"; -import { FakeGlobalState, FakeUserState } from "./fake-state"; +import { FakeActiveUserState, FakeGlobalState, FakeSingleUserState } from "./fake-state"; export class FakeGlobalStateProvider implements GlobalStateProvider { - states: Map, GlobalState> = new Map(); + states: Map> = new Map(); get(keyDefinition: KeyDefinition): GlobalState { - let result = this.states.get(keyDefinition) as GlobalState; + let result = this.states.get(keyDefinition.buildCacheKey("global")) as GlobalState; if (result == null) { result = new FakeGlobalState(); - this.states.set(keyDefinition, result); + this.states.set(keyDefinition.buildCacheKey("global"), result); } return result; } getFake(keyDefinition: KeyDefinition): FakeGlobalState { - const key = Array.from(this.states.keys()).find( - (k) => k.stateDefinition === keyDefinition.stateDefinition && k.key === keyDefinition.key, - ); - return this.get(key) as FakeGlobalState; + return this.get(keyDefinition) as FakeGlobalState; } } -export class FakeUserStateProvider implements UserStateProvider { - states: Map, UserState> = new Map(); - get(keyDefinition: KeyDefinition): UserState { - let result = this.states.get(keyDefinition) as UserState; +export class FakeSingleUserStateProvider { + states: Map> = new Map(); + get(userId: UserId, keyDefinition: KeyDefinition): SingleUserState { + let result = this.states.get(keyDefinition.buildCacheKey("user", userId)) as SingleUserState; if (result == null) { - result = new FakeUserState(); - this.states.set(keyDefinition, result); + result = new FakeSingleUserState(userId); + this.states.set(keyDefinition.buildCacheKey("user", userId), result); } return result; } - getFake(keyDefinition: KeyDefinition): FakeUserState { - const key = Array.from(this.states.keys()).find( - (k) => k.stateDefinition === keyDefinition.stateDefinition && k.key === keyDefinition.key, - ); - return this.get(key) as FakeUserState; + getFake(userId: UserId, keyDefinition: KeyDefinition): FakeSingleUserState { + return this.get(userId, keyDefinition) as FakeSingleUserState; + } +} + +export class FakeActiveUserStateProvider { + states: Map> = new Map(); + get(keyDefinition: KeyDefinition): ActiveUserState { + let result = this.states.get( + keyDefinition.buildCacheKey("user", "active"), + ) as ActiveUserState; + + if (result == null) { + result = new FakeActiveUserState(); + this.states.set(keyDefinition.buildCacheKey("user", "active"), result); + } + return result; + } + + getFake(keyDefinition: KeyDefinition): FakeActiveUserState { + return this.get(keyDefinition) as FakeActiveUserState; } } diff --git a/libs/common/spec/fake-state.ts b/libs/common/spec/fake-state.ts index b403cbd883..42fa498ffd 100644 --- a/libs/common/spec/fake-state.ts +++ b/libs/common/spec/fake-state.ts @@ -1,8 +1,15 @@ import { ReplaySubject, firstValueFrom, timeout } from "rxjs"; -import { DerivedUserState, GlobalState, UserState } from "../src/platform/state"; +import { + DerivedUserState, + GlobalState, + SingleUserState, + ActiveUserState, +} from "../src/platform/state"; // eslint-disable-next-line import/no-restricted-paths -- using unexposed options for clean typing in test class import { StateUpdateOptions } from "../src/platform/state/state-update-options"; +// eslint-disable-next-line import/no-restricted-paths -- using unexposed options for clean typing in test class +import { UserState, activeMarker } from "../src/platform/state/user-state"; import { UserId } from "../src/types/guid"; const DEFAULT_TEST_OPTIONS: StateUpdateOptions = { @@ -97,3 +104,12 @@ export class FakeUserState implements UserState { return this.stateSubject.asObservable(); } } + +export class FakeSingleUserState extends FakeUserState implements SingleUserState { + constructor(readonly userId: UserId) { + super(); + } +} +export class FakeActiveUserState extends FakeUserState implements ActiveUserState { + [activeMarker]: true; +} diff --git a/libs/common/src/platform/state/implementations/default-user-state.provider.ts b/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts similarity index 60% rename from libs/common/src/platform/state/implementations/default-user-state.provider.ts rename to libs/common/src/platform/state/implementations/default-active-user-state.provider.ts index 36d0c8cd94..0950918fc5 100644 --- a/libs/common/src/platform/state/implementations/default-user-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts @@ -7,13 +7,13 @@ import { } from "../../abstractions/storage.service"; import { KeyDefinition } from "../key-definition"; import { StorageLocation } from "../state-definition"; -import { UserState } from "../user-state"; -import { UserStateProvider } from "../user-state.provider"; +import { ActiveUserState } from "../user-state"; +import { ActiveUserStateProvider } from "../user-state.provider"; -import { DefaultUserState } from "./default-user-state"; +import { DefaultActiveUserState } from "./default-active-user-state"; -export class DefaultUserStateProvider implements UserStateProvider { - private userStateCache: Record> = {}; +export class DefaultActiveUserStateProvider implements ActiveUserStateProvider { + private cache: Record> = {}; constructor( protected accountService: AccountService, @@ -22,22 +22,22 @@ export class DefaultUserStateProvider implements UserStateProvider { protected diskStorage: AbstractStorageService & ObservableStorageService, ) {} - get(keyDefinition: KeyDefinition): UserState { - const cacheKey = keyDefinition.buildCacheKey(); - const existingUserState = this.userStateCache[cacheKey]; + get(keyDefinition: KeyDefinition): ActiveUserState { + const cacheKey = keyDefinition.buildCacheKey("user", "active"); + const existingUserState = this.cache[cacheKey]; if (existingUserState != null) { // I have to cast out of the unknown generic but this should be safe if rules // around domain token are made - return existingUserState as DefaultUserState; + return existingUserState as ActiveUserState; } - const newUserState = this.buildUserState(keyDefinition); - this.userStateCache[cacheKey] = newUserState; + const newUserState = this.buildActiveUserState(keyDefinition); + this.cache[cacheKey] = newUserState; return newUserState; } - protected buildUserState(keyDefinition: KeyDefinition): UserState { - return new DefaultUserState( + protected buildActiveUserState(keyDefinition: KeyDefinition): ActiveUserState { + return new DefaultActiveUserState( keyDefinition, this.accountService, this.encryptService, diff --git a/libs/common/src/platform/state/implementations/default-user-state.spec.ts b/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts similarity index 97% rename from libs/common/src/platform/state/implementations/default-user-state.spec.ts rename to libs/common/src/platform/state/implementations/default-active-user-state.spec.ts index 54321ddae2..8b4d8edb6b 100644 --- a/libs/common/src/platform/state/implementations/default-user-state.spec.ts +++ b/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts @@ -14,7 +14,7 @@ import { UserId } from "../../../types/guid"; import { KeyDefinition } from "../key-definition"; import { StateDefinition } from "../state-definition"; -import { DefaultUserState } from "./default-user-state"; +import { DefaultActiveUserState } from "./default-active-user-state"; class TestState { date: Date; @@ -37,18 +37,18 @@ const testKeyDefinition = new KeyDefinition(testStateDefinition, "fak deserializer: TestState.fromJSON, }); -describe("DefaultUserState", () => { +describe("DefaultActiveUserState", () => { const accountService = mock(); let diskStorageService: FakeStorageService; let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>; - let userState: DefaultUserState; + let userState: DefaultActiveUserState; beforeEach(() => { activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(undefined); accountService.activeAccount$ = activeAccountSubject; diskStorageService = new FakeStorageService(); - userState = new DefaultUserState( + userState = new DefaultActiveUserState( testKeyDefinition, accountService, null, // Not testing anything with encrypt service diff --git a/libs/common/src/platform/state/implementations/default-user-state.ts b/libs/common/src/platform/state/implementations/default-active-user-state.ts similarity index 82% rename from libs/common/src/platform/state/implementations/default-user-state.ts rename to libs/common/src/platform/state/implementations/default-active-user-state.ts index bcd8568ce5..6add6f2db8 100644 --- a/libs/common/src/platform/state/implementations/default-user-state.ts +++ b/libs/common/src/platform/state/implementations/default-active-user-state.ts @@ -13,7 +13,6 @@ import { } from "rxjs"; import { AccountService } from "../../../auth/abstractions/account.service"; -import { UserId } from "../../../types/guid"; import { EncryptService } from "../../abstractions/encrypt.service"; import { AbstractStorageService, @@ -22,14 +21,15 @@ import { import { DerivedUserState } from "../derived-user-state"; import { KeyDefinition, userKeyBuilder } from "../key-definition"; import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; -import { Converter, UserState } from "../user-state"; +import { Converter, ActiveUserState, activeMarker } from "../user-state"; import { DefaultDerivedUserState } from "./default-derived-state"; import { getStoredValue } from "./util"; const FAKE_DEFAULT = Symbol("fakeDefault"); -export class DefaultUserState implements UserState { +export class DefaultActiveUserState implements ActiveUserState { + [activeMarker]: true; private formattedKey$: Observable; protected stateSubject: BehaviorSubject = new BehaviorSubject< @@ -130,37 +130,6 @@ export class DefaultUserState implements UserState { return newState; } - async updateFor( - userId: UserId, - configureState: (state: T, dependencies: TCombine) => T, - options: StateUpdateOptions = {}, - ): Promise { - if (userId == null) { - throw new Error("Attempting to update user state, but no userId has been supplied."); - } - options = populateOptionsWithDefault(options); - - const key = userKeyBuilder(userId, this.keyDefinition); - const currentState = await getStoredValue( - key, - this.chosenStorageLocation, - this.keyDefinition.deserializer, - ); - const combinedDependencies = - options.combineLatestWith != null - ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) - : null; - - if (!options.shouldUpdate(currentState, combinedDependencies)) { - return; - } - - const newState = configureState(currentState, combinedDependencies); - await this.saveToStorage(key, newState); - - return newState; - } - async getFromState(): Promise { const key = await this.createKey(); return await getStoredValue(key, this.chosenStorageLocation, this.keyDefinition.deserializer); 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 d5e16dd43c..1d055e2f62 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 @@ -19,7 +19,7 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider { ) {} get(keyDefinition: KeyDefinition): GlobalState { - const cacheKey = keyDefinition.buildCacheKey(); + const cacheKey = keyDefinition.buildCacheKey("global"); const existingGlobalState = this.globalStateCache[cacheKey]; if (existingGlobalState != null) { // The cast into the actual generic is safe because of rules around key definitions 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 new file mode 100644 index 0000000000..1a1352145d --- /dev/null +++ b/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts @@ -0,0 +1,58 @@ +import { UserId } from "../../../types/guid"; +import { EncryptService } from "../../abstractions/encrypt.service"; +import { + AbstractMemoryStorageService, + AbstractStorageService, + ObservableStorageService, +} from "../../abstractions/storage.service"; +import { KeyDefinition } from "../key-definition"; +import { StorageLocation } from "../state-definition"; +import { SingleUserState } from "../user-state"; +import { SingleUserStateProvider } from "../user-state.provider"; + +import { DefaultSingleUserState } from "./default-single-user-state"; + +export class DefaultSingleUserStateProvider implements SingleUserStateProvider { + private cache: Record> = {}; + + constructor( + protected encryptService: EncryptService, + protected memoryStorage: AbstractMemoryStorageService & ObservableStorageService, + protected diskStorage: AbstractStorageService & ObservableStorageService, + ) {} + + get(userId: UserId, keyDefinition: KeyDefinition): SingleUserState { + const cacheKey = keyDefinition.buildCacheKey("user", userId); + const existingUserState = this.cache[cacheKey]; + if (existingUserState != null) { + // I have to cast out of the unknown generic but this should be safe if rules + // around domain token are made + return existingUserState as SingleUserState; + } + + const newUserState = this.buildSingleUserState(userId, keyDefinition); + this.cache[cacheKey] = newUserState; + return newUserState; + } + + protected buildSingleUserState( + userId: UserId, + keyDefinition: KeyDefinition, + ): SingleUserState { + return new DefaultSingleUserState( + userId, + keyDefinition, + this.encryptService, + this.getLocation(keyDefinition.stateDefinition.storageLocation), + ); + } + + private getLocation(location: StorageLocation) { + switch (location) { + case "disk": + return this.diskStorage; + case "memory": + return this.memoryStorage; + } + } +} diff --git a/libs/common/src/platform/state/implementations/default-single-user-state.spec.ts b/libs/common/src/platform/state/implementations/default-single-user-state.spec.ts new file mode 100644 index 0000000000..443a176c2e --- /dev/null +++ b/libs/common/src/platform/state/implementations/default-single-user-state.spec.ts @@ -0,0 +1,212 @@ +/** + * need to update test environment so trackEmissions works appropriately + * @jest-environment ../shared/test.environment.ts + */ + +import { firstValueFrom, of } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { trackEmissions, awaitAsync } from "../../../../spec"; +import { FakeStorageService } from "../../../../spec/fake-storage.service"; +import { UserId } from "../../../types/guid"; +import { Utils } from "../../misc/utils"; +import { KeyDefinition, userKeyBuilder } from "../key-definition"; +import { StateDefinition } from "../state-definition"; + +import { DefaultSingleUserState } from "./default-single-user-state"; + +class TestState { + date: Date; + + static fromJSON(jsonState: Jsonify) { + if (jsonState == null) { + return null; + } + + return Object.assign(new TestState(), jsonState, { + date: new Date(jsonState.date), + }); + } +} + +const testStateDefinition = new StateDefinition("fake", "disk"); + +const testKeyDefinition = new KeyDefinition(testStateDefinition, "fake", { + deserializer: TestState.fromJSON, +}); +const userId = Utils.newGuid() as UserId; +const userKey = userKeyBuilder(userId, testKeyDefinition); + +describe("DefaultSingleUserState", () => { + let diskStorageService: FakeStorageService; + let globalState: DefaultSingleUserState; + const newData = { date: new Date() }; + + beforeEach(() => { + diskStorageService = new FakeStorageService(); + globalState = new DefaultSingleUserState( + userId, + testKeyDefinition, + null, // Not testing anything with encrypt service + diskStorageService, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("state$", () => { + it("should emit when storage updates", async () => { + const emissions = trackEmissions(globalState.state$); + await diskStorageService.save(userKey, newData); + await awaitAsync(); + + expect(emissions).toEqual([ + null, // Initial value + newData, + ]); + }); + + it("should not emit when update key does not match", async () => { + const emissions = trackEmissions(globalState.state$); + await diskStorageService.save("wrong_key", newData); + + expect(emissions).toHaveLength(0); + }); + + it("should emit initial storage value on first subscribe", async () => { + const initialStorage: Record = {}; + initialStorage[userKey] = TestState.fromJSON({ + date: "2022-09-21T13:14:17.648Z", + }); + diskStorageService.internalUpdateStore(initialStorage); + + const state = await firstValueFrom(globalState.state$); + expect(diskStorageService.mock.get).toHaveBeenCalledTimes(1); + expect(diskStorageService.mock.get).toHaveBeenCalledWith( + `user_${userId}_fake_fake`, + undefined, + ); + expect(state).toBeTruthy(); + }); + }); + + describe("update", () => { + it("should save on update", async () => { + const result = await globalState.update((state) => { + return newData; + }); + + expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1); + expect(result).toEqual(newData); + }); + + it("should emit once per update", async () => { + const emissions = trackEmissions(globalState.state$); + await awaitAsync(); // storage updates are behind a promise + + await globalState.update((state) => { + return newData; + }); + + await awaitAsync(); + + expect(emissions).toEqual([ + null, // Initial value + newData, + ]); + }); + + it("should provided combined dependencies", async () => { + const emissions = trackEmissions(globalState.state$); + await awaitAsync(); // storage updates are behind a promise + + const combinedDependencies = { date: new Date() }; + + await globalState.update( + (state, dependencies) => { + expect(dependencies).toEqual(combinedDependencies); + return newData; + }, + { + combineLatestWith: of(combinedDependencies), + }, + ); + + await awaitAsync(); + + expect(emissions).toEqual([ + null, // Initial value + newData, + ]); + }); + + it("should not update if shouldUpdate returns false", async () => { + const emissions = trackEmissions(globalState.state$); + + const result = await globalState.update( + (state) => { + return newData; + }, + { + shouldUpdate: () => false, + }, + ); + + expect(diskStorageService.mock.save).not.toHaveBeenCalled(); + expect(emissions).toEqual([null]); // Initial value + expect(result).toBeUndefined(); + }); + + it("should provide the update callback with the current State", async () => { + const emissions = trackEmissions(globalState.state$); + await awaitAsync(); // storage updates are behind a promise + + // Seed with interesting data + const initialData = { date: new Date(2020, 1, 1) }; + await globalState.update((state, dependencies) => { + return initialData; + }); + + await awaitAsync(); + + await globalState.update((state) => { + expect(state).toEqual(initialData); + return newData; + }); + + await awaitAsync(); + + expect(emissions).toEqual([ + null, // Initial value + initialData, + newData, + ]); + }); + + it("should give initial state for update call", async () => { + const initialStorage: Record = {}; + const initialState = TestState.fromJSON({ + date: "2022-09-21T13:14:17.648Z", + }); + initialStorage[userKey] = initialState; + diskStorageService.internalUpdateStore(initialStorage); + + const emissions = trackEmissions(globalState.state$); + await awaitAsync(); // storage updates are behind a promise + + const newState = { + ...initialState, + date: new Date(initialState.date.getFullYear(), initialState.date.getMonth() + 1), + }; + const actual = await globalState.update((existingState) => newState); + + await awaitAsync(); + + expect(actual).toEqual(newState); + expect(emissions).toHaveLength(2); + expect(emissions).toEqual(expect.arrayContaining([initialState, newState])); + }); + }); +}); diff --git a/libs/common/src/platform/state/implementations/default-single-user-state.ts b/libs/common/src/platform/state/implementations/default-single-user-state.ts new file mode 100644 index 0000000000..aa9abe6b6c --- /dev/null +++ b/libs/common/src/platform/state/implementations/default-single-user-state.ts @@ -0,0 +1,118 @@ +import { + BehaviorSubject, + Observable, + defer, + filter, + firstValueFrom, + shareReplay, + switchMap, + tap, + timeout, +} from "rxjs"; + +import { UserId } from "../../../types/guid"; +import { EncryptService } from "../../abstractions/encrypt.service"; +import { + AbstractStorageService, + ObservableStorageService, +} from "../../abstractions/storage.service"; +import { DerivedUserState } from "../derived-user-state"; +import { KeyDefinition, userKeyBuilder } from "../key-definition"; +import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; +import { Converter, SingleUserState } from "../user-state"; + +import { DefaultDerivedUserState } from "./default-derived-state"; +import { getStoredValue } from "./util"; +const FAKE_DEFAULT = Symbol("fakeDefault"); + +export class DefaultSingleUserState implements SingleUserState { + private storageKey: string; + + protected stateSubject: BehaviorSubject = new BehaviorSubject< + T | typeof FAKE_DEFAULT + >(FAKE_DEFAULT); + + state$: Observable; + + constructor( + readonly userId: UserId, + private keyDefinition: KeyDefinition, + private encryptService: EncryptService, + private chosenLocation: AbstractStorageService & ObservableStorageService, + ) { + this.storageKey = userKeyBuilder(this.userId, this.keyDefinition); + + const storageUpdates$ = this.chosenLocation.updates$.pipe( + filter((update) => update.key === this.storageKey), + switchMap(async (update) => { + if (update.updateType === "remove") { + return null; + } + return await getStoredValue( + this.storageKey, + this.chosenLocation, + this.keyDefinition.deserializer, + ); + }), + shareReplay({ bufferSize: 1, refCount: false }), + ); + + this.state$ = defer(() => { + const storageUpdateSubscription = storageUpdates$.subscribe((value) => { + this.stateSubject.next(value); + }); + + this.getFromState().then((s) => { + this.stateSubject.next(s); + }); + + return this.stateSubject.pipe( + tap({ + complete: () => { + storageUpdateSubscription.unsubscribe(); + }, + }), + ); + }).pipe( + shareReplay({ refCount: false, bufferSize: 1 }), + filter((i) => i != FAKE_DEFAULT), + ); + } + + async update( + configureState: (state: T, dependency: TCombine) => T, + options: StateUpdateOptions = {}, + ): Promise { + options = populateOptionsWithDefault(options); + const currentState = await this.getGuaranteedState(); + const combinedDependencies = + options.combineLatestWith != null + ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) + : null; + + if (!options.shouldUpdate(currentState, combinedDependencies)) { + return; + } + + const newState = configureState(currentState, combinedDependencies); + await this.chosenLocation.save(this.storageKey, newState); + return newState; + } + + createDerived(converter: Converter): DerivedUserState { + return new DefaultDerivedUserState(converter, this.encryptService, this); + } + + private async getGuaranteedState() { + const currentValue = this.stateSubject.getValue(); + return currentValue === FAKE_DEFAULT ? await this.getFromState() : currentValue; + } + + async getFromState(): Promise { + return await getStoredValue( + this.storageKey, + this.chosenLocation, + this.keyDefinition.deserializer, + ); + } +} diff --git a/libs/common/src/platform/state/implementations/default-state.provider.spec.ts b/libs/common/src/platform/state/implementations/default-state.provider.spec.ts new file mode 100644 index 0000000000..16af060dc1 --- /dev/null +++ b/libs/common/src/platform/state/implementations/default-state.provider.spec.ts @@ -0,0 +1,56 @@ +import { + FakeActiveUserStateProvider, + FakeGlobalStateProvider, + FakeSingleUserStateProvider, +} from "../../../../spec/fake-state-provider"; +import { UserId } from "../../../types/guid"; +import { KeyDefinition } from "../key-definition"; +import { StateDefinition } from "../state-definition"; + +import { DefaultStateProvider } from "./default-state.provider"; + +describe("DefaultStateProvider", () => { + let sut: DefaultStateProvider; + let activeUserStateProvider: FakeActiveUserStateProvider; + let singleUserStateProvider: FakeSingleUserStateProvider; + let globalStateProvider: FakeGlobalStateProvider; + + beforeEach(() => { + activeUserStateProvider = new FakeActiveUserStateProvider(); + singleUserStateProvider = new FakeSingleUserStateProvider(); + globalStateProvider = new FakeGlobalStateProvider(); + sut = new DefaultStateProvider( + activeUserStateProvider, + singleUserStateProvider, + globalStateProvider, + ); + }); + + it("should bind the activeUserStateProvider", () => { + const keyDefinition = new KeyDefinition(new StateDefinition("test", "disk"), "test", { + deserializer: () => null, + }); + const existing = activeUserStateProvider.get(keyDefinition); + const actual = sut.getActive(keyDefinition); + expect(actual).toBe(existing); + }); + + it("should bind the singleUserStateProvider", () => { + const userId = "user" as UserId; + const keyDefinition = new KeyDefinition(new StateDefinition("test", "disk"), "test", { + deserializer: () => null, + }); + const existing = singleUserStateProvider.get(userId, keyDefinition); + const actual = sut.getUser(userId, keyDefinition); + expect(actual).toBe(existing); + }); + + it("should bind the globalStateProvider", () => { + const keyDefinition = new KeyDefinition(new StateDefinition("test", "disk"), "test", { + deserializer: () => null, + }); + const existing = globalStateProvider.get(keyDefinition); + const actual = sut.getGlobal(keyDefinition); + expect(actual).toBe(existing); + }); +}); diff --git a/libs/common/src/platform/state/implementations/default-state.provider.ts b/libs/common/src/platform/state/implementations/default-state.provider.ts new file mode 100644 index 0000000000..5641f80291 --- /dev/null +++ b/libs/common/src/platform/state/implementations/default-state.provider.ts @@ -0,0 +1,19 @@ +import { GlobalStateProvider } from "../global-state.provider"; +import { StateProvider } from "../state.provider"; +import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider"; + +export class DefaultStateProvider implements StateProvider { + constructor( + private readonly activeUserStateProvider: ActiveUserStateProvider, + private readonly singleUserStateProvider: SingleUserStateProvider, + private readonly globalStateProvider: GlobalStateProvider, + ) {} + + getActive: InstanceType["get"] = + this.activeUserStateProvider.get.bind(this.activeUserStateProvider); + getUser: InstanceType["get"] = + this.singleUserStateProvider.get.bind(this.singleUserStateProvider); + getGlobal: InstanceType["get"] = this.globalStateProvider.get.bind( + this.globalStateProvider, + ); +} diff --git a/libs/common/src/platform/state/index.ts b/libs/common/src/platform/state/index.ts index 178c21e0b6..4c7347cb1c 100644 --- a/libs/common/src/platform/state/index.ts +++ b/libs/common/src/platform/state/index.ts @@ -1,8 +1,9 @@ export { DerivedUserState } from "./derived-user-state"; export { GlobalState } from "./global-state"; +export { StateProvider } from "./state.provider"; export { GlobalStateProvider } from "./global-state.provider"; -export { UserState } from "./user-state"; -export { UserStateProvider } from "./user-state.provider"; +export { ActiveUserState, SingleUserState } from "./user-state"; +export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider"; export { KeyDefinition } from "./key-definition"; export * from "./state-definitions"; diff --git a/libs/common/src/platform/state/key-definition.ts b/libs/common/src/platform/state/key-definition.ts index 81bc1142cd..db65740388 100644 --- a/libs/common/src/platform/state/key-definition.ts +++ b/libs/common/src/platform/state/key-definition.ts @@ -129,8 +129,13 @@ 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(): string { - return `${this.stateDefinition.storageLocation}_${this.stateDefinition.name}_${this.key}`; + buildCacheKey(scope: "user" | "global", userId?: "active" | UserId): string { + if (scope === "user" && userId == null) { + throw new Error("You must provide a userId when building a user scoped cache key."); + } + return userId === null + ? `${scope}_${userId}_${this.stateDefinition.name}_${this.key}` + : `${scope}_${this.stateDefinition.name}_${this.key}`; } } diff --git a/libs/common/src/platform/state/state.provider.ts b/libs/common/src/platform/state/state.provider.ts new file mode 100644 index 0000000000..06bc3a9d90 --- /dev/null +++ b/libs/common/src/platform/state/state.provider.ts @@ -0,0 +1,21 @@ +import { UserId } from "../../types/guid"; + +import { GlobalState } from "./global-state"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs +import { GlobalStateProvider } from "./global-state.provider"; +import { KeyDefinition } from "./key-definition"; +import { ActiveUserState, SingleUserState } from "./user-state"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs +import { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider"; + +/** Convenience wrapper class for {@link ActiveUserStateProvider}, {@link SingleUserStateProvider}, + * and {@link GlobalStateProvider}. + */ +export abstract class StateProvider { + /** @see{@link ActiveUserStateProvider.get} */ + getActive: (keyDefinition: KeyDefinition) => ActiveUserState; + /** @see{@link SingleUserStateProvider.get} */ + getUser: (userId: UserId, keyDefinition: KeyDefinition) => SingleUserState; + /** @see{@link GlobalStateProvider.get} */ + getGlobal: (keyDefinition: KeyDefinition) => GlobalState; +} diff --git a/libs/common/src/platform/state/user-state.provider.ts b/libs/common/src/platform/state/user-state.provider.ts index 2e20bc4e51..78e44c2916 100644 --- a/libs/common/src/platform/state/user-state.provider.ts +++ b/libs/common/src/platform/state/user-state.provider.ts @@ -1,13 +1,28 @@ -import { KeyDefinition } from "./key-definition"; -import { UserState } from "./user-state"; +import { UserId } from "../../types/guid"; -/** - * A provider for getting an implementation of user scoped state for the given key. - */ -export abstract class UserStateProvider { +import { KeyDefinition } from "./key-definition"; +import { ActiveUserState, SingleUserState } from "./user-state"; + +/** A provider for getting an implementation of state scoped to a given key and userId */ +export abstract class SingleUserStateProvider { /** - * Gets a {@link GlobalState} scoped to the given {@link KeyDefinition} + * Gets a {@link SingleUserState} scoped to the given {@link KeyDefinition} and {@link UserId} + * + * @param userId - The {@link UserId} for which you want the user state for. * @param keyDefinition - The {@link KeyDefinition} for which you want the user state for. */ - get: (keyDefinition: KeyDefinition) => UserState; + get: (userId: UserId, keyDefinition: KeyDefinition) => SingleUserState; +} + +/** A provider for getting an implementation of state scoped to a given key, but always pointing + * to the currently active user + */ +export abstract class ActiveUserStateProvider { + /** + * Gets a {@link ActiveUserState} scoped to the given {@link KeyDefinition}, but updates when active user changes such + * that the emitted values always represents the state for the currently active user. + * + * @param keyDefinition - The {@link KeyDefinition} for which you want the user state for. + */ + get: (keyDefinition: KeyDefinition) => ActiveUserState; } diff --git a/libs/common/src/platform/state/user-state.ts b/libs/common/src/platform/state/user-state.ts index 3adc6c9627..d6e5ab3109 100644 --- a/libs/common/src/platform/state/user-state.ts +++ b/libs/common/src/platform/state/user-state.ts @@ -37,22 +37,6 @@ export interface UserState { configureState: (state: T, dependencies: TCombine) => T, options?: StateUpdateOptions, ) => Promise; - /** - * Updates backing stores for the given userId, which may or may not be active. - * @param userId the UserId to target the update for - * @param configureState function that takes the current state for the targeted user and returns the new state - * @param options Defaults given by @see {module:state-update-options#DEFAULT_OPTIONS} - * @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true - * @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null - * @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set. - - * @returns The new state - */ - readonly updateFor: ( - userId: UserId, - configureState: (state: T, dependencies: TCombine) => T, - options?: StateUpdateOptions, - ) => Promise; /** * Creates a derives state from the current state. Derived states are always tied to the active user. @@ -61,3 +45,11 @@ export interface UserState { */ createDerived: (converter: Converter) => DerivedUserState; } + +export const activeMarker: unique symbol = Symbol("active"); +export interface ActiveUserState extends UserState { + readonly [activeMarker]: true; +} +export interface SingleUserState extends UserState { + readonly userId: UserId; +}