diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 29bff87460..2665f34556 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/libs/client-type/README.md b/libs/client-type/README.md new file mode 100644 index 0000000000..3a29bea584 --- /dev/null +++ b/libs/client-type/README.md @@ -0,0 +1,5 @@ +# client-type + +Owned by: platform + +Exports the ClientType enum diff --git a/libs/client-type/eslint.config.mjs b/libs/client-type/eslint.config.mjs new file mode 100644 index 0000000000..9c37d10e3f --- /dev/null +++ b/libs/client-type/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/client-type/jest.config.js b/libs/client-type/jest.config.js new file mode 100644 index 0000000000..f54ab83aa3 --- /dev/null +++ b/libs/client-type/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "client-type", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/client-type", +}; diff --git a/libs/client-type/package.json b/libs/client-type/package.json new file mode 100644 index 0000000000..1db72603bf --- /dev/null +++ b/libs/client-type/package.json @@ -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" +} diff --git a/libs/client-type/project.json b/libs/client-type/project.json new file mode 100644 index 0000000000..8231e6634e --- /dev/null +++ b/libs/client-type/project.json @@ -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" + } + } + } +} diff --git a/libs/client-type/src/client-type.spec.ts b/libs/client-type/src/client-type.spec.ts new file mode 100644 index 0000000000..a178bba394 --- /dev/null +++ b/libs/client-type/src/client-type.spec.ts @@ -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(); + }); +}); diff --git a/libs/client-type/src/index.ts b/libs/client-type/src/index.ts new file mode 100644 index 0000000000..25e9d6f337 --- /dev/null +++ b/libs/client-type/src/index.ts @@ -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", +} diff --git a/libs/client-type/tsconfig.eslint.json b/libs/client-type/tsconfig.eslint.json new file mode 100644 index 0000000000..3daf120441 --- /dev/null +++ b/libs/client-type/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/client-type/tsconfig.json b/libs/client-type/tsconfig.json new file mode 100644 index 0000000000..62ebbd9464 --- /dev/null +++ b/libs/client-type/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/client-type/tsconfig.lib.json b/libs/client-type/tsconfig.lib.json new file mode 100644 index 0000000000..9cbf673600 --- /dev/null +++ b/libs/client-type/tsconfig.lib.json @@ -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"] +} diff --git a/libs/client-type/tsconfig.spec.json b/libs/client-type/tsconfig.spec.json new file mode 100644 index 0000000000..1275f148a1 --- /dev/null +++ b/libs/client-type/tsconfig.spec.json @@ -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"] +} diff --git a/libs/common/spec/fake-state-provider.ts b/libs/common/spec/fake-state-provider.ts index d666040205..73aadc7931 100644 --- a/libs/common/spec/fake-state-provider.ts +++ b/libs/common/spec/fake-state-provider.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; - - 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; - - activeAccount$: Observable<{ id: UserId }>; - - switch(user: UserId | null) { - this._subject.next(user); - } -} - -export class FakeGlobalStateProvider implements GlobalStateProvider { - mock = mock(); - establishedMocks: Map> = new Map(); - states: Map> = new Map(); - get(keyDefinition: KeyDefinition): GlobalState { - this.mock.get(keyDefinition); - const cacheKey = this.cacheKey(keyDefinition); - let result = this.states.get(cacheKey); - - if (result == null) { - let fake: FakeGlobalState; - // Look for established mock - if (this.establishedMocks.has(keyDefinition.key)) { - fake = this.establishedMocks.get(keyDefinition.key) as FakeGlobalState; - } else { - fake = new FakeGlobalState(); - } - fake.keyDefinition = keyDefinition; - result = fake; - this.states.set(cacheKey, result); - - result = new FakeGlobalState(); - this.states.set(cacheKey, result); - } - return result as GlobalState; - } - - private cacheKey(keyDefinition: KeyDefinition) { - return `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`; - } - - getFake(keyDefinition: KeyDefinition): FakeGlobalState { - return this.get(keyDefinition) as FakeGlobalState; - } - - mockFor(keyDefinition: KeyDefinition, initialValue?: T): FakeGlobalState { - const cacheKey = this.cacheKey(keyDefinition); - if (!this.states.has(cacheKey)) { - this.states.set(cacheKey, new FakeGlobalState(initialValue)); - } - return this.states.get(cacheKey) as FakeGlobalState; - } -} - -export class FakeSingleUserStateProvider implements SingleUserStateProvider { - mock = mock(); - states: Map> = new Map(); - - constructor( - readonly updateSyncCallback?: ( - key: UserKeyDefinition, - userId: UserId, - newValue: unknown, - ) => Promise, - ) {} - - get(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState { - 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; - } - - getFake( - userId: UserId, - userKeyDefinition: UserKeyDefinition, - { allowInit }: { allowInit: boolean } = { allowInit: true }, - ): FakeSingleUserState { - if (!allowInit && this.states.get(this.cacheKey(userId, userKeyDefinition)) == null) { - return null; - } - - return this.get(userId, userKeyDefinition) as FakeSingleUserState; - } - - mockFor( - userId: UserId, - userKeyDefinition: UserKeyDefinition, - initialValue?: T, - ): FakeSingleUserState { - 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; - } - - private buildFakeState( - userId: UserId, - userKeyDefinition: UserKeyDefinition, - 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) { - return `${userKeyDefinitionCacheKey(userKeyDefinition)}_${userId}`; - } -} - -export class FakeActiveUserStateProvider implements ActiveUserStateProvider { - activeUserId$: Observable; - states: Map> = new Map(); - - constructor( - public accountServiceAccessor: MinimalAccountService, - readonly updateSyncCallback?: ( - key: UserKeyDefinition, - userId: UserId, - newValue: unknown, - ) => Promise, - ) { - this.activeUserId$ = accountServiceAccessor.activeAccount$.pipe(map((a) => a?.id)); - } - - get(userKeyDefinition: UserKeyDefinition): ActiveUserState { - 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; - } - - getFake( - userKeyDefinition: UserKeyDefinition, - { allowInit }: { allowInit: boolean } = { allowInit: true }, - ): FakeActiveUserState { - if (!allowInit && this.states.get(userKeyDefinitionCacheKey(userKeyDefinition)) == null) { - return null; - } - return this.get(userKeyDefinition) as FakeActiveUserState; - } - - mockFor(userKeyDefinition: UserKeyDefinition, initialValue?: T): FakeActiveUserState { - const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition); - if (!this.states.has(cacheKey)) { - this.states.set(cacheKey, this.buildFakeState(userKeyDefinition, initialValue)); - } - return this.states.get(cacheKey) as FakeActiveUserState; - } - - private buildFakeState(userKeyDefinition: UserKeyDefinition, initialValue?: T) { - const state = new FakeActiveUserState( - this.accountServiceAccessor, - initialValue, - async (...args) => { - await this.updateSyncCallback?.(userKeyDefinition, ...args); - }, - ); - state.keyDefinition = userKeyDefinition; - return state; - } -} - -function userKeyDefinitionCacheKey(userKeyDefinition: UserKeyDefinition) { - return `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}`; -} - -export class FakeStateProvider implements StateProvider { - mock = mock(); - getUserState$(userKeyDefinition: UserKeyDefinition, userId?: UserId): Observable { - this.mock.getUserState$(userKeyDefinition, userId); - if (userId) { - return this.getUser(userId, userKeyDefinition).state$; - } - - return this.getActive(userKeyDefinition).state$; - } - - getUserStateOrDefault$( - userKeyDefinition: UserKeyDefinition, - config: { userId: UserId | undefined; defaultValue?: T }, - ): Observable { - const { userId, defaultValue = null } = config; - this.mock.getUserStateOrDefault$(userKeyDefinition, config); - if (userId) { - return this.getUser(userId, userKeyDefinition).state$; - } - - return this.activeUserId$.pipe( - take(1), - switchMap((userId) => - userId != null ? this.getUser(userId, userKeyDefinition).state$ : of(defaultValue), - ), - ); - } - - async setUserState( - userKeyDefinition: UserKeyDefinition, - 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(userKeyDefinition: UserKeyDefinition): ActiveUserState { - return this.activeUser.get(userKeyDefinition); - } - - getGlobal(keyDefinition: KeyDefinition): GlobalState { - return this.global.get(keyDefinition); - } - - getUser(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState { - return this.singleUser.get(userId, userKeyDefinition); - } - - getDerived( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState { - return this.derived.get(parentState$, deriveDefinition, dependencies); - } - - constructor(private activeAccountAccessor: MinimalAccountService) {} - - private distributeSingleUserUpdate( - key: UserKeyDefinition, - 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, - 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 = this.activeUser.activeUserId$; -} - -export class FakeDerivedStateProvider implements DerivedStateProvider { - states: Map> = new Map(); - get( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState { - let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState; - - 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"; diff --git a/libs/common/spec/fake-state.ts b/libs/common/spec/fake-state.ts index 38019a3868..14c1b117b5 100644 --- a/libs/common/spec/fake-state.ts +++ b/libs/common/spec/fake-state.ts @@ -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 = { - shouldUpdate: () => true, - combineLatestWith: null, - msTimeout: 10, -}; - -function populateOptionsWithDefault( - options: StateUpdateOptions, -): StateUpdateOptions { - return { - ...DEFAULT_TEST_OPTIONS, - ...options, - }; -} - -export class FakeGlobalState implements GlobalState { - // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup - stateSubject = new ReplaySubject(1); - - constructor(initialValue?: T) { - this.stateSubject.next(initialValue ?? null); - } - - nextState(state: T) { - this.stateSubject.next(state); - } - - async update( - configureState: (state: T, dependency: TCombine) => T, - options?: StateUpdateOptions, - ): Promise { - 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(); - - get state$() { - return this.stateSubject.asObservable(); - } - - private _keyDefinition: KeyDefinition | 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) { - this._keyDefinition = value; - } -} - -export class FakeSingleUserState implements SingleUserState { - // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup - stateSubject = new ReplaySubject<{ - syncValue: boolean; - combinedState: CombinedState; - }>(1); - - state$: Observable; - combinedState$: Observable>; - - constructor( - readonly userId: UserId, - initialValue?: T, - updateSyncCallback?: (userId: UserId, newValue: T) => Promise, - ) { - // 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( - configureState: (state: T | null, dependency: TCombine) => T | null, - options?: StateUpdateOptions, - ): Promise { - 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(); - private _keyDefinition: UserKeyDefinition | 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) { - this._keyDefinition = value; - } -} -export class FakeActiveUserState implements ActiveUserState { - [activeMarker]: true; - - // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup - stateSubject = new ReplaySubject<{ - syncValue: boolean; - combinedState: CombinedState; - }>(1); - - state$: Observable; - combinedState$: Observable>; - - constructor( - private activeAccountAccessor: MinimalAccountService, - initialValue?: T, - updateSyncCallback?: (userId: UserId, newValue: T) => Promise, - ) { - // 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( - configureState: (state: T | null, dependency: TCombine) => T | null, - options?: StateUpdateOptions, - ): 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(); - - private _keyDefinition: UserKeyDefinition | 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) { - this._keyDefinition = value; - } -} - -export class FakeDerivedState - implements DerivedState -{ - // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup - stateSubject = new ReplaySubject(1); - - constructor( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - 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 { - this.stateSubject.next(value); - return Promise.resolve(value); - } - forceValueMock = this.forceValue as jest.MockedFunction; - - get state$() { - return this.stateSubject.asObservable(); - } -} +export { + FakeGlobalState, + FakeSingleUserState, + FakeActiveUserState, + FakeDerivedState, +} from "@bitwarden/state-test-utils"; diff --git a/libs/common/spec/utils.ts b/libs/common/spec/utils.ts index 65b709a201..db9a7e0842 100644 --- a/libs/common/spec/utils.ts +++ b/libs/common/spec/utils.ts @@ -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(observable: Observable): 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"; diff --git a/libs/common/src/enums/client-type.enum.ts b/libs/common/src/enums/client-type.enum.ts index 25e9d6f337..466e67ecd5 100644 --- a/libs/common/src/enums/client-type.enum.ts +++ b/libs/common/src/enums/client-type.enum.ts @@ -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"; diff --git a/libs/common/src/platform/misc/utils.ts b/libs/common/src/platform/misc/utils.ts index b3c1db9180..c103e346a8 100644 --- a/libs/common/src/platform/misc/utils.ts +++ b/libs/common/src/platform/misc/utils.ts @@ -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); } diff --git a/libs/common/src/platform/services/migration-builder.service.spec.ts b/libs/common/src/platform/services/migration-builder.service.spec.ts index ee9508e8b1..1ed7cb9b71 100644 --- a/libs/common/src/platform/services/migration-builder.service.spec.ts +++ b/libs/common/src/platform/services/migration-builder.service.spec.ts @@ -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"; diff --git a/libs/common/src/platform/services/migration-runner.ts b/libs/common/src/platform/services/migration-runner.ts index 9e3a6118af..9e066069e3 100644 --- a/libs/common/src/platform/services/migration-runner.ts +++ b/libs/common/src/platform/services/migration-runner.ts @@ -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"; diff --git a/libs/common/src/platform/state/derive-definition.ts b/libs/common/src/platform/state/derive-definition.ts index 663b55465b..3882e89fd6 100644 --- a/libs/common/src/platform/state/derive-definition.ts +++ b/libs/common/src/platform/state/derive-definition.ts @@ -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 = { - /** - * 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; - /** - * 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; - /** - * 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 { - /** - * 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, - ) {} - - /** - * 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( - definition: - | KeyDefinition - | UserKeyDefinition - | [DeriveDefinition, string], - options: DeriveDefinitionOptions, - ) { - if (isFromDeriveDefinition(definition)) { - return new DeriveDefinition(definition[0].stateDefinition, definition[1], options); - } else { - return new DeriveDefinition(definition.stateDefinition, definition.key, options); - } - } - - static fromWithUserId( - definition: - | KeyDefinition - | UserKeyDefinition - | [DeriveDefinition, 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 { - 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 - | UserKeyDefinition - | [DeriveDefinition, string], -): definition is [DeriveDefinition, string] { - return Array.isArray(definition); -} +export { DeriveDefinition } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/derived-state.provider.ts b/libs/common/src/platform/state/derived-state.provider.ts index 2186048247..3118780a0c 100644 --- a/libs/common/src/platform/state/derived-state.provider.ts +++ b/libs/common/src/platform/state/derived-state.provider.ts @@ -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( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState; -} +export { DerivedStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/derived-state.ts b/libs/common/src/platform/state/derived-state.ts index b466c3024f..06dd28bf4f 100644 --- a/libs/common/src/platform/state/derived-state.ts +++ b/libs/common/src/platform/state/derived-state.ts @@ -1,23 +1 @@ -import { Observable } from "rxjs"; - -export type StateConverter, 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 { - /** - * The derived state observable - */ - state$: Observable; - /** - * 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; -} +export { DerivedState } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/global-state.provider.ts b/libs/common/src/platform/state/global-state.provider.ts index a7179ba0f1..a92e6374c4 100644 --- a/libs/common/src/platform/state/global-state.provider.ts +++ b/libs/common/src/platform/state/global-state.provider.ts @@ -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(keyDefinition: KeyDefinition): GlobalState; -} +export { GlobalStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/global-state.ts b/libs/common/src/platform/state/global-state.ts index b2ac634df2..d65866c930 100644 --- a/libs/common/src/platform/state/global-state.ts +++ b/libs/common/src/platform/state/global-state.ts @@ -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 { - /** - * 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: ( - configureState: (state: T | null, dependency: TCombine) => T | null, - options?: StateUpdateOptions, - ) => Promise; - - /** - * 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; -} +export { GlobalState } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-active-user-state.ts b/libs/common/src/platform/state/implementations/default-active-user-state.ts index 964b74f537..eb8165f853 100644 --- a/libs/common/src/platform/state/implementations/default-active-user-state.ts +++ b/libs/common/src/platform/state/implementations/default-active-user-state.ts @@ -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 implements ActiveUserState { - [activeMarker]: true; - combinedState$: Observable>; - state$: Observable; - - constructor( - protected keyDefinition: UserKeyDefinition, - private activeUserId$: Observable, - 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( - configureState: (state: T, dependency: TCombine) => T, - options: StateUpdateOptions = {}, - ): 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"; diff --git a/libs/common/src/platform/state/implementations/default-derived-state.provider.ts b/libs/common/src/platform/state/implementations/default-derived-state.provider.ts index 61f36fa0b7..06e5e30b5a 100644 --- a/libs/common/src/platform/state/implementations/default-derived-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-derived-state.provider.ts @@ -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, Record>>(); - - constructor() {} - - get( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState { - 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; - } - - const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies); - stateCache[cacheKey] = newDerivedState; - return newDerivedState; - } - - protected buildDerivedState( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState { - return new DefaultDerivedState(parentState$, deriveDefinition, dependencies); - } -} +export { DefaultDerivedStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-derived-state.ts b/libs/common/src/platform/state/implementations/default-derived-state.ts index 9abb299809..e66bc754c4 100644 --- a/libs/common/src/platform/state/implementations/default-derived-state.ts +++ b/libs/common/src/platform/state/implementations/default-derived-state.ts @@ -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 - implements DerivedState -{ - private readonly storageKey: string; - private forcedValueSubject = new Subject(); - - state$: Observable; - - constructor( - private parentState$: Observable, - protected deriveDefinition: DeriveDefinition, - 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(1); - }, - resetOnRefCountZero: () => timer(this.deriveDefinition.cleanupDelayMs), - }), - ); - } - - async forceValue(value: TTo) { - this.forcedValueSubject.next(value); - return value; - } -} +export { DefaultDerivedState } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-global-state.provider.ts b/libs/common/src/platform/state/implementations/default-global-state.provider.ts index bd0cfc1dc9..667dcd60fa 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-global-state.provider.ts @@ -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> = {}; - - constructor( - private storageServiceProvider: StorageServiceProvider, - private readonly logService: LogService, - ) {} - - get(keyDefinition: KeyDefinition): GlobalState { - 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; - } - - const newGlobalState = new DefaultGlobalState( - keyDefinition, - storageService, - this.logService, - ); - - this.globalStateCache[cacheKey] = newGlobalState; - return newGlobalState; - } - - private buildCacheKey(location: string, keyDefinition: KeyDefinition) { - return `${location}_${keyDefinition.fullName}`; - } -} +export { DefaultGlobalStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-global-state.ts b/libs/common/src/platform/state/implementations/default-global-state.ts index a06eb23e01..6306721cd6 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.ts +++ b/libs/common/src/platform/state/implementations/default-global-state.ts @@ -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 - extends StateBase> - implements GlobalState -{ - constructor( - keyDefinition: KeyDefinition, - chosenLocation: AbstractStorageService & ObservableStorageService, - logService: LogService, - ) { - super(globalKeyBuilder(keyDefinition), chosenLocation, keyDefinition, logService); - } -} +export { DefaultGlobalState } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts b/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts index bef56ad230..b822c917a7 100644 --- a/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts @@ -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> = {}; - - constructor( - private readonly storageServiceProvider: StorageServiceProvider, - private readonly stateEventRegistrarService: StateEventRegistrarService, - private readonly logService: LogService, - ) {} - - get(userId: UserId, keyDefinition: UserKeyDefinition): SingleUserState { - 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; - } - - const newUserState = new DefaultSingleUserState( - userId, - keyDefinition, - storageService, - this.stateEventRegistrarService, - this.logService, - ); - this.cache[cacheKey] = newUserState; - return newUserState; - } - - private buildCacheKey( - location: string, - userId: UserId, - keyDefinition: UserKeyDefinition, - ) { - return `${location}_${keyDefinition.fullName}_${userId}`; - } -} +export { DefaultSingleUserStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-single-user-state.ts b/libs/common/src/platform/state/implementations/default-single-user-state.ts index db776c3d11..aec186a275 100644 --- a/libs/common/src/platform/state/implementations/default-single-user-state.ts +++ b/libs/common/src/platform/state/implementations/default-single-user-state.ts @@ -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 - extends StateBase> - implements SingleUserState -{ - readonly combinedState$: Observable>; - - constructor( - readonly userId: UserId, - keyDefinition: UserKeyDefinition, - 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 { - await super.doStorageSave(newState, oldState); - if (newState != null && oldState == null) { - await this.stateEventRegistrarService.registerEvents(this.keyDefinition); - } - } -} +export { DefaultSingleUserState } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-state.provider.ts b/libs/common/src/platform/state/implementations/default-state.provider.ts index 3179576797..e79cf5b59b 100644 --- a/libs/common/src/platform/state/implementations/default-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-state.provider.ts @@ -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; - constructor( - private readonly activeUserStateProvider: ActiveUserStateProvider, - private readonly singleUserStateProvider: SingleUserStateProvider, - private readonly globalStateProvider: GlobalStateProvider, - private readonly derivedStateProvider: DerivedStateProvider, - ) { - this.activeUserId$ = this.activeUserStateProvider.activeUserId$; - } - - getUserState$(userKeyDefinition: UserKeyDefinition, userId?: UserId): Observable { - if (userId) { - return this.getUser(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(userId, userKeyDefinition).state$), - ); - } - } - - getUserStateOrDefault$( - userKeyDefinition: UserKeyDefinition, - config: { userId: UserId | undefined; defaultValue?: T }, - ): Observable { - const { userId, defaultValue = null } = config; - if (userId) { - return this.getUser(userId, userKeyDefinition).state$; - } else { - return this.activeUserId$.pipe( - take(1), - switchMap((userId) => - userId != null ? this.getUser(userId, userKeyDefinition).state$ : of(defaultValue), - ), - ); - } - } - - async setUserState( - userKeyDefinition: UserKeyDefinition, - value: T | null, - userId?: UserId, - ): Promise<[UserId, T | null]> { - if (userId) { - return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)]; - } else { - return await this.getActive(userKeyDefinition).update(() => value); - } - } - - getActive: InstanceType["get"] = - this.activeUserStateProvider.get.bind(this.activeUserStateProvider); - getUser: InstanceType["get"] = - this.singleUserStateProvider.get.bind(this.singleUserStateProvider); - getGlobal: InstanceType["get"] = this.globalStateProvider.get.bind( - this.globalStateProvider, - ); - getDerived: ( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ) => DerivedState = this.derivedStateProvider.get.bind(this.derivedStateProvider); -} +export { DefaultStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/inline-derived-state.ts b/libs/common/src/platform/state/implementations/inline-derived-state.ts index 79b2c92100..aa19d8d7f1 100644 --- a/libs/common/src/platform/state/implementations/inline-derived-state.ts +++ b/libs/common/src/platform/state/implementations/inline-derived-state.ts @@ -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( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState { - return new InlineDerivedState(parentState$, deriveDefinition, dependencies); - } -} - -export class InlineDerivedState - implements DerivedState -{ - constructor( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ) { - this.state$ = parentState$.pipe( - concatMap(async (value) => await deriveDefinition.derive(value, dependencies)), - ); - } - - state$: Observable; - - forceValue(value: TTo): Promise { - // No need to force anything, we don't keep a cache - return Promise.resolve(value); - } -} +export { InlineDerivedState, InlineDerivedStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/state-base.ts b/libs/common/src/platform/state/implementations/state-base.ts index 03140e1fe1..88a2c8cfac 100644 --- a/libs/common/src/platform/state/implementations/state-base.ts +++ b/libs/common/src/platform/state/implementations/state-base.ts @@ -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 = { - deserializer: (jsonState: Jsonify) => T | null; - cleanupDelayMs: number; - debug: Required; -}; - -export abstract class StateBase> { - private updatePromise: Promise; - - readonly state$: Observable; - - 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( - configureState: (state: T | null, dependency: TCombine) => T | null, - options: StateUpdateOptions = {}, - ): Promise { - 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( - configureState: (state: T | null, dependency: TCombine) => T | null, - options: StateUpdateOptions, - ): Promise { - 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"; diff --git a/libs/common/src/platform/state/index.ts b/libs/common/src/platform/state/index.ts index 6dc1409050..8a9175b171 100644 --- a/libs/common/src/platform/state/index.ts +++ b/libs/common/src/platform/state/index.ts @@ -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"; diff --git a/libs/common/src/platform/state/key-definition.ts b/libs/common/src/platform/state/key-definition.ts index 519e98ef52..bc5b02ad5d 100644 --- a/libs/common/src/platform/state/key-definition.ts +++ b/libs/common/src/platform/state/key-definition.ts @@ -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 = { - /** - * 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 | 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 { - readonly debug: Required; - - /** - * 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, - ) { - 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(MY_STATE, "key", { - * deserializer: (myJsonElement) => convertToElement(myJsonElement), - * }); - * ``` - */ - static array( - 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, // The array helper forces an initialValue of an empty array - ) { - return new KeyDefinition(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(MY_STATE, "key", { - * deserializer: (myJsonValue) => convertToValue(myJsonValue), - * }); - * ``` - */ - static record( - 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, // The array helper forces an initialValue of an empty record - ) { - return new KeyDefinition>(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): StorageKey { - return `global_${keyDefinition.stateDefinition.name}_${keyDefinition.key}` as StorageKey; -} +export { KeyDefinition, KeyDefinitionOptions } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/state-definition.ts b/libs/common/src/platform/state/state-definition.ts index 5e24146fbd..f2a40429d1 100644 --- a/libs/common/src/platform/state/state-definition.ts +++ b/libs/common/src/platform/state/state-definition.ts @@ -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; - - /** - * 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, - ) { - this.storageLocationOverrides = storageLocationOverrides ?? {}; - } -} +export { StorageLocation, ClientLocations } from "@bitwarden/storage-core"; diff --git a/libs/common/src/platform/state/state-event-registrar.service.ts b/libs/common/src/platform/state/state-event-registrar.service.ts index e74d46d3b7..1186221c62 100644 --- a/libs/common/src/platform/state/state-event-registrar.service.ts +++ b/libs/common/src/platform/state/state-event-registrar.service.ts @@ -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(CLEAR_EVENT_DISK, "lock", { - deserializer: (e) => e, -}); - -export const STATE_LOGOUT_EVENT = KeyDefinition.array(CLEAR_EVENT_DISK, "logout", { - deserializer: (e) => e, -}); - -export class StateEventRegistrarService { - private readonly stateEventStateMap: { [Prop in ClearEvent]: GlobalState }; - - constructor( - globalStateProvider: GlobalStateProvider, - private storageServiceProvider: StorageServiceProvider, - ) { - this.stateEventStateMap = { - lock: globalStateProvider.get(STATE_LOCK_EVENT), - logout: globalStateProvider.get(STATE_LOGOUT_EVENT), - }; - } - - async registerEvents(keyDefinition: UserKeyDefinition) { - 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"; diff --git a/libs/common/src/platform/state/state-event-runner.service.ts b/libs/common/src/platform/state/state-event-runner.service.ts index 9e6f8f214e..60fb11a8f5 100644 --- a/libs/common/src/platform/state/state-event-runner.service.ts +++ b/libs/common/src/platform/state/state-event-runner.service.ts @@ -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 }; - - 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( - 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"; diff --git a/libs/common/src/platform/state/state.provider.ts b/libs/common/src/platform/state/state.provider.ts index dc8cb3e935..4c36bed959 100644 --- a/libs/common/src/platform/state/state.provider.ts +++ b/libs/common/src/platform/state/state.provider.ts @@ -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; - - /** - * 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$(keyDefinition: UserKeyDefinition, userId?: UserId): Observable; - - /** - * 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$( - keyDefinition: UserKeyDefinition, - config: { userId: UserId | undefined; defaultValue?: T }, - ): Observable; - - /** - * 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( - keyDefinition: UserKeyDefinition, - value: T | null, - userId?: UserId, - ): Promise<[UserId, T | null]>; - - /** @see{@link ActiveUserStateProvider.get} */ - abstract getActive(userKeyDefinition: UserKeyDefinition): ActiveUserState; - - /** @see{@link SingleUserStateProvider.get} */ - abstract getUser(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState; - - /** @see{@link GlobalStateProvider.get} */ - abstract getGlobal(keyDefinition: KeyDefinition): GlobalState; - abstract getDerived( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState; -} +export { StateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/user-key-definition.ts b/libs/common/src/platform/state/user-key-definition.ts index 090a8aad31..fd3bdea32d 100644 --- a/libs/common/src/platform/state/user-key-definition.ts +++ b/libs/common/src/platform/state/user-key-definition.ts @@ -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 = KeyDefinitionOptions & { - clearOn: ClearEvent[]; -}; - -const USER_KEY_DEFINITION_MARKER: unique symbol = Symbol("UserKeyDefinition"); - -export class UserKeyDefinition { - 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; - - constructor( - readonly stateDefinition: StateDefinition, - readonly key: string, - private readonly options: UserKeyDefinitionOptions, - ) { - 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(MY_STATE, "key", { - * deserializer: (myJsonElement) => convertToElement(myJsonElement), - * }); - * ``` - */ - static array( - 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, - ) { - return new UserKeyDefinition(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(MY_STATE, "key", { - * deserializer: (myJsonValue) => convertToValue(myJsonValue), - * }); - * ``` - */ - static record( - 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, // The array helper forces an initialValue of an empty record - ) { - return new UserKeyDefinition>(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"; diff --git a/libs/common/src/platform/state/user-state.provider.ts b/libs/common/src/platform/state/user-state.provider.ts index 677f8b472d..eff529d79b 100644 --- a/libs/common/src/platform/state/user-state.provider.ts +++ b/libs/common/src/platform/state/user-state.provider.ts @@ -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(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState; -} - -/** 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; - - /** - * 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(userKeyDefinition: UserKeyDefinition): ActiveUserState; -} +export { ActiveUserStateProvider, SingleUserStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/user-state.ts b/libs/common/src/platform/state/user-state.ts index 26fa6f83fa..2fbfd4ce41 100644 --- a/libs/common/src/platform/state/user-state.ts +++ b/libs/common/src/platform/state/user-state.ts @@ -1,64 +1 @@ -import { Observable } from "rxjs"; - -import { UserId } from "../../types/guid"; - -import { StateUpdateOptions } from "./state-update-options"; - -export type CombinedState = readonly [userId: UserId, state: T]; - -/** A helper object for interacting with state that is scoped to a specific user. */ -export interface UserState { - /** Emits a stream of data. Emits null if the user does not have specified state. */ - readonly state$: Observable; - - /** 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>; -} - -export const activeMarker: unique symbol = Symbol("active"); - -export interface ActiveUserState extends UserState { - 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; - - /** - * 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: ( - configureState: (state: T | null, dependencies: TCombine) => T | null, - options?: StateUpdateOptions, - ) => Promise<[UserId, T | null]>; -} - -export interface SingleUserState extends UserState { - 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: ( - configureState: (state: T | null, dependencies: TCombine) => T | null, - options?: StateUpdateOptions, - ) => Promise; -} +export { ActiveUserState, SingleUserState, CombinedState } from "@bitwarden/state"; diff --git a/libs/common/src/state-migrations/index.ts b/libs/common/src/state-migrations/index.ts index e51a4e8e93..69f56fe06a 100644 --- a/libs/common/src/state-migrations/index.ts +++ b/libs/common/src/state-migrations/index.ts @@ -1 +1,2 @@ -export { createMigrationBuilder, waitForMigrations, CURRENT_VERSION } from "./migrate"; +// Compatibility re-export for @bitwarden/common/state-migrations +export * from "@bitwarden/state"; diff --git a/libs/common/src/state-migrations/migration-builder.ts b/libs/common/src/state-migrations/migration-builder.ts index b9a1c67cd6..9fdadefa1a 100644 --- a/libs/common/src/state-migrations/migration-builder.ts +++ b/libs/common/src/state-migrations/migration-builder.ts @@ -1,106 +1 @@ -import { MigrationHelper } from "./migration-helper"; -import { Direction, Migrator, VersionFrom, VersionTo } from "./migrator"; - -export class MigrationBuilder { - /** 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; 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 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, - TFrom extends VersionFrom & TCurrent, - TTo extends VersionTo, - >( - ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo] - ): MigrationBuilder { - 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 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, - TFrom extends VersionFrom, - TTo extends VersionTo & TCurrent, - >( - ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TTo, TFrom] - ): MigrationBuilder { - 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 { - return this.migrations.reduce( - (promise, migrator) => - promise.then(async () => { - await this.runMigrator(migrator.migrator, helper, migrator.direction); - }), - Promise.resolve(), - ); - } - - private addMigrator< - TMigrator extends Migrator, - TFrom extends VersionFrom & TCurrent, - TTo extends VersionTo, - >( - 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([...this.migrations, newMigration]); - } - - private async runMigrator( - migrator: Migrator, - helper: MigrationHelper, - direction: Direction, - ): Promise { - 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"; diff --git a/libs/common/src/state-migrations/migration-helper.ts b/libs/common/src/state-migrations/migration-helper.ts index b377df8ef9..167a9a5339 100644 --- a/libs/common/src/state-migrations/migration-helper.ts +++ b/libs/common/src/state-migrations/migration-helper.ts @@ -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(key: string): Promise { - return this.storageService.get(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(key: string, value: T): Promise { - 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 { - 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(keyDefinition: KeyDefinitionLike): Promise { - return this.get(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(keyDefinition: KeyDefinitionLike, value: T): Promise { - 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 { - 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(userId: string, keyDefinition: KeyDefinitionLike): Promise { - return this.get(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(userId: string, keyDefinition: KeyDefinitionLike, value: T): Promise { - 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 { - 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(): Promise< - { userId: string; account: ExpectedAccountType }[] - > { - const userIds = await this.getKnownUserIds(); - return Promise.all( - userIds.map(async (userId) => ({ - userId, - account: await this.get(userId), - })), - ); - } - - /** - * Helper method to read known users ids. - */ - async getKnownUserIds(): Promise { - 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 { - return (await storageService.get("authenticatedAccounts")) ?? []; -} - -async function knownAccountUserIdsBuilder( - storageService: AbstractStorageService, -): Promise { - const accounts = await storageService.get>( - globalKeyBuilder({ stateDefinition: { name: "account" }, key: "accounts" }), - ); - return Object.keys(accounts ?? {}); -} +export { MigrationHelper } from "@bitwarden/state"; diff --git a/libs/common/src/types/state.ts b/libs/common/src/types/state.ts index b98e3a4e79..f5a8a11bd7 100644 --- a/libs/common/src/types/state.ts +++ b/libs/common/src/types/state.ts @@ -1,5 +1,2 @@ -import { Opaque } from "type-fest"; - -export type StorageKey = Opaque; - -export type DerivedStateDependencies = Record; +// Compatibility re-export for @bitwarden/common/types/state +export { StorageKey, DerivedStateDependencies } from "@bitwarden/state"; diff --git a/libs/core-test-utils/README.md b/libs/core-test-utils/README.md new file mode 100644 index 0000000000..5de4cffbdd --- /dev/null +++ b/libs/core-test-utils/README.md @@ -0,0 +1,5 @@ +# core-test-utils + +Owned by: platform + +Async test tools for state and clients diff --git a/libs/core-test-utils/eslint.config.mjs b/libs/core-test-utils/eslint.config.mjs new file mode 100644 index 0000000000..9c37d10e3f --- /dev/null +++ b/libs/core-test-utils/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/core-test-utils/jest.config.js b/libs/core-test-utils/jest.config.js new file mode 100644 index 0000000000..d8e9cdf00a --- /dev/null +++ b/libs/core-test-utils/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "core-test-utils", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/core-test-utils", +}; diff --git a/libs/core-test-utils/package.json b/libs/core-test-utils/package.json new file mode 100644 index 0000000000..acb2edc8eb --- /dev/null +++ b/libs/core-test-utils/package.json @@ -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" +} diff --git a/libs/core-test-utils/project.json b/libs/core-test-utils/project.json new file mode 100644 index 0000000000..a526209edc --- /dev/null +++ b/libs/core-test-utils/project.json @@ -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" + } + } + } +} diff --git a/libs/core-test-utils/src/core-test-utils.spec.ts b/libs/core-test-utils/src/core-test-utils.spec.ts new file mode 100644 index 0000000000..fc878e2b69 --- /dev/null +++ b/libs/core-test-utils/src/core-test-utils.spec.ts @@ -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(); + }); +}); diff --git a/libs/core-test-utils/src/index.ts b/libs/core-test-utils/src/index.ts new file mode 100644 index 0000000000..abb60213c5 --- /dev/null +++ b/libs/core-test-utils/src/index.ts @@ -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(observable: Observable): 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)); + } +} diff --git a/libs/core-test-utils/tsconfig.eslint.json b/libs/core-test-utils/tsconfig.eslint.json new file mode 100644 index 0000000000..3daf120441 --- /dev/null +++ b/libs/core-test-utils/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/core-test-utils/tsconfig.json b/libs/core-test-utils/tsconfig.json new file mode 100644 index 0000000000..62ebbd9464 --- /dev/null +++ b/libs/core-test-utils/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/core-test-utils/tsconfig.lib.json b/libs/core-test-utils/tsconfig.lib.json new file mode 100644 index 0000000000..9cbf673600 --- /dev/null +++ b/libs/core-test-utils/tsconfig.lib.json @@ -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"] +} diff --git a/libs/core-test-utils/tsconfig.spec.json b/libs/core-test-utils/tsconfig.spec.json new file mode 100644 index 0000000000..1275f148a1 --- /dev/null +++ b/libs/core-test-utils/tsconfig.spec.json @@ -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"] +} diff --git a/libs/guid/README.md b/libs/guid/README.md new file mode 100644 index 0000000000..506ef221a9 --- /dev/null +++ b/libs/guid/README.md @@ -0,0 +1,5 @@ +# guid + +Owned by: platform + +Guid utilities extracted from common diff --git a/libs/guid/eslint.config.mjs b/libs/guid/eslint.config.mjs new file mode 100644 index 0000000000..9c37d10e3f --- /dev/null +++ b/libs/guid/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/guid/jest.config.js b/libs/guid/jest.config.js new file mode 100644 index 0000000000..d1c4dc0109 --- /dev/null +++ b/libs/guid/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "guid", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/guid", +}; diff --git a/libs/guid/package.json b/libs/guid/package.json new file mode 100644 index 0000000000..9f7af0667a --- /dev/null +++ b/libs/guid/package.json @@ -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" +} diff --git a/libs/guid/project.json b/libs/guid/project.json new file mode 100644 index 0000000000..e5d510b2e2 --- /dev/null +++ b/libs/guid/project.json @@ -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" + } + } + } +} diff --git a/libs/guid/src/guid.spec.ts b/libs/guid/src/guid.spec.ts new file mode 100644 index 0000000000..026f751a48 --- /dev/null +++ b/libs/guid/src/guid.spec.ts @@ -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(); + }); +}); diff --git a/libs/guid/src/index.ts b/libs/guid/src/index.ts new file mode 100644 index 0000000000..3ef62725eb --- /dev/null +++ b/libs/guid/src/index.ts @@ -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); +} diff --git a/libs/guid/tsconfig.eslint.json b/libs/guid/tsconfig.eslint.json new file mode 100644 index 0000000000..3daf120441 --- /dev/null +++ b/libs/guid/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/guid/tsconfig.json b/libs/guid/tsconfig.json new file mode 100644 index 0000000000..62ebbd9464 --- /dev/null +++ b/libs/guid/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/guid/tsconfig.lib.json b/libs/guid/tsconfig.lib.json new file mode 100644 index 0000000000..9cbf673600 --- /dev/null +++ b/libs/guid/tsconfig.lib.json @@ -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"] +} diff --git a/libs/guid/tsconfig.spec.json b/libs/guid/tsconfig.spec.json new file mode 100644 index 0000000000..1275f148a1 --- /dev/null +++ b/libs/guid/tsconfig.spec.json @@ -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"] +} diff --git a/libs/serialization/README.md b/libs/serialization/README.md new file mode 100644 index 0000000000..9ae7f47724 --- /dev/null +++ b/libs/serialization/README.md @@ -0,0 +1,5 @@ +# serialization + +Owned by: platform + +Core serialization utilities diff --git a/libs/serialization/eslint.config.mjs b/libs/serialization/eslint.config.mjs new file mode 100644 index 0000000000..9c37d10e3f --- /dev/null +++ b/libs/serialization/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/serialization/jest.config.js b/libs/serialization/jest.config.js new file mode 100644 index 0000000000..1137064a7d --- /dev/null +++ b/libs/serialization/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "serialization", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/serialization", +}; diff --git a/libs/serialization/package.json b/libs/serialization/package.json new file mode 100644 index 0000000000..d582d28ac2 --- /dev/null +++ b/libs/serialization/package.json @@ -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" +} diff --git a/libs/serialization/project.json b/libs/serialization/project.json new file mode 100644 index 0000000000..3fe8968ea4 --- /dev/null +++ b/libs/serialization/project.json @@ -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" + } + } + } +} diff --git a/libs/common/src/platform/state/deserialization-helpers.spec.ts b/libs/serialization/src/deserialization-helpers.spec.ts similarity index 89% rename from libs/common/src/platform/state/deserialization-helpers.spec.ts rename to libs/serialization/src/deserialization-helpers.spec.ts index b1ae447997..1918673c8d 100644 --- a/libs/common/src/platform/state/deserialization-helpers.spec.ts +++ b/libs/serialization/src/deserialization-helpers.spec.ts @@ -1,4 +1,4 @@ -import { record } from "./deserialization-helpers"; +import { record } from "@bitwarden/serialization/deserialization-helpers"; describe("deserialization helpers", () => { describe("record", () => { diff --git a/libs/common/src/platform/state/deserialization-helpers.ts b/libs/serialization/src/deserialization-helpers.ts similarity index 100% rename from libs/common/src/platform/state/deserialization-helpers.ts rename to libs/serialization/src/deserialization-helpers.ts diff --git a/libs/serialization/src/index.ts b/libs/serialization/src/index.ts new file mode 100644 index 0000000000..b6e874748f --- /dev/null +++ b/libs/serialization/src/index.ts @@ -0,0 +1 @@ +export * from "./deserialization-helpers"; diff --git a/libs/serialization/src/serialization.spec.ts b/libs/serialization/src/serialization.spec.ts new file mode 100644 index 0000000000..4e10000eab --- /dev/null +++ b/libs/serialization/src/serialization.spec.ts @@ -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(); + }); +}); diff --git a/libs/serialization/tsconfig.eslint.json b/libs/serialization/tsconfig.eslint.json new file mode 100644 index 0000000000..3daf120441 --- /dev/null +++ b/libs/serialization/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/serialization/tsconfig.json b/libs/serialization/tsconfig.json new file mode 100644 index 0000000000..62ebbd9464 --- /dev/null +++ b/libs/serialization/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/serialization/tsconfig.lib.json b/libs/serialization/tsconfig.lib.json new file mode 100644 index 0000000000..9cbf673600 --- /dev/null +++ b/libs/serialization/tsconfig.lib.json @@ -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"] +} diff --git a/libs/serialization/tsconfig.spec.json b/libs/serialization/tsconfig.spec.json new file mode 100644 index 0000000000..1275f148a1 --- /dev/null +++ b/libs/serialization/tsconfig.spec.json @@ -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"] +} diff --git a/libs/state-test-utils/README.md b/libs/state-test-utils/README.md new file mode 100644 index 0000000000..69a6355f8e --- /dev/null +++ b/libs/state-test-utils/README.md @@ -0,0 +1,5 @@ +# state-test-utils + +Owned by: platform + +Test utilities and fakes for state management diff --git a/libs/state-test-utils/eslint.config.mjs b/libs/state-test-utils/eslint.config.mjs new file mode 100644 index 0000000000..9c37d10e3f --- /dev/null +++ b/libs/state-test-utils/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/state-test-utils/jest.config.js b/libs/state-test-utils/jest.config.js new file mode 100644 index 0000000000..76c531ad78 --- /dev/null +++ b/libs/state-test-utils/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "state-test-utils", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/state-test-utils", +}; diff --git a/libs/state-test-utils/package.json b/libs/state-test-utils/package.json new file mode 100644 index 0000000000..9fd9aa64e5 --- /dev/null +++ b/libs/state-test-utils/package.json @@ -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" +} diff --git a/libs/state-test-utils/project.json b/libs/state-test-utils/project.json new file mode 100644 index 0000000000..ef524e5347 --- /dev/null +++ b/libs/state-test-utils/project.json @@ -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" + } + } + } +} diff --git a/libs/state-test-utils/src/fake-state-provider.ts b/libs/state-test-utils/src/fake-state-provider.ts new file mode 100644 index 0000000000..47b1ee3dd0 --- /dev/null +++ b/libs/state-test-utils/src/fake-state-provider.ts @@ -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; + + 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; + + activeAccount$: Observable<{ id: UserId }>; + + switch(user: UserId | null) { + this._subject.next(user); + } +} + +export class FakeGlobalStateProvider implements GlobalStateProvider { + mock = mock(); + establishedMocks: Map> = new Map(); + states: Map> = new Map(); + get(keyDefinition: KeyDefinition): GlobalState { + this.mock.get(keyDefinition); + const cacheKey = this.cacheKey(keyDefinition); + let result = this.states.get(cacheKey); + + if (result == null) { + let fake: FakeGlobalState; + // Look for established mock + if (this.establishedMocks.has(keyDefinition.key)) { + fake = this.establishedMocks.get(keyDefinition.key) as FakeGlobalState; + } else { + fake = new FakeGlobalState(); + } + fake.keyDefinition = keyDefinition; + result = fake; + this.states.set(cacheKey, result); + + result = new FakeGlobalState(); + this.states.set(cacheKey, result); + } + return result as GlobalState; + } + + private cacheKey(keyDefinition: KeyDefinition) { + return `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`; + } + + getFake(keyDefinition: KeyDefinition): FakeGlobalState { + return this.get(keyDefinition) as FakeGlobalState; + } + + mockFor(keyDefinition: KeyDefinition, initialValue?: T): FakeGlobalState { + const cacheKey = this.cacheKey(keyDefinition); + if (!this.states.has(cacheKey)) { + this.states.set(cacheKey, new FakeGlobalState(initialValue)); + } + return this.states.get(cacheKey) as FakeGlobalState; + } +} + +export class FakeSingleUserStateProvider implements SingleUserStateProvider { + mock = mock(); + states: Map> = new Map(); + + constructor( + readonly updateSyncCallback?: ( + key: UserKeyDefinition, + userId: UserId, + newValue: unknown, + ) => Promise, + ) {} + + get(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState { + 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; + } + + getFake( + userId: UserId, + userKeyDefinition: UserKeyDefinition, + { allowInit }: { allowInit: boolean } = { allowInit: true }, + ): FakeSingleUserState { + if (!allowInit && this.states.get(this.cacheKey(userId, userKeyDefinition)) == null) { + return null; + } + + return this.get(userId, userKeyDefinition) as FakeSingleUserState; + } + + mockFor( + userId: UserId, + userKeyDefinition: UserKeyDefinition, + initialValue?: T, + ): FakeSingleUserState { + 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; + } + + private buildFakeState( + userId: UserId, + userKeyDefinition: UserKeyDefinition, + 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) { + return `${userKeyDefinitionCacheKey(userKeyDefinition)}_${userId}`; + } +} + +export class FakeActiveUserStateProvider implements ActiveUserStateProvider { + activeUserId$: Observable; + states: Map> = new Map(); + + constructor( + public accountServiceAccessor: MinimalAccountService, + readonly updateSyncCallback?: ( + key: UserKeyDefinition, + userId: UserId, + newValue: unknown, + ) => Promise, + ) { + this.activeUserId$ = accountServiceAccessor.activeAccount$.pipe(map((a) => a?.id)); + } + + get(userKeyDefinition: UserKeyDefinition): ActiveUserState { + 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; + } + + getFake( + userKeyDefinition: UserKeyDefinition, + { allowInit }: { allowInit: boolean } = { allowInit: true }, + ): FakeActiveUserState { + if (!allowInit && this.states.get(userKeyDefinitionCacheKey(userKeyDefinition)) == null) { + return null; + } + return this.get(userKeyDefinition) as FakeActiveUserState; + } + + mockFor(userKeyDefinition: UserKeyDefinition, initialValue?: T): FakeActiveUserState { + const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition); + if (!this.states.has(cacheKey)) { + this.states.set(cacheKey, this.buildFakeState(userKeyDefinition, initialValue)); + } + return this.states.get(cacheKey) as FakeActiveUserState; + } + + private buildFakeState(userKeyDefinition: UserKeyDefinition, initialValue?: T) { + const state = new FakeActiveUserState( + this.accountServiceAccessor, + initialValue, + async (...args) => { + await this.updateSyncCallback?.(userKeyDefinition, ...args); + }, + ); + state.keyDefinition = userKeyDefinition; + return state; + } +} + +function userKeyDefinitionCacheKey(userKeyDefinition: UserKeyDefinition) { + return `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}`; +} + +export class FakeStateProvider implements StateProvider { + mock = mock(); + getUserState$(userKeyDefinition: UserKeyDefinition, userId?: UserId): Observable { + this.mock.getUserState$(userKeyDefinition, userId); + if (userId) { + return this.getUser(userId, userKeyDefinition).state$; + } + + return this.getActive(userKeyDefinition).state$; + } + + getUserStateOrDefault$( + userKeyDefinition: UserKeyDefinition, + config: { userId: UserId | undefined; defaultValue?: T }, + ): Observable { + const { userId, defaultValue = null } = config; + this.mock.getUserStateOrDefault$(userKeyDefinition, config); + if (userId) { + return this.getUser(userId, userKeyDefinition).state$; + } + + return this.activeUserId$.pipe( + take(1), + switchMap((userId) => + userId != null ? this.getUser(userId, userKeyDefinition).state$ : of(defaultValue), + ), + ); + } + + async setUserState( + userKeyDefinition: UserKeyDefinition, + 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(userKeyDefinition: UserKeyDefinition): ActiveUserState { + return this.activeUser.get(userKeyDefinition); + } + + getGlobal(keyDefinition: KeyDefinition): GlobalState { + return this.global.get(keyDefinition); + } + + getUser(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState { + return this.singleUser.get(userId, userKeyDefinition); + } + + getDerived( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState { + return this.derived.get(parentState$, deriveDefinition, dependencies); + } + + constructor(private activeAccountAccessor: MinimalAccountService) {} + + private distributeSingleUserUpdate( + key: UserKeyDefinition, + 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, + 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 = this.activeUser.activeUserId$; +} + +export class FakeDerivedStateProvider implements DerivedStateProvider { + states: Map> = new Map(); + get( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState { + let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState; + + if (result == null) { + result = new FakeDerivedState(parentState$, deriveDefinition, dependencies); + this.states.set(deriveDefinition.buildCacheKey(), result); + } + return result; + } +} diff --git a/libs/state-test-utils/src/fake-state.ts b/libs/state-test-utils/src/fake-state.ts new file mode 100644 index 0000000000..25aabcd993 --- /dev/null +++ b/libs/state-test-utils/src/fake-state.ts @@ -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 = { + shouldUpdate: () => true, + combineLatestWith: null, + msTimeout: 10, +}; + +function populateOptionsWithDefault( + options: StateUpdateOptions, +): StateUpdateOptions { + return { + ...DEFAULT_TEST_OPTIONS, + ...options, + }; +} + +export class FakeGlobalState implements GlobalState { + // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup + stateSubject = new ReplaySubject(1); + + constructor(initialValue?: T) { + this.stateSubject.next(initialValue ?? null); + } + + nextState(state: T) { + this.stateSubject.next(state); + } + + async update( + configureState: (state: T, dependency: TCombine) => T, + options?: StateUpdateOptions, + ): Promise { + 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(); + + get state$() { + return this.stateSubject.asObservable(); + } + + private _keyDefinition: KeyDefinition | 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) { + this._keyDefinition = value; + } +} + +export class FakeSingleUserState implements SingleUserState { + // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup + stateSubject = new ReplaySubject<{ + syncValue: boolean; + combinedState: CombinedState; + }>(1); + + state$: Observable; + combinedState$: Observable>; + + constructor( + readonly userId: UserId, + initialValue?: T, + updateSyncCallback?: (userId: UserId, newValue: T) => Promise, + ) { + // 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( + configureState: (state: T | null, dependency: TCombine) => T | null, + options?: StateUpdateOptions, + ): Promise { + 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(); + private _keyDefinition: UserKeyDefinition | 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) { + this._keyDefinition = value; + } +} +export class FakeActiveUserState implements ActiveUserState { + [activeMarker]: true; + + // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup + stateSubject = new ReplaySubject<{ + syncValue: boolean; + combinedState: CombinedState; + }>(1); + + state$: Observable; + combinedState$: Observable>; + + constructor( + private activeAccountAccessor: MinimalAccountService, + initialValue?: T, + updateSyncCallback?: (userId: UserId, newValue: T) => Promise, + ) { + // 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( + configureState: (state: T | null, dependency: TCombine) => T | null, + options?: StateUpdateOptions, + ): 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(); + + private _keyDefinition: UserKeyDefinition | 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) { + this._keyDefinition = value; + } +} + +export class FakeDerivedState + implements DerivedState +{ + // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup + stateSubject = new ReplaySubject(1); + + constructor( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + 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 { + this.stateSubject.next(value); + return Promise.resolve(value); + } + forceValueMock = this.forceValue as jest.MockedFunction; + + get state$() { + return this.stateSubject.asObservable(); + } +} diff --git a/libs/state-test-utils/src/index.ts b/libs/state-test-utils/src/index.ts new file mode 100644 index 0000000000..27c5478870 --- /dev/null +++ b/libs/state-test-utils/src/index.ts @@ -0,0 +1,2 @@ +export * from "./fake-state"; +export * from "./fake-state-provider"; diff --git a/libs/state-test-utils/src/state-test-utils.spec.ts b/libs/state-test-utils/src/state-test-utils.spec.ts new file mode 100644 index 0000000000..9b35ba0e8e --- /dev/null +++ b/libs/state-test-utils/src/state-test-utils.spec.ts @@ -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(); + }); +}); diff --git a/libs/state-test-utils/tsconfig.eslint.json b/libs/state-test-utils/tsconfig.eslint.json new file mode 100644 index 0000000000..3daf120441 --- /dev/null +++ b/libs/state-test-utils/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/state-test-utils/tsconfig.json b/libs/state-test-utils/tsconfig.json new file mode 100644 index 0000000000..62ebbd9464 --- /dev/null +++ b/libs/state-test-utils/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/state-test-utils/tsconfig.lib.json b/libs/state-test-utils/tsconfig.lib.json new file mode 100644 index 0000000000..9cbf673600 --- /dev/null +++ b/libs/state-test-utils/tsconfig.lib.json @@ -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"] +} diff --git a/libs/state-test-utils/tsconfig.spec.json b/libs/state-test-utils/tsconfig.spec.json new file mode 100644 index 0000000000..1275f148a1 --- /dev/null +++ b/libs/state-test-utils/tsconfig.spec.json @@ -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"] +} diff --git a/libs/state/README.md b/libs/state/README.md new file mode 100644 index 0000000000..8b9d078612 --- /dev/null +++ b/libs/state/README.md @@ -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`](#globalstatet) +- [`SingleUserState`](#singleuserstatet) +- [`ActiveUserState`](#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`](#singleuserstatet) or +[`ActiveUserState`](#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(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 = UserKeyDefinition.array( + 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> = + KeyDefinition.record(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(keyDefinition: KeyDefinition): GlobalState; + getUser(userId: UserId, keyDefinition: KeyDefinition): SingleUserState; + getDerived( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependenciess: TDeps, + ); + // Deprecated, do not use. + getActive(keyDefinition: KeyDefinition): ActiveUserState; +} +``` + +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` 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 { + return this.stateProvider.getUser(userId, DOMAIN_USER_STATE); + } + + async clearStateValue(userId: UserId): Promise { + 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` + +`GlobalState` 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` below: + +```typescript +interface GlobalState { + state$: Observable; +} +``` + +The `state$` property provides you with an `Observable` that can be subscribed to. +`GlobalState.state$` will emit when the chosen storage location emits an update to the state +defined by the corresponding `KeyDefinition`. + +#### `SingleUserState` + +`SingleUserState` behaves very similarly to `GlobalState`, 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` that can be subscribed to. +`SingleUserState.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` + +For details on how to use derived state, see [Derived State](#derived-state). + +### `ActiveUserState` + +> [!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` 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 { + // ... rest of type left out for brevity + update(updateState: (state: T, dependency: TCombine) => T, options?: StateUpdateOptions); +} + +type StateUpdateOptions = { + shouldUpdate?: (state: T, dependency: TCombine) => boolean; + combineLatestWith?: Observable; + 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 = ...; + +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 = ...; + +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 = pipe( + // perform some transforms + map((value) => value), +); + +async function transformAsync(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()` 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`. 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` 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` 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> + +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 + +![State Diagram](state_diagram.svg) + +## 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: + + + +```typescript +new DeriveDefinition(STATE_DEFINITION, "uniqueKey", _DeriveOptions_); + +// or + +const keyDefinition: KeyDefinition; +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(); +``` + +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` 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: ( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ) => DerivedState; +} +``` + +> [!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 { + state$: Observable; + forceValue(value: T): Promise; +} +``` + +- `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. diff --git a/libs/state/eslint.config.mjs b/libs/state/eslint.config.mjs new file mode 100644 index 0000000000..9c37d10e3f --- /dev/null +++ b/libs/state/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/state/jest.config.js b/libs/state/jest.config.js new file mode 100644 index 0000000000..1ff9b60098 --- /dev/null +++ b/libs/state/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "state", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/state", +}; diff --git a/libs/state/package.json b/libs/state/package.json new file mode 100644 index 0000000000..2c25647e4e --- /dev/null +++ b/libs/state/package.json @@ -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" +} diff --git a/libs/state/project.json b/libs/state/project.json new file mode 100644 index 0000000000..85313ddf14 --- /dev/null +++ b/libs/state/project.json @@ -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" + } + } + } +} diff --git a/libs/state/src/core/active-user.accessor.ts b/libs/state/src/core/active-user.accessor.ts new file mode 100644 index 0000000000..8ee2d53a93 --- /dev/null +++ b/libs/state/src/core/active-user.accessor.ts @@ -0,0 +1,11 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +export abstract class ActiveUserAccessor { + /** + * Returns a stream of the current active user for the application. The stream either emits the user id for that account + * or returns null if there is no current active user. + */ + abstract activeUserId$: Observable; +} diff --git a/libs/common/src/platform/state/derive-definition.spec.ts b/libs/state/src/core/derive-definition.spec.ts similarity index 100% rename from libs/common/src/platform/state/derive-definition.spec.ts rename to libs/state/src/core/derive-definition.spec.ts diff --git a/libs/state/src/core/derive-definition.ts b/libs/state/src/core/derive-definition.ts new file mode 100644 index 0000000000..947a25e267 --- /dev/null +++ b/libs/state/src/core/derive-definition.ts @@ -0,0 +1,197 @@ +// 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 "@bitwarden/user-core"; + +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 = { + /** + * 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; + /** + * 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; + /** + * 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 { + /** + * 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, + ) {} + + /** + * 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( + definition: + | KeyDefinition + | UserKeyDefinition + | [DeriveDefinition, string], + options: DeriveDefinitionOptions, + ) { + if (isFromDeriveDefinition(definition)) { + return new DeriveDefinition(definition[0].stateDefinition, definition[1], options); + } else { + return new DeriveDefinition(definition.stateDefinition, definition.key, options); + } + } + + static fromWithUserId( + definition: + | KeyDefinition + | UserKeyDefinition + | [DeriveDefinition, 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 { + 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 + | UserKeyDefinition + | [DeriveDefinition, string], +): definition is [DeriveDefinition, string] { + return Array.isArray(definition); +} diff --git a/libs/state/src/core/derived-state.provider.ts b/libs/state/src/core/derived-state.provider.ts new file mode 100644 index 0000000000..cf21d98f69 --- /dev/null +++ b/libs/state/src/core/derived-state.provider.ts @@ -0,0 +1,25 @@ +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( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState; +} diff --git a/libs/state/src/core/derived-state.ts b/libs/state/src/core/derived-state.ts new file mode 100644 index 0000000000..b466c3024f --- /dev/null +++ b/libs/state/src/core/derived-state.ts @@ -0,0 +1,23 @@ +import { Observable } from "rxjs"; + +export type StateConverter, 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 { + /** + * The derived state observable + */ + state$: Observable; + /** + * 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; +} diff --git a/libs/state/src/core/global-state.provider.ts b/libs/state/src/core/global-state.provider.ts new file mode 100644 index 0000000000..a7179ba0f1 --- /dev/null +++ b/libs/state/src/core/global-state.provider.ts @@ -0,0 +1,13 @@ +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(keyDefinition: KeyDefinition): GlobalState; +} diff --git a/libs/state/src/core/global-state.ts b/libs/state/src/core/global-state.ts new file mode 100644 index 0000000000..b2ac634df2 --- /dev/null +++ b/libs/state/src/core/global-state.ts @@ -0,0 +1,30 @@ +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 { + /** + * 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: ( + configureState: (state: T | null, dependency: TCombine) => T | null, + options?: StateUpdateOptions, + ) => Promise; + + /** + * 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; +} diff --git a/libs/state/src/core/implementations/default-active-user-state.provider.spec.ts b/libs/state/src/core/implementations/default-active-user-state.provider.spec.ts new file mode 100644 index 0000000000..419daeb1ec --- /dev/null +++ b/libs/state/src/core/implementations/default-active-user-state.provider.spec.ts @@ -0,0 +1,253 @@ +/** + * need to update test environment so structuredClone works appropriately + * @jest-environment ../shared/test.environment.ts + */ +import { Observable, of } from "rxjs"; + +import { awaitAsync, trackEmissions } from "@bitwarden/core-test-utils"; +import { + FakeActiveUserAccessor, + FakeActiveUserStateProvider, + FakeDerivedStateProvider, + FakeGlobalStateProvider, + FakeSingleUserStateProvider, +} from "@bitwarden/state-test-utils"; +import { UserId } from "@bitwarden/user-core"; + +import { DeriveDefinition } from "../derive-definition"; +import { KeyDefinition } from "../key-definition"; +import { StateDefinition } from "../state-definition"; +import { UserKeyDefinition } from "../user-key-definition"; + +import { DefaultStateProvider } from "./default-state.provider"; + +describe("DefaultStateProvider", () => { + let sut: DefaultStateProvider; + let activeUserStateProvider: FakeActiveUserStateProvider; + let singleUserStateProvider: FakeSingleUserStateProvider; + let globalStateProvider: FakeGlobalStateProvider; + let derivedStateProvider: FakeDerivedStateProvider; + let activeAccountAccessor: FakeActiveUserAccessor; + const userId = "fakeUserId" as UserId; + + beforeEach(() => { + activeAccountAccessor = new FakeActiveUserAccessor(userId); + activeUserStateProvider = new FakeActiveUserStateProvider(activeAccountAccessor); + singleUserStateProvider = new FakeSingleUserStateProvider(); + globalStateProvider = new FakeGlobalStateProvider(); + derivedStateProvider = new FakeDerivedStateProvider(); + sut = new DefaultStateProvider( + activeUserStateProvider, + singleUserStateProvider, + globalStateProvider, + derivedStateProvider, + ); + }); + + describe("activeUserId$", () => { + it("should track the active User id from active user state provider", () => { + expect(sut.activeUserId$).toBe(activeUserStateProvider.activeUserId$); + }); + }); + + describe.each([ + [ + "getUserState$", + (keyDefinition: UserKeyDefinition, userId?: UserId) => + sut.getUserState$(keyDefinition, userId), + ], + [ + "getUserStateOrDefault$", + (keyDefinition: UserKeyDefinition, userId?: UserId) => + sut.getUserStateOrDefault$(keyDefinition, { userId: userId }), + ], + ])( + "Shared behavior for %s", + ( + _testName: string, + methodUnderTest: ( + keyDefinition: UserKeyDefinition, + userId?: UserId, + ) => Observable, + ) => { + const keyDefinition = new UserKeyDefinition( + new StateDefinition("test", "disk"), + "test", + { + deserializer: (s) => s, + clearOn: [], + }, + ); + + it("should follow the specified user if userId is provided", async () => { + const state = singleUserStateProvider.getFake(userId, keyDefinition); + state.nextState("value"); + const emissions = trackEmissions(methodUnderTest(keyDefinition, userId)); + + state.nextState("value2"); + state.nextState("value3"); + + expect(emissions).toEqual(["value", "value2", "value3"]); + }); + + it("should follow the current active user if no userId is provided", async () => { + activeAccountAccessor.switch(userId); + const state = singleUserStateProvider.getFake(userId, keyDefinition); + state.nextState("value"); + const emissions = trackEmissions(methodUnderTest(keyDefinition)); + + state.nextState("value2"); + state.nextState("value3"); + + expect(emissions).toEqual(["value", "value2", "value3"]); + }); + + it("should continue to follow the state of the user that was active when called, even if active user changes", async () => { + const state = singleUserStateProvider.getFake(userId, keyDefinition); + state.nextState("value"); + const emissions = trackEmissions(methodUnderTest(keyDefinition)); + + activeAccountAccessor.switch("newUserId" as UserId); + const newUserEmissions = trackEmissions(sut.getUserState$(keyDefinition)); + state.nextState("value2"); + state.nextState("value3"); + + expect(emissions).toEqual(["value", "value2", "value3"]); + expect(newUserEmissions).toEqual([null]); + }); + }, + ); + + describe("getUserState$", () => { + const keyDefinition = new UserKeyDefinition( + new StateDefinition("test", "disk"), + "test", + { + deserializer: (s) => s, + clearOn: [], + }, + ); + + it("should not emit any values until a truthy user id is supplied", async () => { + activeAccountAccessor.switch(null); + const state = singleUserStateProvider.getFake(userId, keyDefinition); + state.nextState("value"); + + const emissions = trackEmissions(sut.getUserState$(keyDefinition)); + + await awaitAsync(); + + expect(emissions).toHaveLength(0); + + activeAccountAccessor.switch(userId); + + await awaitAsync(); + + expect(emissions).toEqual(["value"]); + }); + }); + + describe("getUserStateOrDefault$", () => { + const keyDefinition = new UserKeyDefinition( + new StateDefinition("test", "disk"), + "test", + { + deserializer: (s) => s, + clearOn: [], + }, + ); + + it("should emit default value if no userId supplied and first active user id emission in falsy", async () => { + activeAccountAccessor.switch(null); + + const emissions = trackEmissions( + sut.getUserStateOrDefault$(keyDefinition, { + userId: undefined, + defaultValue: "I'm default!", + }), + ); + + expect(emissions).toEqual(["I'm default!"]); + }); + }); + + describe("setUserState", () => { + const keyDefinition = new UserKeyDefinition( + new StateDefinition("test", "disk"), + "test", + { + deserializer: (s) => s, + clearOn: [], + }, + ); + + it("should set the state for the active user if no userId is provided", async () => { + const value = "value"; + await sut.setUserState(keyDefinition, value); + const state = activeUserStateProvider.getFake(keyDefinition); + expect(state.nextMock).toHaveBeenCalledWith([expect.any(String), value]); + }); + + it("should not set state for a single user if no userId is provided", async () => { + const value = "value"; + await sut.setUserState(keyDefinition, value); + const state = singleUserStateProvider.getFake(userId, keyDefinition); + expect(state.nextMock).not.toHaveBeenCalled(); + }); + + it("should set the state for the provided userId", async () => { + const value = "value"; + await sut.setUserState(keyDefinition, value, userId); + const state = singleUserStateProvider.getFake(userId, keyDefinition); + expect(state.nextMock).toHaveBeenCalledWith(value); + }); + + it("should not set the active user state if userId is provided", async () => { + const value = "value"; + await sut.setUserState(keyDefinition, value, userId); + const state = activeUserStateProvider.getFake(keyDefinition); + expect(state.nextMock).not.toHaveBeenCalled(); + }); + }); + + it("should bind the activeUserStateProvider", () => { + const keyDefinition = new UserKeyDefinition(new StateDefinition("test", "disk"), "test", { + deserializer: () => null, + clearOn: [], + }); + const existing = activeUserStateProvider.get(keyDefinition); + const actual = sut.getActive(keyDefinition); + expect(actual).toBe(existing); + }); + + it("should bind the singleUserStateProvider", () => { + const userId = "user" as UserId; + const keyDefinition = new UserKeyDefinition(new StateDefinition("test", "disk"), "test", { + deserializer: () => null, + clearOn: [], + }); + const existing = singleUserStateProvider.get(userId, keyDefinition); + const actual = sut.getUser(userId, keyDefinition); + expect(actual).toBe(existing); + }); + + it("should bind the globalStateProvider", () => { + const keyDefinition = new KeyDefinition(new StateDefinition("test", "disk"), "test", { + deserializer: () => null, + }); + const existing = globalStateProvider.get(keyDefinition); + const actual = sut.getGlobal(keyDefinition); + expect(actual).toBe(existing); + }); + + it("should bind the derivedStateProvider", () => { + const derivedDefinition = new DeriveDefinition(new StateDefinition("test", "disk"), "test", { + derive: () => null, + deserializer: () => null, + }); + const parentState$ = of(null); + const existing = derivedStateProvider.get(parentState$, derivedDefinition, {}); + const actual = sut.getDerived(parentState$, derivedDefinition, {}); + expect(actual).toBe(existing); + }); +}); diff --git a/libs/state/src/core/implementations/default-active-user-state.provider.ts b/libs/state/src/core/implementations/default-active-user-state.provider.ts new file mode 100644 index 0000000000..e7e456f740 --- /dev/null +++ b/libs/state/src/core/implementations/default-active-user-state.provider.ts @@ -0,0 +1,37 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Observable, distinctUntilChanged } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +import { ActiveUserAccessor } from "../active-user.accessor"; +import { UserKeyDefinition } from "../user-key-definition"; +import { ActiveUserState } from "../user-state"; +import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider"; + +import { DefaultActiveUserState } from "./default-active-user-state"; + +export class DefaultActiveUserStateProvider implements ActiveUserStateProvider { + activeUserId$: Observable; + + constructor( + private readonly activeAccountAccessor: ActiveUserAccessor, + private readonly singleUserStateProvider: SingleUserStateProvider, + ) { + this.activeUserId$ = this.activeAccountAccessor.activeUserId$.pipe( + // To avoid going to storage when we don't need to, only get updates when there is a true change. + distinctUntilChanged((a, b) => (a == null || b == null ? a == b : a === b)), // Treat null and undefined as equal + ); + } + + get(keyDefinition: UserKeyDefinition): ActiveUserState { + // All other providers cache the creation of their corresponding `State` objects, this instance + // doesn't need to do that since it calls `SingleUserStateProvider` it will go through their caching + // layer, because of that, the creation of this instance is quite simple and not worth caching. + return new DefaultActiveUserState( + keyDefinition, + this.activeUserId$, + this.singleUserStateProvider, + ); + } +} diff --git a/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts b/libs/state/src/core/implementations/default-active-user-state.spec.ts similarity index 99% rename from libs/common/src/platform/state/implementations/default-active-user-state.spec.ts rename to libs/state/src/core/implementations/default-active-user-state.spec.ts index f6673e66c1..0c3834ee57 100644 --- a/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts +++ b/libs/state/src/core/implementations/default-active-user-state.spec.ts @@ -6,12 +6,12 @@ import { any, mock } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs"; import { Jsonify } from "type-fest"; +import { awaitAsync, trackEmissions } from "@bitwarden/core-test-utils"; +import { LogService } from "@bitwarden/logging"; import { StorageServiceProvider } from "@bitwarden/storage-core"; +import { FakeStorageService } from "@bitwarden/storage-test-utils"; +import { UserId } from "@bitwarden/user-core"; -import { awaitAsync, trackEmissions } from "../../../../spec"; -import { FakeStorageService } from "../../../../spec/fake-storage.service"; -import { UserId } from "../../../types/guid"; -import { LogService } from "../../abstractions/log.service"; import { StateDefinition } from "../state-definition"; import { StateEventRegistrarService } from "../state-event-registrar.service"; import { UserKeyDefinition } from "../user-key-definition"; diff --git a/libs/state/src/core/implementations/default-active-user-state.ts b/libs/state/src/core/implementations/default-active-user-state.ts new file mode 100644 index 0000000000..aa8b1e401d --- /dev/null +++ b/libs/state/src/core/implementations/default-active-user-state.ts @@ -0,0 +1,65 @@ +// 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 "@bitwarden/user-core"; + +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 implements ActiveUserState { + [activeMarker]: true; + combinedState$: Observable>; + state$: Observable; + + constructor( + protected keyDefinition: UserKeyDefinition, + private activeUserId$: Observable, + 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( + configureState: (state: T, dependency: TCombine) => T, + options: StateUpdateOptions = {}, + ): 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), + ]; + } +} diff --git a/libs/state/src/core/implementations/default-derived-state.provider.ts b/libs/state/src/core/implementations/default-derived-state.provider.ts new file mode 100644 index 0000000000..04883f6311 --- /dev/null +++ b/libs/state/src/core/implementations/default-derived-state.provider.ts @@ -0,0 +1,53 @@ +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, Record>>(); + + constructor() {} + + get( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState { + 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; + } + + const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies); + stateCache[cacheKey] = newDerivedState; + return newDerivedState; + } + + protected buildDerivedState( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState { + return new DefaultDerivedState(parentState$, deriveDefinition, dependencies); + } +} diff --git a/libs/common/src/platform/state/implementations/default-derived-state.spec.ts b/libs/state/src/core/implementations/default-derived-state.spec.ts similarity index 98% rename from libs/common/src/platform/state/implementations/default-derived-state.spec.ts rename to libs/state/src/core/implementations/default-derived-state.spec.ts index 6fcc1c408c..052a04ed19 100644 --- a/libs/common/src/platform/state/implementations/default-derived-state.spec.ts +++ b/libs/state/src/core/implementations/default-derived-state.spec.ts @@ -4,7 +4,8 @@ */ import { Subject, firstValueFrom } from "rxjs"; -import { awaitAsync, trackEmissions } from "../../../../spec"; +import { awaitAsync, trackEmissions } from "@bitwarden/core-test-utils"; + import { DeriveDefinition } from "../derive-definition"; import { StateDefinition } from "../state-definition"; diff --git a/libs/state/src/core/implementations/default-derived-state.ts b/libs/state/src/core/implementations/default-derived-state.ts new file mode 100644 index 0000000000..377a9e4dda --- /dev/null +++ b/libs/state/src/core/implementations/default-derived-state.ts @@ -0,0 +1,50 @@ +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 + implements DerivedState +{ + private readonly storageKey: string; + private forcedValueSubject = new Subject(); + + state$: Observable; + + constructor( + private parentState$: Observable, + protected deriveDefinition: DeriveDefinition, + 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(1); + }, + resetOnRefCountZero: () => timer(this.deriveDefinition.cleanupDelayMs), + }), + ); + } + + async forceValue(value: TTo) { + this.forcedValueSubject.next(value); + return value; + } +} diff --git a/libs/state/src/core/implementations/default-global-state.provider.ts b/libs/state/src/core/implementations/default-global-state.provider.ts new file mode 100644 index 0000000000..f082873614 --- /dev/null +++ b/libs/state/src/core/implementations/default-global-state.provider.ts @@ -0,0 +1,46 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { LogService } from "@bitwarden/logging"; +import { StorageServiceProvider } from "@bitwarden/storage-core"; + +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> = {}; + + constructor( + private storageServiceProvider: StorageServiceProvider, + private readonly logService: LogService, + ) {} + + get(keyDefinition: KeyDefinition): GlobalState { + 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; + } + + const newGlobalState = new DefaultGlobalState( + keyDefinition, + storageService, + this.logService, + ); + + this.globalStateCache[cacheKey] = newGlobalState; + return newGlobalState; + } + + private buildCacheKey(location: string, keyDefinition: KeyDefinition) { + return `${location}_${keyDefinition.fullName}`; + } +} diff --git a/libs/common/src/platform/state/implementations/default-global-state.spec.ts b/libs/state/src/core/implementations/default-global-state.spec.ts similarity index 94% rename from libs/common/src/platform/state/implementations/default-global-state.spec.ts rename to libs/state/src/core/implementations/default-global-state.spec.ts index 0f8e7028af..ecfbc001cf 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.spec.ts +++ b/libs/state/src/core/implementations/default-global-state.spec.ts @@ -7,9 +7,10 @@ import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; import { Jsonify } from "type-fest"; -import { trackEmissions, awaitAsync } from "../../../../spec"; -import { FakeStorageService } from "../../../../spec/fake-storage.service"; -import { LogService } from "../../abstractions/log.service"; +import { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils"; +import { LogService } from "@bitwarden/logging"; +import { FakeStorageService } from "@bitwarden/storage-test-utils"; + import { KeyDefinition, globalKeyBuilder } from "../key-definition"; import { StateDefinition } from "../state-definition"; @@ -343,9 +344,7 @@ describe("DefaultGlobalState", () => { expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(1); // Still be listening to storage updates - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - diskStorageService.save(globalKey, newData); + await diskStorageService.save(globalKey, newData); await awaitAsync(); // storage updates are behind a promise expect(sub2Emissions).toEqual([null, newData]); @@ -367,9 +366,7 @@ describe("DefaultGlobalState", () => { const emissions = trackEmissions(globalState.state$); await awaitAsync(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - diskStorageService.save(globalKey, newData); + await diskStorageService.save(globalKey, newData); await awaitAsync(); expect(emissions).toEqual([null, newData]); diff --git a/libs/state/src/core/implementations/default-global-state.ts b/libs/state/src/core/implementations/default-global-state.ts new file mode 100644 index 0000000000..cb6c6c41a0 --- /dev/null +++ b/libs/state/src/core/implementations/default-global-state.ts @@ -0,0 +1,20 @@ +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core"; + +import { GlobalState } from "../global-state"; +import { KeyDefinition, globalKeyBuilder } from "../key-definition"; + +import { StateBase } from "./state-base"; + +export class DefaultGlobalState + extends StateBase> + implements GlobalState +{ + constructor( + keyDefinition: KeyDefinition, + chosenLocation: AbstractStorageService & ObservableStorageService, + logService: LogService, + ) { + super(globalKeyBuilder(keyDefinition), chosenLocation, keyDefinition, logService); + } +} diff --git a/libs/state/src/core/implementations/default-single-user-state.provider.ts b/libs/state/src/core/implementations/default-single-user-state.provider.ts new file mode 100644 index 0000000000..252ea1fa3e --- /dev/null +++ b/libs/state/src/core/implementations/default-single-user-state.provider.ts @@ -0,0 +1,54 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { LogService } from "@bitwarden/logging"; +import { StorageServiceProvider } from "@bitwarden/storage-core"; +import { UserId } from "@bitwarden/user-core"; + +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> = {}; + + constructor( + private readonly storageServiceProvider: StorageServiceProvider, + private readonly stateEventRegistrarService: StateEventRegistrarService, + private readonly logService: LogService, + ) {} + + get(userId: UserId, keyDefinition: UserKeyDefinition): SingleUserState { + 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; + } + + const newUserState = new DefaultSingleUserState( + userId, + keyDefinition, + storageService, + this.stateEventRegistrarService, + this.logService, + ); + this.cache[cacheKey] = newUserState; + return newUserState; + } + + private buildCacheKey( + location: string, + userId: UserId, + keyDefinition: UserKeyDefinition, + ) { + return `${location}_${keyDefinition.fullName}_${userId}`; + } +} diff --git a/libs/common/src/platform/state/implementations/default-single-user-state.spec.ts b/libs/state/src/core/implementations/default-single-user-state.spec.ts similarity index 95% rename from libs/common/src/platform/state/implementations/default-single-user-state.spec.ts rename to libs/state/src/core/implementations/default-single-user-state.spec.ts index 0a98c55970..c6262581fa 100644 --- a/libs/common/src/platform/state/implementations/default-single-user-state.spec.ts +++ b/libs/state/src/core/implementations/default-single-user-state.spec.ts @@ -7,11 +7,12 @@ import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; import { Jsonify } from "type-fest"; -import { trackEmissions, awaitAsync } from "../../../../spec"; -import { FakeStorageService } from "../../../../spec/fake-storage.service"; -import { UserId } from "../../../types/guid"; -import { LogService } from "../../abstractions/log.service"; -import { Utils } from "../../misc/utils"; +import { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils"; +import { newGuid } from "@bitwarden/guid"; +import { LogService } from "@bitwarden/logging"; +import { FakeStorageService } from "@bitwarden/storage-test-utils"; +import { UserId } from "@bitwarden/user-core"; + import { StateDefinition } from "../state-definition"; import { StateEventRegistrarService } from "../state-event-registrar.service"; import { UserKeyDefinition } from "../user-key-definition"; @@ -39,7 +40,7 @@ const testKeyDefinition = new UserKeyDefinition(testStateDefinition, cleanupDelayMs, clearOn: [], }); -const userId = Utils.newGuid() as UserId; +const userId = newGuid() as UserId; const userKey = testKeyDefinition.buildKey(userId); describe("DefaultSingleUserState", () => { @@ -524,9 +525,7 @@ describe("DefaultSingleUserState", () => { expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(1); // Still be listening to storage updates - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - diskStorageService.save(userKey, newData); + await diskStorageService.save(userKey, newData); await awaitAsync(); // storage updates are behind a promise expect(sub2Emissions).toEqual([null, newData]); @@ -548,9 +547,7 @@ describe("DefaultSingleUserState", () => { const emissions = trackEmissions(userState.state$); await awaitAsync(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - diskStorageService.save(userKey, newData); + await diskStorageService.save(userKey, newData); await awaitAsync(); expect(emissions).toEqual([null, newData]); diff --git a/libs/state/src/core/implementations/default-single-user-state.ts b/libs/state/src/core/implementations/default-single-user-state.ts new file mode 100644 index 0000000000..b177faf011 --- /dev/null +++ b/libs/state/src/core/implementations/default-single-user-state.ts @@ -0,0 +1,36 @@ +import { Observable, combineLatest, of } from "rxjs"; + +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core"; +import { UserId } from "@bitwarden/user-core"; + +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 + extends StateBase> + implements SingleUserState +{ + readonly combinedState$: Observable>; + + constructor( + readonly userId: UserId, + keyDefinition: UserKeyDefinition, + 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 { + await super.doStorageSave(newState, oldState); + if (newState != null && oldState == null) { + await this.stateEventRegistrarService.registerEvents(this.keyDefinition); + } + } +} diff --git a/libs/common/src/platform/state/implementations/default-state.provider.spec.ts b/libs/state/src/core/implementations/default-state.provider.spec.ts similarity index 98% rename from libs/common/src/platform/state/implementations/default-state.provider.spec.ts rename to libs/state/src/core/implementations/default-state.provider.spec.ts index 442cec66d9..419daeb1ec 100644 --- a/libs/common/src/platform/state/implementations/default-state.provider.spec.ts +++ b/libs/state/src/core/implementations/default-state.provider.spec.ts @@ -4,16 +4,16 @@ */ import { Observable, of } from "rxjs"; -import { UserId } from "@bitwarden/user-core"; - -import { awaitAsync, trackEmissions } from "../../../../spec"; +import { awaitAsync, trackEmissions } from "@bitwarden/core-test-utils"; import { FakeActiveUserAccessor, FakeActiveUserStateProvider, FakeDerivedStateProvider, FakeGlobalStateProvider, FakeSingleUserStateProvider, -} from "../../../../spec/fake-state-provider"; +} from "@bitwarden/state-test-utils"; +import { UserId } from "@bitwarden/user-core"; + import { DeriveDefinition } from "../derive-definition"; import { KeyDefinition } from "../key-definition"; import { StateDefinition } from "../state-definition"; diff --git a/libs/state/src/core/implementations/default-state.provider.ts b/libs/state/src/core/implementations/default-state.provider.ts new file mode 100644 index 0000000000..45a8bc8e8d --- /dev/null +++ b/libs/state/src/core/implementations/default-state.provider.ts @@ -0,0 +1,80 @@ +// 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 "@bitwarden/user-core"; + +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; + constructor( + private readonly activeUserStateProvider: ActiveUserStateProvider, + private readonly singleUserStateProvider: SingleUserStateProvider, + private readonly globalStateProvider: GlobalStateProvider, + private readonly derivedStateProvider: DerivedStateProvider, + ) { + this.activeUserId$ = this.activeUserStateProvider.activeUserId$; + } + + getUserState$(userKeyDefinition: UserKeyDefinition, userId?: UserId): Observable { + if (userId) { + return this.getUser(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(userId, userKeyDefinition).state$), + ); + } + } + + getUserStateOrDefault$( + userKeyDefinition: UserKeyDefinition, + config: { userId: UserId | undefined; defaultValue?: T }, + ): Observable { + const { userId, defaultValue = null } = config; + if (userId) { + return this.getUser(userId, userKeyDefinition).state$; + } else { + return this.activeUserId$.pipe( + take(1), + switchMap((userId) => + userId != null ? this.getUser(userId, userKeyDefinition).state$ : of(defaultValue), + ), + ); + } + } + + async setUserState( + userKeyDefinition: UserKeyDefinition, + value: T | null, + userId?: UserId, + ): Promise<[UserId, T | null]> { + if (userId) { + return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)]; + } else { + return await this.getActive(userKeyDefinition).update(() => value); + } + } + + getActive: InstanceType["get"] = + this.activeUserStateProvider.get.bind(this.activeUserStateProvider); + getUser: InstanceType["get"] = + this.singleUserStateProvider.get.bind(this.singleUserStateProvider); + getGlobal: InstanceType["get"] = this.globalStateProvider.get.bind( + this.globalStateProvider, + ); + getDerived: ( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ) => DerivedState = this.derivedStateProvider.get.bind(this.derivedStateProvider); +} diff --git a/libs/state/src/core/implementations/index.ts b/libs/state/src/core/implementations/index.ts new file mode 100644 index 0000000000..bb2544ad6d --- /dev/null +++ b/libs/state/src/core/implementations/index.ts @@ -0,0 +1,12 @@ +export * from "./default-active-user-state.provider"; +export * from "./default-active-user-state"; +export * from "./default-derived-state.provider"; +export * from "./default-derived-state"; +export * from "./default-global-state.provider"; +export * from "./default-global-state"; +export * from "./default-single-user-state.provider"; +export * from "./default-single-user-state"; +export * from "./default-state.provider"; +export * from "./inline-derived-state"; +export * from "./state-base"; +export * from "./util"; diff --git a/libs/common/src/platform/state/implementations/inline-derived-state.spec.ts b/libs/state/src/core/implementations/inline-derived-state.spec.ts similarity index 100% rename from libs/common/src/platform/state/implementations/inline-derived-state.spec.ts rename to libs/state/src/core/implementations/inline-derived-state.spec.ts diff --git a/libs/state/src/core/implementations/inline-derived-state.ts b/libs/state/src/core/implementations/inline-derived-state.ts new file mode 100644 index 0000000000..1202839d5c --- /dev/null +++ b/libs/state/src/core/implementations/inline-derived-state.ts @@ -0,0 +1,37 @@ +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( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState { + return new InlineDerivedState(parentState$, deriveDefinition, dependencies); + } +} + +export class InlineDerivedState + implements DerivedState +{ + constructor( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ) { + this.state$ = parentState$.pipe( + concatMap(async (value) => await deriveDefinition.derive(value, dependencies)), + ); + } + + state$: Observable; + + forceValue(value: TTo): Promise { + // No need to force anything, we don't keep a cache + return Promise.resolve(value); + } +} diff --git a/libs/common/src/platform/state/implementations/specific-state.provider.spec.ts b/libs/state/src/core/implementations/specific-state.provider.spec.ts similarity index 96% rename from libs/common/src/platform/state/implementations/specific-state.provider.spec.ts rename to libs/state/src/core/implementations/specific-state.provider.spec.ts index 701274eca3..287fd8702e 100644 --- a/libs/common/src/platform/state/implementations/specific-state.provider.spec.ts +++ b/libs/state/src/core/implementations/specific-state.provider.spec.ts @@ -1,11 +1,11 @@ import { mock } from "jest-mock-extended"; +import { LogService } from "@bitwarden/logging"; +import { FakeActiveUserAccessor } from "@bitwarden/state-test-utils"; import { StorageServiceProvider } from "@bitwarden/storage-core"; +import { FakeStorageService } from "@bitwarden/storage-test-utils"; +import { UserId } from "@bitwarden/user-core"; -import { FakeActiveUserAccessor } from "../../../../spec"; -import { FakeStorageService } from "../../../../spec/fake-storage.service"; -import { UserId } from "../../../types/guid"; -import { LogService } from "../../abstractions/log.service"; import { KeyDefinition } from "../key-definition"; import { StateDefinition } from "../state-definition"; import { StateEventRegistrarService } from "../state-event-registrar.service"; diff --git a/libs/state/src/core/implementations/state-base.ts b/libs/state/src/core/implementations/state-base.ts new file mode 100644 index 0000000000..72da2075e7 --- /dev/null +++ b/libs/state/src/core/implementations/state-base.ts @@ -0,0 +1,137 @@ +// 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 { LogService } from "@bitwarden/logging"; +import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core"; + +import { StorageKey } from "../../types/state"; +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 = { + deserializer: (jsonState: Jsonify) => T | null; + cleanupDelayMs: number; + debug: Required; +}; + +export abstract class StateBase> { + private updatePromise: Promise; + + readonly state$: Observable; + + 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( + configureState: (state: T | null, dependency: TCombine) => T | null, + options: StateUpdateOptions = {}, + ): Promise { + 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( + configureState: (state: T | null, dependency: TCombine) => T | null, + options: StateUpdateOptions, + ): Promise { + 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); + } +} diff --git a/libs/common/src/platform/state/implementations/util.spec.ts b/libs/state/src/core/implementations/util.spec.ts similarity index 59% rename from libs/common/src/platform/state/implementations/util.spec.ts rename to libs/state/src/core/implementations/util.spec.ts index 266e517702..447fbef2f8 100644 --- a/libs/common/src/platform/state/implementations/util.spec.ts +++ b/libs/state/src/core/implementations/util.spec.ts @@ -1,4 +1,4 @@ -import { FakeStorageService } from "../../../../spec/fake-storage.service"; +import { FakeStorageService } from "@bitwarden/storage-test-utils"; import { getStoredValue } from "./util"; @@ -19,9 +19,7 @@ describe("getStoredValue", () => { }); it("should deserialize", async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - storageService.save(key, value); + await storageService.save(key, value); const result = await getStoredValue(key, storageService, deserializer); @@ -34,9 +32,7 @@ describe("getStoredValue", () => { }); it("should not deserialize", async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - storageService.save(key, value); + await storageService.save(key, value); const result = await getStoredValue(key, storageService, deserializer); @@ -44,9 +40,7 @@ describe("getStoredValue", () => { }); it("should convert undefined to null", async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - storageService.save(key, undefined); + await storageService.save(key, undefined); const result = await getStoredValue(key, storageService, deserializer); diff --git a/libs/common/src/platform/state/implementations/util.ts b/libs/state/src/core/implementations/util.ts similarity index 100% rename from libs/common/src/platform/state/implementations/util.ts rename to libs/state/src/core/implementations/util.ts diff --git a/libs/state/src/core/index.ts b/libs/state/src/core/index.ts new file mode 100644 index 0000000000..6cf92b7ecc --- /dev/null +++ b/libs/state/src/core/index.ts @@ -0,0 +1,19 @@ +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 { activeMarker } from "./user-state"; +export { StateDefinition } from "./state-definition"; +export { ActiveUserAccessor } from "./active-user.accessor"; + +export * from "./state-definitions"; +export * from "./implementations"; +export * from "./state-event-registrar.service"; diff --git a/libs/common/src/platform/state/key-definition.spec.ts b/libs/state/src/core/key-definition.spec.ts similarity index 100% rename from libs/common/src/platform/state/key-definition.spec.ts rename to libs/state/src/core/key-definition.spec.ts diff --git a/libs/state/src/core/key-definition.ts b/libs/state/src/core/key-definition.ts new file mode 100644 index 0000000000..be52368ac8 --- /dev/null +++ b/libs/state/src/core/key-definition.ts @@ -0,0 +1,183 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Jsonify } from "type-fest"; + +import { array, record } from "@bitwarden/serialization"; + +import { StorageKey } from "../types/state"; + +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 = { + /** + * 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 | 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 { + readonly debug: Required; + + /** + * 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, + ) { + 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(MY_STATE, "key", { + * deserializer: (myJsonElement) => convertToElement(myJsonElement), + * }); + * ``` + */ + static array( + 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, // The array helper forces an initialValue of an empty array + ) { + return new KeyDefinition(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(MY_STATE, "key", { + * deserializer: (myJsonValue) => convertToValue(myJsonValue), + * }); + * ``` + */ + static record( + 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, // The array helper forces an initialValue of an empty record + ) { + return new KeyDefinition>(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): StorageKey { + return `global_${keyDefinition.stateDefinition.name}_${keyDefinition.key}` as StorageKey; +} diff --git a/libs/state/src/core/state-definition.ts b/libs/state/src/core/state-definition.ts new file mode 100644 index 0000000000..de28d7d11f --- /dev/null +++ b/libs/state/src/core/state-definition.ts @@ -0,0 +1,21 @@ +import { StorageLocation, ClientLocations } from "@bitwarden/storage-core"; + +/** + * Defines the base location and instruction of where this state is expected to be located. + */ +export class StateDefinition { + readonly storageLocationOverrides: Partial; + + /** + * 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, + ) { + this.storageLocationOverrides = storageLocationOverrides ?? {}; + } +} diff --git a/libs/common/src/platform/state/state-definitions.spec.ts b/libs/state/src/core/state-definitions.spec.ts similarity index 100% rename from libs/common/src/platform/state/state-definitions.spec.ts rename to libs/state/src/core/state-definitions.spec.ts diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/state/src/core/state-definitions.ts similarity index 100% rename from libs/common/src/platform/state/state-definitions.ts rename to libs/state/src/core/state-definitions.ts diff --git a/libs/common/src/platform/state/state-event-registrar.service.spec.ts b/libs/state/src/core/state-event-registrar.service.spec.ts similarity index 97% rename from libs/common/src/platform/state/state-event-registrar.service.spec.ts rename to libs/state/src/core/state-event-registrar.service.spec.ts index b022e2ce41..e79269077d 100644 --- a/libs/common/src/platform/state/state-event-registrar.service.spec.ts +++ b/libs/state/src/core/state-event-registrar.service.spec.ts @@ -1,13 +1,12 @@ import { mock } from "jest-mock-extended"; +import { FakeGlobalStateProvider } from "@bitwarden/state-test-utils"; import { AbstractStorageService, ObservableStorageService, StorageServiceProvider, } from "@bitwarden/storage-core"; -import { FakeGlobalStateProvider } from "../../../spec"; - import { StateDefinition } from "./state-definition"; import { STATE_LOCK_EVENT, StateEventRegistrarService } from "./state-event-registrar.service"; import { UserKeyDefinition } from "./user-key-definition"; diff --git a/libs/state/src/core/state-event-registrar.service.ts b/libs/state/src/core/state-event-registrar.service.ts new file mode 100644 index 0000000000..5e21fe1fcf --- /dev/null +++ b/libs/state/src/core/state-event-registrar.service.ts @@ -0,0 +1,76 @@ +import { PossibleLocation, StorageServiceProvider } from "@bitwarden/storage-core"; + +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(CLEAR_EVENT_DISK, "lock", { + deserializer: (e) => e, +}); + +export const STATE_LOGOUT_EVENT = KeyDefinition.array(CLEAR_EVENT_DISK, "logout", { + deserializer: (e) => e, +}); + +export class StateEventRegistrarService { + private readonly stateEventStateMap: { [Prop in ClearEvent]: GlobalState }; + + constructor( + globalStateProvider: GlobalStateProvider, + private storageServiceProvider: StorageServiceProvider, + ) { + this.stateEventStateMap = { + lock: globalStateProvider.get(STATE_LOCK_EVENT), + logout: globalStateProvider.get(STATE_LOGOUT_EVENT), + }; + } + + async registerEvents(keyDefinition: UserKeyDefinition) { + 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 + ); + }, + }, + ); + } + } +} diff --git a/libs/common/src/platform/state/state-event-runner.service.spec.ts b/libs/state/src/core/state-event-runner.service.spec.ts similarity index 95% rename from libs/common/src/platform/state/state-event-runner.service.spec.ts rename to libs/state/src/core/state-event-runner.service.spec.ts index 4aef3d8516..7a7ddb2d9f 100644 --- a/libs/common/src/platform/state/state-event-runner.service.spec.ts +++ b/libs/state/src/core/state-event-runner.service.spec.ts @@ -1,13 +1,12 @@ import { mock } from "jest-mock-extended"; +import { FakeGlobalStateProvider } from "@bitwarden/state-test-utils"; import { AbstractStorageService, ObservableStorageService, StorageServiceProvider, } from "@bitwarden/storage-core"; - -import { FakeGlobalStateProvider } from "../../../spec"; -import { UserId } from "../../types/guid"; +import { UserId } from "@bitwarden/user-core"; import { STATE_LOCK_EVENT } from "./state-event-registrar.service"; import { StateEventRunnerService } from "./state-event-runner.service"; diff --git a/libs/state/src/core/state-event-runner.service.ts b/libs/state/src/core/state-event-runner.service.ts new file mode 100644 index 0000000000..046816a2ce --- /dev/null +++ b/libs/state/src/core/state-event-runner.service.ts @@ -0,0 +1,82 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { firstValueFrom } from "rxjs"; + +import { StorageServiceProvider, StorageLocation } from "@bitwarden/storage-core"; +import { UserId } from "@bitwarden/user-core"; + +import { GlobalState } from "./global-state"; +import { GlobalStateProvider } from "./global-state.provider"; +import { StateDefinition } 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 }; + + 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( + new StateDefinition(ticket.state, ticket.location as unknown as StorageLocation), + ticket.key, + { + deserializer: (v) => v, + clearOn: [], + }, + ); + return userKey.buildKey(userId); + } +} diff --git a/libs/common/src/platform/state/state-update-options.ts b/libs/state/src/core/state-update-options.ts similarity index 100% rename from libs/common/src/platform/state/state-update-options.ts rename to libs/state/src/core/state-update-options.ts diff --git a/libs/state/src/core/state.provider.ts b/libs/state/src/core/state.provider.ts new file mode 100644 index 0000000000..c6d9942931 --- /dev/null +++ b/libs/state/src/core/state.provider.ts @@ -0,0 +1,81 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +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; + + /** + * 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$(keyDefinition: UserKeyDefinition, userId?: UserId): Observable; + + /** + * 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$( + keyDefinition: UserKeyDefinition, + config: { userId: UserId | undefined; defaultValue?: T }, + ): Observable; + + /** + * 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( + keyDefinition: UserKeyDefinition, + value: T | null, + userId?: UserId, + ): Promise<[UserId, T | null]>; + + /** @see{@link ActiveUserStateProvider.get} */ + abstract getActive(userKeyDefinition: UserKeyDefinition): ActiveUserState; + + /** @see{@link SingleUserStateProvider.get} */ + abstract getUser(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState; + + /** @see{@link GlobalStateProvider.get} */ + abstract getGlobal(keyDefinition: KeyDefinition): GlobalState; + abstract getDerived( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState; +} diff --git a/libs/state/src/core/storage/memory-storage.service.ts b/libs/state/src/core/storage/memory-storage.service.ts new file mode 100644 index 0000000000..53810f11d2 --- /dev/null +++ b/libs/state/src/core/storage/memory-storage.service.ts @@ -0,0 +1 @@ +export { SerializedMemoryStorageService as MemoryStorageService } from "@bitwarden/storage-core"; diff --git a/libs/state/src/core/user-key-definition.ts b/libs/state/src/core/user-key-definition.ts new file mode 100644 index 0000000000..e1c4b02d86 --- /dev/null +++ b/libs/state/src/core/user-key-definition.ts @@ -0,0 +1,143 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { isGuid } from "@bitwarden/guid"; +import { array, record } from "@bitwarden/serialization"; +import { UserId } from "@bitwarden/user-core"; + +import { StorageKey } from "../types/state"; + +import { DebugOptions, KeyDefinitionOptions } from "./key-definition"; +import { StateDefinition } from "./state-definition"; + +export type ClearEvent = "lock" | "logout"; + +export type UserKeyDefinitionOptions = KeyDefinitionOptions & { + clearOn: ClearEvent[]; +}; + +const USER_KEY_DEFINITION_MARKER: unique symbol = Symbol("UserKeyDefinition"); + +export class UserKeyDefinition { + 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; + + constructor( + readonly stateDefinition: StateDefinition, + readonly key: string, + private readonly options: UserKeyDefinitionOptions, + ) { + 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(MY_STATE, "key", { + * deserializer: (myJsonElement) => convertToElement(myJsonElement), + * }); + * ``` + */ + static array( + 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, + ) { + return new UserKeyDefinition(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(MY_STATE, "key", { + * deserializer: (myJsonValue) => convertToValue(myJsonValue), + * }); + * ``` + */ + static record( + 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, // The array helper forces an initialValue of an empty record + ) { + return new UserKeyDefinition>(stateDefinition, key, { + ...options, + deserializer: record((v) => options.deserializer(v)), + }); + } + + get fullName() { + return `${this.stateDefinition.name}_${this.key}`; + } + + buildKey(userId: UserId) { + if (!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}`; + } +} diff --git a/libs/state/src/core/user-state.provider.ts b/libs/state/src/core/user-state.provider.ts new file mode 100644 index 0000000000..82a2f87361 --- /dev/null +++ b/libs/state/src/core/user-state.provider.ts @@ -0,0 +1,35 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +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(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState; +} + +/** 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; + + /** + * 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(userKeyDefinition: UserKeyDefinition): ActiveUserState; +} diff --git a/libs/state/src/core/user-state.ts b/libs/state/src/core/user-state.ts new file mode 100644 index 0000000000..43c989ca22 --- /dev/null +++ b/libs/state/src/core/user-state.ts @@ -0,0 +1,64 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +import { StateUpdateOptions } from "./state-update-options"; + +export type CombinedState = readonly [userId: UserId, state: T]; + +/** A helper object for interacting with state that is scoped to a specific user. */ +export interface UserState { + /** Emits a stream of data. Emits null if the user does not have specified state. */ + readonly state$: Observable; + + /** 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>; +} + +export const activeMarker: unique symbol = Symbol("active"); + +export interface ActiveUserState extends UserState { + 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; + + /** + * 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: ( + configureState: (state: T | null, dependencies: TCombine) => T | null, + options?: StateUpdateOptions, + ) => Promise<[UserId, T | null]>; +} + +export interface SingleUserState extends UserState { + 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: ( + configureState: (state: T | null, dependencies: TCombine) => T | null, + options?: StateUpdateOptions, + ) => Promise; +} diff --git a/libs/state/src/index.ts b/libs/state/src/index.ts new file mode 100644 index 0000000000..d74e7fc137 --- /dev/null +++ b/libs/state/src/index.ts @@ -0,0 +1,4 @@ +// Root barrel for @bitwarden/state +export * from "./core"; +export * from "./state-migrations"; +export * from "./types/state"; diff --git a/libs/state/src/state-migrations/index.ts b/libs/state/src/state-migrations/index.ts new file mode 100644 index 0000000000..cb48631af9 --- /dev/null +++ b/libs/state/src/state-migrations/index.ts @@ -0,0 +1,4 @@ +export * from "./migrate"; +export * from "./migration-builder"; +export * from "./migration-helper"; +export * from "./migrator"; diff --git a/libs/common/src/state-migrations/migrate.spec.ts b/libs/state/src/state-migrations/migrate.spec.ts similarity index 78% rename from libs/common/src/state-migrations/migrate.spec.ts rename to libs/state/src/state-migrations/migrate.spec.ts index a3e1b7ac57..c0484cce37 100644 --- a/libs/common/src/state-migrations/migrate.spec.ts +++ b/libs/state/src/state-migrations/migrate.spec.ts @@ -1,9 +1,7 @@ import { mock, MockProxy } from "jest-mock-extended"; -// 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"; +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService } from "@bitwarden/storage-core"; import { currentVersion } from "./migrate"; diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/state/src/state-migrations/migrate.ts similarity index 97% rename from libs/common/src/state-migrations/migrate.ts rename to libs/state/src/state-migrations/migrate.ts index 2b484a0fbd..620c2d3bb1 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/state/src/state-migrations/migrate.ts @@ -1,7 +1,5 @@ -// 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"; +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService } from "@bitwarden/storage-core"; import { MigrationBuilder } from "./migration-builder"; import { EverHadUserKeyMigrator } from "./migrations/10-move-ever-had-user-key-to-state-providers"; diff --git a/libs/common/src/state-migrations/migration-builder.spec.ts b/libs/state/src/state-migrations/migration-builder.spec.ts similarity index 98% rename from libs/common/src/state-migrations/migration-builder.spec.ts rename to libs/state/src/state-migrations/migration-builder.spec.ts index 59d85609e0..15e526b945 100644 --- a/libs/common/src/state-migrations/migration-builder.spec.ts +++ b/libs/state/src/state-migrations/migration-builder.spec.ts @@ -1,7 +1,6 @@ import { mock } from "jest-mock-extended"; -// eslint-disable-next-line import/no-restricted-paths -import { ClientType } from "../enums"; +import { ClientType } from "@bitwarden/client-type"; import { MigrationBuilder } from "./migration-builder"; import { MigrationHelper } from "./migration-helper"; diff --git a/libs/state/src/state-migrations/migration-builder.ts b/libs/state/src/state-migrations/migration-builder.ts new file mode 100644 index 0000000000..b9a1c67cd6 --- /dev/null +++ b/libs/state/src/state-migrations/migration-builder.ts @@ -0,0 +1,106 @@ +import { MigrationHelper } from "./migration-helper"; +import { Direction, Migrator, VersionFrom, VersionTo } from "./migrator"; + +export class MigrationBuilder { + /** 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; 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 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, + TFrom extends VersionFrom & TCurrent, + TTo extends VersionTo, + >( + ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo] + ): MigrationBuilder { + 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 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, + TFrom extends VersionFrom, + TTo extends VersionTo & TCurrent, + >( + ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TTo, TFrom] + ): MigrationBuilder { + 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 { + return this.migrations.reduce( + (promise, migrator) => + promise.then(async () => { + await this.runMigrator(migrator.migrator, helper, migrator.direction); + }), + Promise.resolve(), + ); + } + + private addMigrator< + TMigrator extends Migrator, + TFrom extends VersionFrom & TCurrent, + TTo extends VersionTo, + >( + 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([...this.migrations, newMigration]); + } + + private async runMigrator( + migrator: Migrator, + helper: MigrationHelper, + direction: Direction, + ): Promise { + 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}`, + ); + } + } +} diff --git a/libs/common/src/state-migrations/migration-helper.spec.ts b/libs/state/src/state-migrations/migration-helper.spec.ts similarity index 93% rename from libs/common/src/state-migrations/migration-helper.spec.ts rename to libs/state/src/state-migrations/migration-helper.spec.ts index 11126c6723..d811354b0b 100644 --- a/libs/common/src/state-migrations/migration-helper.spec.ts +++ b/libs/state/src/state-migrations/migration-helper.spec.ts @@ -1,14 +1,10 @@ import { MockProxy, mock } from "jest-mock-extended"; -import { FakeStorageService } from "../../spec/fake-storage.service"; -// eslint-disable-next-line import/no-restricted-paths -- Needed client type enum -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"; -// eslint-disable-next-line import/no-restricted-paths -- Needed to generate unique strings for injection -import { Utils } from "../platform/misc/utils"; +import { ClientType } from "@bitwarden/client-type"; +import { newGuid } from "@bitwarden/guid"; +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService } from "@bitwarden/storage-core"; +import { FakeStorageService } from "@bitwarden/storage-test-utils"; import { MigrationHelper, MigrationHelperType } from "./migration-helper"; import { Migrator } from "./migrator"; @@ -294,8 +290,8 @@ function injectData(data: Record, path: string[]): InjectedData } } - const propertyName = `__injectedProperty__${Utils.newGuid()}`; - const propertyValue = `__injectedValue__${Utils.newGuid()}`; + const propertyName = `__injectedProperty__${newGuid()}`; + const propertyValue = `__injectedValue__${newGuid()}`; injectedData.push({ propertyName: propertyName, diff --git a/libs/state/src/state-migrations/migration-helper.ts b/libs/state/src/state-migrations/migration-helper.ts new file mode 100644 index 0000000000..f853671956 --- /dev/null +++ b/libs/state/src/state-migrations/migration-helper.ts @@ -0,0 +1,258 @@ +import { ClientType } from "@bitwarden/client-type"; +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService } from "@bitwarden/storage-core"; + +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(key: string): Promise { + return this.storageService.get(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(key: string, value: T): Promise { + 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 { + 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(keyDefinition: KeyDefinitionLike): Promise { + return this.get(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(keyDefinition: KeyDefinitionLike, value: T): Promise { + 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 { + 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(userId: string, keyDefinition: KeyDefinitionLike): Promise { + return this.get(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(userId: string, keyDefinition: KeyDefinitionLike, value: T): Promise { + 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 { + 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(): Promise< + { userId: string; account: ExpectedAccountType }[] + > { + const userIds = await this.getKnownUserIds(); + return Promise.all( + userIds.map(async (userId) => ({ + userId, + account: await this.get(userId), + })), + ); + } + + /** + * Helper method to read known users ids. + */ + async getKnownUserIds(): Promise { + 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 { + return (await storageService.get("authenticatedAccounts")) ?? []; +} + +async function knownAccountUserIdsBuilder( + storageService: AbstractStorageService, +): Promise { + const accounts = await storageService.get>( + globalKeyBuilder({ stateDefinition: { name: "account" }, key: "accounts" }), + ); + return Object.keys(accounts ?? {}); +} diff --git a/libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.ts b/libs/state/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/11-move-org-keys-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/11-move-org-keys-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/11-move-org-keys-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/11-move-org-keys-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/11-move-org-keys-to-state-providers.ts b/libs/state/src/state-migrations/migrations/11-move-org-keys-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/11-move-org-keys-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/11-move-org-keys-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.spec.ts b/libs/state/src/state-migrations/migrations/12-move-environment-state-to-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.spec.ts rename to libs/state/src/state-migrations/migrations/12-move-environment-state-to-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.ts b/libs/state/src/state-migrations/migrations/12-move-environment-state-to-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.ts rename to libs/state/src/state-migrations/migrations/12-move-environment-state-to-providers.ts diff --git a/libs/common/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.ts b/libs/state/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.spec.ts b/libs/state/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.spec.ts rename to libs/state/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.ts b/libs/state/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.ts rename to libs/state/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.ts diff --git a/libs/common/src/state-migrations/migrations/15-move-folder-state-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/15-move-folder-state-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/15-move-folder-state-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/15-move-folder-state-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/15-move-folder-state-to-state-provider.ts b/libs/state/src/state-migrations/migrations/15-move-folder-state-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/15-move-folder-state-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/15-move-folder-state-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/16-move-last-sync-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/16-move-last-sync-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/16-move-last-sync-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/16-move-last-sync-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/16-move-last-sync-to-state-provider.ts b/libs/state/src/state-migrations/migrations/16-move-last-sync-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/16-move-last-sync-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/16-move-last-sync-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.ts b/libs/state/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.ts b/libs/state/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/19-migrate-require-password-on-start.spec.ts b/libs/state/src/state-migrations/migrations/19-migrate-require-password-on-start.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/19-migrate-require-password-on-start.spec.ts rename to libs/state/src/state-migrations/migrations/19-migrate-require-password-on-start.spec.ts diff --git a/libs/common/src/state-migrations/migrations/19-migrate-require-password-on-start.ts b/libs/state/src/state-migrations/migrations/19-migrate-require-password-on-start.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/19-migrate-require-password-on-start.ts rename to libs/state/src/state-migrations/migrations/19-migrate-require-password-on-start.ts diff --git a/libs/common/src/state-migrations/migrations/20-move-private-key-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/20-move-private-key-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/20-move-private-key-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/20-move-private-key-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/20-move-private-key-to-state-providers.ts b/libs/state/src/state-migrations/migrations/20-move-private-key-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/20-move-private-key-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/20-move-private-key-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/21-move-collections-state-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/21-move-collections-state-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/21-move-collections-state-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/21-move-collections-state-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/21-move-collections-state-to-state-provider.ts b/libs/state/src/state-migrations/migrations/21-move-collections-state-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/21-move-collections-state-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/21-move-collections-state-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.ts b/libs/state/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.ts b/libs/state/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.ts b/libs/state/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.ts b/libs/state/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.ts rename to libs/state/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.ts b/libs/state/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.ts b/libs/state/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.ts b/libs/state/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/28-move-provider-state-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/28-move-provider-state-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/28-move-provider-state-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/28-move-provider-state-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/28-move-provider-state-to-state-provider.ts b/libs/state/src/state-migrations/migrations/28-move-provider-state-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/28-move-provider-state-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/28-move-provider-state-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.ts b/libs/state/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/30-move-policy-state-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/30-move-policy-state-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/30-move-policy-state-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/30-move-policy-state-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/30-move-policy-state-to-state-provider.ts b/libs/state/src/state-migrations/migrations/30-move-policy-state-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/30-move-policy-state-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/30-move-policy-state-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.ts b/libs/state/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.ts rename to libs/state/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/32-move-preferred-language.spec.ts b/libs/state/src/state-migrations/migrations/32-move-preferred-language.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/32-move-preferred-language.spec.ts rename to libs/state/src/state-migrations/migrations/32-move-preferred-language.spec.ts diff --git a/libs/common/src/state-migrations/migrations/32-move-preferred-language.ts b/libs/state/src/state-migrations/migrations/32-move-preferred-language.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/32-move-preferred-language.ts rename to libs/state/src/state-migrations/migrations/32-move-preferred-language.ts diff --git a/libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/33-move-app-id-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/33-move-app-id-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.ts b/libs/state/src/state-migrations/migrations/33-move-app-id-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/33-move-app-id-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.ts b/libs/state/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/35-move-theme-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/35-move-theme-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.ts b/libs/state/src/state-migrations/migrations/35-move-theme-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/35-move-theme-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.ts b/libs/state/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.ts b/libs/state/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts b/libs/state/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts b/libs/state/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts b/libs/state/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts rename to libs/state/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts diff --git a/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts b/libs/state/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts rename to libs/state/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts diff --git a/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts b/libs/state/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts b/libs/state/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.ts b/libs/state/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.ts rename to libs/state/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.ts b/libs/state/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.ts b/libs/state/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/45-merge-environment-state.spec.ts b/libs/state/src/state-migrations/migrations/45-merge-environment-state.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/45-merge-environment-state.spec.ts rename to libs/state/src/state-migrations/migrations/45-merge-environment-state.spec.ts diff --git a/libs/common/src/state-migrations/migrations/45-merge-environment-state.ts b/libs/state/src/state-migrations/migrations/45-merge-environment-state.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/45-merge-environment-state.ts rename to libs/state/src/state-migrations/migrations/45-merge-environment-state.ts diff --git a/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts b/libs/state/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts rename to libs/state/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts diff --git a/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts b/libs/state/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts rename to libs/state/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts diff --git a/libs/common/src/state-migrations/migrations/47-move-desktop-settings.spec.ts b/libs/state/src/state-migrations/migrations/47-move-desktop-settings.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/47-move-desktop-settings.spec.ts rename to libs/state/src/state-migrations/migrations/47-move-desktop-settings.spec.ts diff --git a/libs/common/src/state-migrations/migrations/47-move-desktop-settings.ts b/libs/state/src/state-migrations/migrations/47-move-desktop-settings.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/47-move-desktop-settings.ts rename to libs/state/src/state-migrations/migrations/47-move-desktop-settings.ts diff --git a/libs/common/src/state-migrations/migrations/48-move-ddg-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/48-move-ddg-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/48-move-ddg-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/48-move-ddg-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/48-move-ddg-to-state-provider.ts b/libs/state/src/state-migrations/migrations/48-move-ddg-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/48-move-ddg-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/48-move-ddg-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/49-move-account-server-configs.spec.ts b/libs/state/src/state-migrations/migrations/49-move-account-server-configs.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/49-move-account-server-configs.spec.ts rename to libs/state/src/state-migrations/migrations/49-move-account-server-configs.spec.ts diff --git a/libs/common/src/state-migrations/migrations/49-move-account-server-configs.ts b/libs/state/src/state-migrations/migrations/49-move-account-server-configs.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/49-move-account-server-configs.ts rename to libs/state/src/state-migrations/migrations/49-move-account-server-configs.ts diff --git a/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts b/libs/state/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts rename to libs/state/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts diff --git a/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts b/libs/state/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts rename to libs/state/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts diff --git a/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts b/libs/state/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.ts b/libs/state/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/52-delete-installed-version.spec.ts b/libs/state/src/state-migrations/migrations/52-delete-installed-version.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/52-delete-installed-version.spec.ts rename to libs/state/src/state-migrations/migrations/52-delete-installed-version.spec.ts diff --git a/libs/common/src/state-migrations/migrations/52-delete-installed-version.ts b/libs/state/src/state-migrations/migrations/52-delete-installed-version.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/52-delete-installed-version.ts rename to libs/state/src/state-migrations/migrations/52-delete-installed-version.ts diff --git a/libs/common/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.ts b/libs/state/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts b/libs/state/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts rename to libs/state/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts diff --git a/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.ts b/libs/state/src/state-migrations/migrations/54-move-encrypted-sends.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/54-move-encrypted-sends.ts rename to libs/state/src/state-migrations/migrations/54-move-encrypted-sends.ts diff --git a/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts b/libs/state/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts rename to libs/state/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts b/libs/state/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts rename to libs/state/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts diff --git a/libs/common/src/state-migrations/migrations/56-move-auth-requests.spec.ts b/libs/state/src/state-migrations/migrations/56-move-auth-requests.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/56-move-auth-requests.spec.ts rename to libs/state/src/state-migrations/migrations/56-move-auth-requests.spec.ts diff --git a/libs/common/src/state-migrations/migrations/56-move-auth-requests.ts b/libs/state/src/state-migrations/migrations/56-move-auth-requests.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/56-move-auth-requests.ts rename to libs/state/src/state-migrations/migrations/56-move-auth-requests.ts diff --git a/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts b/libs/state/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.spec.ts b/libs/state/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.spec.ts rename to libs/state/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.spec.ts diff --git a/libs/common/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.ts b/libs/state/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.ts rename to libs/state/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.ts diff --git a/libs/common/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.ts b/libs/state/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts b/libs/state/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts rename to libs/state/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts diff --git a/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts b/libs/state/src/state-migrations/migrations/6-remove-legacy-etm-key.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts rename to libs/state/src/state-migrations/migrations/6-remove-legacy-etm-key.ts diff --git a/libs/common/src/state-migrations/migrations/60-known-accounts.spec.ts b/libs/state/src/state-migrations/migrations/60-known-accounts.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/60-known-accounts.spec.ts rename to libs/state/src/state-migrations/migrations/60-known-accounts.spec.ts diff --git a/libs/common/src/state-migrations/migrations/60-known-accounts.ts b/libs/state/src/state-migrations/migrations/60-known-accounts.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/60-known-accounts.ts rename to libs/state/src/state-migrations/migrations/60-known-accounts.ts diff --git a/libs/common/src/state-migrations/migrations/61-move-pin-state-to-providers.spec.ts b/libs/state/src/state-migrations/migrations/61-move-pin-state-to-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/61-move-pin-state-to-providers.spec.ts rename to libs/state/src/state-migrations/migrations/61-move-pin-state-to-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/61-move-pin-state-to-providers.ts b/libs/state/src/state-migrations/migrations/61-move-pin-state-to-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/61-move-pin-state-to-providers.ts rename to libs/state/src/state-migrations/migrations/61-move-pin-state-to-providers.ts diff --git a/libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.ts b/libs/state/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/63-migrate-password-settings.spec.ts b/libs/state/src/state-migrations/migrations/63-migrate-password-settings.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/63-migrate-password-settings.spec.ts rename to libs/state/src/state-migrations/migrations/63-migrate-password-settings.spec.ts diff --git a/libs/common/src/state-migrations/migrations/63-migrate-password-settings.ts b/libs/state/src/state-migrations/migrations/63-migrate-password-settings.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/63-migrate-password-settings.ts rename to libs/state/src/state-migrations/migrations/63-migrate-password-settings.ts diff --git a/libs/common/src/state-migrations/migrations/64-migrate-generator-history.spec.ts b/libs/state/src/state-migrations/migrations/64-migrate-generator-history.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/64-migrate-generator-history.spec.ts rename to libs/state/src/state-migrations/migrations/64-migrate-generator-history.spec.ts diff --git a/libs/common/src/state-migrations/migrations/64-migrate-generator-history.ts b/libs/state/src/state-migrations/migrations/64-migrate-generator-history.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/64-migrate-generator-history.ts rename to libs/state/src/state-migrations/migrations/64-migrate-generator-history.ts diff --git a/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts b/libs/state/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts rename to libs/state/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts diff --git a/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.ts b/libs/state/src/state-migrations/migrations/65-migrate-forwarder-settings.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.ts rename to libs/state/src/state-migrations/migrations/65-migrate-forwarder-settings.ts diff --git a/libs/common/src/state-migrations/migrations/66-move-final-desktop-settings.spec.ts b/libs/state/src/state-migrations/migrations/66-move-final-desktop-settings.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/66-move-final-desktop-settings.spec.ts rename to libs/state/src/state-migrations/migrations/66-move-final-desktop-settings.spec.ts diff --git a/libs/common/src/state-migrations/migrations/66-move-final-desktop-settings.ts b/libs/state/src/state-migrations/migrations/66-move-final-desktop-settings.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/66-move-final-desktop-settings.ts rename to libs/state/src/state-migrations/migrations/66-move-final-desktop-settings.ts diff --git a/libs/common/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.spec.ts b/libs/state/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.spec.ts rename to libs/state/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.spec.ts diff --git a/libs/common/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.ts b/libs/state/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.ts rename to libs/state/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.ts diff --git a/libs/common/src/state-migrations/migrations/68-move-last-sync-date.spec.ts b/libs/state/src/state-migrations/migrations/68-move-last-sync-date.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/68-move-last-sync-date.spec.ts rename to libs/state/src/state-migrations/migrations/68-move-last-sync-date.spec.ts diff --git a/libs/common/src/state-migrations/migrations/68-move-last-sync-date.ts b/libs/state/src/state-migrations/migrations/68-move-last-sync-date.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/68-move-last-sync-date.ts rename to libs/state/src/state-migrations/migrations/68-move-last-sync-date.ts diff --git a/libs/common/src/state-migrations/migrations/69-migrate-incorrect-folder-key.spec.ts b/libs/state/src/state-migrations/migrations/69-migrate-incorrect-folder-key.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/69-migrate-incorrect-folder-key.spec.ts rename to libs/state/src/state-migrations/migrations/69-migrate-incorrect-folder-key.spec.ts diff --git a/libs/common/src/state-migrations/migrations/69-migrate-incorrect-folder-key.ts b/libs/state/src/state-migrations/migrations/69-migrate-incorrect-folder-key.ts similarity index 87% rename from libs/common/src/state-migrations/migrations/69-migrate-incorrect-folder-key.ts rename to libs/state/src/state-migrations/migrations/69-migrate-incorrect-folder-key.ts index 046c0cf0df..0600170eb9 100644 --- a/libs/common/src/state-migrations/migrations/69-migrate-incorrect-folder-key.ts +++ b/libs/state/src/state-migrations/migrations/69-migrate-incorrect-folder-key.ts @@ -1,8 +1,5 @@ -import { - KeyDefinitionLike, - MigrationHelper, -} from "@bitwarden/common/state-migrations/migration-helper"; -import { Migrator } from "@bitwarden/common/state-migrations/migrator"; +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; const BAD_FOLDER_KEY: KeyDefinitionLike = { key: "folder", // We inadvertently changed the key from "folders" to "folder" diff --git a/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts b/libs/state/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts rename to libs/state/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts diff --git a/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts b/libs/state/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts rename to libs/state/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts diff --git a/libs/common/src/state-migrations/migrations/70-remove-ac-banner-dismissed.spec.ts b/libs/state/src/state-migrations/migrations/70-remove-ac-banner-dismissed.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/70-remove-ac-banner-dismissed.spec.ts rename to libs/state/src/state-migrations/migrations/70-remove-ac-banner-dismissed.spec.ts diff --git a/libs/common/src/state-migrations/migrations/70-remove-ac-banner-dismissed.ts b/libs/state/src/state-migrations/migrations/70-remove-ac-banner-dismissed.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/70-remove-ac-banner-dismissed.ts rename to libs/state/src/state-migrations/migrations/70-remove-ac-banner-dismissed.ts diff --git a/libs/common/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.spec.ts b/libs/state/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.spec.ts rename to libs/state/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.spec.ts diff --git a/libs/common/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.ts b/libs/state/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.ts rename to libs/state/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.ts diff --git a/libs/common/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.spec.ts b/libs/state/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.spec.ts rename to libs/state/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.spec.ts diff --git a/libs/common/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.ts b/libs/state/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.ts rename to libs/state/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.ts diff --git a/libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts b/libs/state/src/state-migrations/migrations/8-move-state-version.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts rename to libs/state/src/state-migrations/migrations/8-move-state-version.spec.ts diff --git a/libs/common/src/state-migrations/migrations/8-move-state-version.ts b/libs/state/src/state-migrations/migrations/8-move-state-version.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/8-move-state-version.ts rename to libs/state/src/state-migrations/migrations/8-move-state-version.ts diff --git a/libs/common/src/state-migrations/migrations/9-move-browser-settings-to-global.spec.ts b/libs/state/src/state-migrations/migrations/9-move-browser-settings-to-global.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/9-move-browser-settings-to-global.spec.ts rename to libs/state/src/state-migrations/migrations/9-move-browser-settings-to-global.spec.ts diff --git a/libs/common/src/state-migrations/migrations/9-move-browser-settings-to-global.ts b/libs/state/src/state-migrations/migrations/9-move-browser-settings-to-global.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/9-move-browser-settings-to-global.ts rename to libs/state/src/state-migrations/migrations/9-move-browser-settings-to-global.ts diff --git a/libs/common/src/state-migrations/migrations/min-version.spec.ts b/libs/state/src/state-migrations/migrations/min-version.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/min-version.spec.ts rename to libs/state/src/state-migrations/migrations/min-version.spec.ts diff --git a/libs/common/src/state-migrations/migrations/min-version.ts b/libs/state/src/state-migrations/migrations/min-version.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/min-version.ts rename to libs/state/src/state-migrations/migrations/min-version.ts diff --git a/libs/common/src/state-migrations/migrator.spec.ts b/libs/state/src/state-migrations/migrator.spec.ts similarity index 84% rename from libs/common/src/state-migrations/migrator.spec.ts rename to libs/state/src/state-migrations/migrator.spec.ts index 4079dc3fda..762a608dba 100644 --- a/libs/common/src/state-migrations/migrator.spec.ts +++ b/libs/state/src/state-migrations/migrator.spec.ts @@ -1,11 +1,8 @@ import { mock, MockProxy } from "jest-mock-extended"; -// eslint-disable-next-line import/no-restricted-paths -- Needed client type enum -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"; +import { ClientType } from "@bitwarden/client-type"; +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService } from "@bitwarden/storage-core"; import { MigrationHelper } from "./migration-helper"; import { Migrator } from "./migrator"; diff --git a/libs/common/src/state-migrations/migrator.ts b/libs/state/src/state-migrations/migrator.ts similarity index 100% rename from libs/common/src/state-migrations/migrator.ts rename to libs/state/src/state-migrations/migrator.ts diff --git a/libs/state/src/state.spec.ts b/libs/state/src/state.spec.ts new file mode 100644 index 0000000000..535af9ba98 --- /dev/null +++ b/libs/state/src/state.spec.ts @@ -0,0 +1,8 @@ +import * as lib from "./index"; + +describe("state", () => { + // This test will fail until something is exported from index.ts + it("should work", () => { + expect(lib).toBeDefined(); + }); +}); diff --git a/libs/state/src/types/state.ts b/libs/state/src/types/state.ts new file mode 100644 index 0000000000..b98e3a4e79 --- /dev/null +++ b/libs/state/src/types/state.ts @@ -0,0 +1,5 @@ +import { Opaque } from "type-fest"; + +export type StorageKey = Opaque; + +export type DerivedStateDependencies = Record; diff --git a/libs/state/state_diagram.svg b/libs/state/state_diagram.svg new file mode 100644 index 0000000000..0ba405b6df --- /dev/null +++ b/libs/state/state_diagram.svg @@ -0,0 +1,21 @@ + + + + + + + + StateGlobalUserPlatform owned & Platform managedStateDefinitionPlatform owned & Team managedKeyDefinitionTeam owned & Team managedglobaluser_ac06d663-bbbc-4a51-a764-5d105ae6f7cbglobal_stateuser_ac06d663-bbbc-4a51-a764-5d105ae6f7cb_stateglobal_state_keyuser_ac06d663-bbbc-4a51-a764-5d105ae6f7cb_state_key \ No newline at end of file diff --git a/libs/state/tsconfig.eslint.json b/libs/state/tsconfig.eslint.json new file mode 100644 index 0000000000..3daf120441 --- /dev/null +++ b/libs/state/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/state/tsconfig.json b/libs/state/tsconfig.json new file mode 100644 index 0000000000..62ebbd9464 --- /dev/null +++ b/libs/state/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/state/tsconfig.lib.json b/libs/state/tsconfig.lib.json new file mode 100644 index 0000000000..9cbf673600 --- /dev/null +++ b/libs/state/tsconfig.lib.json @@ -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"] +} diff --git a/libs/state/tsconfig.spec.json b/libs/state/tsconfig.spec.json new file mode 100644 index 0000000000..1275f148a1 --- /dev/null +++ b/libs/state/tsconfig.spec.json @@ -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"] +} diff --git a/package-lock.json b/package-lock.json index 0c0f17e1fe..264e51de1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -318,6 +318,11 @@ "version": "0.0.0", "license": "GPL-3.0" }, + "libs/client-type": { + "name": "@bitwarden/client-type", + "version": "0.0.1", + "license": "GPL-3.0" + }, "libs/common": { "name": "@bitwarden/common", "version": "0.0.0", @@ -327,11 +332,21 @@ "name": "@bitwarden/components", "version": "0.0.0" }, + "libs/core-test-utils": { + "name": "@bitwarden/core-test-utils", + "version": "0.0.1", + "license": "GPL-3.0" + }, "libs/dirt/card": { "name": "@bitwarden/dirt-card", "version": "0.0.0", "license": "GPL-3.0" }, + "libs/guid": { + "name": "@bitwarden/guid", + "version": "0.0.1", + "license": "GPL-3.0" + }, "libs/importer": { "name": "@bitwarden/importer", "version": "0.0.0", @@ -377,6 +392,21 @@ "version": "0.0.0", "license": "GPL-3.0" }, + "libs/serialization": { + "name": "@bitwarden/serialization", + "version": "0.0.1", + "license": "GPL-3.0" + }, + "libs/state": { + "name": "@bitwarden/state", + "version": "0.0.1", + "license": "GPL-3.0" + }, + "libs/state-test-utils": { + "name": "@bitwarden/state-test-utils", + "version": "0.0.1", + "license": "GPL-3.0" + }, "libs/storage-core": { "name": "@bitwarden/storage-core", "version": "0.0.1", @@ -4545,6 +4575,10 @@ "resolved": "apps/cli", "link": true }, + "node_modules/@bitwarden/client-type": { + "resolved": "libs/client-type", + "link": true + }, "node_modules/@bitwarden/common": { "resolved": "libs/common", "link": true @@ -4553,6 +4587,10 @@ "resolved": "libs/components", "link": true }, + "node_modules/@bitwarden/core-test-utils": { + "resolved": "libs/core-test-utils", + "link": true + }, "node_modules/@bitwarden/desktop": { "resolved": "apps/desktop", "link": true @@ -4585,6 +4623,10 @@ "resolved": "libs/tools/generator/extensions/navigation", "link": true }, + "node_modules/@bitwarden/guid": { + "resolved": "libs/guid", + "link": true + }, "node_modules/@bitwarden/importer": { "resolved": "libs/importer", "link": true @@ -4646,6 +4688,18 @@ "resolved": "libs/tools/send/send-ui", "link": true }, + "node_modules/@bitwarden/serialization": { + "resolved": "libs/serialization", + "link": true + }, + "node_modules/@bitwarden/state": { + "resolved": "libs/state", + "link": true + }, + "node_modules/@bitwarden/state-test-utils": { + "resolved": "libs/state-test-utils", + "link": true + }, "node_modules/@bitwarden/storage-core": { "resolved": "libs/storage-core", "link": true diff --git a/tsconfig.base.json b/tsconfig.base.json index 478fce4bfd..c125638391 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -25,14 +25,17 @@ "@bitwarden/billing": ["./libs/billing/src"], "@bitwarden/bit-common/*": ["./bitwarden_license/bit-common/src/*"], "@bitwarden/cli/*": ["./apps/cli/src/*"], + "@bitwarden/client-type": ["libs/client-type/src/index.ts"], "@bitwarden/common/*": ["./libs/common/src/*"], "@bitwarden/components": ["./libs/components/src"], + "@bitwarden/core-test-utils": ["libs/core-test-utils/src/index.ts"], "@bitwarden/dirt-card": ["./libs/dirt/card/src"], "@bitwarden/generator-components": ["./libs/tools/generator/components/src"], "@bitwarden/generator-core": ["./libs/tools/generator/core/src"], "@bitwarden/generator-history": ["./libs/tools/generator/extensions/history/src"], "@bitwarden/generator-legacy": ["./libs/tools/generator/extensions/legacy/src"], "@bitwarden/generator-navigation": ["./libs/tools/generator/extensions/navigation/src"], + "@bitwarden/guid": ["libs/guid/src/index.ts"], "@bitwarden/importer-core": ["./libs/importer/src"], "@bitwarden/importer-ui": ["./libs/importer/src/components"], "@bitwarden/key-management": ["./libs/key-management/src"], @@ -45,6 +48,9 @@ "@bitwarden/platform": ["./libs/platform/src"], "@bitwarden/platform/*": ["./libs/platform/src/*"], "@bitwarden/send-ui": ["./libs/tools/send/send-ui/src"], + "@bitwarden/serialization": ["libs/serialization/src/index.ts"], + "@bitwarden/state": ["libs/state/src/index.ts"], + "@bitwarden/state-test-utils": ["libs/state-test-utils/src/index.ts"], "@bitwarden/storage-core": ["libs/storage-core/src/index.ts"], "@bitwarden/storage-test-utils": ["libs/storage-test-utils/src/index.ts"], "@bitwarden/ui-common": ["./libs/ui/common/src"],