-
Notifications
You must be signed in to change notification settings - Fork 19
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
fdeb40a
commit ed814a5
Showing
1 changed file
with
271 additions
and
36 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,73 +1,308 @@ | ||
# 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. | ||
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. | ||
|
||
An example usage of the framework is below: | ||
Core API's: | ||
|
||
## API's | ||
|
||
- [`StateDefinition`](#statedefinition) | ||
- [`KeyDefinition`](#keydefinition) | ||
- [`StateProvider`](#stateprovider) | ||
- [`ActiveUserState<T>`](#activeuserstatet) | ||
- [`GlobalState<T>`](#globalstatet) | ||
- [`SingleUserState<T>`](#singleuserstatet) | ||
|
||
### `StateDefinition` | ||
|
||
`StateDefinition` is a simple API but a very core part of making the State Provider Framework work | ||
smoothly. 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 | ||
code ownership but teams are expected to create edits to it. A team will edit this file to include a | ||
line like such: | ||
|
||
```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 will be responsible to reviewing all new and updated entries in | ||
this file and will be looking to make sure that there are no duplicate entries containing the same | ||
state name and state location. Teams CAN have the same state name used for both `"disk"` and | ||
`"memory"` locations. Tests are included to ensure this uniqueness and core naming guidelines so you | ||
can ensure a review for a new `StateDefinition` entry can be done promptly and with very few | ||
surprises. | ||
|
||
_TODO: Make tests_ | ||
|
||
:::note | ||
|
||
Secure storage is not currently supported as a storage location in the State Provider Framework. If | ||
you need to store data in the secure storage implementations, please continue to use `StateService`. | ||
The Platform team will weigh the possibility of supporting secure storage down the road. | ||
|
||
::: | ||
|
||
### `KeyDefinition` | ||
|
||
`KeyDefinition` builds on the idea of [`StateDefinition`](#statedefinition) but it gets more | ||
specific about the data to be stored. `KeyDefinition`s can also be instantiated in your own teams | ||
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 KeyDefinition<MyState>(MY_DOMAIN_DISK, "data", { | ||
deserializer: (jsonData) => null, // convert to your data from json | ||
}); | ||
|
||
// Or if your state is an array, use the built in helper | ||
const MY_DOMAIN_DATA: KeyDefinition<MyStateElement[]> = KeyDefinition.array<MyStateElement>( | ||
MY_DOMAIN_DISK, | ||
"data", | ||
{ | ||
deserializer: (jsonDataElement) => null, // provide a deserializer just for the element of the array | ||
}, | ||
); | ||
|
||
// record | ||
const MY_DOMAIN_DATA: KeyDefinition<Record<string, MyStateElement>> = | ||
KeyDefinition.record<MyStateValue>(MY_DOMAIN_DISK, "data", { | ||
deserializer: (jsonDataValue) => null, // provide a deserializer just for the value in each key-value pair | ||
}); | ||
``` | ||
|
||
The first argument to `KeyDefinition` is always the `StateDefinition` that this key should belong | ||
to. The second argument should be a human readable, camelCase formatted, name of the | ||
`KeyDefinition`. This name should be unique amongst all other `KeyDefinition`s that consume the same | ||
`StateDefinition`. The responsibility of this uniqueness is on the team. As such, you should only | ||
consume the `StateDefinition` of another team in your own `KeyDefinition` very rarely and if it is | ||
used, you should practice extreme caution, explicit comments stating such, and with coordination | ||
with the team that owns the `StateDefinition`. | ||
|
||
### `StateProvider` | ||
|
||
`StateProvider` is an injectable service that includes 3 methods for getting state. These three | ||
methods are helpers for invoking their more modular siblings `ActiveStateProvider.get`, | ||
`SingleUserStateProvider.get`, and `GlobalStateProvider.get` and they have the following type | ||
definitions: | ||
|
||
```typescript | ||
interface StateProvider { | ||
getActive<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T>; | ||
getUser<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T>; | ||
getGlobal<T>(keyDefinition: KeyDefinition<T>): GlobalState<T>; | ||
} | ||
``` | ||
|
||
A very common practice will be to inject `StateProvider` in your services constructor and call | ||
`getActive`, `getGlobal`, or both in your constructor and then store private properties for the | ||
resulting `ActiveUserState<T>` and/or `GlobalState<T>`. It's less common to need to call `getUser` | ||
in the constructor because it will require you to know the `UserId` of the user you are attempting | ||
to edit. Instead you will add `private` to the constructor argument injecting `StateProvider` and | ||
instead use it in a method like in the below example. | ||
|
||
```typescript | ||
import { FOLDERS_USER_STATE, FOLDERS_GLOBAL_STATE } from "../key-definitions"; | ||
|
||
class FolderService { | ||
private folderGlobalState: GlobalState<GlobalFolderState>; | ||
private folderUserState: UserState<FolderState>; | ||
|
||
constructor( | ||
private userStateProvider: UserStateProvider, | ||
private globalStateProvider: GlobalStateProvider | ||
) { | ||
this.folderUserState = userStateProvider.get(FOLDERS_USER_STATE); | ||
this.folderGlobalState = globalStateProvider.get(FOLDERS_GLOBAL_STATE); | ||
private folderUserState: ActiveUserState<Record<string, FolderState>>; | ||
|
||
folders$: Observable<Folder[]>; | ||
|
||
constructor(private stateProvider: StateProvider) { | ||
this.folderUserState = stateProvider.getActive(FOLDERS_USER_STATE); | ||
this.folderGlobalState = stateProvider.getGlobal(FOLDERS_GLOBAL_STATE); | ||
|
||
this.folders$ = this.folderUserState.pipe( | ||
map((foldersRecord) => this.transform(foldersRecord)), | ||
); | ||
} | ||
|
||
get folders$(): Observable<Folder[]> { | ||
return this.folderUserState.pipe(map(folders => this.transform(folders))); | ||
async clear(userId: UserId): Promise<void> { | ||
await this.stateProvider.getUser(userId, FOLDERS_USER_STATE).update((state) => null); | ||
} | ||
} | ||
``` | ||
|
||
The constructor takes in 2 new interfaces that both expose a `get` method that takes a single argument, a `KeyDefinition` that should be imported from a file where it was defined as a `const`. A `KeyDefinition` has the following structure. | ||
### ActiveUserState\<T\> | ||
|
||
`ActiveUserState<T>` is an object to help you maintain and view the state of the currently active | ||
user. If the currently active user changes, like through account switching. The data this object | ||
represents will change along with it. Gone is the need to subscribe to | ||
`StateService.activeAccountUnlocked$`. You can see the most likely type definition of the API's on | ||
`ActiveUserState<T>` below: | ||
|
||
```typescript | ||
class KeyDefinition<T> { | ||
stateDefinition: StateDefinition; | ||
key: string; | ||
deserializer: (jsonValue: Jsonify<T>) => T; | ||
interface ActiveUserState<T> { | ||
state$: Observable<T>; | ||
update(updateState: (state: T) => T): Promise<T>; | ||
} | ||
``` | ||
|
||
If a service is stateless or only needs one of either global or user state they only need to take the specific provider that they need. But if you do need state and you call the `get` methods you are returned `UserState<T>` and `GlobalState<T>` respectively. They both expose 2 common properties, those are `state$: Observable<T>` and `update(configureState: (state: T) => T): Promise<T>`. | ||
:::note | ||
|
||
The definition of `update` shown above is not complete and the full definition includes more | ||
customization that you can view in the [Advanced Usage](#advanced-usage) section. | ||
|
||
::: | ||
|
||
The `update` method takes a function `updateState: (state: T) => T` that can be used to update the | ||
state in both a destructive and additive way. The function gives you a representation of what is | ||
currently saved as your state and it requires you to return the state that you want saved into | ||
storage. This means if you have an array on your state, you can `push` onto the array and return the | ||
array back. | ||
|
||
The `state$` property is a stream of state in the shape of the generic `T` that is defined on the `KeyDefinition` passed | ||
into the providers `get` method. The `state$` observable only subscribes to it's upstream data sources when it itself is subscribed to. | ||
The `state$` property provides you an `Observable<T>` that can be subscribed to. | ||
`ActiveUserState<T>.state$` will emit for the following reasons: | ||
|
||
The `update` property is a method that you call with your own function that updates the current state with your desired | ||
shape. The callback style of updating state is such that you can make additive changes to your state vs just replacing the | ||
whole value (which you can still do). An example of this could be pushing an item onto an array. | ||
- The active user changes. | ||
- You call the `update` method. | ||
- Another service in a different context calls `update` on their own instance of | ||
`ActiveUserState<T>` made from the same `KeyDefinition`. | ||
- A `SingleUserState<T>` method pointing at the same `KeyDefinition` as `ActiveUserState` and | ||
pointing at the user that is active that had `update` called | ||
|
||
The `update` method on both `UserState<T>` and `GlobalState<T>` will cause an emission to the associated `state$` observable. For `GlobalState<T>` this is the only way the `state$` observable will update, but for `UserState<T>` it will emit when the currently active user updates. When that updates the observable will automatically update with the equivalent | ||
data for that user. | ||
### `GlobalState<T>` | ||
|
||
## UserState<T> | ||
`GlobalState<T>` has an incredibly similar API surface as `ActiveUserState<T>` except it targets | ||
global scoped storage and does not emit an update to `state$` when the active user changes, only | ||
when the `update` method is called, in this context, or another. | ||
|
||
UserState includes a few more API's that help you interact with a users state, it has a `updateFor` and `createDerived`. | ||
### `SingleUserState<T>` | ||
|
||
The `updateFor` method takes a `UserId` in its first argument that allows you to update the state for a specified user | ||
instead of the user that is currently active. Its second argument is the same callback that exists in the `update` method. | ||
`SingleUserState<T>` behaves very similarly to `GlobalState<T>` where neither will react to active | ||
user changes and you instead give it the user you want it to care about up front. Because of that | ||
`SingleUserState<T>` exposes a `userId` property so you can see the user this instance is about. It | ||
also has an interesting way it can interact with `ActiveUserState<T>`, if you have pointed | ||
`SingleUserState<T>` at user who happens to be active at the time calling `update` from either of | ||
those services will cause an emission from both of their `state$` observables. | ||
|
||
The `createDerived` method looks like this: | ||
## Migrating | ||
|
||
Migrating data to state providers is incredibly similar to migrating data in general. You create | ||
your own class that extends `Migrator<From, To>`. That will require you to implement your own | ||
`migrate(migrationHelper: MigrationHelper)` method. `MigrationHelper` already includes methods like | ||
`get` and `set` for getting and settings value to storage by their string key. There are also | ||
methods for getting and setting using your `KeyDefinition` or `KeyDefinitionLike` object to and from | ||
user and global state. An example of how you might use these new helpers is below: | ||
|
||
```typescript | ||
type ExpectedGlobalState = { myGlobalData: string }; | ||
|
||
type ExpectedAccountState = { myUserData: string }; | ||
|
||
const MY_GLOBAL_KEY_DEFINITION: KeyDefinitionLike = { | ||
stateDefinition: { name: "myState" }, | ||
key: "myGlobalKey", | ||
}; | ||
const MY_USER_KEY_DEFINITION: KeyDefinitionLike = { | ||
stateDefinition: { name: "myState" }, | ||
key: "myUserKey", | ||
}; | ||
|
||
export class MoveToStateProvider extends Migrator<10, 11> { | ||
async migrate(migrationHelper: MigrationHelper): Promise<void> { | ||
const existingGlobalData = await migrationHelper.get<ExpectedGlobalState>("global"); | ||
|
||
await migrationHelper.setGlobal(MY_GLOBAL_KEY_DEFINITION, { | ||
myGlobalData: existingGlobalData.myGlobalData, | ||
}); | ||
|
||
const updateAccount = async (userId: string, account: ExpectedAccountState) => { | ||
await migrationHelper.setUser(MY_USER_KEY_DEFINITION, { | ||
myUserData: account.myUserData, | ||
}); | ||
}; | ||
|
||
const accounts = await migrationHelper.getAccounts<ExpectedAccountState>(); | ||
|
||
await Promise.all(accounts.map(({ userId, account }) => updateAccount(userId, account))); | ||
} | ||
} | ||
``` | ||
|
||
:::note | ||
|
||
`getAccounts` only gets data from the legacy account object that was used in `StateService`. As data | ||
gets migrated off of that account object the response from `getAccounts`, which returns a record | ||
where the key will be a users id and the value being the legacy account object. It can continue to | ||
be used to retrieve the known authenticated user ids though. | ||
|
||
::: | ||
|
||
### Example PRs | ||
|
||
_TODO: Include PR's_ | ||
|
||
## Testing | ||
|
||
Testing business logic with data and observables can sometimes be cumbersome, to help make that a | ||
little easier, there are a sweet of helpful "fakes" that can be used instead of traditional "mocks". | ||
Now instead of calling `mock<StateProvider>()` into your service you can instead use | ||
`new FakeStateProvider()`. | ||
|
||
_TODO: Refine user story_ | ||
|
||
## Advanced Usage | ||
|
||
### `update` | ||
|
||
It was mentioned earlier that update has the type signature | ||
`update(updateState: (state: T) => T): Promise<T>` but that is not entirely true. You can treat it | ||
as if it has that signature and many will but it's true signature is the following: | ||
|
||
```typescript | ||
createDerived<TTo>(derivedStateDefinition: DerivedStateDefinition<T, TTo>): DerivedUserState<T, TTo> | ||
{ActiveUser|SingleUser|Global}State<T> { | ||
// ... rest of type left out for brevity | ||
update<TCombine>(updateState: (state: T, dependency: TCombine) => T, options?: StateUpdateOptions); | ||
} | ||
|
||
type StateUpdateOptions = { | ||
shouldUpdate?: (state: T, dependency: TCombine) => boolean; | ||
combineLatestWith?: Observable<TCombine>; | ||
msTimeout?: number | ||
} | ||
``` | ||
The `shouldUpdate` option can be useful to help avoid an unnecessary update, and therefore avoid an | ||
unnecessary emission of `state$`. You might want to use this to avoid setting state to `null` when | ||
it is already `null`. The `shouldUpdate` method gives you in it's first parameter the value of state | ||
before any change has been made to it and the dependency you have, optionally, provided through | ||
`combineLatestWith`. To avoid setting `null` twice you could call `update` like below: | ||
```typescript | ||
await myUserState.update(() => null, { shouldUpdate: (state) => state != null }); | ||
``` | ||
|
||
and the definition of `DerivedStateDefinition` looks like: | ||
The `combineLatestWith` option can be useful when updates to your state depend on the data from | ||
another stream of data. In | ||
[this example](https://github.com/bitwarden/clients/blob/2eebf890b5b1cfbf5cb7d1395ed921897d0417fd/libs/common/src/auth/services/account.service.ts#L88-L107) | ||
you can see how we don't want to set a user id to the active account id unless that user id exists | ||
in our known accounts list. This can be preferred over the more manual implementation like such: | ||
|
||
```typescript | ||
class DerivedStateDefinition<TFrom, TTo> { | ||
keyDefinition: KeyDefinition<TFrom>; | ||
converter: (data: TFrom, context: { activeUserKey: UserKey, encryptService: EncryptService}) => Promise<TTo> | ||
const accounts = await firstValueFrom(this.accounts$); | ||
if (accounts?.[userId] == null) { | ||
throw new Error(); | ||
} | ||
await this.activeAccountIdState.update(() => userId); | ||
``` | ||
|
||
This class encapsulates the logic for how to go from one version of your state into another form. This is often because encrypted data is stored on disk, but decrypted data is what will be shown to the user. | ||
The use of the `combineLatestWith` option is preferred because it fixes a couple subtle issues. | ||
First, 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. The second issue it | ||
fixes, is that we don't guarantee that your `updateState` function 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. | ||
|
||
## FAQ |