@galaxis/core v0.2.2
Galaxis Core
Apollo-inspired backend-agnostic fetching library with full SSR support.
This package contains core Galaxis functionality, which is dependency-free, framework-agnostic and as unopinionated as possible.
Installation
yarn add @galaxis/core
Note that you should use a framework-specific wrapper (such as Galaxis React) and not install Fecther Core directly.
The library is compiled to modern JS, but it should work in all reasonable browsers with the help of properly configured Babel.
Your client environment has to have AbortController
. You might need to polyfill it.
Features
Queries
Queries are requests that do not change the system state. They are described as Query objects. Note that some options are ignored in the contexts where they are not applicable. For instance, the lazy
option only works for managed queries.
You can work with queries manually via client.query(), client.fetchQuery() and client.readQuery(), or you can create a managed query via client.manageQuery(). Note that managed queries should be hidden behind some framework-specific wrapper. The library provides useQuery for React.
A query execution may or may not result in returning the state (data and error) from the cache and/or performing a network request, depending on the fetchPolicy and the cache state.
A query can also be (re)fetched from the network, which always leads to a network request.
Mutations
Mutations are requests that do change the system state. They are described as Mutation objects.
You can work with mutations manually via client.mutate(), or you can create a managed mutation via client.manageMutation(). Note that managed mutations should be hidden behind some framework-specific wrapper. The library provides useMutation for React.
A mutation execution always leads to a network request.
Shared Cache
Queries and mutations work with the same shared Cache. That means that a query can use the data (and/or error!) that was fetched by another query, and query data can be indirectly updated by a mutation. The library provides InMemoryCache as a recommended cache.
For queries, cache usage is specified by fetchPolicy
option. Note that if fetchPolicy
is not 'no-cache'
, you also need to specify toCache()
and fromCache()
options, or the caching will appear broken. Query errors are automatically cached by the network request id depending on fetchPolicy
. You can opt-out of caching by using fetchPolicy: 'no-cache'
.
For mutations, cache usage is specified by existense of toCache()
option. Mutation errors are never cached. You can opt-out of caching by omitting this option (or setting it to undefined
).
Optimistic Responses
You can specify optimisticData
for mutations. During mutation execution, the cache will immediately be updated with this data, and then with the real data when it arrives. Note that you also have to specify the removeOptimisticData()
and toCache()
functions, so the library knows how to remove the optimistic data from the cache, and how to put the real data in.
Query Merging
The library encourages executing queries at arbitrary parts of code and points in time. This way, any component of the application can express its data requirements in isolation from other components.
That would lead to duplicate network requests to the same resources. To prevent that, the library uses query merging.
Query merging means that if a query execution (or fetching) results in performing a network request, the library will reuse an ongoing network request with the same id, if there is one. Network request id is calculated by the getRequestId
function of BaseRequest.
The cache will also be updated just once, using the first cacheable query that created or reused the network request. A query is cacheable if its fetchPolicy
is not no-cache
.
This means that queries with the same network request id must update the cache in the same way. It shouldn't be an issue, as the opposite makes no sense.
You can't really opt-out of query merging, but you can use the forceRequestOnMerge
option of Query to force a new network request (essentially re-run the existing one) if this query is being merged with another. This means that a promise representing a network request may hide more than one actual network request behind it.
Request Queueing
Since queries (and mutations!) are executed at arbitrary points in time, there must be a way to prevent overwriting newer data by the older one. This is done by request queueing.
Request queueing ensures that all queries, which were started before some mutation, are finished before that mutation is started (in no particular order). It also ensures that the next mutation will be started only after the previous one was finished.
A typical queue may look like this:
[a bunch of queries] -> [mutation] -> [mutation] -> [a bunch of queries]
Note that request queueing means that a promise representing a network request may hide an actual network request that wasn't started yet. Such a request can still be aborted at any time, regardless of its position in the queue.
Race Conditions Handling
Query merging and request queueing, alongside other techniques, make sure that there are no race conditions, as long as data from queries with different network request ids does not overlap. Even if it does, you are still fine, if you only change the system state by mutations, since you don't really care in which order the same data arrives.
Otherwise, beware that you have a razor-thin chance of overwriting an up-to-date data with an outdated one, if you execute such queries simultaneously. In the future, the library may provide means for relative queueing of such queries.
Full Server-Side Rendering Support
The library is built with SSR in mind. Managed queries can be executed on the server side, and, assuming they are wrapped in a framework-specific wrapper, there is no SSR-specific code in the application components, so they are SSR-ready by default. Note that you can disable fetching on the server by the disableSsr
option of Query.
The server uses an instance of SsrPromisesManager to wait until all managed queries network requests are finished and the cache is filled with data and errors, then renders the app based on the cache, and sends resulting HTML with embedded cache to the client.
The exact server rendering logic and means of providing a SsrPromisesManager instance to managed queries are framework-specific. The library provides getDataFromTree for React.
Hydrate Stage Optimization
If you're doing SSR, you're going to have a hydrate stage on the client, which is the initial render with cached data. By default, queries with fetchPolicy: 'cache-and-network'
will be fetched during the hydrate stage. This is likely undesirable because these requests were just performed on the server.
It can be fixed by setting optimizeOnHydrate: true
for all queries by default. In general, you should always do that, unless your cache is not coming from just-performed requests (e.g. you are not doing SSR, but persist the cache to local storage).
Note that you have to indicate that the hydrate stage is complete by calling client.onHydrateComplete().
High Customizability
The library is completely unopinionated about the network level. You can use fetch, axios, XMLHttpRequest, or any other solution. You can add network requests logging, retries, or timeouts. The library doesn't care in which format your data arrives. Just provide a getRequestFactory
option of BaseRequest that will abstract it all away. The library provides Fetch as a recommended network interface.
The library is also unopinionated about Cache internals. You can add cache persistence, partial or complete. You even should be able to integrate the cache with your own state management solution, should you need so. The library provides InMemoryCache as a recommended cache.
Note that almost everything is configurable on a per-request level. For instance, you can use different network interfaces for different queries!
Public API
⚠ Anything that is not documented here is not considered a part of public API and may change at any time.
Client
Client
is the heart of the library that does all the heavy-lifting.
const client = new Client({
cache: new MyCache(),
merge,
hash,
defaultRequest,
defaultQuery,
defaultMutation,
});
Arguments
ClientOptions
⚠ Note that defaults are static for the given client instance. Dynamic defaults would add too much complexity. If you really need dynamic defaults, such as a user-specific header that is common for all requests, you should do it on the network level, somewhere inside the
getRequestFactory
option of the BaseRequest. Ideally, if we're talking about authorization, you should rely on aHttpOnly
cookie set by the server.
Name | Type | Description | Required |
---|---|---|---|
cache | CACHE | A cache for storing normalized data and errors. The library provides InMemoryCache that should work in a lot of cases. | Yes |
merge | (r1: R1, r2: R2, r3: R3) => R1 & R2 & R3; | A function for merging queries and mutations with defaults. R1 is defaultRequest , R2 is defaultQuery or defaultMutation and R3 is the given query or mutation. The library provides mergeDeepNonUndefined() that should work in a lot of cases. | Yes |
hash | (value: unknown) => string | A function for hashing BaseRequest, Query or Mutation objects, and their requestParams fields. The library provides objectHash() that should work in a lot of cases. | Yes |
defaultRequest | Partial<BaseRequest> | Default request. Can't be changed later. | No |
defaultQuery | Partial<Query> | Default query. Can't be changed later. | No |
defaultMutation | Partial<Mutation> | Default mutation. Can't be changed later. | No |
client.manageQuery()
Starts managing of the given query. If the query is not lazy
, it is executed immediately.
Note that this method is supposed to be wrapped in some framework-specific wrapper. You should use client.query(), client.fetchQuery() and client.readQuery() to work with queries manually.
const [result, dispose] = client.manageQuery(query, onChange, ssrPromisesManager);
Arguments
Name | Type | Description | Required |
---|---|---|---|
query | Query | undefined | A query to create a manager for. Pass undefined to create an empty manager. It's useful when you can't call the function optionally, e.g. in React Hooks. | Yes |
onChange | (result: QueryManagerResult) => void | A callback that will be called when the state of the manager changes. | Yes |
ssrPromisesManager | SsrPromisesManager | Should only be passed on the server side. If a non-lazy query produces a network request during its initial execution, it is added to the ssrPromisesManager . | No |
Return value
Name | Type | Description |
---|---|---|
result | QueryManagerResult | Current manager state and API for manipulating it. |
dispose | () => void | Call it when you don't need the manager anymore to perform the internal cleanup. The ongoing network request will be soft-aborted. |
Related types
QueryManagerResult
Name | Type | Description |
---|---|---|
data | D | undefined | The last known data of the query, stored inside the manager. Can be updated by the cache update, depending on the fetchPolicy . |
error | E | Error | undefined | The error from the last network request, stored inside the manager. Can be updated by the cache update, depending on the fetchPolicy . |
loading | boolean | If true , there is a network request in progress, initiated by the manager. If the query is loading, its data and error updates are paused. |
executed | boolean | If true , the query was executed. Initially true for non-lazy queries, and it's switched to true after the first call of execute . |
execute | () => QueryResult | Call it to execute the query. If the query is already executed , you most likely want to refetch it instead. Note that query execution will immediately update the data and error fields with the cached values (undefined in case of fetchPolicy: 'no-cache' ). The ongoing network request will be soft-aborted. |
refetch | () => Promise<D> | Call it to fetch the query from the network, even for fetchPolicy: cache-only . The query has to be executed first. Note that this method will always (forceRequestOnMerge: true ) create a new network request, aborting the previous one. |
abort | () => void | Call it to abort current network request. If there is no network request in progress, it's a no-op. |
reset | () => void | Call it to reset the manager to the non-executed state. This will reset data and error to undefined and executed to false . The ongoing network request will be soft-aborted. |
client.manageMutation()
Starts managing of the given mutation.
Note that this method is supposed to be wrapped in some framework-specific wrapper. You should use client.mutate() to work with mutations manually.
const [result, dispose] = client.manageMutation(mutation, onChange);
Arguments
Name | Type | Description | Required |
---|---|---|---|
mutation | Mutation | undefined | A mutation to create a manager for. Pass undefined to create an empty manager. It's useful when you can't call the function optionally, e.g. in React Hooks. | Yes |
onChange | (result: MutationManagerResult) => void | A callback that will be called when the state of the manager changes. | Yes |
Return value
Name | Type | Description |
---|---|---|
result | MutationManagerResult | Current manager state and API for manipulating it. |
dispose | () => void | Call it when you don't need the manager anymore to perform the internal cleanup. Note that the ongoing network request will not be aborted automatically. |
Related types
MutationManagerResult
⚠ Note that mutation manager only tracks the latest mutation execution.
Name | Type | Description |
---|---|---|
data | D | undefined | The last known data of the mutation, stored inside the manager. Note that mutation execution doesn't reset this value. |
error | E | Error | undefined | The error from the last network request, stored inside the manager. Note that mutation execution doesn't reset this value. |
loading | boolean | If true , there is a network request in progress, initiated by the manager. |
executed | boolean | If true , the mutation was executed. Initially it's false , and it's switched to true after the first call of execute() . |
execute | () => Promise<D> | Call it to execute the mutation. Note that the ongoing network request will not be aborted automatically. |
abort | () => void | Call it to abort current network request. If there is no network request in progress, it's a no-op. |
reset | () => void | Call it to reset the manager to the initial (non-executed) state. This will reset data and error to undefined and executed to false . Note that the ongoing network request will not be aborted automatically. |
client.query()
Execute the query and optionally subscribe to the changes in its state.
const result = client.query(query, onChange);
Arguments
Name | Type | Description | Required |
---|---|---|---|
query | Query | A query to execute. | Yes |
onChange | (state: QueryState) => void | A callback to call when the state of the query changes. | No |
Return value
QueryResult
Extends QueryState.
Name | Type | Description |
---|---|---|
request | Promise<D> | undefined | A promise representing network request. It will be undefined , if it wasn't required (or was required, but wasn't allowed on the server side). Internally, there may be more than one actual network request. |
client.fetchQuery()
Fetch the query. This method will always lead to a network request. Note that this method respects the forceRequestOnMerge
option of Query.
const result = client.fetchQuery(query);
Arguments
Name | Type | Description | Required |
---|---|---|---|
query | Query | A query to fetch. | Yes |
Return value
Promise<D>
client.readQuery()
Get state of the given query and optionally subscribe to its changes.
const queryState = client.readQuery(query, onChange);
Arguments
Name | Type | Description | Required |
---|---|---|---|
query | Query | A query to read. | Yes |
onChange | (state: QueryState) => void | A callback to call when the state of the query changes. | No |
Return value
QueryState
Name | Type | Description |
---|---|---|
data | D | undefined | Data from the cache. undefined means no data. An unsuccessful request will not overwrite this field. It can be thought of as the last known data. Always undefined for non-cacheable query. |
error | E | Error | undefined | Error from the cache. undefined means no error. A successful request will overwrite this field to undefined . It can be thought of as the error from the last request. Always undefined for non-cacheable query. |
requestRequired | boolean | Specifies whether a network request is required, based on cache state and fetch policy of the given query. The actual network request may still not be allowed on the server side. If true , the query should be rendered with loading: true . |
requestAllowed | boolean | Specifies whether a network request is allowed. Always true on the client side. |
cacheable | boolean | A query is not cacheable, if it has fetchPolicy: 'no-cache' . |
unsubscribe | () => void | undefined | A function for unsubscribing. Will be undefined if there was no subscription. It can happen when onChange argument wasn't passed, or if the query itself is not cacheable. |
client.mutate()
Execute the mutation.
const mutationResult = client.mutate(mutation);
Arguments
Name | Type | Description | Required |
---|---|---|---|
mutation | Mutation | A mutation to execute. | Yes |
Return value
Promise<D>
client.purge()
Reset the client. Specifically, abort all requests, clear the cache, execute all executed managed queries and reset all managed mutations.
You should call this method on logout.
client.purge();
client.getCache()
Get cache that was passed to the constructor.
const cache = client.getCache();
Return value
client.onHydrateComplete()
Report to the client that the hydrate stage is complete. The client always starts in the hydrate stage, and it's a one-way operation.
client.onHydrateComplete();
client.hash()
Get hash of the given value using the hash
function that was passed to the constructor.
const hash = client.hash(value);
Arguments
Name | Type | Description | Required |
---|---|---|---|
value | unknown | A value to hash. | Yes |
Return value
string
SsrPromisesManager
SsrPromisesManager is a small helper for dealing with promises on the server side.
The exact algorithm is up to you, but this seems to be the most robust way:
- Render the app, adding promises from QueryResult as they appear.
- Check for added promises. If there are any, wait for them, then go to 1. Otherwise, go to 3.
- The cache is now filled with data and errors.
const ssrPromisesManager = new SsrPromisesManager();
ssrPromisesManager.addPromise()
Add a promise to the manager.
ssrPromisesManager.addPromise(promise);
Arguments
Name | Type | Description | Required |
---|---|---|---|
promise | Promise<unknown> | A promise to add. | Yes |
ssrPromisesManager.awaitPromises()
Returns a promise that resolves when all added promises are resolved or rejected. Note that you can't use the manager while this promise is pending.
await ssrPromisesManager.awaitPromises();
Return value
Promise<void>
ssrPromisesManager.hasPromises()
Checks if there are added promises.
const hasPromises = ssrPromisesManager.hasPromises();
Return value
boolean
Important Types
User-defined types
Name | Scope | Constraint | Description |
---|---|---|---|
CACHE | Client-specific | Must extend Cache | Cache for storing normalized data and errors. |
C | Client-specific | Must extend NonUndefined | Cache data. Normalized data from all requests. |
BD | Client-specific | Must extend NonUndefined | Query or mutation data, common for all requests. Used for defaults. |
BE | Client-specific | Must extend Error | Query or mutation error, common for all requests. Used for defaults. |
BR | Client-specific | None | Query or mutation request parameters, common for all requests. Used for defaults. |
D | Request-specific | Must extend BD | Query or mutation data. |
E | Request-specific | Must extend BE | Query or mutation error. |
R | Request-specific | Must extend BR | Query or mutation request parameters. |
NonUndefined
Anything but undefined
.
BaseRequest
This type describes base network request.
Name | Type | Description | Required |
---|---|---|---|
requestParams | R | Arbitrary storage of request parameters. | Yes |
abortSignal | AbortSignal | Signal for aborting the request. | No |
getRequestFactory | (opts: RequestOptions) => (abortSignal?: AbortSignal) => Promise<D>; | A function that returns the factory for creating network requests.Note that abortSignal for the factory is created by the library. It is not the same signal as abortSignal field of BaseRequest . | No, a rejected promise will be used by default |
getRequestId | (opts: RequestOptions) => string; | A function for calculating a network request id. It should take some hash from requestParams , excluding parts that are different between client and server. | No, a hash from requestParams will be used by default |
toCache | (opts: CacheAndDataOptions) => C; | A function that modifies cache data based on request data (from network or optimistic response). | No, the cache data will not be modified by default. |
Query
Extends BaseRequest.
Name | Type | Description | Required |
---|---|---|---|
fetchPolicy | FetchPolicy | FetchPolicy. | No, 'cache-only' is used by default. |
lazy | boolean | If true , the query will not be executed automatically. | No |
disableSsr | boolean | If true , the query will not be fetched on the server. | No |
optimizeOnHydrate | boolean | If true , the query won't be fetched on the client during the hydrate stage, if there is data or error in the cache. fetchPolicy option is ignored. | No |
forceRequestOnMerge | boolean | If true , the query will start a new network request, if it's merged with the existing query. | No |
softAbortSignal | AbortSignal | Soft aborting should be used to indicate loss of interest in the ongoing network request. The actual request won't be aborted if there are other interested parties. | No |
fromCache | (opts: CacheOptions) => D | undefined | A function for retrieving query data from cache data. | No, a function returning undefined will be used by default |
Mutation
Extends BaseRequest.
Name | Type | Description | Required |
---|---|---|---|
optimisticData | D | Optimistic data (optimistic response). | No |
removeOptimisticData | (opts: CacheAndDataOptions) => C | A function that removes optimistic data from the cache. | No |
RequestOptions
Name | Type | Description |
---|---|---|
requestParams | R | Arbitrary storage of request parameters. |
CacheOptions
Name | Type | Description |
---|---|---|
cacheData | C | Cache data. |
requestParams | R | Arbitrary storage of request parameters. |
requestId | string | Network request id, generated by the getRequestId() function of BaseRequest. |
CacheAndDataOptions
Extends CacheOptions
Name | Type | Description |
---|---|---|
data | D | Data of the given request (from network or optimistic response). |
FetchPolicy
fetchPolicy | Query execution result |
---|---|
'cache-only' | No network request. Returns state from the cache. |
'cache-first' | Network request, if there is no data in the cache. Returns state from the cache. The cache is then updated with the request result (if there was a network request). |
'cache-and-network' | Network request regardless of cache state. Returns state from the cache. The cache is then updated with the request result. |
'no-cache' | Network request regardless of cache state. Does not touch the cache in any way. |
Cache
Name | Type | Description |
---|---|---|
subscribe | (callback: () => void) => () => void | Subscribe to the cache. The callback will be called on cache changes. Call returned function to unsubscribe. |
update | (opts: UpdateOptions) => void | Update cache state. |
getData | () => C | Get cache data. |
getError | (requestId: string) => Error | undefined | Get cached error for the given request id. |
purge | () => void | Reset the cache to empty state. |
UpdateOptions
Name | Type | Description | Required |
---|---|---|---|
data | C | New cache data. Omit or set to undefined if no update is needed. | No |
error | string, Error | undefined | New error. The first element of the tuple is the request id. The second one is the error value, where undefined means "clear error". Omit the tuple or set it to undefined if no update is needed. | No |