@instructure/updown
v1.3.2
Published
apply a set of inter-dependent functions in order
Downloads
3,797
Maintainers
Keywords
Readme
updown
Apply a set of inter-dependent functions, and later undo their effects. This routine is suitable when you need to run separate pieces of logic that are order-dependent: the dependencies are explicit and made clear to the reader, and the order in which they are declared does not govern the order in which they are run.
import up from 'updown'
await up({
up: (a) => { /* do something when everything is ready */
console.log(a) // => "hi"
},
requires: [
{
up: () => { /* but cause some side-effects first... */
window.im_bad = 1
return {
down: () => { /* ...and clean them up when asked */
delete window.im_bad
}
}
}
},
{ /* inject dependencies... */
up: () => Promise.resolve({ value: 'hi' }),
requires: [
{ /* ...but do even more things first... */
up: ...,
requires: [...]
}
]
}
]
}) // => { down: Function, value: undefined }
The functions can be asynchronous or not, the interface remains identical.
See also the example.ts
file in this repository for sample usage that is
written in a somewhat more real-world way.
Usage and going up
The input to the up
function is a tree of objects - its nodes - that contain
two significant properties: (1) "up", which points to the function to run, and
(2) "requires", which is the list of other nodes that must be run before it.
const A = {
up: () => {},
requires: []
}
const B = {
up: () => { ... },
requires: [A]
}
A node is brought "up" exactly once, and the time that happens is when all of
its dependencies have been satisfied. In the example above, A
will be brought
up before B
. You don't need to worry about the order in which dependencies are
declared; up
will figure that out.
A node is considered to be up when its up
function returns. However, if it
returns a Promise, then updown
will wait on that Promise to be settled before
considering it to be up. Once a node is determined to be up, then other nodes
which depend upon it will be brought up in turn.
With that said, the tree is expected to be free of circular dependencies. We
don't attempt to detect such cases and the behavior of up
is undefined if
this constraint is not held.
Producing values
A node may produce a value to be used by other nodes which depend upon it. The value provided is opaque to and is not examined by the updown logic.
up
may return void
(or a Promise that resolves to void
), in which case
the value is undefined
and the down
action is nothing. up
may also
return either an Object (or a Promise which resolves to an Object); that Object
must match the type signature:
{
value?: unknown,
down?: () => (void | Promise<void>)
}
It is the value
property that is considered the value produced by the up
function and which is passed as an argument to dependents, discussed in the
next section.
Considerations for positional arguments
The value itself is provided as an argument to the dependent's up
function at
the position equal to where the dependency was declared in the requires
set.
For example, the node B
will receive the value of A
as the second argument:
const A = { up: () => ({ value: 5 }), requires: [] }
const B = { up: (_, valueOfA) => {}, requires: [C, A] }
│ │
│ └─ position of dependency
└─ position of dependency value
This symmetry suggests a particular arrangement for the dependencies we declare
in requires
whereby ones that produce values are placed before others that
don't. Consider a different example where nodes A
and B
do produce values
whereas C
does not:
const A = { up: () => ({ value: 1 }), requires: [] }
const B = { up: (a) => ({ value: a + 1 }), requires: [A] }
const C = { up: () => (), requires: [A,B] }
up({
up: (c, a, b) => { console.log(c, a, b) /* => undefined, 1, 2 */ },
requires: [C, A, B]
})
As you see in the body of up
for the last node, C
's value was undefined and
looked awkward. Had we declared it after A
and B
, its argument could then
be omitted entirely, which would make for a more natural interface:
up({
up: (a, b) => { console.log(a, b) /* => 1, 2 */ },
requires: [A, B, C]
})
In practice, the functions whose output you care about are declared earlier in
requires
so that you may easily reference them.
Remember that the order in which you declare the dependencies does not affect the order in which they are run, so you are safe to arrange them in a way that suits you.
Considerations for Capabilities that are not idempotent
Suppose we have two entirely separate trees of dependencies:
const A1: Capability = { up: ..., requires: [B, C, E] };
const A2: Capability = { up: ..., requires: [C, D, E] };
But C's up action is not idempotent so running it twice might result in undefined
behavior, so it can be protected with oncePerPage
meaning it will only execute
once per global code execution. oncePerPage
can be imported by name from the
updown
package if needed.
E is also a dependency of both trees, but pretend that its up action is
idempotent, so it won't matter if we run it more than once. Thus it will run
twice in this case if not wrapped in oncePerPage
.
Note that when possible, it's easier (and preferred) to simply define a new
dependency A with requires: [ A1, A2 ]
and then updown
itself would take care
of assuring that C only executed once. But sometimes disparate sections of code
running in the same environment need to run completely different instances of up
and all the respective sets of descendent requirements; whenever this is the case
one must be careful to use oncePerPage
to guard shared capabilities from bringing
themselves up more than once if that would create problems.
The included example.ts
demonstrates how to include oncePerPage
and how to use
it to protect capability up
functions from executing more than once.
Considerations for values that are unresolved Promises
Be careful: If a node returns an object with a value which is an unresolved
Promise, updown
will not wait on it to be settled before considering your
node to be up! If you mean to block your dependents until a Promise settles,
be sure to return that Promise as the result of up
itself.
For example, the following will cause updown
to proceed immediately after
calling this up
function:
up({
up: () => ({ value: new Promise(resolve => resolve(...)); })
}); // WILL NOT WAIT!
Whereas this will cause it to wait until the Promise settles:
up({
up: () => new Promise(resolve => resolve({ value: ... }))
}); // This will wait
The usual use case is for up
itself to return the Promise, which allows
updown
to automatically manage the asynchronous running of dependents at
the proper times. Since the value
inside the return object is opaque to
updown
an implementation is certainly free to set it to anything
appropriate for that code's logic, including an unresolved Promise; just be
aware that that alone will not block bringing up dependent nodes.
Going down
A node that causes side-effects while going up should implement a counterpart function that restores those effects by defining a function at the "down" property of its output.
{
up: () => {
window.be_bad = 1
return {
down: () => {
delete window.be_bad
}
}
},
requires: []
}
Nodes are brought down in a LIFO fashion.
Error handling
Should any node fail to go up - either by throwing an Error or by producing a rejected Promise - the chain is aborted. The error is decorated with a custom property "down" that can be used to tear down the nodes that were successfully brought up to that point.
try {
await up(...)
}
catch (e) {
// do something with error:
...
// clean up:
await e.down()
}
Errors caught while going down are tracked and provided to you but they do not
stop other nodes from going down. In this case, down
rejects with the last
error encountered.
Input integrity
The up
routine makes no attempts at validating the structure of the nodes you
provide at runtime; it expects them to be valid. To interface properly with this
package, please make sure you're utilizing the available TypeScript types and
run your own integration through the tsc
checker at build-time.