Skip to main content

Actions

What you'll learn
  • Enhancing actions
  • Running actions
  • Extending actions for builder pattern calls
  • Conditionally enhancing and running actions
  • Registering hooks on actions

Before reading this guide

This guide is only about basic usage and lifecycle of actions.

Usage

Once your models and action factory are set up, you are ready to run actions through your models.

Foscia uses two typologies of functions to run actions, enhancers and runners, which are described in the lifecycle guide.

info

Examples of the documentation commonly use variadic run calls on actions, as this is the simplest way of using actions.

Retrieving records

query can target a records index using a model. You can use it in combination with all to retrieve a list of the model instances.

import { query, all } from '@foscia/core';

const posts = await action().run(
query(Post),
all(),
);

posts.forEach((post) => {
console.log(post.title);
});

In addition, query also provide a way to target a record using a model and an ID. You can use it in combination with oneOrFail or oneOr to retrieve a single record and handle not found errors.

import { query, oneOrFail } from '@foscia/core';

const postOrFail = await action().run(
query(Post, '1'),
oneOrFail(),
);

const postOrNull = await action().run(
query(Post, '2'),
oneOr(() => null),
);

Finally, query can also be used to target a record using an instance. If the instance does not exist, this will target the records index, otherwise it will target the record. This can be used when writing a record (e.g. create/update) or when lazy loading a relation.

import { query, oneOrFail } from '@foscia/core';

const post = await action().run(query(somePost), oneOrFail());

Creating and updating records

You can create or update records using create and update functions. As a convenience, Foscia provides a save function which will call create or update depending on the existence state of your record.

oneOrCurrent is a runner which you can combine with those enhancers to always retrieve an instance, even if your data source does not return the record on those actions (such as a 204 No Content on update). It will return a record if there is one if your data source response, otherwise it will return the initially provided instance.

You can also combine those functions with none runner to not handle the data source response.

import { create, save, fill, oneOrCurrent, none } from '@foscia/core';

const post = await action().run(
create(fill(new Post(), { title: 'Hello World!' })),
include('author'),
oneOrCurrent(),
);

post.title = 'Edited Hello World!';

await action().run(save(post), none());

Deleting records

You can use destroy to trigger a record deletion. You can combine it with none to ignore the data source's response (errors are still thrown by the adapter).

import { destroy, none } from '@foscia/core';

await action().run(destroy(post), none());

Relationships

Eager loading relations

Some implementations of Foscia support dedicated relationships operations (such as JSON:API). With those, you may read and write relationships.

Eager loading relations

Eager loading provides a way to retrieve records and eager load related models without further requests. It also alleviates the "N + 1" query problem.

To eager load a relation, you can use the include function.

import { query, all, include } from '@foscia/core';

const posts = await action().run(
query(Post),
include('author'),
all(),
);

Lazy loading relations

Lazy loading provides a way to fetch related records for a relation with an already retrieved a record.

import { query, all, oneOrFail } from '@foscia/core';

// Fetch a post without relations.
const post = await action().run(query(Post, 1), oneOrFail());
// Fetch "comments" relation.
const comments = await action().run(query(post, 'comments'), all());
info

Lazy loading a record's relation won't affect the given record instance (it won't hydrate the relation on parent instance, here post).

Writing relations

To one

For hasOne relationship, you can use associate and dissociate to change the relation value.

import { associate, dissociate, none } from '@foscia/core';

// Associate myUser as myPost's author.
await action().run(
associate(myPost, 'author', myUser),
none(),
);
console.log(myPost.author); // myUser

// Dissociate author of myPost.
await action().run(
dissociate(myPost, 'author'),
none(),
);
console.log(myPost.author); // null
info

Using associate and dissociate will update the model's property and mark it synced.

To many

For hasMany relationship, you can use attach, detach and updateRelation to change the relation value:

  • attach will attach one or many related records, without touching existing related records;
  • detach will detach one or many related records, without touching attaching other records;
  • updateRelation will sync related records to the given records (detach past records, attach given records);
import { attach, detach, updateRelation, none } from '@foscia/core';

// Attach myUser1 and myUser2 to myTeam's members.
await action().run(
attach(myTeam, 'members', [myUser1, myUser2]),
none(),
);

// Detach myUser1 and myUser2 from myTeam's members.
await action().run(
detach(myTeam, 'members', [myUser1, myUser2]),
none(),
);

// Sync myUser1 and myUser2 as myTeam's members.
await action().run(
updateRelation(myTeam, 'members', [myUser1, myUser2]),
none(),
);
warning

Using attach, detach and updateRelation will not update the model's property.

Creating through
version: v0.9.3+

In some data source implementation, you may want to create a record "through" a parent record's relation. As an example, creating a comment through a post in a REST API, and using a POST /posts/1/comments request.

This can be achieved using the create enhancer.

import { create } from '@foscia/core';

// Create a comment through a post.
await action().run(
create(newComment, post, 'comments'),
oneOrCurrent(),
);

Caching

When having a cache enabled on your action, all fetched records are cached when deserializing. You can use the cache to retrieve an already fetched record without interacting with your data source again using cached and cachedOrFail.

import { query, cached, cachedOrFail } from '@foscia/core';

const postOrNull = await action().run(query(Post, 1), cached());
const postOrFail = await action().run(query(Post, 1), cachedOrFail());

Sometimes, you may want to interact with the cache if your record does not have been cached already. For this particular case, you can use cachedOr.

import { query, cachedOr, oneOrFail } from '@foscia/core';

const cachedPostOrFetch = await action().run(query(Post, 1), cachedOr(oneOrFail()));
info

cached runners will check for loaded relations if you are requesting relations include. If some relations (even deep ones) are not loaded, it will act as the record is not inside the cache.

Conditionals

Sometimes, you may need to conditionally apply an enhancer or run an action. As an example, you may want to sort results differently based on the user's defined sort's direction. This can be done easily using the when helper:

import { query, when } from '@foscia/core';
import { sortByDesc } from '@foscia/jsonapi';

action().run(
query(Post),
when(displayLatestFirst, sortByDesc('createdAt'),
);

when returns a new enhancer or runner based on the given value's truthiness. It will execute the first enhancer/runner only if its value is truthy. You may pass the value as a factory function returning the value, and even a promise value. You may also pass a second enhancer/runner which will only execute if the value is falsy. Each callback arguments will receive the action as their first argument and the value as their second argument. Each callback may also be async, as any enhancers and runners.

Here are further examples:

import { changed, create, oneOrFail, when } from '@foscia/core';

const post = fill(new Post(), userInputData);

action().run(
create(post),
when(
() => /* compute a special value */,
(a, specialTruthyValue) => /* do something */,
(a, specialFalsyValue) => /* do something */,
),
when(
changed(post),
oneOrFail(),
() => post,
),
);

Lifecycle

When using variadic run calls, Foscia actions lifecycle is "hidden" by variadic arguments. In fact, all actions execution have 3 steps: instantiation, enhancements and execution (run).

action().run(               // Instantiation.
query(Post), // `query` enhancement.
include('comments'), // `include` enhancement.
all(), // `all` run.
);

The above example is equivalent to and can be decomposed as follows:

action()                    // Instantiation.
.use(query(Post)) // `query` enhancement.
.use(include('comments')) // `include` enhancement.
.run(all()); // `all` run.

Instantiation

As stated in the getting started guide, actions are instantiated through your action factory. In this guide, we'll admit you have a setup action factory.

Enhancers

An action instance can receive multiple enhancements that will build an appropriate context to run requests to your data sources.

Each enhancer can be applied using the use action method. Note that those enhancers are not instantly applied to the action context, but during the action run step (or context computation).

action()
// Enhance the action.
.use(query(Post))
.use(include('comments'));

Variadic use

use also support variadic enhancers. Number of arguments might be limited, check the function signature for details.

action().use(
query(Post),
include('comments'),
sortByDesc('publishedAt'),
// etc.
);

Enhancers API guide

Available enhancers API guide

Runners

An action instance can be run using the run method. The runner can execute multiple enhancers or runners internally.

When an action run, it does 3 things:

  • Dequeue all enhancers since the action instantiation and builds context
  • Execute the runner and each of its internal enhancers/runners (this may update the context)
  • Return the runner result (might be any value, including void or an error throwing)

Internally, action running will also trigger actions hooks.

action()
.use(query(Post))
.use(include('comments'))
// Run the action.
.run(all());

Variadic run

run also support variadic enhancers. Last argument must be a runner. Number of arguments might be limited, check the function signature for details.

const posts = await action().run(
query(Post),
include('comments'),
sortByDesc('publishedAt'),
all(),
);

Runners API guide

Available runners API guide

Hooks

You may hook on multiple events which occurs on action instance using the hook registration function:

  • onRunning: after context computation, before context runner execution.
  • onSuccess: after context runner successful execution (no error thrown).
  • onError: after context runner failed execution (error thrown).
  • onFinally: after context runner successful or failed execution.

To register a hook callback, you must use the registration enhancer on your building action.

import {
onRunning,
onSuccess,
onError,
onFinally,
} from '@foscia/core';

action().use(onRunning(({ context }) => /* ... */));
action().use(onSuccess(({ context, result }) => /* ... */));
action().use(onError(({ context, error }) => /* ... */));
action().use(onFinally(({ context }) => /* ... */));
info

Hooks' callbacks are async and executed in a sequential fashion (one by one, not parallelized).

You can temporally disable hook execution for a given action by using the withoutHooks function. withoutHooks can receive a sync or async callback: if an async callback is passed, it will return a Promise.

import { query, all, withoutHooks } from '@foscia/core';

// Retrieve a list of User instances without action hooks running.
const users = await withoutHooks(action(), async (a) => {
return await a.use(query(User)).run(all());
});
warning

Foscia may also register hooks internally when using some enhancers. Those provide some library features (models hooks, etc.). Be careful running actions without hooks, as those hooks will also be disable.

Extensions

Sometimes, functional programming can be frustrating, because you must always rewrite the same words (e.g. use) to keep a builder pattern styled code.

Extensions provide a set of properties or methods which will be added to your actions' instances. As an action, extensions can avoid you writing use or run by adding enhancers/runners methods on you action.

The first step to use one or many extensions is to update your action factory in which you should provide a second parameter.

action.ts
import {
makeActionFactory,
query,
include,
all,
hooksExtensions,
} from '@foscia/core';

export default makeActionFactory(
{
// makeJsonRestAdapter(), ...etc.
},
{
...hooksExtensions(),
...query.extension(),
...include.extension(),
...all.extension(),
},
);

You can now use the extended enhancers and runners without calling use or run:

import action from './action';

await action().query(Post).include('tags').all();

Every enhancers and runners of Foscia provide a .extension() property which is extendable by an action instance.

You may extend your action with any enhancers or runners extensions manually. Otherwise, you may also use prebuild extensions packs. Those provide multiple extensions in one exported object allowing you to extend multiple extensions at one time!

Available extensions packs API guide

warning

Keep in mind that using extensions will avoid tree-shaking the extended enhancers or runners functions (even when those are unused in your codebase), because those are imported by their extensions.