mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
refactor: introduce @bitwarden/state and other common libs (#15772)
* refactor: introduce @bitwarden/serialization * refactor: introduce @bitwarden/guid * refactor: introduce @bitwaren/client-type * refactor: introduce @bitwarden/core-test-utils * refactor: introduce @bitwarden/state and @bitwarden/state-test-utils Creates initial project structure for centralized application state management. Part of modularization effort to extract state code from common. * Added state provider documentation to README. * Changed callouts to Github format. * Fixed linting on file name. * Forced git to accept rename --------- Co-authored-by: Todd Martin <tmartin@bitwarden.com>
This commit is contained in:
6
.github/CODEOWNERS
vendored
6
.github/CODEOWNERS
vendored
@@ -96,6 +96,12 @@ libs/logging @bitwarden/team-platform-dev
|
||||
libs/storage-test-utils @bitwarden/team-platform-dev
|
||||
libs/messaging @bitwarden/team-platform-dev
|
||||
libs/messaging-internal @bitwarden/team-platform-dev
|
||||
libs/serialization @bitwarden/team-platform-dev
|
||||
libs/guid @bitwarden/team-platform-dev
|
||||
libs/client-type @bitwarden/team-platform-dev
|
||||
libs/core-test-utils @bitwarden/team-platform-dev
|
||||
libs/state @bitwarden/team-platform-dev
|
||||
libs/state-test-utils @bitwarden/team-platform-dev
|
||||
# Web utils used across app and connectors
|
||||
apps/web/src/utils/ @bitwarden/team-platform-dev
|
||||
# Web core and shared files
|
||||
|
||||
5
libs/client-type/README.md
Normal file
5
libs/client-type/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# client-type
|
||||
|
||||
Owned by: platform
|
||||
|
||||
Exports the ClientType enum
|
||||
3
libs/client-type/eslint.config.mjs
Normal file
3
libs/client-type/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
10
libs/client-type/jest.config.js
Normal file
10
libs/client-type/jest.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
displayName: "client-type",
|
||||
preset: "../../jest.preset.js",
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "html"],
|
||||
coverageDirectory: "../../coverage/libs/client-type",
|
||||
};
|
||||
11
libs/client-type/package.json
Normal file
11
libs/client-type/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@bitwarden/client-type",
|
||||
"version": "0.0.1",
|
||||
"description": "Exports the ClientType enum",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "GPL-3.0",
|
||||
"author": "platform"
|
||||
}
|
||||
33
libs/client-type/project.json
Normal file
33
libs/client-type/project.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "client-type",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/client-type/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/client-type",
|
||||
"main": "libs/client-type/src/index.ts",
|
||||
"tsConfig": "libs/client-type/tsconfig.lib.json",
|
||||
"assets": ["libs/client-type/*.md"]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/client-type/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/client-type/jest.config.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
libs/client-type/src/client-type.spec.ts
Normal file
8
libs/client-type/src/client-type.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as lib from "./index";
|
||||
|
||||
describe("client-type", () => {
|
||||
// This test will fail until something is exported from index.ts
|
||||
it("should work", () => {
|
||||
expect(lib).toBeDefined();
|
||||
});
|
||||
});
|
||||
10
libs/client-type/src/index.ts
Normal file
10
libs/client-type/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum ClientType {
|
||||
Web = "web",
|
||||
Browser = "browser",
|
||||
Desktop = "desktop",
|
||||
// Mobile = "mobile",
|
||||
Cli = "cli",
|
||||
// DirectoryConnector = "connector",
|
||||
}
|
||||
6
libs/client-type/tsconfig.eslint.json
Normal file
6
libs/client-type/tsconfig.eslint.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": ["src/**/*.ts", "src/**/*.js"],
|
||||
"exclude": ["**/build", "**/dist"]
|
||||
}
|
||||
13
libs/client-type/tsconfig.json
Normal file
13
libs/client-type/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/client-type/tsconfig.lib.json
Normal file
10
libs/client-type/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
|
||||
}
|
||||
10
libs/client-type/tsconfig.spec.json
Normal file
10
libs/client-type/tsconfig.spec.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -1,342 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, map, Observable, of, switchMap, take } from "rxjs";
|
||||
|
||||
import {
|
||||
GlobalState,
|
||||
GlobalStateProvider,
|
||||
KeyDefinition,
|
||||
ActiveUserState,
|
||||
SingleUserState,
|
||||
SingleUserStateProvider,
|
||||
StateProvider,
|
||||
ActiveUserStateProvider,
|
||||
DerivedState,
|
||||
DeriveDefinition,
|
||||
DerivedStateProvider,
|
||||
UserKeyDefinition,
|
||||
ActiveUserAccessor,
|
||||
} from "../src/platform/state";
|
||||
import { UserId } from "../src/types/guid";
|
||||
import { DerivedStateDependencies } from "../src/types/state";
|
||||
|
||||
import {
|
||||
FakeActiveUserState,
|
||||
FakeDerivedState,
|
||||
FakeGlobalState,
|
||||
FakeSingleUserState,
|
||||
} from "./fake-state";
|
||||
|
||||
export interface MinimalAccountService {
|
||||
activeUserId: UserId | null;
|
||||
activeAccount$: Observable<{ id: UserId } | null>;
|
||||
}
|
||||
|
||||
export class FakeActiveUserAccessor implements MinimalAccountService, ActiveUserAccessor {
|
||||
private _subject: BehaviorSubject<UserId | null>;
|
||||
|
||||
constructor(startingUser: UserId | null) {
|
||||
this._subject = new BehaviorSubject(startingUser);
|
||||
this.activeAccount$ = this._subject
|
||||
.asObservable()
|
||||
.pipe(map((id) => (id != null ? { id } : null)));
|
||||
this.activeUserId$ = this._subject.asObservable();
|
||||
}
|
||||
|
||||
get activeUserId(): UserId {
|
||||
return this._subject.value;
|
||||
}
|
||||
|
||||
activeUserId$: Observable<UserId>;
|
||||
|
||||
activeAccount$: Observable<{ id: UserId }>;
|
||||
|
||||
switch(user: UserId | null) {
|
||||
this._subject.next(user);
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeGlobalStateProvider implements GlobalStateProvider {
|
||||
mock = mock<GlobalStateProvider>();
|
||||
establishedMocks: Map<string, FakeGlobalState<unknown>> = new Map();
|
||||
states: Map<string, GlobalState<unknown>> = new Map();
|
||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
this.mock.get(keyDefinition);
|
||||
const cacheKey = this.cacheKey(keyDefinition);
|
||||
let result = this.states.get(cacheKey);
|
||||
|
||||
if (result == null) {
|
||||
let fake: FakeGlobalState<T>;
|
||||
// Look for established mock
|
||||
if (this.establishedMocks.has(keyDefinition.key)) {
|
||||
fake = this.establishedMocks.get(keyDefinition.key) as FakeGlobalState<T>;
|
||||
} else {
|
||||
fake = new FakeGlobalState<T>();
|
||||
}
|
||||
fake.keyDefinition = keyDefinition;
|
||||
result = fake;
|
||||
this.states.set(cacheKey, result);
|
||||
|
||||
result = new FakeGlobalState<T>();
|
||||
this.states.set(cacheKey, result);
|
||||
}
|
||||
return result as GlobalState<T>;
|
||||
}
|
||||
|
||||
private cacheKey(keyDefinition: KeyDefinition<unknown>) {
|
||||
return `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`;
|
||||
}
|
||||
|
||||
getFake<T>(keyDefinition: KeyDefinition<T>): FakeGlobalState<T> {
|
||||
return this.get(keyDefinition) as FakeGlobalState<T>;
|
||||
}
|
||||
|
||||
mockFor<T>(keyDefinition: KeyDefinition<T>, initialValue?: T): FakeGlobalState<T> {
|
||||
const cacheKey = this.cacheKey(keyDefinition);
|
||||
if (!this.states.has(cacheKey)) {
|
||||
this.states.set(cacheKey, new FakeGlobalState<T>(initialValue));
|
||||
}
|
||||
return this.states.get(cacheKey) as FakeGlobalState<T>;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeSingleUserStateProvider implements SingleUserStateProvider {
|
||||
mock = mock<SingleUserStateProvider>();
|
||||
states: Map<string, SingleUserState<unknown>> = new Map();
|
||||
|
||||
constructor(
|
||||
readonly updateSyncCallback?: (
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newValue: unknown,
|
||||
) => Promise<void>,
|
||||
) {}
|
||||
|
||||
get<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T> {
|
||||
this.mock.get(userId, userKeyDefinition);
|
||||
const cacheKey = this.cacheKey(userId, userKeyDefinition);
|
||||
let result = this.states.get(cacheKey);
|
||||
|
||||
if (result == null) {
|
||||
result = this.buildFakeState(userId, userKeyDefinition);
|
||||
this.states.set(cacheKey, result);
|
||||
}
|
||||
return result as SingleUserState<T>;
|
||||
}
|
||||
|
||||
getFake<T>(
|
||||
userId: UserId,
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
{ allowInit }: { allowInit: boolean } = { allowInit: true },
|
||||
): FakeSingleUserState<T> {
|
||||
if (!allowInit && this.states.get(this.cacheKey(userId, userKeyDefinition)) == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.get(userId, userKeyDefinition) as FakeSingleUserState<T>;
|
||||
}
|
||||
|
||||
mockFor<T>(
|
||||
userId: UserId,
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
initialValue?: T,
|
||||
): FakeSingleUserState<T> {
|
||||
const cacheKey = this.cacheKey(userId, userKeyDefinition);
|
||||
if (!this.states.has(cacheKey)) {
|
||||
this.states.set(cacheKey, this.buildFakeState(userId, userKeyDefinition, initialValue));
|
||||
}
|
||||
return this.states.get(cacheKey) as FakeSingleUserState<T>;
|
||||
}
|
||||
|
||||
private buildFakeState<T>(
|
||||
userId: UserId,
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
initialValue?: T,
|
||||
) {
|
||||
const state = new FakeSingleUserState(userId, initialValue, async (...args) => {
|
||||
await this.updateSyncCallback?.(userKeyDefinition, ...args);
|
||||
});
|
||||
state.keyDefinition = userKeyDefinition;
|
||||
return state;
|
||||
}
|
||||
|
||||
private cacheKey(userId: UserId, userKeyDefinition: UserKeyDefinition<unknown>) {
|
||||
return `${userKeyDefinitionCacheKey(userKeyDefinition)}_${userId}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
|
||||
activeUserId$: Observable<UserId | null>;
|
||||
states: Map<string, FakeActiveUserState<unknown>> = new Map();
|
||||
|
||||
constructor(
|
||||
public accountServiceAccessor: MinimalAccountService,
|
||||
readonly updateSyncCallback?: (
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newValue: unknown,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
this.activeUserId$ = accountServiceAccessor.activeAccount$.pipe(map((a) => a?.id));
|
||||
}
|
||||
|
||||
get<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T> {
|
||||
const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition);
|
||||
let result = this.states.get(cacheKey);
|
||||
|
||||
if (result == null) {
|
||||
result = this.buildFakeState(userKeyDefinition);
|
||||
this.states.set(cacheKey, result);
|
||||
}
|
||||
return result as ActiveUserState<T>;
|
||||
}
|
||||
|
||||
getFake<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
{ allowInit }: { allowInit: boolean } = { allowInit: true },
|
||||
): FakeActiveUserState<T> {
|
||||
if (!allowInit && this.states.get(userKeyDefinitionCacheKey(userKeyDefinition)) == null) {
|
||||
return null;
|
||||
}
|
||||
return this.get(userKeyDefinition) as FakeActiveUserState<T>;
|
||||
}
|
||||
|
||||
mockFor<T>(userKeyDefinition: UserKeyDefinition<T>, initialValue?: T): FakeActiveUserState<T> {
|
||||
const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition);
|
||||
if (!this.states.has(cacheKey)) {
|
||||
this.states.set(cacheKey, this.buildFakeState(userKeyDefinition, initialValue));
|
||||
}
|
||||
return this.states.get(cacheKey) as FakeActiveUserState<T>;
|
||||
}
|
||||
|
||||
private buildFakeState<T>(userKeyDefinition: UserKeyDefinition<T>, initialValue?: T) {
|
||||
const state = new FakeActiveUserState<T>(
|
||||
this.accountServiceAccessor,
|
||||
initialValue,
|
||||
async (...args) => {
|
||||
await this.updateSyncCallback?.(userKeyDefinition, ...args);
|
||||
},
|
||||
);
|
||||
state.keyDefinition = userKeyDefinition;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function userKeyDefinitionCacheKey(userKeyDefinition: UserKeyDefinition<unknown>) {
|
||||
return `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}`;
|
||||
}
|
||||
|
||||
export class FakeStateProvider implements StateProvider {
|
||||
mock = mock<StateProvider>();
|
||||
getUserState$<T>(userKeyDefinition: UserKeyDefinition<T>, userId?: UserId): Observable<T> {
|
||||
this.mock.getUserState$(userKeyDefinition, userId);
|
||||
if (userId) {
|
||||
return this.getUser<T>(userId, userKeyDefinition).state$;
|
||||
}
|
||||
|
||||
return this.getActive(userKeyDefinition).state$;
|
||||
}
|
||||
|
||||
getUserStateOrDefault$<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
config: { userId: UserId | undefined; defaultValue?: T },
|
||||
): Observable<T> {
|
||||
const { userId, defaultValue = null } = config;
|
||||
this.mock.getUserStateOrDefault$(userKeyDefinition, config);
|
||||
if (userId) {
|
||||
return this.getUser<T>(userId, userKeyDefinition).state$;
|
||||
}
|
||||
|
||||
return this.activeUserId$.pipe(
|
||||
take(1),
|
||||
switchMap((userId) =>
|
||||
userId != null ? this.getUser(userId, userKeyDefinition).state$ : of(defaultValue),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async setUserState<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
value: T | null,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T | null]> {
|
||||
await this.mock.setUserState(userKeyDefinition, value, userId);
|
||||
if (userId) {
|
||||
return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)];
|
||||
} else {
|
||||
return await this.getActive(userKeyDefinition).update(() => value);
|
||||
}
|
||||
}
|
||||
|
||||
getActive<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T> {
|
||||
return this.activeUser.get(userKeyDefinition);
|
||||
}
|
||||
|
||||
getGlobal<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
return this.global.get(keyDefinition);
|
||||
}
|
||||
|
||||
getUser<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T> {
|
||||
return this.singleUser.get(userId, userKeyDefinition);
|
||||
}
|
||||
|
||||
getDerived<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<unknown, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
return this.derived.get(parentState$, deriveDefinition, dependencies);
|
||||
}
|
||||
|
||||
constructor(private activeAccountAccessor: MinimalAccountService) {}
|
||||
|
||||
private distributeSingleUserUpdate(
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newState: unknown,
|
||||
) {
|
||||
if (this.activeUser.accountServiceAccessor.activeUserId === userId) {
|
||||
const state = this.activeUser.getFake(key, { allowInit: false });
|
||||
state?.nextState(newState, { syncValue: false });
|
||||
}
|
||||
}
|
||||
|
||||
private distributeActiveUserUpdate(
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newState: unknown,
|
||||
) {
|
||||
this.singleUser
|
||||
.getFake(userId, key, { allowInit: false })
|
||||
?.nextState(newState, { syncValue: false });
|
||||
}
|
||||
|
||||
global: FakeGlobalStateProvider = new FakeGlobalStateProvider();
|
||||
singleUser: FakeSingleUserStateProvider = new FakeSingleUserStateProvider(
|
||||
this.distributeSingleUserUpdate.bind(this),
|
||||
);
|
||||
activeUser: FakeActiveUserStateProvider = new FakeActiveUserStateProvider(
|
||||
this.activeAccountAccessor,
|
||||
this.distributeActiveUserUpdate.bind(this),
|
||||
);
|
||||
derived: FakeDerivedStateProvider = new FakeDerivedStateProvider();
|
||||
activeUserId$: Observable<UserId> = this.activeUser.activeUserId$;
|
||||
}
|
||||
|
||||
export class FakeDerivedStateProvider implements DerivedStateProvider {
|
||||
states: Map<string, DerivedState<unknown>> = new Map();
|
||||
get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState<TTo>;
|
||||
|
||||
if (result == null) {
|
||||
result = new FakeDerivedState(parentState$, deriveDefinition, dependencies);
|
||||
this.states.set(deriveDefinition.buildCacheKey(), result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
export {
|
||||
MinimalAccountService,
|
||||
FakeActiveUserAccessor,
|
||||
FakeGlobalStateProvider,
|
||||
FakeSingleUserStateProvider,
|
||||
FakeActiveUserStateProvider,
|
||||
FakeStateProvider,
|
||||
FakeDerivedStateProvider,
|
||||
} from "@bitwarden/state-test-utils";
|
||||
|
||||
@@ -1,275 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable, ReplaySubject, concatMap, filter, firstValueFrom, map, timeout } from "rxjs";
|
||||
|
||||
import {
|
||||
DerivedState,
|
||||
GlobalState,
|
||||
SingleUserState,
|
||||
ActiveUserState,
|
||||
KeyDefinition,
|
||||
DeriveDefinition,
|
||||
UserKeyDefinition,
|
||||
} from "../src/platform/state";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- using unexposed options for clean typing in test class
|
||||
import { StateUpdateOptions } from "../src/platform/state/state-update-options";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- using unexposed options for clean typing in test class
|
||||
import { CombinedState, activeMarker } from "../src/platform/state/user-state";
|
||||
import { UserId } from "../src/types/guid";
|
||||
import { DerivedStateDependencies } from "../src/types/state";
|
||||
|
||||
import { MinimalAccountService } from "./fake-state-provider";
|
||||
|
||||
const DEFAULT_TEST_OPTIONS: StateUpdateOptions<any, any> = {
|
||||
shouldUpdate: () => true,
|
||||
combineLatestWith: null,
|
||||
msTimeout: 10,
|
||||
};
|
||||
|
||||
function populateOptionsWithDefault(
|
||||
options: StateUpdateOptions<any, any>,
|
||||
): StateUpdateOptions<any, any> {
|
||||
return {
|
||||
...DEFAULT_TEST_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
export class FakeGlobalState<T> implements GlobalState<T> {
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<T>(1);
|
||||
|
||||
constructor(initialValue?: T) {
|
||||
this.stateSubject.next(initialValue ?? null);
|
||||
}
|
||||
|
||||
nextState(state: T) {
|
||||
this.stateSubject.next(state);
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<T> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
if (this.stateSubject["_buffer"].length == 0) {
|
||||
// throw a more helpful not initialized error
|
||||
throw new Error(
|
||||
"You must initialize the state with a value before calling update. Try calling `stateSubject.next(initialState)` before calling update",
|
||||
);
|
||||
}
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(100)));
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
: null;
|
||||
if (!options.shouldUpdate(current, combinedDependencies)) {
|
||||
return current;
|
||||
}
|
||||
const newState = configureState(current, combinedDependencies);
|
||||
this.stateSubject.next(newState);
|
||||
this.nextMock(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [T]>();
|
||||
|
||||
get state$() {
|
||||
return this.stateSubject.asObservable();
|
||||
}
|
||||
|
||||
private _keyDefinition: KeyDefinition<T> | null = null;
|
||||
get keyDefinition() {
|
||||
if (this._keyDefinition == null) {
|
||||
throw new Error(
|
||||
"Key definition not yet set, usually this means your sut has not asked for this state yet",
|
||||
);
|
||||
}
|
||||
return this._keyDefinition;
|
||||
}
|
||||
set keyDefinition(value: KeyDefinition<T>) {
|
||||
this._keyDefinition = value;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeSingleUserState<T> implements SingleUserState<T> {
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<{
|
||||
syncValue: boolean;
|
||||
combinedState: CombinedState<T>;
|
||||
}>(1);
|
||||
|
||||
state$: Observable<T>;
|
||||
combinedState$: Observable<CombinedState<T>>;
|
||||
|
||||
constructor(
|
||||
readonly userId: UserId,
|
||||
initialValue?: T,
|
||||
updateSyncCallback?: (userId: UserId, newValue: T) => Promise<void>,
|
||||
) {
|
||||
// Inform the state provider of updates to keep active user states in sync
|
||||
this.stateSubject
|
||||
.pipe(
|
||||
filter((next) => next.syncValue),
|
||||
concatMap(async ({ combinedState }) => {
|
||||
await updateSyncCallback?.(...combinedState);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
this.nextState(initialValue ?? null, { syncValue: initialValue != null });
|
||||
|
||||
this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState));
|
||||
this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state));
|
||||
}
|
||||
|
||||
nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
|
||||
this.stateSubject.next({
|
||||
syncValue,
|
||||
combinedState: [this.userId, state],
|
||||
});
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<T | null> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
: null;
|
||||
if (!options.shouldUpdate(current, combinedDependencies)) {
|
||||
return current;
|
||||
}
|
||||
const newState = configureState(current, combinedDependencies);
|
||||
this.nextState(newState);
|
||||
this.nextMock(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [T]>();
|
||||
private _keyDefinition: UserKeyDefinition<T> | null = null;
|
||||
get keyDefinition() {
|
||||
if (this._keyDefinition == null) {
|
||||
throw new Error(
|
||||
"Key definition not yet set, usually this means your sut has not asked for this state yet",
|
||||
);
|
||||
}
|
||||
return this._keyDefinition;
|
||||
}
|
||||
set keyDefinition(value: UserKeyDefinition<T>) {
|
||||
this._keyDefinition = value;
|
||||
}
|
||||
}
|
||||
export class FakeActiveUserState<T> implements ActiveUserState<T> {
|
||||
[activeMarker]: true;
|
||||
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<{
|
||||
syncValue: boolean;
|
||||
combinedState: CombinedState<T>;
|
||||
}>(1);
|
||||
|
||||
state$: Observable<T>;
|
||||
combinedState$: Observable<CombinedState<T>>;
|
||||
|
||||
constructor(
|
||||
private activeAccountAccessor: MinimalAccountService,
|
||||
initialValue?: T,
|
||||
updateSyncCallback?: (userId: UserId, newValue: T) => Promise<void>,
|
||||
) {
|
||||
// Inform the state provider of updates to keep single user states in sync
|
||||
this.stateSubject.pipe(
|
||||
filter((next) => next.syncValue),
|
||||
concatMap(async ({ combinedState }) => {
|
||||
await updateSyncCallback?.(...combinedState);
|
||||
}),
|
||||
);
|
||||
this.nextState(initialValue ?? null, { syncValue: initialValue != null });
|
||||
|
||||
this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState));
|
||||
this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state));
|
||||
}
|
||||
|
||||
nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
|
||||
this.stateSubject.next({
|
||||
syncValue,
|
||||
combinedState: [this.activeAccountAccessor.activeUserId, state],
|
||||
});
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<[UserId, T | null]> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
: null;
|
||||
if (!options.shouldUpdate(current, combinedDependencies)) {
|
||||
return [this.activeAccountAccessor.activeUserId, current];
|
||||
}
|
||||
const newState = configureState(current, combinedDependencies);
|
||||
this.nextState(newState);
|
||||
this.nextMock([this.activeAccountAccessor.activeUserId, newState]);
|
||||
return [this.activeAccountAccessor.activeUserId, newState];
|
||||
}
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [[UserId, T]]>();
|
||||
|
||||
private _keyDefinition: UserKeyDefinition<T> | null = null;
|
||||
get keyDefinition() {
|
||||
if (this._keyDefinition == null) {
|
||||
throw new Error(
|
||||
"Key definition not yet set, usually this means your sut has not asked for this state yet",
|
||||
);
|
||||
}
|
||||
return this._keyDefinition;
|
||||
}
|
||||
set keyDefinition(value: UserKeyDefinition<T>) {
|
||||
this._keyDefinition = value;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>
|
||||
implements DerivedState<TTo>
|
||||
{
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<TTo>(1);
|
||||
|
||||
constructor(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
) {
|
||||
parentState$
|
||||
.pipe(
|
||||
concatMap(async (v) => {
|
||||
const newState = deriveDefinition.derive(v, dependencies);
|
||||
if (newState instanceof Promise) {
|
||||
return newState;
|
||||
}
|
||||
return Promise.resolve(newState);
|
||||
}),
|
||||
)
|
||||
.subscribe((newState) => {
|
||||
this.stateSubject.next(newState);
|
||||
});
|
||||
}
|
||||
|
||||
forceValue(value: TTo): Promise<TTo> {
|
||||
this.stateSubject.next(value);
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
forceValueMock = this.forceValue as jest.MockedFunction<typeof this.forceValue>;
|
||||
|
||||
get state$() {
|
||||
return this.stateSubject.asObservable();
|
||||
}
|
||||
}
|
||||
export {
|
||||
FakeGlobalState,
|
||||
FakeSingleUserState,
|
||||
FakeActiveUserState,
|
||||
FakeDerivedState,
|
||||
} from "@bitwarden/state-test-utils";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
|
||||
@@ -78,57 +77,4 @@ export const mockFromSdk = (stub: any) => {
|
||||
return `${stub}_fromSdk`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks the emissions of the given observable.
|
||||
*
|
||||
* Call this function before you expect any emissions and then use code that will cause the observable to emit values,
|
||||
* then assert after all expected emissions have occurred.
|
||||
* @param observable
|
||||
* @returns An array that will be populated with all emissions of the observable.
|
||||
*/
|
||||
export function trackEmissions<T>(observable: Observable<T>): T[] {
|
||||
const emissions: T[] = [];
|
||||
observable.subscribe((value) => {
|
||||
switch (value) {
|
||||
case undefined:
|
||||
case null:
|
||||
emissions.push(value);
|
||||
return;
|
||||
default:
|
||||
// process by type
|
||||
break;
|
||||
}
|
||||
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
emissions.push(value);
|
||||
break;
|
||||
case "symbol":
|
||||
// Cheating types to make symbols work at all
|
||||
emissions.push(value.toString() as T);
|
||||
break;
|
||||
default: {
|
||||
emissions.push(clone(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
return emissions;
|
||||
}
|
||||
|
||||
function clone(value: any): any {
|
||||
if (global.structuredClone != undefined) {
|
||||
return structuredClone(value);
|
||||
} else {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
export async function awaitAsync(ms = 1) {
|
||||
if (ms < 1) {
|
||||
await Promise.resolve();
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
export { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils";
|
||||
|
||||
@@ -1,10 +1 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum ClientType {
|
||||
Web = "web",
|
||||
Browser = "browser",
|
||||
Desktop = "desktop",
|
||||
// Mobile = "mobile",
|
||||
Cli = "cli",
|
||||
// DirectoryConnector = "connector",
|
||||
}
|
||||
export { ClientType } from "@bitwarden/client-type";
|
||||
|
||||
@@ -252,6 +252,7 @@ export class Utils {
|
||||
}
|
||||
|
||||
// ref: http://stackoverflow.com/a/2117523/1090359
|
||||
/** @deprecated Use newGuid from @bitwarden/guid instead */
|
||||
static newGuid(): string {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
@@ -260,8 +261,10 @@ export class Utils {
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated Use guidRegex from @bitwarden/guid instead */
|
||||
static guidRegex = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/;
|
||||
|
||||
/** @deprecated Use isGuid from @bitwarden/guid instead */
|
||||
static isGuid(id: string) {
|
||||
return RegExp(Utils.guidRegex, "i").test(id);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "@bitwarden/state";
|
||||
|
||||
import { FakeStorageService } from "../../../spec/fake-storage.service";
|
||||
import { ClientType } from "../../enums";
|
||||
import { MigrationHelper } from "../../state-migrations/migration-helper";
|
||||
|
||||
import { MigrationBuilderService } from "./migration-builder.service";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CURRENT_VERSION, currentVersion, MigrationHelper } from "@bitwarden/state";
|
||||
|
||||
import { ClientType } from "../../enums";
|
||||
import { waitForMigrations } from "../../state-migrations";
|
||||
import { CURRENT_VERSION, currentVersion } from "../../state-migrations/migrate";
|
||||
import { MigrationHelper } from "../../state-migrations/migration-helper";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { AbstractStorageService } from "../abstractions/storage.service";
|
||||
|
||||
|
||||
@@ -1,196 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DerivedStateDependencies, StorageKey } from "../../types/state";
|
||||
|
||||
import { KeyDefinition } from "./key-definition";
|
||||
import { StateDefinition } from "./state-definition";
|
||||
import { UserKeyDefinition } from "./user-key-definition";
|
||||
|
||||
declare const depShapeMarker: unique symbol;
|
||||
/**
|
||||
* A set of options for customizing the behavior of a {@link DeriveDefinition}
|
||||
*/
|
||||
type DeriveDefinitionOptions<TFrom, TTo, TDeps extends DerivedStateDependencies = never> = {
|
||||
/**
|
||||
* A function to use to convert values from TFrom to TTo. This is called on each emit of the parent state observable
|
||||
* and the resulting value will be emitted from the derived state observable.
|
||||
*
|
||||
* @param from Populated with the latest emission from the parent state observable.
|
||||
* @param deps Populated with the dependencies passed into the constructor of the derived state.
|
||||
* These are constant for the lifetime of the derived state.
|
||||
* @returns The derived state value or a Promise that resolves to the derived state value.
|
||||
*/
|
||||
derive: (from: TFrom, deps: TDeps) => TTo | Promise<TTo>;
|
||||
/**
|
||||
* A function to use to safely convert your type from json to your expected type.
|
||||
*
|
||||
* **Important:** Your data may be serialized/deserialized at any time and this
|
||||
* callback needs to be able to faithfully re-initialize from the JSON object representation of your type.
|
||||
*
|
||||
* @param jsonValue The JSON object representation of your state.
|
||||
* @returns The fully typed version of your state.
|
||||
*/
|
||||
deserializer: (serialized: Jsonify<TTo>) => TTo;
|
||||
/**
|
||||
* An object defining the dependencies of the derive function. The keys of the object are the names of the dependencies
|
||||
* and the values are the types of the dependencies.
|
||||
*
|
||||
* for example:
|
||||
* ```
|
||||
* {
|
||||
* myService: MyService,
|
||||
* myOtherService: MyOtherService,
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
[depShapeMarker]?: TDeps;
|
||||
/**
|
||||
* The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||
* Defaults to 1000ms.
|
||||
*/
|
||||
cleanupDelayMs?: number;
|
||||
/**
|
||||
* Whether or not to clear the derived state when cleanup occurs. Defaults to true.
|
||||
*/
|
||||
clearOnCleanup?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* DeriveDefinitions describe state derived from another observable, the value type of which is given by `TFrom`.
|
||||
*
|
||||
* The StateDefinition is used to describe the domain of the state, and the DeriveDefinition
|
||||
* sub-divides that domain into specific keys. These keys are used to cache data in memory and enables derived state to
|
||||
* be calculated once regardless of multiple execution contexts.
|
||||
*/
|
||||
|
||||
export class DeriveDefinition<TFrom, TTo, TDeps extends DerivedStateDependencies> {
|
||||
/**
|
||||
* Creates a new instance of a DeriveDefinition. Derived state is always stored in memory, so the storage location
|
||||
* defined in @link{StateDefinition} is ignored.
|
||||
*
|
||||
* @param stateDefinition The state definition for which this key belongs to.
|
||||
* @param uniqueDerivationName The name of the key, this should be unique per domain.
|
||||
* @param options A set of options to customize the behavior of {@link DeriveDefinition}.
|
||||
* @param options.derive A function to use to convert values from TFrom to TTo. This is called on each emit of the parent state observable
|
||||
* and the resulting value will be emitted from the derived state observable.
|
||||
* @param options.cleanupDelayMs The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||
* Defaults to 1000ms.
|
||||
* @param options.dependencyShape An object defining the dependencies of the derive function. The keys of the object are the names of the dependencies
|
||||
* and the values are the types of the dependencies.
|
||||
* for example:
|
||||
* ```
|
||||
* {
|
||||
* myService: MyService,
|
||||
* myOtherService: MyOtherService,
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param options.deserializer A function to use to safely convert your type from json to your expected type.
|
||||
* Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize
|
||||
* from the JSON object representation of your type.
|
||||
*/
|
||||
constructor(
|
||||
readonly stateDefinition: StateDefinition,
|
||||
readonly uniqueDerivationName: string,
|
||||
readonly options: DeriveDefinitionOptions<TFrom, TTo, TDeps>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Factory that produces a {@link DeriveDefinition} from a {@link KeyDefinition} or {@link DeriveDefinition} and new name.
|
||||
*
|
||||
* If a `KeyDefinition` is passed in, the returned definition will have the same key as the given key definition, but
|
||||
* will not collide with it in storage, even if they both reside in memory.
|
||||
*
|
||||
* If a `DeriveDefinition` is passed in, the returned definition will instead use the name given in the second position
|
||||
* of the tuple. It is up to you to ensure this is unique within the domain of derived state.
|
||||
*
|
||||
* @param options A set of options to customize the behavior of {@link DeriveDefinition}.
|
||||
* @param options.derive A function to use to convert values from TFrom to TTo. This is called on each emit of the parent state observable
|
||||
* and the resulting value will be emitted from the derived state observable.
|
||||
* @param options.cleanupDelayMs The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||
* Defaults to 1000ms.
|
||||
* @param options.dependencyShape An object defining the dependencies of the derive function. The keys of the object are the names of the dependencies
|
||||
* and the values are the types of the dependencies.
|
||||
* for example:
|
||||
* ```
|
||||
* {
|
||||
* myService: MyService,
|
||||
* myOtherService: MyOtherService,
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param options.deserializer A function to use to safely convert your type from json to your expected type.
|
||||
* Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize
|
||||
* from the JSON object representation of your type.
|
||||
* @param definition
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
static from<TFrom, TTo, TDeps extends DerivedStateDependencies = never>(
|
||||
definition:
|
||||
| KeyDefinition<TFrom>
|
||||
| UserKeyDefinition<TFrom>
|
||||
| [DeriveDefinition<unknown, TFrom, DerivedStateDependencies>, string],
|
||||
options: DeriveDefinitionOptions<TFrom, TTo, TDeps>,
|
||||
) {
|
||||
if (isFromDeriveDefinition(definition)) {
|
||||
return new DeriveDefinition(definition[0].stateDefinition, definition[1], options);
|
||||
} else {
|
||||
return new DeriveDefinition(definition.stateDefinition, definition.key, options);
|
||||
}
|
||||
}
|
||||
|
||||
static fromWithUserId<TKeyDef, TTo, TDeps extends DerivedStateDependencies = never>(
|
||||
definition:
|
||||
| KeyDefinition<TKeyDef>
|
||||
| UserKeyDefinition<TKeyDef>
|
||||
| [DeriveDefinition<unknown, TKeyDef, DerivedStateDependencies>, string],
|
||||
options: DeriveDefinitionOptions<[UserId, TKeyDef], TTo, TDeps>,
|
||||
) {
|
||||
if (isFromDeriveDefinition(definition)) {
|
||||
return new DeriveDefinition(definition[0].stateDefinition, definition[1], options);
|
||||
} else {
|
||||
return new DeriveDefinition(definition.stateDefinition, definition.key, options);
|
||||
}
|
||||
}
|
||||
|
||||
get derive() {
|
||||
return this.options.derive;
|
||||
}
|
||||
|
||||
deserialize(serialized: Jsonify<TTo>): TTo {
|
||||
return this.options.deserializer(serialized);
|
||||
}
|
||||
|
||||
get cleanupDelayMs() {
|
||||
return this.options.cleanupDelayMs < 0 ? 0 : (this.options.cleanupDelayMs ?? 1000);
|
||||
}
|
||||
|
||||
get clearOnCleanup() {
|
||||
return this.options.clearOnCleanup ?? true;
|
||||
}
|
||||
|
||||
buildCacheKey(): string {
|
||||
return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link StorageKey} that points to the data for the given derived definition.
|
||||
* @returns A key that is ready to be used in a storage service to get data.
|
||||
*/
|
||||
get storageKey(): StorageKey {
|
||||
return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}` as StorageKey;
|
||||
}
|
||||
}
|
||||
|
||||
function isFromDeriveDefinition(
|
||||
definition:
|
||||
| KeyDefinition<unknown>
|
||||
| UserKeyDefinition<unknown>
|
||||
| [DeriveDefinition<unknown, unknown, DerivedStateDependencies>, string],
|
||||
): definition is [DeriveDefinition<unknown, unknown, DerivedStateDependencies>, string] {
|
||||
return Array.isArray(definition);
|
||||
}
|
||||
export { DeriveDefinition } from "@bitwarden/state";
|
||||
|
||||
@@ -1,25 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DerivedStateDependencies } from "../../types/state";
|
||||
|
||||
import { DeriveDefinition } from "./derive-definition";
|
||||
import { DerivedState } from "./derived-state";
|
||||
|
||||
/**
|
||||
* State derived from an observable and a derive function
|
||||
*/
|
||||
export abstract class DerivedStateProvider {
|
||||
/**
|
||||
* Creates a derived state observable from a parent state observable, a deriveDefinition, and the dependencies
|
||||
* required by the deriveDefinition
|
||||
* @param parentState$ The parent state observable
|
||||
* @param deriveDefinition The deriveDefinition that defines conversion from the parent state to the derived state as
|
||||
* well as some memory persistent information.
|
||||
* @param dependencies The dependencies of the derive function
|
||||
*/
|
||||
abstract get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo>;
|
||||
}
|
||||
export { DerivedStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,23 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export type StateConverter<TFrom extends Array<unknown>, TTo> = (...args: TFrom) => TTo;
|
||||
|
||||
/**
|
||||
* State derived from an observable and a converter function
|
||||
*
|
||||
* Derived state is cached and persisted to memory for sychronization across execution contexts.
|
||||
* For clients with multiple execution contexts, the derived state will be executed only once in the background process.
|
||||
*/
|
||||
export interface DerivedState<T> {
|
||||
/**
|
||||
* The derived state observable
|
||||
*/
|
||||
state$: Observable<T>;
|
||||
/**
|
||||
* Forces the derived state to a given value.
|
||||
*
|
||||
* Useful for setting an in-memory value as a side effect of some event, such as emptying state as a result of a lock.
|
||||
* @param value The value to force the derived state to
|
||||
*/
|
||||
forceValue(value: T): Promise<T>;
|
||||
}
|
||||
export { DerivedState } from "@bitwarden/state";
|
||||
|
||||
@@ -1,13 +1 @@
|
||||
import { GlobalState } from "./global-state";
|
||||
import { KeyDefinition } from "./key-definition";
|
||||
|
||||
/**
|
||||
* A provider for getting an implementation of global state scoped to the given key.
|
||||
*/
|
||||
export abstract class GlobalStateProvider {
|
||||
/**
|
||||
* Gets a {@link GlobalState} scoped to the given {@link KeyDefinition}
|
||||
* @param keyDefinition - The {@link KeyDefinition} for which you want the state for.
|
||||
*/
|
||||
abstract get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T>;
|
||||
}
|
||||
export { GlobalStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,30 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { StateUpdateOptions } from "./state-update-options";
|
||||
|
||||
/**
|
||||
* A helper object for interacting with state that is scoped to a specific domain
|
||||
* but is not scoped to a user. This is application wide storage.
|
||||
*/
|
||||
export interface GlobalState<T> {
|
||||
/**
|
||||
* Method for allowing you to manipulate state in an additive way.
|
||||
* @param configureState callback for how you want to manipulate this section of 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 A promise that must be awaited before your next action to ensure the update has been written to state.
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
update: <TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
) => Promise<T | null>;
|
||||
|
||||
/**
|
||||
* An observable stream of this state, the first emission of this will be the current state on disk
|
||||
* and subsequent updates will be from an update to that state.
|
||||
*/
|
||||
state$: Observable<T | null>;
|
||||
}
|
||||
export { GlobalState } from "@bitwarden/state";
|
||||
|
||||
@@ -1,64 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable, map, switchMap, firstValueFrom, timeout, throwError, NEVER } from "rxjs";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { StateUpdateOptions } from "../state-update-options";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
import { ActiveUserState, CombinedState, activeMarker } from "../user-state";
|
||||
import { SingleUserStateProvider } from "../user-state.provider";
|
||||
|
||||
export class DefaultActiveUserState<T> implements ActiveUserState<T> {
|
||||
[activeMarker]: true;
|
||||
combinedState$: Observable<CombinedState<T>>;
|
||||
state$: Observable<T>;
|
||||
|
||||
constructor(
|
||||
protected keyDefinition: UserKeyDefinition<T>,
|
||||
private activeUserId$: Observable<UserId | null>,
|
||||
private singleUserStateProvider: SingleUserStateProvider,
|
||||
) {
|
||||
this.combinedState$ = this.activeUserId$.pipe(
|
||||
switchMap((userId) =>
|
||||
userId != null
|
||||
? this.singleUserStateProvider.get(userId, this.keyDefinition).combinedState$
|
||||
: NEVER,
|
||||
),
|
||||
);
|
||||
|
||||
// State should just be combined state without the user id
|
||||
this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state));
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
options: StateUpdateOptions<T, TCombine> = {},
|
||||
): Promise<[UserId, T]> {
|
||||
const userId = await firstValueFrom(
|
||||
this.activeUserId$.pipe(
|
||||
timeout({
|
||||
first: 1000,
|
||||
with: () =>
|
||||
throwError(
|
||||
() =>
|
||||
new Error(
|
||||
`Timeout while retrieving active user for key ${this.keyDefinition.fullName}.`,
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (userId == null) {
|
||||
throw new Error(
|
||||
`Error storing ${this.keyDefinition.fullName} for the active user: No active user at this time.`,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
userId,
|
||||
await this.singleUserStateProvider
|
||||
.get(userId, this.keyDefinition)
|
||||
.update(configureState, options),
|
||||
];
|
||||
}
|
||||
}
|
||||
export { DefaultActiveUserState } from "@bitwarden/state";
|
||||
|
||||
@@ -1,53 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DerivedStateDependencies } from "../../../types/state";
|
||||
import { DeriveDefinition } from "../derive-definition";
|
||||
import { DerivedState } from "../derived-state";
|
||||
import { DerivedStateProvider } from "../derived-state.provider";
|
||||
|
||||
import { DefaultDerivedState } from "./default-derived-state";
|
||||
|
||||
export class DefaultDerivedStateProvider implements DerivedStateProvider {
|
||||
/**
|
||||
* The cache uses a WeakMap to maintain separate derived states per user.
|
||||
* Each user's state Observable acts as a unique key, without needing to
|
||||
* pass around `userId`. Also, when a user's state Observable is cleaned up
|
||||
* (like during an account swap) their cache is automatically garbage
|
||||
* collected.
|
||||
*/
|
||||
private cache = new WeakMap<Observable<unknown>, Record<string, DerivedState<unknown>>>();
|
||||
|
||||
constructor() {}
|
||||
|
||||
get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
let stateCache = this.cache.get(parentState$);
|
||||
if (!stateCache) {
|
||||
stateCache = {};
|
||||
this.cache.set(parentState$, stateCache);
|
||||
}
|
||||
|
||||
const cacheKey = deriveDefinition.buildCacheKey();
|
||||
const existingDerivedState = stateCache[cacheKey];
|
||||
if (existingDerivedState != null) {
|
||||
// I have to cast out of the unknown generic but this should be safe if rules
|
||||
// around domain token are made
|
||||
return existingDerivedState as DefaultDerivedState<TFrom, TTo, TDeps>;
|
||||
}
|
||||
|
||||
const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies);
|
||||
stateCache[cacheKey] = newDerivedState;
|
||||
return newDerivedState;
|
||||
}
|
||||
|
||||
protected buildDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
return new DefaultDerivedState<TFrom, TTo, TDeps>(parentState$, deriveDefinition, dependencies);
|
||||
}
|
||||
}
|
||||
export { DefaultDerivedStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,50 +1 @@
|
||||
import { Observable, ReplaySubject, Subject, concatMap, merge, share, timer } from "rxjs";
|
||||
|
||||
import { DerivedStateDependencies } from "../../../types/state";
|
||||
import { DeriveDefinition } from "../derive-definition";
|
||||
import { DerivedState } from "../derived-state";
|
||||
|
||||
/**
|
||||
* Default derived state
|
||||
*/
|
||||
export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>
|
||||
implements DerivedState<TTo>
|
||||
{
|
||||
private readonly storageKey: string;
|
||||
private forcedValueSubject = new Subject<TTo>();
|
||||
|
||||
state$: Observable<TTo>;
|
||||
|
||||
constructor(
|
||||
private parentState$: Observable<TFrom>,
|
||||
protected deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
private dependencies: TDeps,
|
||||
) {
|
||||
this.storageKey = deriveDefinition.storageKey;
|
||||
|
||||
const derivedState$ = this.parentState$.pipe(
|
||||
concatMap(async (state) => {
|
||||
let derivedStateOrPromise = this.deriveDefinition.derive(state, this.dependencies);
|
||||
if (derivedStateOrPromise instanceof Promise) {
|
||||
derivedStateOrPromise = await derivedStateOrPromise;
|
||||
}
|
||||
const derivedState = derivedStateOrPromise;
|
||||
return derivedState;
|
||||
}),
|
||||
);
|
||||
|
||||
this.state$ = merge(this.forcedValueSubject, derivedState$).pipe(
|
||||
share({
|
||||
connector: () => {
|
||||
return new ReplaySubject<TTo>(1);
|
||||
},
|
||||
resetOnRefCountZero: () => timer(this.deriveDefinition.cleanupDelayMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async forceValue(value: TTo) {
|
||||
this.forcedValueSubject.next(value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
export { DefaultDerivedState } from "@bitwarden/state";
|
||||
|
||||
@@ -1,46 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { GlobalState } from "../global-state";
|
||||
import { GlobalStateProvider } from "../global-state.provider";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
|
||||
import { DefaultGlobalState } from "./default-global-state";
|
||||
|
||||
export class DefaultGlobalStateProvider implements GlobalStateProvider {
|
||||
private globalStateCache: Record<string, GlobalState<unknown>> = {};
|
||||
|
||||
constructor(
|
||||
private storageServiceProvider: StorageServiceProvider,
|
||||
private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
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
|
||||
// being unique.
|
||||
return existingGlobalState as DefaultGlobalState<T>;
|
||||
}
|
||||
|
||||
const newGlobalState = new DefaultGlobalState<T>(
|
||||
keyDefinition,
|
||||
storageService,
|
||||
this.logService,
|
||||
);
|
||||
|
||||
this.globalStateCache[cacheKey] = newGlobalState;
|
||||
return newGlobalState;
|
||||
}
|
||||
|
||||
private buildCacheKey(location: string, keyDefinition: KeyDefinition<unknown>) {
|
||||
return `${location}_${keyDefinition.fullName}`;
|
||||
}
|
||||
}
|
||||
export { DefaultGlobalStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,20 +1 @@
|
||||
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { GlobalState } from "../global-state";
|
||||
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
||||
|
||||
import { StateBase } from "./state-base";
|
||||
|
||||
export class DefaultGlobalState<T>
|
||||
extends StateBase<T, KeyDefinition<T>>
|
||||
implements GlobalState<T>
|
||||
{
|
||||
constructor(
|
||||
keyDefinition: KeyDefinition<T>,
|
||||
chosenLocation: AbstractStorageService & ObservableStorageService,
|
||||
logService: LogService,
|
||||
) {
|
||||
super(globalKeyBuilder(keyDefinition), chosenLocation, keyDefinition, logService);
|
||||
}
|
||||
}
|
||||
export { DefaultGlobalState } from "@bitwarden/state";
|
||||
|
||||
@@ -1,54 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { UserKeyDefinition } from "../user-key-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(
|
||||
private readonly storageServiceProvider: StorageServiceProvider,
|
||||
private readonly stateEventRegistrarService: StateEventRegistrarService,
|
||||
private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
get<T>(userId: UserId, keyDefinition: UserKeyDefinition<T>): SingleUserState<T> {
|
||||
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
|
||||
// around domain token are made
|
||||
return existingUserState as SingleUserState<T>;
|
||||
}
|
||||
|
||||
const newUserState = new DefaultSingleUserState<T>(
|
||||
userId,
|
||||
keyDefinition,
|
||||
storageService,
|
||||
this.stateEventRegistrarService,
|
||||
this.logService,
|
||||
);
|
||||
this.cache[cacheKey] = newUserState;
|
||||
return newUserState;
|
||||
}
|
||||
|
||||
private buildCacheKey(
|
||||
location: string,
|
||||
userId: UserId,
|
||||
keyDefinition: UserKeyDefinition<unknown>,
|
||||
) {
|
||||
return `${location}_${keyDefinition.fullName}_${userId}`;
|
||||
}
|
||||
}
|
||||
export { DefaultSingleUserStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,36 +1 @@
|
||||
import { Observable, combineLatest, of } from "rxjs";
|
||||
|
||||
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
import { CombinedState, SingleUserState } from "../user-state";
|
||||
|
||||
import { StateBase } from "./state-base";
|
||||
|
||||
export class DefaultSingleUserState<T>
|
||||
extends StateBase<T, UserKeyDefinition<T>>
|
||||
implements SingleUserState<T>
|
||||
{
|
||||
readonly combinedState$: Observable<CombinedState<T | null>>;
|
||||
|
||||
constructor(
|
||||
readonly userId: UserId,
|
||||
keyDefinition: UserKeyDefinition<T>,
|
||||
chosenLocation: AbstractStorageService & ObservableStorageService,
|
||||
private stateEventRegistrarService: StateEventRegistrarService,
|
||||
logService: LogService,
|
||||
) {
|
||||
super(keyDefinition.buildKey(userId), chosenLocation, keyDefinition, logService);
|
||||
this.combinedState$ = combineLatest([of(userId), this.state$]);
|
||||
}
|
||||
|
||||
protected override async doStorageSave(newState: T, oldState: T): Promise<void> {
|
||||
await super.doStorageSave(newState, oldState);
|
||||
if (newState != null && oldState == null) {
|
||||
await this.stateEventRegistrarService.registerEvents(this.keyDefinition);
|
||||
}
|
||||
}
|
||||
}
|
||||
export { DefaultSingleUserState } from "@bitwarden/state";
|
||||
|
||||
@@ -1,79 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable, filter, of, switchMap, take } from "rxjs";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { DerivedStateDependencies } from "../../../types/state";
|
||||
import { DeriveDefinition } from "../derive-definition";
|
||||
import { DerivedState } from "../derived-state";
|
||||
import { DerivedStateProvider } from "../derived-state.provider";
|
||||
import { GlobalStateProvider } from "../global-state.provider";
|
||||
import { StateProvider } from "../state.provider";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider";
|
||||
|
||||
export class DefaultStateProvider implements StateProvider {
|
||||
activeUserId$: Observable<UserId>;
|
||||
constructor(
|
||||
private readonly activeUserStateProvider: ActiveUserStateProvider,
|
||||
private readonly singleUserStateProvider: SingleUserStateProvider,
|
||||
private readonly globalStateProvider: GlobalStateProvider,
|
||||
private readonly derivedStateProvider: DerivedStateProvider,
|
||||
) {
|
||||
this.activeUserId$ = this.activeUserStateProvider.activeUserId$;
|
||||
}
|
||||
|
||||
getUserState$<T>(userKeyDefinition: UserKeyDefinition<T>, userId?: UserId): Observable<T> {
|
||||
if (userId) {
|
||||
return this.getUser<T>(userId, userKeyDefinition).state$;
|
||||
} else {
|
||||
return this.activeUserId$.pipe(
|
||||
filter((userId) => userId != null), // Filter out null-ish user ids since we can't get state for a null user id
|
||||
take(1),
|
||||
switchMap((userId) => this.getUser<T>(userId, userKeyDefinition).state$),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getUserStateOrDefault$<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
config: { userId: UserId | undefined; defaultValue?: T },
|
||||
): Observable<T> {
|
||||
const { userId, defaultValue = null } = config;
|
||||
if (userId) {
|
||||
return this.getUser<T>(userId, userKeyDefinition).state$;
|
||||
} else {
|
||||
return this.activeUserId$.pipe(
|
||||
take(1),
|
||||
switchMap((userId) =>
|
||||
userId != null ? this.getUser<T>(userId, userKeyDefinition).state$ : of(defaultValue),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async setUserState<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
value: T | null,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T | null]> {
|
||||
if (userId) {
|
||||
return [userId, await this.getUser<T>(userId, userKeyDefinition).update(() => value)];
|
||||
} else {
|
||||
return await this.getActive<T>(userKeyDefinition).update(() => value);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
getDerived: <TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<unknown, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
) => DerivedState<TTo> = this.derivedStateProvider.get.bind(this.derivedStateProvider);
|
||||
}
|
||||
export { DefaultStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,37 +1 @@
|
||||
import { Observable, concatMap } from "rxjs";
|
||||
|
||||
import { DerivedStateDependencies } from "../../../types/state";
|
||||
import { DeriveDefinition } from "../derive-definition";
|
||||
import { DerivedState } from "../derived-state";
|
||||
import { DerivedStateProvider } from "../derived-state.provider";
|
||||
|
||||
export class InlineDerivedStateProvider implements DerivedStateProvider {
|
||||
get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
return new InlineDerivedState(parentState$, deriveDefinition, dependencies);
|
||||
}
|
||||
}
|
||||
|
||||
export class InlineDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>
|
||||
implements DerivedState<TTo>
|
||||
{
|
||||
constructor(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
) {
|
||||
this.state$ = parentState$.pipe(
|
||||
concatMap(async (value) => await deriveDefinition.derive(value, dependencies)),
|
||||
);
|
||||
}
|
||||
|
||||
state$: Observable<TTo>;
|
||||
|
||||
forceValue(value: TTo): Promise<TTo> {
|
||||
// No need to force anything, we don't keep a cache
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
}
|
||||
export { InlineDerivedState, InlineDerivedStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,137 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
defer,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
merge,
|
||||
Observable,
|
||||
ReplaySubject,
|
||||
share,
|
||||
switchMap,
|
||||
tap,
|
||||
timeout,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
import { StorageKey } from "../../../types/state";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { DebugOptions } from "../key-definition";
|
||||
import { populateOptionsWithDefault, StateUpdateOptions } from "../state-update-options";
|
||||
|
||||
import { getStoredValue } from "./util";
|
||||
|
||||
// The parts of a KeyDefinition this class cares about to make it work
|
||||
type KeyDefinitionRequirements<T> = {
|
||||
deserializer: (jsonState: Jsonify<T>) => T | null;
|
||||
cleanupDelayMs: number;
|
||||
debug: Required<DebugOptions>;
|
||||
};
|
||||
|
||||
export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>> {
|
||||
private updatePromise: Promise<T>;
|
||||
|
||||
readonly state$: Observable<T | null>;
|
||||
|
||||
constructor(
|
||||
protected readonly key: StorageKey,
|
||||
protected readonly storageService: AbstractStorageService & ObservableStorageService,
|
||||
protected readonly keyDefinition: KeyDef,
|
||||
protected readonly logService: LogService,
|
||||
) {
|
||||
const storageUpdate$ = storageService.updates$.pipe(
|
||||
filter((storageUpdate) => storageUpdate.key === key),
|
||||
switchMap(async (storageUpdate) => {
|
||||
if (storageUpdate.updateType === "remove") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await getStoredValue(key, storageService, keyDefinition.deserializer);
|
||||
}),
|
||||
);
|
||||
|
||||
let state$ = merge(
|
||||
defer(() => getStoredValue(key, storageService, keyDefinition.deserializer)),
|
||||
storageUpdate$,
|
||||
);
|
||||
|
||||
if (keyDefinition.debug.enableRetrievalLogging) {
|
||||
state$ = state$.pipe(
|
||||
tap({
|
||||
next: (v) => {
|
||||
this.logService.info(
|
||||
`Retrieving '${key}' from storage, value is ${v == null ? "null" : "non-null"}`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// If 0 cleanup is chosen, treat this as absolutely no cache
|
||||
if (keyDefinition.cleanupDelayMs !== 0) {
|
||||
state$ = state$.pipe(
|
||||
share({
|
||||
connector: () => new ReplaySubject(1),
|
||||
resetOnRefCountZero: () => timer(keyDefinition.cleanupDelayMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.state$ = state$;
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options: StateUpdateOptions<T, TCombine> = {},
|
||||
): Promise<T | null> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
if (this.updatePromise != null) {
|
||||
await this.updatePromise;
|
||||
}
|
||||
|
||||
try {
|
||||
this.updatePromise = this.internalUpdate(configureState, options);
|
||||
return await this.updatePromise;
|
||||
} finally {
|
||||
this.updatePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async internalUpdate<TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<T | null> {
|
||||
const currentState = await this.getStateForUpdate();
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
: null;
|
||||
|
||||
if (!options.shouldUpdate(currentState, combinedDependencies)) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const newState = configureState(currentState, combinedDependencies);
|
||||
await this.doStorageSave(newState, currentState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
protected async doStorageSave(newState: T | null, oldState: T) {
|
||||
if (this.keyDefinition.debug.enableUpdateLogging) {
|
||||
this.logService.info(
|
||||
`Updating '${this.key}' from ${oldState == null ? "null" : "non-null"} to ${newState == null ? "null" : "non-null"}`,
|
||||
);
|
||||
}
|
||||
await this.storageService.save(this.key, newState);
|
||||
}
|
||||
|
||||
/** For use in update methods, does not wait for update to complete before yielding state.
|
||||
* The expectation is that that await is already done
|
||||
*/
|
||||
private async getStateForUpdate() {
|
||||
return await getStoredValue(this.key, this.storageService, this.keyDefinition.deserializer);
|
||||
}
|
||||
}
|
||||
export { StateBase } from "@bitwarden/state";
|
||||
|
||||
@@ -1,15 +1 @@
|
||||
export { DeriveDefinition } from "./derive-definition";
|
||||
export { DerivedStateProvider } from "./derived-state.provider";
|
||||
export { DerivedState } from "./derived-state";
|
||||
export { GlobalState } from "./global-state";
|
||||
export { StateProvider } from "./state.provider";
|
||||
export { GlobalStateProvider } from "./global-state.provider";
|
||||
export { ActiveUserState, SingleUserState, CombinedState } from "./user-state";
|
||||
export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
|
||||
export { KeyDefinition, KeyDefinitionOptions } from "./key-definition";
|
||||
export { StateUpdateOptions } from "./state-update-options";
|
||||
export { UserKeyDefinitionOptions, UserKeyDefinition } from "./user-key-definition";
|
||||
export { StateEventRunnerService } from "./state-event-runner.service";
|
||||
export { ActiveUserAccessor } from "./active-user.accessor";
|
||||
|
||||
export * from "./state-definitions";
|
||||
export * from "@bitwarden/state";
|
||||
|
||||
@@ -1,182 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { StorageKey } from "../../types/state";
|
||||
|
||||
import { array, record } from "./deserialization-helpers";
|
||||
import { StateDefinition } from "./state-definition";
|
||||
|
||||
export type DebugOptions = {
|
||||
/**
|
||||
* When true, logs will be written that look like the following:
|
||||
*
|
||||
* ```
|
||||
* "Updating 'global_myState_myKey' from null to non-null"
|
||||
* "Updating 'user_32265eda-62ff-4797-9ead-22214772f888_myState_myKey' from non-null to null."
|
||||
* ```
|
||||
*
|
||||
* It does not include the value of the data, only whether it is null or non-null.
|
||||
*/
|
||||
enableUpdateLogging?: boolean;
|
||||
|
||||
/**
|
||||
* When true, logs will be written that look like the following everytime a value is retrieved from storage.
|
||||
*
|
||||
* "Retrieving 'global_myState_myKey' from storage, value is null."
|
||||
* "Retrieving 'user_32265eda-62ff-4797-9ead-22214772f888_myState_myKey' from storage, value is non-null."
|
||||
*/
|
||||
enableRetrievalLogging?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A set of options for customizing the behavior of a {@link KeyDefinition}
|
||||
*/
|
||||
export type KeyDefinitionOptions<T> = {
|
||||
/**
|
||||
* A function to use to safely convert your type from json to your expected type.
|
||||
*
|
||||
* **Important:** Your data may be serialized/deserialized at any time and this
|
||||
* callback needs to be able to faithfully re-initialize from the JSON object representation of your type.
|
||||
*
|
||||
* @param jsonValue The JSON object representation of your state.
|
||||
* @returns The fully typed version of your state.
|
||||
*/
|
||||
readonly deserializer: (jsonValue: Jsonify<T>) => T | null;
|
||||
/**
|
||||
* The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||
* Defaults to 1000ms.
|
||||
*/
|
||||
readonly cleanupDelayMs?: number;
|
||||
|
||||
/**
|
||||
* Options for configuring the debugging behavior, see individual options for more info.
|
||||
*/
|
||||
readonly debug?: DebugOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* KeyDefinitions describe the precise location to store data for a given piece of state.
|
||||
* The StateDefinition is used to describe the domain of the state, and the KeyDefinition
|
||||
* sub-divides that domain into specific keys.
|
||||
*/
|
||||
export class KeyDefinition<T> {
|
||||
readonly debug: Required<DebugOptions>;
|
||||
|
||||
/**
|
||||
* Creates a new instance of a KeyDefinition
|
||||
* @param stateDefinition The state definition for which this key belongs to.
|
||||
* @param key The name of the key, this should be unique per domain.
|
||||
* @param options A set of options to customize the behavior of {@link KeyDefinition}. All options are required.
|
||||
* @param options.deserializer A function to use to safely convert your type from json to your expected type.
|
||||
* Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize
|
||||
* from the JSON object representation of your type.
|
||||
*/
|
||||
constructor(
|
||||
readonly stateDefinition: StateDefinition,
|
||||
readonly key: string,
|
||||
private readonly options: KeyDefinitionOptions<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 or equal to 0. Value of ${options.cleanupDelayMs} passed to key ${this.errorKeyName} `,
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize optional debug options
|
||||
const { enableUpdateLogging = false, enableRetrievalLogging = false } = options.debug ?? {};
|
||||
this.debug = {
|
||||
enableUpdateLogging,
|
||||
enableRetrievalLogging,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link KeyDefinition} for state that is an array.
|
||||
* @param stateDefinition The state definition to be added to the KeyDefinition
|
||||
* @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.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const MY_KEY = KeyDefinition.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: KeyDefinitionOptions<T>, // The array helper forces an initialValue of an empty array
|
||||
) {
|
||||
return new KeyDefinition<T[]>(stateDefinition, key, {
|
||||
...options,
|
||||
deserializer: array((e) => options.deserializer(e)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link KeyDefinition} for state that is a record.
|
||||
* @param stateDefinition The state definition to be added to the KeyDefinition
|
||||
* @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.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const MY_KEY = KeyDefinition.record<MyRecordValue>(MY_STATE, "key", {
|
||||
* deserializer: (myJsonValue) => convertToValue(myJsonValue),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
static record<T, TKey extends string | number = 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: KeyDefinitionOptions<T>, // The array helper forces an initialValue of an empty record
|
||||
) {
|
||||
return new KeyDefinition<Record<TKey, T>>(stateDefinition, key, {
|
||||
...options,
|
||||
deserializer: record((v) => options.deserializer(v)),
|
||||
});
|
||||
}
|
||||
|
||||
get fullName() {
|
||||
return `${this.stateDefinition.name}_${this.key}`;
|
||||
}
|
||||
|
||||
protected get errorKeyName() {
|
||||
return `${this.stateDefinition.name} > ${this.key}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link StorageKey}
|
||||
* @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 globalKeyBuilder(keyDefinition: KeyDefinition<unknown>): StorageKey {
|
||||
return `global_${keyDefinition.stateDefinition.name}_${keyDefinition.key}` as StorageKey;
|
||||
}
|
||||
export { KeyDefinition, KeyDefinitionOptions } from "@bitwarden/state";
|
||||
|
||||
@@ -1,24 +1,4 @@
|
||||
import { StorageLocation, ClientLocations } from "@bitwarden/storage-core";
|
||||
export { StateDefinition } from "@bitwarden/state";
|
||||
|
||||
// To be removed once references are updated to point to @bitwarden/storage-core
|
||||
export { StorageLocation, ClientLocations };
|
||||
|
||||
/**
|
||||
* Defines the base location and instruction of where this state is expected to be located.
|
||||
*/
|
||||
export class StateDefinition {
|
||||
readonly storageLocationOverrides: Partial<ClientLocations>;
|
||||
|
||||
/**
|
||||
* Creates a new instance of {@link StateDefinition}, the creation of which is owned by the platform team.
|
||||
* @param name The name of the state, this needs to be unique from all other {@link StateDefinition}'s.
|
||||
* @param defaultStorageLocation The location of where this state should be stored.
|
||||
*/
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly defaultStorageLocation: StorageLocation,
|
||||
storageLocationOverrides?: Partial<ClientLocations>,
|
||||
) {
|
||||
this.storageLocationOverrides = storageLocationOverrides ?? {};
|
||||
}
|
||||
}
|
||||
export { StorageLocation, ClientLocations } from "@bitwarden/storage-core";
|
||||
|
||||
@@ -1,76 +1,6 @@
|
||||
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
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
export {
|
||||
StateEventRegistrarService,
|
||||
StateEventInfo,
|
||||
STATE_LOCK_EVENT,
|
||||
STATE_LOGOUT_EVENT,
|
||||
} from "@bitwarden/state";
|
||||
|
||||
@@ -1,83 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
export { StateEventRunnerService } from "@bitwarden/state";
|
||||
|
||||
@@ -1,80 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DerivedStateDependencies } from "../../types/state";
|
||||
|
||||
import { DeriveDefinition } from "./derive-definition";
|
||||
import { DerivedState } from "./derived-state";
|
||||
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";
|
||||
|
||||
/** Convenience wrapper class for {@link ActiveUserStateProvider}, {@link SingleUserStateProvider},
|
||||
* and {@link GlobalStateProvider}.
|
||||
*/
|
||||
export abstract class StateProvider {
|
||||
/** @see{@link ActiveUserStateProvider.activeUserId$} */
|
||||
abstract activeUserId$: Observable<UserId | undefined>;
|
||||
|
||||
/**
|
||||
* Gets a state observable for a given key and userId.
|
||||
*
|
||||
* @remarks If userId is falsy the observable returned will attempt to point to the currently active user _and not update if the active user changes_.
|
||||
* This is different to how `getActive` works and more similar to `getUser` for whatever user happens to be active at the time of the call.
|
||||
* If no user happens to be active at the time this method is called with a falsy userId then this observable will not emit a value until
|
||||
* a user becomes active. If you are not confident a user is active at the time this method is called, you may want to pipe a call to `timeout`
|
||||
* or instead call {@link getUserStateOrDefault$} and supply a value you would rather have given in the case of no passed in userId and no active user.
|
||||
*
|
||||
* @param keyDefinition - The key definition for the state you want to get.
|
||||
* @param userId - The userId for which you want the state for. If not provided, the state for the currently active user will be returned.
|
||||
*/
|
||||
abstract getUserState$<T>(keyDefinition: UserKeyDefinition<T>, userId?: UserId): Observable<T>;
|
||||
|
||||
/**
|
||||
* Gets a state observable for a given key and userId
|
||||
*
|
||||
* @remarks If userId is falsy the observable return will first attempt to point to the currently active user but will not follow subsequent active user changes,
|
||||
* if there is no immediately available active user, then it will fallback to returning a default value in an observable that immediately completes.
|
||||
*
|
||||
* @param keyDefinition - The key definition for the state you want to get.
|
||||
* @param config.userId - The userId for which you want the state for. If not provided, the state for the currently active user will be returned.
|
||||
* @param config.defaultValue - The default value that should be wrapped in an observable if no active user is immediately available and no truthy userId is passed in.
|
||||
*/
|
||||
abstract getUserStateOrDefault$<T>(
|
||||
keyDefinition: UserKeyDefinition<T>,
|
||||
config: { userId: UserId | undefined; defaultValue?: T },
|
||||
): 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.
|
||||
*/
|
||||
abstract setUserState<T>(
|
||||
keyDefinition: UserKeyDefinition<T>,
|
||||
value: T | null,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T | null]>;
|
||||
|
||||
/** @see{@link ActiveUserStateProvider.get} */
|
||||
abstract getActive<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T>;
|
||||
|
||||
/** @see{@link SingleUserStateProvider.get} */
|
||||
abstract getUser<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T>;
|
||||
|
||||
/** @see{@link GlobalStateProvider.get} */
|
||||
abstract getGlobal<T>(keyDefinition: KeyDefinition<T>): GlobalState<T>;
|
||||
abstract getDerived<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo>;
|
||||
}
|
||||
export { StateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,142 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { UserId } from "../../types/guid";
|
||||
import { StorageKey } from "../../types/state";
|
||||
import { Utils } from "../misc/utils";
|
||||
|
||||
import { array, record } from "./deserialization-helpers";
|
||||
import { DebugOptions, KeyDefinitionOptions } from "./key-definition";
|
||||
import { StateDefinition } from "./state-definition";
|
||||
|
||||
export type ClearEvent = "lock" | "logout";
|
||||
|
||||
export type UserKeyDefinitionOptions<T> = KeyDefinitionOptions<T> & {
|
||||
clearOn: ClearEvent[];
|
||||
};
|
||||
|
||||
const USER_KEY_DEFINITION_MARKER: unique symbol = Symbol("UserKeyDefinition");
|
||||
|
||||
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[];
|
||||
|
||||
/**
|
||||
* Normalized options used for debugging purposes.
|
||||
*/
|
||||
readonly debug: Required<DebugOptions>;
|
||||
|
||||
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 or equal to 0. Value of ${options.cleanupDelayMs} passed to key ${this.errorKeyName} `,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out repeat values
|
||||
this.clearOn = Array.from(new Set(options.clearOn));
|
||||
|
||||
// Normalize optional debug options
|
||||
const { enableUpdateLogging = false, enableRetrievalLogging = false } = options.debug ?? {};
|
||||
this.debug = {
|
||||
enableUpdateLogging,
|
||||
enableRetrievalLogging,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 | number = 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, building for key ${this.fullName}`,
|
||||
);
|
||||
}
|
||||
return `user_${userId}_${this.stateDefinition.name}_${this.key}` as StorageKey;
|
||||
}
|
||||
|
||||
private get errorKeyName() {
|
||||
return `${this.stateDefinition.name} > ${this.key}`;
|
||||
}
|
||||
}
|
||||
export { UserKeyDefinition, UserKeyDefinitionOptions } from "@bitwarden/state";
|
||||
|
||||
@@ -1,35 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
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 */
|
||||
export abstract class SingleUserStateProvider {
|
||||
/**
|
||||
* 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>;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
/**
|
||||
* Convenience re-emission of active user ID from {@link AccountService.activeAccount$}
|
||||
*/
|
||||
abstract 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>;
|
||||
}
|
||||
export { ActiveUserStateProvider, SingleUserStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,64 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
import { StateUpdateOptions } from "./state-update-options";
|
||||
|
||||
export type CombinedState<T> = readonly [userId: UserId, state: T];
|
||||
|
||||
/** A helper object for interacting with state that is scoped to a specific user. */
|
||||
export interface UserState<T> {
|
||||
/** Emits a stream of data. Emits null if the user does not have specified state. */
|
||||
readonly state$: Observable<T | null>;
|
||||
|
||||
/** Emits a stream of tuples, with the first element being a user id and the second element being the data for that user. */
|
||||
readonly combinedState$: Observable<CombinedState<T | null>>;
|
||||
}
|
||||
|
||||
export const activeMarker: unique symbol = Symbol("active");
|
||||
|
||||
export interface ActiveUserState<T> extends UserState<T> {
|
||||
readonly [activeMarker]: true;
|
||||
|
||||
/**
|
||||
* Emits a stream of data. Emits null if the user does not have specified state.
|
||||
* Note: Will not emit if there is no active user.
|
||||
*/
|
||||
readonly state$: Observable<T | null>;
|
||||
|
||||
/**
|
||||
* Updates backing stores for the active user.
|
||||
* @param configureState function that takes the current state and returns the new state
|
||||
* @param options Defaults to @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 A promise that must be awaited before your next action to ensure the update has been written to state.
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
readonly update: <TCombine>(
|
||||
configureState: (state: T | null, dependencies: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
) => Promise<[UserId, T | null]>;
|
||||
}
|
||||
|
||||
export interface SingleUserState<T> extends UserState<T> {
|
||||
readonly userId: UserId;
|
||||
|
||||
/**
|
||||
* Updates backing stores for the active user.
|
||||
* @param configureState function that takes the current state and returns the new state
|
||||
* @param options Defaults to @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 A promise that must be awaited before your next action to ensure the update has been written to state.
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
readonly update: <TCombine>(
|
||||
configureState: (state: T | null, dependencies: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
) => Promise<T | null>;
|
||||
}
|
||||
export { ActiveUserState, SingleUserState, CombinedState } from "@bitwarden/state";
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { createMigrationBuilder, waitForMigrations, CURRENT_VERSION } from "./migrate";
|
||||
// Compatibility re-export for @bitwarden/common/state-migrations
|
||||
export * from "@bitwarden/state";
|
||||
|
||||
@@ -1,106 +1 @@
|
||||
import { MigrationHelper } from "./migration-helper";
|
||||
import { Direction, Migrator, VersionFrom, VersionTo } from "./migrator";
|
||||
|
||||
export class MigrationBuilder<TCurrent extends number = 0> {
|
||||
/** Create a new MigrationBuilder with an empty buffer of migrations to perform.
|
||||
*
|
||||
* Add migrations to the buffer with {@link with} and {@link rollback}.
|
||||
* @returns A new MigrationBuilder.
|
||||
*/
|
||||
static create(): MigrationBuilder<0> {
|
||||
return new MigrationBuilder([]);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private migrations: readonly { migrator: Migrator<number, number>; direction: Direction }[],
|
||||
) {}
|
||||
|
||||
/** Add a migrator to the MigrationBuilder. Types are updated such that the chained MigrationBuilder must currently be
|
||||
* at state version equal to the from version of the migrator. Return as MigrationBuilder<TTo> where TTo is the to
|
||||
* version of the migrator, so that the next migrator can be chained.
|
||||
*
|
||||
* @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is
|
||||
* required to instantiate version numbers unless a default constructor is defined.
|
||||
* @returns A new MigrationBuilder with the to version of the migrator as the current version.
|
||||
*/
|
||||
with<
|
||||
TMigrator extends Migrator<number, number>,
|
||||
TFrom extends VersionFrom<TMigrator> & TCurrent,
|
||||
TTo extends VersionTo<TMigrator>,
|
||||
>(
|
||||
...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo]
|
||||
): MigrationBuilder<TTo> {
|
||||
return this.addMigrator(migrate, "up");
|
||||
}
|
||||
|
||||
/** Add a migrator to rollback on the MigrationBuilder's list of migrations. As with {@link with}, types of
|
||||
* MigrationBuilder and Migrator must align. However, this time the migration is reversed so TCurrent of the
|
||||
* MigrationBuilder must be equal to the to version of the migrator. Return as MigrationBuilder<TFrom> where TFrom
|
||||
* is the from version of the migrator, so that the next migrator can be chained.
|
||||
*
|
||||
* @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is
|
||||
* required to instantiate version numbers unless a default constructor is defined.
|
||||
* @returns A new MigrationBuilder with the from version of the migrator as the current version.
|
||||
*/
|
||||
rollback<
|
||||
TMigrator extends Migrator<number, number>,
|
||||
TFrom extends VersionFrom<TMigrator>,
|
||||
TTo extends VersionTo<TMigrator> & TCurrent,
|
||||
>(
|
||||
...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TTo, TFrom]
|
||||
): MigrationBuilder<TFrom> {
|
||||
if (migrate.length === 3) {
|
||||
migrate = [migrate[0], migrate[2], migrate[1]];
|
||||
}
|
||||
return this.addMigrator(migrate, "down");
|
||||
}
|
||||
|
||||
/** Execute the migrations as defined in the MigrationBuilder's migrator buffer */
|
||||
migrate(helper: MigrationHelper): Promise<void> {
|
||||
return this.migrations.reduce(
|
||||
(promise, migrator) =>
|
||||
promise.then(async () => {
|
||||
await this.runMigrator(migrator.migrator, helper, migrator.direction);
|
||||
}),
|
||||
Promise.resolve(),
|
||||
);
|
||||
}
|
||||
|
||||
private addMigrator<
|
||||
TMigrator extends Migrator<number, number>,
|
||||
TFrom extends VersionFrom<TMigrator> & TCurrent,
|
||||
TTo extends VersionTo<TMigrator>,
|
||||
>(
|
||||
migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo],
|
||||
direction: Direction = "up",
|
||||
) {
|
||||
const newMigration =
|
||||
migrate.length === 1
|
||||
? { migrator: new migrate[0](), direction }
|
||||
: { migrator: new migrate[0](migrate[1], migrate[2]), direction };
|
||||
|
||||
return new MigrationBuilder<TTo>([...this.migrations, newMigration]);
|
||||
}
|
||||
|
||||
private async runMigrator(
|
||||
migrator: Migrator<number, number>,
|
||||
helper: MigrationHelper,
|
||||
direction: Direction,
|
||||
): Promise<void> {
|
||||
const shouldMigrate = await migrator.shouldMigrate(helper, direction);
|
||||
helper.info(
|
||||
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) should migrate: ${shouldMigrate} - ${direction}`,
|
||||
);
|
||||
if (shouldMigrate) {
|
||||
const method = direction === "up" ? migrator.migrate : migrator.rollback;
|
||||
await method.bind(migrator)(helper);
|
||||
helper.info(
|
||||
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) migrated - ${direction}`,
|
||||
);
|
||||
await migrator.updateVersion(helper, direction);
|
||||
helper.info(
|
||||
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) updated version - ${direction}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
export { MigrationBuilder } from "@bitwarden/state";
|
||||
|
||||
@@ -1,261 +1 @@
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to provide client type to migrations
|
||||
import { ClientType } from "../enums";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
|
||||
import { AbstractStorageService } from "../platform/abstractions/storage.service";
|
||||
|
||||
export type StateDefinitionLike = { name: string };
|
||||
export type KeyDefinitionLike = {
|
||||
stateDefinition: StateDefinitionLike;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type MigrationHelperType = "general" | "web-disk-local";
|
||||
|
||||
export class MigrationHelper {
|
||||
constructor(
|
||||
public currentVersion: number,
|
||||
private storageService: AbstractStorageService,
|
||||
public logService: LogService,
|
||||
type: MigrationHelperType,
|
||||
public clientType: ClientType,
|
||||
) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* On some clients, migrations are ran multiple times without direct action from the migration writer.
|
||||
*
|
||||
* All clients will run through migrations at least once, this run is referred to as `"general"`. If a migration is
|
||||
* ran more than that single time, they will get a unique name if that the write can make conditional logic based on which
|
||||
* migration run this is.
|
||||
*
|
||||
* @remarks The preferrable way of writing migrations is ALWAYS to be defensive and reflect on the data you are given back. This
|
||||
* should really only be used when reflecting on the data given isn't enough.
|
||||
*/
|
||||
type: MigrationHelperType;
|
||||
|
||||
/**
|
||||
* Gets a value from the storage service at the given key.
|
||||
*
|
||||
* This is a brute force method to just get a value from the storage service. If you can use {@link getFromGlobal} or {@link getFromUser}, you should.
|
||||
* @param key location
|
||||
* @returns the value at the location
|
||||
*/
|
||||
get<T>(key: string): Promise<T> {
|
||||
return this.storageService.get<T>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a value in the storage service at the given key.
|
||||
*
|
||||
* This is a brute force method to just set a value in the storage service. If you can use {@link setToGlobal} or {@link setToUser}, you should.
|
||||
* @param key location
|
||||
* @param value the value to set
|
||||
* @returns
|
||||
*/
|
||||
set<T>(key: string, value: T): Promise<void> {
|
||||
this.logService.info(`Setting ${key}`);
|
||||
return this.storageService.save(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a value in the storage service at the given key.
|
||||
*
|
||||
* This is a brute force method to just remove a value in the storage service. If you can use {@link removeFromGlobal} or {@link removeFromUser}, you should.
|
||||
* @param key location
|
||||
* @returns void
|
||||
*/
|
||||
remove(key: string): Promise<void> {
|
||||
this.logService.info(`Removing ${key}`);
|
||||
return this.storageService.remove(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a globally scoped value from a location derived through the key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link get} for those.
|
||||
* @param keyDefinition unique key definition
|
||||
* @returns value from store
|
||||
*/
|
||||
getFromGlobal<T>(keyDefinition: KeyDefinitionLike): Promise<T> {
|
||||
return this.get<T>(this.getGlobalKey(keyDefinition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a globally scoped value to a location derived through the key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link set} for those.
|
||||
* @param keyDefinition unique key definition
|
||||
* @param value value to store
|
||||
* @returns void
|
||||
*/
|
||||
setToGlobal<T>(keyDefinition: KeyDefinitionLike, value: T): Promise<void> {
|
||||
return this.set(this.getGlobalKey(keyDefinition), value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a globally scoped location derived through the key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link remove} for those.
|
||||
* @param keyDefinition unique key definition
|
||||
* @returns void
|
||||
*/
|
||||
removeFromGlobal(keyDefinition: KeyDefinitionLike): Promise<void> {
|
||||
return this.remove(this.getGlobalKey(keyDefinition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user scoped value from a location derived through the user id and key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link get} for those.
|
||||
* @param userId userId to use in the key
|
||||
* @param keyDefinition unique key definition
|
||||
* @returns value from store
|
||||
*/
|
||||
getFromUser<T>(userId: string, keyDefinition: KeyDefinitionLike): Promise<T> {
|
||||
return this.get<T>(this.getUserKey(userId, keyDefinition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a user scoped value to a location derived through the user id and key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link set} for those.
|
||||
* @param userId userId to use in the key
|
||||
* @param keyDefinition unique key definition
|
||||
* @param value value to store
|
||||
* @returns void
|
||||
*/
|
||||
setToUser<T>(userId: string, keyDefinition: KeyDefinitionLike, value: T): Promise<void> {
|
||||
return this.set(this.getUserKey(userId, keyDefinition), value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user scoped location derived through the key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link remove} for those.
|
||||
* @param keyDefinition unique key definition
|
||||
* @returns void
|
||||
*/
|
||||
removeFromUser(userId: string, keyDefinition: KeyDefinitionLike): Promise<void> {
|
||||
return this.remove(this.getUserKey(userId, keyDefinition));
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
this.logService.info(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to read all Account objects stored by the State Service.
|
||||
*
|
||||
* This is useful from creating migrations off of this paradigm, but should not be used once a value is migrated to a state provider.
|
||||
*
|
||||
* @returns a list of all accounts that have been authenticated with state service, cast the expected type.
|
||||
*/
|
||||
async getAccounts<ExpectedAccountType>(): Promise<
|
||||
{ userId: string; account: ExpectedAccountType }[]
|
||||
> {
|
||||
const userIds = await this.getKnownUserIds();
|
||||
return Promise.all(
|
||||
userIds.map(async (userId) => ({
|
||||
userId,
|
||||
account: await this.get<ExpectedAccountType>(userId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to read known users ids.
|
||||
*/
|
||||
async getKnownUserIds(): Promise<string[]> {
|
||||
if (this.currentVersion < 60) {
|
||||
return knownAccountUserIdsBuilderPre60(this.storageService);
|
||||
} else {
|
||||
return knownAccountUserIdsBuilder(this.storageService);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a user storage key appropriate for the current version.
|
||||
*
|
||||
* @param userId userId to use in the key
|
||||
* @param keyDefinition state and key to use in the key
|
||||
* @returns
|
||||
*/
|
||||
private getUserKey(userId: string, keyDefinition: KeyDefinitionLike): string {
|
||||
if (this.currentVersion < 9) {
|
||||
return userKeyBuilderPre9();
|
||||
} else {
|
||||
return userKeyBuilder(userId, keyDefinition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a global storage key appropriate for the current version.
|
||||
*
|
||||
* @param keyDefinition state and key to use in the key
|
||||
* @returns
|
||||
*/
|
||||
private getGlobalKey(keyDefinition: KeyDefinitionLike): string {
|
||||
if (this.currentVersion < 9) {
|
||||
return globalKeyBuilderPre9();
|
||||
} else {
|
||||
return globalKeyBuilder(keyDefinition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When this is updated, rename this function to `userKeyBuilderXToY` where `X` is the version number it
|
||||
* became relevant, and `Y` prior to the version it was updated.
|
||||
*
|
||||
* Be sure to update the map in `MigrationHelper` to point to the appropriate function for the current version.
|
||||
* @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
|
||||
*/
|
||||
function userKeyBuilder(userId: string, keyDefinition: KeyDefinitionLike): string {
|
||||
return `user_${userId}_${keyDefinition.stateDefinition.name}_${keyDefinition.key}`;
|
||||
}
|
||||
|
||||
function userKeyBuilderPre9(): string {
|
||||
throw Error("No key builder should be used for versions prior to 9.");
|
||||
}
|
||||
|
||||
/**
|
||||
* When this is updated, rename this function to `globalKeyBuilderXToY` where `X` is the version number
|
||||
* it became relevant, and `Y` prior to the version it was updated.
|
||||
*
|
||||
* Be sure to update the map in `MigrationHelper` to point to the appropriate function for the current version.
|
||||
* @param keyDefinition the key definition of which data the key should point to.
|
||||
* @returns
|
||||
*/
|
||||
function globalKeyBuilder(keyDefinition: KeyDefinitionLike): string {
|
||||
return `global_${keyDefinition.stateDefinition.name}_${keyDefinition.key}`;
|
||||
}
|
||||
|
||||
function globalKeyBuilderPre9(): string {
|
||||
throw Error("No key builder should be used for versions prior to 9.");
|
||||
}
|
||||
|
||||
async function knownAccountUserIdsBuilderPre60(
|
||||
storageService: AbstractStorageService,
|
||||
): Promise<string[]> {
|
||||
return (await storageService.get<string[]>("authenticatedAccounts")) ?? [];
|
||||
}
|
||||
|
||||
async function knownAccountUserIdsBuilder(
|
||||
storageService: AbstractStorageService,
|
||||
): Promise<string[]> {
|
||||
const accounts = await storageService.get<Record<string, unknown>>(
|
||||
globalKeyBuilder({ stateDefinition: { name: "account" }, key: "accounts" }),
|
||||
);
|
||||
return Object.keys(accounts ?? {});
|
||||
}
|
||||
export { MigrationHelper } from "@bitwarden/state";
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
export type StorageKey = Opaque<string, "StorageKey">;
|
||||
|
||||
export type DerivedStateDependencies = Record<string, unknown>;
|
||||
// Compatibility re-export for @bitwarden/common/types/state
|
||||
export { StorageKey, DerivedStateDependencies } from "@bitwarden/state";
|
||||
|
||||
5
libs/core-test-utils/README.md
Normal file
5
libs/core-test-utils/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# core-test-utils
|
||||
|
||||
Owned by: platform
|
||||
|
||||
Async test tools for state and clients
|
||||
3
libs/core-test-utils/eslint.config.mjs
Normal file
3
libs/core-test-utils/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
10
libs/core-test-utils/jest.config.js
Normal file
10
libs/core-test-utils/jest.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
displayName: "core-test-utils",
|
||||
preset: "../../jest.preset.js",
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "html"],
|
||||
coverageDirectory: "../../coverage/libs/core-test-utils",
|
||||
};
|
||||
11
libs/core-test-utils/package.json
Normal file
11
libs/core-test-utils/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@bitwarden/core-test-utils",
|
||||
"version": "0.0.1",
|
||||
"description": "Async test tools for state and clients",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "GPL-3.0",
|
||||
"author": "platform"
|
||||
}
|
||||
33
libs/core-test-utils/project.json
Normal file
33
libs/core-test-utils/project.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "core-test-utils",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/core-test-utils/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/core-test-utils",
|
||||
"main": "libs/core-test-utils/src/index.ts",
|
||||
"tsConfig": "libs/core-test-utils/tsconfig.lib.json",
|
||||
"assets": ["libs/core-test-utils/*.md"]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/core-test-utils/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/core-test-utils/jest.config.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
libs/core-test-utils/src/core-test-utils.spec.ts
Normal file
8
libs/core-test-utils/src/core-test-utils.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as lib from "./index";
|
||||
|
||||
describe("core-test-utils", () => {
|
||||
// This test will fail until something is exported from index.ts
|
||||
it("should work", () => {
|
||||
expect(lib).toBeDefined();
|
||||
});
|
||||
});
|
||||
60
libs/core-test-utils/src/index.ts
Normal file
60
libs/core-test-utils/src/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
/**
|
||||
* Tracks all emissions of a given observable and returns them as an array.
|
||||
*
|
||||
* Typically used for testing: Call before actions that trigger observable emissions,
|
||||
* then assert that expected values have been emitted.
|
||||
* @param observable The observable to track.
|
||||
* @returns An array of all emitted values.
|
||||
*/
|
||||
export function trackEmissions<T>(observable: Observable<T>): T[] {
|
||||
const emissions: T[] = [];
|
||||
observable.subscribe((value) => {
|
||||
switch (value) {
|
||||
case undefined:
|
||||
case null:
|
||||
emissions.push(value);
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
emissions.push(value);
|
||||
break;
|
||||
case "symbol":
|
||||
// Symbols are converted to strings for storage
|
||||
emissions.push(value.toString() as T);
|
||||
break;
|
||||
default:
|
||||
emissions.push(clone(value));
|
||||
}
|
||||
});
|
||||
return emissions;
|
||||
}
|
||||
|
||||
function clone(value: any): any {
|
||||
if (global.structuredClone !== undefined) {
|
||||
return structuredClone(value);
|
||||
} else {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits asynchronously for a given number of milliseconds.
|
||||
*
|
||||
* If ms < 1, yields to the event loop immediately.
|
||||
* Useful in tests to await the next tick or introduce artificial delays.
|
||||
* @param ms Milliseconds to wait (default: 1)
|
||||
*/
|
||||
export async function awaitAsync(ms = 1) {
|
||||
if (ms < 1) {
|
||||
await Promise.resolve();
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
6
libs/core-test-utils/tsconfig.eslint.json
Normal file
6
libs/core-test-utils/tsconfig.eslint.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": ["src/**/*.ts", "src/**/*.js"],
|
||||
"exclude": ["**/build", "**/dist"]
|
||||
}
|
||||
13
libs/core-test-utils/tsconfig.json
Normal file
13
libs/core-test-utils/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/core-test-utils/tsconfig.lib.json
Normal file
10
libs/core-test-utils/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
|
||||
}
|
||||
10
libs/core-test-utils/tsconfig.spec.json
Normal file
10
libs/core-test-utils/tsconfig.spec.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
5
libs/guid/README.md
Normal file
5
libs/guid/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# guid
|
||||
|
||||
Owned by: platform
|
||||
|
||||
Guid utilities extracted from common
|
||||
3
libs/guid/eslint.config.mjs
Normal file
3
libs/guid/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
10
libs/guid/jest.config.js
Normal file
10
libs/guid/jest.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
displayName: "guid",
|
||||
preset: "../../jest.preset.js",
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "html"],
|
||||
coverageDirectory: "../../coverage/libs/guid",
|
||||
};
|
||||
11
libs/guid/package.json
Normal file
11
libs/guid/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@bitwarden/guid",
|
||||
"version": "0.0.1",
|
||||
"description": "Guid utilities extracted from common",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "GPL-3.0",
|
||||
"author": "platform"
|
||||
}
|
||||
33
libs/guid/project.json
Normal file
33
libs/guid/project.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "guid",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/guid/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/guid",
|
||||
"main": "libs/guid/src/index.ts",
|
||||
"tsConfig": "libs/guid/tsconfig.lib.json",
|
||||
"assets": ["libs/guid/*.md"]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/guid/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/guid/jest.config.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
libs/guid/src/guid.spec.ts
Normal file
8
libs/guid/src/guid.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as lib from "./index";
|
||||
|
||||
describe("guid", () => {
|
||||
// This test will fail until something is exported from index.ts
|
||||
it("should work", () => {
|
||||
expect(lib).toBeDefined();
|
||||
});
|
||||
});
|
||||
13
libs/guid/src/index.ts
Normal file
13
libs/guid/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const guidRegex = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
|
||||
|
||||
export function newGuid(): string {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
export function isGuid(id: string): boolean {
|
||||
return guidRegex.test(id);
|
||||
}
|
||||
6
libs/guid/tsconfig.eslint.json
Normal file
6
libs/guid/tsconfig.eslint.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": ["src/**/*.ts", "src/**/*.js"],
|
||||
"exclude": ["**/build", "**/dist"]
|
||||
}
|
||||
13
libs/guid/tsconfig.json
Normal file
13
libs/guid/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/guid/tsconfig.lib.json
Normal file
10
libs/guid/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
|
||||
}
|
||||
10
libs/guid/tsconfig.spec.json
Normal file
10
libs/guid/tsconfig.spec.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
5
libs/serialization/README.md
Normal file
5
libs/serialization/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# serialization
|
||||
|
||||
Owned by: platform
|
||||
|
||||
Core serialization utilities
|
||||
3
libs/serialization/eslint.config.mjs
Normal file
3
libs/serialization/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
10
libs/serialization/jest.config.js
Normal file
10
libs/serialization/jest.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
displayName: "serialization",
|
||||
preset: "../../jest.preset.js",
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "html"],
|
||||
coverageDirectory: "../../coverage/libs/serialization",
|
||||
};
|
||||
11
libs/serialization/package.json
Normal file
11
libs/serialization/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@bitwarden/serialization",
|
||||
"version": "0.0.1",
|
||||
"description": "Core serialization utilities",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "GPL-3.0",
|
||||
"author": "platform"
|
||||
}
|
||||
33
libs/serialization/project.json
Normal file
33
libs/serialization/project.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "serialization",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/serialization/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/serialization",
|
||||
"main": "libs/serialization/src/index.ts",
|
||||
"tsConfig": "libs/serialization/tsconfig.lib.json",
|
||||
"assets": ["libs/serialization/*.md"]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/serialization/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/serialization/jest.config.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { record } from "./deserialization-helpers";
|
||||
import { record } from "@bitwarden/serialization/deserialization-helpers";
|
||||
|
||||
describe("deserialization helpers", () => {
|
||||
describe("record", () => {
|
||||
1
libs/serialization/src/index.ts
Normal file
1
libs/serialization/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./deserialization-helpers";
|
||||
8
libs/serialization/src/serialization.spec.ts
Normal file
8
libs/serialization/src/serialization.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as lib from "./index";
|
||||
|
||||
describe("serialization", () => {
|
||||
// This test will fail until something is exported from index.ts
|
||||
it("should work", () => {
|
||||
expect(lib).toBeDefined();
|
||||
});
|
||||
});
|
||||
6
libs/serialization/tsconfig.eslint.json
Normal file
6
libs/serialization/tsconfig.eslint.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": ["src/**/*.ts", "src/**/*.js"],
|
||||
"exclude": ["**/build", "**/dist"]
|
||||
}
|
||||
13
libs/serialization/tsconfig.json
Normal file
13
libs/serialization/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/serialization/tsconfig.lib.json
Normal file
10
libs/serialization/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
|
||||
}
|
||||
10
libs/serialization/tsconfig.spec.json
Normal file
10
libs/serialization/tsconfig.spec.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
5
libs/state-test-utils/README.md
Normal file
5
libs/state-test-utils/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# state-test-utils
|
||||
|
||||
Owned by: platform
|
||||
|
||||
Test utilities and fakes for state management
|
||||
3
libs/state-test-utils/eslint.config.mjs
Normal file
3
libs/state-test-utils/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
10
libs/state-test-utils/jest.config.js
Normal file
10
libs/state-test-utils/jest.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
displayName: "state-test-utils",
|
||||
preset: "../../jest.preset.js",
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "html"],
|
||||
coverageDirectory: "../../coverage/libs/state-test-utils",
|
||||
};
|
||||
11
libs/state-test-utils/package.json
Normal file
11
libs/state-test-utils/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@bitwarden/state-test-utils",
|
||||
"version": "0.0.1",
|
||||
"description": "Test utilities and fakes for state management",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "GPL-3.0",
|
||||
"author": "platform"
|
||||
}
|
||||
33
libs/state-test-utils/project.json
Normal file
33
libs/state-test-utils/project.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "state-test-utils",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/state-test-utils/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/state-test-utils",
|
||||
"main": "libs/state-test-utils/src/index.ts",
|
||||
"tsConfig": "libs/state-test-utils/tsconfig.lib.json",
|
||||
"assets": ["libs/state-test-utils/*.md"]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/state-test-utils/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/state-test-utils/jest.config.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
341
libs/state-test-utils/src/fake-state-provider.ts
Normal file
341
libs/state-test-utils/src/fake-state-provider.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, map, Observable, of, switchMap, take } from "rxjs";
|
||||
|
||||
import {
|
||||
DerivedStateDependencies,
|
||||
GlobalState,
|
||||
GlobalStateProvider,
|
||||
KeyDefinition,
|
||||
ActiveUserState,
|
||||
SingleUserState,
|
||||
SingleUserStateProvider,
|
||||
StateProvider,
|
||||
ActiveUserStateProvider,
|
||||
DerivedState,
|
||||
DeriveDefinition,
|
||||
DerivedStateProvider,
|
||||
UserKeyDefinition,
|
||||
ActiveUserAccessor,
|
||||
} from "@bitwarden/state";
|
||||
import {
|
||||
FakeActiveUserState,
|
||||
FakeDerivedState,
|
||||
FakeGlobalState,
|
||||
FakeSingleUserState,
|
||||
} from "@bitwarden/state-test-utils";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export interface MinimalAccountService {
|
||||
activeUserId: UserId | null;
|
||||
activeAccount$: Observable<{ id: UserId } | null>;
|
||||
}
|
||||
|
||||
export class FakeActiveUserAccessor implements MinimalAccountService, ActiveUserAccessor {
|
||||
private _subject: BehaviorSubject<UserId | null>;
|
||||
|
||||
constructor(startingUser: UserId | null) {
|
||||
this._subject = new BehaviorSubject(startingUser);
|
||||
this.activeAccount$ = this._subject
|
||||
.asObservable()
|
||||
.pipe(map((id) => (id != null ? { id } : null)));
|
||||
this.activeUserId$ = this._subject.asObservable();
|
||||
}
|
||||
|
||||
get activeUserId(): UserId {
|
||||
return this._subject.value;
|
||||
}
|
||||
|
||||
activeUserId$: Observable<UserId>;
|
||||
|
||||
activeAccount$: Observable<{ id: UserId }>;
|
||||
|
||||
switch(user: UserId | null) {
|
||||
this._subject.next(user);
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeGlobalStateProvider implements GlobalStateProvider {
|
||||
mock = mock<GlobalStateProvider>();
|
||||
establishedMocks: Map<string, FakeGlobalState<unknown>> = new Map();
|
||||
states: Map<string, GlobalState<unknown>> = new Map();
|
||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
this.mock.get(keyDefinition);
|
||||
const cacheKey = this.cacheKey(keyDefinition);
|
||||
let result = this.states.get(cacheKey);
|
||||
|
||||
if (result == null) {
|
||||
let fake: FakeGlobalState<T>;
|
||||
// Look for established mock
|
||||
if (this.establishedMocks.has(keyDefinition.key)) {
|
||||
fake = this.establishedMocks.get(keyDefinition.key) as FakeGlobalState<T>;
|
||||
} else {
|
||||
fake = new FakeGlobalState<T>();
|
||||
}
|
||||
fake.keyDefinition = keyDefinition;
|
||||
result = fake;
|
||||
this.states.set(cacheKey, result);
|
||||
|
||||
result = new FakeGlobalState<T>();
|
||||
this.states.set(cacheKey, result);
|
||||
}
|
||||
return result as GlobalState<T>;
|
||||
}
|
||||
|
||||
private cacheKey(keyDefinition: KeyDefinition<unknown>) {
|
||||
return `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`;
|
||||
}
|
||||
|
||||
getFake<T>(keyDefinition: KeyDefinition<T>): FakeGlobalState<T> {
|
||||
return this.get(keyDefinition) as FakeGlobalState<T>;
|
||||
}
|
||||
|
||||
mockFor<T>(keyDefinition: KeyDefinition<T>, initialValue?: T): FakeGlobalState<T> {
|
||||
const cacheKey = this.cacheKey(keyDefinition);
|
||||
if (!this.states.has(cacheKey)) {
|
||||
this.states.set(cacheKey, new FakeGlobalState<T>(initialValue));
|
||||
}
|
||||
return this.states.get(cacheKey) as FakeGlobalState<T>;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeSingleUserStateProvider implements SingleUserStateProvider {
|
||||
mock = mock<SingleUserStateProvider>();
|
||||
states: Map<string, SingleUserState<unknown>> = new Map();
|
||||
|
||||
constructor(
|
||||
readonly updateSyncCallback?: (
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newValue: unknown,
|
||||
) => Promise<void>,
|
||||
) {}
|
||||
|
||||
get<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T> {
|
||||
this.mock.get(userId, userKeyDefinition);
|
||||
const cacheKey = this.cacheKey(userId, userKeyDefinition);
|
||||
let result = this.states.get(cacheKey);
|
||||
|
||||
if (result == null) {
|
||||
result = this.buildFakeState(userId, userKeyDefinition);
|
||||
this.states.set(cacheKey, result);
|
||||
}
|
||||
return result as SingleUserState<T>;
|
||||
}
|
||||
|
||||
getFake<T>(
|
||||
userId: UserId,
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
{ allowInit }: { allowInit: boolean } = { allowInit: true },
|
||||
): FakeSingleUserState<T> {
|
||||
if (!allowInit && this.states.get(this.cacheKey(userId, userKeyDefinition)) == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.get(userId, userKeyDefinition) as FakeSingleUserState<T>;
|
||||
}
|
||||
|
||||
mockFor<T>(
|
||||
userId: UserId,
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
initialValue?: T,
|
||||
): FakeSingleUserState<T> {
|
||||
const cacheKey = this.cacheKey(userId, userKeyDefinition);
|
||||
if (!this.states.has(cacheKey)) {
|
||||
this.states.set(cacheKey, this.buildFakeState(userId, userKeyDefinition, initialValue));
|
||||
}
|
||||
return this.states.get(cacheKey) as FakeSingleUserState<T>;
|
||||
}
|
||||
|
||||
private buildFakeState<T>(
|
||||
userId: UserId,
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
initialValue?: T,
|
||||
) {
|
||||
const state = new FakeSingleUserState(userId, initialValue, async (...args) => {
|
||||
await this.updateSyncCallback?.(userKeyDefinition, ...args);
|
||||
});
|
||||
state.keyDefinition = userKeyDefinition;
|
||||
return state;
|
||||
}
|
||||
|
||||
private cacheKey(userId: UserId, userKeyDefinition: UserKeyDefinition<unknown>) {
|
||||
return `${userKeyDefinitionCacheKey(userKeyDefinition)}_${userId}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
|
||||
activeUserId$: Observable<UserId | null>;
|
||||
states: Map<string, FakeActiveUserState<unknown>> = new Map();
|
||||
|
||||
constructor(
|
||||
public accountServiceAccessor: MinimalAccountService,
|
||||
readonly updateSyncCallback?: (
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newValue: unknown,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
this.activeUserId$ = accountServiceAccessor.activeAccount$.pipe(map((a) => a?.id));
|
||||
}
|
||||
|
||||
get<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T> {
|
||||
const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition);
|
||||
let result = this.states.get(cacheKey);
|
||||
|
||||
if (result == null) {
|
||||
result = this.buildFakeState(userKeyDefinition);
|
||||
this.states.set(cacheKey, result);
|
||||
}
|
||||
return result as ActiveUserState<T>;
|
||||
}
|
||||
|
||||
getFake<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
{ allowInit }: { allowInit: boolean } = { allowInit: true },
|
||||
): FakeActiveUserState<T> {
|
||||
if (!allowInit && this.states.get(userKeyDefinitionCacheKey(userKeyDefinition)) == null) {
|
||||
return null;
|
||||
}
|
||||
return this.get(userKeyDefinition) as FakeActiveUserState<T>;
|
||||
}
|
||||
|
||||
mockFor<T>(userKeyDefinition: UserKeyDefinition<T>, initialValue?: T): FakeActiveUserState<T> {
|
||||
const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition);
|
||||
if (!this.states.has(cacheKey)) {
|
||||
this.states.set(cacheKey, this.buildFakeState(userKeyDefinition, initialValue));
|
||||
}
|
||||
return this.states.get(cacheKey) as FakeActiveUserState<T>;
|
||||
}
|
||||
|
||||
private buildFakeState<T>(userKeyDefinition: UserKeyDefinition<T>, initialValue?: T) {
|
||||
const state = new FakeActiveUserState<T>(
|
||||
this.accountServiceAccessor,
|
||||
initialValue,
|
||||
async (...args) => {
|
||||
await this.updateSyncCallback?.(userKeyDefinition, ...args);
|
||||
},
|
||||
);
|
||||
state.keyDefinition = userKeyDefinition;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function userKeyDefinitionCacheKey(userKeyDefinition: UserKeyDefinition<unknown>) {
|
||||
return `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}`;
|
||||
}
|
||||
|
||||
export class FakeStateProvider implements StateProvider {
|
||||
mock = mock<StateProvider>();
|
||||
getUserState$<T>(userKeyDefinition: UserKeyDefinition<T>, userId?: UserId): Observable<T> {
|
||||
this.mock.getUserState$(userKeyDefinition, userId);
|
||||
if (userId) {
|
||||
return this.getUser<T>(userId, userKeyDefinition).state$;
|
||||
}
|
||||
|
||||
return this.getActive(userKeyDefinition).state$;
|
||||
}
|
||||
|
||||
getUserStateOrDefault$<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
config: { userId: UserId | undefined; defaultValue?: T },
|
||||
): Observable<T> {
|
||||
const { userId, defaultValue = null } = config;
|
||||
this.mock.getUserStateOrDefault$(userKeyDefinition, config);
|
||||
if (userId) {
|
||||
return this.getUser<T>(userId, userKeyDefinition).state$;
|
||||
}
|
||||
|
||||
return this.activeUserId$.pipe(
|
||||
take(1),
|
||||
switchMap((userId) =>
|
||||
userId != null ? this.getUser(userId, userKeyDefinition).state$ : of(defaultValue),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async setUserState<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
value: T | null,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T | null]> {
|
||||
await this.mock.setUserState(userKeyDefinition, value, userId);
|
||||
if (userId) {
|
||||
return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)];
|
||||
} else {
|
||||
return await this.getActive(userKeyDefinition).update(() => value);
|
||||
}
|
||||
}
|
||||
|
||||
getActive<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T> {
|
||||
return this.activeUser.get(userKeyDefinition);
|
||||
}
|
||||
|
||||
getGlobal<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
return this.global.get(keyDefinition);
|
||||
}
|
||||
|
||||
getUser<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T> {
|
||||
return this.singleUser.get(userId, userKeyDefinition);
|
||||
}
|
||||
|
||||
getDerived<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<unknown, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
return this.derived.get(parentState$, deriveDefinition, dependencies);
|
||||
}
|
||||
|
||||
constructor(private activeAccountAccessor: MinimalAccountService) {}
|
||||
|
||||
private distributeSingleUserUpdate(
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newState: unknown,
|
||||
) {
|
||||
if (this.activeUser.accountServiceAccessor.activeUserId === userId) {
|
||||
const state = this.activeUser.getFake(key, { allowInit: false });
|
||||
state?.nextState(newState, { syncValue: false });
|
||||
}
|
||||
}
|
||||
|
||||
private distributeActiveUserUpdate(
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newState: unknown,
|
||||
) {
|
||||
this.singleUser
|
||||
.getFake(userId, key, { allowInit: false })
|
||||
?.nextState(newState, { syncValue: false });
|
||||
}
|
||||
|
||||
global: FakeGlobalStateProvider = new FakeGlobalStateProvider();
|
||||
singleUser: FakeSingleUserStateProvider = new FakeSingleUserStateProvider(
|
||||
this.distributeSingleUserUpdate.bind(this),
|
||||
);
|
||||
activeUser: FakeActiveUserStateProvider = new FakeActiveUserStateProvider(
|
||||
this.activeAccountAccessor,
|
||||
this.distributeActiveUserUpdate.bind(this),
|
||||
);
|
||||
derived: FakeDerivedStateProvider = new FakeDerivedStateProvider();
|
||||
activeUserId$: Observable<UserId> = this.activeUser.activeUserId$;
|
||||
}
|
||||
|
||||
export class FakeDerivedStateProvider implements DerivedStateProvider {
|
||||
states: Map<string, DerivedState<unknown>> = new Map();
|
||||
get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState<TTo>;
|
||||
|
||||
if (result == null) {
|
||||
result = new FakeDerivedState(parentState$, deriveDefinition, dependencies);
|
||||
this.states.set(deriveDefinition.buildCacheKey(), result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
274
libs/state-test-utils/src/fake-state.ts
Normal file
274
libs/state-test-utils/src/fake-state.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable, ReplaySubject, concatMap, filter, firstValueFrom, map, timeout } from "rxjs";
|
||||
|
||||
import {
|
||||
ActiveUserState,
|
||||
CombinedState,
|
||||
DeriveDefinition,
|
||||
DerivedStateDependencies,
|
||||
DerivedState,
|
||||
GlobalState,
|
||||
KeyDefinition,
|
||||
SingleUserState,
|
||||
StateUpdateOptions,
|
||||
UserKeyDefinition,
|
||||
activeMarker,
|
||||
} from "@bitwarden/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { MinimalAccountService } from "./fake-state-provider";
|
||||
|
||||
const DEFAULT_TEST_OPTIONS: StateUpdateOptions<any, any> = {
|
||||
shouldUpdate: () => true,
|
||||
combineLatestWith: null,
|
||||
msTimeout: 10,
|
||||
};
|
||||
|
||||
function populateOptionsWithDefault(
|
||||
options: StateUpdateOptions<any, any>,
|
||||
): StateUpdateOptions<any, any> {
|
||||
return {
|
||||
...DEFAULT_TEST_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
export class FakeGlobalState<T> implements GlobalState<T> {
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<T>(1);
|
||||
|
||||
constructor(initialValue?: T) {
|
||||
this.stateSubject.next(initialValue ?? null);
|
||||
}
|
||||
|
||||
nextState(state: T) {
|
||||
this.stateSubject.next(state);
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<T> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
if (this.stateSubject["_buffer"].length == 0) {
|
||||
// throw a more helpful not initialized error
|
||||
throw new Error(
|
||||
"You must initialize the state with a value before calling update. Try calling `stateSubject.next(initialState)` before calling update",
|
||||
);
|
||||
}
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(100)));
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
: null;
|
||||
if (!options.shouldUpdate(current, combinedDependencies)) {
|
||||
return current;
|
||||
}
|
||||
const newState = configureState(current, combinedDependencies);
|
||||
this.stateSubject.next(newState);
|
||||
this.nextMock(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [T]>();
|
||||
|
||||
get state$() {
|
||||
return this.stateSubject.asObservable();
|
||||
}
|
||||
|
||||
private _keyDefinition: KeyDefinition<T> | null = null;
|
||||
get keyDefinition() {
|
||||
if (this._keyDefinition == null) {
|
||||
throw new Error(
|
||||
"Key definition not yet set, usually this means your sut has not asked for this state yet",
|
||||
);
|
||||
}
|
||||
return this._keyDefinition;
|
||||
}
|
||||
set keyDefinition(value: KeyDefinition<T>) {
|
||||
this._keyDefinition = value;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeSingleUserState<T> implements SingleUserState<T> {
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<{
|
||||
syncValue: boolean;
|
||||
combinedState: CombinedState<T>;
|
||||
}>(1);
|
||||
|
||||
state$: Observable<T>;
|
||||
combinedState$: Observable<CombinedState<T>>;
|
||||
|
||||
constructor(
|
||||
readonly userId: UserId,
|
||||
initialValue?: T,
|
||||
updateSyncCallback?: (userId: UserId, newValue: T) => Promise<void>,
|
||||
) {
|
||||
// Inform the state provider of updates to keep active user states in sync
|
||||
this.stateSubject
|
||||
.pipe(
|
||||
filter((next) => next.syncValue),
|
||||
concatMap(async ({ combinedState }) => {
|
||||
await updateSyncCallback?.(...combinedState);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
this.nextState(initialValue ?? null, { syncValue: initialValue != null });
|
||||
|
||||
this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState));
|
||||
this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state));
|
||||
}
|
||||
|
||||
nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
|
||||
this.stateSubject.next({
|
||||
syncValue,
|
||||
combinedState: [this.userId, state],
|
||||
});
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<T | null> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
: null;
|
||||
if (!options.shouldUpdate(current, combinedDependencies)) {
|
||||
return current;
|
||||
}
|
||||
const newState = configureState(current, combinedDependencies);
|
||||
this.nextState(newState);
|
||||
this.nextMock(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [T]>();
|
||||
private _keyDefinition: UserKeyDefinition<T> | null = null;
|
||||
get keyDefinition() {
|
||||
if (this._keyDefinition == null) {
|
||||
throw new Error(
|
||||
"Key definition not yet set, usually this means your sut has not asked for this state yet",
|
||||
);
|
||||
}
|
||||
return this._keyDefinition;
|
||||
}
|
||||
set keyDefinition(value: UserKeyDefinition<T>) {
|
||||
this._keyDefinition = value;
|
||||
}
|
||||
}
|
||||
export class FakeActiveUserState<T> implements ActiveUserState<T> {
|
||||
[activeMarker]: true;
|
||||
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<{
|
||||
syncValue: boolean;
|
||||
combinedState: CombinedState<T>;
|
||||
}>(1);
|
||||
|
||||
state$: Observable<T>;
|
||||
combinedState$: Observable<CombinedState<T>>;
|
||||
|
||||
constructor(
|
||||
private activeAccountAccessor: MinimalAccountService,
|
||||
initialValue?: T,
|
||||
updateSyncCallback?: (userId: UserId, newValue: T) => Promise<void>,
|
||||
) {
|
||||
// Inform the state provider of updates to keep single user states in sync
|
||||
this.stateSubject.pipe(
|
||||
filter((next) => next.syncValue),
|
||||
concatMap(async ({ combinedState }) => {
|
||||
await updateSyncCallback?.(...combinedState);
|
||||
}),
|
||||
);
|
||||
this.nextState(initialValue ?? null, { syncValue: initialValue != null });
|
||||
|
||||
this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState));
|
||||
this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state));
|
||||
}
|
||||
|
||||
nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
|
||||
this.stateSubject.next({
|
||||
syncValue,
|
||||
combinedState: [this.activeAccountAccessor.activeUserId, state],
|
||||
});
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<[UserId, T | null]> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
: null;
|
||||
if (!options.shouldUpdate(current, combinedDependencies)) {
|
||||
return [this.activeAccountAccessor.activeUserId, current];
|
||||
}
|
||||
const newState = configureState(current, combinedDependencies);
|
||||
this.nextState(newState);
|
||||
this.nextMock([this.activeAccountAccessor.activeUserId, newState]);
|
||||
return [this.activeAccountAccessor.activeUserId, newState];
|
||||
}
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [[UserId, T]]>();
|
||||
|
||||
private _keyDefinition: UserKeyDefinition<T> | null = null;
|
||||
get keyDefinition() {
|
||||
if (this._keyDefinition == null) {
|
||||
throw new Error(
|
||||
"Key definition not yet set, usually this means your sut has not asked for this state yet",
|
||||
);
|
||||
}
|
||||
return this._keyDefinition;
|
||||
}
|
||||
set keyDefinition(value: UserKeyDefinition<T>) {
|
||||
this._keyDefinition = value;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>
|
||||
implements DerivedState<TTo>
|
||||
{
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<TTo>(1);
|
||||
|
||||
constructor(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
) {
|
||||
parentState$
|
||||
.pipe(
|
||||
concatMap(async (v) => {
|
||||
const newState = deriveDefinition.derive(v, dependencies);
|
||||
if (newState instanceof Promise) {
|
||||
return newState;
|
||||
}
|
||||
return Promise.resolve(newState);
|
||||
}),
|
||||
)
|
||||
.subscribe((newState) => {
|
||||
this.stateSubject.next(newState);
|
||||
});
|
||||
}
|
||||
|
||||
forceValue(value: TTo): Promise<TTo> {
|
||||
this.stateSubject.next(value);
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
forceValueMock = this.forceValue as jest.MockedFunction<typeof this.forceValue>;
|
||||
|
||||
get state$() {
|
||||
return this.stateSubject.asObservable();
|
||||
}
|
||||
}
|
||||
2
libs/state-test-utils/src/index.ts
Normal file
2
libs/state-test-utils/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./fake-state";
|
||||
export * from "./fake-state-provider";
|
||||
8
libs/state-test-utils/src/state-test-utils.spec.ts
Normal file
8
libs/state-test-utils/src/state-test-utils.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as lib from "./index";
|
||||
|
||||
describe("state-test-utils", () => {
|
||||
// This test will fail until something is exported from index.ts
|
||||
it("should work", () => {
|
||||
expect(lib).toBeDefined();
|
||||
});
|
||||
});
|
||||
6
libs/state-test-utils/tsconfig.eslint.json
Normal file
6
libs/state-test-utils/tsconfig.eslint.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": ["src/**/*.ts", "src/**/*.js"],
|
||||
"exclude": ["**/build", "**/dist"]
|
||||
}
|
||||
13
libs/state-test-utils/tsconfig.json
Normal file
13
libs/state-test-utils/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/state-test-utils/tsconfig.lib.json
Normal file
10
libs/state-test-utils/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
|
||||
}
|
||||
10
libs/state-test-utils/tsconfig.spec.json
Normal file
10
libs/state-test-utils/tsconfig.spec.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
679
libs/state/README.md
Normal file
679
libs/state/README.md
Normal file
@@ -0,0 +1,679 @@
|
||||
# `@bitwarden/state`
|
||||
|
||||
# State Provider Framework
|
||||
|
||||
The state provider framework was designed for the purpose of allowing state to be owned by domains
|
||||
but also to enforce good practices, reduce boilerplate around account switching, and provide a
|
||||
trustworthy observable stream of that state.
|
||||
|
||||
## APIs
|
||||
|
||||
- [Storage definitions](#storage-definitions)
|
||||
- [`StateDefinition`](#statedefinition)
|
||||
- [`KeyDefinition` & `UserKeyDefinition`](#keydefinition-and-userkeydefinition)
|
||||
- [`StateProvider`](#stateprovider)
|
||||
- [`Update`](#updating-state-with-update)
|
||||
- [`GlobalState<T>`](#globalstatet)
|
||||
- [`SingleUserState<T>`](#singleuserstatet)
|
||||
- [`ActiveUserState<T>`](#activeuserstatet)
|
||||
|
||||
### Storage definitions
|
||||
|
||||
In order to store and retrieve data, we need to have constant keys to reference storage locations.
|
||||
This includes a storage medium (disk or memory) and a unique key. `StateDefinition` and
|
||||
`KeyDefinition` classes allow for reasonable reuse of partial namespaces while also enabling
|
||||
expansion to precise keys. They exist to help minimize the potential of overlaps in a distributed
|
||||
storage framework.
|
||||
|
||||
> [!WARNING]
|
||||
> Once you have created the definitions you need to take extreme caution when changing any part of the
|
||||
> namespace. If you change the name of a `StateDefinition` pointing at `"disk"` without also migrating
|
||||
> data from the old name to the new name you will lose data. Data pointing at `"memory"` can have its
|
||||
> name changed.
|
||||
|
||||
#### `StateDefinition`
|
||||
|
||||
> [!NOTE]
|
||||
> Secure storage is not currently supported as a storage location in the State Provider Framework. For
|
||||
> now, don't migrate data that is stored in secure storage but please contact the Platform team when
|
||||
> you have data you wanted to migrate so we can prioritize a long-term solution. If you need new data
|
||||
> in secure storage, use `StateService` for now.
|
||||
|
||||
`StateDefinition` is a simple API but a very core part of making the State Provider Framework work
|
||||
smoothly. It defines a storage location and top-level namespace for storage. Teams will interact
|
||||
with it only in a single `state-definitions.ts` file in the
|
||||
[`clients`](https://github.com/bitwarden/clients) repository. This file is located under Platform
|
||||
team code ownership but teams are expected to create edits to it. A team will edit this file to
|
||||
include a line such as:
|
||||
|
||||
```typescript
|
||||
export const MY_DOMAIN_DISK = new StateDefinition("myDomain", "disk");
|
||||
```
|
||||
|
||||
The first argument to the `StateDefinition` constructor is expected to be a human readable,
|
||||
camelCase-formatted name for your domain or state area. The second argument will either be the
|
||||
string literal `"disk"` or `"memory"` dictating where all the state using this `StateDefinition`
|
||||
should be stored.
|
||||
|
||||
The Platform team is responsible for reviewing all new and updated entries in this file and makes
|
||||
sure that there are no duplicate entries containing the same state name and state location. Teams
|
||||
are able to have the same state name used for both `"disk"` and `"memory"` locations. Tests are
|
||||
included to ensure this uniqueness and core naming guidelines so teams can ensure a review for a new
|
||||
`StateDefinition` entry is done promptly and with very few surprises.
|
||||
|
||||
##### Client-specific storage locations
|
||||
|
||||
An optional third parameter to the `StateDefinition` constructor is provided if you need to specify
|
||||
client-specific storage location for your state.
|
||||
|
||||
This will most commonly be used to handle the distinction between session and local storage on the
|
||||
web client. The default `"disk"` storage for the web client is session storage, and local storage
|
||||
can be specified by defining your state as:
|
||||
|
||||
```typescript
|
||||
export const MY_DOMAIN_DISK = new StateDefinition("myDomain", "disk", { web: "disk-local" });
|
||||
```
|
||||
|
||||
#### `KeyDefinition` and `UserKeyDefinition`
|
||||
|
||||
`KeyDefinition` and `UserKeyDefinition` build on the [`StateDefinition`](#statedefinition),
|
||||
specifying a single element of state data within the `StateDefinition`.
|
||||
|
||||
The framework provides both `KeyDefinition` and `UserKeyDefinition` for teams to use. Use
|
||||
`UserKeyDefinition` for state scoped to a user and `KeyDefinition` for user-independent state. These
|
||||
will be consumed via the [`SingleUserState<T>`](#singleuserstatet) or
|
||||
[`ActiveUserState<T>`](#activeuserstatet) within your consuming services and components. The
|
||||
`UserKeyDefinition` extends the `KeyDefinition` and provides a way to specify how the state will be
|
||||
cleaned up on specific user account actions.
|
||||
|
||||
`KeyDefinition`s and `UserKeyDefinition`s can also be instantiated in your own team's code. This
|
||||
might mean creating it in the same file as the service you plan to consume it or you may want to
|
||||
have a single `key-definitions.ts` file that contains all the entries for your team. Some example
|
||||
instantiations are:
|
||||
|
||||
```typescript
|
||||
const MY_DOMAIN_DATA = new UserKeyDefinition<MyState>(MY_DOMAIN_DISK, "data", {
|
||||
// convert to your data from serialized representation `{ foo: string }` to fully-typed `MyState`
|
||||
deserializer: (jsonData) => MyState.fromJSON(jsonData),
|
||||
clearOn: ["logout"], // can be lock, logout, both, or an empty array
|
||||
});
|
||||
|
||||
// Or if your state is an array, use the built-in helper
|
||||
const MY_DOMAIN_DATA: UserKeyDefinition<MyStateElement[]> = UserKeyDefinition.array<MyStateElement>(
|
||||
MY_DOMAIN_DISK,
|
||||
"data",
|
||||
{
|
||||
deserializer: (jsonDataElement) => MyState.fromJSON(jsonDataElement), // provide a deserializer just for the element of the array
|
||||
},
|
||||
{
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
// record
|
||||
const MY_DOMAIN_DATA: UserKeyDefinition<Record<string, MyStateElement>> =
|
||||
KeyDefinition.record<MyStateValue>(MY_DOMAIN_DISK, "data", {
|
||||
deserializer: (jsonDataValue) => MyState.fromJSON(jsonDataValue), // provide a deserializer just for the value in each key-value pair
|
||||
clearOn: ["logout"],
|
||||
});
|
||||
```
|
||||
|
||||
The arguments for defining a `KeyDefinition` or `UserKeyDefinition` are:
|
||||
|
||||
| Argument | Usage |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `stateDefinition` | The `StateDefinition` to which that this key belongs |
|
||||
| `key` | A human readable, camelCase-formatted name for the key definition. This name should be unique amongst all other `KeyDefinition`s or `UserKeyDefinition`s that consume the same `StateDefinition`. |
|
||||
| `options` | An object of type [`KeyDefinitionOptions`](#key-definition-options) or [`UserKeyDefinitionOptions`](#key-definition-options), which defines the behavior of the key. |
|
||||
|
||||
> [!WARNING]
|
||||
> It is the responsibility of the team to ensure the uniqueness of the `key` within a
|
||||
> `StateDefinition`. As such, you should never consume the `StateDefinition` of another team in your
|
||||
> own key definition.
|
||||
|
||||
##### Key Definition Options
|
||||
|
||||
| Option | Required? | Usage |
|
||||
| ---------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `deserializer` | Yes | Takes a method that gives you your state in it's JSON format and makes you responsible for converting that into JSON back into a full JavaScript object, if you choose to use a class to represent your state that means having its prototype and any method you declare on it. If your state is a simple value like `string`, `boolean`, `number`, or arrays of those values, your deserializer can be as simple as `data => data`. But, if your data has something like `Date`, which gets serialized as a string you will need to convert that back into a `Date` like: `data => new Date(data)`. |
|
||||
| `cleanupDelayMs` | No | Takes a number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. Defaults to 1000ms. When this is set to 0, no `share()` is used on the underlying observable stream. |
|
||||
| `clearOn` | Yes, for `UserKeyDefinition` | An additional parameter provided for `UserKeyDefinition` **only**, which allows specification of the user account `ClearEvent`s that will remove the piece of state from persistence. The available values for `ClearEvent` are `logout`, `lock`, or both. An empty array should be used if the state should not ever be removed (e.g. for settings). |
|
||||
|
||||
### `StateProvider`
|
||||
|
||||
`StateProvider` is an injectable service that includes four methods for getting state, expressed in
|
||||
the type definition below:
|
||||
|
||||
```typescript
|
||||
interface StateProvider {
|
||||
getGlobal<T>(keyDefinition: KeyDefinition<T>): GlobalState<T>;
|
||||
getUser<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T>;
|
||||
getDerived<TFrom, TTo, TDeps>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependenciess: TDeps,
|
||||
);
|
||||
// Deprecated, do not use.
|
||||
getActive<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T>;
|
||||
}
|
||||
```
|
||||
|
||||
These methods are helpers for invoking their more modular siblings `SingleUserStateProvider.get`,
|
||||
`GlobalStateProvider.get`, `DerivedStateProvider.get`, and `ActiveUserStateProvider.get`. These siblings
|
||||
can all be injected into your service as well. If you prefer thin dependencies over the slightly
|
||||
larger changeset required, you can absolutely make use of the more targeted providers.
|
||||
|
||||
> [!WARNING] > `ActiveUserState` is deprecated
|
||||
>
|
||||
> The `ActiveUserStateProvider.get` and its helper `getActive<T>` are deprecated. See
|
||||
> [here](#should-i-use-activeuserstate) for details.
|
||||
|
||||
You will most likely use `StateProvider` in a domain service that is responsible for managing the
|
||||
state, with the state values being scoped to a single user. The `StateProvider` should be injected
|
||||
as a `private` member into the class, with the `getUser()` helper method to retrieve the current
|
||||
state value for the provided `userId`. See a simple example below:
|
||||
|
||||
```typescript
|
||||
import { DOMAIN_USER_STATE } from "../key-definitions";
|
||||
|
||||
class DomainService {
|
||||
constructor(private stateProvider: StateProvider) {}
|
||||
|
||||
private getStateValue(userId: UserId): SingleUserState<DomainObject> {
|
||||
return this.stateProvider.getUser(userId, DOMAIN_USER_STATE);
|
||||
}
|
||||
|
||||
async clearStateValue(userId: UserId): Promise<void> {
|
||||
await this.stateProvider.getUser(userId, DOMAIN_USER_STATE).update((state) => null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each of the methods on the `StateProvider` will return an object typed based on the state requested:
|
||||
|
||||
#### `GlobalState<T>`
|
||||
|
||||
`GlobalState<T>` is an object to help you maintain and view the state of global-scoped storage. You
|
||||
can see the type definition of the API on `GlobalState<T>` below:
|
||||
|
||||
```typescript
|
||||
interface GlobalState<T> {
|
||||
state$: Observable<T | null>;
|
||||
}
|
||||
```
|
||||
|
||||
The `state$` property provides you with an `Observable<T | null>` that can be subscribed to.
|
||||
`GlobalState<T>.state$` will emit when the chosen storage location emits an update to the state
|
||||
defined by the corresponding `KeyDefinition`.
|
||||
|
||||
#### `SingleUserState<T>`
|
||||
|
||||
`SingleUserState<T>` behaves very similarly to `GlobalState<T>`, but for state that is defined as
|
||||
user-scoped with a `UserKeyDefinition`. The `UserId` for the state's user exposed as a `readonly`
|
||||
member.
|
||||
|
||||
The `state$` property provides you with an `Observable<T | null>` that can be subscribed to.
|
||||
`SingleUserState<T>.state$` will emit when the chosen storage location emits an update to the state
|
||||
defined by the corresponding `UserKeyDefinition` for the requested `userId`.
|
||||
|
||||
> [!NOTE]
|
||||
> Updates to `SingleUserState` or `ActiveUserState` handling the same `KeyDefinition` will cause each
|
||||
> other to emit on their `state$` observables if the `userId` handled by the `SingleUserState` happens
|
||||
> to be active at the time of the update.
|
||||
|
||||
### `DerivedState<T>`
|
||||
|
||||
For details on how to use derived state, see [Derived State](#derived-state).
|
||||
|
||||
### `ActiveUserState<T>`
|
||||
|
||||
> [!WARNING] > `ActiveUserState` has race condition problems. Do not add usages and consider transitioning your
|
||||
> code to SingleUserState instead. [Read more.](#should-i-use-activeuserstate)
|
||||
|
||||
`ActiveUserState<T>` is an object to help you maintain and view the state of the currently active
|
||||
user. If the currently-active user changes, like through account switching, the data this object
|
||||
represents will change along with it.
|
||||
|
||||
### Updating state with `update`
|
||||
|
||||
The update method has options defined as follows:
|
||||
|
||||
```typescript
|
||||
{ActiveUser|SingleUser|Global}State<T> {
|
||||
// ... rest of type left out for brevity
|
||||
update<TCombine>(updateState: (state: T, dependency: TCombine) => T, options?: StateUpdateOptions);
|
||||
}
|
||||
|
||||
type StateUpdateOptions = {
|
||||
shouldUpdate?: (state: T, dependency: TCombine) => boolean;
|
||||
combineLatestWith?: Observable<TCombine>;
|
||||
msTimeout?: number
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING] > `firstValueFrom()` and state updates
|
||||
>
|
||||
> A usage pattern of updating state and then immediately requesting a value through `firstValueFrom()` > **will not always result in the updated value being returned**. This is because we cannot guarantee
|
||||
> that the update has taken place before the `firstValueFrom()` executes, in which case the previous
|
||||
> (cached) value of the observable will be returned.
|
||||
>
|
||||
> Use of `firstValueFrom()` should be avoided. If you find yourself trying to use `firstValueFrom()`,
|
||||
> consider propagating the underlying observable instead of leaving reactivity.
|
||||
>
|
||||
> If you do need to obtain the result of an update in a non-reactive way, you should use the result
|
||||
> returned from the `update()` method. The `update()` will return the value that will be persisted
|
||||
> to
|
||||
> state, after any `shouldUpdate()` filters are applied.
|
||||
|
||||
#### Using `shouldUpdate` to filter unnecessary updates
|
||||
|
||||
We recommend using `shouldUpdate` when possible. This will avoid unnecessary I/O for redundant
|
||||
updates and avoid an unnecessary emission of `state$`. The `shouldUpdate` method gives you in its
|
||||
first parameter the value of state before any change has been made, and the dependency you have,
|
||||
optionally, provided through `combineLatestWith`.
|
||||
|
||||
If your state is a simple JavaScript primitive type, this can be done with the strict equality
|
||||
operator (`===`):
|
||||
|
||||
```typescript
|
||||
const USES_KEYCONNECTOR: UserKeyDefinition<boolean> = ...;
|
||||
|
||||
async setUsesKeyConnector(value: boolean, userId: UserId) {
|
||||
// Only do the update if the current value saved in state
|
||||
// differs in equality of the incoming value.
|
||||
await this.stateProvider.getUser(userId, USES_KEYCONNECTOR).update(
|
||||
currentValue => currentValue !== value
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For more complex state, implementing a custom equality operator is recommended. It's important that
|
||||
if you implement an equality function that you then negate the output of that function for use in
|
||||
`shouldUpdate()` since you will want to go through the update when they are NOT the same value.
|
||||
|
||||
```typescript
|
||||
type Cipher = { id: string, username: string, password: string, revisionDate: Date };
|
||||
const LAST_USED_CIPHER: UserKeyDefinition<Cipher> = ...;
|
||||
|
||||
async setLastUsedCipher(lastUsedCipher: Cipher | null, userId: UserId) {
|
||||
await this.stateProvider.getUser(userId, LAST_USED_CIPHER).update(
|
||||
currentValue => !this.areEqual(currentValue, lastUsedCipher)
|
||||
);
|
||||
}
|
||||
|
||||
areEqual(a: Cipher | null, b: Cipher | null) {
|
||||
if (a == null) {
|
||||
return b == null;
|
||||
}
|
||||
|
||||
if (b == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Option one - Full equality, comparing every property for value equality
|
||||
return a.id === b.id &&
|
||||
a.username === b.username &&
|
||||
a.password === b.password &&
|
||||
a.revisionDate === b.revisionDate;
|
||||
|
||||
// Option two - Partial equality based on requirement that any update would
|
||||
// bump the revision date.
|
||||
return a.id === b.id && a.revisionDate === b.revisionDate;
|
||||
}
|
||||
```
|
||||
|
||||
#### Using `combineLatestWith` option to control updates
|
||||
|
||||
The `combineLatestWith` option can be useful when updates to your state depend on the data from
|
||||
another stream of data.
|
||||
|
||||
For example, if we were asked to set a `userId` to the active account only if that `userId` exists
|
||||
in our known accounts list, an initial approach could do the check as follows:
|
||||
|
||||
```typescript
|
||||
const accounts = await firstValueFrom(this.accounts$);
|
||||
if (accounts?.[userId] == null) {
|
||||
throw new Error();
|
||||
}
|
||||
await this.activeAccountIdState.update(() => userId);
|
||||
```
|
||||
|
||||
However, this implementation has a few subtle issues that the `combineLatestWith` option addresses:
|
||||
|
||||
- The use of `firstValueFrom` with no `timeout`. Behind the scenes we enforce that the observable
|
||||
given to `combineLatestWith` will emit a value in a timely manner, in this case a `1000ms`
|
||||
timeout, but that number is configurable through the `msTimeout` option.
|
||||
- We don't guarantee that your `updateState` callback is called the instant that the `update` method
|
||||
is called. We do, however, promise that it will be called before the returned promise resolves or
|
||||
rejects. This may be because we have a lock on the current storage key. No such locking mechanism
|
||||
exists today but it may be implemented in the future. As such, it is safer to use
|
||||
`combineLatestWith` because the data is more likely to retrieved closer to when it needs to be
|
||||
evaluated.
|
||||
|
||||
We recommend instead using the `combineLatestWith` option within the `update()` method to address
|
||||
these issues:
|
||||
|
||||
```typescript
|
||||
await this.activeAccountIdState.update(
|
||||
(_, accounts) => {
|
||||
if (userId == null) {
|
||||
// indicates no account is active
|
||||
return null;
|
||||
}
|
||||
if (accounts?.[userId] == null) {
|
||||
throw new Error("Account does not exist");
|
||||
}
|
||||
return userId;
|
||||
},
|
||||
{
|
||||
combineLatestWith: this.accounts$,
|
||||
shouldUpdate: (id) => {
|
||||
// update only if userId changes
|
||||
return id !== userId;
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
`combineLatestWith` can also be used to handle updates where either the new value depends on `async`
|
||||
code or you prefer to handle generation of a new value in an observable transform flow:
|
||||
|
||||
```typescript
|
||||
const state = this.stateProvider.get(userId, SavedFiltersStateDefinition);
|
||||
|
||||
const transform: OperatorFunction<any, any> = pipe(
|
||||
// perform some transforms
|
||||
map((value) => value),
|
||||
);
|
||||
|
||||
async function transformAsync<T>(value: T) {
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
|
||||
await state.update((_, newState) => newState, {
|
||||
// Actual processing to generate the new state is done here
|
||||
combineLatestWith: state.state$.pipe(
|
||||
mergeMap(async (old) => {
|
||||
return await transformAsync(old);
|
||||
}),
|
||||
transform,
|
||||
),
|
||||
shouldUpdate: (oldState, newState) => !areEqual(oldState, newState),
|
||||
});
|
||||
```
|
||||
|
||||
#### Conditions under which emission not guaranteed after `update()`
|
||||
|
||||
The `state$` property is **not guaranteed** to emit a value after an update where the value would
|
||||
conventionally be considered equal. It _is_ emitted in many cases but not guaranteed. The reason for
|
||||
this is because we leverage on platform APIs to initiate state emission. In particular, we use the
|
||||
`chrome.storage.{area}.onChanged` event to facilitate the `state$` observable in the extension
|
||||
client, and Chrome won’t emit a change if the value is the same. You can easily see this with the
|
||||
below instructions:
|
||||
|
||||
```
|
||||
chrome.storage.local.onChanged.addListener(console.log);
|
||||
chrome.storage.local.set({ key: true });
|
||||
chrome.storage.local.set({ key: true });
|
||||
```
|
||||
|
||||
The second instance of calling `set` will not log a changed event. As a result, the `state$` relying
|
||||
on this value will not emit. Due to nuances like this, using a `StateProvider` as an event stream is
|
||||
discouraged, and we recommend using [`MessageSender`](https://github.com/bitwarden/clients/blob/main/libs/messaging/src/message.sender.ts) for events that you always want sent to
|
||||
subscribers.
|
||||
|
||||
## Testing
|
||||
|
||||
Testing business logic with data and observables can sometimes be cumbersome. To help make that a
|
||||
little easier there are a suite of helpful "fakes" that can be used instead of traditional "mocks".
|
||||
Now instead of calling `mock<StateProvider>()` into your service you can instead use
|
||||
`new FakeStateProvider()`.
|
||||
|
||||
`FakeStateProvider` exposes the specific provider's fakes as properties on itself. Each of those
|
||||
specific providers gives a method `getFake` that allows you to get the fake version of state that
|
||||
you can control and `expect`.
|
||||
|
||||
## Migrating
|
||||
|
||||
Migrating data to state providers is incredibly similar to migrating data in general. You create
|
||||
your own class that extends `Migrator<From, To>`. That will require you to implement your own
|
||||
`migrate(migrationHelper: MigrationHelper)` method. `MigrationHelper` already includes methods like
|
||||
`get` and `set` for getting and settings value to storage by their string key. There are also
|
||||
methods for getting and setting using your `KeyDefinition` or `KeyDefinitionLike` object to and from
|
||||
user and global state.
|
||||
|
||||
For examples of migrations, you can reference the
|
||||
[existing](https://github.com/bitwarden/clients/tree/main/libs/common/src/state-migrations/migrations)
|
||||
migrations list.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Do I need to have my own in-memory cache?
|
||||
|
||||
If you previously had a memory cache that exactly represented the data you stored on disk (not
|
||||
decrypted for example), then you likely don't need that anymore. All the `*State` classes maintain
|
||||
an in memory cache of the last known value in state for as long as someone is subscribed to the
|
||||
data. The cache is cleared after 1000ms of no one subscribing to the state though. If you know you
|
||||
have sporadic subscribers and a high cost of going to disk you may increase that time using the
|
||||
`cleanupDelayMs` on `KeyDefinitionOptions`.
|
||||
|
||||
### I store my data as a Record / Map but expose it as an array -- what should I do?
|
||||
|
||||
Give `KeyDefinition<T>` generic the record shape you want, or even use the static `record` helper
|
||||
method. Then to convert that to an array that you expose just do a simple
|
||||
`.pipe(map(data => this.transform(data)))` to convert that to the array you want to expose.
|
||||
|
||||
### Why `KeyDefinitionLike`?
|
||||
|
||||
`KeyDefinitionLike` exists to help you create a frozen-in-time version of your `KeyDefinition`. This
|
||||
is helpful in state migrations so that you don't have to import something from the greater
|
||||
application which is something that should rarely happen.
|
||||
|
||||
### When does my deserializer run?
|
||||
|
||||
The `deserialier` that you provide in the `KeyDefinitionOptions` is used whenever your state is
|
||||
retrieved from a storage service that stores its data as JSON. All disk storage services serialize
|
||||
data into JSON but memory storage differs in this area across platforms. That's why it's imperative
|
||||
to include a high quality JSON deserializer even if you think your object will only be stored in
|
||||
memory. This can mean you might be able to drop the `*Data` class pattern for your code. Since the
|
||||
`*Data` class generally represented the JSON safe version of your state which we now do
|
||||
automatically through the `Jsonify<T>` given to your in your `deserializer` method.
|
||||
|
||||
### Should I use `ActiveUserState`?
|
||||
|
||||
Probably not, `ActiveUserState` is either currently in the process of or already completed the
|
||||
removal of its `update` method. This will effectively make it readonly, but you should consider
|
||||
maybe not even using it for reading either. `update` is actively bad, while reading is just not as
|
||||
dynamic of a API design.
|
||||
|
||||
Take the following example:
|
||||
|
||||
```typescript
|
||||
private folderState: ActiveUserState<Record<string, Folder>>
|
||||
|
||||
renameFolder(folderId: string, newName: string) {
|
||||
// Get state
|
||||
const folders = await firstValueFrom(this.folderState.state$);
|
||||
// Mutate state
|
||||
folders[folderId].name = await encryptString(newName);
|
||||
// Save state
|
||||
await this.folderState.update(() => folders);
|
||||
}
|
||||
```
|
||||
|
||||
You can imagine a scenario where the active user changes between the read and the write. This would
|
||||
be a big problem because now user A's folders was stored in state for user B. By taking a user id
|
||||
and utilizing `SingleUserState` instead you can avoid this problem by passing ensuring both
|
||||
operation happen for the same user. This is obviously an extreme example where the point between the
|
||||
read and write is pretty minimal but there are places in our application where the time between is
|
||||
much larger. Maybe information is read out and placed into a form for editing and then the form can
|
||||
be submitted to be saved.
|
||||
|
||||
The first reason for why you maybe shouldn't use `ActiveUserState` for reading is for API
|
||||
flexibility. Even though you may not need an API to return the data of a non-active user right now,
|
||||
you or someone else may want to. If you have a method that takes the `UserId` then it can be
|
||||
consumed by someone passing in the active user or by passing a non-active user. You can now have a
|
||||
single API that is useful in multiple scenarios.
|
||||
|
||||
The other reason is so that you can more cleanly switch users to new data when multiple streams are
|
||||
in play. Consider the following example:
|
||||
|
||||
```typescript
|
||||
const view$ = combineLatest([
|
||||
this.folderService.activeUserFolders$,
|
||||
this.cipherService.activeUserCiphers$,
|
||||
]).pipe(map(([folders, ciphers]) => buildView(folders, ciphers)));
|
||||
```
|
||||
|
||||
Since both are tied to the active user, you will get one emission when first subscribed to and
|
||||
during an account switch, you will likely get TWO other emissions. One for each, inner observable
|
||||
reacting to the new user. This could mean you try to combine the folders and ciphers of two
|
||||
accounts. This is ideally not a huge issue because the last emission will have the same users data
|
||||
but it's not ideal, and easily avoidable. Instead you can write it like this:
|
||||
|
||||
```typescript
|
||||
const view$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) => {
|
||||
if (account == null) {
|
||||
throw new Error("This view should only be viewable while there is an active user.");
|
||||
}
|
||||
|
||||
return combineLatest([
|
||||
this.folderService.userFolders$(account.id),
|
||||
this.cipherService.userCiphers$(account.id),
|
||||
]);
|
||||
}),
|
||||
map(([folders, ciphers]) => buildView(folders, ciphers)),
|
||||
);
|
||||
```
|
||||
|
||||
You have to write a little more code but you do a few things that might force you to think about the
|
||||
UX and rules around when this information should be viewed. With `ActiveUserState` it will simply
|
||||
not emit while there is no active user. But with this, you can choose what to do when there isn't an
|
||||
active user and you could simple add a `first()` to the `activeAccount$` pipe if you do NOT want to
|
||||
support account switching. An account switch will also emit the `combineLatest` information a single
|
||||
time and the info will be always for the same account.
|
||||
|
||||
## Structure
|
||||
|
||||

|
||||
|
||||
## Derived State
|
||||
|
||||
It is common to need to cache the result of expensive work that does not represent true alterations
|
||||
in application state. Derived state exists to store this kind of data in memory and keep it up to
|
||||
date when the underlying observable state changes.
|
||||
|
||||
## `DeriveDefinition`
|
||||
|
||||
Derived state has all of the same issues with storage and retrieval that normal state does. Similar
|
||||
to `KeyDefinition`, derived state depends on `DeriveDefinition`s to define magic string keys to
|
||||
store and retrieve data from a cache. Unlike normal state, derived state is always stored in memory.
|
||||
It still takes a `StateDefinition`, but this is used only to define a namespace for the derived
|
||||
state, the storage location is ignored. _This can lead to collisions if you use the same key for two
|
||||
different derived state definitions in the same namespace._
|
||||
|
||||
Derive definitions can be created in two ways:
|
||||
|
||||
<a name="deriveDefinitionFactories"></a>
|
||||
|
||||
```typescript
|
||||
new DeriveDefinition(STATE_DEFINITION, "uniqueKey", _DeriveOptions_);
|
||||
|
||||
// or
|
||||
|
||||
const keyDefinition: KeyDefinition<T>;
|
||||
DeriveDefinition.from(keyDefinition, _DeriveOptions_);
|
||||
```
|
||||
|
||||
The first allows building from basic building blocks, the second recognizes that derived state is
|
||||
often built from existing state and allows you to create a definition from an existing
|
||||
`KeyDefinition`. The resulting `DeriveDefinition` will have the same state namespace, key, and
|
||||
`TFrom` type as the `KeyDefinition` it was built from.
|
||||
|
||||
### Type Parameters
|
||||
|
||||
`DeriveDefinition`s have three type parameters:
|
||||
|
||||
- `TFrom`: The type of the state that the derived state is built from.
|
||||
- `TTo`: The type of the derived state.
|
||||
- `TDeps`: defines the dependencies required to derive the state. This is further discussed in
|
||||
[Derive Definition Options](#derivedefinitionoptions).
|
||||
|
||||
### `DeriveDefinitionOptions`
|
||||
|
||||
[The `DeriveDefinition` section](#deriveDefinitionFactories) specifies a third parameter as
|
||||
`_DeriveOptions_`, which is used to fully specify the way to transform `TFrom` to `TTo`.
|
||||
|
||||
- `deserializer` - For the same reasons as [Key Definition Options](#keydefinitionoptions),
|
||||
`DeriveDefinition`s require have a `deserializer` function that is used to convert the stored data
|
||||
back into the `TTo` type.
|
||||
- `derive` - A function that takes the current state and returns the derived state. This function
|
||||
takes two parameters:
|
||||
- `from` - The latest value of the parent state.
|
||||
- `deps` - dependencies used to instantiate the derived state. These are provided when the
|
||||
`DerivedState` class is instantiated. This object should contain all of the application runtime
|
||||
dependencies for transform the from parent state to the derived state.
|
||||
- `cleanupDelayMs` (optional) - Takes the number of milliseconds to wait before cleaning up the
|
||||
state after the last subscriber unsubscribes. Defaults to 1000ms. If you have a particularly
|
||||
expensive operation, such as decryption of a vault, it may be worth increasing this value to avoid
|
||||
unnecessary recomputation.
|
||||
|
||||
Specifying dependencies required for your `derive` function is done through the type parameters on
|
||||
`DerivedState`.
|
||||
|
||||
```typescript
|
||||
new DerivedState<TFrom, TTo, { example: Dependency }>();
|
||||
```
|
||||
|
||||
would require a `deps` object with an `example` property of type `Dependency` to be passed to any
|
||||
`DerivedState` configured to use the `DerivedDefinition`.
|
||||
|
||||
> [!WARNING]
|
||||
> Both `derive` and `deserializer` functions should take null inputs into consideration. Both parent
|
||||
> state and stored data for deserialization can be `null` or `undefined`.
|
||||
|
||||
## `DerivedStateProvider`
|
||||
|
||||
The `DerivedState<TFrom, TTo, TDeps>` class has a purpose-built provider which instantiates the
|
||||
correct `DerivedState` implementation for a given application context. These derived states are
|
||||
cached within a context, so that multiple instances of the same derived state will share the same
|
||||
underlying cache, based on the `DeriveDefinition` used to create them.
|
||||
|
||||
Instantiating a `DerivedState` instance requires an observable parent state, the derive definition,
|
||||
and an object containing the dependencies defined in the `DeriveDefinition` type parameters.
|
||||
|
||||
```typescript
|
||||
interface DerivedStateProvider {
|
||||
get: <TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
) => DerivedState<TTo>;
|
||||
}
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Any observable can be used as the parent state. If you need to perform some kind of work on data
|
||||
> stored to disk prior to sending to your `derive` functions, that is supported.
|
||||
|
||||
## `DerivedState`
|
||||
|
||||
`DerivedState` is intended to be built with a provider rather than directly instantiated. The
|
||||
interface consists of two items:
|
||||
|
||||
```typescript
|
||||
interface DerivedState<T> {
|
||||
state$: Observable<T>;
|
||||
forceValue(value: T): Promise<T>;
|
||||
}
|
||||
```
|
||||
|
||||
- `state$` - An observable that emits the current value of the derived state and emits new values
|
||||
whenever the parent state changes.
|
||||
- `forceValue` - A function that takes a value and immediately sets `state$` to that value. This is
|
||||
useful for clearing derived state from memory without impacting the parent state, such as during
|
||||
logout.
|
||||
|
||||
> [!NOTE] > `forceValue` forces `state$` _once_. It does not prevent the derived state from being recomputed
|
||||
> when the parent state changes.
|
||||
3
libs/state/eslint.config.mjs
Normal file
3
libs/state/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
10
libs/state/jest.config.js
Normal file
10
libs/state/jest.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
displayName: "state",
|
||||
preset: "../../jest.preset.js",
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "html"],
|
||||
coverageDirectory: "../../coverage/libs/state",
|
||||
};
|
||||
11
libs/state/package.json
Normal file
11
libs/state/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@bitwarden/state",
|
||||
"version": "0.0.1",
|
||||
"description": "Centralized application state management",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "GPL-3.0",
|
||||
"author": "platform"
|
||||
}
|
||||
33
libs/state/project.json
Normal file
33
libs/state/project.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "state",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/state/src",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:state", "type:lib", "!dependsOn:common"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/state",
|
||||
"main": "libs/state/src/index.ts",
|
||||
"tsConfig": "libs/state/tsconfig.lib.json",
|
||||
"assets": ["libs/state/*.md"]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/state/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/state/jest.config.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user