mirror of
https://github.com/bitwarden/browser
synced 2025-12-17 08:43:33 +00:00
Add State Provider Framework (#6640)
* Add StateDefinition
Add a class for encapsulation information about state
this will often be for a domain but creations of this will
exist outside of a specific domain, hence just the name State.
* Add KeyDefinition
This adds a type that extends state definition into another sub-key
and forces creators to define the data that will be stored and how
to read the data that they expect to be stored.
* Add key-builders helper functions
Adds to function to help building keys for both keys scoped
to a specific user and for keys scoped to global storage.
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
* Add updates$ stream to existing storageServices
Original commit by Matt: 823d9546fe
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
* Add fromChromeEvent helper
Create a helper that creats an Observable from a chrome event
and removes the listener when the subscription is completed.
* Implement `updates$` property for chrome storage
Use fromChromeEvent to create an observable from chrome
event and map that into our expected shape.
* Add GlobalState Abstractions
* Add UserState Abstractions
* Add Default Implementations of User/Global state
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
* Add Barrel File for state
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
* Fix ChromeStorageServices
* Rework fromChromeEvent
Rework fromChromeEvent so we have to lie to TS less and
remove unneeded generics. I did this by caring less about
the function and more about the parameters only.
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
* Fix UserStateProvider Test
* Add Inner Mock & Assert Calls
* Update Tests to use new keys
Use different key format
* Prefer returns over mutations in update
* Update Tests
* Address PR Feedback
* Be stricter with userId parameter
* Add Better Way To Determine if it was a remove
* Fix Web & Browser Storage Services
* Fix Desktop & CLI Storage Services
* Fix Test Storage Service
* Use createKey Helper
* Prefer implement to extending
* Determine storage location in providers
* Export default providers publicly
* Fix user state tests
* Name tests
* Fix CLI
* Prefer Implement In Chrome Storage
* Remove Secure Storage Option
Also throw an exception for subscribes to the secure storage observable.
* Update apps/browser/src/platform/browser/from-chrome-event.ts
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
* Enforce state module barrel file
* Fix Linting Error
* Allow state module import from other modules
* Globally Unregister fromChromeEvent Listeners
Changed fromChromeEvent to add its listeners through the BrowserApi, so that
they will be unregistered when safari closes.
* Test default global state
* Use Proper Casing in Parameter
* Address Feedback
* Update libs/common/src/platform/state/key-definition.ts
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
* Add `buildCacheKey` Method
* Fix lint errors
* Add Comment
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
* Use Generic in callback parameter
* Refactor Out DerivedStateDefinition
* Persist Listener Return Type
* Add Ticket Link
---------
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { Observable, switchMap } from "rxjs";
|
||||
|
||||
import { EncryptService } from "../../abstractions/encrypt.service";
|
||||
import { DerivedUserState } from "../derived-user-state";
|
||||
import { Converter, DeriveContext, UserState } from "../user-state";
|
||||
|
||||
export class DefaultDerivedUserState<TFrom, TTo> implements DerivedUserState<TTo> {
|
||||
state$: Observable<TTo>;
|
||||
|
||||
constructor(
|
||||
private converter: Converter<TFrom, TTo>,
|
||||
private encryptService: EncryptService,
|
||||
private userState: UserState<TFrom>
|
||||
) {
|
||||
this.state$ = userState.state$.pipe(
|
||||
switchMap(async (from) => {
|
||||
// TODO: How do I get the key?
|
||||
const convertedData = await this.converter(from, new DeriveContext(null, encryptService));
|
||||
return convertedData;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
AbstractMemoryStorageService,
|
||||
AbstractStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { GlobalState } from "../global-state";
|
||||
import { GlobalStateProvider } from "../global-state.provider";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
import { StorageLocation } from "../state-definition";
|
||||
|
||||
import { DefaultGlobalState } from "./default-global-state";
|
||||
|
||||
export class DefaultGlobalStateProvider implements GlobalStateProvider {
|
||||
private globalStateCache: Record<string, GlobalState<unknown>> = {};
|
||||
|
||||
constructor(
|
||||
private memoryStorage: AbstractMemoryStorageService,
|
||||
private diskStorage: AbstractStorageService
|
||||
) {}
|
||||
|
||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
const cacheKey = keyDefinition.buildCacheKey();
|
||||
const existingGlobalState = this.globalStateCache[cacheKey];
|
||||
if (existingGlobalState != null) {
|
||||
// The cast into the actual generic is safe because of rules around key definitions
|
||||
// being unique.
|
||||
return existingGlobalState as DefaultGlobalState<T>;
|
||||
}
|
||||
|
||||
const newGlobalState = new DefaultGlobalState<T>(
|
||||
keyDefinition,
|
||||
this.getLocation(keyDefinition.stateDefinition.storageLocation)
|
||||
);
|
||||
|
||||
this.globalStateCache[cacheKey] = newGlobalState;
|
||||
return newGlobalState;
|
||||
}
|
||||
|
||||
private getLocation(location: StorageLocation) {
|
||||
switch (location) {
|
||||
case "disk":
|
||||
return this.diskStorage;
|
||||
case "memory":
|
||||
return this.memoryStorage;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* need to update test environment so trackEmissions works appropriately
|
||||
* @jest-environment ../shared/test.environment.ts
|
||||
*/
|
||||
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { trackEmissions } from "../../../../spec";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
|
||||
import { DefaultGlobalState } from "./default-global-state";
|
||||
|
||||
class TestState {
|
||||
date: Date;
|
||||
|
||||
static fromJSON(jsonState: Jsonify<TestState>) {
|
||||
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<TestState>(
|
||||
testStateDefinition,
|
||||
"fake",
|
||||
TestState.fromJSON
|
||||
);
|
||||
const globalKey = globalKeyBuilder(testKeyDefinition);
|
||||
|
||||
describe("DefaultGlobalState", () => {
|
||||
let diskStorageService: FakeStorageService;
|
||||
let globalState: DefaultGlobalState<TestState>;
|
||||
|
||||
beforeEach(() => {
|
||||
diskStorageService = new FakeStorageService();
|
||||
globalState = new DefaultGlobalState(testKeyDefinition, diskStorageService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should emit when storage updates", async () => {
|
||||
const emissions = trackEmissions(globalState.state$);
|
||||
const newData = { date: new Date() };
|
||||
await diskStorageService.save(globalKey, newData);
|
||||
|
||||
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([
|
||||
null, // Initial value
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("should save on update", async () => {
|
||||
const newData = { date: new Date() };
|
||||
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$);
|
||||
const newData = { date: new Date() };
|
||||
|
||||
await globalState.update((state) => {
|
||||
return newData;
|
||||
});
|
||||
|
||||
expect(emissions).toEqual([
|
||||
null, // Initial value
|
||||
newData,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BehaviorSubject, Observable, defer, filter, map, shareReplay, tap } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AbstractStorageService } from "../../abstractions/storage.service";
|
||||
import { GlobalState } from "../global-state";
|
||||
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
||||
|
||||
export class DefaultGlobalState<T> implements GlobalState<T> {
|
||||
private storageKey: string;
|
||||
private seededPromise: Promise<void>;
|
||||
|
||||
protected stateSubject: BehaviorSubject<T | null> = new BehaviorSubject<T | null>(null);
|
||||
|
||||
state$: Observable<T>;
|
||||
|
||||
constructor(
|
||||
private keyDefinition: KeyDefinition<T>,
|
||||
private chosenLocation: AbstractStorageService
|
||||
) {
|
||||
this.storageKey = globalKeyBuilder(this.keyDefinition);
|
||||
|
||||
this.seededPromise = this.chosenLocation.get<Jsonify<T>>(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<T>);
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: false })
|
||||
);
|
||||
|
||||
this.state$ = defer(() => {
|
||||
const storageUpdateSubscription = storageUpdates$.subscribe((value) => {
|
||||
this.stateSubject.next(value);
|
||||
});
|
||||
|
||||
return this.stateSubject.pipe(
|
||||
tap({
|
||||
complete: () => storageUpdateSubscription.unsubscribe(),
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async update(configureState: (state: T) => T): Promise<T> {
|
||||
await this.seededPromise;
|
||||
const currentState = this.stateSubject.getValue();
|
||||
const newState = configureState(currentState);
|
||||
await this.chosenLocation.save(this.storageKey, newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
async getFromState(): Promise<T> {
|
||||
const data = await this.chosenLocation.get<Jsonify<T>>(this.storageKey);
|
||||
return this.keyDefinition.deserializer(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { EncryptService } from "../../abstractions/encrypt.service";
|
||||
import {
|
||||
AbstractMemoryStorageService,
|
||||
AbstractStorageService,
|
||||
} 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 { DefaultUserState } from "./default-user-state";
|
||||
|
||||
export class DefaultUserStateProvider implements UserStateProvider {
|
||||
private userStateCache: Record<string, UserState<unknown>> = {};
|
||||
|
||||
constructor(
|
||||
protected accountService: AccountService,
|
||||
protected encryptService: EncryptService,
|
||||
protected memoryStorage: AbstractMemoryStorageService,
|
||||
protected diskStorage: AbstractStorageService
|
||||
) {}
|
||||
|
||||
get<T>(keyDefinition: KeyDefinition<T>): UserState<T> {
|
||||
const cacheKey = keyDefinition.buildCacheKey();
|
||||
const existingUserState = this.userStateCache[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<T>;
|
||||
}
|
||||
|
||||
const newUserState = this.buildUserState(keyDefinition);
|
||||
this.userStateCache[cacheKey] = newUserState;
|
||||
return newUserState;
|
||||
}
|
||||
|
||||
protected buildUserState<T>(keyDefinition: KeyDefinition<T>): UserState<T> {
|
||||
return new DefaultUserState<T>(
|
||||
keyDefinition,
|
||||
this.accountService,
|
||||
this.encryptService,
|
||||
this.getLocation(keyDefinition.stateDefinition.storageLocation)
|
||||
);
|
||||
}
|
||||
|
||||
private getLocation(location: StorageLocation) {
|
||||
switch (location) {
|
||||
case "disk":
|
||||
return this.diskStorage;
|
||||
case "memory":
|
||||
return this.memoryStorage;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { any, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, timeout } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { 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";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
|
||||
import { DefaultUserState } from "./default-user-state";
|
||||
|
||||
class TestState {
|
||||
date: Date;
|
||||
array: string[];
|
||||
|
||||
static fromJSON(jsonState: Jsonify<TestState>) {
|
||||
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<TestState>(
|
||||
testStateDefinition,
|
||||
"fake",
|
||||
TestState.fromJSON
|
||||
);
|
||||
|
||||
describe("DefaultUserState", () => {
|
||||
const accountService = mock<AccountService>();
|
||||
let diskStorageService: FakeStorageService;
|
||||
let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>;
|
||||
let userState: DefaultUserState<TestState>;
|
||||
|
||||
beforeEach(() => {
|
||||
activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(undefined);
|
||||
accountService.activeAccount$ = activeAccountSubject;
|
||||
|
||||
diskStorageService = new FakeStorageService();
|
||||
userState = new DefaultUserState(
|
||||
testKeyDefinition,
|
||||
accountService,
|
||||
null, // Not testing anything with encrypt service
|
||||
diskStorageService
|
||||
);
|
||||
});
|
||||
|
||||
const changeActiveUser = async (id: string) => {
|
||||
const userId = id != null ? `00000000-0000-1000-a000-00000000000${id}` : undefined;
|
||||
activeAccountSubject.next({
|
||||
id: userId as UserId,
|
||||
email: `test${id}@example.com`,
|
||||
name: `Test User ${id}`,
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
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<TestState>,
|
||||
"user_00000000-0000-1000-a000-000000000002_fake_fake": {
|
||||
date: "2021-09-21T13:14:17.648Z",
|
||||
array: ["user2_value"],
|
||||
},
|
||||
});
|
||||
|
||||
const emissions = trackEmissions(userState.state$);
|
||||
|
||||
// User signs in
|
||||
changeActiveUser("1");
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1));
|
||||
|
||||
// Service does an update
|
||||
await userState.update((state) => {
|
||||
state.array.push("value3");
|
||||
state.date = new Date(2023, 0);
|
||||
return state;
|
||||
});
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1));
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"user_00000000-0000-1000-a000-000000000001_fake_fake",
|
||||
any()
|
||||
);
|
||||
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"user_00000000-0000-1000-a000-000000000002_fake_fake",
|
||||
any()
|
||||
);
|
||||
|
||||
// Should only have saved data for the first user
|
||||
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1);
|
||||
expect(diskStorageService.mock.save).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"user_00000000-0000-1000-a000-000000000001_fake_fake",
|
||||
any()
|
||||
);
|
||||
});
|
||||
|
||||
it("will not emit any value if there isn't an active user", async () => {
|
||||
let resolvedValue: TestState | undefined = undefined;
|
||||
let rejectedError: Error | undefined = undefined;
|
||||
|
||||
const promise = firstValueFrom(userState.state$.pipe(timeout(20)))
|
||||
.then((value) => {
|
||||
resolvedValue = value;
|
||||
})
|
||||
.catch((err) => {
|
||||
rejectedError = err;
|
||||
});
|
||||
await promise;
|
||||
|
||||
expect(diskStorageService.mock.get).not.toHaveBeenCalled();
|
||||
|
||||
expect(resolvedValue).toBe(undefined);
|
||||
expect(rejectedError).toBeTruthy();
|
||||
expect(rejectedError.message).toBe("Timeout has occurred");
|
||||
});
|
||||
|
||||
it("will emit value for a new active user after subscription started", async () => {
|
||||
let resolvedValue: TestState | undefined = undefined;
|
||||
let rejectedError: Error | undefined = undefined;
|
||||
|
||||
diskStorageService.internalUpdateStore({
|
||||
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
|
||||
date: "2020-09-21T13:14:17.648Z",
|
||||
array: ["testValue"],
|
||||
} as Jsonify<TestState>,
|
||||
});
|
||||
|
||||
const promise = firstValueFrom(userState.state$.pipe(timeout(20)))
|
||||
.then((value) => {
|
||||
resolvedValue = value;
|
||||
})
|
||||
.catch((err) => {
|
||||
rejectedError = err;
|
||||
});
|
||||
await changeActiveUser("1");
|
||||
await promise;
|
||||
|
||||
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(resolvedValue).toBeTruthy();
|
||||
expect(resolvedValue.array).toHaveLength(1);
|
||||
expect(resolvedValue.date.getUTCFullYear()).toBe(2020);
|
||||
expect(rejectedError).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should not emit a previous users value if that user is no longer active", async () => {
|
||||
diskStorageService.internalUpdateStore({
|
||||
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
|
||||
date: "2020-09-21T13:14:17.648Z",
|
||||
array: ["value"],
|
||||
} as Jsonify<TestState>,
|
||||
"user_00000000-0000-1000-a000-000000000002_fake_fake": {
|
||||
date: "2020-09-21T13:14:17.648Z",
|
||||
array: [],
|
||||
} as Jsonify<TestState>,
|
||||
});
|
||||
|
||||
// This starts one subscription on the observable for tracking emissions throughout
|
||||
// the whole test.
|
||||
const emissions = trackEmissions(userState.state$);
|
||||
|
||||
// Change to a user with data
|
||||
await changeActiveUser("1");
|
||||
|
||||
// This should always return a value right await
|
||||
const value = await firstValueFrom(userState.state$);
|
||||
expect(value).toBeTruthy();
|
||||
|
||||
// Make it such that there is no active user
|
||||
await changeActiveUser(undefined);
|
||||
|
||||
let resolvedValue: TestState | undefined = undefined;
|
||||
let rejectedError: Error | undefined = undefined;
|
||||
|
||||
// Even if the observable has previously emitted a value it shouldn't have
|
||||
// a value for the user subscribing to it because there isn't an active user
|
||||
// to get data for.
|
||||
await firstValueFrom(userState.state$.pipe(timeout(20)))
|
||||
.then((value) => {
|
||||
resolvedValue = value;
|
||||
})
|
||||
.catch((err) => {
|
||||
rejectedError = err;
|
||||
});
|
||||
|
||||
expect(resolvedValue).toBeFalsy();
|
||||
expect(rejectedError).toBeTruthy();
|
||||
expect(rejectedError.message).toBe("Timeout has occurred");
|
||||
|
||||
// We need to figure out if something should be emitted
|
||||
// when there becomes no active user, if we don't want that to emit
|
||||
// this value is correct.
|
||||
expect(emissions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
Observable,
|
||||
BehaviorSubject,
|
||||
map,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
defer,
|
||||
firstValueFrom,
|
||||
combineLatestWith,
|
||||
filter,
|
||||
} from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
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 { Converter, UserState } from "../user-state";
|
||||
|
||||
import { DefaultDerivedUserState } from "./default-derived-state";
|
||||
|
||||
const FAKE_DEFAULT = Symbol("fakeDefault");
|
||||
|
||||
export class DefaultUserState<T> implements UserState<T> {
|
||||
private formattedKey$: Observable<string>;
|
||||
|
||||
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
|
||||
T | typeof FAKE_DEFAULT
|
||||
>(FAKE_DEFAULT);
|
||||
private stateSubject$ = this.stateSubject.asObservable();
|
||||
|
||||
state$: Observable<T>;
|
||||
|
||||
constructor(
|
||||
protected keyDefinition: KeyDefinition<T>,
|
||||
private accountService: AccountService,
|
||||
private encryptService: EncryptService,
|
||||
private chosenStorageLocation: AbstractStorageService
|
||||
) {
|
||||
this.formattedKey$ = this.accountService.activeAccount$.pipe(
|
||||
map((account) =>
|
||||
account != null && account.id != null
|
||||
? userKeyBuilder(account.id, this.keyDefinition)
|
||||
: null
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: false })
|
||||
);
|
||||
|
||||
const activeAccountData$ = this.formattedKey$.pipe(
|
||||
switchMap(async (key) => {
|
||||
if (key == null) {
|
||||
return FAKE_DEFAULT;
|
||||
}
|
||||
const jsonData = await this.chosenStorageLocation.get<Jsonify<T>>(key);
|
||||
const data = keyDefinition.deserializer(jsonData);
|
||||
return data;
|
||||
}),
|
||||
// Share the execution
|
||||
shareReplay({ refCount: false, bufferSize: 1 })
|
||||
);
|
||||
|
||||
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<T>);
|
||||
})
|
||||
);
|
||||
|
||||
// Whomever subscribes to this data, should be notified of updated data
|
||||
// if someone calls my update() method, or the active user changes.
|
||||
this.state$ = defer(() => {
|
||||
const accountChangeSubscription = activeAccountData$.subscribe((data) => {
|
||||
this.stateSubject.next(data);
|
||||
});
|
||||
const storageUpdateSubscription = storageUpdates$.subscribe((data) => {
|
||||
this.stateSubject.next(data);
|
||||
});
|
||||
|
||||
return this.stateSubject$.pipe(
|
||||
tap({
|
||||
complete: () => {
|
||||
accountChangeSubscription.unsubscribe();
|
||||
storageUpdateSubscription.unsubscribe();
|
||||
},
|
||||
})
|
||||
);
|
||||
})
|
||||
// I fake the generic here because I am filtering out the other union type
|
||||
// and this makes it so that typescript understands the true type
|
||||
.pipe(filter<T>((value) => value != FAKE_DEFAULT));
|
||||
}
|
||||
|
||||
async update(configureState: (state: T) => T): Promise<T> {
|
||||
const key = await this.createKey();
|
||||
const currentState = await this.getGuaranteedState(key);
|
||||
const newState = configureState(currentState);
|
||||
await this.saveToStorage(key, newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
async updateFor(userId: UserId, configureState: (state: T) => T): Promise<T> {
|
||||
if (userId == null) {
|
||||
throw new Error("Attempting to update user state, but no userId has been supplied.");
|
||||
}
|
||||
|
||||
const key = userKeyBuilder(userId, this.keyDefinition);
|
||||
const currentStore = await this.chosenStorageLocation.get<Jsonify<T>>(key);
|
||||
const currentState = this.keyDefinition.deserializer(currentStore);
|
||||
const newState = configureState(currentState);
|
||||
await this.saveToStorage(key, newState);
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
async getFromState(): Promise<T> {
|
||||
const key = await this.createKey();
|
||||
const data = await this.chosenStorageLocation.get<Jsonify<T>>(key);
|
||||
return this.keyDefinition.deserializer(data);
|
||||
}
|
||||
|
||||
createDerived<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
|
||||
return new DefaultDerivedUserState<T, TTo>(converter, this.encryptService, this);
|
||||
}
|
||||
|
||||
protected async createKey(): Promise<string> {
|
||||
const formattedKey = await firstValueFrom(this.formattedKey$);
|
||||
if (formattedKey == null) {
|
||||
throw new Error("Cannot create a key while there is no active user.");
|
||||
}
|
||||
return formattedKey;
|
||||
}
|
||||
|
||||
protected async getGuaranteedState(key: string) {
|
||||
const currentValue = this.stateSubject.getValue();
|
||||
return currentValue === FAKE_DEFAULT ? await this.seedInitial(key) : currentValue;
|
||||
}
|
||||
|
||||
private async seedInitial(key: string): Promise<T> {
|
||||
const data = await this.chosenStorageLocation.get<Jsonify<T>>(key);
|
||||
const serializedData = this.keyDefinition.deserializer(data);
|
||||
this.stateSubject.next(serializedData);
|
||||
return serializedData;
|
||||
}
|
||||
|
||||
protected saveToStorage(key: string, data: T): Promise<void> {
|
||||
return this.chosenStorageLocation.save(key, data);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user