Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { runFakeRxjs } from '@hypertrace/test-utils';
import { AbstractStorage } from './abstract-storage';
import { DictionaryStorageImpl } from './dictionary-storage-impl';

describe('Abstract storage', () => {
let storage: AbstractStorage;

beforeEach(() => {
storage = new (class extends AbstractStorage {})(localStorage);
});

afterEach(() => {
localStorage.clear();
storage = new (class extends AbstractStorage {})(new DictionaryStorageImpl());
});

test('should support basic crud operations', () => {
Expand Down Expand Up @@ -37,4 +34,60 @@ describe('Abstract storage', () => {
expectObservable(storage.watch('bar')).toBe('u--b-----', { u: undefined, b: 'b' });
});
});

test('should support scoped storage with no fallback', () => {
const globalDictionary = new DictionaryStorageImpl({ foo: 'bad-foo' });

const scopedStorage = new (class extends AbstractStorage {})(globalDictionary, {
scopeKey: 'test-scope'
});

expect(scopedStorage.get('foo')).toBeUndefined();
expect(scopedStorage.contains('foo')).toBe(false);
scopedStorage.set('foo', 'bar');
expect(scopedStorage.get('foo')).toBe('bar');
expect(globalDictionary.toJsonString()).toBe('{"foo":"bad-foo","test-scope":"{\\"foo\\":\\"bar\\"}"}');
expect(scopedStorage.contains('foo')).toBe(true);
scopedStorage.set('foo', 'baz');
expect(globalDictionary.toJsonString()).toBe('{"foo":"bad-foo","test-scope":"{\\"foo\\":\\"baz\\"}"}');
scopedStorage.set('a', 'b');
scopedStorage.delete('foo');
expect(globalDictionary.toJsonString()).toBe('{"foo":"bad-foo","test-scope":"{\\"a\\":\\"b\\"}"}');
});

test('should support scoped storage with readonly fallback', () => {
const globalDictionary = new DictionaryStorageImpl({ foo: 'original-foo' });

const scopedStorage = new (class extends AbstractStorage {})(globalDictionary, {
scopeKey: 'test-scope',
fallbackPolicy: 'read-only'
});

expect(scopedStorage.get('foo')).toBe('original-foo');
expect(scopedStorage.contains('foo')).toBe(true);
scopedStorage.set('foo', 'bar');
expect(scopedStorage.get('foo')).toBe('bar');
expect(globalDictionary.toJsonString()).toBe('{"foo":"original-foo","test-scope":"{\\"foo\\":\\"bar\\"}"}');
scopedStorage.delete('foo');
expect(scopedStorage.contains('foo')).toBe(true);
expect(scopedStorage.get('foo')).toBe('original-foo');
expect(globalDictionary.toJsonString()).toBe('{"foo":"original-foo","test-scope":"{}"}');
});

test('should migrate on read if configured', () => {
const globalDictionary = new DictionaryStorageImpl({ foo: 'original-foo' });

const scopedStorage = new (class extends AbstractStorage {})(globalDictionary, {
scopeKey: 'test-scope',
fallbackPolicy: 'read-and-migrate'
});

expect(scopedStorage.get('foo')).toBe('original-foo');
expect(scopedStorage.contains('foo')).toBe(true);
expect(globalDictionary.toJsonString()).toBe('{"test-scope":"{\\"foo\\":\\"original-foo\\"}"}');

scopedStorage.delete('foo');
expect(scopedStorage.contains('foo')).toBe(false);
expect(globalDictionary.toJsonString()).toBe('{"test-scope":"{}"}');
});
});
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
import { Observable, Subject } from 'rxjs';
import { distinctUntilChanged, filter, map, startWith } from 'rxjs/operators';
import { DictionaryStorageImpl } from './dictionary-storage-impl';

// A small abstraction on browser's storage for mocking and cleaning up the API a bit
export abstract class AbstractStorage {
private readonly changeSubject: Subject<string> = new Subject();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this redundant ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant with what? Not sure I follow - if you mean the type portion, we have a lint rule that requires all class members to have a declared type.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant, this changeSubject. I saw we're using changeSubject.next but apart from that this is not being used, or am I missing something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's used in watch - it's not visible by default in this PR because it didn't change (neither did the subject, just the line below it 😉 )

public constructor(private readonly storage: Storage) {}
private readonly scopedStorage?: DictionaryStorageImpl;

public constructor(private readonly storage: Storage, private readonly scopeConfig?: ScopedStorageConfiguration) {
if (scopeConfig) {
this.scopedStorage = DictionaryStorageImpl.fromString(storage.getItem(scopeConfig.scopeKey) ?? '{}');
}
}

public contains(key: string): boolean {
return this.storage.getItem(key) !== null;
return this.get(key) !== undefined;
}

public get<T extends string = string>(key: string): T | undefined {
const value = this.storage.getItem(key);
const value =
this.scopeConfig && !this.scopeConfig.fallbackPolicy
? this.scopedStorage!.getItem(key) // Only read from scoped storage if in use AND no fallback policy
: this.scopedStorage?.getItem(key) ?? this.storage.getItem(key);

if (
this.scopeConfig?.fallbackPolicy === 'read-and-migrate' &&
value !== null &&
this.scopedStorage?.getItem(key) === null
) {
this.set(key, value);
this.storage.removeItem(key);
}

return value !== null ? (value as T) : undefined;
}
Expand All @@ -26,12 +45,25 @@ export abstract class AbstractStorage {
}

public set(key: string, value: string): void {
this.storage.setItem(key, value);
(this.scopedStorage ?? this.storage).setItem(key, value);
this.flushAnyScopedStorage();
this.changeSubject.next(key);
}

public delete(key: string): void {
this.storage.removeItem(key);
(this.scopedStorage ?? this.storage).removeItem(key);
this.flushAnyScopedStorage();
this.changeSubject.next(key);
}

private flushAnyScopedStorage(): void {
if (this.scopedStorage && this.scopeConfig?.scopeKey) {
this.storage.setItem(this.scopeConfig.scopeKey, this.scopedStorage?.toJsonString());
}
}
}

export interface ScopedStorageConfiguration {
scopeKey: string;
fallbackPolicy?: 'read-only' | 'read-and-migrate';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { DictionaryStorageImpl } from './dictionary-storage-impl';

describe('Dictionary Storage impl', () => {
test('can read and write values', () => {
const storage = new DictionaryStorageImpl();

storage.setItem('foo', 'bar');
expect(storage.getItem('foo')).toBe('bar');
expect(storage.getItem('non-existent')).toBeNull();
storage.setItem('foo', 'bar-update');
expect(storage.getItem('foo')).toBe('bar-update');
});

test('can detect length', () => {
const storage = new DictionaryStorageImpl();
expect(storage.length).toBe(0);
storage.setItem('foo', 'bar');
expect(storage.length).toBe(1);

storage.setItem('a', 'b');
expect(storage.length).toBe(2);
storage.removeItem('a');
expect(storage.length).toBe(1);
storage.clear();
expect(storage.length).toBe(0);
});

test('can be initialized from a dictionary', () => {
const storage = new DictionaryStorageImpl({ foo: 'bar', a: 'b' });
storage.setItem('x', 'y');
expect(storage.length).toBe(3);

expect(storage.getItem('foo')).toBe('bar');
expect(storage.getItem('a')).toBe('b');
expect(storage.getItem('x')).toBe('y');
});

test('can serialize to and from JSON string', () => {
const storageFromDictionary = new DictionaryStorageImpl({ foo: 'bar', a: 'b' });
storageFromDictionary.setItem('c', 'd');
storageFromDictionary.removeItem('a');
expect(storageFromDictionary.toJsonString()).toBe('{"foo":"bar","c":"d"}');

const storageFromString = DictionaryStorageImpl.fromString('{"foo":"bar","a":"b"}');
expect(storageFromString.length).toBe(2);
expect(storageFromString.getItem('foo')).toBe('bar');
expect(storageFromString.getItem('a')).toBe('b');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Dictionary } from '../../types/types';

export class DictionaryStorageImpl implements Storage {
private readonly data: Dictionary<string>;

public static fromString(jsonString: string): DictionaryStorageImpl {
return new DictionaryStorageImpl(JSON.parse(jsonString));
}

public constructor(data: Dictionary<string> = {}) {
this.data = { ...data };
}

public get length(): number {
return Object.keys(this.data).length;
}

public clear(): void {
Object.keys(this.data).forEach(key => this.removeItem(key));
}

public getItem(key: string): string | null {
// tslint:disable-next-line: no-null-keyword
return this.data[key] ?? null;
}

public key(index: number): string | null {
// tslint:disable-next-line: no-null-keyword
return Object.keys(this.data)[index] ?? null;
}

public removeItem(key: string): void {
// tslint:disable-next-line:no-dynamic-delete
delete this.data[key];
}

public setItem(key: string, value: string): void {
this.data[key] = value;
}

public toJsonString(): string {
return JSON.stringify(this.data);
}
}
Original file line number Diff line number Diff line change
@@ -1,39 +1,10 @@
import { Injectable } from '@angular/core';
import { AbstractStorage } from './abstract-storage';
import { DictionaryStorageImpl } from './dictionary-storage-impl';

@Injectable({ providedIn: 'root' })
export class InMemoryStorage extends AbstractStorage {
public constructor() {
super(
new (class {
private readonly data: Map<string, string> = new Map();

public get length(): number {
return this.data.size;
}

public clear(): void {
this.data.clear();
}

public getItem(key: string): string | null {
// tslint:disable-next-line: no-null-keyword
return this.data.get(key) ?? null;
}

public key(index: number): string | null {
// tslint:disable-next-line: no-null-keyword
return Array.from(this.data.keys())[index] ?? null;
}

public removeItem(key: string): void {
this.data.delete(key);
}

public setItem(key: string, value: string): void {
this.data.set(key, value);
}
})()
);
super(new DictionaryStorageImpl());
}
}