Skip to content

Commit ee0762a

Browse files
feat: add storage scoping (#1517)
1 parent 5d2c2eb commit ee0762a

File tree

5 files changed

+190
-41
lines changed

5 files changed

+190
-41
lines changed

projects/common/src/utilities/browser/storage/abstract-storage.test.ts

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
import { runFakeRxjs } from '@hypertrace/test-utils';
22
import { AbstractStorage } from './abstract-storage';
3+
import { DictionaryStorageImpl } from './dictionary-storage-impl';
34

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

78
beforeEach(() => {
8-
storage = new (class extends AbstractStorage {})(localStorage);
9-
});
10-
11-
afterEach(() => {
12-
localStorage.clear();
9+
storage = new (class extends AbstractStorage {})(new DictionaryStorageImpl());
1310
});
1411

1512
test('should support basic crud operations', () => {
@@ -37,4 +34,60 @@ describe('Abstract storage', () => {
3734
expectObservable(storage.watch('bar')).toBe('u--b-----', { u: undefined, b: 'b' });
3835
});
3936
});
37+
38+
test('should support scoped storage with no fallback', () => {
39+
const globalDictionary = new DictionaryStorageImpl({ foo: 'bad-foo' });
40+
41+
const scopedStorage = new (class extends AbstractStorage {})(globalDictionary, {
42+
scopeKey: 'test-scope'
43+
});
44+
45+
expect(scopedStorage.get('foo')).toBeUndefined();
46+
expect(scopedStorage.contains('foo')).toBe(false);
47+
scopedStorage.set('foo', 'bar');
48+
expect(scopedStorage.get('foo')).toBe('bar');
49+
expect(globalDictionary.toJsonString()).toBe('{"foo":"bad-foo","test-scope":"{\\"foo\\":\\"bar\\"}"}');
50+
expect(scopedStorage.contains('foo')).toBe(true);
51+
scopedStorage.set('foo', 'baz');
52+
expect(globalDictionary.toJsonString()).toBe('{"foo":"bad-foo","test-scope":"{\\"foo\\":\\"baz\\"}"}');
53+
scopedStorage.set('a', 'b');
54+
scopedStorage.delete('foo');
55+
expect(globalDictionary.toJsonString()).toBe('{"foo":"bad-foo","test-scope":"{\\"a\\":\\"b\\"}"}');
56+
});
57+
58+
test('should support scoped storage with readonly fallback', () => {
59+
const globalDictionary = new DictionaryStorageImpl({ foo: 'original-foo' });
60+
61+
const scopedStorage = new (class extends AbstractStorage {})(globalDictionary, {
62+
scopeKey: 'test-scope',
63+
fallbackPolicy: 'read-only'
64+
});
65+
66+
expect(scopedStorage.get('foo')).toBe('original-foo');
67+
expect(scopedStorage.contains('foo')).toBe(true);
68+
scopedStorage.set('foo', 'bar');
69+
expect(scopedStorage.get('foo')).toBe('bar');
70+
expect(globalDictionary.toJsonString()).toBe('{"foo":"original-foo","test-scope":"{\\"foo\\":\\"bar\\"}"}');
71+
scopedStorage.delete('foo');
72+
expect(scopedStorage.contains('foo')).toBe(true);
73+
expect(scopedStorage.get('foo')).toBe('original-foo');
74+
expect(globalDictionary.toJsonString()).toBe('{"foo":"original-foo","test-scope":"{}"}');
75+
});
76+
77+
test('should migrate on read if configured', () => {
78+
const globalDictionary = new DictionaryStorageImpl({ foo: 'original-foo' });
79+
80+
const scopedStorage = new (class extends AbstractStorage {})(globalDictionary, {
81+
scopeKey: 'test-scope',
82+
fallbackPolicy: 'read-and-migrate'
83+
});
84+
85+
expect(scopedStorage.get('foo')).toBe('original-foo');
86+
expect(scopedStorage.contains('foo')).toBe(true);
87+
expect(globalDictionary.toJsonString()).toBe('{"test-scope":"{\\"foo\\":\\"original-foo\\"}"}');
88+
89+
scopedStorage.delete('foo');
90+
expect(scopedStorage.contains('foo')).toBe(false);
91+
expect(globalDictionary.toJsonString()).toBe('{"test-scope":"{}"}');
92+
});
4093
});

projects/common/src/utilities/browser/storage/abstract-storage.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,36 @@
11
import { Observable, Subject } from 'rxjs';
22
import { distinctUntilChanged, filter, map, startWith } from 'rxjs/operators';
3+
import { DictionaryStorageImpl } from './dictionary-storage-impl';
34

45
// A small abstraction on browser's storage for mocking and cleaning up the API a bit
56
export abstract class AbstractStorage {
67
private readonly changeSubject: Subject<string> = new Subject();
7-
public constructor(private readonly storage: Storage) {}
8+
private readonly scopedStorage?: DictionaryStorageImpl;
9+
10+
public constructor(private readonly storage: Storage, private readonly scopeConfig?: ScopedStorageConfiguration) {
11+
if (scopeConfig) {
12+
this.scopedStorage = DictionaryStorageImpl.fromString(storage.getItem(scopeConfig.scopeKey) ?? '{}');
13+
}
14+
}
815

916
public contains(key: string): boolean {
10-
return this.storage.getItem(key) !== null;
17+
return this.get(key) !== undefined;
1118
}
1219

1320
public get<T extends string = string>(key: string): T | undefined {
14-
const value = this.storage.getItem(key);
21+
const value =
22+
this.scopeConfig && !this.scopeConfig.fallbackPolicy
23+
? this.scopedStorage!.getItem(key) // Only read from scoped storage if in use AND no fallback policy
24+
: this.scopedStorage?.getItem(key) ?? this.storage.getItem(key);
25+
26+
if (
27+
this.scopeConfig?.fallbackPolicy === 'read-and-migrate' &&
28+
value !== null &&
29+
this.scopedStorage?.getItem(key) === null
30+
) {
31+
this.set(key, value);
32+
this.storage.removeItem(key);
33+
}
1534

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

2847
public set(key: string, value: string): void {
29-
this.storage.setItem(key, value);
48+
(this.scopedStorage ?? this.storage).setItem(key, value);
49+
this.flushAnyScopedStorage();
3050
this.changeSubject.next(key);
3151
}
3252

3353
public delete(key: string): void {
34-
this.storage.removeItem(key);
54+
(this.scopedStorage ?? this.storage).removeItem(key);
55+
this.flushAnyScopedStorage();
3556
this.changeSubject.next(key);
3657
}
58+
59+
private flushAnyScopedStorage(): void {
60+
if (this.scopedStorage && this.scopeConfig?.scopeKey) {
61+
this.storage.setItem(this.scopeConfig.scopeKey, this.scopedStorage?.toJsonString());
62+
}
63+
}
64+
}
65+
66+
export interface ScopedStorageConfiguration {
67+
scopeKey: string;
68+
fallbackPolicy?: 'read-only' | 'read-and-migrate';
3769
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { DictionaryStorageImpl } from './dictionary-storage-impl';
2+
3+
describe('Dictionary Storage impl', () => {
4+
test('can read and write values', () => {
5+
const storage = new DictionaryStorageImpl();
6+
7+
storage.setItem('foo', 'bar');
8+
expect(storage.getItem('foo')).toBe('bar');
9+
expect(storage.getItem('non-existent')).toBeNull();
10+
storage.setItem('foo', 'bar-update');
11+
expect(storage.getItem('foo')).toBe('bar-update');
12+
});
13+
14+
test('can detect length', () => {
15+
const storage = new DictionaryStorageImpl();
16+
expect(storage.length).toBe(0);
17+
storage.setItem('foo', 'bar');
18+
expect(storage.length).toBe(1);
19+
20+
storage.setItem('a', 'b');
21+
expect(storage.length).toBe(2);
22+
storage.removeItem('a');
23+
expect(storage.length).toBe(1);
24+
storage.clear();
25+
expect(storage.length).toBe(0);
26+
});
27+
28+
test('can be initialized from a dictionary', () => {
29+
const storage = new DictionaryStorageImpl({ foo: 'bar', a: 'b' });
30+
storage.setItem('x', 'y');
31+
expect(storage.length).toBe(3);
32+
33+
expect(storage.getItem('foo')).toBe('bar');
34+
expect(storage.getItem('a')).toBe('b');
35+
expect(storage.getItem('x')).toBe('y');
36+
});
37+
38+
test('can serialize to and from JSON string', () => {
39+
const storageFromDictionary = new DictionaryStorageImpl({ foo: 'bar', a: 'b' });
40+
storageFromDictionary.setItem('c', 'd');
41+
storageFromDictionary.removeItem('a');
42+
expect(storageFromDictionary.toJsonString()).toBe('{"foo":"bar","c":"d"}');
43+
44+
const storageFromString = DictionaryStorageImpl.fromString('{"foo":"bar","a":"b"}');
45+
expect(storageFromString.length).toBe(2);
46+
expect(storageFromString.getItem('foo')).toBe('bar');
47+
expect(storageFromString.getItem('a')).toBe('b');
48+
});
49+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Dictionary } from '../../types/types';
2+
3+
export class DictionaryStorageImpl implements Storage {
4+
private readonly data: Dictionary<string>;
5+
6+
public static fromString(jsonString: string): DictionaryStorageImpl {
7+
return new DictionaryStorageImpl(JSON.parse(jsonString));
8+
}
9+
10+
public constructor(data: Dictionary<string> = {}) {
11+
this.data = { ...data };
12+
}
13+
14+
public get length(): number {
15+
return Object.keys(this.data).length;
16+
}
17+
18+
public clear(): void {
19+
Object.keys(this.data).forEach(key => this.removeItem(key));
20+
}
21+
22+
public getItem(key: string): string | null {
23+
// tslint:disable-next-line: no-null-keyword
24+
return this.data[key] ?? null;
25+
}
26+
27+
public key(index: number): string | null {
28+
// tslint:disable-next-line: no-null-keyword
29+
return Object.keys(this.data)[index] ?? null;
30+
}
31+
32+
public removeItem(key: string): void {
33+
// tslint:disable-next-line:no-dynamic-delete
34+
delete this.data[key];
35+
}
36+
37+
public setItem(key: string, value: string): void {
38+
this.data[key] = value;
39+
}
40+
41+
public toJsonString(): string {
42+
return JSON.stringify(this.data);
43+
}
44+
}
Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,10 @@
11
import { Injectable } from '@angular/core';
22
import { AbstractStorage } from './abstract-storage';
3+
import { DictionaryStorageImpl } from './dictionary-storage-impl';
34

45
@Injectable({ providedIn: 'root' })
56
export class InMemoryStorage extends AbstractStorage {
67
public constructor() {
7-
super(
8-
new (class {
9-
private readonly data: Map<string, string> = new Map();
10-
11-
public get length(): number {
12-
return this.data.size;
13-
}
14-
15-
public clear(): void {
16-
this.data.clear();
17-
}
18-
19-
public getItem(key: string): string | null {
20-
// tslint:disable-next-line: no-null-keyword
21-
return this.data.get(key) ?? null;
22-
}
23-
24-
public key(index: number): string | null {
25-
// tslint:disable-next-line: no-null-keyword
26-
return Array.from(this.data.keys())[index] ?? null;
27-
}
28-
29-
public removeItem(key: string): void {
30-
this.data.delete(key);
31-
}
32-
33-
public setItem(key: string, value: string): void {
34-
this.data.set(key, value);
35-
}
36-
})()
37-
);
8+
super(new DictionaryStorageImpl());
389
}
3910
}

0 commit comments

Comments
 (0)