add entity store class (#70)

This commit is contained in:
Kilin Mikhail
2021-01-14 11:45:46 +03:00
committed by GitHub
parent 555f68ebc6
commit 5078a2cf4b
13 changed files with 4515 additions and 4074 deletions

8108
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@
"@material-ui/icons": "^4.9.1",
"@most/adapter": "^1.0.0",
"@most/core": "^1.6.1",
"@most/hold": "^4.1.0",
"@most/scheduler": "^1.3.0",
"axios": "^0.21.0",
"convert-layout": "^0.8.2",

View File

@ -1,7 +1,7 @@
import {makeApi} from '_utils/makeApi';
import {http} from '_infrastructure/Http';
type User = {
export type User = {
id: number;
avatar: string;
email: string;

View File

@ -1,7 +1,7 @@
import {v4} from 'uuid';
import {TaskStatus} from '_enums/common';
import {Task} from '_types/common';
import {createService} from '_utils/createService';
import {createStore} from '_utils/createStore';
import {makeLocalStorageService} from './LocalStorageService';
const TASK_STORAGE_NAME = 'FYB_TASK_STORAGE';
@ -32,5 +32,5 @@ const INIT_TASKS: Task[] = [
const taskListService = makeLocalStorageService(INIT_TASKS, TASK_STORAGE_NAME);
export const tasksService = createService(taskListService.get(), {
export const tasksService = createStore(taskListService.get(), {
});

View File

@ -0,0 +1,4 @@
import {RemoteData} from '@devexperts/remote-data-ts';
import {Stream} from '@most/types';
export type LiveData<A, B> = Stream<RemoteData<A, B>>;

View File

@ -1,8 +1,9 @@
import React, {ReactNode} from 'react';
import {RemoteData, fold, map as remoteDateMap} from '@devexperts/remote-data-ts';
import {RemoteData, fold, map as remoteDataMap, isSuccess} from '@devexperts/remote-data-ts';
import {Stream} from '@most/types';
import {map} from '@most/core';
import {chain, map, now, tap} from '@most/core';
import {pipe} from 'fp-ts/lib/pipeable';
import {LiveData} from '_types/LiveData';
export const renderAsyncData = <E, A>(
data: RemoteData<E, A>,
@ -20,7 +21,32 @@ export const mapRD = <E, A, R>(mapper: (val: A) => R) => {
return (stream$: Stream<RemoteData<E, A>>): Stream<RemoteData<E, R>> => {
return pipe(
stream$,
map(val => remoteDateMap(mapper)(val))
map(val => remoteDataMap(mapper)(val))
);
};
};
export const tapRD = <E, A>(mapper: (val: A) => void) => {
return (stream$: Stream<RemoteData<E, A>>) => {
return pipe(
stream$,
tap(val => remoteDataMap(mapper)(val))
);
};
};
export const chainRD = <E, A, R>(chainer: (val: A) => Stream<RemoteData<E, R>>) => {
return (stream$: Stream<RemoteData<E, A>>) => {
return pipe(
stream$,
chain(rVal => {
if (isSuccess(rVal)) {
return chainer(rVal.value);
}
const res: LiveData<E, R> = now(rVal);
return res;
})
);
};
};

View File

@ -13,7 +13,7 @@ type ServiceActions<State, T extends Record<string, ServiceAction<State, unknown
};
// eslint-disable-next-line
export const createService = <State, Actions extends Record<string, ServiceAction<State, any>>>(
export const createStore = <State, Actions extends Record<string, ServiceAction<State, any>>>(
initData: State,
actions: Actions
) => {

View File

@ -0,0 +1,23 @@
import {createAdapter} from '@most/adapter';
import {Stream} from '@most/types';
import {hold} from '@most/hold';
export type Subject<T> = {
stream$: Stream<T>;
next: (val: T) => void;
getValue: () => T;
}
export const createSubject = <T>(data: T): Subject<T> => {
let cache = data;
const [handler, stream$] = createAdapter<T>();
return {
next: (val: T) => {
cache = val;
handler(val);
},
stream$: hold(stream$),
getValue: () => cache
};
};

View File

@ -0,0 +1,149 @@
import {RemoteData, remoteData, isSuccess, success} from '@devexperts/remote-data-ts';
import {filter, map, skipRepeats, chain, tap, now} from '@most/core';
import {hold} from '@most/hold';
import {newDefaultScheduler} from '@most/scheduler';
import {array} from 'fp-ts/lib/Array';
import {Predicate} from 'fp-ts/lib/function';
import {pipe} from 'fp-ts/pipeable';
import {noop} from 'lodash';
import {isNotEmpty} from '_referers/common';
import {LiveData} from '_types/LiveData';
import {chainRD, mapRD, tapRD} from './asyncDataUtils';
import {StreamMap} from './streamMap';
export class EntityStore<L = never, A = never> {
get allValues$() {
return this._getAllValues$;
}
set allValues$(value: any) {
this._getAllValues$ = value;
}
private readonly cache = new StreamMap<string, RemoteData<L, A>>();
private readonly cachedStreams = new Map<string, LiveData<L, A>>();
private hasLoadedAll = false;
private isLoadingAll = false;
private _getAllValues$ = pipe(
this.cache.values$,
filter(() => !this.isLoadingAll && this.hasLoadedAll),
map(data => data.filter(item => isSuccess(item))),
map(array.sequence(remoteData)),
skipRepeats,
hold
);
readonly keys$ = this.cache.keys$;
get(key: string, get: () => LiveData<L, A>): LiveData<L, A> {
let sharedGetter = this.cachedStreams.get(key);
if (!isNotEmpty(sharedGetter)) {
const hasValue = this.cache.has(key);
const cachedValue = this.cache.getValue(key);
const valueIsResolved = isNotEmpty(cachedValue) && isSuccess(cachedValue);
if (hasValue && valueIsResolved) {
return this.cache.get(key);
}
sharedGetter = pipe(get(), hold);
this.cachedStreams.set(key, sharedGetter);
}
return sharedGetter;
}
getAll(
personalKey: (value: A) => string,
partialGetAll: () => LiveData<L, A[]>,
predicate?: Predicate<A>
): LiveData<L, A[]> {
this.isLoadingAll = false;
return pipe(
partialGetAll(),
tapRD(values => {
this.hasLoadedAll = true;
this.updateCache(values, personalKey);
}),
chain(data => {
this.cache.values$.run(
{
event: noop,
end: noop,
error: noop
},
newDefaultScheduler()
);
return isSuccess(data) ? this._getAllValues$ : now(data);
}),
skipRepeats,
mapRD(entities => {
if (typeof predicate === 'undefined') {
return entities;
}
let hasChanges = false;
const filtered = entities.filter(value => {
const result = predicate(value);
if (!result) {
hasChanges = true;
}
return result;
});
return hasChanges ? filtered : entities;
}),
hold
);
}
remove(
key: string,
pk: (value: A) => string,
remove: () => LiveData<L, A[]>,
optimistic = true
): LiveData<L, A[]> {
if (optimistic) {
this.cache.delete(key);
}
return pipe(
remove(),
tapRD(values => {
this.updateCache(values, pk);
}),
chain(() => this._getAllValues$)
);
}
create(personalKey: (value: A) => string, create: () => LiveData<L, A>): LiveData<L, A> {
return pipe(
create(),
chainRD(value => {
const key = personalKey(value);
this.cache.set(key, success(value));
return this.cache.get(key);
})
);
}
update(key: string, update: () => LiveData<L, A>): LiveData<L, A> {
return pipe(
update(),
tap(value => {
if (isSuccess(value)) {
this.cache.set(key, value);
}
})
);
}
private updateCache(values: A[], pk: (value: A) => string): void {
const entries = values.map<[string, RemoteData<L, A>]>(item => [pk(item), success(item)]);
this.cache.setMany(entries);
}
}

185
src/core/utils/streamMap.ts Normal file
View File

@ -0,0 +1,185 @@
import {skipRepeats, filter, map} from '@most/core';
import {hold} from '@most/hold';
import {Stream} from '@most/types';
import {pipe} from 'fp-ts/pipeable';
import {createSubject, Subject} from '_utils/createSubject';
import {isNotEmpty} from '../referers/common';
type UninitializedEntity<V> = {
hasValue: false;
subject: Subject<V | undefined>;
stream: Stream<V>;
};
type InitializedEntity<V> = {
hasValue: true;
subject: Subject<V>;
stream: Stream<V>;
};
type StreamMapEntity<V> = UninitializedEntity<V> | InitializedEntity<V>;
type InitializedEntry<K, V> = [K, InitializedEntity<V>];
type StreamMapEntry<K, V> = [K, StreamMapEntity<V>];
export class StreamMap<K, V> {
private cache = new Map<K, StreamMapEntity<V>>();
private allSubject$ = createSubject<void>(undefined);
private isInTransaction = false;
private hasChanges = false;
private _keys$ = createSubject<K[]>([]);
readonly keys$ = this._keys$.stream$;
readonly values$ = pipe(this.allSubject$.stream$,
map(() => {
const values = Array.from(this.cache.values());
return values.filter(isInitialized).map(entity => entity.subject.getValue());
}),
);
readonly entries$: Stream<[K, V][]> = pipe(
this.allSubject$.stream$,
map(() => {
const entries = Array.from(this.cache.entries()).filter(isEntryInitialized);
return entries.map<[K, V]>(entry => [entry[0], entry[1].subject.getValue()]);
}),
hold,
);
get size(): number {
return this.cache.size;
}
get isEmpty(): boolean {
return this.size === 0;
}
private handleKeys = (keys: K[]) => this._keys$.next(keys);
has(key: K): boolean {
return this.cache.has(key);
}
get(key: K): Stream<V> {
return this.getOrCreateCached(key).stream;
}
getValue(key: K): V | undefined {
if (this.cache.has(key)) {
const value = this.cache.get(key);
if (isNotEmpty(value) && value.hasValue) {
return value.subject.getValue();
}
}
}
set(key: K, value: V): void {
this.transaction(() => {
const isCachedKey = this.cache.has(key);
let cached = this.getOrCreateCached(key);
if (cached.hasValue === false) {
cached = initializeEntity(cached);
this.cache.set(key, cached);
if (!isCachedKey) {
this.handleKeys(Array.from(this.cache.keys()));
}
}
if (cached.subject.getValue() !== value) {
cached.subject.next(value);
this.hasChanges = true;
}
});
}
setMany(entries: [K, V][]): void {
this.transaction(() => {
entries.forEach(entry => {
const [key, value] = entry;
this.set(key, value);
});
});
}
transaction(thunk: () => void): void {
if (!this.isInTransaction) {
this.isInTransaction = true;
thunk();
if (this.hasChanges) {
this.hasChanges = false;
this.allSubject$.next(undefined);
}
this.isInTransaction = false;
} else {
// Execute the thunk, notifications will be handled by parent transaction
thunk();
}
}
delete(key: K): void {
this.transaction(() => {
const isCachedKey = this.cache.has(key);
this.cache.delete(key);
if (isCachedKey) {
this.handleKeys(Array.from(this.cache.keys()));
}
this.hasChanges = true;
});
}
deleteMany(keys: K[]): void {
this.transaction(() => {
keys.forEach(key => {
this.delete(key);
});
});
}
clear(): void {
this.transaction(() => {
this.cache.clear();
this.handleKeys(Array.from(this.cache.keys()));
this.hasChanges = true;
});
}
private getOrCreateCached(key: K): StreamMapEntity<V> {
const cached = this.cache.get(key);
if (cached) {
return cached;
}
const subject = createSubject<V | undefined>(undefined);
const stream = pipe(subject.stream$, filter(isNotEmpty), skipRepeats);
const entity: StreamMapEntity<V> = {
hasValue: false,
subject,
stream,
};
this.cache.set(key, entity);
return entity;
}
}
function initializeEntity<V>(entity: UninitializedEntity<V>): InitializedEntity<V> {
return {
...entity,
subject: entity.subject as Subject<V>,
hasValue: true,
};
}
function isInitialized<V>(entity: StreamMapEntity<V>): entity is InitializedEntity<V> {
return entity.hasValue;
}
function isEntryInitialized<K, V>(entry: StreamMapEntry<K, V>): entry is InitializedEntry<K, V> {
return isInitialized(entry[1]);
}

View File

@ -1,23 +1,27 @@
import React, {memo} from 'react';
import {Link} from 'react-router-dom';
import React, {Fragment, memo} from 'react';
import {usersApi} from '_api/usersTestApi';
import {useStream} from '_utils/useStream';
const userList$ = usersApi.request();
import UserComponent from './User';
const Page: React.FC = () => {
const users = useStream(() => userList$, []);
const users = useStream(() => usersApi.request(), []);
return (
<div>
tags
{users?.map(user => (
<div key={user.id}>
{user.first_name}, {user.last_name}
<span><Link to={`/tags/${user.id}`}> More info...</Link></span>
</div>
))}
</div>
<Fragment>
<div>
tags
{users?.map(user => (
<UserComponent userId={user.id} key={user.id}/>
))}
</div>
<div>
tags
{users?.map(user => (
<UserComponent userId={user.id} key={user.id}/>
))}
</div>
</Fragment>
);
};

View File

@ -0,0 +1,43 @@
import {pending, success} from '@devexperts/remote-data-ts';
import {map} from '@most/core';
import {pipe} from 'fp-ts/lib/function';
import React, {FC, Fragment, memo} from 'react';
import {Link} from 'react-router-dom';
import {usersApi} from '_api/usersTestApi';
import {renderAsyncData} from '_utils/asyncDataUtils';
import {useStream} from '_utils/useStream';
import {userEntityStore} from './utils';
type Props = {
userId: number;
};
const User: FC<Props> = ({userId}) => {
const data =
useStream(() => {
const userStringId = userId.toString();
return userEntityStore.get(userStringId, () =>
pipe(
usersApi.findById(userStringId),
map(val => success(val))
)
);
}, [userId]) ?? pending;
return (
<Fragment>
{renderAsyncData(data, user => (
<div key={user.id}>
{user.first_name}, {user.last_name}
<span>
<Link to={`/tags/${user.id}`}> More info...</Link>
</span>
</div>
))}
</Fragment>
);
};
export default memo(User);

View File

@ -0,0 +1,4 @@
import {User} from '_api/usersTestApi';
import {EntityStore} from '_utils/entity-store';
export const userEntityStore = new EntityStore<Error, User>();