-
Notifications
You must be signed in to change notification settings - Fork 18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Updates to state provider deep dive #549
base: main
Are you sure you want to change the base?
Conversation
Deploying contributing-docs with
|
Latest commit: |
fbd3861
|
Status: | ✅ Deploy successful! |
Preview URL: | https://157b4563.contributing-docs.pages.dev |
Branch Preview URL: | https://state-provider-updates.contributing-docs.pages.dev |
Great job, no security vulnerabilities found in this Pull Request |
❓ @justindbaur Do you think that it's OK to remove the section on Migration and the details on how |
@trmartin4 Thank you for this, I had thought about adding some more docs regarding I do think having a brief blurb about state migrations is good as teams will still occasionally have to do them. We don't need to keep around the state service to state provider mappings though. Since state migrations shouldn't change after being written though it could be useful to just defer to here for any examples and we don't need one here in the docs. As for ============= A simple example using const USES_KEYCONNECTOR: UserKeyDefinition<boolean> = ...;
async setUsesKeyConnector(value: boolean, userId: UserId) {
// Only do the update if the current value saved in state
// differs in equality of the incoming value.
await this.stateProvider.getUser(userId, USES_KEYCONNECTOR).update(
currentValue => currentValue !== value
);
} Implementing a custom equality operator type Cipher = { id: string, username: string, password: string, revisionDate: Date };
const LAST_USED_CIPHER: UserKeyDefinition<Cipher> = ...;
async setLastUsedCipher(lastUsedCipher: Cipher | null, userId: UserId) {
await this.stateProvider.getUser(userId, LAST_USED_CIPHER).update(
currentValue => !this.areEqual(currentValue, lastUsedCipher)
);
}
areEqual(a: Cipher | null, b: Cipher | null) {
if (a == null) {
return b == null;
}
if (b == null) {
return false;
}
// Option one - Full equality, comparing every property for value equality
return a.id === b.id &&
a.username === b.username &&
a.password === b.password &&
a.revisionDate === b.revisionDate;
// Option two - Partial equality based on requirement that any update would
// bump the revision date.
return a.id === b.id && a.revisionDate === b.revisionDate;
} It's important that if you implement an equality function that you then negate the output of that function for use in ============= |
@justindbaur I addressed your concerns, if you don't mind taking a look. |
@justindbaur added today's feedback 👍 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a few comments, thank you so much!
- [`EnvironmentService`](https://github.com/bitwarden/clients/pull/7621) | ||
- [Org Keys](https://github.com/bitwarden/clients/pull/7521) | ||
Use of `firstValueFrom()` should be avoided. If you find yourself trying to use `firstValueFrom()`, | ||
consider propagating the underlying observable instead of leaving reactivity. ::: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing end to the warning block?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops! Fixed in fbd3861
(#549).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you so much
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few tweaks, but the biggest one is an omission from the original -- async updates.
`StateProvider` is an injectable service that includes 3 methods for getting state. These three | ||
methods are helpers for invoking their more modular siblings `SingleUserStateProvider.get`, | ||
`GlobalStateProvider.get`, and `DerivedStateProvider`. 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. `StateProvider` has the following type | ||
definition (aliasing the targeted providers): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why remove active user state from here, but not the
#### `KeyDefinition` and `UserKeyDefinition`
section?
It's also referenced below. Let's acknowledge its existence here, but introduce it as a deprecated method that attempted to resolve the state of the active user, but produces race conditions.
getUser<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T>; | ||
getGlobal<T>(keyDefinition: KeyDefinition<T>): GlobalState<T>; | ||
getDerived<TFrom, TTo, TDeps>( | ||
parentState$: Observable<TFrom>, | ||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>, | ||
dependenciess: TDeps, | ||
); | ||
// Deprecated, do not use. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
observation, I don't really think this needs to change here, but this isn't yet deprecated in the interface.
The `state$` property provides you with an `Observable<T>` that can be subscribed to. | ||
`ActiveUserState<T>.state$` will emit for the following reasons: | ||
|
||
- The active user changes. | ||
- The chosen storage location emits an update to the key defined by `KeyDefinition`. This can occur | ||
for any reason including: | ||
- A `SingleUserState<T>` method pointing at the same `UserKeyDefinition` as `ActiveUserState` and | ||
pointing at the user that is active that had `update` called | ||
- Someone updates the key directly on the underlying storage service _(please don't do this)_ | ||
`GlobalState<T>.state$` will emit when the chosen storage location emits an update to the state |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue Let's maintain the T | null
type parameters for state. This would be true of roughtly every type parameter in this file.
|
||
### `GlobalState<T>` | ||
#### `SingleUserState<T>` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue This section should mention combinedState$
somewhere
|
||
type ExpectedAccountState = { myUserData: string }; | ||
`ActiveUserState` has race condition problems. Do not use it for updates and consider transitioning |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
`ActiveUserState` has race condition problems. Do not use it for updates and consider transitioning | |
`ActiveUserState` has race condition problems. Do not add usages and consider transitioning |
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. This should be used instead of immediately re-requesting the | ||
value through `firstValueFrom()`. The `update()` will return the value that will be persisted to | ||
state, after any `shouldUpdate()` filters are applied. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
duplicative
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. This should be used instead of immediately re-requesting the | |
value through `firstValueFrom()`. The `update()` will return the value that will be persisted to | |
state, after any `shouldUpdate()` filters are applied. | |
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. |
- 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` function is called the instant that the `update` method |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- We don't guarantee that your `updateState` function is called the instant that the `update` method | |
- We don't guarantee that your `updateState` callback is called the instant that the `update` method |
}, | ||
); | ||
``` | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue This section is missing a large use case for async updates and rx transform pipelines. combineLatestWith
can be used to handle updates where the new value depends on either async code or you'd prefer to handle generation of a new value in an observable transform flow.
const state = this.stateProvider.get(userId, SavedFiltersStateDefinition);
const transform: OperatorFunction<any, any> = pipe(
// perform some transforms
map((value) => value),
)
async function transformAsync<T>(value: T) {
return Promise.resolve(value);
}
await state.update((_, newState) => newState, {
// Actual processing to generate the new state is done here
combineLatestWith: state.state$.pipe(
mergeMap(async (old) => {
return await transformAsync(old);
}),
transform,
),
shouldUpdate: (oldState, newState) => !areEqual(oldState, newState),
})
|
||
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` for events that you always want sent to |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we have a doc to link here? If not, please link to a github permalink until we can create one.
🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-14197
https://bitwarden.atlassian.net/browse/PM-19220
https://bitwarden.atlassian.net/browse/PM-19218
https://bitwarden.atlassian.net/browse/PM-19219
📔 Objective
update
out of the "Advanced Usage" section, since we are going to be encouraging teams to use it to avoid unnecessary I/O for state updates (see Reduce desktop disk writes clients#13271 for an example). Before we start asking teams to pay more attention to this, I wanted to make sure it had a good place in the docs.ActiveUserState
to the bottom, since it is no longer recommended.cleanupDelayMs
option behavior when it's set to0
.firstValueFrom(state$)
directly after update.state$
does not guarantee an emission on same value updates.⏰ Reminders before review
team
🦮 Reviewer guidelines
:+1:
) or similar for great changes:memo:
) or ℹ️ (:information_source:
) for notes or general info:question:
) for questions:thinking:
) or 💭 (:thought_balloon:
) for more open inquiry that's not quite a confirmedissue and could potentially benefit from discussion
:art:
) for suggestions / improvements:x:
) or:warning:
) for more significant problems or concerns needing attention:seedling:
) or ♻️ (:recycle:
) for future improvements or indications of technical debt:pick:
) for minor or nitpick changes