Skip to content

Instantly share code, notes, and snippets.

@YBogomolov
Last active November 13, 2020 17:35
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save YBogomolov/bacbb3f3acc69bf6c3f8103520ee7765 to your computer and use it in GitHub Desktop.
Save YBogomolov/bacbb3f3acc69bf6c3f8103520ee7765 to your computer and use it in GitHub Desktop.
Tagless Final-encoded algebras for sync/async browser storages

Постановка задачи

Изначально задачу я сформулировал так — предоставить возможность программисту, который будет писать модуль, возможность работать с браузерным хранилищем, причём так, чтобы он не знал, с чем именно будет работать его модуль в рантайме — с sessionStorage, localStorage, IndexedDB, WebSQL или еще какой приблудой.

Для этого я решил воспользоваться паттерном Tagless Final (он же Finally Tagless Interpreters), описанным Олегом Киселёвым в серии одноимённых пейперов. Сам модуль реализован как Reader, принимающий в качестве среды некие Options<M>, содержащие, в том числе, и поле storage типа StorageAlgebra<M> & Monad<M>. Таким образом, программист модуля сможет выстраивать цепочки монадических операций, а уже внешняя среда, запускающая модуль, будет знать, с чем она работает, и запускать эти цепочки на выполнение. Например, в тестовой среде можно будет использовать простой Either<L, R> для запуска тестов синхронно; в случае использования sessionStorage/localStorage — IOEither<L, R>, а уж если мы размахнулись на IndexedDB/WebSQL/REST/GraphQL/whatever — тогда можно и TaskEither<L, R> применить. Код модуля при этом менять не придется, разумеется.

Но я не учёл одной простой, если не сказать «прописной», истины. Как архитектор, я настаиваю на том, чтобы работа с хранилищем происходила в redux-saga, который сам по себе асинхронный. Так что все мои приседания не имели большого смысла, и можно было ограничиться простым localForage.

Тем не менее, ниже приведен код моего решения — чисто как умозрительное упражнение.

import { HKT2, Type2, URIS2 } from 'fp-ts/lib/HKT';
export interface StorageAlgebra2<M extends URIS2> {
getItem<TData>(key: string): Type2<M, Error, TData>;
setItem<TData>(key: string, data: TData): Type2<M, Error, void>;
removeItem(key: string): Type2<M, Error, void>;
}
export interface StorageAlgebra<M> {
getItem<TData>(key: string): HKT2<M, Error, TData>;
setItem<TData>(key: string, data: TData): HKT2<M, Error, void>;
removeItem(key: string): HKT2<M, Error, void>;
}
import { toString } from 'fp-ts/lib/function';
import { URI as IOEitherURI, IOEither, fromEither as fromEitherIO, fromLeft as fromLeftIO } from 'fp-ts/lib/IOEither';
import { right } from 'fp-ts/lib/Either';
import { StorageAlgebra, StorageAlgebra2 } from './StorageAlgebra';
export interface IOStorageAlgebra extends StorageAlgebra2<IOEitherURI> {}
export abstract class IOStorageAlgebra implements StorageAlgebra<IOEitherURI> {
constructor(protected readonly storage: Storage) { }
public getItem<TData>(key: string): IOEither<Error, TData> {
const item = this.storage.getItem(key);
if (item != null) {
return fromEitherIO(right(JSON.parse(item) as TData));
}
return fromLeftIO(new Error('Not found'));
}
public setItem<TData>(key: string, data: TData): IOEither<Error, void> {
try {
this.storage.setItem(key, toString(data));
return fromEitherIO(right(undefined));
} catch (error) {
return fromLeftIO(error);
}
}
public removeItem(key: string): IOEither<Error, void> {
try {
this.storage.removeItem(key);
return fromEitherIO(right(undefined));
} catch (error) {
return fromLeftIO(error);
}
}
}
import 'localforage';
import { toString } from 'fp-ts/lib/function';
import { TaskEither, URI as TaskEitherURI } from 'fp-ts/lib/TaskEither';
import { right, left } from 'fp-ts/lib/Either';
import { StorageAlgebra, StorageAlgebra2 } from './StorageAlgebra';
import { Task } from 'fp-ts/lib/Task';
export interface TaskStorageAlgebra extends StorageAlgebra2<TaskEitherURI> {}
export abstract class TaskStorageAlgebra implements StorageAlgebra<TaskEitherURI> {
constructor(protected readonly storage: LocalForage) { }
public getItem<TData>(key: string): TaskEither<Error, TData> {
return new TaskEither(new Task(async () => {
const data = await this.storage.getItem<TData>(key);
if (data != null) {
return right<Error, TData>(data);
}
return left<Error, TData>(new Error('Not found'));
}));
}
public setItem<TData>(key: string, data: TData): TaskEither<Error, void> {
return new TaskEither(new Task(async () => {
try {
await this.storage.setItem(key, toString(data));
return right<Error, void>(undefined);
} catch (error) {
return left<Error, void>(error);
}
}));
}
public removeItem(key: string): TaskEither<Error, void> {
return new TaskEither(new Task(async () => {
try {
this.storage.removeItem(key);
return right<Error, void>(undefined);
} catch (error) {
return left<Error, void>(error);
}
}));
}
}
import { URI, ioEither } from 'fp-ts/lib/IOEither';
import { Monad2 } from 'fp-ts/lib/Monad';
import { IOStorageAlgebra } from './IOStorageAlgebra';
import { StorageAlgebra2 } from './StorageAlgebra';
export class SessionStorageAlgebra extends IOStorageAlgebra {
constructor(protected readonly storage: Storage = sessionStorage) {
super(storage);
}
}
const ssa = new SessionStorageAlgebra();
export const ssaMonad: StorageAlgebra2<URI> & Monad2<URI> = {
...ioEither,
getItem: ssa.getItem,
setItem: ssa.setItem,
removeItem: ssa.removeItem
};
import { ioEither, URI } from 'fp-ts/lib/IOEither';
import { Monad2 } from 'fp-ts/lib/Monad';
import { StorageAlgebra2 } from './StorageAlgebra';
import { IOStorageAlgebra } from './IOStorageAlgebra';
export class LocalStorageAlgebra extends IOStorageAlgebra implements IOStorageAlgebra {
constructor(protected readonly storage: Storage = localStorage) {
super(storage);
}
}
const lsa = new LocalStorageAlgebra();
export const lsaMonad: StorageAlgebra2<URI> & Monad2<URI> = {
...ioEither,
getItem: lsa.getItem,
setItem: lsa.setItem,
removeItem: lsa.removeItem
};
import * as localForage from 'localforage';
import { taskEither, URI } from 'fp-ts/lib/TaskEither';
import { Monad2 } from 'fp-ts/lib/Monad';
import { StorageAlgebra2 } from './StorageAlgebra';
import { TaskStorageAlgebra } from './TaskStorageAlgebra';
export class LocalForageAlgebra extends TaskStorageAlgebra {
constructor(protected readonly storage: LocalForage = localForage) {
super(storage);
}
}
const lfa = new LocalForageAlgebra();
export const lfaMonad: StorageAlgebra2<URI> & Monad2<URI> = {
...taskEither,
getItem: lfa.getItem,
setItem: lfa.setItem,
removeItem: lfa.removeItem
};
import * as localForage from 'localforage';
import { LocalForageAlgebra } from './LocalForageAlgebra';
describe('LocalForage algebra', () => {
it('should get item', (done) => {
const storageMock: LocalForage = {
...localForage,
getItem: async <T>() => 42 as unknown as T,
};
const algebra = new LocalForageAlgebra(storageMock);
algebra.getItem<number>('any').fold(
(e) => done(e),
(a: number) => {
expect(a).toEqual(42);
done();
}
).run();
});
it('should set item', (done) => {
const storageMock: LocalForage = {
...localForage,
setItem: async <T>(key: string, item: T) => {
expect(key).toEqual('key');
expect(item).toEqual('42');
expect(typeof item).toEqual('string');
return done() as unknown as T;
}
};
const algebra = new LocalForageAlgebra(storageMock);
algebra.setItem('key', 42).run();
});
it('should remove item', (done) => {
const storageMock: LocalForage = {
...localForage,
removeItem: async (key) => {
expect(key).toEqual('key');
done();
},
};
const algebra = new LocalForageAlgebra(storageMock);
algebra.removeItem('key').run();
});
});
it('complex example of module usage', () => {
type Requirements = 'thingy' | 'another';
type Capabilities = 'user';
const completeModule = createModule<IOEitherURI, Requirements, Capabilities>(
(opts: Options2<IOEitherURI>) => ({
name: 'My module',
description: 'This is my module',
reducers: ({
thingy: (state = {}, { payload }) => ({
...state,
number: payload
}),
another: (state = {}, { payload }) => ({
...state,
another: payload
}),
user: (state = {}, { payload }) => ({
...state,
id: payload
})
}),
sagas: [
takeLatest('*', function*() {
const result = yield opts.fetcher<Error, string>('http://never.none');
yield opts.storage.setItem('foo', result).chain(
() => opts.storage.getItem<number>('bar')
).chain(
(bar: number) => opts.storage.of(`This is bar: ${bar}`)
); // => will yield `This is bar: 42`, because why not 42?
})
]
})
).run({
...defaultOptions,
storage: lsaMonad
});
const constant = completeModule.createSyncConstant('GET_DATA');
const action = completeModule.createAction(constant)(42);
expect(completeModule.rootSaga).toBeDefined();
expect(completeModule.rootReducer).toBeDefined();
expect(completeModule.rootReducer!({}, action)).toEqual({
thingy: { number: 42 },
another: { another: 42 },
user: { id: 42 }
});
});

Бонус для самых усидчивых

Чего я еще не учёл — монадические вычисления нужно будет запускать внутри саг. То есть нужно саге как-то сообщать способ запуска монады, проще говоря. Для этого я подумал, что можно в options передавать еще некий actuator — функцию, которая будет принимать Monad<T>, а возвращать T. Но это обязало бы программиста модуля его корректно использовать, а это дополнительные приседания, которые не всем по вкусу.

P.S. Подписывайтесь на t.me//nohomofp и t.me//randomstuffilike <3

@Xotabu4
Copy link

Xotabu4 commented Dec 7, 2018

Ничего не понял потому что тупой, но прочитал с удовольствием

@pesterev
Copy link

pesterev commented Dec 7, 2018

Ща бы пытаться реализовать Tagless Final на языке в котором нет HKT

@YBogomolov
Copy link
Author

Ща бы пытаться реализовать Tagless Final на языке в котором нет HKT

Порой условия среды ограничивают в выборе инструмента. К сожалению, притащить в проект PureScript или GHCJS мне никто не дал и не даст.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment