mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-25 12:15:18 +01:00
Add State Provider Framework (#6640)
* Add StateDefinition
Add a class for encapsulation information about state
this will often be for a domain but creations of this will
exist outside of a specific domain, hence just the name State.
* Add KeyDefinition
This adds a type that extends state definition into another sub-key
and forces creators to define the data that will be stored and how
to read the data that they expect to be stored.
* Add key-builders helper functions
Adds to function to help building keys for both keys scoped
to a specific user and for keys scoped to global storage.
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
* Add updates$ stream to existing storageServices
Original commit by Matt: 823d9546fe
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
* Add fromChromeEvent helper
Create a helper that creats an Observable from a chrome event
and removes the listener when the subscription is completed.
* Implement `updates$` property for chrome storage
Use fromChromeEvent to create an observable from chrome
event and map that into our expected shape.
* Add GlobalState Abstractions
* Add UserState Abstractions
* Add Default Implementations of User/Global state
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
* Add Barrel File for state
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
* Fix ChromeStorageServices
* Rework fromChromeEvent
Rework fromChromeEvent so we have to lie to TS less and
remove unneeded generics. I did this by caring less about
the function and more about the parameters only.
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
* Fix UserStateProvider Test
* Add Inner Mock & Assert Calls
* Update Tests to use new keys
Use different key format
* Prefer returns over mutations in update
* Update Tests
* Address PR Feedback
* Be stricter with userId parameter
* Add Better Way To Determine if it was a remove
* Fix Web & Browser Storage Services
* Fix Desktop & CLI Storage Services
* Fix Test Storage Service
* Use createKey Helper
* Prefer implement to extending
* Determine storage location in providers
* Export default providers publicly
* Fix user state tests
* Name tests
* Fix CLI
* Prefer Implement In Chrome Storage
* Remove Secure Storage Option
Also throw an exception for subscribes to the secure storage observable.
* Update apps/browser/src/platform/browser/from-chrome-event.ts
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
* Enforce state module barrel file
* Fix Linting Error
* Allow state module import from other modules
* Globally Unregister fromChromeEvent Listeners
Changed fromChromeEvent to add its listeners through the BrowserApi, so that
they will be unregistered when safari closes.
* Test default global state
* Use Proper Casing in Parameter
* Address Feedback
* Update libs/common/src/platform/state/key-definition.ts
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
* Add `buildCacheKey` Method
* Fix lint errors
* Add Comment
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
* Use Generic in callback parameter
* Refactor Out DerivedStateDefinition
* Persist Listener Return Type
* Add Ticket Link
---------
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
This commit is contained in:
parent
801141f90e
commit
e1b5b83723
@ -100,6 +100,19 @@
|
||||
"./libs/importer/**/*",
|
||||
"./libs/exporter/**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
// avoid import of unexported state objects
|
||||
"target": [
|
||||
"!(libs)/**/*",
|
||||
"libs/!(common)/**/*",
|
||||
"libs/common/!(src)/**/*",
|
||||
"libs/common/src/!(platform)/**/*",
|
||||
"libs/common/src/platform/!(state)/**/*"
|
||||
],
|
||||
"from": ["./libs/common/src/platform/state/**/*"],
|
||||
// allow module index import
|
||||
"except": ["**/state/index.ts"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -179,17 +192,23 @@
|
||||
},
|
||||
{
|
||||
"files": ["apps/browser/src/**/*.ts", "libs/**/*.ts"],
|
||||
"excludedFiles": "apps/browser/src/autofill/{content,notification}/**/*.ts",
|
||||
"excludedFiles": [
|
||||
"apps/browser/src/autofill/{content,notification}/**/*.ts",
|
||||
"apps/browser/src/**/background/**/*.ts", // It's okay to have long lived listeners in the background
|
||||
"apps/browser/src/platform/background.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"message": "Using addListener in the browser popup produces a memory leak in Safari, use `BrowserApi.messageListener` instead",
|
||||
"selector": "CallExpression > [object.object.object.name='chrome'][object.object.property.name='runtime'][object.property.name='onMessage'][property.name='addListener']"
|
||||
"message": "Using addListener in the browser popup produces a memory leak in Safari, use `BrowserApi.addListener` instead",
|
||||
// This selector covers events like chrome.storage.onChange & chrome.runtime.onMessage
|
||||
"selector": "CallExpression > [object.object.object.name='chrome'][property.name='addListener']"
|
||||
},
|
||||
{
|
||||
"message": "Using addListener in the browser popup produces a memory leak in Safari, use `BrowserApi.storageChangeListener` instead",
|
||||
"selector": "CallExpression > [object.object.object.name='chrome'][object.object.property.name='storage'][object.property.name='onChanged'][property.name='addListener']"
|
||||
"message": "Using addListener in the browser popup produces a memory leak in Safari, use `BrowserApi.addListener` instead",
|
||||
// This selector covers events like chrome.storage.local.onChange
|
||||
"selector": "CallExpression > [object.object.object.object.name='chrome'][property.name='addListener']"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -193,6 +193,9 @@ export class BrowserApi {
|
||||
}
|
||||
|
||||
static async onWindowCreated(callback: (win: chrome.windows.Window) => any) {
|
||||
// FIXME: Make sure that is does not cause a memory leak in Safari or use BrowserApi.AddListener
|
||||
// and test that it doesn't break.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return chrome.windows.onCreated.addListener(callback);
|
||||
}
|
||||
|
||||
@ -220,8 +223,10 @@ export class BrowserApi {
|
||||
|
||||
// Keep track of all the events registered in a Safari popup so we can remove
|
||||
// them when the popup gets unloaded, otherwise we cause a memory leak
|
||||
private static registeredMessageListeners: any[] = [];
|
||||
private static registeredStorageChangeListeners: any[] = [];
|
||||
private static trackedChromeEventListeners: [
|
||||
event: chrome.events.Event<(...args: unknown[]) => unknown>,
|
||||
callback: (...args: unknown[]) => unknown
|
||||
][] = [];
|
||||
|
||||
static messageListener(
|
||||
name: string,
|
||||
@ -231,13 +236,7 @@ export class BrowserApi {
|
||||
sendResponse: any
|
||||
) => boolean | void
|
||||
) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chrome.runtime.onMessage.addListener(callback);
|
||||
|
||||
if (BrowserApi.isSafariApi && !BrowserApi.isBackgroundPage(window)) {
|
||||
BrowserApi.registeredMessageListeners.push(callback);
|
||||
BrowserApi.setupUnloadListeners();
|
||||
}
|
||||
BrowserApi.addListener(chrome.runtime.onMessage, callback);
|
||||
}
|
||||
|
||||
static messageListener$() {
|
||||
@ -246,44 +245,67 @@ export class BrowserApi {
|
||||
subscriber.next(message);
|
||||
};
|
||||
|
||||
BrowserApi.messageListener("message", handler);
|
||||
BrowserApi.addListener(chrome.runtime.onMessage, handler);
|
||||
|
||||
return () => {
|
||||
chrome.runtime.onMessage.removeListener(handler);
|
||||
|
||||
if (BrowserApi.isSafariApi) {
|
||||
const index = BrowserApi.registeredMessageListeners.indexOf(handler);
|
||||
if (index !== -1) {
|
||||
BrowserApi.registeredMessageListeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
return () => BrowserApi.removeListener(chrome.runtime.onMessage, handler);
|
||||
});
|
||||
}
|
||||
|
||||
static storageChangeListener(
|
||||
callback: Parameters<typeof chrome.storage.onChanged.addListener>[0]
|
||||
) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chrome.storage.onChanged.addListener(callback);
|
||||
BrowserApi.addListener(chrome.storage.onChanged, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a callback to the given chrome event in a cross-browser platform manner.
|
||||
*
|
||||
* **Important:** All event listeners in the browser extension popup context must
|
||||
* use this instead of the native APIs to handle unsubscribing from Safari properly.
|
||||
*
|
||||
* @param event - The event in which to add the listener to.
|
||||
* @param callback - The callback you want registered onto the event.
|
||||
*/
|
||||
static addListener<T extends (...args: readonly unknown[]) => unknown>(
|
||||
event: chrome.events.Event<T>,
|
||||
callback: T
|
||||
) {
|
||||
event.addListener(callback);
|
||||
|
||||
if (BrowserApi.isSafariApi && !BrowserApi.isBackgroundPage(window)) {
|
||||
BrowserApi.registeredStorageChangeListeners.push(callback);
|
||||
BrowserApi.trackedChromeEventListeners.push([event, callback]);
|
||||
BrowserApi.setupUnloadListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a callback from the given chrome event in a cross-browser platform manner.
|
||||
* @param event - The event in which to remove the listener from.
|
||||
* @param callback - The callback you want removed from the event.
|
||||
*/
|
||||
static removeListener<T extends (...args: readonly unknown[]) => unknown>(
|
||||
event: chrome.events.Event<T>,
|
||||
callback: T
|
||||
) {
|
||||
event.removeListener(callback);
|
||||
|
||||
if (BrowserApi.isSafariApi && !BrowserApi.isBackgroundPage(window)) {
|
||||
const index = BrowserApi.trackedChromeEventListeners.findIndex(([_event, eventListener]) => {
|
||||
return eventListener == callback;
|
||||
});
|
||||
if (index !== -1) {
|
||||
BrowserApi.trackedChromeEventListeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup the event to destroy all the listeners when the popup gets unloaded in Safari, otherwise we get a memory leak
|
||||
private static setupUnloadListeners() {
|
||||
// The MDN recommend using 'visibilitychange' but that event is fired any time the popup window is obscured as well
|
||||
// 'pagehide' works just like 'unload' but is compatible with the back/forward cache, so we prefer using that one
|
||||
window.onpagehide = () => {
|
||||
for (const callback of BrowserApi.registeredMessageListeners) {
|
||||
chrome.runtime.onMessage.removeListener(callback);
|
||||
}
|
||||
|
||||
for (const callback of BrowserApi.registeredStorageChangeListeners) {
|
||||
chrome.storage.onChanged.removeListener(callback);
|
||||
for (const [event, callback] of BrowserApi.trackedChromeEventListeners) {
|
||||
event.removeListener(callback);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
103
apps/browser/src/platform/browser/from-chrome-event.spec.ts
Normal file
103
apps/browser/src/platform/browser/from-chrome-event.spec.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { fromChromeEvent } from "./from-chrome-event";
|
||||
|
||||
describe("fromChromeEvent", () => {
|
||||
class FakeEvent implements chrome.events.Event<(arg1: string, arg2: number) => void> {
|
||||
listenerWasAdded: boolean;
|
||||
listenerWasRemoved: boolean;
|
||||
activeListeners: ((arg1: string, arg2: number) => void)[] = [];
|
||||
|
||||
addListener(callback: (arg1: string, arg2: number) => void): void {
|
||||
this.listenerWasAdded = true;
|
||||
this.activeListeners.push(callback);
|
||||
}
|
||||
getRules(callback: (rules: chrome.events.Rule[]) => void): void;
|
||||
getRules(ruleIdentifiers: string[], callback: (rules: chrome.events.Rule[]) => void): void;
|
||||
getRules(ruleIdentifiers: unknown, callback?: unknown): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
hasListener(callback: (arg1: string, arg2: number) => void): boolean {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
removeRules(ruleIdentifiers?: string[], callback?: () => void): void;
|
||||
removeRules(callback?: () => void): void;
|
||||
removeRules(ruleIdentifiers?: unknown, callback?: unknown): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
addRules(rules: chrome.events.Rule[], callback?: (rules: chrome.events.Rule[]) => void): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
removeListener(callback: (arg1: string, arg2: number) => void): void {
|
||||
const index = this.activeListeners.findIndex((c) => c == callback);
|
||||
if (index === -1) {
|
||||
throw new Error("No registered callback.");
|
||||
}
|
||||
|
||||
this.listenerWasRemoved = true;
|
||||
this.activeListeners.splice(index, 1);
|
||||
}
|
||||
hasListeners(): boolean {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
fireEvent(arg1: string, arg2: number) {
|
||||
this.activeListeners.forEach((listener) => {
|
||||
listener(arg1, arg2);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let event: FakeEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
event = new FakeEvent();
|
||||
});
|
||||
|
||||
it("should never call addListener when never subscribed to", () => {
|
||||
fromChromeEvent(event);
|
||||
expect(event.listenerWasAdded).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should add a listener when subscribed to.", () => {
|
||||
const eventObservable = fromChromeEvent(event);
|
||||
eventObservable.subscribe();
|
||||
expect(event.listenerWasAdded).toBeTruthy();
|
||||
expect(event.activeListeners).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should call remove listener when the created subscription is unsubscribed", () => {
|
||||
const eventObservable = fromChromeEvent(event);
|
||||
const subscription = eventObservable.subscribe();
|
||||
subscription.unsubscribe();
|
||||
expect(event.listenerWasAdded).toBeTruthy();
|
||||
expect(event.listenerWasRemoved).toBeTruthy();
|
||||
expect(event.activeListeners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should fire each callback given to subscribe", () => {
|
||||
const eventObservable = fromChromeEvent(event);
|
||||
|
||||
let subscription1Called = false;
|
||||
let subscription2Called = false;
|
||||
|
||||
const subscription1 = eventObservable.subscribe(([arg1, arg2]) => {
|
||||
expect(arg1).toBe("Hi!");
|
||||
expect(arg2).toBe(2);
|
||||
subscription1Called = true;
|
||||
});
|
||||
|
||||
const subscription2 = eventObservable.subscribe(([arg1, arg2]) => {
|
||||
expect(arg1).toBe("Hi!");
|
||||
expect(arg2).toBe(2);
|
||||
subscription2Called = true;
|
||||
});
|
||||
|
||||
event.fireEvent("Hi!", 2);
|
||||
|
||||
subscription1.unsubscribe();
|
||||
subscription2.unsubscribe();
|
||||
|
||||
expect(event.activeListeners).toHaveLength(0);
|
||||
expect(subscription1Called).toBeTruthy();
|
||||
expect(subscription2Called).toBeTruthy();
|
||||
});
|
||||
});
|
39
apps/browser/src/platform/browser/from-chrome-event.ts
Normal file
39
apps/browser/src/platform/browser/from-chrome-event.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { BrowserApi } from "./browser-api";
|
||||
|
||||
/**
|
||||
* Converts a Chrome event to an Observable stream.
|
||||
*
|
||||
* @typeParam T - The type of the event arguments.
|
||||
* @param event - The Chrome event to convert.
|
||||
* @returns An Observable stream of the event arguments.
|
||||
*
|
||||
* @remarks
|
||||
* This function creates an Observable stream that listens to a Chrome event and emits its arguments
|
||||
* whenever the event is triggered. If the event throws an error, the Observable will emit an error
|
||||
* notification with the error message.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const onMessage = fromChromeEvent(chrome.runtime.onMessage);
|
||||
* onMessage.subscribe((message) => console.log('Received message:', message));
|
||||
* ```
|
||||
*/
|
||||
export function fromChromeEvent<T extends unknown[]>(
|
||||
event: chrome.events.Event<(...args: T) => void>
|
||||
): Observable<T> {
|
||||
return new Observable<T>((subscriber) => {
|
||||
const handler = (...args: T) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
subscriber.error(chrome.runtime.lastError);
|
||||
return;
|
||||
}
|
||||
|
||||
subscriber.next(args);
|
||||
};
|
||||
|
||||
BrowserApi.addListener(event, handler);
|
||||
return () => BrowserApi.removeListener(event, handler);
|
||||
});
|
||||
}
|
@ -1,7 +1,39 @@
|
||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { Observable, mergeMap } from "rxjs";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
StorageUpdate,
|
||||
StorageUpdateType,
|
||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
|
||||
import { fromChromeEvent } from "../../browser/from-chrome-event";
|
||||
|
||||
export default abstract class AbstractChromeStorageService implements AbstractStorageService {
|
||||
protected abstract chromeStorageApi: chrome.storage.StorageArea;
|
||||
constructor(protected chromeStorageApi: chrome.storage.StorageArea) {}
|
||||
|
||||
get updates$(): Observable<StorageUpdate> {
|
||||
return fromChromeEvent(this.chromeStorageApi.onChanged).pipe(
|
||||
mergeMap(([changes]) => {
|
||||
return Object.entries(changes).map(([key, change]) => {
|
||||
// The `newValue` property isn't on the StorageChange object
|
||||
// when the change was from a remove. Similarly a check of the `oldValue`
|
||||
// could be used to tell if the operation was the first creation of this key
|
||||
// but we currently do not differentiate that.
|
||||
// Ref: https://developer.chrome.com/docs/extensions/reference/storage/#type-StorageChange
|
||||
// Ref: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/StorageChange
|
||||
const updateType: StorageUpdateType = "newValue" in change ? "save" : "remove";
|
||||
|
||||
return {
|
||||
key: key,
|
||||
// For removes this property will not exist but then it will just be
|
||||
// undefined which is fine.
|
||||
value: change.newValue,
|
||||
updateType: updateType,
|
||||
};
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
@ -22,11 +54,7 @@ export default abstract class AbstractChromeStorageService implements AbstractSt
|
||||
async save(key: string, obj: any): Promise<void> {
|
||||
if (obj == null) {
|
||||
// Fix safari not liking null in set
|
||||
return new Promise<void>((resolve) => {
|
||||
this.chromeStorageApi.remove(key, () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
return this.remove(key);
|
||||
}
|
||||
|
||||
if (obj instanceof Set) {
|
||||
|
@ -1,5 +1,7 @@
|
||||
import AbstractChromeStorageService from "./abstractions/abstract-chrome-storage-api.service";
|
||||
|
||||
export default class BrowserLocalStorageService extends AbstractChromeStorageService {
|
||||
protected chromeStorageApi = chrome.storage.local;
|
||||
constructor() {
|
||||
super(chrome.storage.local);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
import AbstractChromeStorageService from "./abstractions/abstract-chrome-storage-api.service";
|
||||
|
||||
export default class BrowserMemoryStorageService extends AbstractChromeStorageService {
|
||||
protected chromeStorageApi = chrome.storage.session;
|
||||
constructor() {
|
||||
super(chrome.storage.session);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { Subject } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { AbstractMemoryStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import {
|
||||
AbstractMemoryStorageService,
|
||||
StorageUpdate,
|
||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { MemoryStorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
@ -22,6 +26,7 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi
|
||||
private cache = new Map<string, unknown>();
|
||||
private localStorage = new BrowserLocalStorageService();
|
||||
private sessionStorage = new BrowserMemoryStorageService();
|
||||
private updatesSubject = new Subject<StorageUpdate>();
|
||||
|
||||
constructor(
|
||||
private encryptService: EncryptService,
|
||||
@ -30,6 +35,10 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi
|
||||
super();
|
||||
}
|
||||
|
||||
get updates$() {
|
||||
return this.updatesSubject.asObservable();
|
||||
}
|
||||
|
||||
async get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> {
|
||||
if (this.cache.has(key)) {
|
||||
return this.cache.get(key) as T;
|
||||
|
@ -219,6 +219,9 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
});
|
||||
|
||||
this.windowClosed$ = fromEventPattern(
|
||||
// FIXME: Make sure that is does not cause a memory leak in Safari or use BrowserApi.AddListener
|
||||
// and test that it doesn't break. Tracking Ticket: https://bitwarden.atlassian.net/browse/PM-4735
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(handler: any) => chrome.windows.onRemoved.addListener(handler),
|
||||
(handler: any) => chrome.windows.onRemoved.removeListener(handler)
|
||||
);
|
||||
|
@ -5,10 +5,14 @@ import * as lowdb from "lowdb";
|
||||
import * as FileSync from "lowdb/adapters/FileSync";
|
||||
import * as lock from "proper-lockfile";
|
||||
import { OperationOptions } from "retry";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { NodeUtils } from "@bitwarden/common/misc/nodeUtils";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
StorageUpdate,
|
||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { sequentialize } from "@bitwarden/common/platform/misc/sequentialize";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
@ -24,6 +28,7 @@ export class LowdbStorageService implements AbstractStorageService {
|
||||
private db: lowdb.LowdbSync<any>;
|
||||
private defaults: any;
|
||||
private ready = false;
|
||||
private updatesSubject = new Subject<StorageUpdate>();
|
||||
|
||||
constructor(
|
||||
protected logService: LogService,
|
||||
@ -102,6 +107,10 @@ export class LowdbStorageService implements AbstractStorageService {
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
get updates$() {
|
||||
return this.updatesSubject.asObservable();
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T> {
|
||||
await this.waitForReady();
|
||||
return this.lockDbFile(() => {
|
||||
@ -119,21 +128,23 @@ export class LowdbStorageService implements AbstractStorageService {
|
||||
return this.get(key).then((v) => v != null);
|
||||
}
|
||||
|
||||
async save(key: string, obj: any): Promise<any> {
|
||||
async save(key: string, obj: any): Promise<void> {
|
||||
await this.waitForReady();
|
||||
return this.lockDbFile(() => {
|
||||
this.readForNoCache();
|
||||
this.db.set(key, obj).write();
|
||||
this.updatesSubject.next({ key, value: obj, updateType: "save" });
|
||||
this.logService.debug(`Successfully wrote ${key} to db`);
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<any> {
|
||||
async remove(key: string): Promise<void> {
|
||||
await this.waitForReady();
|
||||
return this.lockDbFile(() => {
|
||||
this.readForNoCache();
|
||||
this.db.unset(key).write();
|
||||
this.updatesSubject.next({ key, value: null, updateType: "remove" });
|
||||
this.logService.debug(`Successfully removed ${key} from db`);
|
||||
return;
|
||||
});
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { throwError } from "rxjs";
|
||||
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
@ -12,6 +14,12 @@ export class NodeEnvSecureStorageService implements AbstractStorageService {
|
||||
private cryptoService: () => CryptoService
|
||||
) {}
|
||||
|
||||
get updates$() {
|
||||
return throwError(
|
||||
() => new Error("Secure storage implementations cannot have their updates subscribed to.")
|
||||
);
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T> {
|
||||
const value = await this.storageService.get<string>(this.makeProtectedStorageKey(key));
|
||||
if (value == null) {
|
||||
@ -25,7 +33,7 @@ export class NodeEnvSecureStorageService implements AbstractStorageService {
|
||||
return (await this.get(key)) != null;
|
||||
}
|
||||
|
||||
async save(key: string, obj: any): Promise<any> {
|
||||
async save(key: string, obj: any): Promise<void> {
|
||||
if (obj == null) {
|
||||
return this.remove(key);
|
||||
}
|
||||
@ -37,8 +45,9 @@ export class NodeEnvSecureStorageService implements AbstractStorageService {
|
||||
await this.storageService.save(this.makeProtectedStorageKey(key), protectedObj);
|
||||
}
|
||||
|
||||
remove(key: string): Promise<any> {
|
||||
return this.storageService.remove(this.makeProtectedStorageKey(key));
|
||||
async remove(key: string): Promise<void> {
|
||||
await this.storageService.remove(this.makeProtectedStorageKey(key));
|
||||
return;
|
||||
}
|
||||
|
||||
private async encrypt(plainValue: string): Promise<string> {
|
||||
|
@ -1,7 +1,15 @@
|
||||
import { throwError } from "rxjs";
|
||||
|
||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
|
||||
|
||||
export class ElectronRendererSecureStorageService implements AbstractStorageService {
|
||||
get updates$() {
|
||||
return throwError(
|
||||
() => new Error("Secure storage implementations cannot have their updates subscribed to.")
|
||||
);
|
||||
}
|
||||
|
||||
async get<T>(key: string, options?: StorageOptions): Promise<T> {
|
||||
const val = await ipc.platform.passwords.get(key, options?.keySuffix ?? "");
|
||||
return val != null ? (JSON.parse(val) as T) : null;
|
||||
@ -12,11 +20,11 @@ export class ElectronRendererSecureStorageService implements AbstractStorageServ
|
||||
return !!val;
|
||||
}
|
||||
|
||||
async save(key: string, obj: any, options?: StorageOptions): Promise<any> {
|
||||
async save<T>(key: string, obj: T, options?: StorageOptions): Promise<void> {
|
||||
await ipc.platform.passwords.set(key, options?.keySuffix ?? "", JSON.stringify(obj));
|
||||
}
|
||||
|
||||
async remove(key: string, options?: StorageOptions): Promise<any> {
|
||||
async remove(key: string, options?: StorageOptions): Promise<void> {
|
||||
await ipc.platform.passwords.delete(key, options?.keySuffix ?? "");
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,17 @@
|
||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
StorageUpdate,
|
||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
|
||||
export class ElectronRendererStorageService implements AbstractStorageService {
|
||||
private updatesSubject = new Subject<StorageUpdate>();
|
||||
|
||||
get updates$() {
|
||||
return this.updatesSubject.asObservable();
|
||||
}
|
||||
|
||||
get<T>(key: string): Promise<T> {
|
||||
return ipc.platform.storage.get(key);
|
||||
}
|
||||
@ -9,11 +20,13 @@ export class ElectronRendererStorageService implements AbstractStorageService {
|
||||
return ipc.platform.storage.has(key);
|
||||
}
|
||||
|
||||
save(key: string, obj: any): Promise<any> {
|
||||
return ipc.platform.storage.save(key, obj);
|
||||
async save<T>(key: string, obj: T): Promise<void> {
|
||||
await ipc.platform.storage.save(key, obj);
|
||||
this.updatesSubject.next({ key, value: obj, updateType: "save" });
|
||||
}
|
||||
|
||||
remove(key: string): Promise<any> {
|
||||
return ipc.platform.storage.remove(key);
|
||||
async remove(key: string): Promise<void> {
|
||||
await ipc.platform.storage.remove(key);
|
||||
this.updatesSubject.next({ key, value: null, updateType: "remove" });
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,13 @@
|
||||
import * as fs from "fs";
|
||||
|
||||
import { ipcMain } from "electron";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { NodeUtils } from "@bitwarden/common/misc/nodeUtils";
|
||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
StorageUpdate,
|
||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
|
||||
// See: https://github.com/sindresorhus/electron-store/blob/main/index.d.ts
|
||||
interface ElectronStoreOptions {
|
||||
@ -35,6 +39,7 @@ type Options = BaseOptions<"get"> | BaseOptions<"has"> | SaveOptions | BaseOptio
|
||||
|
||||
export class ElectronStorageService implements AbstractStorageService {
|
||||
private store: ElectronStore;
|
||||
private updatesSubject = new Subject<StorageUpdate>();
|
||||
|
||||
constructor(dir: string, defaults = {}) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
@ -60,6 +65,10 @@ export class ElectronStorageService implements AbstractStorageService {
|
||||
});
|
||||
}
|
||||
|
||||
get updates$() {
|
||||
return this.updatesSubject.asObservable();
|
||||
}
|
||||
|
||||
get<T>(key: string): Promise<T> {
|
||||
const val = this.store.get(key) as T;
|
||||
return Promise.resolve(val != null ? val : null);
|
||||
@ -75,11 +84,13 @@ export class ElectronStorageService implements AbstractStorageService {
|
||||
obj = Array.from(obj);
|
||||
}
|
||||
this.store.set(key, obj);
|
||||
this.updatesSubject.next({ key, value: obj, updateType: "save" });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
remove(key: string): Promise<void> {
|
||||
this.store.delete(key);
|
||||
this.updatesSubject.next({ key, value: null, updateType: "remove" });
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,25 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { HtmlStorageLocation } from "@bitwarden/common/enums";
|
||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
StorageUpdate,
|
||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
|
||||
|
||||
@Injectable()
|
||||
export class HtmlStorageService implements AbstractStorageService {
|
||||
private updatesSubject = new Subject<StorageUpdate>();
|
||||
|
||||
get defaultOptions(): StorageOptions {
|
||||
return { htmlStorageLocation: HtmlStorageLocation.Session };
|
||||
}
|
||||
|
||||
get updates$() {
|
||||
return this.updatesSubject.asObservable();
|
||||
}
|
||||
|
||||
get<T>(key: string, options: StorageOptions = this.defaultOptions): Promise<T> {
|
||||
let json: string = null;
|
||||
switch (options.htmlStorageLocation) {
|
||||
@ -52,6 +62,7 @@ export class HtmlStorageService implements AbstractStorageService {
|
||||
window.sessionStorage.setItem(key, json);
|
||||
break;
|
||||
}
|
||||
this.updatesSubject.next({ key, value: obj, updateType: "save" });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@ -65,6 +76,7 @@ export class HtmlStorageService implements AbstractStorageService {
|
||||
window.sessionStorage.removeItem(key);
|
||||
break;
|
||||
}
|
||||
this.updatesSubject.next({ key, value: null, updateType: "remove" });
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
60
libs/common/spec/fake-storage.service.ts
Normal file
60
libs/common/spec/fake-storage.service.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
StorageUpdate,
|
||||
} from "../src/platform/abstractions/storage.service";
|
||||
import { StorageOptions } from "../src/platform/models/domain/storage-options";
|
||||
|
||||
export class FakeStorageService implements AbstractStorageService {
|
||||
private store: Record<string, unknown>;
|
||||
private updatesSubject = new Subject<StorageUpdate>();
|
||||
|
||||
/**
|
||||
* Returns a mock of a {@see AbstractStorageService} for asserting the expected
|
||||
* amount of calls. It is not recommended to use this to mock implementations as
|
||||
* they are not respected.
|
||||
*/
|
||||
mock: MockProxy<AbstractStorageService>;
|
||||
|
||||
constructor(initial?: Record<string, unknown>) {
|
||||
this.store = initial ?? {};
|
||||
this.mock = mock<AbstractStorageService>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the internal store for this fake implementation, this bypasses any mock calls
|
||||
* or updates to the {@link updates$} observable.
|
||||
* @param store
|
||||
*/
|
||||
internalUpdateStore(store: Record<string, unknown>) {
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
get updates$() {
|
||||
return this.updatesSubject.asObservable();
|
||||
}
|
||||
|
||||
get<T>(key: string, options?: StorageOptions): Promise<T> {
|
||||
this.mock.get(key, options);
|
||||
const value = this.store[key] as T;
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
has(key: string, options?: StorageOptions): Promise<boolean> {
|
||||
this.mock.has(key, options);
|
||||
return Promise.resolve(this.store[key] != null);
|
||||
}
|
||||
save<T>(key: string, obj: T, options?: StorageOptions): Promise<void> {
|
||||
this.mock.save(key, options);
|
||||
this.store[key] = obj;
|
||||
this.updatesSubject.next({ key: key, value: obj, updateType: "save" });
|
||||
return Promise.resolve();
|
||||
}
|
||||
remove(key: string, options?: StorageOptions): Promise<void> {
|
||||
this.mock.remove(key, options);
|
||||
delete this.store[key];
|
||||
this.updatesSubject.next({ key: key, value: undefined, updateType: "remove" });
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
@ -69,12 +69,18 @@ export function trackEmissions<T>(observable: Observable<T>): T[] {
|
||||
case "boolean":
|
||||
emissions.push(value);
|
||||
break;
|
||||
case "object":
|
||||
emissions.push({ ...value });
|
||||
break;
|
||||
default:
|
||||
emissions.push(JSON.parse(JSON.stringify(value)));
|
||||
default: {
|
||||
emissions.push(clone(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
return emissions;
|
||||
}
|
||||
|
||||
function clone(value: any): any {
|
||||
if (global.structuredClone != undefined) {
|
||||
return structuredClone(value);
|
||||
} else {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,21 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { MemoryStorageOptions, StorageOptions } from "../models/domain/storage-options";
|
||||
|
||||
export type StorageUpdateType = "save" | "remove";
|
||||
export type StorageUpdate = {
|
||||
key: string;
|
||||
value?: unknown;
|
||||
updateType: StorageUpdateType;
|
||||
};
|
||||
|
||||
export abstract class AbstractStorageService {
|
||||
/**
|
||||
* Provides an {@link Observable} that represents a stream of updates that
|
||||
* have happened in this storage service or in the storage this service provides
|
||||
* an interface to.
|
||||
*/
|
||||
abstract get updates$(): Observable<StorageUpdate>;
|
||||
abstract get<T>(key: string, options?: StorageOptions): Promise<T>;
|
||||
abstract has(key: string, options?: StorageOptions): Promise<boolean>;
|
||||
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;
|
||||
|
@ -1,7 +1,14 @@
|
||||
import { AbstractMemoryStorageService } from "../abstractions/storage.service";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { AbstractMemoryStorageService, StorageUpdate } from "../abstractions/storage.service";
|
||||
|
||||
export class MemoryStorageService extends AbstractMemoryStorageService {
|
||||
private store = new Map<string, any>();
|
||||
private store = new Map<string, unknown>();
|
||||
private updatesSubject = new Subject<StorageUpdate>();
|
||||
|
||||
get updates$() {
|
||||
return this.updatesSubject.asObservable();
|
||||
}
|
||||
|
||||
get<T>(key: string): Promise<T> {
|
||||
if (this.store.has(key)) {
|
||||
@ -15,16 +22,18 @@ export class MemoryStorageService extends AbstractMemoryStorageService {
|
||||
return (await this.get(key)) != null;
|
||||
}
|
||||
|
||||
save(key: string, obj: any): Promise<any> {
|
||||
save<T>(key: string, obj: T): Promise<void> {
|
||||
if (obj == null) {
|
||||
return this.remove(key);
|
||||
}
|
||||
this.store.set(key, obj);
|
||||
this.updatesSubject.next({ key, value: obj, updateType: "save" });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
remove(key: string): Promise<any> {
|
||||
remove(key: string): Promise<void> {
|
||||
this.store.delete(key);
|
||||
this.updatesSubject.next({ key, value: null, updateType: "remove" });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
5
libs/common/src/platform/state/derived-user-state.ts
Normal file
5
libs/common/src/platform/state/derived-user-state.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export interface DerivedUserState<T> {
|
||||
state$: Observable<T>;
|
||||
}
|
13
libs/common/src/platform/state/global-state.provider.ts
Normal file
13
libs/common/src/platform/state/global-state.provider.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { GlobalState } from "./global-state";
|
||||
import { KeyDefinition } from "./key-definition";
|
||||
|
||||
/**
|
||||
* A provider for geting an implementation of global state scoped to the given key.
|
||||
*/
|
||||
export abstract class GlobalStateProvider {
|
||||
/**
|
||||
* Gets a {@link GlobalState} scoped to the given {@link KeyDefinition}
|
||||
* @param keyDefinition - The {@link KeyDefinition} for which you want the state for.
|
||||
*/
|
||||
get: <T>(keyDefinition: KeyDefinition<T>) => GlobalState<T>;
|
||||
}
|
20
libs/common/src/platform/state/global-state.ts
Normal file
20
libs/common/src/platform/state/global-state.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
/**
|
||||
* A helper object for interacting with state that is scoped to a specific domain
|
||||
* but is not scoped to a user. This is application wide storage.
|
||||
*/
|
||||
export interface GlobalState<T> {
|
||||
/**
|
||||
* Method for allowing you to manipulate state in an additive way.
|
||||
* @param configureState callback for how you want manipulate this section of 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>;
|
||||
|
||||
/**
|
||||
* An observable stream of this state, the first emission of this will be the current state on disk
|
||||
* and subsequent updates will be from an update to that state.
|
||||
*/
|
||||
state$: Observable<T>;
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import { Observable, switchMap } from "rxjs";
|
||||
|
||||
import { EncryptService } from "../../abstractions/encrypt.service";
|
||||
import { DerivedUserState } from "../derived-user-state";
|
||||
import { Converter, DeriveContext, UserState } from "../user-state";
|
||||
|
||||
export class DefaultDerivedUserState<TFrom, TTo> implements DerivedUserState<TTo> {
|
||||
state$: Observable<TTo>;
|
||||
|
||||
constructor(
|
||||
private converter: Converter<TFrom, TTo>,
|
||||
private encryptService: EncryptService,
|
||||
private userState: UserState<TFrom>
|
||||
) {
|
||||
this.state$ = userState.state$.pipe(
|
||||
switchMap(async (from) => {
|
||||
// TODO: How do I get the key?
|
||||
const convertedData = await this.converter(from, new DeriveContext(null, encryptService));
|
||||
return convertedData;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import {
|
||||
AbstractMemoryStorageService,
|
||||
AbstractStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { GlobalState } from "../global-state";
|
||||
import { GlobalStateProvider } from "../global-state.provider";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
import { StorageLocation } from "../state-definition";
|
||||
|
||||
import { DefaultGlobalState } from "./default-global-state";
|
||||
|
||||
export class DefaultGlobalStateProvider implements GlobalStateProvider {
|
||||
private globalStateCache: Record<string, GlobalState<unknown>> = {};
|
||||
|
||||
constructor(
|
||||
private memoryStorage: AbstractMemoryStorageService,
|
||||
private diskStorage: AbstractStorageService
|
||||
) {}
|
||||
|
||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
const cacheKey = keyDefinition.buildCacheKey();
|
||||
const existingGlobalState = this.globalStateCache[cacheKey];
|
||||
if (existingGlobalState != null) {
|
||||
// The cast into the actual generic is safe because of rules around key definitions
|
||||
// being unique.
|
||||
return existingGlobalState as DefaultGlobalState<T>;
|
||||
}
|
||||
|
||||
const newGlobalState = new DefaultGlobalState<T>(
|
||||
keyDefinition,
|
||||
this.getLocation(keyDefinition.stateDefinition.storageLocation)
|
||||
);
|
||||
|
||||
this.globalStateCache[cacheKey] = newGlobalState;
|
||||
return newGlobalState;
|
||||
}
|
||||
|
||||
private getLocation(location: StorageLocation) {
|
||||
switch (location) {
|
||||
case "disk":
|
||||
return this.diskStorage;
|
||||
case "memory":
|
||||
return this.memoryStorage;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* need to update test environment so trackEmissions works appropriately
|
||||
* @jest-environment ../shared/test.environment.ts
|
||||
*/
|
||||
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { trackEmissions } from "../../../../spec";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
|
||||
import { DefaultGlobalState } from "./default-global-state";
|
||||
|
||||
class TestState {
|
||||
date: Date;
|
||||
|
||||
static fromJSON(jsonState: Jsonify<TestState>) {
|
||||
if (jsonState == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new TestState(), jsonState, {
|
||||
date: new Date(jsonState.date),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const testStateDefinition = new StateDefinition("fake", "disk");
|
||||
|
||||
const testKeyDefinition = new KeyDefinition<TestState>(
|
||||
testStateDefinition,
|
||||
"fake",
|
||||
TestState.fromJSON
|
||||
);
|
||||
const globalKey = globalKeyBuilder(testKeyDefinition);
|
||||
|
||||
describe("DefaultGlobalState", () => {
|
||||
let diskStorageService: FakeStorageService;
|
||||
let globalState: DefaultGlobalState<TestState>;
|
||||
|
||||
beforeEach(() => {
|
||||
diskStorageService = new FakeStorageService();
|
||||
globalState = new DefaultGlobalState(testKeyDefinition, diskStorageService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should emit when storage updates", async () => {
|
||||
const emissions = trackEmissions(globalState.state$);
|
||||
const newData = { date: new Date() };
|
||||
await diskStorageService.save(globalKey, newData);
|
||||
|
||||
expect(emissions).toEqual([
|
||||
null, // Initial value
|
||||
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 () => {
|
||||
const emissions = trackEmissions(globalState.state$);
|
||||
const newData = { date: new Date() };
|
||||
await diskStorageService.save("wrong_key", newData);
|
||||
|
||||
expect(emissions).toEqual(
|
||||
expect.arrayContaining([
|
||||
null, // Initial value
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("should save on update", async () => {
|
||||
const newData = { date: new Date() };
|
||||
const result = await globalState.update((state) => {
|
||||
return newData;
|
||||
});
|
||||
|
||||
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(newData);
|
||||
});
|
||||
|
||||
it("should emit once per update", async () => {
|
||||
const emissions = trackEmissions(globalState.state$);
|
||||
const newData = { date: new Date() };
|
||||
|
||||
await globalState.update((state) => {
|
||||
return newData;
|
||||
});
|
||||
|
||||
expect(emissions).toEqual([
|
||||
null, // Initial value
|
||||
newData,
|
||||
]);
|
||||
});
|
||||
});
|
@ -0,0 +1,60 @@
|
||||
import { BehaviorSubject, Observable, defer, filter, map, shareReplay, tap } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AbstractStorageService } from "../../abstractions/storage.service";
|
||||
import { GlobalState } from "../global-state";
|
||||
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
||||
|
||||
export class DefaultGlobalState<T> implements GlobalState<T> {
|
||||
private storageKey: string;
|
||||
private seededPromise: Promise<void>;
|
||||
|
||||
protected stateSubject: BehaviorSubject<T | null> = new BehaviorSubject<T | null>(null);
|
||||
|
||||
state$: Observable<T>;
|
||||
|
||||
constructor(
|
||||
private keyDefinition: KeyDefinition<T>,
|
||||
private chosenLocation: AbstractStorageService
|
||||
) {
|
||||
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(
|
||||
filter((update) => update.key === this.storageKey),
|
||||
map((update) => {
|
||||
return this.keyDefinition.deserializer(update.value as Jsonify<T>);
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: false })
|
||||
);
|
||||
|
||||
this.state$ = defer(() => {
|
||||
const storageUpdateSubscription = storageUpdates$.subscribe((value) => {
|
||||
this.stateSubject.next(value);
|
||||
});
|
||||
|
||||
return this.stateSubject.pipe(
|
||||
tap({
|
||||
complete: () => storageUpdateSubscription.unsubscribe(),
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async update(configureState: (state: T) => T): Promise<T> {
|
||||
await this.seededPromise;
|
||||
const currentState = this.stateSubject.getValue();
|
||||
const newState = configureState(currentState);
|
||||
await this.chosenLocation.save(this.storageKey, newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
async getFromState(): Promise<T> {
|
||||
const data = await this.chosenLocation.get<Jsonify<T>>(this.storageKey);
|
||||
return this.keyDefinition.deserializer(data);
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { EncryptService } from "../../abstractions/encrypt.service";
|
||||
import {
|
||||
AbstractMemoryStorageService,
|
||||
AbstractStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
import { StorageLocation } from "../state-definition";
|
||||
import { UserState } from "../user-state";
|
||||
import { UserStateProvider } from "../user-state.provider";
|
||||
|
||||
import { DefaultUserState } from "./default-user-state";
|
||||
|
||||
export class DefaultUserStateProvider implements UserStateProvider {
|
||||
private userStateCache: Record<string, UserState<unknown>> = {};
|
||||
|
||||
constructor(
|
||||
protected accountService: AccountService,
|
||||
protected encryptService: EncryptService,
|
||||
protected memoryStorage: AbstractMemoryStorageService,
|
||||
protected diskStorage: AbstractStorageService
|
||||
) {}
|
||||
|
||||
get<T>(keyDefinition: KeyDefinition<T>): UserState<T> {
|
||||
const cacheKey = keyDefinition.buildCacheKey();
|
||||
const existingUserState = this.userStateCache[cacheKey];
|
||||
if (existingUserState != null) {
|
||||
// I have to cast out of the unknown generic but this should be safe if rules
|
||||
// around domain token are made
|
||||
return existingUserState as DefaultUserState<T>;
|
||||
}
|
||||
|
||||
const newUserState = this.buildUserState(keyDefinition);
|
||||
this.userStateCache[cacheKey] = newUserState;
|
||||
return newUserState;
|
||||
}
|
||||
|
||||
protected buildUserState<T>(keyDefinition: KeyDefinition<T>): UserState<T> {
|
||||
return new DefaultUserState<T>(
|
||||
keyDefinition,
|
||||
this.accountService,
|
||||
this.encryptService,
|
||||
this.getLocation(keyDefinition.stateDefinition.storageLocation)
|
||||
);
|
||||
}
|
||||
|
||||
private getLocation(location: StorageLocation) {
|
||||
switch (location) {
|
||||
case "disk":
|
||||
return this.diskStorage;
|
||||
case "memory":
|
||||
return this.memoryStorage;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,236 @@
|
||||
import { any, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, timeout } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { trackEmissions } from "../../../../spec";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
|
||||
import { DefaultUserState } from "./default-user-state";
|
||||
|
||||
class TestState {
|
||||
date: Date;
|
||||
array: string[];
|
||||
|
||||
static fromJSON(jsonState: Jsonify<TestState>) {
|
||||
if (jsonState == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new TestState(), jsonState, {
|
||||
date: new Date(jsonState.date),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const testStateDefinition = new StateDefinition("fake", "disk");
|
||||
|
||||
const testKeyDefinition = new KeyDefinition<TestState>(
|
||||
testStateDefinition,
|
||||
"fake",
|
||||
TestState.fromJSON
|
||||
);
|
||||
|
||||
describe("DefaultUserState", () => {
|
||||
const accountService = mock<AccountService>();
|
||||
let diskStorageService: FakeStorageService;
|
||||
let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>;
|
||||
let userState: DefaultUserState<TestState>;
|
||||
|
||||
beforeEach(() => {
|
||||
activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(undefined);
|
||||
accountService.activeAccount$ = activeAccountSubject;
|
||||
|
||||
diskStorageService = new FakeStorageService();
|
||||
userState = new DefaultUserState(
|
||||
testKeyDefinition,
|
||||
accountService,
|
||||
null, // Not testing anything with encrypt service
|
||||
diskStorageService
|
||||
);
|
||||
});
|
||||
|
||||
const changeActiveUser = async (id: string) => {
|
||||
const userId = id != null ? `00000000-0000-1000-a000-00000000000${id}` : undefined;
|
||||
activeAccountSubject.next({
|
||||
id: userId as UserId,
|
||||
email: `test${id}@example.com`,
|
||||
name: `Test User ${id}`,
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("emits updates for each user switch and update", async () => {
|
||||
diskStorageService.internalUpdateStore({
|
||||
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
|
||||
date: "2022-09-21T13:14:17.648Z",
|
||||
array: ["value1", "value2"],
|
||||
} as Jsonify<TestState>,
|
||||
"user_00000000-0000-1000-a000-000000000002_fake_fake": {
|
||||
date: "2021-09-21T13:14:17.648Z",
|
||||
array: ["user2_value"],
|
||||
},
|
||||
});
|
||||
|
||||
const emissions = trackEmissions(userState.state$);
|
||||
|
||||
// User signs in
|
||||
changeActiveUser("1");
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1));
|
||||
|
||||
// Service does an update
|
||||
await userState.update((state) => {
|
||||
state.array.push("value3");
|
||||
state.date = new Date(2023, 0);
|
||||
return state;
|
||||
});
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1));
|
||||
|
||||
// Emulate an account switch
|
||||
await changeActiveUser("2");
|
||||
|
||||
expect(emissions).toHaveLength(3);
|
||||
// Gotten starter user data
|
||||
expect(emissions[0]).toBeTruthy();
|
||||
expect(emissions[0].array).toHaveLength(2);
|
||||
|
||||
// Gotten emission for the update call
|
||||
expect(emissions[1]).toBeTruthy();
|
||||
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(
|
||||
1,
|
||||
"user_00000000-0000-1000-a000-000000000001_fake_fake",
|
||||
any()
|
||||
);
|
||||
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"user_00000000-0000-1000-a000-000000000002_fake_fake",
|
||||
any()
|
||||
);
|
||||
|
||||
// Should only have saved data for the first user
|
||||
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1);
|
||||
expect(diskStorageService.mock.save).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"user_00000000-0000-1000-a000-000000000001_fake_fake",
|
||||
any()
|
||||
);
|
||||
});
|
||||
|
||||
it("will not emit any value if there isn't an active user", async () => {
|
||||
let resolvedValue: TestState | undefined = undefined;
|
||||
let rejectedError: Error | undefined = undefined;
|
||||
|
||||
const promise = firstValueFrom(userState.state$.pipe(timeout(20)))
|
||||
.then((value) => {
|
||||
resolvedValue = value;
|
||||
})
|
||||
.catch((err) => {
|
||||
rejectedError = err;
|
||||
});
|
||||
await promise;
|
||||
|
||||
expect(diskStorageService.mock.get).not.toHaveBeenCalled();
|
||||
|
||||
expect(resolvedValue).toBe(undefined);
|
||||
expect(rejectedError).toBeTruthy();
|
||||
expect(rejectedError.message).toBe("Timeout has occurred");
|
||||
});
|
||||
|
||||
it("will emit value for a new active user after subscription started", async () => {
|
||||
let resolvedValue: TestState | undefined = undefined;
|
||||
let rejectedError: Error | undefined = undefined;
|
||||
|
||||
diskStorageService.internalUpdateStore({
|
||||
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
|
||||
date: "2020-09-21T13:14:17.648Z",
|
||||
array: ["testValue"],
|
||||
} as Jsonify<TestState>,
|
||||
});
|
||||
|
||||
const promise = firstValueFrom(userState.state$.pipe(timeout(20)))
|
||||
.then((value) => {
|
||||
resolvedValue = value;
|
||||
})
|
||||
.catch((err) => {
|
||||
rejectedError = err;
|
||||
});
|
||||
await changeActiveUser("1");
|
||||
await promise;
|
||||
|
||||
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(resolvedValue).toBeTruthy();
|
||||
expect(resolvedValue.array).toHaveLength(1);
|
||||
expect(resolvedValue.date.getUTCFullYear()).toBe(2020);
|
||||
expect(rejectedError).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should not emit a previous users value if that user is no longer active", async () => {
|
||||
diskStorageService.internalUpdateStore({
|
||||
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
|
||||
date: "2020-09-21T13:14:17.648Z",
|
||||
array: ["value"],
|
||||
} as Jsonify<TestState>,
|
||||
"user_00000000-0000-1000-a000-000000000002_fake_fake": {
|
||||
date: "2020-09-21T13:14:17.648Z",
|
||||
array: [],
|
||||
} as Jsonify<TestState>,
|
||||
});
|
||||
|
||||
// This starts one subscription on the observable for tracking emissions throughout
|
||||
// the whole test.
|
||||
const emissions = trackEmissions(userState.state$);
|
||||
|
||||
// Change to a user with data
|
||||
await changeActiveUser("1");
|
||||
|
||||
// This should always return a value right await
|
||||
const value = await firstValueFrom(userState.state$);
|
||||
expect(value).toBeTruthy();
|
||||
|
||||
// Make it such that there is no active user
|
||||
await changeActiveUser(undefined);
|
||||
|
||||
let resolvedValue: TestState | undefined = undefined;
|
||||
let rejectedError: Error | undefined = undefined;
|
||||
|
||||
// Even if the observable has previously emitted a value it shouldn't have
|
||||
// a value for the user subscribing to it because there isn't an active user
|
||||
// to get data for.
|
||||
await firstValueFrom(userState.state$.pipe(timeout(20)))
|
||||
.then((value) => {
|
||||
resolvedValue = value;
|
||||
})
|
||||
.catch((err) => {
|
||||
rejectedError = err;
|
||||
});
|
||||
|
||||
expect(resolvedValue).toBeFalsy();
|
||||
expect(rejectedError).toBeTruthy();
|
||||
expect(rejectedError.message).toBe("Timeout has occurred");
|
||||
|
||||
// We need to figure out if something should be emitted
|
||||
// when there becomes no active user, if we don't want that to emit
|
||||
// this value is correct.
|
||||
expect(emissions).toHaveLength(2);
|
||||
});
|
||||
});
|
@ -0,0 +1,152 @@
|
||||
import {
|
||||
Observable,
|
||||
BehaviorSubject,
|
||||
map,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
defer,
|
||||
firstValueFrom,
|
||||
combineLatestWith,
|
||||
filter,
|
||||
} from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { EncryptService } from "../../abstractions/encrypt.service";
|
||||
import { AbstractStorageService } from "../../abstractions/storage.service";
|
||||
import { DerivedUserState } from "../derived-user-state";
|
||||
import { KeyDefinition, userKeyBuilder } from "../key-definition";
|
||||
import { Converter, UserState } from "../user-state";
|
||||
|
||||
import { DefaultDerivedUserState } from "./default-derived-state";
|
||||
|
||||
const FAKE_DEFAULT = Symbol("fakeDefault");
|
||||
|
||||
export class DefaultUserState<T> implements UserState<T> {
|
||||
private formattedKey$: Observable<string>;
|
||||
|
||||
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
|
||||
T | typeof FAKE_DEFAULT
|
||||
>(FAKE_DEFAULT);
|
||||
private stateSubject$ = this.stateSubject.asObservable();
|
||||
|
||||
state$: Observable<T>;
|
||||
|
||||
constructor(
|
||||
protected keyDefinition: KeyDefinition<T>,
|
||||
private accountService: AccountService,
|
||||
private encryptService: EncryptService,
|
||||
private chosenStorageLocation: AbstractStorageService
|
||||
) {
|
||||
this.formattedKey$ = this.accountService.activeAccount$.pipe(
|
||||
map((account) =>
|
||||
account != null && account.id != null
|
||||
? userKeyBuilder(account.id, this.keyDefinition)
|
||||
: null
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: false })
|
||||
);
|
||||
|
||||
const activeAccountData$ = this.formattedKey$.pipe(
|
||||
switchMap(async (key) => {
|
||||
if (key == null) {
|
||||
return FAKE_DEFAULT;
|
||||
}
|
||||
const jsonData = await this.chosenStorageLocation.get<Jsonify<T>>(key);
|
||||
const data = keyDefinition.deserializer(jsonData);
|
||||
return data;
|
||||
}),
|
||||
// Share the execution
|
||||
shareReplay({ refCount: false, bufferSize: 1 })
|
||||
);
|
||||
|
||||
const storageUpdates$ = this.chosenStorageLocation.updates$.pipe(
|
||||
combineLatestWith(this.formattedKey$),
|
||||
filter(([update, key]) => key !== null && update.key === key),
|
||||
map(([update]) => {
|
||||
return keyDefinition.deserializer(update.value as Jsonify<T>);
|
||||
})
|
||||
);
|
||||
|
||||
// Whomever subscribes to this data, should be notified of updated data
|
||||
// if someone calls my update() method, or the active user changes.
|
||||
this.state$ = defer(() => {
|
||||
const accountChangeSubscription = activeAccountData$.subscribe((data) => {
|
||||
this.stateSubject.next(data);
|
||||
});
|
||||
const storageUpdateSubscription = storageUpdates$.subscribe((data) => {
|
||||
this.stateSubject.next(data);
|
||||
});
|
||||
|
||||
return this.stateSubject$.pipe(
|
||||
tap({
|
||||
complete: () => {
|
||||
accountChangeSubscription.unsubscribe();
|
||||
storageUpdateSubscription.unsubscribe();
|
||||
},
|
||||
})
|
||||
);
|
||||
})
|
||||
// I fake the generic here because I am filtering out the other union type
|
||||
// and this makes it so that typescript understands the true type
|
||||
.pipe(filter<T>((value) => value != FAKE_DEFAULT));
|
||||
}
|
||||
|
||||
async update(configureState: (state: T) => T): Promise<T> {
|
||||
const key = await this.createKey();
|
||||
const currentState = await this.getGuaranteedState(key);
|
||||
const newState = configureState(currentState);
|
||||
await this.saveToStorage(key, newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
async updateFor(userId: UserId, configureState: (state: T) => T): Promise<T> {
|
||||
if (userId == null) {
|
||||
throw new Error("Attempting to update user state, but no userId has been supplied.");
|
||||
}
|
||||
|
||||
const key = userKeyBuilder(userId, this.keyDefinition);
|
||||
const currentStore = await this.chosenStorageLocation.get<Jsonify<T>>(key);
|
||||
const currentState = this.keyDefinition.deserializer(currentStore);
|
||||
const newState = configureState(currentState);
|
||||
await this.saveToStorage(key, newState);
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
async getFromState(): Promise<T> {
|
||||
const key = await this.createKey();
|
||||
const data = await this.chosenStorageLocation.get<Jsonify<T>>(key);
|
||||
return this.keyDefinition.deserializer(data);
|
||||
}
|
||||
|
||||
createDerived<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
|
||||
return new DefaultDerivedUserState<T, TTo>(converter, this.encryptService, this);
|
||||
}
|
||||
|
||||
protected async createKey(): Promise<string> {
|
||||
const formattedKey = await firstValueFrom(this.formattedKey$);
|
||||
if (formattedKey == null) {
|
||||
throw new Error("Cannot create a key while there is no active user.");
|
||||
}
|
||||
return formattedKey;
|
||||
}
|
||||
|
||||
protected async getGuaranteedState(key: string) {
|
||||
const currentValue = this.stateSubject.getValue();
|
||||
return currentValue === FAKE_DEFAULT ? await this.seedInitial(key) : currentValue;
|
||||
}
|
||||
|
||||
private async seedInitial(key: string): Promise<T> {
|
||||
const data = await this.chosenStorageLocation.get<Jsonify<T>>(key);
|
||||
const serializedData = this.keyDefinition.deserializer(data);
|
||||
this.stateSubject.next(serializedData);
|
||||
return serializedData;
|
||||
}
|
||||
|
||||
protected saveToStorage(key: string, data: T): Promise<void> {
|
||||
return this.chosenStorageLocation.save(key, data);
|
||||
}
|
||||
}
|
3
libs/common/src/platform/state/index.ts
Normal file
3
libs/common/src/platform/state/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { DerivedUserState } from "./derived-user-state";
|
||||
export { DefaultGlobalStateProvider } from "./implementations/default-global-state.provider";
|
||||
export { DefaultUserStateProvider } from "./implementations/default-user-state.provider";
|
102
libs/common/src/platform/state/key-definition.ts
Normal file
102
libs/common/src/platform/state/key-definition.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { Jsonify, Opaque } from "type-fest";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { Utils } from "../misc/utils";
|
||||
|
||||
import { StateDefinition } from "./state-definition";
|
||||
|
||||
/**
|
||||
* KeyDefinitions describe the precise location to store data for a given piece of state.
|
||||
* The StateDefinition is used to describe the domain of the state, and the KeyDefinition
|
||||
* sub-divides that domain into specific keys.
|
||||
*/
|
||||
export class KeyDefinition<T> {
|
||||
/**
|
||||
* Creates a new instance of a KeyDefinition
|
||||
* @param stateDefinition The state definition for which this key belongs to.
|
||||
* @param key The name of the key, this should be unique per domain
|
||||
* @param deserializer A function to use to safely convert your type from json to your expected type.
|
||||
*/
|
||||
constructor(
|
||||
readonly stateDefinition: StateDefinition,
|
||||
readonly key: string,
|
||||
readonly deserializer: (jsonValue: Jsonify<T>) => T
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a {@link KeyDefinition} for state that is an array.
|
||||
* @param stateDefinition The state definition to be added to the KeyDefinition
|
||||
* @param key The key to be added to the KeyDefinition
|
||||
* @param deserializer The deserializer for the element of the array in your state.
|
||||
* @returns A {@link KeyDefinition} that contains a serializer that will run the provided deserializer for each
|
||||
* element of an array **unless that array is null in which case it will return an empty list.**
|
||||
*/
|
||||
static array<T>(
|
||||
stateDefinition: StateDefinition,
|
||||
key: string,
|
||||
deserializer: (jsonValue: Jsonify<T>) => T
|
||||
) {
|
||||
return new KeyDefinition<T[]>(stateDefinition, key, (jsonValue) => {
|
||||
return jsonValue?.map((v) => deserializer(v)) ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link KeyDefinition} for state that is a record.
|
||||
* @param stateDefinition The state definition to be added to the KeyDefinition
|
||||
* @param key The key to be added to the KeyDefinition
|
||||
* @param deserializer The deserializer for the value part of a record.
|
||||
* @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.**
|
||||
*/
|
||||
static record<T>(
|
||||
stateDefinition: StateDefinition,
|
||||
key: string,
|
||||
deserializer: (jsonValue: Jsonify<T>) => T
|
||||
) {
|
||||
return new KeyDefinition<Record<string, T>>(stateDefinition, key, (jsonValue) => {
|
||||
const output: Record<string, T> = {};
|
||||
|
||||
if (jsonValue == null) {
|
||||
return output;
|
||||
}
|
||||
|
||||
for (const key in jsonValue) {
|
||||
output[key] = deserializer((jsonValue as Record<string, Jsonify<T>>)[key]);
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
buildCacheKey(): string {
|
||||
return `${this.stateDefinition.storageLocation}_${this.stateDefinition.name}_${this.key}`;
|
||||
}
|
||||
}
|
||||
|
||||
export type StorageKey = Opaque<string, "StorageKey">;
|
||||
|
||||
/**
|
||||
* Creates a {@link StorageKey} that points to the data at the given key definition for the specified user.
|
||||
* @param userId The userId of the user you want the key to be for.
|
||||
* @param keyDefinition The key definition of which data the key should point to.
|
||||
* @returns A key that is ready to be used in a storage service to get data.
|
||||
*/
|
||||
export function userKeyBuilder(userId: UserId, keyDefinition: KeyDefinition<unknown>): StorageKey {
|
||||
if (!Utils.isGuid(userId)) {
|
||||
throw new Error("You cannot build a user key without a valid UserId");
|
||||
}
|
||||
return `user_${userId}_${keyDefinition.stateDefinition.name}_${keyDefinition.key}` as StorageKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link StorageKey}
|
||||
* @param keyDefinition The key definition of which data the key should point to.
|
||||
* @returns A key that is ready to be used in a storage service to get data.
|
||||
*/
|
||||
export function globalKeyBuilder(keyDefinition: KeyDefinition<unknown>): StorageKey {
|
||||
return `global_${keyDefinition.stateDefinition.name}_${keyDefinition.key}` as StorageKey;
|
||||
}
|
13
libs/common/src/platform/state/state-definition.ts
Normal file
13
libs/common/src/platform/state/state-definition.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export type StorageLocation = "disk" | "memory";
|
||||
|
||||
/**
|
||||
* Defines the base location and instruction of where this state is expected to be located.
|
||||
*/
|
||||
export class StateDefinition {
|
||||
/**
|
||||
* Creates a new instance of {@link StateDefinition}, the creation of which is owned by the platform team.
|
||||
* @param name The name of the state, this needs to be unique from all other {@link StateDefinition}'s.
|
||||
* @param storageLocation The location of where this state should be stored.
|
||||
*/
|
||||
constructor(readonly name: string, readonly storageLocation: StorageLocation) {}
|
||||
}
|
13
libs/common/src/platform/state/user-state.provider.ts
Normal file
13
libs/common/src/platform/state/user-state.provider.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { KeyDefinition } from "./key-definition";
|
||||
import { UserState } from "./user-state";
|
||||
|
||||
/**
|
||||
* A provider for getting an implementation of user scoped state for the given key.
|
||||
*/
|
||||
export abstract class UserStateProvider {
|
||||
/**
|
||||
* Gets a {@link GlobalState} scoped to the given {@link KeyDefinition}
|
||||
* @param keyDefinition - The {@link KeyDefinition} for which you want the user state for.
|
||||
*/
|
||||
get: <T>(keyDefinition: KeyDefinition<T>) => UserState<T>;
|
||||
}
|
41
libs/common/src/platform/state/user-state.ts
Normal file
41
libs/common/src/platform/state/user-state.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
import { UserKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
import { DerivedUserState } from ".";
|
||||
|
||||
export class DeriveContext {
|
||||
constructor(readonly activeUserKey: UserKey, readonly encryptService: EncryptService) {}
|
||||
}
|
||||
|
||||
export type Converter<TFrom, TTo> = (data: TFrom, context: DeriveContext) => Promise<TTo>;
|
||||
|
||||
/**
|
||||
* A helper object for interacting with state that is scoped to a specific user.
|
||||
*/
|
||||
export interface UserState<T> {
|
||||
readonly state$: Observable<T>;
|
||||
readonly getFromState: () => Promise<T>;
|
||||
/**
|
||||
* Updates backing stores for the active user.
|
||||
* @param configureState function that takes the current state and returns the new state
|
||||
* @returns The new state
|
||||
*/
|
||||
readonly update: (configureState: (state: T) => T) => Promise<T>;
|
||||
/**
|
||||
* Updates backing stores for the given userId, which may or may not be active.
|
||||
* @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
|
||||
* @returns The new state
|
||||
*/
|
||||
readonly updateFor: (userId: UserId, configureState: (state: T) => T) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Creates a derives state from the current state. Derived states are always tied to the active user.
|
||||
* @param converter
|
||||
* @returns
|
||||
*/
|
||||
createDerived: <TTo>(converter: Converter<T, TTo>) => DerivedUserState<TTo>;
|
||||
}
|
@ -8,6 +8,7 @@ const sharedConfig = require("../shared/jest.config.ts");
|
||||
module.exports = {
|
||||
...sharedConfig,
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/",
|
||||
|
22
libs/shared/test.environment.ts
Normal file
22
libs/shared/test.environment.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import JSDOMEnvironment from "jest-environment-jsdom";
|
||||
|
||||
/**
|
||||
* https://github.com/jsdom/jsdom/issues/3363#issuecomment-1467894943
|
||||
* Adds nodes structuredClone implementation to the global object of jsdom.
|
||||
* use by either adding this file to the testEnvironment property of jest config
|
||||
* or by adding the following to the top spec file:
|
||||
*
|
||||
* ```
|
||||
* /**
|
||||
* * @jest-environment ../shared/test.environment.ts
|
||||
* *\/
|
||||
* ```
|
||||
*/
|
||||
export default class FixJSDOMEnvironment extends JSDOMEnvironment {
|
||||
constructor(...args: ConstructorParameters<typeof JSDOMEnvironment>) {
|
||||
super(...args);
|
||||
|
||||
// FIXME https://github.com/jsdom/jsdom/issues/3363
|
||||
this.global.structuredClone = structuredClone;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user