mirror of
https://github.com/bitwarden/browser
synced 2025-12-18 17:23:37 +00:00
Merge branch 'main' into vault/pm-5273
# Conflicts: # libs/common/src/platform/state/state-definitions.ts # libs/common/src/state-migrations/migrate.ts
This commit is contained in:
38
libs/common/src/platform/state/deserialization-helpers.ts
Normal file
38
libs/common/src/platform/state/deserialization-helpers.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param elementDeserializer
|
||||
* @returns
|
||||
*/
|
||||
export function array<T>(
|
||||
elementDeserializer: (element: Jsonify<T>) => T,
|
||||
): (array: Jsonify<T[]>) => T[] {
|
||||
return (array) => {
|
||||
if (array == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array.map((element) => elementDeserializer(element));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param valueDeserializer
|
||||
*/
|
||||
export function record<T, TKey extends string = string>(
|
||||
valueDeserializer: (value: Jsonify<T>) => T,
|
||||
): (record: Jsonify<Record<TKey, T>>) => Record<TKey, T> {
|
||||
return (jsonValue: Jsonify<Record<TKey, T> | null>) => {
|
||||
if (jsonValue == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: Record<string, T> = {};
|
||||
for (const key in jsonValue) {
|
||||
output[key] = valueDeserializer((jsonValue as Record<string, Jsonify<T>>)[key]);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
}
|
||||
@@ -3,17 +3,14 @@ import { mock } from "jest-mock-extended";
|
||||
import { mockAccountServiceWith, trackEmissions } from "../../../../spec";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import {
|
||||
AbstractMemoryStorageService,
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
|
||||
import { DefaultActiveUserStateProvider } from "./default-active-user-state.provider";
|
||||
|
||||
describe("DefaultActiveUserStateProvider", () => {
|
||||
const memoryStorage = mock<AbstractMemoryStorageService & ObservableStorageService>();
|
||||
const diskStorage = mock<AbstractStorageService & ObservableStorageService>();
|
||||
const storageServiceProvider = mock<StorageServiceProvider>();
|
||||
const stateEventRegistrarService = mock<StateEventRegistrarService>();
|
||||
const userId = "userId" as UserId;
|
||||
const accountInfo = {
|
||||
id: userId,
|
||||
@@ -25,7 +22,11 @@ describe("DefaultActiveUserStateProvider", () => {
|
||||
let sut: DefaultActiveUserStateProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new DefaultActiveUserStateProvider(accountService, memoryStorage, diskStorage);
|
||||
sut = new DefaultActiveUserStateProvider(
|
||||
accountService,
|
||||
storageServiceProvider,
|
||||
stateEventRegistrarService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -2,13 +2,10 @@ import { Observable, map } from "rxjs";
|
||||
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import {
|
||||
AbstractMemoryStorageService,
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { UserKeyDefinition, isUserKeyDefinition } from "../user-key-definition";
|
||||
import { ActiveUserState } from "../user-state";
|
||||
import { ActiveUserStateProvider } from "../user-state.provider";
|
||||
|
||||
@@ -20,15 +17,22 @@ export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
|
||||
activeUserId$: Observable<UserId | undefined>;
|
||||
|
||||
constructor(
|
||||
protected readonly accountService: AccountService,
|
||||
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
|
||||
protected readonly diskStorage: AbstractStorageService & ObservableStorageService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly storageServiceProvider: StorageServiceProvider,
|
||||
private readonly stateEventRegistrarService: StateEventRegistrarService,
|
||||
) {
|
||||
this.activeUserId$ = this.accountService.activeAccount$.pipe(map((account) => account?.id));
|
||||
}
|
||||
|
||||
get<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
|
||||
const cacheKey = this.buildCacheKey(keyDefinition);
|
||||
get<T>(keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>): ActiveUserState<T> {
|
||||
if (!isUserKeyDefinition(keyDefinition)) {
|
||||
keyDefinition = UserKeyDefinition.fromBaseKeyDefinition(keyDefinition);
|
||||
}
|
||||
const [location, storageService] = this.storageServiceProvider.get(
|
||||
keyDefinition.stateDefinition.defaultStorageLocation,
|
||||
keyDefinition.stateDefinition.storageLocationOverrides,
|
||||
);
|
||||
const cacheKey = this.buildCacheKey(location, keyDefinition);
|
||||
const existingUserState = this.cache[cacheKey];
|
||||
if (existingUserState != null) {
|
||||
// I have to cast out of the unknown generic but this should be safe if rules
|
||||
@@ -36,36 +40,17 @@ export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
|
||||
return existingUserState as ActiveUserState<T>;
|
||||
}
|
||||
|
||||
const newUserState = this.buildActiveUserState(keyDefinition);
|
||||
const newUserState = new DefaultActiveUserState<T>(
|
||||
keyDefinition,
|
||||
this.accountService,
|
||||
storageService,
|
||||
this.stateEventRegistrarService,
|
||||
);
|
||||
this.cache[cacheKey] = newUserState;
|
||||
return newUserState;
|
||||
}
|
||||
|
||||
private buildCacheKey(keyDefinition: KeyDefinition<unknown>) {
|
||||
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}`;
|
||||
}
|
||||
|
||||
protected buildActiveUserState<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
|
||||
return new DefaultActiveUserState<T>(
|
||||
keyDefinition,
|
||||
this.accountService,
|
||||
this.getLocation(keyDefinition.stateDefinition),
|
||||
);
|
||||
}
|
||||
|
||||
protected getLocationString(keyDefinition: KeyDefinition<unknown>): string {
|
||||
return keyDefinition.stateDefinition.defaultStorageLocation;
|
||||
}
|
||||
|
||||
protected getLocation(stateDefinition: StateDefinition) {
|
||||
// The default implementations don't support the client overrides
|
||||
// it is up to the client to extend this class and add that support
|
||||
const location = stateDefinition.defaultStorageLocation;
|
||||
switch (location) {
|
||||
case "disk":
|
||||
return this.diskStorage;
|
||||
case "memory":
|
||||
return this.memoryStorage;
|
||||
}
|
||||
private buildCacheKey(location: string, keyDefinition: UserKeyDefinition<unknown>) {
|
||||
return `${location}_${keyDefinition.fullName}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { KeyDefinition, userKeyBuilder } from "../key-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
|
||||
import { DefaultActiveUserState } from "./default-active-user-state";
|
||||
|
||||
@@ -32,15 +33,17 @@ class TestState {
|
||||
}
|
||||
|
||||
const testStateDefinition = new StateDefinition("fake", "disk");
|
||||
const cleanupDelayMs = 10;
|
||||
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
|
||||
const cleanupDelayMs = 15;
|
||||
const testKeyDefinition = new UserKeyDefinition<TestState>(testStateDefinition, "fake", {
|
||||
deserializer: TestState.fromJSON,
|
||||
cleanupDelayMs,
|
||||
clearOn: [],
|
||||
});
|
||||
|
||||
describe("DefaultActiveUserState", () => {
|
||||
const accountService = mock<AccountService>();
|
||||
let diskStorageService: FakeStorageService;
|
||||
const stateEventRegistrarService = mock<StateEventRegistrarService>();
|
||||
let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>;
|
||||
let userState: DefaultActiveUserState<TestState>;
|
||||
|
||||
@@ -49,7 +52,12 @@ describe("DefaultActiveUserState", () => {
|
||||
accountService.activeAccount$ = activeAccountSubject;
|
||||
|
||||
diskStorageService = new FakeStorageService();
|
||||
userState = new DefaultActiveUserState(testKeyDefinition, accountService, diskStorageService);
|
||||
userState = new DefaultActiveUserState(
|
||||
testKeyDefinition,
|
||||
accountService,
|
||||
diskStorageService,
|
||||
stateEventRegistrarService,
|
||||
);
|
||||
});
|
||||
|
||||
const makeUserId = (id: string) => {
|
||||
@@ -390,6 +398,48 @@ describe("DefaultActiveUserState", () => {
|
||||
"No active user at this time.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"should register user key definition when state transitions from null-ish (%s) to non-null",
|
||||
async (startingValue: TestState | null) => {
|
||||
diskStorageService.internalUpdateStore({
|
||||
"user_00000000-0000-1000-a000-000000000001_fake_fake": startingValue,
|
||||
});
|
||||
|
||||
await userState.update(() => ({ array: ["one"], date: new Date() }));
|
||||
|
||||
expect(stateEventRegistrarService.registerEvents).toHaveBeenCalledWith(testKeyDefinition);
|
||||
},
|
||||
);
|
||||
|
||||
it("should not register user key definition when state has preexisting value", async () => {
|
||||
diskStorageService.internalUpdateStore({
|
||||
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
|
||||
date: new Date(2019, 1),
|
||||
array: [],
|
||||
},
|
||||
});
|
||||
|
||||
await userState.update(() => ({ array: ["one"], date: new Date() }));
|
||||
|
||||
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"should not register user key definition when setting value to null-ish (%s) value",
|
||||
async (updatedValue: TestState | null) => {
|
||||
diskStorageService.internalUpdateStore({
|
||||
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
|
||||
date: new Date(2019, 1),
|
||||
array: [],
|
||||
},
|
||||
});
|
||||
|
||||
await userState.update(() => updatedValue);
|
||||
|
||||
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("update races", () => {
|
||||
@@ -592,7 +642,7 @@ describe("DefaultActiveUserState", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await changeActiveUser("1");
|
||||
userKey = userKeyBuilder(userId, testKeyDefinition);
|
||||
userKey = testKeyDefinition.buildKey(userId);
|
||||
});
|
||||
|
||||
function assertClean() {
|
||||
|
||||
@@ -21,8 +21,9 @@ import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { KeyDefinition, userKeyBuilder } from "../key-definition";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
import { ActiveUserState, CombinedState, activeMarker } from "../user-state";
|
||||
|
||||
import { getStoredValue } from "./util";
|
||||
@@ -39,9 +40,10 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
|
||||
state$: Observable<T>;
|
||||
|
||||
constructor(
|
||||
protected keyDefinition: KeyDefinition<T>,
|
||||
protected keyDefinition: UserKeyDefinition<T>,
|
||||
private accountService: AccountService,
|
||||
private chosenStorageLocation: AbstractStorageService & ObservableStorageService,
|
||||
private stateEventRegistrarService: StateEventRegistrarService,
|
||||
) {
|
||||
this.activeUserId$ = this.accountService.activeAccount$.pipe(
|
||||
// We only care about the UserId but we do want to know about no user as well.
|
||||
@@ -61,7 +63,7 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
|
||||
return FAKE;
|
||||
}
|
||||
|
||||
const fullKey = userKeyBuilder(userId, this.keyDefinition);
|
||||
const fullKey = this.keyDefinition.buildKey(userId);
|
||||
const data = await getStoredValue(
|
||||
fullKey,
|
||||
this.chosenStorageLocation,
|
||||
@@ -80,7 +82,7 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
|
||||
// Null userId is already taken care of through the userChange observable above
|
||||
filter((u) => u != null),
|
||||
// Take the userId and build the fullKey that we can now create
|
||||
map((userId) => [userId, userKeyBuilder(userId, this.keyDefinition)] as const),
|
||||
map((userId) => [userId, this.keyDefinition.buildKey(userId)] as const),
|
||||
),
|
||||
),
|
||||
// Filter to only storage updates that pertain to our key
|
||||
@@ -150,6 +152,11 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
|
||||
|
||||
const newState = configureState(currentState, combinedDependencies);
|
||||
await this.saveToStorage(key, newState);
|
||||
if (newState != null && currentState == null) {
|
||||
// Only register this state as something clearable on the first time it saves something
|
||||
// worth deleting. This is helpful in making sure there is less of a race to adding events.
|
||||
await this.stateEventRegistrarService.registerEvents(this.keyDefinition);
|
||||
}
|
||||
return [userId, newState];
|
||||
}
|
||||
|
||||
@@ -168,7 +175,7 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
|
||||
if (userId == null) {
|
||||
throw new Error("No active user at this time.");
|
||||
}
|
||||
const fullKey = userKeyBuilder(userId, this.keyDefinition);
|
||||
const fullKey = this.keyDefinition.buildKey(userId);
|
||||
return [
|
||||
userId,
|
||||
fullKey,
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import {
|
||||
AbstractMemoryStorageService,
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { GlobalState } from "../global-state";
|
||||
import { GlobalStateProvider } from "../global-state.provider";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
|
||||
import { DefaultGlobalState } from "./default-global-state";
|
||||
|
||||
export class DefaultGlobalStateProvider implements GlobalStateProvider {
|
||||
private globalStateCache: Record<string, GlobalState<unknown>> = {};
|
||||
|
||||
constructor(
|
||||
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
|
||||
protected readonly diskStorage: AbstractStorageService & ObservableStorageService,
|
||||
) {}
|
||||
constructor(private storageServiceProvider: StorageServiceProvider) {}
|
||||
|
||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
const cacheKey = this.buildCacheKey(keyDefinition);
|
||||
const [location, storageService] = this.storageServiceProvider.get(
|
||||
keyDefinition.stateDefinition.defaultStorageLocation,
|
||||
keyDefinition.stateDefinition.storageLocationOverrides,
|
||||
);
|
||||
const cacheKey = this.buildCacheKey(location, keyDefinition);
|
||||
const existingGlobalState = this.globalStateCache[cacheKey];
|
||||
if (existingGlobalState != null) {
|
||||
// The cast into the actual generic is safe because of rules around key definitions
|
||||
@@ -27,30 +23,13 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider {
|
||||
return existingGlobalState as DefaultGlobalState<T>;
|
||||
}
|
||||
|
||||
const newGlobalState = new DefaultGlobalState<T>(
|
||||
keyDefinition,
|
||||
this.getLocation(keyDefinition.stateDefinition),
|
||||
);
|
||||
const newGlobalState = new DefaultGlobalState<T>(keyDefinition, storageService);
|
||||
|
||||
this.globalStateCache[cacheKey] = newGlobalState;
|
||||
return newGlobalState;
|
||||
}
|
||||
|
||||
private buildCacheKey(keyDefinition: KeyDefinition<unknown>) {
|
||||
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}`;
|
||||
}
|
||||
|
||||
protected getLocationString(keyDefinition: KeyDefinition<unknown>): string {
|
||||
return keyDefinition.stateDefinition.defaultStorageLocation;
|
||||
}
|
||||
|
||||
protected getLocation(stateDefinition: StateDefinition) {
|
||||
const location = stateDefinition.defaultStorageLocation;
|
||||
switch (location) {
|
||||
case "disk":
|
||||
return this.diskStorage;
|
||||
case "memory":
|
||||
return this.memoryStorage;
|
||||
}
|
||||
private buildCacheKey(location: string, keyDefinition: KeyDefinition<unknown>) {
|
||||
return `${location}_${keyDefinition.fullName}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { UserId } from "../../../types/guid";
|
||||
import {
|
||||
AbstractMemoryStorageService,
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { UserKeyDefinition, isUserKeyDefinition } from "../user-key-definition";
|
||||
import { SingleUserState } from "../user-state";
|
||||
import { SingleUserStateProvider } from "../user-state.provider";
|
||||
|
||||
@@ -15,12 +12,22 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider {
|
||||
private cache: Record<string, SingleUserState<unknown>> = {};
|
||||
|
||||
constructor(
|
||||
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
|
||||
protected readonly diskStorage: AbstractStorageService & ObservableStorageService,
|
||||
private readonly storageServiceProvider: StorageServiceProvider,
|
||||
private readonly stateEventRegistrarService: StateEventRegistrarService,
|
||||
) {}
|
||||
|
||||
get<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T> {
|
||||
const cacheKey = this.buildCacheKey(userId, keyDefinition);
|
||||
get<T>(
|
||||
userId: UserId,
|
||||
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
|
||||
): SingleUserState<T> {
|
||||
if (!isUserKeyDefinition(keyDefinition)) {
|
||||
keyDefinition = UserKeyDefinition.fromBaseKeyDefinition(keyDefinition);
|
||||
}
|
||||
const [location, storageService] = this.storageServiceProvider.get(
|
||||
keyDefinition.stateDefinition.defaultStorageLocation,
|
||||
keyDefinition.stateDefinition.storageLocationOverrides,
|
||||
);
|
||||
const cacheKey = this.buildCacheKey(location, userId, keyDefinition);
|
||||
const existingUserState = this.cache[cacheKey];
|
||||
if (existingUserState != null) {
|
||||
// I have to cast out of the unknown generic but this should be safe if rules
|
||||
@@ -28,38 +35,21 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider {
|
||||
return existingUserState as SingleUserState<T>;
|
||||
}
|
||||
|
||||
const newUserState = this.buildSingleUserState(userId, keyDefinition);
|
||||
const newUserState = new DefaultSingleUserState<T>(
|
||||
userId,
|
||||
keyDefinition,
|
||||
storageService,
|
||||
this.stateEventRegistrarService,
|
||||
);
|
||||
this.cache[cacheKey] = newUserState;
|
||||
return newUserState;
|
||||
}
|
||||
|
||||
private buildCacheKey(userId: UserId, keyDefinition: KeyDefinition<unknown>) {
|
||||
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}_${userId}`;
|
||||
}
|
||||
|
||||
protected buildSingleUserState<T>(
|
||||
private buildCacheKey(
|
||||
location: string,
|
||||
userId: UserId,
|
||||
keyDefinition: KeyDefinition<T>,
|
||||
): SingleUserState<T> {
|
||||
return new DefaultSingleUserState<T>(
|
||||
userId,
|
||||
keyDefinition,
|
||||
this.getLocation(keyDefinition.stateDefinition),
|
||||
);
|
||||
}
|
||||
|
||||
protected getLocationString(keyDefinition: KeyDefinition<unknown>): string {
|
||||
return keyDefinition.stateDefinition.defaultStorageLocation;
|
||||
}
|
||||
|
||||
protected getLocation(stateDefinition: StateDefinition) {
|
||||
// The default implementations don't support the client overrides
|
||||
// it is up to the client to extend this class and add that support
|
||||
switch (stateDefinition.defaultStorageLocation) {
|
||||
case "disk":
|
||||
return this.diskStorage;
|
||||
case "memory":
|
||||
return this.memoryStorage;
|
||||
}
|
||||
keyDefinition: UserKeyDefinition<unknown>,
|
||||
) {
|
||||
return `${location}_${keyDefinition.fullName}_${userId}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* @jest-environment ../shared/test.environment.ts
|
||||
*/
|
||||
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
@@ -10,8 +11,9 @@ 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 { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
|
||||
import { DefaultSingleUserState } from "./default-single-user-state";
|
||||
|
||||
@@ -31,21 +33,28 @@ class TestState {
|
||||
|
||||
const testStateDefinition = new StateDefinition("fake", "disk");
|
||||
const cleanupDelayMs = 10;
|
||||
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
|
||||
const testKeyDefinition = new UserKeyDefinition<TestState>(testStateDefinition, "fake", {
|
||||
deserializer: TestState.fromJSON,
|
||||
cleanupDelayMs,
|
||||
clearOn: [],
|
||||
});
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
const userKey = userKeyBuilder(userId, testKeyDefinition);
|
||||
const userKey = testKeyDefinition.buildKey(userId);
|
||||
|
||||
describe("DefaultSingleUserState", () => {
|
||||
let diskStorageService: FakeStorageService;
|
||||
let userState: DefaultSingleUserState<TestState>;
|
||||
const stateEventRegistrarService = mock<StateEventRegistrarService>();
|
||||
const newData = { date: new Date() };
|
||||
|
||||
beforeEach(() => {
|
||||
diskStorageService = new FakeStorageService();
|
||||
userState = new DefaultSingleUserState(userId, testKeyDefinition, diskStorageService);
|
||||
userState = new DefaultSingleUserState(
|
||||
userId,
|
||||
testKeyDefinition,
|
||||
diskStorageService,
|
||||
stateEventRegistrarService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -254,6 +263,49 @@ describe("DefaultSingleUserState", () => {
|
||||
expect(emissions).toHaveLength(2);
|
||||
expect(emissions).toEqual(expect.arrayContaining([initialState, newState]));
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"should register user key definition when state transitions from null-ish (%s) to non-null",
|
||||
async (startingValue: TestState | null) => {
|
||||
const initialState: Record<string, TestState> = {};
|
||||
initialState[userKey] = startingValue;
|
||||
|
||||
diskStorageService.internalUpdateStore(initialState);
|
||||
|
||||
await userState.update(() => ({ array: ["one"], date: new Date() }));
|
||||
|
||||
expect(stateEventRegistrarService.registerEvents).toHaveBeenCalledWith(testKeyDefinition);
|
||||
},
|
||||
);
|
||||
|
||||
it("should not register user key definition when state has preexisting value", async () => {
|
||||
const initialState: Record<string, TestState> = {};
|
||||
initialState[userKey] = {
|
||||
date: new Date(2019, 1),
|
||||
};
|
||||
|
||||
diskStorageService.internalUpdateStore(initialState);
|
||||
|
||||
await userState.update(() => ({ array: ["one"], date: new Date() }));
|
||||
|
||||
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"should not register user key definition when setting value to null-ish (%s) value",
|
||||
async (updatedValue: TestState | null) => {
|
||||
const initialState: Record<string, TestState> = {};
|
||||
initialState[userKey] = {
|
||||
date: new Date(2019, 1),
|
||||
};
|
||||
|
||||
diskStorageService.internalUpdateStore(initialState);
|
||||
|
||||
await userState.update(() => updatedValue);
|
||||
|
||||
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("update races", () => {
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { KeyDefinition, userKeyBuilder } from "../key-definition";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
import { CombinedState, SingleUserState } from "../user-state";
|
||||
|
||||
import { getStoredValue } from "./util";
|
||||
@@ -33,10 +34,11 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
|
||||
|
||||
constructor(
|
||||
readonly userId: UserId,
|
||||
private keyDefinition: KeyDefinition<T>,
|
||||
private keyDefinition: UserKeyDefinition<T>,
|
||||
private chosenLocation: AbstractStorageService & ObservableStorageService,
|
||||
private stateEventRegistrarService: StateEventRegistrarService,
|
||||
) {
|
||||
this.storageKey = userKeyBuilder(this.userId, this.keyDefinition);
|
||||
this.storageKey = this.keyDefinition.buildKey(this.userId);
|
||||
const initialStorageGet$ = defer(() => {
|
||||
return getStoredValue(this.storageKey, this.chosenLocation, this.keyDefinition.deserializer);
|
||||
});
|
||||
@@ -100,6 +102,11 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
|
||||
|
||||
const newState = configureState(currentState, combinedDependencies);
|
||||
await this.chosenLocation.save(this.storageKey, newState);
|
||||
if (newState != null && currentState == null) {
|
||||
// Only register this state as something clearable on the first time it saves something
|
||||
// worth deleting. This is helpful in making sure there is less of a race to adding events.
|
||||
await this.stateEventRegistrarService.registerEvents(this.keyDefinition);
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DerivedStateProvider } from "../derived-state.provider";
|
||||
import { GlobalStateProvider } from "../global-state.provider";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
import { StateProvider } from "../state.provider";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider";
|
||||
|
||||
export class DefaultStateProvider implements StateProvider {
|
||||
@@ -21,7 +22,10 @@ export class DefaultStateProvider implements StateProvider {
|
||||
this.activeUserId$ = this.activeUserStateProvider.activeUserId$;
|
||||
}
|
||||
|
||||
getUserState$<T>(keyDefinition: KeyDefinition<T>, userId?: UserId): Observable<T> {
|
||||
getUserState$<T>(
|
||||
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
|
||||
userId?: UserId,
|
||||
): Observable<T> {
|
||||
if (userId) {
|
||||
return this.getUser<T>(userId, keyDefinition).state$;
|
||||
} else {
|
||||
@@ -33,7 +37,7 @@ export class DefaultStateProvider implements StateProvider {
|
||||
}
|
||||
|
||||
async setUserState<T>(
|
||||
keyDefinition: KeyDefinition<T>,
|
||||
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
|
||||
value: T,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T]> {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { mockAccountServiceWith } from "../../../../spec/fake-account-service";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
|
||||
import { DefaultActiveUserState } from "./default-active-user-state";
|
||||
import { DefaultActiveUserStateProvider } from "./default-active-user-state.provider";
|
||||
@@ -12,6 +16,9 @@ import { DefaultSingleUserState } from "./default-single-user-state";
|
||||
import { DefaultSingleUserStateProvider } from "./default-single-user-state.provider";
|
||||
|
||||
describe("Specific State Providers", () => {
|
||||
const storageServiceProvider = mock<StorageServiceProvider>();
|
||||
const stateEventRegistrarService = mock<StateEventRegistrarService>();
|
||||
|
||||
let singleSut: DefaultSingleUserStateProvider;
|
||||
let activeSut: DefaultActiveUserStateProvider;
|
||||
let globalSut: DefaultGlobalStateProvider;
|
||||
@@ -19,19 +26,20 @@ describe("Specific State Providers", () => {
|
||||
const fakeUser1 = "00000000-0000-1000-a000-000000000001" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
storageServiceProvider.get.mockImplementation((location) => {
|
||||
return [location, new FakeStorageService()];
|
||||
});
|
||||
|
||||
singleSut = new DefaultSingleUserStateProvider(
|
||||
new FakeStorageService() as any,
|
||||
new FakeStorageService(),
|
||||
storageServiceProvider,
|
||||
stateEventRegistrarService,
|
||||
);
|
||||
activeSut = new DefaultActiveUserStateProvider(
|
||||
mockAccountServiceWith(null),
|
||||
new FakeStorageService() as any,
|
||||
new FakeStorageService(),
|
||||
);
|
||||
globalSut = new DefaultGlobalStateProvider(
|
||||
new FakeStorageService() as any,
|
||||
new FakeStorageService(),
|
||||
storageServiceProvider,
|
||||
stateEventRegistrarService,
|
||||
);
|
||||
globalSut = new DefaultGlobalStateProvider(storageServiceProvider);
|
||||
});
|
||||
|
||||
const fakeDiskStateDefinition = new StateDefinition("fake", "disk");
|
||||
|
||||
@@ -4,8 +4,11 @@ export { DerivedState } from "./derived-state";
|
||||
export { GlobalState } from "./global-state";
|
||||
export { StateProvider } from "./state.provider";
|
||||
export { GlobalStateProvider } from "./global-state.provider";
|
||||
export { ActiveUserState, SingleUserState } from "./user-state";
|
||||
export { ActiveUserState, SingleUserState, CombinedState } from "./user-state";
|
||||
export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
|
||||
export { KeyDefinition } from "./key-definition";
|
||||
export { StateUpdateOptions } from "./state-update-options";
|
||||
export { UserKeyDefinition } from "./user-key-definition";
|
||||
export { StateEventRunnerService } from "./state-event-runner.service";
|
||||
|
||||
export * from "./state-definitions";
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { StorageKey } from "../../types/state";
|
||||
import { Utils } from "../misc/utils";
|
||||
|
||||
import { array, record } from "./deserialization-helpers";
|
||||
import { StateDefinition } from "./state-definition";
|
||||
|
||||
/**
|
||||
* A set of options for customizing the behavior of a {@link KeyDefinition}
|
||||
*/
|
||||
type KeyDefinitionOptions<T> = {
|
||||
export type KeyDefinitionOptions<T> = {
|
||||
/**
|
||||
* A function to use to safely convert your type from json to your expected type.
|
||||
*
|
||||
@@ -78,8 +77,7 @@ export class KeyDefinition<T> {
|
||||
* @param key The key to be added to the KeyDefinition
|
||||
* @param options The options to customize the final {@link KeyDefinition}.
|
||||
* @returns A {@link KeyDefinition} initialized for arrays, the options run
|
||||
* the deserializer on the provided options for each element of an array
|
||||
* **unless that array is null, in which case it will return an empty list.**
|
||||
* the deserializer on the provided options for each element of an array.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
@@ -96,12 +94,7 @@ export class KeyDefinition<T> {
|
||||
) {
|
||||
return new KeyDefinition<T[]>(stateDefinition, key, {
|
||||
...options,
|
||||
deserializer: (jsonValue) => {
|
||||
if (jsonValue == null) {
|
||||
return null;
|
||||
}
|
||||
return jsonValue.map((v) => options.deserializer(v));
|
||||
},
|
||||
deserializer: array((e) => options.deserializer(e)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,7 +104,7 @@ export class KeyDefinition<T> {
|
||||
* @param key The key to be added to the KeyDefinition
|
||||
* @param options The options to customize the final {@link KeyDefinition}.
|
||||
* @returns A {@link KeyDefinition} that contains a serializer that will run the provided deserializer for each
|
||||
* value in a record and returns every key as a string **unless that record is null, in which case it will return an record.**
|
||||
* value in a record and returns every key as a string.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
@@ -128,17 +121,7 @@ export class KeyDefinition<T> {
|
||||
) {
|
||||
return new KeyDefinition<Record<TKey, T>>(stateDefinition, key, {
|
||||
...options,
|
||||
deserializer: (jsonValue) => {
|
||||
if (jsonValue == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: Record<string, T> = {};
|
||||
for (const key in jsonValue) {
|
||||
output[key] = options.deserializer((jsonValue as Record<string, Jsonify<T>>)[key]);
|
||||
}
|
||||
return output;
|
||||
},
|
||||
deserializer: record((v) => options.deserializer(v)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,24 +129,11 @@ export class KeyDefinition<T> {
|
||||
return `${this.stateDefinition.name}_${this.key}`;
|
||||
}
|
||||
|
||||
private get errorKeyName() {
|
||||
protected get errorKeyName() {
|
||||
return `${this.stateDefinition.name} > ${this.key}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link StorageKey} that points to the data at the given key definition for the specified user.
|
||||
* @param userId The userId of the user you want the key to be for.
|
||||
* @param keyDefinition The key definition of which data the key should point to.
|
||||
* @returns A key that is ready to be used in a storage service to get data.
|
||||
*/
|
||||
export function userKeyBuilder(userId: UserId, keyDefinition: KeyDefinition<unknown>): StorageKey {
|
||||
if (!Utils.isGuid(userId)) {
|
||||
throw new Error("You cannot build a user key without a valid UserId");
|
||||
}
|
||||
return `user_${userId}_${keyDefinition.stateDefinition.name}_${keyDefinition.key}` as StorageKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link StorageKey}
|
||||
* @param keyDefinition The key definition of which data the key should point to.
|
||||
|
||||
@@ -17,50 +17,72 @@ import { StateDefinition } from "./state-definition";
|
||||
*
|
||||
*/
|
||||
|
||||
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
|
||||
|
||||
export const BILLING_BANNERS_DISK = new StateDefinition("billingBanners", "disk");
|
||||
|
||||
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
|
||||
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
|
||||
|
||||
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
|
||||
|
||||
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
|
||||
|
||||
export const VAULT_ONBOARDING = new StateDefinition("vaultOnboarding", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
|
||||
export const GENERATOR_DISK = new StateDefinition("generator", "disk");
|
||||
export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");
|
||||
|
||||
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
|
||||
|
||||
// Admin Console
|
||||
|
||||
export const ORGANIZATIONS_DISK = new StateDefinition("organizations", "disk");
|
||||
export const POLICIES_DISK = new StateDefinition("policies", "disk");
|
||||
export const POLICIES_MEMORY = new StateDefinition("policies", "memory");
|
||||
export const PROVIDERS_DISK = new StateDefinition("providers", "disk");
|
||||
|
||||
export const FOLDER_DISK = new StateDefinition("folder", "disk", { web: "memory" });
|
||||
// Auth
|
||||
|
||||
export const SYNC_STATE = new StateDefinition("sync", "disk", { web: "memory" });
|
||||
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
|
||||
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
|
||||
|
||||
export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
// Autofill
|
||||
|
||||
export const BADGE_SETTINGS_DISK = new StateDefinition("badgeSettings", "disk");
|
||||
export const USER_NOTIFICATION_SETTINGS_DISK = new StateDefinition(
|
||||
"userNotificationSettings",
|
||||
"disk",
|
||||
);
|
||||
|
||||
// Billing
|
||||
|
||||
export const COLLECTION_DATA = new StateDefinition("collection", "disk", {
|
||||
web: "memory",
|
||||
});
|
||||
export const AUTOFILL_SETTINGS_DISK = new StateDefinition("autofillSettings", "disk");
|
||||
export const AUTOFILL_SETTINGS_DISK_LOCAL = new StateDefinition("autofillSettingsLocal", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const BILLING_DISK = new StateDefinition("billing", "disk");
|
||||
|
||||
// Components
|
||||
|
||||
export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanner", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
|
||||
// Platform
|
||||
|
||||
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
|
||||
export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
|
||||
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
|
||||
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
|
||||
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
|
||||
|
||||
// Secrets Manager
|
||||
|
||||
export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
|
||||
// Tools
|
||||
|
||||
export const GENERATOR_DISK = new StateDefinition("generator", "disk");
|
||||
export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");
|
||||
|
||||
// Vault
|
||||
|
||||
export const COLLECTION_DATA = new StateDefinition("collection", "disk", {
|
||||
web: "memory",
|
||||
});
|
||||
export const FOLDER_DISK = new StateDefinition("folder", "disk", { web: "memory" });
|
||||
export const VAULT_FILTER_DISK = new StateDefinition("vaultFilter", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const VAULT_ONBOARDING = new StateDefinition("vaultOnboarding", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
|
||||
export const CIPHERS_DISK = new StateDefinition("localData", "disk", { web: "disk-local" });
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { FakeGlobalStateProvider } from "../../../spec";
|
||||
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
|
||||
import { StorageServiceProvider } from "../services/storage-service.provider";
|
||||
|
||||
import { StateDefinition } from "./state-definition";
|
||||
import { STATE_LOCK_EVENT, StateEventRegistrarService } from "./state-event-registrar.service";
|
||||
import { UserKeyDefinition } from "./user-key-definition";
|
||||
|
||||
describe("StateEventRegistrarService", () => {
|
||||
const globalStateProvider = new FakeGlobalStateProvider();
|
||||
const lockState = globalStateProvider.getFake(STATE_LOCK_EVENT);
|
||||
const storageServiceProvider = mock<StorageServiceProvider>();
|
||||
|
||||
const sut = new StateEventRegistrarService(globalStateProvider, storageServiceProvider);
|
||||
|
||||
describe("registerEvents", () => {
|
||||
const fakeKeyDefinition = new UserKeyDefinition<boolean>(
|
||||
new StateDefinition("fakeState", "disk"),
|
||||
"fakeKey",
|
||||
{
|
||||
deserializer: (s) => s,
|
||||
clearOn: ["lock"],
|
||||
},
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("adds event on null storage", async () => {
|
||||
storageServiceProvider.get.mockReturnValue([
|
||||
"disk",
|
||||
mock<AbstractStorageService & ObservableStorageService>(),
|
||||
]);
|
||||
|
||||
await sut.registerEvents(fakeKeyDefinition);
|
||||
|
||||
expect(lockState.nextMock).toHaveBeenCalledWith([
|
||||
{
|
||||
key: "fakeKey",
|
||||
location: "disk",
|
||||
state: "fakeState",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds event on empty array in storage", async () => {
|
||||
lockState.stateSubject.next([]);
|
||||
storageServiceProvider.get.mockReturnValue([
|
||||
"disk",
|
||||
mock<AbstractStorageService & ObservableStorageService>(),
|
||||
]);
|
||||
|
||||
await sut.registerEvents(fakeKeyDefinition);
|
||||
|
||||
expect(lockState.nextMock).toHaveBeenCalledWith([
|
||||
{
|
||||
key: "fakeKey",
|
||||
location: "disk",
|
||||
state: "fakeState",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("doesn't add a duplicate", async () => {
|
||||
lockState.stateSubject.next([
|
||||
{
|
||||
key: "fakeKey",
|
||||
location: "disk",
|
||||
state: "fakeState",
|
||||
},
|
||||
]);
|
||||
storageServiceProvider.get.mockReturnValue([
|
||||
"disk",
|
||||
mock<AbstractStorageService & ObservableStorageService>(),
|
||||
]);
|
||||
|
||||
await sut.registerEvents(fakeKeyDefinition);
|
||||
|
||||
expect(lockState.nextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { PossibleLocation, StorageServiceProvider } from "../services/storage-service.provider";
|
||||
|
||||
import { GlobalState } from "./global-state";
|
||||
import { GlobalStateProvider } from "./global-state.provider";
|
||||
import { KeyDefinition } from "./key-definition";
|
||||
import { CLEAR_EVENT_DISK } from "./state-definitions";
|
||||
import { ClearEvent, UserKeyDefinition } from "./user-key-definition";
|
||||
|
||||
export type StateEventInfo = {
|
||||
state: string;
|
||||
key: string;
|
||||
location: PossibleLocation;
|
||||
};
|
||||
|
||||
export const STATE_LOCK_EVENT = KeyDefinition.array<StateEventInfo>(CLEAR_EVENT_DISK, "lock", {
|
||||
deserializer: (e) => e,
|
||||
});
|
||||
|
||||
export const STATE_LOGOUT_EVENT = KeyDefinition.array<StateEventInfo>(CLEAR_EVENT_DISK, "logout", {
|
||||
deserializer: (e) => e,
|
||||
});
|
||||
|
||||
export class StateEventRegistrarService {
|
||||
private readonly stateEventStateMap: { [Prop in ClearEvent]: GlobalState<StateEventInfo[]> };
|
||||
|
||||
constructor(
|
||||
globalStateProvider: GlobalStateProvider,
|
||||
private storageServiceProvider: StorageServiceProvider,
|
||||
) {
|
||||
this.stateEventStateMap = {
|
||||
lock: globalStateProvider.get(STATE_LOCK_EVENT),
|
||||
logout: globalStateProvider.get(STATE_LOGOUT_EVENT),
|
||||
};
|
||||
}
|
||||
|
||||
async registerEvents(keyDefinition: UserKeyDefinition<unknown>) {
|
||||
for (const clearEvent of keyDefinition.clearOn) {
|
||||
const eventState = this.stateEventStateMap[clearEvent];
|
||||
// Determine the storage location for this
|
||||
const [storageLocation] = this.storageServiceProvider.get(
|
||||
keyDefinition.stateDefinition.defaultStorageLocation,
|
||||
keyDefinition.stateDefinition.storageLocationOverrides,
|
||||
);
|
||||
|
||||
const newEvent: StateEventInfo = {
|
||||
state: keyDefinition.stateDefinition.name,
|
||||
key: keyDefinition.key,
|
||||
location: storageLocation,
|
||||
};
|
||||
|
||||
// Only update the event state if the existing list doesn't have a matching entry
|
||||
await eventState.update(
|
||||
(existingTickets) => {
|
||||
existingTickets ??= [];
|
||||
existingTickets.push(newEvent);
|
||||
return existingTickets;
|
||||
},
|
||||
{
|
||||
shouldUpdate: (currentTickets) => {
|
||||
return (
|
||||
// If the current tickets are null, then it will for sure be added
|
||||
currentTickets == null ||
|
||||
// If an existing match couldn't be found, we also need to add one
|
||||
currentTickets.findIndex(
|
||||
(e) =>
|
||||
e.state === newEvent.state &&
|
||||
e.key === newEvent.key &&
|
||||
e.location === newEvent.location,
|
||||
) === -1
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { FakeGlobalStateProvider } from "../../../spec";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
|
||||
import { StorageServiceProvider } from "../services/storage-service.provider";
|
||||
|
||||
import { STATE_LOCK_EVENT } from "./state-event-registrar.service";
|
||||
import { StateEventRunnerService } from "./state-event-runner.service";
|
||||
|
||||
describe("EventRunnerService", () => {
|
||||
const fakeGlobalStateProvider = new FakeGlobalStateProvider();
|
||||
const lockState = fakeGlobalStateProvider.getFake(STATE_LOCK_EVENT);
|
||||
|
||||
const storageServiceProvider = mock<StorageServiceProvider>();
|
||||
|
||||
const sut = new StateEventRunnerService(fakeGlobalStateProvider, storageServiceProvider);
|
||||
|
||||
describe("handleEvent", () => {
|
||||
it("does nothing if there are no events in state", async () => {
|
||||
const mockStorageService = mock<AbstractStorageService & ObservableStorageService>();
|
||||
storageServiceProvider.get.mockReturnValue(["disk", mockStorageService]);
|
||||
|
||||
await sut.handleEvent("lock", "bff09d3c-762a-4551-9275-45b137b2f073" as UserId);
|
||||
|
||||
expect(lockState.nextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loops through and acts on all events", async () => {
|
||||
const mockDiskStorageService = mock<AbstractStorageService & ObservableStorageService>();
|
||||
const mockMemoryStorageService = mock<AbstractStorageService & ObservableStorageService>();
|
||||
|
||||
lockState.stateSubject.next([
|
||||
{
|
||||
state: "fakeState1",
|
||||
key: "fakeKey1",
|
||||
location: "disk",
|
||||
},
|
||||
{
|
||||
state: "fakeState2",
|
||||
key: "fakeKey2",
|
||||
location: "memory",
|
||||
},
|
||||
]);
|
||||
|
||||
storageServiceProvider.get.mockImplementation((defaultLocation, overrides) => {
|
||||
if (defaultLocation === "disk") {
|
||||
return [defaultLocation, mockDiskStorageService];
|
||||
} else if (defaultLocation === "memory") {
|
||||
return [defaultLocation, mockMemoryStorageService];
|
||||
}
|
||||
});
|
||||
|
||||
mockMemoryStorageService.get.mockResolvedValue("something");
|
||||
|
||||
await sut.handleEvent("lock", "bff09d3c-762a-4551-9275-45b137b2f073" as UserId);
|
||||
|
||||
expect(mockDiskStorageService.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockDiskStorageService.get).toHaveBeenCalledWith(
|
||||
"user_bff09d3c-762a-4551-9275-45b137b2f073_fakeState1_fakeKey1",
|
||||
);
|
||||
expect(mockMemoryStorageService.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockMemoryStorageService.get).toHaveBeenCalledWith(
|
||||
"user_bff09d3c-762a-4551-9275-45b137b2f073_fakeState2_fakeKey2",
|
||||
);
|
||||
expect(mockMemoryStorageService.remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
80
libs/common/src/platform/state/state-event-runner.service.ts
Normal file
80
libs/common/src/platform/state/state-event-runner.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { StorageServiceProvider } from "../services/storage-service.provider";
|
||||
|
||||
import { GlobalState } from "./global-state";
|
||||
import { GlobalStateProvider } from "./global-state.provider";
|
||||
import { StateDefinition, StorageLocation } from "./state-definition";
|
||||
import {
|
||||
STATE_LOCK_EVENT,
|
||||
STATE_LOGOUT_EVENT,
|
||||
StateEventInfo,
|
||||
} from "./state-event-registrar.service";
|
||||
import { ClearEvent, UserKeyDefinition } from "./user-key-definition";
|
||||
|
||||
export class StateEventRunnerService {
|
||||
private readonly stateEventMap: { [Prop in ClearEvent]: GlobalState<StateEventInfo[]> };
|
||||
|
||||
constructor(
|
||||
globalStateProvider: GlobalStateProvider,
|
||||
private storageServiceProvider: StorageServiceProvider,
|
||||
) {
|
||||
this.stateEventMap = {
|
||||
lock: globalStateProvider.get(STATE_LOCK_EVENT),
|
||||
logout: globalStateProvider.get(STATE_LOGOUT_EVENT),
|
||||
};
|
||||
}
|
||||
|
||||
async handleEvent(event: ClearEvent, userId: UserId) {
|
||||
let tickets = await firstValueFrom(this.stateEventMap[event].state$);
|
||||
tickets ??= [];
|
||||
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const ticket of tickets) {
|
||||
try {
|
||||
const [, service] = this.storageServiceProvider.get(
|
||||
ticket.location,
|
||||
{}, // The storage location is already the computed storage location for this client
|
||||
);
|
||||
|
||||
const ticketStorageKey = this.storageKeyFor(userId, ticket);
|
||||
|
||||
// Evaluate current value so we can avoid writing to state if we don't need to
|
||||
const currentValue = await service.get(ticketStorageKey);
|
||||
if (currentValue != null) {
|
||||
await service.remove(ticketStorageKey);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
let errorMessage = "Unknown Error";
|
||||
if (typeof err === "object" && "message" in err && typeof err.message === "string") {
|
||||
errorMessage = err.message;
|
||||
}
|
||||
|
||||
failures.push(
|
||||
`${errorMessage} in ${ticket.state} > ${ticket.key} located ${ticket.location}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
// Throw aggregated error
|
||||
throw new Error(
|
||||
`One or more errors occurred while handling event '${event}' for user ${userId}.\n${failures.join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private storageKeyFor(userId: UserId, ticket: StateEventInfo) {
|
||||
const userKey = new UserKeyDefinition<unknown>(
|
||||
new StateDefinition(ticket.state, ticket.location as unknown as StorageLocation),
|
||||
ticket.key,
|
||||
{
|
||||
deserializer: (v) => v,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
return userKey.buildKey(userId);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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 { UserKeyDefinition } from "./user-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";
|
||||
@@ -29,22 +30,72 @@ export abstract class StateProvider {
|
||||
* @param userId - The userId for which you want the state for. If not provided, the state for the currently active user will be returned.
|
||||
*/
|
||||
getUserState$: <T>(keyDefinition: KeyDefinition<T>, userId?: UserId) => Observable<T>;
|
||||
|
||||
/**
|
||||
* Sets the state for a given key and userId.
|
||||
*
|
||||
* @overload
|
||||
* @param keyDefinition - The key definition for the state you want to set.
|
||||
* @param value - The value to set the state to.
|
||||
* @param userId - The userId for which you want to set the state for. If not provided, the state for the currently active user will be set.
|
||||
*/
|
||||
setUserState: <T>(
|
||||
abstract setUserState<T>(
|
||||
keyDefinition: UserKeyDefinition<T>,
|
||||
value: T,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T]>;
|
||||
|
||||
/**
|
||||
* Sets the state for a given key and userId.
|
||||
*
|
||||
* **NOTE** Consider converting your {@link KeyDefinition} to a {@link UserKeyDefinition} for additional features.
|
||||
*
|
||||
* @overload
|
||||
* @param keyDefinition - The key definition for the state you want to set.
|
||||
* @param value - The value to set the state to.
|
||||
* @param userId - The userId for which you want to set the state for. If not provided, the state for the currently active user will be set.
|
||||
*/
|
||||
abstract setUserState<T>(
|
||||
keyDefinition: KeyDefinition<T>,
|
||||
value: T,
|
||||
userId?: UserId,
|
||||
) => Promise<[UserId, T]>;
|
||||
): Promise<[UserId, T]>;
|
||||
|
||||
abstract setUserState<T>(
|
||||
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
|
||||
value: T,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T]>;
|
||||
|
||||
/** @see{@link ActiveUserStateProvider.get} */
|
||||
getActive: <T>(keyDefinition: KeyDefinition<T>) => ActiveUserState<T>;
|
||||
abstract getActive<T>(keyDefinition: UserKeyDefinition<T>): ActiveUserState<T>;
|
||||
|
||||
/**
|
||||
* @see{@link ActiveUserStateProvider.get}
|
||||
*
|
||||
* **NOTE** Consider converting your {@link KeyDefinition} to a {@link UserKeyDefinition} for additional features.
|
||||
*/
|
||||
abstract getActive<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T>;
|
||||
|
||||
/** @see{@link ActiveUserStateProvider.get} */
|
||||
abstract getActive<T>(keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>): ActiveUserState<T>;
|
||||
|
||||
/** @see{@link SingleUserStateProvider.get} */
|
||||
getUser: <T>(userId: UserId, keyDefinition: KeyDefinition<T>) => SingleUserState<T>;
|
||||
abstract getUser<T>(userId: UserId, keyDefinition: UserKeyDefinition<T>): SingleUserState<T>;
|
||||
|
||||
/**
|
||||
* @see{@link SingleUserStateProvider.get}
|
||||
*
|
||||
* **NOTE** Consider converting your {@link KeyDefinition} to a {@link UserKeyDefinition} for additional features.
|
||||
*/
|
||||
abstract getUser<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T>;
|
||||
|
||||
/** @see{@link SingleUserStateProvider.get} */
|
||||
abstract getUser<T>(
|
||||
userId: UserId,
|
||||
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
|
||||
): SingleUserState<T>;
|
||||
|
||||
/** @see{@link GlobalStateProvider.get} */
|
||||
getGlobal: <T>(keyDefinition: KeyDefinition<T>) => GlobalState<T>;
|
||||
getDerived: <TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
|
||||
149
libs/common/src/platform/state/user-key-definition.ts
Normal file
149
libs/common/src/platform/state/user-key-definition.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { UserId } from "../../types/guid";
|
||||
import { StorageKey } from "../../types/state";
|
||||
import { Utils } from "../misc/utils";
|
||||
|
||||
import { array, record } from "./deserialization-helpers";
|
||||
import { KeyDefinition, KeyDefinitionOptions } from "./key-definition";
|
||||
import { StateDefinition } from "./state-definition";
|
||||
|
||||
export type ClearEvent = "lock" | "logout";
|
||||
|
||||
type UserKeyDefinitionOptions<T> = KeyDefinitionOptions<T> & {
|
||||
clearOn: ClearEvent[];
|
||||
};
|
||||
|
||||
const USER_KEY_DEFINITION_MARKER: unique symbol = Symbol("UserKeyDefinition");
|
||||
|
||||
export function isUserKeyDefinition<T>(
|
||||
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
|
||||
): keyDefinition is UserKeyDefinition<T> {
|
||||
return (
|
||||
USER_KEY_DEFINITION_MARKER in keyDefinition &&
|
||||
keyDefinition[USER_KEY_DEFINITION_MARKER] === true
|
||||
);
|
||||
}
|
||||
|
||||
export class UserKeyDefinition<T> {
|
||||
readonly [USER_KEY_DEFINITION_MARKER] = true;
|
||||
/**
|
||||
* A unique array of events that the state stored at this key should be cleared on.
|
||||
*/
|
||||
readonly clearOn: ClearEvent[];
|
||||
|
||||
constructor(
|
||||
readonly stateDefinition: StateDefinition,
|
||||
readonly key: string,
|
||||
private readonly options: UserKeyDefinitionOptions<T>,
|
||||
) {
|
||||
if (options.deserializer == null) {
|
||||
throw new Error(`'deserializer' is a required property on key ${this.errorKeyName}`);
|
||||
}
|
||||
|
||||
if (options.cleanupDelayMs <= 0) {
|
||||
throw new Error(
|
||||
`'cleanupDelayMs' must be greater than 0. Value of ${options.cleanupDelayMs} passed to key ${this.errorKeyName} `,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out repeat values
|
||||
this.clearOn = Array.from(new Set(options.clearOn));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the deserializer configured for this {@link KeyDefinition}
|
||||
*/
|
||||
get deserializer() {
|
||||
return this.options.deserializer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||
*/
|
||||
get cleanupDelayMs() {
|
||||
return this.options.cleanupDelayMs < 0 ? 0 : this.options.cleanupDelayMs ?? 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param keyDefinition
|
||||
* @returns
|
||||
*
|
||||
* @deprecated You should not use this to convert, just create a {@link UserKeyDefinition}
|
||||
*/
|
||||
static fromBaseKeyDefinition<T>(keyDefinition: KeyDefinition<T>) {
|
||||
return new UserKeyDefinition<T>(keyDefinition.stateDefinition, keyDefinition.key, {
|
||||
...keyDefinition["options"],
|
||||
clearOn: [], // Default to not clearing
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link UserKeyDefinition} for state that is an array.
|
||||
* @param stateDefinition The state definition to be added to the UserKeyDefinition
|
||||
* @param key The key to be added to the KeyDefinition
|
||||
* @param options The options to customize the final {@link UserKeyDefinition}.
|
||||
* @returns A {@link UserKeyDefinition} initialized for arrays, the options run
|
||||
* the deserializer on the provided options for each element of an array
|
||||
* **unless that array is null, in which case it will return an empty list.**
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const MY_KEY = UserKeyDefinition.array<MyArrayElement>(MY_STATE, "key", {
|
||||
* deserializer: (myJsonElement) => convertToElement(myJsonElement),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
static array<T>(
|
||||
stateDefinition: StateDefinition,
|
||||
key: string,
|
||||
// We have them provide options for the element of the array, depending on future options we add, this could get a little weird.
|
||||
options: UserKeyDefinitionOptions<T>,
|
||||
) {
|
||||
return new UserKeyDefinition<T[]>(stateDefinition, key, {
|
||||
...options,
|
||||
deserializer: array((e) => options.deserializer(e)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link UserKeyDefinition} for state that is a record.
|
||||
* @param stateDefinition The state definition to be added to the UserKeyDefinition
|
||||
* @param key The key to be added to the KeyDefinition
|
||||
* @param options The options to customize the final {@link UserKeyDefinition}.
|
||||
* @returns A {@link UserKeyDefinition} that contains a serializer that will run the provided deserializer for each
|
||||
* value in a record and returns every key as a string **unless that record is null, in which case it will return an record.**
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const MY_KEY = UserKeyDefinition.record<MyRecordValue>(MY_STATE, "key", {
|
||||
* deserializer: (myJsonValue) => convertToValue(myJsonValue),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
static record<T, TKey extends string = string>(
|
||||
stateDefinition: StateDefinition,
|
||||
key: string,
|
||||
// We have them provide options for the value of the record, depending on future options we add, this could get a little weird.
|
||||
options: UserKeyDefinitionOptions<T>, // The array helper forces an initialValue of an empty record
|
||||
) {
|
||||
return new UserKeyDefinition<Record<TKey, T>>(stateDefinition, key, {
|
||||
...options,
|
||||
deserializer: record((v) => options.deserializer(v)),
|
||||
});
|
||||
}
|
||||
|
||||
get fullName() {
|
||||
return `${this.stateDefinition.name}_${this.key}`;
|
||||
}
|
||||
|
||||
buildKey(userId: UserId) {
|
||||
if (!Utils.isGuid(userId)) {
|
||||
throw new Error("You cannot build a user key without a valid UserId");
|
||||
}
|
||||
return `user_${userId}_${this.stateDefinition.name}_${this.key}` as StorageKey;
|
||||
}
|
||||
|
||||
private get errorKeyName() {
|
||||
return `${this.stateDefinition.name} > ${this.key}`;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Observable } from "rxjs";
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
import { KeyDefinition } from "./key-definition";
|
||||
import { UserKeyDefinition } from "./user-key-definition";
|
||||
import { ActiveUserState, SingleUserState } from "./user-state";
|
||||
|
||||
/** A provider for getting an implementation of state scoped to a given key and userId */
|
||||
@@ -10,10 +11,25 @@ export abstract class SingleUserStateProvider {
|
||||
/**
|
||||
* Gets a {@link SingleUserState} scoped to the given {@link KeyDefinition} and {@link UserId}
|
||||
*
|
||||
* **NOTE** Consider converting your {@link KeyDefinition} to a {@link UserKeyDefinition} for additional features.
|
||||
*
|
||||
* @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>(userId: UserId, keyDefinition: KeyDefinition<T>) => SingleUserState<T>;
|
||||
abstract get<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T>;
|
||||
|
||||
/**
|
||||
* Gets a {@link SingleUserState} scoped to the given {@link UserKeyDefinition} and {@link UserId}
|
||||
*
|
||||
* @param userId - The {@link UserId} for which you want the user state for.
|
||||
* @param userKeyDefinition - The {@link UserKeyDefinition} for which you want the user state for.
|
||||
*/
|
||||
abstract get<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T>;
|
||||
|
||||
abstract get<T>(
|
||||
userId: UserId,
|
||||
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
|
||||
): SingleUserState<T>;
|
||||
}
|
||||
|
||||
/** A provider for getting an implementation of state scoped to a given key, but always pointing
|
||||
@@ -24,11 +40,24 @@ export abstract class ActiveUserStateProvider {
|
||||
* Convenience re-emission of active user ID from {@link AccountService.activeAccount$}
|
||||
*/
|
||||
activeUserId$: Observable<UserId | undefined>;
|
||||
|
||||
/**
|
||||
* 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 UserKeyDefinition} for which you want the user state for.
|
||||
*/
|
||||
abstract get<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* **NOTE** Consider converting your {@link KeyDefinition} to a {@link UserKeyDefinition} for additional features.
|
||||
*
|
||||
* @param keyDefinition - The {@link KeyDefinition} for which you want the user state for.
|
||||
*/
|
||||
get: <T>(keyDefinition: KeyDefinition<T>) => ActiveUserState<T>;
|
||||
abstract get<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T>;
|
||||
|
||||
abstract get<T>(keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>): ActiveUserState<T>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user