From 29aabeb4f57e8531a888b65dd4a62d48e02ed96c Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Thu, 16 Nov 2023 14:15:34 -0500 Subject: [PATCH] Ps/pm 2910/state framework improvements (#6860) * Allow for update logic in state update callbacks * Prefer reading updates to sending in stream * Inform state providers when they must deserialize * Update DefaultGlobalState to act more like DefaultUserState * Fully Implement AbstractStorageService * Add KeyDefinitionOptions * Address PR feedback * More Descriptive Error --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> --- .../session-syncer.spec.ts | 2 +- .../abstract-chrome-storage-api.service.ts | 4 +- .../local-backed-session-storage.service.ts | 4 + .../services/lowdb-storage.service.ts | 7 +- .../node-env-secure-storage.service.ts | 4 + ...lectron-renderer-secure-storage.service.ts | 3 + .../electron-renderer-storage.service.ts | 7 +- .../services/electron-storage.service.ts | 7 +- apps/web/src/app/core/html-storage.service.ts | 7 +- libs/angular/test-utils.ts | 3 - libs/common/spec/fake-storage.service.ts | 13 +- libs/common/spec/utils.ts | 8 + .../platform/abstractions/storage.service.ts | 2 +- .../services/memory-storage.service.ts | 7 +- .../common/src/platform/state/global-state.ts | 11 +- .../default-global-state.spec.ts | 193 ++++++++++++++---- .../implementations/default-global-state.ts | 83 ++++++-- .../default-user-state.spec.ts | 182 +++++++++++++---- .../implementations/default-user-state.ts | 80 ++++++-- .../state/implementations/util.spec.ts | 50 +++++ .../platform/state/implementations/util.ts | 18 ++ .../src/platform/state/key-definition.spec.ts | 81 ++++++++ .../src/platform/state/key-definition.ts | 107 +++++++--- .../platform/state/state-update-options.ts | 26 +++ libs/common/src/platform/state/user-state.ts | 23 ++- 25 files changed, 761 insertions(+), 171 deletions(-) delete mode 100644 libs/angular/test-utils.ts create mode 100644 libs/common/src/platform/state/implementations/util.spec.ts create mode 100644 libs/common/src/platform/state/implementations/util.ts create mode 100644 libs/common/src/platform/state/key-definition.spec.ts create mode 100644 libs/common/src/platform/state/state-update-options.ts diff --git a/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.spec.ts b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.spec.ts index afa66db045..96c5a4eea5 100644 --- a/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.spec.ts +++ b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.spec.ts @@ -1,4 +1,4 @@ -import { awaitAsync } from "@bitwarden/angular/../test-utils"; +import { awaitAsync } from "@bitwarden/common/../spec/utils"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, ReplaySubject } from "rxjs"; diff --git a/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts b/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts index 7d6cf1390f..77267c3e87 100644 --- a/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts +++ b/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts @@ -11,6 +11,9 @@ import { fromChromeEvent } from "../../browser/from-chrome-event"; export default abstract class AbstractChromeStorageService implements AbstractStorageService { constructor(protected chromeStorageApi: chrome.storage.StorageArea) {} + get valuesRequireDeserialization(): boolean { + return true; + } get updates$(): Observable { return fromChromeEvent(this.chromeStorageApi.onChanged).pipe( mergeMap(([changes]) => { @@ -27,7 +30,6 @@ export default abstract class AbstractChromeStorageService implements AbstractSt key: key, // For removes this property will not exist but then it will just be // undefined which is fine. - value: change.newValue, updateType: updateType, }; }); diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.ts index 188a9854c5..6366c51de3 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.ts @@ -35,6 +35,10 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi super(); } + get valuesRequireDeserialization(): boolean { + return true; + } + get updates$() { return this.updatesSubject.asObservable(); } diff --git a/apps/cli/src/platform/services/lowdb-storage.service.ts b/apps/cli/src/platform/services/lowdb-storage.service.ts index e80e94c043..d8f8f29412 100644 --- a/apps/cli/src/platform/services/lowdb-storage.service.ts +++ b/apps/cli/src/platform/services/lowdb-storage.service.ts @@ -107,6 +107,9 @@ export class LowdbStorageService implements AbstractStorageService { this.ready = true; } + get valuesRequireDeserialization(): boolean { + return true; + } get updates$() { return this.updatesSubject.asObservable(); } @@ -133,7 +136,7 @@ export class LowdbStorageService implements AbstractStorageService { return this.lockDbFile(() => { this.readForNoCache(); this.db.set(key, obj).write(); - this.updatesSubject.next({ key, value: obj, updateType: "save" }); + this.updatesSubject.next({ key, updateType: "save" }); this.logService.debug(`Successfully wrote ${key} to db`); return; }); @@ -144,7 +147,7 @@ export class LowdbStorageService implements AbstractStorageService { return this.lockDbFile(() => { this.readForNoCache(); this.db.unset(key).write(); - this.updatesSubject.next({ key, value: null, updateType: "remove" }); + this.updatesSubject.next({ key, updateType: "remove" }); this.logService.debug(`Successfully removed ${key} from db`); return; }); diff --git a/apps/cli/src/platform/services/node-env-secure-storage.service.ts b/apps/cli/src/platform/services/node-env-secure-storage.service.ts index 364491469e..14ea6a6bb1 100644 --- a/apps/cli/src/platform/services/node-env-secure-storage.service.ts +++ b/apps/cli/src/platform/services/node-env-secure-storage.service.ts @@ -14,6 +14,10 @@ export class NodeEnvSecureStorageService implements AbstractStorageService { private cryptoService: () => CryptoService ) {} + get valuesRequireDeserialization(): boolean { + return true; + } + get updates$() { return throwError( () => new Error("Secure storage implementations cannot have their updates subscribed to.") diff --git a/apps/desktop/src/platform/services/electron-renderer-secure-storage.service.ts b/apps/desktop/src/platform/services/electron-renderer-secure-storage.service.ts index 8d6b51cf7d..42513510ff 100644 --- a/apps/desktop/src/platform/services/electron-renderer-secure-storage.service.ts +++ b/apps/desktop/src/platform/services/electron-renderer-secure-storage.service.ts @@ -4,6 +4,9 @@ import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/ import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; export class ElectronRendererSecureStorageService implements AbstractStorageService { + get valuesRequireDeserialization(): boolean { + return true; + } get updates$() { return throwError( () => new Error("Secure storage implementations cannot have their updates subscribed to.") diff --git a/apps/desktop/src/platform/services/electron-renderer-storage.service.ts b/apps/desktop/src/platform/services/electron-renderer-storage.service.ts index 8bb9ff7217..e81a0ca908 100644 --- a/apps/desktop/src/platform/services/electron-renderer-storage.service.ts +++ b/apps/desktop/src/platform/services/electron-renderer-storage.service.ts @@ -8,6 +8,9 @@ import { export class ElectronRendererStorageService implements AbstractStorageService { private updatesSubject = new Subject(); + get valuesRequireDeserialization(): boolean { + return true; + } get updates$() { return this.updatesSubject.asObservable(); } @@ -22,11 +25,11 @@ export class ElectronRendererStorageService implements AbstractStorageService { async save(key: string, obj: T): Promise { await ipc.platform.storage.save(key, obj); - this.updatesSubject.next({ key, value: obj, updateType: "save" }); + this.updatesSubject.next({ key, updateType: "save" }); } async remove(key: string): Promise { await ipc.platform.storage.remove(key); - this.updatesSubject.next({ key, value: null, updateType: "remove" }); + this.updatesSubject.next({ key, updateType: "remove" }); } } diff --git a/apps/desktop/src/platform/services/electron-storage.service.ts b/apps/desktop/src/platform/services/electron-storage.service.ts index 40be8260dc..065e3f5de0 100644 --- a/apps/desktop/src/platform/services/electron-storage.service.ts +++ b/apps/desktop/src/platform/services/electron-storage.service.ts @@ -65,6 +65,9 @@ export class ElectronStorageService implements AbstractStorageService { }); } + get valuesRequireDeserialization(): boolean { + return true; + } get updates$() { return this.updatesSubject.asObservable(); } @@ -84,13 +87,13 @@ export class ElectronStorageService implements AbstractStorageService { obj = Array.from(obj); } this.store.set(key, obj); - this.updatesSubject.next({ key, value: obj, updateType: "save" }); + this.updatesSubject.next({ key, updateType: "save" }); return Promise.resolve(); } remove(key: string): Promise { this.store.delete(key); - this.updatesSubject.next({ key, value: null, updateType: "remove" }); + this.updatesSubject.next({ key, updateType: "remove" }); return Promise.resolve(); } } diff --git a/apps/web/src/app/core/html-storage.service.ts b/apps/web/src/app/core/html-storage.service.ts index ef839fb4d9..ebcd9241c9 100644 --- a/apps/web/src/app/core/html-storage.service.ts +++ b/apps/web/src/app/core/html-storage.service.ts @@ -16,6 +16,9 @@ export class HtmlStorageService implements AbstractStorageService { return { htmlStorageLocation: HtmlStorageLocation.Session }; } + get valuesRequireDeserialization(): boolean { + return true; + } get updates$() { return this.updatesSubject.asObservable(); } @@ -62,7 +65,7 @@ export class HtmlStorageService implements AbstractStorageService { window.sessionStorage.setItem(key, json); break; } - this.updatesSubject.next({ key, value: obj, updateType: "save" }); + this.updatesSubject.next({ key, updateType: "save" }); return Promise.resolve(); } @@ -76,7 +79,7 @@ export class HtmlStorageService implements AbstractStorageService { window.sessionStorage.removeItem(key); break; } - this.updatesSubject.next({ key, value: null, updateType: "remove" }); + this.updatesSubject.next({ key, updateType: "remove" }); return Promise.resolve(); } } diff --git a/libs/angular/test-utils.ts b/libs/angular/test-utils.ts deleted file mode 100644 index a2422e698f..0000000000 --- a/libs/angular/test-utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function awaitAsync(ms = 0) { - await new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/libs/common/spec/fake-storage.service.ts b/libs/common/spec/fake-storage.service.ts index 281df01533..4198fdd293 100644 --- a/libs/common/spec/fake-storage.service.ts +++ b/libs/common/spec/fake-storage.service.ts @@ -10,6 +10,7 @@ import { StorageOptions } from "../src/platform/models/domain/storage-options"; export class FakeStorageService implements AbstractStorageService { private store: Record; private updatesSubject = new Subject(); + private _valuesRequireDeserialization = false; /** * Returns a mock of a {@see AbstractStorageService} for asserting the expected @@ -32,6 +33,14 @@ export class FakeStorageService implements AbstractStorageService { this.store = store; } + internalUpdateValuesRequireDeserialization(value: boolean) { + this._valuesRequireDeserialization = value; + } + + get valuesRequireDeserialization(): boolean { + return this._valuesRequireDeserialization; + } + get updates$() { return this.updatesSubject.asObservable(); } @@ -48,13 +57,13 @@ export class FakeStorageService implements AbstractStorageService { save(key: string, obj: T, options?: StorageOptions): Promise { this.mock.save(key, options); this.store[key] = obj; - this.updatesSubject.next({ key: key, value: obj, updateType: "save" }); + this.updatesSubject.next({ key: key, updateType: "save" }); return Promise.resolve(); } remove(key: string, options?: StorageOptions): Promise { this.mock.remove(key, options); delete this.store[key]; - this.updatesSubject.next({ key: key, value: undefined, updateType: "remove" }); + this.updatesSubject.next({ key: key, updateType: "remove" }); return Promise.resolve(); } } diff --git a/libs/common/spec/utils.ts b/libs/common/spec/utils.ts index 91d2033da8..7db5711adf 100644 --- a/libs/common/spec/utils.ts +++ b/libs/common/spec/utils.ts @@ -84,3 +84,11 @@ function clone(value: any): any { return JSON.parse(JSON.stringify(value)); } } + +export async function awaitAsync(ms = 0) { + if (ms < 1) { + await Promise.resolve(); + } else { + await new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/libs/common/src/platform/abstractions/storage.service.ts b/libs/common/src/platform/abstractions/storage.service.ts index 8beac2c1c1..c0e3478f54 100644 --- a/libs/common/src/platform/abstractions/storage.service.ts +++ b/libs/common/src/platform/abstractions/storage.service.ts @@ -5,11 +5,11 @@ import { MemoryStorageOptions, StorageOptions } from "../models/domain/storage-o export type StorageUpdateType = "save" | "remove"; export type StorageUpdate = { key: string; - value?: unknown; updateType: StorageUpdateType; }; export abstract class AbstractStorageService { + abstract get valuesRequireDeserialization(): boolean; /** * Provides an {@link Observable} that represents a stream of updates that * have happened in this storage service or in the storage this service provides diff --git a/libs/common/src/platform/services/memory-storage.service.ts b/libs/common/src/platform/services/memory-storage.service.ts index abfc0b1597..f3ad25d3a0 100644 --- a/libs/common/src/platform/services/memory-storage.service.ts +++ b/libs/common/src/platform/services/memory-storage.service.ts @@ -6,6 +6,9 @@ export class MemoryStorageService extends AbstractMemoryStorageService { private store = new Map(); private updatesSubject = new Subject(); + get valuesRequireDeserialization(): boolean { + return false; + } get updates$() { return this.updatesSubject.asObservable(); } @@ -27,13 +30,13 @@ export class MemoryStorageService extends AbstractMemoryStorageService { return this.remove(key); } this.store.set(key, obj); - this.updatesSubject.next({ key, value: obj, updateType: "save" }); + this.updatesSubject.next({ key, updateType: "save" }); return Promise.resolve(); } remove(key: string): Promise { this.store.delete(key); - this.updatesSubject.next({ key, value: null, updateType: "remove" }); + this.updatesSubject.next({ key, updateType: "remove" }); return Promise.resolve(); } diff --git a/libs/common/src/platform/state/global-state.ts b/libs/common/src/platform/state/global-state.ts index 3d330668de..c14338a2fb 100644 --- a/libs/common/src/platform/state/global-state.ts +++ b/libs/common/src/platform/state/global-state.ts @@ -1,5 +1,7 @@ import { Observable } from "rxjs"; +import { StateUpdateOptions } from "./state-update-options"; + /** * A helper object for interacting with state that is scoped to a specific domain * but is not scoped to a user. This is application wide storage. @@ -8,9 +10,16 @@ export interface GlobalState { /** * Method for allowing you to manipulate state in an additive way. * @param configureState callback for how you want manipulate this section of 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 A promise that must be awaited before your next action to ensure the update has been written to state. */ - update: (configureState: (state: T) => T) => Promise; + update: ( + configureState: (state: T, dependency: TCombine) => T, + options?: StateUpdateOptions + ) => Promise; /** * An observable stream of this state, the first emission of this will be the current state on disk diff --git a/libs/common/src/platform/state/implementations/default-global-state.spec.ts b/libs/common/src/platform/state/implementations/default-global-state.spec.ts index 86dc7e1670..656b8031c6 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.spec.ts +++ b/libs/common/src/platform/state/implementations/default-global-state.spec.ts @@ -3,9 +3,10 @@ * @jest-environment ../shared/test.environment.ts */ +import { firstValueFrom, of } from "rxjs"; import { Jsonify } from "type-fest"; -import { trackEmissions } from "../../../../spec"; +import { trackEmissions, awaitAsync } from "../../../../spec"; import { FakeStorageService } from "../../../../spec/fake-storage.service"; import { KeyDefinition, globalKeyBuilder } from "../key-definition"; import { StateDefinition } from "../state-definition"; @@ -28,16 +29,15 @@ class TestState { const testStateDefinition = new StateDefinition("fake", "disk"); -const testKeyDefinition = new KeyDefinition( - testStateDefinition, - "fake", - TestState.fromJSON -); +const testKeyDefinition = new KeyDefinition(testStateDefinition, "fake", { + deserializer: TestState.fromJSON, +}); const globalKey = globalKeyBuilder(testKeyDefinition); describe("DefaultGlobalState", () => { let diskStorageService: FakeStorageService; let globalState: DefaultGlobalState; + const newData = { date: new Date() }; beforeEach(() => { diskStorageService = new FakeStorageService(); @@ -48,51 +48,154 @@ describe("DefaultGlobalState", () => { jest.resetAllMocks(); }); - it("should emit when storage updates", async () => { - const emissions = trackEmissions(globalState.state$); - const newData = { date: new Date() }; - await diskStorageService.save(globalKey, newData); + describe("state$", () => { + it("should emit when storage updates", async () => { + const emissions = trackEmissions(globalState.state$); + await diskStorageService.save(globalKey, newData); + await awaitAsync(); - expect(emissions).toEqual([ - null, // Initial value - newData, - // JSON.parse(JSON.stringify(newData)), // This is due to the way `trackEmissions` clones - ]); - }); - - it("should not emit when update key does not match", async () => { - const emissions = trackEmissions(globalState.state$); - const newData = { date: new Date() }; - await diskStorageService.save("wrong_key", newData); - - expect(emissions).toEqual( - expect.arrayContaining([ + expect(emissions).toEqual([ null, // Initial value - ]) - ); - }); - - it("should save on update", async () => { - const newData = { date: new Date() }; - const result = await globalState.update((state) => { - return newData; + newData, + ]); }); - expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1); - expect(result).toEqual(newData); - }); + it("should not emit when update key does not match", async () => { + const emissions = trackEmissions(globalState.state$); + await diskStorageService.save("wrong_key", newData); - it("should emit once per update", async () => { - const emissions = trackEmissions(globalState.state$); - const newData = { date: new Date() }; - - await globalState.update((state) => { - return newData; + expect(emissions).toHaveLength(0); }); - expect(emissions).toEqual([ - null, // Initial value - newData, - ]); + it("should emit initial storage value on first subscribe", async () => { + const initialStorage: Record = {}; + initialStorage[globalKey] = 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("global_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[globalKey] = 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-global-state.ts b/libs/common/src/platform/state/implementations/default-global-state.ts index a7f6576426..d6713e14bf 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.ts +++ b/libs/common/src/platform/state/implementations/default-global-state.ts @@ -1,15 +1,29 @@ -import { BehaviorSubject, Observable, defer, filter, map, shareReplay, tap } from "rxjs"; -import { Jsonify } from "type-fest"; +import { + BehaviorSubject, + Observable, + defer, + filter, + firstValueFrom, + shareReplay, + switchMap, + tap, + timeout, +} from "rxjs"; import { AbstractStorageService } from "../../abstractions/storage.service"; import { GlobalState } from "../global-state"; import { KeyDefinition, globalKeyBuilder } from "../key-definition"; +import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; + +import { getStoredValue } from "./util"; +const FAKE_DEFAULT = Symbol("fakeDefault"); export class DefaultGlobalState implements GlobalState { private storageKey: string; - private seededPromise: Promise; - protected stateSubject: BehaviorSubject = new BehaviorSubject(null); + protected stateSubject: BehaviorSubject = new BehaviorSubject< + T | typeof FAKE_DEFAULT + >(FAKE_DEFAULT); state$: Observable; @@ -19,15 +33,17 @@ export class DefaultGlobalState implements GlobalState { ) { this.storageKey = globalKeyBuilder(this.keyDefinition); - this.seededPromise = this.chosenLocation.get>(this.storageKey).then((data) => { - const serializedData = this.keyDefinition.deserializer(data); - this.stateSubject.next(serializedData); - }); - const storageUpdates$ = this.chosenLocation.updates$.pipe( filter((update) => update.key === this.storageKey), - map((update) => { - return this.keyDefinition.deserializer(update.value as Jsonify); + switchMap(async (update) => { + if (update.updateType === "remove") { + return null; + } + return await getStoredValue( + this.storageKey, + this.chosenLocation, + this.keyDefinition.deserializer + ); }), shareReplay({ bufferSize: 1, refCount: false }) ); @@ -37,24 +53,53 @@ export class DefaultGlobalState implements GlobalState { this.stateSubject.next(value); }); + this.getFromState().then((s) => { + this.stateSubject.next(s); + }); + return this.stateSubject.pipe( tap({ - complete: () => storageUpdateSubscription.unsubscribe(), + complete: () => { + storageUpdateSubscription.unsubscribe(); + }, }) ); - }); + }).pipe( + shareReplay({ refCount: false, bufferSize: 1 }), + filter((i) => i != FAKE_DEFAULT) + ); } - async update(configureState: (state: T) => T): Promise { - await this.seededPromise; - const currentState = this.stateSubject.getValue(); - const newState = configureState(currentState); + 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; } + private async getGuaranteedState() { + const currentValue = this.stateSubject.getValue(); + return currentValue === FAKE_DEFAULT ? await this.getFromState() : currentValue; + } + async getFromState(): Promise { - const data = await this.chosenLocation.get>(this.storageKey); - return this.keyDefinition.deserializer(data); + return await getStoredValue( + this.storageKey, + this.chosenLocation, + this.keyDefinition.deserializer + ); } } diff --git a/libs/common/src/platform/state/implementations/default-user-state.spec.ts b/libs/common/src/platform/state/implementations/default-user-state.spec.ts index e1ab3c1a62..f5cc7e9693 100644 --- a/libs/common/src/platform/state/implementations/default-user-state.spec.ts +++ b/libs/common/src/platform/state/implementations/default-user-state.spec.ts @@ -1,8 +1,12 @@ +/** + * need to update test environment so trackEmissions works appropriately + * @jest-environment ../shared/test.environment.ts + */ import { any, mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom, timeout } from "rxjs"; +import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs"; import { Jsonify } from "type-fest"; -import { trackEmissions } from "../../../../spec"; +import { awaitAsync, trackEmissions } from "../../../../spec"; import { FakeStorageService } from "../../../../spec/fake-storage.service"; import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; @@ -29,11 +33,9 @@ class TestState { const testStateDefinition = new StateDefinition("fake", "disk"); -const testKeyDefinition = new KeyDefinition( - testStateDefinition, - "fake", - TestState.fromJSON -); +const testKeyDefinition = new KeyDefinition(testStateDefinition, "fake", { + deserializer: TestState.fromJSON, +}); describe("DefaultUserState", () => { const accountService = mock(); @@ -62,7 +64,7 @@ describe("DefaultUserState", () => { name: `Test User ${id}`, status: AuthenticationStatus.Unlocked, }); - await new Promise((resolve) => setTimeout(resolve, 1)); + await awaitAsync(); }; afterEach(() => { @@ -70,51 +72,42 @@ describe("DefaultUserState", () => { }); it("emits updates for each user switch and update", async () => { - diskStorageService.internalUpdateStore({ - "user_00000000-0000-1000-a000-000000000001_fake_fake": { - date: "2022-09-21T13:14:17.648Z", - array: ["value1", "value2"], - } as Jsonify, - "user_00000000-0000-1000-a000-000000000002_fake_fake": { - date: "2021-09-21T13:14:17.648Z", - array: ["user2_value"], - }, - }); + const user1 = "user_00000000-0000-1000-a000-000000000001_fake_fake"; + const user2 = "user_00000000-0000-1000-a000-000000000002_fake_fake"; + const state1 = { + date: new Date(2021, 0), + array: ["value1"], + }; + const state2 = { + date: new Date(2022, 0), + array: ["value2"], + }; + const initialState: Record = {}; + initialState[user1] = state1; + initialState[user2] = state2; + diskStorageService.internalUpdateStore(initialState); const emissions = trackEmissions(userState.state$); // User signs in changeActiveUser("1"); - await new Promise((resolve) => setTimeout(resolve, 1)); + await awaitAsync(); // Service does an update - await userState.update((state) => { - state.array.push("value3"); - state.date = new Date(2023, 0); - return state; - }); - await new Promise((resolve) => setTimeout(resolve, 1)); + const updatedState = { + date: new Date(2023, 0), + array: ["value3"], + }; + await userState.update(() => updatedState); + await awaitAsync(); // Emulate an account switch await changeActiveUser("2"); - expect(emissions).toHaveLength(3); - // Gotten starter user data - expect(emissions[0]).toBeTruthy(); - expect(emissions[0].array).toHaveLength(2); + expect(emissions).toEqual([state1, updatedState, state2]); - // Gotten emission for the update call - expect(emissions[1]).toBeTruthy(); - expect(emissions[1].array).toHaveLength(3); - expect(new Date(emissions[1].date).getUTCFullYear()).toBe(2023); - - // The second users data - expect(emissions[2]).toBeTruthy(); - expect(emissions[2].array).toHaveLength(1); - expect(new Date(emissions[2].date).getUTCFullYear()).toBe(2021); - - // Should only be called twice to get state, once for each user - expect(diskStorageService.mock.get).toHaveBeenCalledTimes(2); + // Should be called three time to get state, once for each user and once for the update + expect(diskStorageService.mock.get).toHaveBeenCalledTimes(3); expect(diskStorageService.mock.get).toHaveBeenNthCalledWith( 1, "user_00000000-0000-1000-a000-000000000001_fake_fake", @@ -122,6 +115,11 @@ describe("DefaultUserState", () => { ); expect(diskStorageService.mock.get).toHaveBeenNthCalledWith( 2, + "user_00000000-0000-1000-a000-000000000001_fake_fake", + any() + ); + expect(diskStorageService.mock.get).toHaveBeenNthCalledWith( + 3, "user_00000000-0000-1000-a000-000000000002_fake_fake", any() ); @@ -161,9 +159,9 @@ describe("DefaultUserState", () => { diskStorageService.internalUpdateStore({ "user_00000000-0000-1000-a000-000000000001_fake_fake": { - date: "2020-09-21T13:14:17.648Z", + date: new Date(2020, 0), array: ["testValue"], - } as Jsonify, + } as TestState, }); const promise = firstValueFrom(userState.state$.pipe(timeout(20))) @@ -233,4 +231,102 @@ describe("DefaultUserState", () => { // this value is correct. expect(emissions).toHaveLength(2); }); + + describe("update", () => { + const newData = { date: new Date(), array: ["test"] }; + beforeEach(async () => { + changeActiveUser("1"); + }); + + it("should save on update", async () => { + const result = await userState.update((state, dependencies) => { + return newData; + }); + + expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1); + expect(result).toEqual(newData); + }); + + it("should emit once per update", async () => { + const emissions = trackEmissions(userState.state$); + await awaitAsync(); // Need to await for the initial value to be emitted + + await userState.update((state, dependencies) => { + return newData; + }); + await awaitAsync(); + + expect(emissions).toEqual([ + null, // initial value + newData, + ]); + }); + + it("should provide combined dependencies", async () => { + const emissions = trackEmissions(userState.state$); + await awaitAsync(); // Need to await for the initial value to be emitted + + const combinedDependencies = { date: new Date() }; + + await userState.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(userState.state$); + await awaitAsync(); // Need to await for the initial value to be emitted + + const result = await userState.update( + (state, dependencies) => { + return newData; + }, + { + shouldUpdate: () => false, + } + ); + + await awaitAsync(); + + expect(diskStorageService.mock.save).not.toHaveBeenCalled(); + expect(result).toBe(undefined); + expect(emissions).toEqual([null]); + }); + + it("should provide the current state to the update callback", async () => { + const emissions = trackEmissions(userState.state$); + await awaitAsync(); // Need to await for the initial value to be emitted + + // Seed with interesting data + const initialData = { date: new Date(2020, 0), array: ["value1", "value2"] }; + await userState.update((state, dependencies) => { + return initialData; + }); + + await userState.update((state, dependencies) => { + expect(state).toEqual(initialData); + return newData; + }); + + await awaitAsync(); + + expect(emissions).toEqual([ + null, // Initial value + initialData, + newData, + ]); + }); + }); }); diff --git a/libs/common/src/platform/state/implementations/default-user-state.ts b/libs/common/src/platform/state/implementations/default-user-state.ts index 10d1329d70..19a2c420d0 100644 --- a/libs/common/src/platform/state/implementations/default-user-state.ts +++ b/libs/common/src/platform/state/implementations/default-user-state.ts @@ -9,8 +9,8 @@ import { firstValueFrom, combineLatestWith, filter, + timeout, } from "rxjs"; -import { Jsonify } from "type-fest"; import { AccountService } from "../../../auth/abstractions/account.service"; import { UserId } from "../../../types/guid"; @@ -18,9 +18,11 @@ import { EncryptService } from "../../abstractions/encrypt.service"; import { AbstractStorageService } 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, UserState } from "../user-state"; import { DefaultDerivedUserState } from "./default-derived-state"; +import { getStoredValue } from "./util"; const FAKE_DEFAULT = Symbol("fakeDefault"); @@ -54,9 +56,11 @@ export class DefaultUserState implements UserState { if (key == null) { return FAKE_DEFAULT; } - const jsonData = await this.chosenStorageLocation.get>(key); - const data = keyDefinition.deserializer(jsonData); - return data; + return await getStoredValue( + key, + this.chosenStorageLocation, + this.keyDefinition.deserializer + ); }), // Share the execution shareReplay({ refCount: false, bufferSize: 1 }) @@ -65,8 +69,16 @@ export class DefaultUserState implements UserState { const storageUpdates$ = this.chosenStorageLocation.updates$.pipe( combineLatestWith(this.formattedKey$), filter(([update, key]) => key !== null && update.key === key), - map(([update]) => { - return keyDefinition.deserializer(update.value as Jsonify); + switchMap(async ([update, key]) => { + if (update.updateType === "remove") { + return null; + } + const data = await getStoredValue( + key, + this.chosenStorageLocation, + this.keyDefinition.deserializer + ); + return data; }) ); @@ -94,23 +106,53 @@ export class DefaultUserState implements UserState { .pipe(filter((value) => value != FAKE_DEFAULT)); } - async update(configureState: (state: T) => T): Promise { + async update( + configureState: (state: T, dependency: TCombine) => T, + options: StateUpdateOptions = {} + ): Promise { + options = populateOptionsWithDefault(options); const key = await this.createKey(); const currentState = await this.getGuaranteedState(key); - const newState = configureState(currentState); + 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 updateFor(userId: UserId, configureState: (state: T) => T): Promise { + 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 currentStore = await this.chosenStorageLocation.get>(key); - const currentState = this.keyDefinition.deserializer(currentStore); - const newState = configureState(currentState); + 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; @@ -118,8 +160,7 @@ export class DefaultUserState implements UserState { async getFromState(): Promise { const key = await this.createKey(); - const data = await this.chosenStorageLocation.get>(key); - return this.keyDefinition.deserializer(data); + return await getStoredValue(key, this.chosenStorageLocation, this.keyDefinition.deserializer); } createDerived(converter: Converter): DerivedUserState { @@ -140,10 +181,13 @@ export class DefaultUserState implements UserState { } private async seedInitial(key: string): Promise { - const data = await this.chosenStorageLocation.get>(key); - const serializedData = this.keyDefinition.deserializer(data); - this.stateSubject.next(serializedData); - return serializedData; + const value = await getStoredValue( + key, + this.chosenStorageLocation, + this.keyDefinition.deserializer + ); + this.stateSubject.next(value); + return value; } protected saveToStorage(key: string, data: T): Promise { diff --git a/libs/common/src/platform/state/implementations/util.spec.ts b/libs/common/src/platform/state/implementations/util.spec.ts new file mode 100644 index 0000000000..15737b0c8c --- /dev/null +++ b/libs/common/src/platform/state/implementations/util.spec.ts @@ -0,0 +1,50 @@ +import { FakeStorageService } from "../../../../spec/fake-storage.service"; + +import { getStoredValue } from "./util"; + +describe("getStoredValue", () => { + const key = "key"; + const deserializedValue = { value: 1 }; + const value = JSON.stringify(deserializedValue); + const deserializer = (v: string) => JSON.parse(v); + let storageService: FakeStorageService; + + beforeEach(() => { + storageService = new FakeStorageService(); + }); + + describe("when the storage service requires deserialization", () => { + beforeEach(() => { + storageService.internalUpdateValuesRequireDeserialization(true); + }); + + it("should deserialize", async () => { + storageService.save(key, value); + + const result = await getStoredValue(key, storageService, deserializer); + + expect(result).toEqual(deserializedValue); + }); + }); + describe("when the storage service does not require deserialization", () => { + beforeEach(() => { + storageService.internalUpdateValuesRequireDeserialization(false); + }); + + it("should not deserialize", async () => { + storageService.save(key, value); + + const result = await getStoredValue(key, storageService, deserializer); + + expect(result).toEqual(value); + }); + + it("should convert undefined to null", async () => { + storageService.save(key, undefined); + + const result = await getStoredValue(key, storageService, deserializer); + + expect(result).toEqual(null); + }); + }); +}); diff --git a/libs/common/src/platform/state/implementations/util.ts b/libs/common/src/platform/state/implementations/util.ts new file mode 100644 index 0000000000..60401ce523 --- /dev/null +++ b/libs/common/src/platform/state/implementations/util.ts @@ -0,0 +1,18 @@ +import { Jsonify } from "type-fest"; + +import { AbstractStorageService } from "../../abstractions/storage.service"; + +export async function getStoredValue( + key: string, + storage: AbstractStorageService, + deserializer: (jsonValue: Jsonify) => T +) { + if (storage.valuesRequireDeserialization) { + const jsonValue = await storage.get>(key); + const value = deserializer(jsonValue); + return value; + } else { + const value = await storage.get(key); + return value ?? null; + } +} diff --git a/libs/common/src/platform/state/key-definition.spec.ts b/libs/common/src/platform/state/key-definition.spec.ts new file mode 100644 index 0000000000..8ddc690008 --- /dev/null +++ b/libs/common/src/platform/state/key-definition.spec.ts @@ -0,0 +1,81 @@ +import { Opaque } from "type-fest"; + +import { KeyDefinition } from "./key-definition"; +import { StateDefinition } from "./state-definition"; + +const fakeStateDefinition = new StateDefinition("fake", "disk"); + +type FancyString = Opaque; + +describe("KeyDefinition", () => { + describe("constructor", () => { + it("throws on undefined deserializer", () => { + expect(() => { + new KeyDefinition(fakeStateDefinition, "fake", { + deserializer: undefined, + }); + }); + }); + }); + + describe("record", () => { + it("runs custom deserializer for each record value", () => { + const recordDefinition = KeyDefinition.record(fakeStateDefinition, "fake", { + // Intentionally negate the value for testing + deserializer: (value) => !value, + }); + + expect(recordDefinition).toBeTruthy(); + expect(recordDefinition.deserializer).toBeTruthy(); + + const deserializedValue = recordDefinition.deserializer({ + test1: false, + test2: true, + }); + + expect(Object.keys(deserializedValue)).toHaveLength(2); + + // Values should have swapped from their initial value + expect(deserializedValue["test1"]).toBeTruthy(); + expect(deserializedValue["test2"]).toBeFalsy(); + }); + + it("can handle fancy string type", () => { + // This test is more of a test that I got the typescript typing correctly than actually testing any business logic + const recordDefinition = KeyDefinition.record( + fakeStateDefinition, + "fake", + { + deserializer: (value) => !value, + } + ); + + const fancyRecord = recordDefinition.deserializer( + JSON.parse(`{ "myKey": false, "mySecondKey": true }`) + ); + + expect(fancyRecord).toBeTruthy(); + expect(Object.keys(fancyRecord)).toHaveLength(2); + expect(fancyRecord["myKey" as FancyString]).toBeTruthy(); + expect(fancyRecord["mySecondKey" as FancyString]).toBeFalsy(); + }); + }); + + describe("array", () => { + it("run custom deserializer for each array element", () => { + const arrayDefinition = KeyDefinition.array(fakeStateDefinition, "fake", { + deserializer: (value) => !value, + }); + + expect(arrayDefinition).toBeTruthy(); + expect(arrayDefinition.deserializer).toBeTruthy(); + + const deserializedValue = arrayDefinition.deserializer([false, true]); + + expect(deserializedValue).toBeTruthy(); + expect(deserializedValue).toHaveLength(2); + expect(deserializedValue[0]).toBeTruthy(); + expect(deserializedValue[1]).toBeFalsy(); + }); + }); +}); diff --git a/libs/common/src/platform/state/key-definition.ts b/libs/common/src/platform/state/key-definition.ts index 91dafcb5e9..17eb1d943f 100644 --- a/libs/common/src/platform/state/key-definition.ts +++ b/libs/common/src/platform/state/key-definition.ts @@ -5,6 +5,22 @@ import { Utils } from "../misc/utils"; import { StateDefinition } from "./state-definition"; +/** + * A set of options for customizing the behavior of a {@link KeyDefinition} + */ +type KeyDefinitionOptions = { + /** + * A function to use to safely convert your type from json to your expected type. + * + * **Important:** Your data may be serialized/deserialized at any time and this + * callback needs to be able to faithfully re-initialize from the JSON object representation of your type. + * + * @param jsonValue The JSON object representation of your state. + * @returns The fully typed version of your state. + */ + readonly deserializer: (jsonValue: Jsonify) => T; +}; + /** * KeyDefinitions describe the precise location to store data for a given piece of state. * The StateDefinition is used to describe the domain of the state, and the KeyDefinition @@ -14,30 +30,61 @@ export class KeyDefinition { /** * Creates a new instance of a KeyDefinition * @param stateDefinition The state definition for which this key belongs to. - * @param key The name of the key, this should be unique per domain - * @param deserializer A function to use to safely convert your type from json to your expected type. + * @param key The name of the key, this should be unique per domain. + * @param options A set of options to customize the behavior of {@link KeyDefinition}. All options are required. + * @param options.deserializer A function to use to safely convert your type from json to your expected type. + * Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize + * from the JSON object representation of your type. */ constructor( readonly stateDefinition: StateDefinition, readonly key: string, - readonly deserializer: (jsonValue: Jsonify) => T - ) {} + private readonly options: KeyDefinitionOptions + ) { + if (options.deserializer == null) { + throw new Error( + `'deserializer' is a required property on key ${stateDefinition.name} > ${key}` + ); + } + } + + /** + * Gets the deserializer configured for this {@link KeyDefinition} + */ + get deserializer() { + return this.options.deserializer; + } /** * Creates a {@link KeyDefinition} for state that is an array. * @param stateDefinition The state definition to be added to the KeyDefinition * @param key The key to be added to the KeyDefinition - * @param deserializer The deserializer for the element of the array in your state. - * @returns A {@link KeyDefinition} that contains a serializer that will run the provided deserializer for each - * element of an array **unless that array is null in which case it will return an empty list.** + * @param options The options to customize the final {@link KeyDefinition}. + * @returns A {@link KeyDefinition} initialized for arrays, the options run + * the deserializer on the provided options for each element of an array + * **unless that array is null, in which case it will return an empty list.** + * + * @example + * ```typescript + * const MY_KEY = KeyDefinition.array(MY_STATE, "key", { + * deserializer: (myJsonElement) => convertToElement(myJsonElement), + * }); + * ``` */ static array( stateDefinition: StateDefinition, key: string, - deserializer: (jsonValue: Jsonify) => T + // We have them provide options for the element of the array, depending on future options we add, this could get a little weird. + options: KeyDefinitionOptions // The array helper forces an initialValue of an empty array ) { - return new KeyDefinition(stateDefinition, key, (jsonValue) => { - return jsonValue?.map((v) => deserializer(v)) ?? []; + return new KeyDefinition(stateDefinition, key, { + ...options, + deserializer: (jsonValue) => { + if (jsonValue == null) { + return null; + } + return jsonValue.map((v) => options.deserializer(v)); + }, }); } @@ -45,32 +92,42 @@ export class KeyDefinition { * Creates a {@link KeyDefinition} for state that is a record. * @param stateDefinition The state definition to be added to the KeyDefinition * @param key The key to be added to the KeyDefinition - * @param deserializer The deserializer for the value part of a record. + * @param options The options to customize the final {@link KeyDefinition}. * @returns A {@link KeyDefinition} that contains a serializer that will run the provided deserializer for each - * value in a record and returns every key as a string **unless that record is null in which case it will return an record.** + * value in a record and returns every key as a string **unless that record is null, in which case it will return an record.** + * + * @example + * ```typescript + * const MY_KEY = KeyDefinition.record(MY_STATE, "key", { + * deserializer: (myJsonValue) => convertToValue(myJsonValue), + * }); + * ``` */ - static record( + static record( stateDefinition: StateDefinition, key: string, - deserializer: (jsonValue: Jsonify) => T + // We have them provide options for the value of the record, depending on future options we add, this could get a little weird. + options: KeyDefinitionOptions // The array helper forces an initialValue of an empty record ) { - return new KeyDefinition>(stateDefinition, key, (jsonValue) => { - const output: Record = {}; + return new KeyDefinition>(stateDefinition, key, { + ...options, + deserializer: (jsonValue) => { + if (jsonValue == null) { + return null; + } - if (jsonValue == null) { + const output: Record = {}; + for (const key in jsonValue) { + output[key] = options.deserializer((jsonValue as Record>)[key]); + } return output; - } - - for (const key in jsonValue) { - output[key] = deserializer((jsonValue as Record>)[key]); - } - return output; + }, }); } /** - * - * @returns + * 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}`; diff --git a/libs/common/src/platform/state/state-update-options.ts b/libs/common/src/platform/state/state-update-options.ts new file mode 100644 index 0000000000..3a4bfed464 --- /dev/null +++ b/libs/common/src/platform/state/state-update-options.ts @@ -0,0 +1,26 @@ +import { Observable } from "rxjs"; + +export const DEFAULT_OPTIONS = { + shouldUpdate: () => true, + combineLatestWith: null as Observable, + msTimeout: 1000, +}; + +type DefinitelyTypedDefault = Omit< + typeof DEFAULT_OPTIONS, + "shouldUpdate" | "combineLatestWith" +> & { + shouldUpdate: (state: T, dependency: TCombine) => boolean; + combineLatestWith?: Observable; +}; + +export type StateUpdateOptions = Partial>; + +export function populateOptionsWithDefault( + options: StateUpdateOptions +): StateUpdateOptions { + return { + ...(DEFAULT_OPTIONS as StateUpdateOptions), + ...options, + }; +} diff --git a/libs/common/src/platform/state/user-state.ts b/libs/common/src/platform/state/user-state.ts index 82113e37ae..93404926dd 100644 --- a/libs/common/src/platform/state/user-state.ts +++ b/libs/common/src/platform/state/user-state.ts @@ -4,6 +4,8 @@ import { UserId } from "../../types/guid"; import { EncryptService } from "../abstractions/encrypt.service"; import { UserKey } from "../models/domain/symmetric-crypto-key"; +import { StateUpdateOptions } from "./state-update-options"; + import { DerivedUserState } from "."; export class DeriveContext { @@ -21,16 +23,33 @@ export interface UserState { /** * Updates backing stores for the active user. * @param configureState function that takes the current state and returns the new state + * @param options Defaults to @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 update: (configureState: (state: T) => T) => Promise; + readonly update: ( + 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) => T) => Promise; + 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.