mirror of
https://github.com/bitwarden/browser.git
synced 2025-02-18 01:41:27 +01:00
Ps/pm 2910/state framework improvements (#6860)
* Allow for update logic in state update callbacks * Prefer reading updates to sending in stream * Inform state providers when they must deserialize * Update DefaultGlobalState to act more like DefaultUserState * Fully Implement AbstractStorageService * Add KeyDefinitionOptions * Address PR feedback * More Descriptive Error --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
parent
bb46907951
commit
29aabeb4f5
@ -1,4 +1,4 @@
|
|||||||
import { awaitAsync } from "@bitwarden/angular/../test-utils";
|
import { awaitAsync } from "@bitwarden/common/../spec/utils";
|
||||||
import { mock, MockProxy } from "jest-mock-extended";
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
import { BehaviorSubject, ReplaySubject } from "rxjs";
|
import { BehaviorSubject, ReplaySubject } from "rxjs";
|
||||||
|
|
||||||
|
@ -11,6 +11,9 @@ import { fromChromeEvent } from "../../browser/from-chrome-event";
|
|||||||
export default abstract class AbstractChromeStorageService implements AbstractStorageService {
|
export default abstract class AbstractChromeStorageService implements AbstractStorageService {
|
||||||
constructor(protected chromeStorageApi: chrome.storage.StorageArea) {}
|
constructor(protected chromeStorageApi: chrome.storage.StorageArea) {}
|
||||||
|
|
||||||
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
get updates$(): Observable<StorageUpdate> {
|
get updates$(): Observable<StorageUpdate> {
|
||||||
return fromChromeEvent(this.chromeStorageApi.onChanged).pipe(
|
return fromChromeEvent(this.chromeStorageApi.onChanged).pipe(
|
||||||
mergeMap(([changes]) => {
|
mergeMap(([changes]) => {
|
||||||
@ -27,7 +30,6 @@ export default abstract class AbstractChromeStorageService implements AbstractSt
|
|||||||
key: key,
|
key: key,
|
||||||
// For removes this property will not exist but then it will just be
|
// For removes this property will not exist but then it will just be
|
||||||
// undefined which is fine.
|
// undefined which is fine.
|
||||||
value: change.newValue,
|
|
||||||
updateType: updateType,
|
updateType: updateType,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -35,6 +35,10 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
get updates$() {
|
get updates$() {
|
||||||
return this.updatesSubject.asObservable();
|
return this.updatesSubject.asObservable();
|
||||||
}
|
}
|
||||||
|
@ -107,6 +107,9 @@ export class LowdbStorageService implements AbstractStorageService {
|
|||||||
this.ready = true;
|
this.ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
get updates$() {
|
get updates$() {
|
||||||
return this.updatesSubject.asObservable();
|
return this.updatesSubject.asObservable();
|
||||||
}
|
}
|
||||||
@ -133,7 +136,7 @@ export class LowdbStorageService implements AbstractStorageService {
|
|||||||
return this.lockDbFile(() => {
|
return this.lockDbFile(() => {
|
||||||
this.readForNoCache();
|
this.readForNoCache();
|
||||||
this.db.set(key, obj).write();
|
this.db.set(key, obj).write();
|
||||||
this.updatesSubject.next({ key, value: obj, updateType: "save" });
|
this.updatesSubject.next({ key, updateType: "save" });
|
||||||
this.logService.debug(`Successfully wrote ${key} to db`);
|
this.logService.debug(`Successfully wrote ${key} to db`);
|
||||||
return;
|
return;
|
||||||
});
|
});
|
||||||
@ -144,7 +147,7 @@ export class LowdbStorageService implements AbstractStorageService {
|
|||||||
return this.lockDbFile(() => {
|
return this.lockDbFile(() => {
|
||||||
this.readForNoCache();
|
this.readForNoCache();
|
||||||
this.db.unset(key).write();
|
this.db.unset(key).write();
|
||||||
this.updatesSubject.next({ key, value: null, updateType: "remove" });
|
this.updatesSubject.next({ key, updateType: "remove" });
|
||||||
this.logService.debug(`Successfully removed ${key} from db`);
|
this.logService.debug(`Successfully removed ${key} from db`);
|
||||||
return;
|
return;
|
||||||
});
|
});
|
||||||
|
@ -14,6 +14,10 @@ export class NodeEnvSecureStorageService implements AbstractStorageService {
|
|||||||
private cryptoService: () => CryptoService
|
private cryptoService: () => CryptoService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
get updates$() {
|
get updates$() {
|
||||||
return throwError(
|
return throwError(
|
||||||
() => new Error("Secure storage implementations cannot have their updates subscribed to.")
|
() => new Error("Secure storage implementations cannot have their updates subscribed to.")
|
||||||
|
@ -4,6 +4,9 @@ import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/
|
|||||||
import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
|
import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
|
||||||
|
|
||||||
export class ElectronRendererSecureStorageService implements AbstractStorageService {
|
export class ElectronRendererSecureStorageService implements AbstractStorageService {
|
||||||
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
get updates$() {
|
get updates$() {
|
||||||
return throwError(
|
return throwError(
|
||||||
() => new Error("Secure storage implementations cannot have their updates subscribed to.")
|
() => new Error("Secure storage implementations cannot have their updates subscribed to.")
|
||||||
|
@ -8,6 +8,9 @@ import {
|
|||||||
export class ElectronRendererStorageService implements AbstractStorageService {
|
export class ElectronRendererStorageService implements AbstractStorageService {
|
||||||
private updatesSubject = new Subject<StorageUpdate>();
|
private updatesSubject = new Subject<StorageUpdate>();
|
||||||
|
|
||||||
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
get updates$() {
|
get updates$() {
|
||||||
return this.updatesSubject.asObservable();
|
return this.updatesSubject.asObservable();
|
||||||
}
|
}
|
||||||
@ -22,11 +25,11 @@ export class ElectronRendererStorageService implements AbstractStorageService {
|
|||||||
|
|
||||||
async save<T>(key: string, obj: T): Promise<void> {
|
async save<T>(key: string, obj: T): Promise<void> {
|
||||||
await ipc.platform.storage.save(key, obj);
|
await ipc.platform.storage.save(key, obj);
|
||||||
this.updatesSubject.next({ key, value: obj, updateType: "save" });
|
this.updatesSubject.next({ key, updateType: "save" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(key: string): Promise<void> {
|
async remove(key: string): Promise<void> {
|
||||||
await ipc.platform.storage.remove(key);
|
await ipc.platform.storage.remove(key);
|
||||||
this.updatesSubject.next({ key, value: null, updateType: "remove" });
|
this.updatesSubject.next({ key, updateType: "remove" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -65,6 +65,9 @@ export class ElectronStorageService implements AbstractStorageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
get updates$() {
|
get updates$() {
|
||||||
return this.updatesSubject.asObservable();
|
return this.updatesSubject.asObservable();
|
||||||
}
|
}
|
||||||
@ -84,13 +87,13 @@ export class ElectronStorageService implements AbstractStorageService {
|
|||||||
obj = Array.from(obj);
|
obj = Array.from(obj);
|
||||||
}
|
}
|
||||||
this.store.set(key, obj);
|
this.store.set(key, obj);
|
||||||
this.updatesSubject.next({ key, value: obj, updateType: "save" });
|
this.updatesSubject.next({ key, updateType: "save" });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(key: string): Promise<void> {
|
remove(key: string): Promise<void> {
|
||||||
this.store.delete(key);
|
this.store.delete(key);
|
||||||
this.updatesSubject.next({ key, value: null, updateType: "remove" });
|
this.updatesSubject.next({ key, updateType: "remove" });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,9 @@ export class HtmlStorageService implements AbstractStorageService {
|
|||||||
return { htmlStorageLocation: HtmlStorageLocation.Session };
|
return { htmlStorageLocation: HtmlStorageLocation.Session };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
get updates$() {
|
get updates$() {
|
||||||
return this.updatesSubject.asObservable();
|
return this.updatesSubject.asObservable();
|
||||||
}
|
}
|
||||||
@ -62,7 +65,7 @@ export class HtmlStorageService implements AbstractStorageService {
|
|||||||
window.sessionStorage.setItem(key, json);
|
window.sessionStorage.setItem(key, json);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
this.updatesSubject.next({ key, value: obj, updateType: "save" });
|
this.updatesSubject.next({ key, updateType: "save" });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,7 +79,7 @@ export class HtmlStorageService implements AbstractStorageService {
|
|||||||
window.sessionStorage.removeItem(key);
|
window.sessionStorage.removeItem(key);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
this.updatesSubject.next({ key, value: null, updateType: "remove" });
|
this.updatesSubject.next({ key, updateType: "remove" });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
export async function awaitAsync(ms = 0) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
@ -10,6 +10,7 @@ import { StorageOptions } from "../src/platform/models/domain/storage-options";
|
|||||||
export class FakeStorageService implements AbstractStorageService {
|
export class FakeStorageService implements AbstractStorageService {
|
||||||
private store: Record<string, unknown>;
|
private store: Record<string, unknown>;
|
||||||
private updatesSubject = new Subject<StorageUpdate>();
|
private updatesSubject = new Subject<StorageUpdate>();
|
||||||
|
private _valuesRequireDeserialization = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a mock of a {@see AbstractStorageService} for asserting the expected
|
* Returns a mock of a {@see AbstractStorageService} for asserting the expected
|
||||||
@ -32,6 +33,14 @@ export class FakeStorageService implements AbstractStorageService {
|
|||||||
this.store = store;
|
this.store = store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internalUpdateValuesRequireDeserialization(value: boolean) {
|
||||||
|
this._valuesRequireDeserialization = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
return this._valuesRequireDeserialization;
|
||||||
|
}
|
||||||
|
|
||||||
get updates$() {
|
get updates$() {
|
||||||
return this.updatesSubject.asObservable();
|
return this.updatesSubject.asObservable();
|
||||||
}
|
}
|
||||||
@ -48,13 +57,13 @@ export class FakeStorageService implements AbstractStorageService {
|
|||||||
save<T>(key: string, obj: T, options?: StorageOptions): Promise<void> {
|
save<T>(key: string, obj: T, options?: StorageOptions): Promise<void> {
|
||||||
this.mock.save(key, options);
|
this.mock.save(key, options);
|
||||||
this.store[key] = obj;
|
this.store[key] = obj;
|
||||||
this.updatesSubject.next({ key: key, value: obj, updateType: "save" });
|
this.updatesSubject.next({ key: key, updateType: "save" });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
remove(key: string, options?: StorageOptions): Promise<void> {
|
remove(key: string, options?: StorageOptions): Promise<void> {
|
||||||
this.mock.remove(key, options);
|
this.mock.remove(key, options);
|
||||||
delete this.store[key];
|
delete this.store[key];
|
||||||
this.updatesSubject.next({ key: key, value: undefined, updateType: "remove" });
|
this.updatesSubject.next({ key: key, updateType: "remove" });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,3 +84,11 @@ function clone(value: any): any {
|
|||||||
return JSON.parse(JSON.stringify(value));
|
return JSON.parse(JSON.stringify(value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function awaitAsync(ms = 0) {
|
||||||
|
if (ms < 1) {
|
||||||
|
await Promise.resolve();
|
||||||
|
} else {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -5,11 +5,11 @@ import { MemoryStorageOptions, StorageOptions } from "../models/domain/storage-o
|
|||||||
export type StorageUpdateType = "save" | "remove";
|
export type StorageUpdateType = "save" | "remove";
|
||||||
export type StorageUpdate = {
|
export type StorageUpdate = {
|
||||||
key: string;
|
key: string;
|
||||||
value?: unknown;
|
|
||||||
updateType: StorageUpdateType;
|
updateType: StorageUpdateType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export abstract class AbstractStorageService {
|
export abstract class AbstractStorageService {
|
||||||
|
abstract get valuesRequireDeserialization(): boolean;
|
||||||
/**
|
/**
|
||||||
* Provides an {@link Observable} that represents a stream of updates that
|
* Provides an {@link Observable} that represents a stream of updates that
|
||||||
* have happened in this storage service or in the storage this service provides
|
* have happened in this storage service or in the storage this service provides
|
||||||
|
@ -6,6 +6,9 @@ export class MemoryStorageService extends AbstractMemoryStorageService {
|
|||||||
private store = new Map<string, unknown>();
|
private store = new Map<string, unknown>();
|
||||||
private updatesSubject = new Subject<StorageUpdate>();
|
private updatesSubject = new Subject<StorageUpdate>();
|
||||||
|
|
||||||
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
get updates$() {
|
get updates$() {
|
||||||
return this.updatesSubject.asObservable();
|
return this.updatesSubject.asObservable();
|
||||||
}
|
}
|
||||||
@ -27,13 +30,13 @@ export class MemoryStorageService extends AbstractMemoryStorageService {
|
|||||||
return this.remove(key);
|
return this.remove(key);
|
||||||
}
|
}
|
||||||
this.store.set(key, obj);
|
this.store.set(key, obj);
|
||||||
this.updatesSubject.next({ key, value: obj, updateType: "save" });
|
this.updatesSubject.next({ key, updateType: "save" });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(key: string): Promise<void> {
|
remove(key: string): Promise<void> {
|
||||||
this.store.delete(key);
|
this.store.delete(key);
|
||||||
this.updatesSubject.next({ key, value: null, updateType: "remove" });
|
this.updatesSubject.next({ key, updateType: "remove" });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { StateUpdateOptions } from "./state-update-options";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A helper object for interacting with state that is scoped to a specific domain
|
* 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.
|
* but is not scoped to a user. This is application wide storage.
|
||||||
@ -8,9 +10,16 @@ export interface GlobalState<T> {
|
|||||||
/**
|
/**
|
||||||
* Method for allowing you to manipulate state in an additive way.
|
* Method for allowing you to manipulate state in an additive way.
|
||||||
* @param configureState callback for how you want manipulate this section of state
|
* @param configureState callback for how you want 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.
|
* @returns A promise that must be awaited before your next action to ensure the update has been written to state.
|
||||||
*/
|
*/
|
||||||
update: (configureState: (state: T) => T) => Promise<T>;
|
update: <TCombine>(
|
||||||
|
configureState: (state: T, dependency: TCombine) => T,
|
||||||
|
options?: StateUpdateOptions<T, TCombine>
|
||||||
|
) => Promise<T>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An observable stream of this state, the first emission of this will be the current state on disk
|
* An observable stream of this state, the first emission of this will be the current state on disk
|
||||||
|
@ -3,9 +3,10 @@
|
|||||||
* @jest-environment ../shared/test.environment.ts
|
* @jest-environment ../shared/test.environment.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { firstValueFrom, of } from "rxjs";
|
||||||
import { Jsonify } from "type-fest";
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
import { trackEmissions } from "../../../../spec";
|
import { trackEmissions, awaitAsync } from "../../../../spec";
|
||||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||||
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
||||||
import { StateDefinition } from "../state-definition";
|
import { StateDefinition } from "../state-definition";
|
||||||
@ -28,16 +29,15 @@ class TestState {
|
|||||||
|
|
||||||
const testStateDefinition = new StateDefinition("fake", "disk");
|
const testStateDefinition = new StateDefinition("fake", "disk");
|
||||||
|
|
||||||
const testKeyDefinition = new KeyDefinition<TestState>(
|
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
|
||||||
testStateDefinition,
|
deserializer: TestState.fromJSON,
|
||||||
"fake",
|
});
|
||||||
TestState.fromJSON
|
|
||||||
);
|
|
||||||
const globalKey = globalKeyBuilder(testKeyDefinition);
|
const globalKey = globalKeyBuilder(testKeyDefinition);
|
||||||
|
|
||||||
describe("DefaultGlobalState", () => {
|
describe("DefaultGlobalState", () => {
|
||||||
let diskStorageService: FakeStorageService;
|
let diskStorageService: FakeStorageService;
|
||||||
let globalState: DefaultGlobalState<TestState>;
|
let globalState: DefaultGlobalState<TestState>;
|
||||||
|
const newData = { date: new Date() };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
diskStorageService = new FakeStorageService();
|
diskStorageService = new FakeStorageService();
|
||||||
@ -48,32 +48,41 @@ describe("DefaultGlobalState", () => {
|
|||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("state$", () => {
|
||||||
it("should emit when storage updates", async () => {
|
it("should emit when storage updates", async () => {
|
||||||
const emissions = trackEmissions(globalState.state$);
|
const emissions = trackEmissions(globalState.state$);
|
||||||
const newData = { date: new Date() };
|
|
||||||
await diskStorageService.save(globalKey, newData);
|
await diskStorageService.save(globalKey, newData);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
expect(emissions).toEqual([
|
expect(emissions).toEqual([
|
||||||
null, // Initial value
|
null, // Initial value
|
||||||
newData,
|
newData,
|
||||||
// JSON.parse(JSON.stringify(newData)), // This is due to the way `trackEmissions` clones
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not emit when update key does not match", async () => {
|
it("should not emit when update key does not match", async () => {
|
||||||
const emissions = trackEmissions(globalState.state$);
|
const emissions = trackEmissions(globalState.state$);
|
||||||
const newData = { date: new Date() };
|
|
||||||
await diskStorageService.save("wrong_key", newData);
|
await diskStorageService.save("wrong_key", newData);
|
||||||
|
|
||||||
expect(emissions).toEqual(
|
expect(emissions).toHaveLength(0);
|
||||||
expect.arrayContaining([
|
|
||||||
null, // Initial value
|
|
||||||
])
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should emit initial storage value on first subscribe", async () => {
|
||||||
|
const initialStorage: Record<string, TestState> = {};
|
||||||
|
initialStorage[globalKey] = TestState.fromJSON({
|
||||||
|
date: "2022-09-21T13:14:17.648Z",
|
||||||
|
});
|
||||||
|
diskStorageService.internalUpdateStore(initialStorage);
|
||||||
|
|
||||||
|
const state = await firstValueFrom(globalState.state$);
|
||||||
|
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(1);
|
||||||
|
expect(diskStorageService.mock.get).toHaveBeenCalledWith("global_fake_fake", undefined);
|
||||||
|
expect(state).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update", () => {
|
||||||
it("should save on update", async () => {
|
it("should save on update", async () => {
|
||||||
const newData = { date: new Date() };
|
|
||||||
const result = await globalState.update((state) => {
|
const result = await globalState.update((state) => {
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
@ -84,15 +93,109 @@ describe("DefaultGlobalState", () => {
|
|||||||
|
|
||||||
it("should emit once per update", async () => {
|
it("should emit once per update", async () => {
|
||||||
const emissions = trackEmissions(globalState.state$);
|
const emissions = trackEmissions(globalState.state$);
|
||||||
const newData = { date: new Date() };
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
await globalState.update((state) => {
|
await globalState.update((state) => {
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
expect(emissions).toEqual([
|
expect(emissions).toEqual([
|
||||||
null, // Initial value
|
null, // Initial value
|
||||||
newData,
|
newData,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should provided combined dependencies", async () => {
|
||||||
|
const emissions = trackEmissions(globalState.state$);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
const combinedDependencies = { date: new Date() };
|
||||||
|
|
||||||
|
await globalState.update(
|
||||||
|
(state, dependencies) => {
|
||||||
|
expect(dependencies).toEqual(combinedDependencies);
|
||||||
|
return newData;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combineLatestWith: of(combinedDependencies),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([
|
||||||
|
null, // Initial value
|
||||||
|
newData,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not update if shouldUpdate returns false", async () => {
|
||||||
|
const emissions = trackEmissions(globalState.state$);
|
||||||
|
|
||||||
|
const result = await globalState.update(
|
||||||
|
(state) => {
|
||||||
|
return newData;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shouldUpdate: () => false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(diskStorageService.mock.save).not.toHaveBeenCalled();
|
||||||
|
expect(emissions).toEqual([null]); // Initial value
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should provide the update callback with the current State", async () => {
|
||||||
|
const emissions = trackEmissions(globalState.state$);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
// Seed with interesting data
|
||||||
|
const initialData = { date: new Date(2020, 1, 1) };
|
||||||
|
await globalState.update((state, dependencies) => {
|
||||||
|
return initialData;
|
||||||
|
});
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
await globalState.update((state) => {
|
||||||
|
expect(state).toEqual(initialData);
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([
|
||||||
|
null, // Initial value
|
||||||
|
initialData,
|
||||||
|
newData,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should give initial state for update call", async () => {
|
||||||
|
const initialStorage: Record<string, TestState> = {};
|
||||||
|
const initialState = TestState.fromJSON({
|
||||||
|
date: "2022-09-21T13:14:17.648Z",
|
||||||
|
});
|
||||||
|
initialStorage[globalKey] = initialState;
|
||||||
|
diskStorageService.internalUpdateStore(initialStorage);
|
||||||
|
|
||||||
|
const emissions = trackEmissions(globalState.state$);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
const newState = {
|
||||||
|
...initialState,
|
||||||
|
date: new Date(initialState.date.getFullYear(), initialState.date.getMonth() + 1),
|
||||||
|
};
|
||||||
|
const actual = await globalState.update((existingState) => newState);
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(actual).toEqual(newState);
|
||||||
|
expect(emissions).toHaveLength(2);
|
||||||
|
expect(emissions).toEqual(expect.arrayContaining([initialState, newState]));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,15 +1,29 @@
|
|||||||
import { BehaviorSubject, Observable, defer, filter, map, shareReplay, tap } from "rxjs";
|
import {
|
||||||
import { Jsonify } from "type-fest";
|
BehaviorSubject,
|
||||||
|
Observable,
|
||||||
|
defer,
|
||||||
|
filter,
|
||||||
|
firstValueFrom,
|
||||||
|
shareReplay,
|
||||||
|
switchMap,
|
||||||
|
tap,
|
||||||
|
timeout,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
import { AbstractStorageService } from "../../abstractions/storage.service";
|
import { AbstractStorageService } from "../../abstractions/storage.service";
|
||||||
import { GlobalState } from "../global-state";
|
import { GlobalState } from "../global-state";
|
||||||
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
||||||
|
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
|
||||||
|
|
||||||
|
import { getStoredValue } from "./util";
|
||||||
|
const FAKE_DEFAULT = Symbol("fakeDefault");
|
||||||
|
|
||||||
export class DefaultGlobalState<T> implements GlobalState<T> {
|
export class DefaultGlobalState<T> implements GlobalState<T> {
|
||||||
private storageKey: string;
|
private storageKey: string;
|
||||||
private seededPromise: Promise<void>;
|
|
||||||
|
|
||||||
protected stateSubject: BehaviorSubject<T | null> = new BehaviorSubject<T | null>(null);
|
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
|
||||||
|
T | typeof FAKE_DEFAULT
|
||||||
|
>(FAKE_DEFAULT);
|
||||||
|
|
||||||
state$: Observable<T>;
|
state$: Observable<T>;
|
||||||
|
|
||||||
@ -19,15 +33,17 @@ export class DefaultGlobalState<T> implements GlobalState<T> {
|
|||||||
) {
|
) {
|
||||||
this.storageKey = globalKeyBuilder(this.keyDefinition);
|
this.storageKey = globalKeyBuilder(this.keyDefinition);
|
||||||
|
|
||||||
this.seededPromise = this.chosenLocation.get<Jsonify<T>>(this.storageKey).then((data) => {
|
|
||||||
const serializedData = this.keyDefinition.deserializer(data);
|
|
||||||
this.stateSubject.next(serializedData);
|
|
||||||
});
|
|
||||||
|
|
||||||
const storageUpdates$ = this.chosenLocation.updates$.pipe(
|
const storageUpdates$ = this.chosenLocation.updates$.pipe(
|
||||||
filter((update) => update.key === this.storageKey),
|
filter((update) => update.key === this.storageKey),
|
||||||
map((update) => {
|
switchMap(async (update) => {
|
||||||
return this.keyDefinition.deserializer(update.value as Jsonify<T>);
|
if (update.updateType === "remove") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await getStoredValue(
|
||||||
|
this.storageKey,
|
||||||
|
this.chosenLocation,
|
||||||
|
this.keyDefinition.deserializer
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
shareReplay({ bufferSize: 1, refCount: false })
|
shareReplay({ bufferSize: 1, refCount: false })
|
||||||
);
|
);
|
||||||
@ -37,24 +53,53 @@ export class DefaultGlobalState<T> implements GlobalState<T> {
|
|||||||
this.stateSubject.next(value);
|
this.stateSubject.next(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.getFromState().then((s) => {
|
||||||
|
this.stateSubject.next(s);
|
||||||
|
});
|
||||||
|
|
||||||
return this.stateSubject.pipe(
|
return this.stateSubject.pipe(
|
||||||
tap({
|
tap({
|
||||||
complete: () => storageUpdateSubscription.unsubscribe(),
|
complete: () => {
|
||||||
|
storageUpdateSubscription.unsubscribe();
|
||||||
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
}).pipe(
|
||||||
|
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||||
|
filter<T>((i) => i != FAKE_DEFAULT)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(configureState: (state: T) => T): Promise<T> {
|
async update<TCombine>(
|
||||||
await this.seededPromise;
|
configureState: (state: T, dependency: TCombine) => T,
|
||||||
const currentState = this.stateSubject.getValue();
|
options: StateUpdateOptions<T, TCombine> = {}
|
||||||
const newState = configureState(currentState);
|
): Promise<T> {
|
||||||
|
options = populateOptionsWithDefault(options);
|
||||||
|
const currentState = await this.getGuaranteedState();
|
||||||
|
const combinedDependencies =
|
||||||
|
options.combineLatestWith != null
|
||||||
|
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!options.shouldUpdate(currentState, combinedDependencies)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = configureState(currentState, combinedDependencies);
|
||||||
await this.chosenLocation.save(this.storageKey, newState);
|
await this.chosenLocation.save(this.storageKey, newState);
|
||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getGuaranteedState() {
|
||||||
|
const currentValue = this.stateSubject.getValue();
|
||||||
|
return currentValue === FAKE_DEFAULT ? await this.getFromState() : currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
async getFromState(): Promise<T> {
|
async getFromState(): Promise<T> {
|
||||||
const data = await this.chosenLocation.get<Jsonify<T>>(this.storageKey);
|
return await getStoredValue(
|
||||||
return this.keyDefinition.deserializer(data);
|
this.storageKey,
|
||||||
|
this.chosenLocation,
|
||||||
|
this.keyDefinition.deserializer
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* need to update test environment so trackEmissions works appropriately
|
||||||
|
* @jest-environment ../shared/test.environment.ts
|
||||||
|
*/
|
||||||
import { any, mock } from "jest-mock-extended";
|
import { any, mock } from "jest-mock-extended";
|
||||||
import { BehaviorSubject, firstValueFrom, timeout } from "rxjs";
|
import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs";
|
||||||
import { Jsonify } from "type-fest";
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
import { trackEmissions } from "../../../../spec";
|
import { awaitAsync, trackEmissions } from "../../../../spec";
|
||||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||||
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
|
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
|
||||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||||
@ -29,11 +33,9 @@ class TestState {
|
|||||||
|
|
||||||
const testStateDefinition = new StateDefinition("fake", "disk");
|
const testStateDefinition = new StateDefinition("fake", "disk");
|
||||||
|
|
||||||
const testKeyDefinition = new KeyDefinition<TestState>(
|
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
|
||||||
testStateDefinition,
|
deserializer: TestState.fromJSON,
|
||||||
"fake",
|
});
|
||||||
TestState.fromJSON
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("DefaultUserState", () => {
|
describe("DefaultUserState", () => {
|
||||||
const accountService = mock<AccountService>();
|
const accountService = mock<AccountService>();
|
||||||
@ -62,7 +64,7 @@ describe("DefaultUserState", () => {
|
|||||||
name: `Test User ${id}`,
|
name: `Test User ${id}`,
|
||||||
status: AuthenticationStatus.Unlocked,
|
status: AuthenticationStatus.Unlocked,
|
||||||
});
|
});
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
await awaitAsync();
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -70,51 +72,42 @@ describe("DefaultUserState", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("emits updates for each user switch and update", async () => {
|
it("emits updates for each user switch and update", async () => {
|
||||||
diskStorageService.internalUpdateStore({
|
const user1 = "user_00000000-0000-1000-a000-000000000001_fake_fake";
|
||||||
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
|
const user2 = "user_00000000-0000-1000-a000-000000000002_fake_fake";
|
||||||
date: "2022-09-21T13:14:17.648Z",
|
const state1 = {
|
||||||
array: ["value1", "value2"],
|
date: new Date(2021, 0),
|
||||||
} as Jsonify<TestState>,
|
array: ["value1"],
|
||||||
"user_00000000-0000-1000-a000-000000000002_fake_fake": {
|
};
|
||||||
date: "2021-09-21T13:14:17.648Z",
|
const state2 = {
|
||||||
array: ["user2_value"],
|
date: new Date(2022, 0),
|
||||||
},
|
array: ["value2"],
|
||||||
});
|
};
|
||||||
|
const initialState: Record<string, TestState> = {};
|
||||||
|
initialState[user1] = state1;
|
||||||
|
initialState[user2] = state2;
|
||||||
|
diskStorageService.internalUpdateStore(initialState);
|
||||||
|
|
||||||
const emissions = trackEmissions(userState.state$);
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
|
||||||
// User signs in
|
// User signs in
|
||||||
changeActiveUser("1");
|
changeActiveUser("1");
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 1));
|
await awaitAsync();
|
||||||
|
|
||||||
// Service does an update
|
// Service does an update
|
||||||
await userState.update((state) => {
|
const updatedState = {
|
||||||
state.array.push("value3");
|
date: new Date(2023, 0),
|
||||||
state.date = new Date(2023, 0);
|
array: ["value3"],
|
||||||
return state;
|
};
|
||||||
});
|
await userState.update(() => updatedState);
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 1));
|
await awaitAsync();
|
||||||
|
|
||||||
// Emulate an account switch
|
// Emulate an account switch
|
||||||
await changeActiveUser("2");
|
await changeActiveUser("2");
|
||||||
|
|
||||||
expect(emissions).toHaveLength(3);
|
expect(emissions).toEqual([state1, updatedState, state2]);
|
||||||
// Gotten starter user data
|
|
||||||
expect(emissions[0]).toBeTruthy();
|
|
||||||
expect(emissions[0].array).toHaveLength(2);
|
|
||||||
|
|
||||||
// Gotten emission for the update call
|
// Should be called three time to get state, once for each user and once for the update
|
||||||
expect(emissions[1]).toBeTruthy();
|
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(3);
|
||||||
expect(emissions[1].array).toHaveLength(3);
|
|
||||||
expect(new Date(emissions[1].date).getUTCFullYear()).toBe(2023);
|
|
||||||
|
|
||||||
// The second users data
|
|
||||||
expect(emissions[2]).toBeTruthy();
|
|
||||||
expect(emissions[2].array).toHaveLength(1);
|
|
||||||
expect(new Date(emissions[2].date).getUTCFullYear()).toBe(2021);
|
|
||||||
|
|
||||||
// Should only be called twice to get state, once for each user
|
|
||||||
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(2);
|
|
||||||
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
"user_00000000-0000-1000-a000-000000000001_fake_fake",
|
"user_00000000-0000-1000-a000-000000000001_fake_fake",
|
||||||
@ -122,6 +115,11 @@ describe("DefaultUserState", () => {
|
|||||||
);
|
);
|
||||||
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
|
"user_00000000-0000-1000-a000-000000000001_fake_fake",
|
||||||
|
any()
|
||||||
|
);
|
||||||
|
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
"user_00000000-0000-1000-a000-000000000002_fake_fake",
|
"user_00000000-0000-1000-a000-000000000002_fake_fake",
|
||||||
any()
|
any()
|
||||||
);
|
);
|
||||||
@ -161,9 +159,9 @@ describe("DefaultUserState", () => {
|
|||||||
|
|
||||||
diskStorageService.internalUpdateStore({
|
diskStorageService.internalUpdateStore({
|
||||||
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
|
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
|
||||||
date: "2020-09-21T13:14:17.648Z",
|
date: new Date(2020, 0),
|
||||||
array: ["testValue"],
|
array: ["testValue"],
|
||||||
} as Jsonify<TestState>,
|
} as TestState,
|
||||||
});
|
});
|
||||||
|
|
||||||
const promise = firstValueFrom(userState.state$.pipe(timeout(20)))
|
const promise = firstValueFrom(userState.state$.pipe(timeout(20)))
|
||||||
@ -233,4 +231,102 @@ describe("DefaultUserState", () => {
|
|||||||
// this value is correct.
|
// this value is correct.
|
||||||
expect(emissions).toHaveLength(2);
|
expect(emissions).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("update", () => {
|
||||||
|
const newData = { date: new Date(), array: ["test"] };
|
||||||
|
beforeEach(async () => {
|
||||||
|
changeActiveUser("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should save on update", async () => {
|
||||||
|
const result = await userState.update((state, dependencies) => {
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toEqual(newData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit once per update", async () => {
|
||||||
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
await awaitAsync(); // Need to await for the initial value to be emitted
|
||||||
|
|
||||||
|
await userState.update((state, dependencies) => {
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([
|
||||||
|
null, // initial value
|
||||||
|
newData,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should provide combined dependencies", async () => {
|
||||||
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
await awaitAsync(); // Need to await for the initial value to be emitted
|
||||||
|
|
||||||
|
const combinedDependencies = { date: new Date() };
|
||||||
|
|
||||||
|
await userState.update(
|
||||||
|
(state, dependencies) => {
|
||||||
|
expect(dependencies).toEqual(combinedDependencies);
|
||||||
|
return newData;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combineLatestWith: of(combinedDependencies),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([
|
||||||
|
null, // initial value
|
||||||
|
newData,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not update if shouldUpdate returns false", async () => {
|
||||||
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
await awaitAsync(); // Need to await for the initial value to be emitted
|
||||||
|
|
||||||
|
const result = await userState.update(
|
||||||
|
(state, dependencies) => {
|
||||||
|
return newData;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shouldUpdate: () => false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(diskStorageService.mock.save).not.toHaveBeenCalled();
|
||||||
|
expect(result).toBe(undefined);
|
||||||
|
expect(emissions).toEqual([null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should provide the current state to the update callback", async () => {
|
||||||
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
await awaitAsync(); // Need to await for the initial value to be emitted
|
||||||
|
|
||||||
|
// Seed with interesting data
|
||||||
|
const initialData = { date: new Date(2020, 0), array: ["value1", "value2"] };
|
||||||
|
await userState.update((state, dependencies) => {
|
||||||
|
return initialData;
|
||||||
|
});
|
||||||
|
|
||||||
|
await userState.update((state, dependencies) => {
|
||||||
|
expect(state).toEqual(initialData);
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([
|
||||||
|
null, // Initial value
|
||||||
|
initialData,
|
||||||
|
newData,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -9,8 +9,8 @@ import {
|
|||||||
firstValueFrom,
|
firstValueFrom,
|
||||||
combineLatestWith,
|
combineLatestWith,
|
||||||
filter,
|
filter,
|
||||||
|
timeout,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { Jsonify } from "type-fest";
|
|
||||||
|
|
||||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||||
import { UserId } from "../../../types/guid";
|
import { UserId } from "../../../types/guid";
|
||||||
@ -18,9 +18,11 @@ import { EncryptService } from "../../abstractions/encrypt.service";
|
|||||||
import { AbstractStorageService } from "../../abstractions/storage.service";
|
import { AbstractStorageService } from "../../abstractions/storage.service";
|
||||||
import { DerivedUserState } from "../derived-user-state";
|
import { DerivedUserState } from "../derived-user-state";
|
||||||
import { KeyDefinition, userKeyBuilder } from "../key-definition";
|
import { KeyDefinition, userKeyBuilder } from "../key-definition";
|
||||||
|
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
|
||||||
import { Converter, UserState } from "../user-state";
|
import { Converter, UserState } from "../user-state";
|
||||||
|
|
||||||
import { DefaultDerivedUserState } from "./default-derived-state";
|
import { DefaultDerivedUserState } from "./default-derived-state";
|
||||||
|
import { getStoredValue } from "./util";
|
||||||
|
|
||||||
const FAKE_DEFAULT = Symbol("fakeDefault");
|
const FAKE_DEFAULT = Symbol("fakeDefault");
|
||||||
|
|
||||||
@ -54,9 +56,11 @@ export class DefaultUserState<T> implements UserState<T> {
|
|||||||
if (key == null) {
|
if (key == null) {
|
||||||
return FAKE_DEFAULT;
|
return FAKE_DEFAULT;
|
||||||
}
|
}
|
||||||
const jsonData = await this.chosenStorageLocation.get<Jsonify<T>>(key);
|
return await getStoredValue(
|
||||||
const data = keyDefinition.deserializer(jsonData);
|
key,
|
||||||
return data;
|
this.chosenStorageLocation,
|
||||||
|
this.keyDefinition.deserializer
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
// Share the execution
|
// Share the execution
|
||||||
shareReplay({ refCount: false, bufferSize: 1 })
|
shareReplay({ refCount: false, bufferSize: 1 })
|
||||||
@ -65,8 +69,16 @@ export class DefaultUserState<T> implements UserState<T> {
|
|||||||
const storageUpdates$ = this.chosenStorageLocation.updates$.pipe(
|
const storageUpdates$ = this.chosenStorageLocation.updates$.pipe(
|
||||||
combineLatestWith(this.formattedKey$),
|
combineLatestWith(this.formattedKey$),
|
||||||
filter(([update, key]) => key !== null && update.key === key),
|
filter(([update, key]) => key !== null && update.key === key),
|
||||||
map(([update]) => {
|
switchMap(async ([update, key]) => {
|
||||||
return keyDefinition.deserializer(update.value as Jsonify<T>);
|
if (update.updateType === "remove") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await getStoredValue(
|
||||||
|
key,
|
||||||
|
this.chosenStorageLocation,
|
||||||
|
this.keyDefinition.deserializer
|
||||||
|
);
|
||||||
|
return data;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -94,23 +106,53 @@ export class DefaultUserState<T> implements UserState<T> {
|
|||||||
.pipe(filter<T>((value) => value != FAKE_DEFAULT));
|
.pipe(filter<T>((value) => value != FAKE_DEFAULT));
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(configureState: (state: T) => T): Promise<T> {
|
async update<TCombine>(
|
||||||
|
configureState: (state: T, dependency: TCombine) => T,
|
||||||
|
options: StateUpdateOptions<T, TCombine> = {}
|
||||||
|
): Promise<T> {
|
||||||
|
options = populateOptionsWithDefault(options);
|
||||||
const key = await this.createKey();
|
const key = await this.createKey();
|
||||||
const currentState = await this.getGuaranteedState(key);
|
const currentState = await this.getGuaranteedState(key);
|
||||||
const newState = configureState(currentState);
|
const combinedDependencies =
|
||||||
|
options.combineLatestWith != null
|
||||||
|
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!options.shouldUpdate(currentState, combinedDependencies)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = configureState(currentState, combinedDependencies);
|
||||||
await this.saveToStorage(key, newState);
|
await this.saveToStorage(key, newState);
|
||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFor(userId: UserId, configureState: (state: T) => T): Promise<T> {
|
async updateFor<TCombine>(
|
||||||
|
userId: UserId,
|
||||||
|
configureState: (state: T, dependencies: TCombine) => T,
|
||||||
|
options: StateUpdateOptions<T, TCombine> = {}
|
||||||
|
): Promise<T> {
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
throw new Error("Attempting to update user state, but no userId has been supplied.");
|
throw new Error("Attempting to update user state, but no userId has been supplied.");
|
||||||
}
|
}
|
||||||
|
options = populateOptionsWithDefault(options);
|
||||||
|
|
||||||
const key = userKeyBuilder(userId, this.keyDefinition);
|
const key = userKeyBuilder(userId, this.keyDefinition);
|
||||||
const currentStore = await this.chosenStorageLocation.get<Jsonify<T>>(key);
|
const currentState = await getStoredValue(
|
||||||
const currentState = this.keyDefinition.deserializer(currentStore);
|
key,
|
||||||
const newState = configureState(currentState);
|
this.chosenStorageLocation,
|
||||||
|
this.keyDefinition.deserializer
|
||||||
|
);
|
||||||
|
const combinedDependencies =
|
||||||
|
options.combineLatestWith != null
|
||||||
|
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!options.shouldUpdate(currentState, combinedDependencies)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = configureState(currentState, combinedDependencies);
|
||||||
await this.saveToStorage(key, newState);
|
await this.saveToStorage(key, newState);
|
||||||
|
|
||||||
return newState;
|
return newState;
|
||||||
@ -118,8 +160,7 @@ export class DefaultUserState<T> implements UserState<T> {
|
|||||||
|
|
||||||
async getFromState(): Promise<T> {
|
async getFromState(): Promise<T> {
|
||||||
const key = await this.createKey();
|
const key = await this.createKey();
|
||||||
const data = await this.chosenStorageLocation.get<Jsonify<T>>(key);
|
return await getStoredValue(key, this.chosenStorageLocation, this.keyDefinition.deserializer);
|
||||||
return this.keyDefinition.deserializer(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createDerived<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
|
createDerived<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
|
||||||
@ -140,10 +181,13 @@ export class DefaultUserState<T> implements UserState<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async seedInitial(key: string): Promise<T> {
|
private async seedInitial(key: string): Promise<T> {
|
||||||
const data = await this.chosenStorageLocation.get<Jsonify<T>>(key);
|
const value = await getStoredValue(
|
||||||
const serializedData = this.keyDefinition.deserializer(data);
|
key,
|
||||||
this.stateSubject.next(serializedData);
|
this.chosenStorageLocation,
|
||||||
return serializedData;
|
this.keyDefinition.deserializer
|
||||||
|
);
|
||||||
|
this.stateSubject.next(value);
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected saveToStorage(key: string, data: T): Promise<void> {
|
protected saveToStorage(key: string, data: T): Promise<void> {
|
||||||
|
50
libs/common/src/platform/state/implementations/util.spec.ts
Normal file
50
libs/common/src/platform/state/implementations/util.spec.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||||
|
|
||||||
|
import { getStoredValue } from "./util";
|
||||||
|
|
||||||
|
describe("getStoredValue", () => {
|
||||||
|
const key = "key";
|
||||||
|
const deserializedValue = { value: 1 };
|
||||||
|
const value = JSON.stringify(deserializedValue);
|
||||||
|
const deserializer = (v: string) => JSON.parse(v);
|
||||||
|
let storageService: FakeStorageService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storageService = new FakeStorageService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the storage service requires deserialization", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storageService.internalUpdateValuesRequireDeserialization(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should deserialize", async () => {
|
||||||
|
storageService.save(key, value);
|
||||||
|
|
||||||
|
const result = await getStoredValue(key, storageService, deserializer);
|
||||||
|
|
||||||
|
expect(result).toEqual(deserializedValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("when the storage service does not require deserialization", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storageService.internalUpdateValuesRequireDeserialization(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not deserialize", async () => {
|
||||||
|
storageService.save(key, value);
|
||||||
|
|
||||||
|
const result = await getStoredValue(key, storageService, deserializer);
|
||||||
|
|
||||||
|
expect(result).toEqual(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert undefined to null", async () => {
|
||||||
|
storageService.save(key, undefined);
|
||||||
|
|
||||||
|
const result = await getStoredValue(key, storageService, deserializer);
|
||||||
|
|
||||||
|
expect(result).toEqual(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
18
libs/common/src/platform/state/implementations/util.ts
Normal file
18
libs/common/src/platform/state/implementations/util.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
|
import { AbstractStorageService } from "../../abstractions/storage.service";
|
||||||
|
|
||||||
|
export async function getStoredValue<T>(
|
||||||
|
key: string,
|
||||||
|
storage: AbstractStorageService,
|
||||||
|
deserializer: (jsonValue: Jsonify<T>) => T
|
||||||
|
) {
|
||||||
|
if (storage.valuesRequireDeserialization) {
|
||||||
|
const jsonValue = await storage.get<Jsonify<T>>(key);
|
||||||
|
const value = deserializer(jsonValue);
|
||||||
|
return value;
|
||||||
|
} else {
|
||||||
|
const value = await storage.get<T>(key);
|
||||||
|
return value ?? null;
|
||||||
|
}
|
||||||
|
}
|
81
libs/common/src/platform/state/key-definition.spec.ts
Normal file
81
libs/common/src/platform/state/key-definition.spec.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { Opaque } from "type-fest";
|
||||||
|
|
||||||
|
import { KeyDefinition } from "./key-definition";
|
||||||
|
import { StateDefinition } from "./state-definition";
|
||||||
|
|
||||||
|
const fakeStateDefinition = new StateDefinition("fake", "disk");
|
||||||
|
|
||||||
|
type FancyString = Opaque<string, "FancyString">;
|
||||||
|
|
||||||
|
describe("KeyDefinition", () => {
|
||||||
|
describe("constructor", () => {
|
||||||
|
it("throws on undefined deserializer", () => {
|
||||||
|
expect(() => {
|
||||||
|
new KeyDefinition<boolean>(fakeStateDefinition, "fake", {
|
||||||
|
deserializer: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("record", () => {
|
||||||
|
it("runs custom deserializer for each record value", () => {
|
||||||
|
const recordDefinition = KeyDefinition.record<boolean>(fakeStateDefinition, "fake", {
|
||||||
|
// Intentionally negate the value for testing
|
||||||
|
deserializer: (value) => !value,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordDefinition).toBeTruthy();
|
||||||
|
expect(recordDefinition.deserializer).toBeTruthy();
|
||||||
|
|
||||||
|
const deserializedValue = recordDefinition.deserializer({
|
||||||
|
test1: false,
|
||||||
|
test2: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Object.keys(deserializedValue)).toHaveLength(2);
|
||||||
|
|
||||||
|
// Values should have swapped from their initial value
|
||||||
|
expect(deserializedValue["test1"]).toBeTruthy();
|
||||||
|
expect(deserializedValue["test2"]).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can handle fancy string type", () => {
|
||||||
|
// This test is more of a test that I got the typescript typing correctly than actually testing any business logic
|
||||||
|
const recordDefinition = KeyDefinition.record<boolean, FancyString>(
|
||||||
|
fakeStateDefinition,
|
||||||
|
"fake",
|
||||||
|
{
|
||||||
|
deserializer: (value) => !value,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const fancyRecord = recordDefinition.deserializer(
|
||||||
|
JSON.parse(`{ "myKey": false, "mySecondKey": true }`)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fancyRecord).toBeTruthy();
|
||||||
|
expect(Object.keys(fancyRecord)).toHaveLength(2);
|
||||||
|
expect(fancyRecord["myKey" as FancyString]).toBeTruthy();
|
||||||
|
expect(fancyRecord["mySecondKey" as FancyString]).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("array", () => {
|
||||||
|
it("run custom deserializer for each array element", () => {
|
||||||
|
const arrayDefinition = KeyDefinition.array<boolean>(fakeStateDefinition, "fake", {
|
||||||
|
deserializer: (value) => !value,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(arrayDefinition).toBeTruthy();
|
||||||
|
expect(arrayDefinition.deserializer).toBeTruthy();
|
||||||
|
|
||||||
|
const deserializedValue = arrayDefinition.deserializer([false, true]);
|
||||||
|
|
||||||
|
expect(deserializedValue).toBeTruthy();
|
||||||
|
expect(deserializedValue).toHaveLength(2);
|
||||||
|
expect(deserializedValue[0]).toBeTruthy();
|
||||||
|
expect(deserializedValue[1]).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -5,6 +5,22 @@ import { Utils } from "../misc/utils";
|
|||||||
|
|
||||||
import { StateDefinition } from "./state-definition";
|
import { StateDefinition } from "./state-definition";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of options for customizing the behavior of a {@link KeyDefinition}
|
||||||
|
*/
|
||||||
|
type KeyDefinitionOptions<T> = {
|
||||||
|
/**
|
||||||
|
* A function to use to safely convert your type from json to your expected type.
|
||||||
|
*
|
||||||
|
* **Important:** Your data may be serialized/deserialized at any time and this
|
||||||
|
* callback needs to be able to faithfully re-initialize from the JSON object representation of your type.
|
||||||
|
*
|
||||||
|
* @param jsonValue The JSON object representation of your state.
|
||||||
|
* @returns The fully typed version of your state.
|
||||||
|
*/
|
||||||
|
readonly deserializer: (jsonValue: Jsonify<T>) => T;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KeyDefinitions describe the precise location to store data for a given piece of state.
|
* 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
|
* The StateDefinition is used to describe the domain of the state, and the KeyDefinition
|
||||||
@ -14,30 +30,61 @@ export class KeyDefinition<T> {
|
|||||||
/**
|
/**
|
||||||
* Creates a new instance of a KeyDefinition
|
* Creates a new instance of a KeyDefinition
|
||||||
* @param stateDefinition The state definition for which this key belongs to.
|
* @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 key The name of the key, this should be unique per domain.
|
||||||
* @param deserializer A function to use to safely convert your type from json to your expected type.
|
* @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(
|
constructor(
|
||||||
readonly stateDefinition: StateDefinition,
|
readonly stateDefinition: StateDefinition,
|
||||||
readonly key: string,
|
readonly key: string,
|
||||||
readonly deserializer: (jsonValue: Jsonify<T>) => T
|
private readonly options: KeyDefinitionOptions<T>
|
||||||
) {}
|
) {
|
||||||
|
if (options.deserializer == null) {
|
||||||
|
throw new Error(
|
||||||
|
`'deserializer' is a required property on key ${stateDefinition.name} > ${key}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the deserializer configured for this {@link KeyDefinition}
|
||||||
|
*/
|
||||||
|
get deserializer() {
|
||||||
|
return this.options.deserializer;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a {@link KeyDefinition} for state that is an array.
|
* Creates a {@link KeyDefinition} for state that is an array.
|
||||||
* @param stateDefinition The state definition to be added to the KeyDefinition
|
* @param stateDefinition The state definition to be added to the KeyDefinition
|
||||||
* @param key The key to be added to the KeyDefinition
|
* @param key The key to be added to the KeyDefinition
|
||||||
* @param deserializer The deserializer for the element of the array in your state.
|
* @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
|
* @returns A {@link KeyDefinition} initialized for arrays, the options run
|
||||||
* element of an array **unless that array is null in which case it will return an empty list.**
|
* the deserializer on the provided options for each element of an array
|
||||||
|
* **unless that array is null, in which case it will return an empty list.**
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const MY_KEY = KeyDefinition.array<MyArrayElement>(MY_STATE, "key", {
|
||||||
|
* deserializer: (myJsonElement) => convertToElement(myJsonElement),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
static array<T>(
|
static array<T>(
|
||||||
stateDefinition: StateDefinition,
|
stateDefinition: StateDefinition,
|
||||||
key: string,
|
key: string,
|
||||||
deserializer: (jsonValue: Jsonify<T>) => T
|
// We have them provide options for the element of the array, depending on future options we add, this could get a little weird.
|
||||||
|
options: KeyDefinitionOptions<T> // The array helper forces an initialValue of an empty array
|
||||||
) {
|
) {
|
||||||
return new KeyDefinition<T[]>(stateDefinition, key, (jsonValue) => {
|
return new KeyDefinition<T[]>(stateDefinition, key, {
|
||||||
return jsonValue?.map((v) => deserializer(v)) ?? [];
|
...options,
|
||||||
|
deserializer: (jsonValue) => {
|
||||||
|
if (jsonValue == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return jsonValue.map((v) => options.deserializer(v));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,32 +92,42 @@ export class KeyDefinition<T> {
|
|||||||
* Creates a {@link KeyDefinition} for state that is a record.
|
* Creates a {@link KeyDefinition} for state that is a record.
|
||||||
* @param stateDefinition The state definition to be added to the KeyDefinition
|
* @param stateDefinition The state definition to be added to the KeyDefinition
|
||||||
* @param key The key to be added to the KeyDefinition
|
* @param key The key to be added to the KeyDefinition
|
||||||
* @param deserializer The deserializer for the value part of a record.
|
* @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
|
* @returns A {@link KeyDefinition} that contains a serializer that will run the provided deserializer for each
|
||||||
* value in a record and returns every key as a string **unless that record is null in which case it will return an record.**
|
* value in a record and returns every key as a string **unless that record is null, in which case it will return an record.**
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const MY_KEY = KeyDefinition.record<MyRecordValue>(MY_STATE, "key", {
|
||||||
|
* deserializer: (myJsonValue) => convertToValue(myJsonValue),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
static record<T>(
|
static record<T, TKey extends string = string>(
|
||||||
stateDefinition: StateDefinition,
|
stateDefinition: StateDefinition,
|
||||||
key: string,
|
key: string,
|
||||||
deserializer: (jsonValue: Jsonify<T>) => T
|
// We have them provide options for the value of the record, depending on future options we add, this could get a little weird.
|
||||||
|
options: KeyDefinitionOptions<T> // The array helper forces an initialValue of an empty record
|
||||||
) {
|
) {
|
||||||
return new KeyDefinition<Record<string, T>>(stateDefinition, key, (jsonValue) => {
|
return new KeyDefinition<Record<TKey, T>>(stateDefinition, key, {
|
||||||
const output: Record<string, T> = {};
|
...options,
|
||||||
|
deserializer: (jsonValue) => {
|
||||||
if (jsonValue == null) {
|
if (jsonValue == null) {
|
||||||
return output;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const output: Record<string, T> = {};
|
||||||
for (const key in jsonValue) {
|
for (const key in jsonValue) {
|
||||||
output[key] = deserializer((jsonValue as Record<string, Jsonify<T>>)[key]);
|
output[key] = options.deserializer((jsonValue as Record<string, Jsonify<T>>)[key]);
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Create a string that should be unique across the entire application.
|
||||||
* @returns
|
* @returns A string that can be used to cache instances created via this key.
|
||||||
*/
|
*/
|
||||||
buildCacheKey(): string {
|
buildCacheKey(): string {
|
||||||
return `${this.stateDefinition.storageLocation}_${this.stateDefinition.name}_${this.key}`;
|
return `${this.stateDefinition.storageLocation}_${this.stateDefinition.name}_${this.key}`;
|
||||||
|
26
libs/common/src/platform/state/state-update-options.ts
Normal file
26
libs/common/src/platform/state/state-update-options.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
|
export const DEFAULT_OPTIONS = {
|
||||||
|
shouldUpdate: () => true,
|
||||||
|
combineLatestWith: null as Observable<unknown>,
|
||||||
|
msTimeout: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
type DefinitelyTypedDefault<T, TCombine> = Omit<
|
||||||
|
typeof DEFAULT_OPTIONS,
|
||||||
|
"shouldUpdate" | "combineLatestWith"
|
||||||
|
> & {
|
||||||
|
shouldUpdate: (state: T, dependency: TCombine) => boolean;
|
||||||
|
combineLatestWith?: Observable<TCombine>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StateUpdateOptions<T, TCombine> = Partial<DefinitelyTypedDefault<T, TCombine>>;
|
||||||
|
|
||||||
|
export function populateOptionsWithDefault<T, TCombine>(
|
||||||
|
options: StateUpdateOptions<T, TCombine>
|
||||||
|
): StateUpdateOptions<T, TCombine> {
|
||||||
|
return {
|
||||||
|
...(DEFAULT_OPTIONS as StateUpdateOptions<T, TCombine>),
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
}
|
@ -4,6 +4,8 @@ import { UserId } from "../../types/guid";
|
|||||||
import { EncryptService } from "../abstractions/encrypt.service";
|
import { EncryptService } from "../abstractions/encrypt.service";
|
||||||
import { UserKey } from "../models/domain/symmetric-crypto-key";
|
import { UserKey } from "../models/domain/symmetric-crypto-key";
|
||||||
|
|
||||||
|
import { StateUpdateOptions } from "./state-update-options";
|
||||||
|
|
||||||
import { DerivedUserState } from ".";
|
import { DerivedUserState } from ".";
|
||||||
|
|
||||||
export class DeriveContext {
|
export class DeriveContext {
|
||||||
@ -21,16 +23,33 @@ export interface UserState<T> {
|
|||||||
/**
|
/**
|
||||||
* Updates backing stores for the active user.
|
* Updates backing stores for the active user.
|
||||||
* @param configureState function that takes the current state and returns the new state
|
* @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 The new state
|
* @returns The new state
|
||||||
*/
|
*/
|
||||||
readonly update: (configureState: (state: T) => T) => Promise<T>;
|
readonly update: <TCombine>(
|
||||||
|
configureState: (state: T, dependencies: TCombine) => T,
|
||||||
|
options?: StateUpdateOptions<T, TCombine>
|
||||||
|
) => Promise<T>;
|
||||||
/**
|
/**
|
||||||
* Updates backing stores for the given userId, which may or may not be active.
|
* Updates backing stores for the given userId, which may or may not be active.
|
||||||
* @param userId the UserId to target the update for
|
* @param userId the UserId to target the update for
|
||||||
* @param configureState function that takes the current state for the targeted user and returns the new state
|
* @param configureState function that takes the current state for the targeted user and returns the new 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 The new state
|
* @returns The new state
|
||||||
*/
|
*/
|
||||||
readonly updateFor: (userId: UserId, configureState: (state: T) => T) => Promise<T>;
|
readonly updateFor: <TCombine>(
|
||||||
|
userId: UserId,
|
||||||
|
configureState: (state: T, dependencies: TCombine) => T,
|
||||||
|
options?: StateUpdateOptions<T, TCombine>
|
||||||
|
) => Promise<T>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a derives state from the current state. Derived states are always tied to the active user.
|
* Creates a derives state from the current state. Derived states are always tied to the active user.
|
||||||
|
Loading…
Reference in New Issue
Block a user