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:
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 { 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 {}
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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,
|
@ -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
|
@ -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);
|
@ -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
|
||||||
|
@ -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 { 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";
|
||||||
|
@ -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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
21
libs/common/src/platform/state/state.provider.ts
Normal file
21
libs/common/src/platform/state/state.provider.ts
Normal 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>;
|
||||||
|
}
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user