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
This commit is contained in:
parent
3deb6ea0c8
commit
e045c6b103
|
@ -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 {}
|
||||
|
|
|
@ -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<KeyDefinition<unknown>, GlobalState<unknown>> = new Map();
|
||||
states: Map<string, GlobalState<unknown>> = new Map();
|
||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
let result = this.states.get(keyDefinition) as GlobalState<T>;
|
||||
let result = this.states.get(keyDefinition.buildCacheKey("global")) as GlobalState<T>;
|
||||
|
||||
if (result == null) {
|
||||
result = new FakeGlobalState<T>();
|
||||
this.states.set(keyDefinition, result);
|
||||
this.states.set(keyDefinition.buildCacheKey("global"), result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getFake<T>(keyDefinition: KeyDefinition<T>): FakeGlobalState<T> {
|
||||
const key = Array.from(this.states.keys()).find(
|
||||
(k) => k.stateDefinition === keyDefinition.stateDefinition && k.key === keyDefinition.key,
|
||||
);
|
||||
return this.get(key) as FakeGlobalState<T>;
|
||||
return this.get(keyDefinition) as FakeGlobalState<T>;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeUserStateProvider implements UserStateProvider {
|
||||
states: Map<KeyDefinition<unknown>, UserState<unknown>> = new Map();
|
||||
get<T>(keyDefinition: KeyDefinition<T>): UserState<T> {
|
||||
let result = this.states.get(keyDefinition) as UserState<T>;
|
||||
export class FakeSingleUserStateProvider {
|
||||
states: Map<string, SingleUserState<unknown>> = new Map();
|
||||
get<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T> {
|
||||
let result = this.states.get(keyDefinition.buildCacheKey("user", userId)) as SingleUserState<T>;
|
||||
|
||||
if (result == null) {
|
||||
result = new FakeUserState<T>();
|
||||
this.states.set(keyDefinition, result);
|
||||
result = new FakeSingleUserState<T>(userId);
|
||||
this.states.set(keyDefinition.buildCacheKey("user", userId), result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getFake<T>(keyDefinition: KeyDefinition<T>): FakeUserState<T> {
|
||||
const key = Array.from(this.states.keys()).find(
|
||||
(k) => k.stateDefinition === keyDefinition.stateDefinition && k.key === keyDefinition.key,
|
||||
);
|
||||
return this.get(key) as FakeUserState<T>;
|
||||
getFake<T>(userId: UserId, keyDefinition: KeyDefinition<T>): FakeSingleUserState<T> {
|
||||
return this.get(userId, keyDefinition) as FakeSingleUserState<T>;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeActiveUserStateProvider {
|
||||
states: Map<string, ActiveUserState<unknown>> = new Map();
|
||||
get<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
|
||||
let result = this.states.get(
|
||||
keyDefinition.buildCacheKey("user", "active"),
|
||||
) as ActiveUserState<T>;
|
||||
|
||||
if (result == null) {
|
||||
result = new FakeActiveUserState<T>();
|
||||
this.states.set(keyDefinition.buildCacheKey("user", "active"), result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getFake<T>(keyDefinition: KeyDefinition<T>): FakeActiveUserState<T> {
|
||||
return this.get(keyDefinition) as FakeActiveUserState<T>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<any, any> = {
|
||||
|
@ -97,3 +104,12 @@ export class FakeUserState<T> implements UserState<T> {
|
|||
return this.stateSubject.asObservable();
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeSingleUserState<T> extends FakeUserState<T> implements SingleUserState<T> {
|
||||
constructor(readonly userId: UserId) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
export class FakeActiveUserState<T> extends FakeUserState<T> implements ActiveUserState<T> {
|
||||
[activeMarker]: true;
|
||||
}
|
||||
|
|
|
@ -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<string, UserState<unknown>> = {};
|
||||
export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
|
||||
private cache: Record<string, ActiveUserState<unknown>> = {};
|
||||
|
||||
constructor(
|
||||
protected accountService: AccountService,
|
||||
|
@ -22,22 +22,22 @@ export class DefaultUserStateProvider implements UserStateProvider {
|
|||
protected diskStorage: AbstractStorageService & ObservableStorageService,
|
||||
) {}
|
||||
|
||||
get<T>(keyDefinition: KeyDefinition<T>): UserState<T> {
|
||||
const cacheKey = keyDefinition.buildCacheKey();
|
||||
const existingUserState = this.userStateCache[cacheKey];
|
||||
get<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
|
||||
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<T>;
|
||||
return existingUserState as ActiveUserState<T>;
|
||||
}
|
||||
|
||||
const newUserState = this.buildUserState(keyDefinition);
|
||||
this.userStateCache[cacheKey] = newUserState;
|
||||
const newUserState = this.buildActiveUserState(keyDefinition);
|
||||
this.cache[cacheKey] = newUserState;
|
||||
return newUserState;
|
||||
}
|
||||
|
||||
protected buildUserState<T>(keyDefinition: KeyDefinition<T>): UserState<T> {
|
||||
return new DefaultUserState<T>(
|
||||
protected buildActiveUserState<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
|
||||
return new DefaultActiveUserState<T>(
|
||||
keyDefinition,
|
||||
this.accountService,
|
||||
this.encryptService,
|
|
@ -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<TestState>(testStateDefinition, "fak
|
|||
deserializer: TestState.fromJSON,
|
||||
});
|
||||
|
||||
describe("DefaultUserState", () => {
|
||||
describe("DefaultActiveUserState", () => {
|
||||
const accountService = mock<AccountService>();
|
||||
let diskStorageService: FakeStorageService;
|
||||
let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>;
|
||||
let userState: DefaultUserState<TestState>;
|
||||
let userState: DefaultActiveUserState<TestState>;
|
||||
|
||||
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
|
|
@ -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<T> implements UserState<T> {
|
||||
export class DefaultActiveUserState<T> implements ActiveUserState<T> {
|
||||
[activeMarker]: true;
|
||||
private formattedKey$: Observable<string>;
|
||||
|
||||
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
|
||||
|
@ -130,37 +130,6 @@ export class DefaultUserState<T> implements UserState<T> {
|
|||
return newState;
|
||||
}
|
||||
|
||||
async updateFor<TCombine>(
|
||||
userId: UserId,
|
||||
configureState: (state: T, dependencies: TCombine) => T,
|
||||
options: StateUpdateOptions<T, TCombine> = {},
|
||||
): Promise<T> {
|
||||
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<T> {
|
||||
const key = await this.createKey();
|
||||
return await getStoredValue(key, this.chosenStorageLocation, this.keyDefinition.deserializer);
|
|
@ -19,7 +19,7 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider {
|
|||
) {}
|
||||
|
||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
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
|
||||
|
|
|
@ -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<string, SingleUserState<unknown>> = {};
|
||||
|
||||
constructor(
|
||||
protected encryptService: EncryptService,
|
||||
protected memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
|
||||
protected diskStorage: AbstractStorageService & ObservableStorageService,
|
||||
) {}
|
||||
|
||||
get<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
const newUserState = this.buildSingleUserState(userId, keyDefinition);
|
||||
this.cache[cacheKey] = newUserState;
|
||||
return newUserState;
|
||||
}
|
||||
|
||||
protected buildSingleUserState<T>(
|
||||
userId: UserId,
|
||||
keyDefinition: KeyDefinition<T>,
|
||||
): SingleUserState<T> {
|
||||
return new DefaultSingleUserState<T>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<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", {
|
||||
deserializer: TestState.fromJSON,
|
||||
});
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
const userKey = userKeyBuilder(userId, testKeyDefinition);
|
||||
|
||||
describe("DefaultSingleUserState", () => {
|
||||
let diskStorageService: FakeStorageService;
|
||||
let globalState: DefaultSingleUserState<TestState>;
|
||||
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<string, TestState> = {};
|
||||
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<string, TestState> = {};
|
||||
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]));
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<T> implements SingleUserState<T> {
|
||||
private storageKey: string;
|
||||
|
||||
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
|
||||
T | typeof FAKE_DEFAULT
|
||||
>(FAKE_DEFAULT);
|
||||
|
||||
state$: Observable<T>;
|
||||
|
||||
constructor(
|
||||
readonly userId: UserId,
|
||||
private keyDefinition: KeyDefinition<T>,
|
||||
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<T>((i) => i != FAKE_DEFAULT),
|
||||
);
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
options: StateUpdateOptions<T, TCombine> = {},
|
||||
): Promise<T> {
|
||||
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<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
|
||||
return new DefaultDerivedUserState<T, TTo>(converter, this.encryptService, this);
|
||||
}
|
||||
|
||||
private async getGuaranteedState() {
|
||||
const currentValue = this.stateSubject.getValue();
|
||||
return currentValue === FAKE_DEFAULT ? await this.getFromState() : currentValue;
|
||||
}
|
||||
|
||||
async getFromState(): Promise<T> {
|
||||
return await getStoredValue(
|
||||
this.storageKey,
|
||||
this.chosenLocation,
|
||||
this.keyDefinition.deserializer,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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<typeof ActiveUserStateProvider>["get"] =
|
||||
this.activeUserStateProvider.get.bind(this.activeUserStateProvider);
|
||||
getUser: InstanceType<typeof SingleUserStateProvider>["get"] =
|
||||
this.singleUserStateProvider.get.bind(this.singleUserStateProvider);
|
||||
getGlobal: InstanceType<typeof GlobalStateProvider>["get"] = this.globalStateProvider.get.bind(
|
||||
this.globalStateProvider,
|
||||
);
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -129,8 +129,13 @@ export class KeyDefinition<T> {
|
|||
* 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}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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: <T>(keyDefinition: KeyDefinition<T>) => ActiveUserState<T>;
|
||||
/** @see{@link SingleUserStateProvider.get} */
|
||||
getUser: <T>(userId: UserId, keyDefinition: KeyDefinition<T>) => SingleUserState<T>;
|
||||
/** @see{@link GlobalStateProvider.get} */
|
||||
getGlobal: <T>(keyDefinition: KeyDefinition<T>) => GlobalState<T>;
|
||||
}
|
|
@ -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: <T>(keyDefinition: KeyDefinition<T>) => UserState<T>;
|
||||
get: <T>(userId: UserId, keyDefinition: KeyDefinition<T>) => SingleUserState<T>;
|
||||
}
|
||||
|
||||
/** 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: <T>(keyDefinition: KeyDefinition<T>) => ActiveUserState<T>;
|
||||
}
|
||||
|
|
|
@ -37,22 +37,6 @@ export interface UserState<T> {
|
|||
configureState: (state: T, dependencies: TCombine) => T,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
) => Promise<T>;
|
||||
/**
|
||||
* 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: <TCombine>(
|
||||
userId: UserId,
|
||||
configureState: (state: T, dependencies: TCombine) => T,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Creates a derives state from the current state. Derived states are always tied to the active user.
|
||||
|
@ -61,3 +45,11 @@ export interface UserState<T> {
|
|||
*/
|
||||
createDerived: <TTo>(converter: Converter<T, TTo>) => DerivedUserState<TTo>;
|
||||
}
|
||||
|
||||
export const activeMarker: unique symbol = Symbol("active");
|
||||
export interface ActiveUserState<T> extends UserState<T> {
|
||||
readonly [activeMarker]: true;
|
||||
}
|
||||
export interface SingleUserState<T> extends UserState<T> {
|
||||
readonly userId: UserId;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue