https://github.com/buildo/avenger
A CQRS-flavoured data fetching and caching layer in TypeScript. Batching, caching, data-dependencies and manual invalidations in a declarative fashion for Node and the browser
https://github.com/buildo/avenger
Last synced: about 1 year ago
JSON representation
A CQRS-flavoured data fetching and caching layer in TypeScript. Batching, caching, data-dependencies and manual invalidations in a declarative fashion for Node and the browser
- Host: GitHub
- URL: https://github.com/buildo/avenger
- Owner: buildo
- License: mit
- Created: 2015-06-08T15:50:57.000Z (about 11 years ago)
- Default Branch: master
- Last Pushed: 2021-11-05T11:51:25.000Z (almost 5 years ago)
- Last Synced: 2023-04-10T01:34:31.451Z (over 3 years ago)
- Language: TypeScript
- Homepage:
- Size: 1.13 MB
- Stars: 59
- Watchers: 13
- Forks: 1
- Open Issues: 12
-
Metadata Files:
- Readme: README.md
- License: LICENSE.txt
Awesome Lists containing this project
README
Avenger is a data fetching and caching layer written in TypeScript. Its API is designed to mirror the principles of **Command Query Responsibility Segregation** and facilitate their adoption (if you are new to the concept you can get a grasp of its foundations in [this nice article](https://martinfowler.com/bliki/CQRS.html) by Martin Fowler).
Building user interfaces is a complex task, mainly because of its `IO` intensive nature. Reads (**queries**) and updates (**commands**) toward "external" data sources are ubiquitous and difficult to orchestrate, but _orchestration_ is not the only challenge a UI developer faces, _performance_ and _scalability_ are also key aspects of good design.
We believe that an _effective and powerful abstraction to handle caching and synchronization of external data in a declarative way_ is of fundamental importance when designing a solid user interface.
This is what **Avenger** aims to be: an abstraction layer over external data that handles caching and synchronization for you:

By separating how we fetch external data and how we update it we are able to state in a very declarative and _natural_ way the correct lifecycle of that data:
```tsx
import { queryStrict, command } from 'avenger';
// define a cached query, with strategy "available" (more about this later)
const user = queryStrict((id: string) => API.fetchUser(id), available);
// define a command that invalidates the previous query
const updateUsername = command(
(patch: Partial) => API.updateUser(patch),
{ user }
);
// declare it for usage in a React component
import { pipe } from 'fp-ts/lib/pipeable';
import * as QR from 'avenger/lib/QueryResult';
import { declareQueries } from 'avenger/lib/react';
const queries = declareQueries({ user });
const Username = queries(props => (
{pipe(
props.queries,
QR.fold(
() => 'loading...',
() => 'error while retrieving user',
queries => (
)
)
)}
));
// render the component
;
```
# Avenger
At the very heart of Avenger's DSL there are two constructors: **query** and **command**.
## queries
The [**`query`**](#query) function allows you to query your data source and get an object of type [**`CachedQuery`**](#CachedQuery) in return.
It accepts two parameters: the first is a function with a [**`Fetch`**](#Fetch) signature that is used to retrieve data from your data source; the second is an object with the [**`Strategy`**](#Strategy) signature that will be used to decide if the data stored by **Avenger** is still relevant or needs to be refetched.
Although important, `query` is a pretty low-level API and **Avenger** offers some convenient utils with a [**`StrategyBuilder`**](#StrategyBuilder) signature that you should prefer over it (unless you have very specific needs):
- **refetch:** runs the fetch function every time the data is requested (unless there's an ongoing pending request, which is always reused).
- **expire:** when the data is requested, the fetch function is run only if data in the `Cache` is older than the expiration defined, otherwise the cached value is used.
- **available:** when the data is requested, if a cached value is available it is always returned, otherwise the fetch function is run and the result stored in the `Cache` accordingly.
All these utils ask you to pass custom [**`Eq`**](https://github.com/gcanti/fp-ts/blob/master/docs/modules/Eq.ts.md) instances as arguments; they will be used to check if a value for an input combination is already present in one of the `Cache`'s keys (if the check is successful `Avenger` will try to use that value, otherwise it will resort to the `Fetch` function).
You can (and should) use these utils together with one of the built-in implementations that automatically take care of passing by the needed `Eq`s:
- **queryShallow:** will use an `Eq` instance that performs a shallow equality check to compare inputs.
- **queryStrict:** will use an `Eq` instance that performs a strict equality check to compare inputs.
Some examples will help clarify:
```ts
/*
this implementation will always re-run the `Fetch` function
even if valid cached data is already present
and use shallow equality to compare input
*/
const myQuery = queryShallow(fetchFunction, refetch);
/*
this implementation will never run the `Fetch` function
unless no valid data is present in the Cache
and use strict equality to compare input
*/
const myQuery = queryStrict(fetchFunction, available);
/*
this implementation will run the `Fetch` function only if no valid data is present in the Cache
or t > 10000 ms passed till the last time data was fetched
and use strict equality to compare input
*/
const myQuery = queryStrict(fetchFunction, expire(10000));
```
Each time the `Fetch` function is run with some `input`, those same `input` is used as a `key` to store the result obtained:
```
// usersCache is empty
usersCache: {}
//a user is fetched
getUser({ userId: 1 }) -> { userName: "Mario" }
// usersCache is now populated
usersCache: {
[{ userId: 1 }]: { userName: Mario }
}
```
From that moment onwards, when **Avenger** will need to decide if the data in our [**`Cache`**](#Cache) is present and still valid it will:
1. attempt to retrieve data from the [**`Cache`**](#Cache)
2. match the result against the cache strategy defined (for instance if we chose `refetch` the data will always be deemed invalid irrespective of the result).
If a valid result is found it is used without further actions, otherwise the `Fetch` function will be re-run in order to get valid data. The two flows are relatively simple:
##### Valid CacheValue

##### Invalid CacheValue
when you call `run` or `subscribe` on a `query` with a combination of `inputs` that was never used before (or whose last use ended up with a `Failure`), avenger will try to run the `Fetch` function resulting in a more complex flow:

## listening to queries
There are two ways to get a query result:
```ts
type Error = '500' | '404';
type User = { userName: String };
declare function getUser(userId: number): TaskEither;
const userQuery: CachedQuery = query(getUser)(refetch);
declare function dispatchError(e: Error): void;
declare function setCurrentUser(e: User): void;
// feeding your query to `observe` will give you an observable on the query
// N.B. until now no fetch is yet attempted, avenger will wait until the first subscription is issued
const observable: Observable> = observe(userQuery);
// this will trigger the fetch function
observable.subscribe(dispatchError, setCurrentUser);
// alternatively you can call `run` on your query and it will return a TaskEither
// you can then use it imperatively
const task: TaskEither = userQuery.run(1);
const result: Either = await task();
```
although the `run` method is available to check a query result imperatively, it is highly suggested the use of the `observe` utility in order to be notified in real time of when data changes.
Either way, whenever you ask for a query result you will end up with an object with the [**`QueryResult`**](#QueryResult) signature that conveniently lets you `fold` to decide the best way to handle the result. The `fold` method takes three functions as parameters: the first is used to handle a `Loading` result; the second is used in case a `Failure` occurs; the last one handles `Success` values.
## composing queries
You can build bigger queries from smaller ones in two ways:
- by composing them with [**`compose`**](#compose): when you need your queries to be sequentially run with the results of one feeding the other, you can use `compose`.
- by grouping them with [**`product`**](#product): when you don't need to run the queries sequentially but would like to conveniently group them and treat them as if they were one you can use `product`\*.
\*Internally `product` uses the `Applicative` nature of `QueryResults` to group them using the following hierarchical logic:
1. If any of the queries returned a `Failure` then the whole composition is a `Failure`.
2. If any of the queries is `Loading` then the whole composition is `Loading`.
3. If all the queries ended with a `Success` then the composition is a `Success` with a record of results that mirrors the key/value result of the single queries as value.
Here are a couple of simple examples on how to use them:
```ts
/* N.B. each value defined is explicitly annotated for clarity, although the annotations are not strictly required */
import { compose } from 'avenger/lib/Query';
type UserPreferences = { color: string };
// note that the two ends of the composed functions must have compatible types
declare function getUser(userId: number): TaskEither;
declare function getUserPreferences(
user: User
): TaskEither;
const userQuery: CachedQuery = queryStrict(
getUser,
refetch
);
const preferencesQuery: CachedQuery<
User,
Error,
UserPreferences
> = queryShallow(getUserPreferences, refetch);
// this is a query composition
const composition: Composition = compose(
userQuery,
preferencesQuery
);
// this is a query product
const group: Product = product({
myQuery,
myQuery2
});
```
# commands
Up to now we only described how to fetch data. When you need to update or insert data remotely you can make use of [**`command`**](#command):
```ts
declare function updateUserPreferences({
color: string
}): TaskEither;
const updatePreferencesCommand = command(updateUserPreferences, {
preferencesQuery
});
```
`command` accepts a `Fetch` function that will be used to modify the remote data source and, as a second optional parameter, a record of `query`es that will be invalidated once the `command` is successfully run:
```ts
/* when you call the command you can specify the input value corresponding
to the Cache key that should be invalidated as a second parameter */
updatePreferencesCommand({ color: 'acquamarine' }, { preferencesQuery: 1 });
```
# React
Avenger also exports some utilities to use with `React`.
## declareQueries
`declareQueries` is a `HOC` (Higher-Order Component) builder. It lets you define the queries that you want to inject into a component and then creates a simple `HOC` to wrap it:
```tsx
import { pipe } from 'fp-ts/lib/pipeable';
import { declareQueries } from 'avenger/lib/react';
import * as QR from 'avenger/lib/QueryResult';
import { userPreferences } from './queries';
const queries = declareQueries({ userPreferences });
class MyComponent extends React.PureComponent {
render() {
return pipe(
this.props.queries,
QR.fold(
() =>
loading
,
() => there was a problem when fetching preferences
,
({ userPreferences }) => my favourite color is {userPreferences.color}
)
)
}
}
export queries(MyComponent)
```
When using this component from outside you will have to pass it the correct query parameters inside the `queries` prop in order for it to load the declared queries:
```ts
class MyOtherComponent extends React.PureComponent {
render() {
return (
);
}
}
```
## WithQueries
alternatively, to avoid unecessary boilerplate, you can use the `WithQueries` component:
```tsx
import * as QR from 'avenger/lib/QueryResult';
import { WithQueries } from 'avenger/lib/react';
import { userPreferences } from './queries';
class MyComponent extends React.PureComponent {
render() {
return (
(
loading
),
() => (
there was a problem when fetching preferences
),
({ userPreferences }) => (
Mario's favourite color is {userPreferences.color}
)
)}
/>
);
}
}
```
**NB** both `declareQueries` and `WithQueries` do not support dynamic queries definition (e.g. `declareQueries(someCondition ? { queryA } : { queryA, queryB }` will not work).
## useQuery
alternatively, to avoid unecessary boilerplate, you can use the `useQuery` and `useQueries` hooks:
```tsx
import { pipe } from 'fp-ts/lib/pipeable';
import * as QR from 'avenger/lib/QueryResult';
import { useQuery } from 'avenger/lib/react';
import { userPreferences } from './queries';
const MyComponent: React.FC<{ userName: string }> = props => {
return pipe(
useQuery(userPreferences, { userName: props.userName }),
QR.fold(
() =>
loading
,
() => there was a problem when fetching preferences
,
userPreferences => (
{props.userName}'s favourite color is {userPreferences.color}
)
)
);
};
```
## useQueries
```tsx
import { pipe } from 'fp-ts/lib/pipeable';
import * as QR from 'avenger/lib/QueryResult';
import { useQueries } from 'avenger/lib/react';
declare const query1: ObservableQuery;
declare const query2: ObservableQuery;
const MyComponent: React.FC = props => {
return pipe(
useQueries({ query1, query2 }, { query1: 'query-1-input' }),
QR.fold(
() =>
still loading query1 or query2 (or both)
,
() => there was a problem when fetching either query1 or query2
,
({ query1, query2 }) => (
{query2}: {query1}
)
)
);
};
```
**NB** both `useQuery` and `useQueries` support dynamic queries definition (e.g. `useQueries(someCondition ? { queryA } : { queryA, queryB }` will work as expected).
# Navigation
Another useful set of utilities is the one used to handle client navigation in the browser. Following you can find a simple but exhaustive example of how it is used:
```ts
import { getCurrentView, getDoUpdateCurrentView } from "avenger/lib/browser";
export type CurrentView =
| { view: 'itemView'; itemId: String }
| { view: 'items' };
| { view: 'home' };
const itemViewRegex = /^\/items\/([^\/]+)$/;
const itemsRegex = /^\/items$/;
export function locationToView(location: HistoryLocation): CurrentView {
const itemViewMatch = location.pathname.match(itemViewRegex);
const itemsMatch = location.pathname.match(itemsRegex);
if (itemViewMatch) {
return { view: 'itemView'; itemId: itemViewMatch[1] };
} else if (itemsMatch) {
return { view: 'items' };
} else {
return { view: 'home' };
}
}
export function viewToLocation(view: CurrentView): HistoryLocation {
switch (view.view) {
case 'itemView':
return { pathname: `/items/${view.itemId}`, search: {} };
case 'items':
return { pathname: '/items', search: {} };
case 'home':
return { pathname: '/home', search: {} };
}
}
export const currentView = getCurrentView(locationToView); // ObservableQuery
export const doUpdateCurrentView = getDoUpdateCurrentView(viewToLocation); // Command
```
once you instantiated all the boilerplate needed to instruct Avenger on how to navigate, you can use `currentView` and `doUpdateCurrentView` like they were normal queries and commands (and, in fact, they are..).
```tsx
// ./App.ts
import { pipe } from 'fp-ts/lib/pipeable';
import * as QR from 'avenger/lib/QueryResult';
import { declareQueries } from 'avenger/lib/react';
const queries = declareQueries({ currentView });
// usually at the top level of your app there will be a sort of index of your navigation
class Navigation extends React.PureComponent {
render() {
return pipe(
this.props.queries,
QR.fold(
() =>
loading
,
() => null,
({ currentView }) => {
switch(currentView.view) {
case 'itemView':
return
case 'items':
return
case 'home':
return
}
}
)
)
}
}
export queries(MyComponent)
```
```tsx
// ./Components/ItemView.ts
class ItemView extends React.PureComponent {
goToItems: () => doUpdateCurrentView({ view: 'items' })()
render() {
return
}
}
```
## Signatures
> N.B. all the following signatures reference the abstractions in [`fp-ts`](https://github.com/gcanti/fp-ts)
### `query`
```ts
declare function query(
fetch: Fetch
): (strategy: Strategy) => CachedQuery;
```
### `Fetch`
```ts
type Fetch = (input: A) => TaskEither;
```
### `StrategyBuilder`
```ts
type StrategyBuilder = (
inputEq: Eq,
cacheValueEq: Eq>
) => Strategy;
```
### `Strategy`
```ts
export class Strategy {
constructor(
readonly inputEq: Eq,
readonly filter: Function1, boolean>,
readonly cacheValueEq: Eq>
) {}
}
```
### `CachedQuery`
```ts
interface CachedQuery {
type: 'cached';
inputEq: Eq;
run: Fetch;
invalidate: Fetch;
cache: Cache;
}
```
### `Composition`
```ts
interface Composition {
type: 'composition';
inputEq: Eq;
run: Fetch;
invalidate: Fetch;
master: ObservableQuery;
slave: ObservableQuery;
}
```
### `Product`
```ts
interface Product {
type: 'product';
inputEq: Eq;
run: Fetch;
invalidate: Fetch;
queries: Record>;
}
```
### `ObservableQuery`
```ts
type ObservableQuery =
| CachedQuery
| Composition
| Product;
```
### `QueryResult`
```ts
// instance of Bifunctor2 & Monad2
type QueryResult = Loading | Failure | Success;
```
### `compose`
```ts
function compose(
master: ObservableQuery,
slave: ObservableQuery
): Composition;
```
### `product`
```ts
function product(
queries: EnforceNonEmptyRecord
): Product, ProductL, ProductP>;
```
### `command`
```ts
function command>(
cmd: Fetch,
queries?: EnforceNonEmptyRecord
): (a: A, ia?: ProductA) => TaskEither;
```