1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-25 12:15:18 +01:00

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:
Matt Gibson 2023-12-05 10:20:16 -05:00 committed by GitHub
parent 3deb6ea0c8
commit e045c6b103
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 629 additions and 104 deletions

View File

@ -107,9 +107,18 @@ import { NoopNotificationsService } from "@bitwarden/common/platform/services/no
import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StateService } from "@bitwarden/common/platform/services/state.service";
import { ValidationService } from "@bitwarden/common/platform/services/validation.service"; import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state"; import {
// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed 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 { 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 { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
import { ApiService } from "@bitwarden/common/services/api.service"; import { ApiService } from "@bitwarden/common/services/api.service";
import { AuditService } from "@bitwarden/common/services/audit.service"; import { AuditService } from "@bitwarden/common/services/audit.service";
@ -785,6 +794,26 @@ import { ModalService } from "./modal.service";
useClass: DefaultGlobalStateProvider, useClass: DefaultGlobalStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE], 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 {} export class JslibServicesModule {}

View File

@ -2,48 +2,62 @@ import {
GlobalState, GlobalState,
GlobalStateProvider, GlobalStateProvider,
KeyDefinition, KeyDefinition,
UserState, ActiveUserState,
UserStateProvider, SingleUserState,
} from "../src/platform/state"; } 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 { 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> { 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) { if (result == null) {
result = new FakeGlobalState<T>(); result = new FakeGlobalState<T>();
this.states.set(keyDefinition, result); this.states.set(keyDefinition.buildCacheKey("global"), result);
} }
return result; return result;
} }
getFake<T>(keyDefinition: KeyDefinition<T>): FakeGlobalState<T> { getFake<T>(keyDefinition: KeyDefinition<T>): FakeGlobalState<T> {
const key = Array.from(this.states.keys()).find( return this.get(keyDefinition) as FakeGlobalState<T>;
(k) => k.stateDefinition === keyDefinition.stateDefinition && k.key === keyDefinition.key,
);
return this.get(key) as FakeGlobalState<T>;
} }
} }
export class FakeUserStateProvider implements UserStateProvider { export class FakeSingleUserStateProvider {
states: Map<KeyDefinition<unknown>, UserState<unknown>> = new Map(); states: Map<string, SingleUserState<unknown>> = new Map();
get<T>(keyDefinition: KeyDefinition<T>): UserState<T> { get<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T> {
let result = this.states.get(keyDefinition) as UserState<T>; let result = this.states.get(keyDefinition.buildCacheKey("user", userId)) as SingleUserState<T>;
if (result == null) { if (result == null) {
result = new FakeUserState<T>(); result = new FakeSingleUserState<T>(userId);
this.states.set(keyDefinition, result); this.states.set(keyDefinition.buildCacheKey("user", userId), result);
} }
return result; return result;
} }
getFake<T>(keyDefinition: KeyDefinition<T>): FakeUserState<T> { getFake<T>(userId: UserId, keyDefinition: KeyDefinition<T>): FakeSingleUserState<T> {
const key = Array.from(this.states.keys()).find( return this.get(userId, keyDefinition) as FakeSingleUserState<T>;
(k) => k.stateDefinition === keyDefinition.stateDefinition && k.key === keyDefinition.key, }
); }
return this.get(key) as FakeUserState<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>;
} }
} }

View File

@ -1,8 +1,15 @@
import { ReplaySubject, firstValueFrom, timeout } from "rxjs"; 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 // 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"; 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"; import { UserId } from "../src/types/guid";
const DEFAULT_TEST_OPTIONS: StateUpdateOptions<any, any> = { const DEFAULT_TEST_OPTIONS: StateUpdateOptions<any, any> = {
@ -97,3 +104,12 @@ export class FakeUserState<T> implements UserState<T> {
return this.stateSubject.asObservable(); 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;
}

View File

@ -7,13 +7,13 @@ import {
} from "../../abstractions/storage.service"; } from "../../abstractions/storage.service";
import { KeyDefinition } from "../key-definition"; import { KeyDefinition } from "../key-definition";
import { StorageLocation } from "../state-definition"; import { StorageLocation } from "../state-definition";
import { UserState } from "../user-state"; import { ActiveUserState } from "../user-state";
import { UserStateProvider } from "../user-state.provider"; import { ActiveUserStateProvider } from "../user-state.provider";
import { DefaultUserState } from "./default-user-state"; import { DefaultActiveUserState } from "./default-active-user-state";
export class DefaultUserStateProvider implements UserStateProvider { export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
private userStateCache: Record<string, UserState<unknown>> = {}; private cache: Record<string, ActiveUserState<unknown>> = {};
constructor( constructor(
protected accountService: AccountService, protected accountService: AccountService,
@ -22,22 +22,22 @@ export class DefaultUserStateProvider implements UserStateProvider {
protected diskStorage: AbstractStorageService & ObservableStorageService, protected diskStorage: AbstractStorageService & ObservableStorageService,
) {} ) {}
get<T>(keyDefinition: KeyDefinition<T>): UserState<T> { get<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
const cacheKey = keyDefinition.buildCacheKey(); const cacheKey = keyDefinition.buildCacheKey("user", "active");
const existingUserState = this.userStateCache[cacheKey]; const existingUserState = this.cache[cacheKey];
if (existingUserState != null) { if (existingUserState != null) {
// I have to cast out of the unknown generic but this should be safe if rules // I have to cast out of the unknown generic but this should be safe if rules
// around domain token are made // around domain token are made
return existingUserState as DefaultUserState<T>; return existingUserState as ActiveUserState<T>;
} }
const newUserState = this.buildUserState(keyDefinition); const newUserState = this.buildActiveUserState(keyDefinition);
this.userStateCache[cacheKey] = newUserState; this.cache[cacheKey] = newUserState;
return newUserState; return newUserState;
} }
protected buildUserState<T>(keyDefinition: KeyDefinition<T>): UserState<T> { protected buildActiveUserState<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
return new DefaultUserState<T>( return new DefaultActiveUserState<T>(
keyDefinition, keyDefinition,
this.accountService, this.accountService,
this.encryptService, this.encryptService,

View File

@ -14,7 +14,7 @@ import { UserId } from "../../../types/guid";
import { KeyDefinition } from "../key-definition"; import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-definition"; import { StateDefinition } from "../state-definition";
import { DefaultUserState } from "./default-user-state"; import { DefaultActiveUserState } from "./default-active-user-state";
class TestState { class TestState {
date: Date; date: Date;
@ -37,18 +37,18 @@ const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fak
deserializer: TestState.fromJSON, deserializer: TestState.fromJSON,
}); });
describe("DefaultUserState", () => { describe("DefaultActiveUserState", () => {
const accountService = mock<AccountService>(); const accountService = mock<AccountService>();
let diskStorageService: FakeStorageService; let diskStorageService: FakeStorageService;
let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>; let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>;
let userState: DefaultUserState<TestState>; let userState: DefaultActiveUserState<TestState>;
beforeEach(() => { beforeEach(() => {
activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(undefined); activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(undefined);
accountService.activeAccount$ = activeAccountSubject; accountService.activeAccount$ = activeAccountSubject;
diskStorageService = new FakeStorageService(); diskStorageService = new FakeStorageService();
userState = new DefaultUserState( userState = new DefaultActiveUserState(
testKeyDefinition, testKeyDefinition,
accountService, accountService,
null, // Not testing anything with encrypt service null, // Not testing anything with encrypt service

View File

@ -13,7 +13,6 @@ import {
} from "rxjs"; } from "rxjs";
import { AccountService } from "../../../auth/abstractions/account.service"; import { AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import { EncryptService } from "../../abstractions/encrypt.service"; import { EncryptService } from "../../abstractions/encrypt.service";
import { import {
AbstractStorageService, AbstractStorageService,
@ -22,14 +21,15 @@ import {
import { DerivedUserState } from "../derived-user-state"; import { DerivedUserState } from "../derived-user-state";
import { KeyDefinition, userKeyBuilder } from "../key-definition"; import { KeyDefinition, userKeyBuilder } from "../key-definition";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; 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 { DefaultDerivedUserState } from "./default-derived-state";
import { getStoredValue } from "./util"; import { getStoredValue } from "./util";
const FAKE_DEFAULT = Symbol("fakeDefault"); 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>; private formattedKey$: Observable<string>;
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject< protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
@ -130,37 +130,6 @@ export class DefaultUserState<T> implements UserState<T> {
return newState; 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> { async getFromState(): Promise<T> {
const key = await this.createKey(); const key = await this.createKey();
return await getStoredValue(key, this.chosenStorageLocation, this.keyDefinition.deserializer); return await getStoredValue(key, this.chosenStorageLocation, this.keyDefinition.deserializer);

View File

@ -19,7 +19,7 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider {
) {} ) {}
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> { get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
const cacheKey = keyDefinition.buildCacheKey(); const cacheKey = keyDefinition.buildCacheKey("global");
const existingGlobalState = this.globalStateCache[cacheKey]; const existingGlobalState = this.globalStateCache[cacheKey];
if (existingGlobalState != null) { if (existingGlobalState != null) {
// The cast into the actual generic is safe because of rules around key definitions // The cast into the actual generic is safe because of rules around key definitions

View File

@ -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;
}
}
}

View File

@ -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]));
});
});
});

View File

@ -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,
);
}
}

View File

@ -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);
});
});

View File

@ -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,
);
}

View File

@ -1,8 +1,9 @@
export { DerivedUserState } from "./derived-user-state"; export { DerivedUserState } from "./derived-user-state";
export { GlobalState } from "./global-state"; export { GlobalState } from "./global-state";
export { StateProvider } from "./state.provider";
export { GlobalStateProvider } from "./global-state.provider"; export { GlobalStateProvider } from "./global-state.provider";
export { UserState } from "./user-state"; export { ActiveUserState, SingleUserState } from "./user-state";
export { UserStateProvider } from "./user-state.provider"; export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
export { KeyDefinition } from "./key-definition"; export { KeyDefinition } from "./key-definition";
export * from "./state-definitions"; export * from "./state-definitions";

View File

@ -129,8 +129,13 @@ export class KeyDefinition<T> {
* Create a string that should be unique across the entire application. * 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. * @returns A string that can be used to cache instances created via this key.
*/ */
buildCacheKey(): string { buildCacheKey(scope: "user" | "global", userId?: "active" | UserId): string {
return `${this.stateDefinition.storageLocation}_${this.stateDefinition.name}_${this.key}`; 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}`;
} }
} }

View File

@ -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>;
}

View File

@ -1,13 +1,28 @@
import { KeyDefinition } from "./key-definition"; import { UserId } from "../../types/guid";
import { UserState } from "./user-state";
/** import { KeyDefinition } from "./key-definition";
* A provider for getting an implementation of user scoped state for the given key. import { ActiveUserState, SingleUserState } from "./user-state";
*/
export abstract class UserStateProvider { /** 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. * @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>;
} }

View File

@ -37,22 +37,6 @@ export interface UserState<T> {
configureState: (state: T, dependencies: TCombine) => T, configureState: (state: T, dependencies: TCombine) => T,
options?: StateUpdateOptions<T, TCombine>, options?: StateUpdateOptions<T, TCombine>,
) => Promise<T>; ) => 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. * 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>; 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;
}