1.3.2 • Published 2 years ago

@instructure/updown v1.3.2

Weekly downloads
-
License
MIT
Repository
-
Last release
2 years ago

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.

1.3.2

2 years ago

1.3.1

2 years ago

1.2.2

2 years ago

1.2.1

2 years ago

1.1.2

2 years ago

1.1.1

2 years ago

1.0.0

2 years ago