diff options
Diffstat (limited to 'src/data/things/composite.js')
-rw-r--r-- | src/data/things/composite.js | 2359 |
1 files changed, 2359 insertions, 0 deletions
diff --git a/src/data/things/composite.js b/src/data/things/composite.js new file mode 100644 index 00000000..c03f8833 --- /dev/null +++ b/src/data/things/composite.js @@ -0,0 +1,2359 @@ +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {TupleMap} from '#wiki-data'; + +import { + a, + is, + isString, + isWholeNumber, + validateArrayItems, +} from '#validators'; + +import { + decorateErrorWithIndex, + empty, + filterProperties, + openAggregate, + stitchArrays, + typeAppearance, + unique, + withAggregate, +} from '#sugar'; + +// Composes multiple compositional "steps" and a "base" to form a property +// descriptor out of modular building blocks. This is an extension to the +// more general-purpose CacheableObject property descriptor syntax, and +// aims to make modular data processing - which lends to declarativity - +// much easier, without fundamentally altering much of the typical syntax +// or terminology, nor building on it to an excessive degree. +// +// Think of a composition as being a chain of steps which lead into a final +// base property, which is usually responsible for returning the value that +// will actually get exposed when the property being described is accessed. +// +// == The compositional base: == +// +// The final item in a compositional list is its base, and it identifies +// the essential qualities of the property descriptor. The compositional +// steps preceding it may exit early, in which case the expose function +// defined on the base won't be called; or they will provide dependencies +// that the base may use to compute the final value that gets exposed for +// this property. +// +// The base indicates the capabilities of the composition as a whole. +// It should be {expose: true}, since that's the only area that preceding +// compositional steps (currently) can actually influence. If it's also +// {update: true}, then the composition as a whole accepts an update value +// just like normal update-flag property descriptors - meaning it can be +// set with `thing.someProperty = value` and that value will be paseed +// into each (implementing) step's transform() function, as well as the +// base. Bases usually aren't {compose: true}, but can be - check out the +// section on "nesting compositions" for details about that. +// +// Every composition always has exactly one compositional base, and it's +// always the last item in the composition list. All items preceding it +// are compositional steps, described below. +// +// == Compositional steps: == +// +// Compositional steps are, in essence, typical property descriptors with +// the extra flag {compose: true}. They operate on existing dependencies, +// and are typically dynamically constructed by "utility" functions (but +// can also be manually declared within the step list of a composition). +// Compositional steps serve two purposes: +// +// 1. exit early, if some condition is matched, returning and exposing +// some value directly from that step instead of continuing further +// down the step list; +// +// 2. and/or provide new, dynamically created "private" dependencies which +// can be accessed by further steps down the list, or at the base at +// the bottom, modularly supplying information that will contribute to +// the final value exposed for this property. +// +// Usually it's just one of those two, but it's fine for a step to perform +// both jobs if the situation benefits. +// +// Compositional steps are the real "modular" or "compositional" part of +// this data processing style - they're designed to be combined together +// in dynamic, versatile ways, as each property demands it. You usually +// define a compositional step to be returned by some ordinary static +// property-descriptor-returning function (customarily namespaced under +// the relevant Thing class's static `composite` field) - that lets you +// reuse it in multiple compositions later on. +// +// Compositional steps are implemented with "continuation passing style", +// meaning the connection to the next link on the chain is passed right to +// each step's compute (or transform) function, and the implementation gets +// to decide whether to continue on that chain or exit early by returning +// some other value. +// +// Every step along the chain, apart from the base at the bottom, has to +// have the {compose: true} step. That means its compute() or transform() +// function will be passed an extra argument at the end, `continuation`. +// To provide new dependencies to items further down the chain, just pass +// them directly to this continuation() function, customarily with a hash +// ('#') prefixing each name - for example: +// +// compute({..some dependencies..}, continuation) { +// return continuation({ +// '#excitingProperty': (..a value made from dependencies..), +// }); +// } +// +// Performing an early exit is as simple as returning some other value, +// instead of the continuation. You may also use `continuation.exit(value)` +// to perform the exact same kind of early exit - it's just a different +// syntax that might fit in better in certain longer compositions. +// +// It may be fine to simply provide new dependencies under a hard-coded +// name, such as '#excitingProperty' above, but if you're writing a utility +// that dynamically returns the compositional step and you suspect you +// might want to use this step multiple times in a single composition, +// it's customary to accept a name for the result. +// +// Here's a detailed example showing off early exit, dynamically operating +// on a provided dependency name, and then providing a result in another +// also-provided dependency name: +// +// withResolvedContribs = ({ +// from: contribsByRefDependency, +// into: outputDependency, +// }) => ({ +// flags: {expose: true, compose: true}, +// expose: { +// dependencies: [contribsByRefDependency, 'artistData'], +// compute({ +// [contribsByRefDependency]: contribsByRef, +// artistData, +// }, continuation) { +// if (!artistData) return null; /* early exit! */ +// return continuation({ +// [outputDependency]: /* this is the important part */ +// (..resolve contributions one way or another..), +// }); +// }, +// }, +// }); +// +// And how you might work that into a composition: +// +// Track.coverArtists = +// compositeFrom([ +// doSomethingWhichMightEarlyExit(), +// +// withResolvedContribs({ +// from: 'coverArtistContribsByRef', +// into: '#coverArtistContribs', +// }), +// +// { +// flags: {expose: true}, +// expose: { +// dependencies: ['#coverArtistContribs'], +// compute: ({'#coverArtistContribs': coverArtistContribs}) => +// coverArtistContribs.map(({who}) => who), +// }, +// }, +// ]); +// +// One last note! A super common code pattern when creating more complex +// compositions is to have several steps which *only* expose and compose. +// As a syntax shortcut, you can skip the outer section. It's basically +// like writing out just the {expose: {...}} part. Remember that this +// indicates that the step you're defining is compositional, so you have +// to specify the flags manually for the base, even if this property isn't +// going to get an {update: true} flag. +// +// == Cache-safe dependency names: == +// +// [Disclosure: The caching engine hasn't actually been implemented yet. +// As such, this section is subject to change, and simply provides sound +// forward-facing advice and interfaces.] +// +// It's a good idea to write individual compositional steps in such a way +// that they're "cache-safe" - meaning the same input (dependency) values +// will always result in the same output (continuation or early exit). +// +// In order to facilitate this, compositional step descriptors may specify +// unique `mapDependencies`, `mapContinuation`, and `options` values. +// +// Consider the `withResolvedContribs` example adjusted to make use of +// two of these options below: +// +// withResolvedContribs = ({ +// from: contribsByRefDependency, +// into: outputDependency, +// }) => ({ +// flags: {expose: true, compose: true}, +// expose: { +// dependencies: ['artistData'], +// mapDependencies: {contribsByRef: contribsByRefDependency}, +// mapContinuation: {outputDependency}, +// compute({ +// contribsByRef, /* no longer in square brackets */ +// artistData, +// }, continuation) { +// if (!artistData) return null; +// return continuation({ +// outputDependency: /* no longer in square brackets */ +// (..resolve contributions one way or another..), +// }); +// }, +// }, +// }); +// +// With a little destructuring and restructuring JavaScript sugar, the +// above can be simplified some more: +// +// withResolvedContribs = ({from, to}) => ({ +// flags: {expose: true, compose: true}, +// expose: { +// dependencies: ['artistData'], +// mapDependencies: {from}, +// mapContinuation: {into}, +// compute({artistData, from: contribsByRef}, continuation) { +// if (!artistData) return null; +// return continuation({ +// into: (..resolve contributions one way or another..), +// }); +// }, +// }, +// }); +// +// These two properties let you separate the name-mapping behavior (for +// dependencies and the continuation) from the main body of the compute +// function. That means the compute function will *always* get inputs in +// the same form (dependencies 'artistData' and 'from' above), and will +// *always* provide its output in the same form (early return or 'to'). +// +// Thanks to that, this `compute` function is cache-safe! Its outputs can +// be cached corresponding to each set of mapped inputs. So it won't matter +// whether the `from` dependency is named `coverArtistContribsByRef` or +// `contributorContribsByRef` or something else - the compute function +// doesn't care, and only expects that value to be provided via its `from` +// argument. Likewise, it doesn't matter if the output should be sent to +// '#coverArtistContribs` or `#contributorContribs` or some other name; +// the mapping is handled automatically outside, and compute will always +// output its value to the continuation's `to`. +// +// Note that `mapDependencies` and `mapContinuation` should be objects of +// the same "shape" each run - that is, the values will change depending on +// outside context, but the keys are always the same. You shouldn't use +// `mapDependencies` to dynamically select more or fewer dependencies. +// If you need to dynamically select a range of dependencies, just specify +// them in the `dependencies` array like usual. The caching engine will +// understand that differently named `dependencies` indicate separate +// input-output caches should be used. +// +// The 'options' property makes it possible to specify external arguments +// that fundamentally change the behavior of the `compute` function, while +// still remaining cache-safe. It indicates that the caching engine should +// use a completely different input-to-output cache for each permutation +// of the 'options' values. This way, those functions are still cacheable +// at all; they'll just be cached separately for each set of option values. +// Values on the 'options' property will always be provided in compute's +// dependencies under '#options' (to avoid name conflicts with other +// dependencies). +// +// == To compute or to transform: == +// +// A compositional step can work directly on a property's stored update +// value, transforming it in place and either early exiting with it or +// passing it on (via continuation) to the next item(s) in the +// compositional step list. (If needed, these can provide dependencies +// the same way as compute functions too - just pass that object after +// the updated (or same) transform value in your call to continuation().) +// +// But in order to make them more versatile, compositional steps have an +// extra trick up their sleeve. If a compositional step implements compute +// and *not* transform, it can still be used in a composition targeting a +// property which updates! These retain their full dependency-providing and +// early exit functionality - they just won't be provided the update value. +// If a compute-implementing step returns its continuation, then whichever +// later step (or the base) next implements transform() will receive the +// update value that had so far been running - as well as any dependencies +// the compute() step returned, of course! +// +// Please note that a compositional step which transforms *should not* +// specify, in its flags, {update: true}. Just provide the transform() +// function in its expose descriptor; it will be automatically detected +// and used when appropriate. +// +// It's actually possible for a step to specify both transform and compute, +// in which case the transform() implementation will only be selected if +// the composition's base is {update: true}. It's not exactly known why you +// would want to specify unique-but-related transform and compute behavior, +// but the basic possibility was too cool to skip out on. +// +// == Nesting compositions: == +// +// Compositional steps are so convenient that you just might want to bundle +// them together, and form a whole new step-shaped unit of its own! +// +// In order to allow for this while helping to ensure internal dependencies +// remain neatly isolated from the composition which nests your bundle, +// the compositeFrom() function will accept and adapt to a base that +// specifies the {compose: true} flag, just like the steps preceding it. +// +// The continuation function that gets provided to the base will be mildly +// special - after all, nothing follows the base within the composition's +// own list! Instead of appending dependencies alongside any previously +// provided ones to be available to the next step, the base's continuation +// function should be used to define "exports" of the composition as a +// whole. It's similar to the usual behavior of the continuation, just +// expanded to the scope of the composition instead of following steps. +// +// For example, suppose your composition (which you expect to include in +// other compositions) brings about several private, hash-prefixed +// dependencies to contribute to its own results. Those dependencies won't +// end up "bleeding" into the dependency list of whichever composition is +// nesting this one - they will totally disappear once all the steps in +// the nested composition have finished up. +// +// To "export" the results of processing all those dependencies (provided +// that's something you want to do and this composition isn't used purely +// for a conditional early-exit), you'll want to define them in the +// continuation passed to the base. (Customarily, those should start with +// a hash just like the exports from any other compositional step; they're +// still dynamically provided dependencies!) +// +// Another way to "export" dependencies is by using calling *any* step's +// `continuation.raise()` function. This is sort of like early exiting, +// but instead of quitting out the whole entire property, it will just +// break out of the current, nested composition's list of steps, acting +// as though the composition had finished naturally. The dependencies +// passed to `raise` will be the ones which get exported. +// +// Since `raise` is another way to export dependencies, if you're using +// dynamic export names, you should specify `mapContinuation` on the step +// calling `continuation.raise` as well. +// +// An important note on `mapDependencies` here: A nested composition gets +// free access to all the ordinary properties defined on the thing it's +// working on, but if you want it to depend on *private* dependencies - +// ones prefixed with '#' - which were provided by some other compositional +// step preceding wherever this one gets nested, then you *have* to use +// `mapDependencies` to gain access. Check out the section on "cache-safe +// dependency names" for information on this syntax! +// +// Also - on rare occasion - you might want to make a reusable composition +// that itself causes the composition *it's* nested in to raise. If that's +// the case, give `composition.raiseAbove()` a go! This effectively means +// kicking out of *two* layers of nested composition - the one including +// the step with the `raiseAbove` call, and the composition which that one +// is nested within. You don't need to use `raiseAbove` if the reusable +// utility function just returns a single compositional step, but if you +// want to make use of other compositional steps, it gives you access to +// the same conditional-raise capabilities. +// +// Have some syntax sugar! Since nested compositions are defined by having +// the base be {compose: true}, the composition will infer as much if you +// don't specifying the base's flags at all. Simply use the same shorthand +// syntax as for other compositional steps, and it'll work out cleanly! +// + +const globalCompositeCache = {}; + +const _valueIntoToken = shape => + (value = null) => + (value === null + ? Symbol.for(`hsmusic.composite.${shape}`) + : typeof value === 'string' + ? Symbol.for(`hsmusic.composite.${shape}:${value}`) + : { + symbol: Symbol.for(`hsmusic.composite.input`), + shape, + value, + }); + +export const input = _valueIntoToken('input'); +input.symbol = Symbol.for('hsmusic.composite.input'); + +input.value = _valueIntoToken('input.value'); +input.dependency = _valueIntoToken('input.dependency'); + +input.myself = () => Symbol.for(`hsmusic.composite.input.myself`); + +input.updateValue = _valueIntoToken('input.updateValue'); + +input.staticDependency = _valueIntoToken('input.staticDependency'); +input.staticValue = _valueIntoToken('input.staticValue'); + +function isInputToken(token) { + if (token === null) { + return false; + } else if (typeof token === 'object') { + return token.symbol === Symbol.for('hsmusic.composite.input'); + } else if (typeof token === 'symbol') { + return token.description.startsWith('hsmusic.composite.input'); + } else { + return false; + } +} + +function getInputTokenShape(token) { + if (!isInputToken(token)) { + throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`); + } + + if (typeof token === 'object') { + return token.shape; + } else { + return token.description.match(/hsmusic\.composite\.(input.*?)(:|$)/)[1]; + } +} + +function getInputTokenValue(token) { + if (!isInputToken(token)) { + throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`); + } + + if (typeof token === 'object') { + return token.value; + } else { + return token.description.match(/hsmusic\.composite\.input.*?:(.*)/)?.[1] ?? null; + } +} + +function getStaticInputMetadata(inputOptions) { + const metadata = {}; + + for (const [name, token] of Object.entries(inputOptions)) { + if (typeof token === 'string') { + metadata[input.staticDependency(name)] = token; + metadata[input.staticValue(name)] = null; + } else if (isInputToken(token)) { + const tokenShape = getInputTokenShape(token); + const tokenValue = getInputTokenValue(token); + + metadata[input.staticDependency(name)] = + (tokenShape === 'input.dependency' + ? tokenValue + : null); + + metadata[input.staticValue(name)] = + (tokenShape === 'input.value' + ? tokenValue + : null); + } else { + metadata[input.staticDependency(name)] = null; + metadata[input.staticValue(name)] = null; + } + } + + return metadata; +} + +function getCompositionName(description) { + return ( + (description.annotation + ? description.annotation + : `unnamed composite`)); +} + +function validateInputValue(value, description) { + const tokenValue = getInputTokenValue(description); + + const {acceptsNull, defaultValue, type, validate} = tokenValue || {}; + + if (value === null || value === undefined) { + if (acceptsNull || defaultValue === null) { + return true; + } else { + throw new TypeError( + (type + ? `Expected ${a(type)}, got ${typeAppearance(value)}` + : `Expected a value, got ${typeAppearance(value)}`)); + } + } + + if (type) { + // Note: null is already handled earlier in this function, so it won't + // cause any trouble here. + const typeofValue = + (typeof value === 'object' + ? Array.isArray(value) ? 'array' : 'object' + : typeof value); + + if (typeofValue !== type) { + throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`); + } + } + + if (validate) { + validate(value); + } + + return true; +} + +export function templateCompositeFrom(description) { + const compositionName = getCompositionName(description); + + withAggregate({message: `Errors in description for ${compositionName}`}, ({map, nest, push}) => { + if ('steps' in description) { + if (Array.isArray(description.steps)) { + push(new TypeError(`Wrap steps array in a function`)); + } else if (typeof description.steps !== 'function') { + push(new TypeError(`Expected steps to be a function (returning an array)`)); + } + } + + validateInputs: + if ('inputs' in description) { + if ( + Array.isArray(description.inputs) || + typeof description.inputs !== 'object' + ) { + push(new Error(`Expected inputs to be object, got ${typeAppearance(description.inputs)}`)); + break validateInputs; + } + + nest({message: `Errors in static input descriptions for ${compositionName}`}, ({push}) => { + const missingCallsToInput = []; + const wrongCallsToInput = []; + + for (const [name, value] of Object.entries(description.inputs)) { + if (!isInputToken(value)) { + missingCallsToInput.push(name); + continue; + } + + if (!['input', 'input.staticDependency', 'input.staticValue'].includes(getInputTokenShape(value))) { + wrongCallsToInput.push(name); + } + } + + for (const name of missingCallsToInput) { + push(new Error(`${name}: Missing call to input()`)); + } + + for (const name of wrongCallsToInput) { + const shape = getInputTokenShape(description.inputs[name]); + push(new Error(`${name}: Expected call to input, input.staticDependency, or input.staticValue, got ${shape}`)); + } + }); + } + + validateOutputs: + if ('outputs' in description) { + if ( + !Array.isArray(description.outputs) && + typeof description.outputs !== 'function' + ) { + push(new Error(`Expected outputs to be array or function, got ${typeAppearance(description.outputs)}`)); + break validateOutputs; + } + + if (Array.isArray(description.outputs)) { + map( + description.outputs, + decorateErrorWithIndex(value => { + if (typeof value !== 'string') { + throw new Error(`${value}: Expected string, got ${typeAppearance(value)}`) + } else if (!value.startsWith('#')) { + throw new Error(`${value}: Expected "#" at start`); + } + }), + {message: `Errors in output descriptions for ${compositionName}`}); + } + } + }); + + const expectedInputNames = + (description.inputs + ? Object.keys(description.inputs) + : []); + + const instantiate = (inputOptions = {}) => { + withAggregate({message: `Errors in input options passed to ${compositionName}`}, ({push}) => { + const providedInputNames = Object.keys(inputOptions); + + const misplacedInputNames = + providedInputNames + .filter(name => !expectedInputNames.includes(name)); + + const missingInputNames = + expectedInputNames + .filter(name => !providedInputNames.includes(name)) + .filter(name => { + const inputDescription = getInputTokenValue(description.inputs[name]); + if (!inputDescription) return true; + if ('defaultValue' in inputDescription) return false; + if ('defaultDependency' in inputDescription) return false; + return true; + }); + + const wrongTypeInputNames = []; + + const expectedStaticValueInputNames = []; + const expectedStaticDependencyInputNames = []; + const expectedValueProvidingTokenInputNames = []; + + const validateFailedErrors = []; + + for (const [name, value] of Object.entries(inputOptions)) { + if (misplacedInputNames.includes(name)) { + continue; + } + + if (typeof value !== 'string' && !isInputToken(value)) { + wrongTypeInputNames.push(name); + continue; + } + + const descriptionShape = getInputTokenShape(description.inputs[name]); + + const tokenShape = (isInputToken(value) ? getInputTokenShape(value) : null); + const tokenValue = (isInputToken(value) ? getInputTokenValue(value) : null); + + switch (descriptionShape) { + case'input.staticValue': + if (tokenShape !== 'input.value') { + expectedStaticValueInputNames.push(name); + continue; + } + break; + + case 'input.staticDependency': + if (typeof value !== 'string' && tokenShape !== 'input.dependency') { + expectedStaticDependencyInputNames.push(name); + continue; + } + break; + + case 'input': + if (typeof value !== 'string' && ![ + 'input', + 'input.value', + 'input.dependency', + 'input.myself', + 'input.updateValue', + ].includes(tokenShape)) { + expectedValueProvidingTokenInputNames.push(name); + continue; + } + break; + } + + if (tokenShape === 'input.value') { + try { + validateInputValue(tokenValue, description.inputs[name]); + } catch (error) { + error.message = `${name}: ${error.message}`; + validateFailedErrors.push(error); + } + } + } + + if (!empty(misplacedInputNames)) { + push(new Error(`Unexpected input names: ${misplacedInputNames.join(', ')}`)); + } + + if (!empty(missingInputNames)) { + push(new Error(`Required these inputs: ${missingInputNames.join(', ')}`)); + } + + const inputAppearance = name => + (isInputToken(inputOptions[name]) + ? `${getInputTokenShape(inputOptions[name])}() call` + : `dependency name`); + + for (const name of expectedStaticDependencyInputNames) { + const appearance = inputAppearance(name); + push(new Error(`${name}: Expected dependency name, got ${appearance}`)); + } + + for (const name of expectedStaticValueInputNames) { + const appearance = inputAppearance(name) + push(new Error(`${name}: Expected input.value() call, got ${appearance}`)); + } + + for (const name of expectedValueProvidingTokenInputNames) { + const appearance = getInputTokenShape(inputOptions[name]); + push(new Error(`${name}: Expected dependency name or value-providing input() call, got ${appearance}`)); + } + + for (const name of wrongTypeInputNames) { + const type = typeAppearance(inputOptions[name]); + push(new Error(`${name}: Expected dependency name or input() call, got ${type}`)); + } + + for (const error of validateFailedErrors) { + push(error); + } + }); + + const inputMetadata = getStaticInputMetadata(inputOptions); + + const expectedOutputNames = + (Array.isArray(description.outputs) + ? description.outputs + : typeof description.outputs === 'function' + ? description.outputs(inputMetadata) + .map(name => + (name.startsWith('#') + ? name + : '#' + name)) + : []); + + const ownUpdateDescription = + (typeof description.update === 'object' + ? description.update + : typeof description.update === 'function' + ? description.update(inputMetadata) + : null); + + const outputOptions = {}; + + const instantiatedTemplate = { + symbol: templateCompositeFrom.symbol, + + outputs(providedOptions) { + withAggregate({message: `Errors in output options passed to ${compositionName}`}, ({push}) => { + const misplacedOutputNames = []; + const wrongTypeOutputNames = []; + + for (const [name, value] of Object.entries(providedOptions)) { + if (!expectedOutputNames.includes(name)) { + misplacedOutputNames.push(name); + continue; + } + + if (typeof value !== 'string') { + wrongTypeOutputNames.push(name); + continue; + } + } + + if (!empty(misplacedOutputNames)) { + push(new Error(`Unexpected output names: ${misplacedOutputNames.join(', ')}`)); + } + + for (const name of wrongTypeOutputNames) { + const appearance = typeAppearance(providedOptions[name]); + push(new Error(`${name}: Expected string, got ${appearance}`)); + } + }); + + Object.assign(outputOptions, providedOptions); + return instantiatedTemplate; + }, + + toDescription() { + const finalDescription = {}; + + if ('annotation' in description) { + finalDescription.annotation = description.annotation; + } + + if ('compose' in description) { + finalDescription.compose = description.compose; + } + + if (ownUpdateDescription) { + finalDescription.update = ownUpdateDescription; + } + + if ('inputs' in description) { + const inputMapping = {}; + + for (const [name, token] of Object.entries(description.inputs)) { + const tokenValue = getInputTokenValue(token); + if (name in inputOptions) { + if (typeof inputOptions[name] === 'string') { + inputMapping[name] = input.dependency(inputOptions[name]); + } else { + inputMapping[name] = inputOptions[name]; + } + } else if (tokenValue.defaultValue) { + inputMapping[name] = input.value(tokenValue.defaultValue); + } else if (tokenValue.defaultDependency) { + inputMapping[name] = input.dependency(tokenValue.defaultDependency); + } else { + inputMapping[name] = input.value(null); + } + } + + finalDescription.inputMapping = inputMapping; + finalDescription.inputDescriptions = description.inputs; + } + + if ('outputs' in description) { + const finalOutputs = {}; + + for (const name of expectedOutputNames) { + if (name in outputOptions) { + finalOutputs[name] = outputOptions[name]; + } else { + finalOutputs[name] = name; + } + } + + finalDescription.outputs = finalOutputs; + } + + if ('steps' in description) { + finalDescription.steps = description.steps; + } + + return finalDescription; + }, + + toResolvedComposition() { + const ownDescription = instantiatedTemplate.toDescription(); + + const finalDescription = {...ownDescription}; + + const aggregate = openAggregate({message: `Errors resolving ${compositionName}`}); + + const steps = ownDescription.steps(); + + const resolvedSteps = + aggregate.map( + steps, + decorateErrorWithIndex(step => + (step.symbol === templateCompositeFrom.symbol + ? compositeFrom(step.toResolvedComposition()) + : step)), + {message: `Errors resolving steps`}); + + aggregate.close(); + + finalDescription.steps = resolvedSteps; + + return finalDescription; + }, + }; + + return instantiatedTemplate; + }; + + instantiate.inputs = instantiate; + + return instantiate; +} + +templateCompositeFrom.symbol = Symbol(); + +export const continuationSymbol = Symbol.for('compositeFrom: continuation symbol'); +export const noTransformSymbol = Symbol.for('compositeFrom: no-transform symbol'); + +export function compositeFrom(description) { + const {annotation} = description; + const compositionName = getCompositionName(description); + + const debug = fn => { + if (compositeFrom.debug === true) { + const label = + (annotation + ? colors.dim(`[composite: ${annotation}]`) + : colors.dim(`[composite]`)); + const result = fn(); + if (Array.isArray(result)) { + console.log(label, ...result.map(value => + (typeof value === 'object' + ? inspect(value, {depth: 1, colors: true, compact: true, breakLength: Infinity}) + : value))); + } else { + console.log(label, result); + } + } + }; + + if (!Array.isArray(description.steps)) { + throw new TypeError( + `Expected steps to be array, got ${typeAppearance(description.steps)}` + + (annotation ? ` (${annotation})` : '')); + } + + const composition = + description.steps.map(step => + ('toResolvedComposition' in step + ? compositeFrom(step.toResolvedComposition()) + : step)); + + const inputMetadata = getStaticInputMetadata(description.inputMapping ?? {}); + + function _mapDependenciesToOutputs(providedDependencies) { + if (!description.outputs) { + return {}; + } + + if (!providedDependencies) { + return {}; + } + + return ( + Object.fromEntries( + Object.entries(description.outputs) + .map(([continuationName, outputName]) => [ + outputName, + (continuationName in providedDependencies + ? providedDependencies[continuationName] + : providedDependencies[continuationName.replace(/^#/, '')]), + ]))); + } + + // These dependencies were all provided by the composition which this one is + // nested inside, so input('name')-shaped tokens are going to be evaluated + // in the context of the containing composition. + const dependenciesFromInputs = + Object.values(description.inputMapping ?? {}) + .map(token => { + const tokenShape = getInputTokenShape(token); + const tokenValue = getInputTokenValue(token); + switch (tokenShape) { + case 'input.dependency': + return tokenValue; + case 'input': + case 'input.updateValue': + return token; + case 'input.myself': + return 'this'; + default: + return null; + } + }) + .filter(Boolean); + + const anyInputsUseUpdateValue = + dependenciesFromInputs + .filter(dependency => isInputToken(dependency)) + .some(token => getInputTokenShape(token) === 'input.updateValue'); + + const inputNames = + Object.keys(description.inputMapping ?? {}); + + const inputSymbols = + inputNames.map(name => input(name)); + + const inputsMayBeDynamicValue = + stitchArrays({ + mappingToken: Object.values(description.inputMapping ?? {}), + descriptionToken: Object.values(description.inputDescriptions ?? {}), + }).map(({mappingToken, descriptionToken}) => { + if (getInputTokenShape(descriptionToken) === 'input.staticValue') return false; + if (getInputTokenShape(mappingToken) === 'input.value') return false; + return true; + }); + + const inputDescriptions = + Object.values(description.inputDescriptions ?? {}); + + /* + const inputsAcceptNull = + Object.values(description.inputDescriptions ?? {}) + .map(token => { + const tokenValue = getInputTokenValue(token); + if (!tokenValue) return false; + if ('acceptsNull' in tokenValue) return tokenValue.acceptsNull; + if ('defaultValue' in tokenValue) return tokenValue.defaultValue === null; + return false; + }); + */ + + // Update descriptions passed as the value in an input.updateValue() token, + // as provided as inputs for this composition. + const inputUpdateDescriptions = + Object.values(description.inputMapping ?? {}) + .map(token => + (getInputTokenShape(token) === 'input.updateValue' + ? getInputTokenValue(token) + : null)) + .filter(Boolean); + + const base = composition.at(-1); + const steps = composition.slice(); + + const aggregate = openAggregate({ + message: + `Errors preparing composition` + + (annotation ? ` (${annotation})` : ''), + }); + + const compositionNests = description.compose ?? true; + + // Steps default to exposing if using a shorthand syntax where flags aren't + // specified at all. + const stepsExpose = + steps + .map(step => + (step.flags + ? step.flags.expose ?? false + : true)); + + // Steps default to composing if using a shorthand syntax where flags aren't + // specified at all - *and* aren't the base (final step), unless the whole + // composition is nestable. + const stepsCompose = + steps + .map((step, index, {length}) => + (step.flags + ? step.flags.compose ?? false + : (index === length - 1 + ? compositionNests + : true))); + + // Steps update if the corresponding flag is explicitly set, if a transform + // function is provided, or if the dependencies include an input.updateValue + // token. + const stepsUpdate = + steps + .map(step => + (step.flags + ? step.flags.update ?? false + : !!step.transform || + !!step.dependencies?.some(dependency => + isInputToken(dependency) && + getInputTokenShape(dependency) === 'input.updateValue'))); + + // The expose description for a step is just the entire step object, when + // using the shorthand syntax where {flags: {expose: true}} is left implied. + const stepExposeDescriptions = + steps + .map((step, index) => + (stepsExpose[index] + ? (step.flags + ? step.expose ?? null + : step) + : null)); + + // The update description for a step, if present at all, is always set + // explicitly. There may be multiple per step - namely that step's own + // {update} description, and any descriptions passed as the value in an + // input.updateValue({...}) token. + const stepUpdateDescriptions = + steps + .map((step, index) => + (stepsUpdate[index] + ? [ + step.update ?? null, + ...(stepExposeDescriptions[index]?.dependencies ?? []) + .filter(dependency => isInputToken(dependency)) + .filter(token => getInputTokenShape(token) === 'input.updateValue') + .map(token => getInputTokenValue(token)), + ].filter(Boolean) + : [])); + + // Indicates presence of a {compute} function on the expose description. + const stepsCompute = + stepExposeDescriptions + .map(expose => !!expose?.compute); + + // Indicates presence of a {transform} function on the expose description. + const stepsTransform = + stepExposeDescriptions + .map(expose => !!expose?.transform); + + const dependenciesFromSteps = + unique( + stepExposeDescriptions + .flatMap(expose => expose?.dependencies ?? []) + .map(dependency => { + if (typeof dependency === 'string') + return (dependency.startsWith('#') ? null : dependency); + + const tokenShape = getInputTokenShape(dependency); + const tokenValue = getInputTokenValue(dependency); + switch (tokenShape) { + case 'input.dependency': + return (tokenValue.startsWith('#') ? null : tokenValue); + case 'input.myself': + return 'this'; + default: + return null; + } + }) + .filter(Boolean)); + + const anyStepsUseUpdateValue = + stepExposeDescriptions + .some(expose => + (expose?.dependencies + ? expose.dependencies.includes(input.updateValue()) + : false)); + + const anyStepsExpose = + stepsExpose.includes(true); + + const anyStepsUpdate = + stepsUpdate.includes(true); + + const anyStepsCompute = + stepsCompute.includes(true); + + const anyStepsTransform = + stepsTransform.includes(true); + + const compositionExposes = + anyStepsExpose; + + const compositionUpdates = + 'update' in description || + anyInputsUseUpdateValue || + anyStepsUseUpdateValue || + anyStepsUpdate; + + const stepEntries = stitchArrays({ + step: steps, + stepComposes: stepsCompose, + stepComputes: stepsCompute, + stepTransforms: stepsTransform, + }); + + for (let i = 0; i < stepEntries.length; i++) { + const { + step, + stepComposes, + stepComputes, + stepTransforms, + } = stepEntries[i]; + + const isBase = i === stepEntries.length - 1; + const message = + `Errors in step #${i + 1}` + + (isBase ? ` (base)` : ``) + + (step.annotation ? ` (${step.annotation})` : ``); + + aggregate.nest({message}, ({push}) => { + if (isBase && stepComposes !== compositionNests) { + return push(new TypeError( + (compositionNests + ? `Base must compose, this composition is nestable` + : `Base must not compose, this composition isn't nestable`))); + } else if (!isBase && !stepComposes) { + return push(new TypeError( + (compositionNests + ? `All steps must compose` + : `All steps (except base) must compose`))); + } + + if ( + !compositionNests && !compositionUpdates && + stepTransforms && !stepComputes + ) { + return push(new TypeError( + `Steps which only transform can't be used in a composition that doesn't update`)); + } + }); + } + + if (!compositionNests && !anyStepsCompute && !anyStepsTransform) { + aggregate.push(new TypeError(`Expected at least one step to compute or transform`)); + } + + aggregate.close(); + + function _prepareContinuation(callingTransformForThisStep) { + const continuationStorage = { + returnedWith: null, + providedDependencies: undefined, + providedValue: undefined, + }; + + const continuation = + (callingTransformForThisStep + ? (providedValue, providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + } + : (providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + return continuationSymbol; + }); + + continuation.exit = (providedValue) => { + continuationStorage.returnedWith = 'exit'; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + }; + + if (compositionNests) { + const makeRaiseLike = returnWith => + (callingTransformForThisStep + ? (providedValue, providedDependencies = null) => { + continuationStorage.returnedWith = returnWith; + continuationStorage.providedDependencies = providedDependencies; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + } + : (providedDependencies = null) => { + continuationStorage.returnedWith = returnWith; + continuationStorage.providedDependencies = providedDependencies; + return continuationSymbol; + }); + + continuation.raiseOutput = makeRaiseLike('raiseOutput'); + continuation.raiseOutputAbove = makeRaiseLike('raiseOutputAbove'); + } + + return {continuation, continuationStorage}; + } + + function _computeOrTransform(initialValue, continuationIfApplicable, initialDependencies) { + const expectingTransform = initialValue !== noTransformSymbol; + + let valueSoFar = + (expectingTransform + ? initialValue + : undefined); + + const availableDependencies = {...initialDependencies}; + + const inputValues = + Object.values(description.inputMapping ?? {}) + .map(token => { + const tokenShape = getInputTokenShape(token); + const tokenValue = getInputTokenValue(token); + switch (tokenShape) { + case 'input.dependency': + return initialDependencies[tokenValue]; + case 'input.value': + return tokenValue; + case 'input.updateValue': + if (!expectingTransform) + throw new Error(`Unexpected input.updateValue() accessed on non-transform call`); + return valueSoFar; + case 'input.myself': + return initialDependencies['this']; + case 'input': + return initialDependencies[token]; + default: + throw new TypeError(`Unexpected input shape ${tokenShape}`); + } + }); + + withAggregate({message: `Errors in input values provided to ${compositionName}`}, ({push}) => { + for (const {dynamic, name, value, description} of stitchArrays({ + dynamic: inputsMayBeDynamicValue, + name: inputNames, + value: inputValues, + description: inputDescriptions, + })) { + if (!dynamic) continue; + try { + validateInputValue(value, description); + } catch (error) { + error.message = `${name}: ${error.message}`; + push(error); + } + } + }); + + if (expectingTransform) { + debug(() => [colors.bright(`begin composition - transforming from:`), initialValue]); + } else { + debug(() => colors.bright(`begin composition - not transforming`)); + } + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const isBase = i === steps.length - 1; + + debug(() => [ + `step #${i+1}` + + (isBase + ? ` (base):` + : ` of ${steps.length}:`), + step]); + + const expose = + (step.flags + ? step.expose + : step); + + if (!expose) { + if (!isBase) { + debug(() => `step #${i+1} - no expose description, nothing to do for this step`); + continue; + } + + if (expectingTransform) { + debug(() => `step #${i+1} (base) - no expose description, returning so-far update value:`, valueSoFar); + if (continuationIfApplicable) { + debug(() => colors.bright(`end composition - raise (inferred - composing)`)); + return continuationIfApplicable(valueSoFar); + } else { + debug(() => colors.bright(`end composition - exit (inferred - not composing)`)); + return valueSoFar; + } + } else { + debug(() => `step #${i+1} (base) - no expose description, nothing to continue with`); + if (continuationIfApplicable) { + debug(() => colors.bright(`end composition - raise (inferred - composing)`)); + return continuationIfApplicable(); + } else { + debug(() => colors.bright(`end composition - exit (inferred - not composing)`)); + return null; + } + } + } + + const callingTransformForThisStep = + expectingTransform && expose.transform; + + let continuationStorage; + + const inputDictionary = + Object.fromEntries( + stitchArrays({symbol: inputSymbols, value: inputValues}) + .map(({symbol, value}) => [symbol, value])); + + const filterableDependencies = { + ...availableDependencies, + ...inputMetadata, + ...inputDictionary, + ... + (expectingTransform + ? {[input.updateValue()]: valueSoFar} + : {}), + [input.myself()]: initialDependencies?.['this'] ?? null, + }; + + const selectDependencies = + (expose.dependencies ?? []).map(dependency => { + if (!isInputToken(dependency)) return dependency; + const tokenShape = getInputTokenShape(dependency); + const tokenValue = getInputTokenValue(dependency); + switch (tokenShape) { + case 'input': + case 'input.staticDependency': + case 'input.staticValue': + return dependency; + case 'input.myself': + return input.myself(); + case 'input.dependency': + return tokenValue; + case 'input.updateValue': + return input.updateValue(); + default: + throw new Error(`Unexpected token ${tokenShape} as dependency`); + } + }) + + const filteredDependencies = + filterProperties(filterableDependencies, selectDependencies); + + debug(() => [ + `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`, + `with dependencies:`, filteredDependencies, + `selecting:`, selectDependencies, + `from available:`, filterableDependencies, + ...callingTransformForThisStep ? [`from value:`, valueSoFar] : []]); + + let result; + + const getExpectedEvaluation = () => + (callingTransformForThisStep + ? (filteredDependencies + ? ['transform', valueSoFar, continuationSymbol, filteredDependencies] + : ['transform', valueSoFar, continuationSymbol]) + : (filteredDependencies + ? ['compute', continuationSymbol, filteredDependencies] + : ['compute', continuationSymbol])); + + const naturalEvaluate = () => { + const [name, ...argsLayout] = getExpectedEvaluation(); + + let args; + + if (isBase && !compositionNests) { + args = + argsLayout.filter(arg => arg !== continuationSymbol); + } else { + let continuation; + + ({continuation, continuationStorage} = + _prepareContinuation(callingTransformForThisStep)); + + args = + argsLayout.map(arg => + (arg === continuationSymbol + ? continuation + : arg)); + } + + return expose[name](...args); + } + + switch (step.cache) { + // Warning! Highly WIP! + case 'aggressive': { + const hrnow = () => { + const hrTime = process.hrtime(); + return hrTime[0] * 1000000000 + hrTime[1]; + }; + + const [name, ...args] = getExpectedEvaluation(); + + let cache = globalCompositeCache[step.annotation]; + if (!cache) { + cache = globalCompositeCache[step.annotation] = { + transform: new TupleMap(), + compute: new TupleMap(), + times: { + read: [], + evaluate: [], + }, + }; + } + + const tuplefied = args + .flatMap(arg => [ + Symbol.for('compositeFrom: tuplefied arg divider'), + ...(typeof arg !== 'object' || Array.isArray(arg) + ? [arg] + : Object.entries(arg).flat()), + ]); + + const readTime = hrnow(); + const cacheContents = cache[name].get(tuplefied); + cache.times.read.push(hrnow() - readTime); + + if (cacheContents) { + ({result, continuationStorage} = cacheContents); + } else { + const evaluateTime = hrnow(); + result = naturalEvaluate(); + cache.times.evaluate.push(hrnow() - evaluateTime); + cache[name].set(tuplefied, {result, continuationStorage}); + } + + break; + } + + default: { + result = naturalEvaluate(); + break; + } + } + + if (result !== continuationSymbol) { + debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]); + + if (compositionNests) { + throw new TypeError(`Inferred early-exit is disallowed in nested compositions`); + } + + debug(() => colors.bright(`end composition - exit (inferred)`)); + + return result; + } + + const {returnedWith} = continuationStorage; + + if (returnedWith === 'exit') { + const {providedValue} = continuationStorage; + + debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]); + debug(() => colors.bright(`end composition - exit (explicit)`)); + + if (compositionNests) { + return continuationIfApplicable.exit(providedValue); + } else { + return providedValue; + } + } + + const {providedValue, providedDependencies} = continuationStorage; + + const continuationArgs = []; + if (expectingTransform) { + continuationArgs.push( + (callingTransformForThisStep + ? providedValue ?? null + : valueSoFar ?? null)); + } + + debug(() => { + const base = `step #${i+1} - result: ` + returnedWith; + const parts = []; + + if (callingTransformForThisStep) { + parts.push('value:', providedValue); + } + + if (providedDependencies !== null) { + parts.push(`deps:`, providedDependencies); + } else { + parts.push(`(no deps)`); + } + + if (empty(parts)) { + return base; + } else { + return [base + ' ->', ...parts]; + } + }); + + switch (returnedWith) { + case 'raiseOutput': + debug(() => + (isBase + ? colors.bright(`end composition - raiseOutput (base: explicit)`) + : colors.bright(`end composition - raiseOutput`))); + continuationArgs.push(_mapDependenciesToOutputs(providedDependencies)); + return continuationIfApplicable(...continuationArgs); + + case 'raiseOutputAbove': + debug(() => colors.bright(`end composition - raiseOutputAbove`)); + continuationArgs.push(_mapDependenciesToOutputs(providedDependencies)); + return continuationIfApplicable.raiseOutput(...continuationArgs); + + case 'continuation': + if (isBase) { + debug(() => colors.bright(`end composition - raiseOutput (inferred)`)); + continuationArgs.push(_mapDependenciesToOutputs(providedDependencies)); + return continuationIfApplicable(...continuationArgs); + } else { + Object.assign(availableDependencies, providedDependencies); + if (callingTransformForThisStep && providedValue !== null) { + valueSoFar = providedValue; + } + break; + } + } + } + } + + const constructedDescriptor = {}; + + if (annotation) { + constructedDescriptor.annotation = annotation; + } + + constructedDescriptor.flags = { + update: compositionUpdates, + expose: compositionExposes, + compose: compositionNests, + }; + + if (compositionUpdates) { + // TODO: This is a dumb assign statement, and it could probably do more + // interesting things, like combining validation functions. + constructedDescriptor.update = + Object.assign( + {...description.update ?? {}}, + ...inputUpdateDescriptions, + ...stepUpdateDescriptions.flat()); + } + + if (compositionExposes) { + const expose = constructedDescriptor.expose = {}; + + expose.dependencies = + unique([ + ...dependenciesFromInputs, + ...dependenciesFromSteps, + ]); + + const _wrapper = (...args) => { + try { + return _computeOrTransform(...args); + } catch (thrownError) { + const error = new Error( + `Error computing composition` + + (annotation ? ` ${annotation}` : '')); + error.cause = thrownError; + throw error; + } + }; + + if (compositionNests) { + if (compositionUpdates) { + expose.transform = (value, continuation, dependencies) => + _wrapper(value, continuation, dependencies); + } + + if (anyStepsCompute && !anyStepsUseUpdateValue && !anyInputsUseUpdateValue) { + expose.compute = (continuation, dependencies) => + _wrapper(noTransformSymbol, continuation, dependencies); + } + + if (base.cacheComposition) { + expose.cache = base.cacheComposition; + } + } else if (compositionUpdates) { + expose.transform = (value, dependencies) => + _wrapper(value, null, dependencies); + } else { + expose.compute = (dependencies) => + _wrapper(noTransformSymbol, null, dependencies); + } + } + + return constructedDescriptor; +} + +export function displayCompositeCacheAnalysis() { + const showTimes = (cache, key) => { + const times = cache.times[key].slice().sort(); + + const all = times; + const worst10pc = times.slice(-times.length / 10); + const best10pc = times.slice(0, times.length / 10); + const middle50pc = times.slice(times.length / 4, -times.length / 4); + const middle80pc = times.slice(times.length / 10, -times.length / 10); + + const fmt = val => `${(val / 1000).toFixed(2)}ms`.padStart(9); + const avg = times => times.reduce((a, b) => a + b, 0) / times.length; + + const left = ` - ${key}: `; + const indn = ' '.repeat(left.length); + console.log(left + `${fmt(avg(all))} (all ${all.length})`); + console.log(indn + `${fmt(avg(worst10pc))} (worst 10%)`); + console.log(indn + `${fmt(avg(best10pc))} (best 10%)`); + console.log(indn + `${fmt(avg(middle80pc))} (middle 80%)`); + console.log(indn + `${fmt(avg(middle50pc))} (middle 50%)`); + }; + + for (const [annotation, cache] of Object.entries(globalCompositeCache)) { + console.log(`Cached ${annotation}:`); + showTimes(cache, 'evaluate'); + showTimes(cache, 'read'); + } +} + +// Evaluates a function with composite debugging enabled, turns debugging +// off again, and returns the result of the function. This is mostly syntax +// sugar, but also helps avoid unit tests avoid accidentally printing debug +// info for a bunch of unrelated composites (due to property enumeration +// when displaying an unexpected result). Use as so: +// +// Without debugging: +// t.same(thing.someProp, value) +// +// With debugging: +// t.same(debugComposite(() => thing.someProp), value) +// +export function debugComposite(fn) { + compositeFrom.debug = true; + const value = fn(); + compositeFrom.debug = false; + return value; +} + +// Exposes a dependency exactly as it is; this is typically the base of a +// composition which was created to serve as one property's descriptor. +// +// Please note that this *doesn't* verify that the dependency exists, so +// if you provide the wrong name or it hasn't been set by a previous +// compositional step, the property will be exposed as undefined instead +// of null. +// +export const exposeDependency = templateCompositeFrom({ + annotation: `exposeDependency`, + + compose: false, + + inputs: { + dependency: input.staticDependency({acceptsNull: true}), + }, + + steps: () => [ + { + dependencies: [input('dependency')], + compute: ({ + [input('dependency')]: dependency + }) => dependency, + }, + ], +}); + +// Exposes a constant value exactly as it is; like exposeDependency, this +// is typically the base of a composition serving as a particular property +// descriptor. It generally follows steps which will conditionally early +// exit with some other value, with the exposeConstant base serving as the +// fallback default value. +export const exposeConstant = templateCompositeFrom({ + annotation: `exposeConstant`, + + compose: false, + + inputs: { + value: input.staticValue(), + }, + + steps: () => [ + { + dependencies: [input('value')], + compute: ({ + [input('value')]: value, + }) => value, + }, + ], +}); + +// Checks the availability of a dependency and provides the result to later +// steps under '#availability' (by default). This is mainly intended for use +// by the more specific utilities, which you should consider using instead. +// Customize {mode} to select one of these modes, or default to 'null': +// +// * 'null': Check that the value isn't null (and not undefined either). +// * 'empty': Check that the value is neither null, undefined, nor an empty +// array. +// * 'falsy': Check that the value isn't false when treated as a boolean +// (nor an empty array). Keep in mind this will also be false +// for values like zero and the empty string! +// + +const inputAvailabilityCheckMode = () => input({ + validate: is('null', 'empty', 'falsy'), + defaultValue: 'null', +}); + +export const withResultOfAvailabilityCheck = templateCompositeFrom({ + annotation: `withResultOfAvailabilityCheck`, + + inputs: { + from: input({acceptsNull: true}), + mode: inputAvailabilityCheckMode(), + }, + + outputs: ['#availability'], + + steps: () => [ + { + dependencies: [input('from'), input('mode')], + + compute: (continuation, { + [input('from')]: value, + [input('mode')]: mode, + }) => { + let availability; + + switch (mode) { + case 'null': + availability = value !== undefined && value !== null; + break; + + case 'empty': + availability = value !== undefined && !empty(value); + break; + + case 'falsy': + availability = !!value && (!Array.isArray(value) || !empty(value)); + break; + } + + return continuation({'#availability': availability}); + }, + }, + ], +}); + +// Exposes a dependency as it is, or continues if it's unavailable. +// See withResultOfAvailabilityCheck for {mode} options! +export const exposeDependencyOrContinue = templateCompositeFrom({ + annotation: `exposeDependencyOrContinue`, + + inputs: { + dependency: input({acceptsNull: true}), + mode: inputAvailabilityCheckMode(), + }, + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('dependency'), + mode: input('mode'), + }), + + { + dependencies: ['#availability', input('dependency')], + compute: (continuation, { + ['#availability']: availability, + [input('dependency')]: dependency, + }) => + (availability + ? continuation.exit(dependency) + : continuation()), + }, + ], +}); + +// Exposes the update value of an {update: true} property as it is, +// or continues if it's unavailable. See withResultOfAvailabilityCheck +// for {mode} options! Also provide {validate} here to conveniently +// set a custom validation check for this property's update value. +export const exposeUpdateValueOrContinue = templateCompositeFrom({ + annotation: `exposeUpdateValueOrContinue`, + + inputs: { + mode: inputAvailabilityCheckMode(), + + validate: input({ + type: 'function', + defaultValue: null, + }), + }, + + update: ({ + [input.staticValue('validate')]: validate, + }) => + (validate + ? {validate} + : {}), + + steps: () => [ + exposeDependencyOrContinue({ + dependency: input.updateValue(), + mode: input('mode'), + }), + ], +}); + +// Early exits if a dependency isn't available. +// See withResultOfAvailabilityCheck for {mode} options! +export const exitWithoutDependency = templateCompositeFrom({ + annotation: `exitWithoutDependency`, + + inputs: { + dependency: input({acceptsNull: true}), + mode: inputAvailabilityCheckMode(), + value: input({defaultValue: null}), + }, + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('dependency'), + mode: input('mode'), + }), + + { + dependencies: ['#availability', input('value')], + compute: (continuation, { + ['#availability']: availability, + [input('value')]: value, + }) => + (availability + ? continuation() + : continuation.exit(value)), + }, + ], +}); + +// Early exits if this property's update value isn't available. +// See withResultOfAvailabilityCheck for {mode} options! +export const exitWithoutUpdateValue = templateCompositeFrom({ + annotation: `exitWithoutUpdateValue`, + + inputs: { + mode: inputAvailabilityCheckMode(), + value: input({defaultValue: null}), + }, + + steps: () => [ + exitWithoutDependency({ + dependency: input.updateValue(), + mode: input('mode'), + value: input('value'), + }), + ], +}); + +// Raises if a dependency isn't available. +// See withResultOfAvailabilityCheck for {mode} options! +export const raiseOutputWithoutDependency = templateCompositeFrom({ + annotation: `raiseOutputWithoutDependency`, + + inputs: { + dependency: input({acceptsNull: true}), + mode: inputAvailabilityCheckMode(), + output: input.staticValue({defaultValue: {}}), + }, + + outputs: ({ + [input.staticValue('output')]: output, + }) => Object.keys(output), + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('dependency'), + mode: input('mode'), + }), + + { + dependencies: ['#availability', input('output')], + compute: (continuation, { + ['#availability']: availability, + [input('output')]: output, + }) => + (availability + ? continuation() + : continuation.raiseOutputAbove(output)), + }, + ], +}); + +// Raises if this property's update value isn't available. +// See withResultOfAvailabilityCheck for {mode} options! +export const raiseOutputWithoutUpdateValue = templateCompositeFrom({ + annotation: `raiseOutputWithoutUpdateValue`, + + inputs: { + mode: inputAvailabilityCheckMode(), + output: input.staticValue({defaultValue: {}}), + }, + + outputs: ({ + [input.staticValue('output')]: output, + }) => Object.keys(output), + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input.updateValue(), + mode: input('mode'), + }), + + { + dependencies: ['#availability', input('output')], + compute: (continuation, { + ['#availability']: availability, + [input('output')]: output, + }) => + (availability + ? continuation() + : continuation.raiseOutputAbove(output)), + }, + ], +}); + +// Gets a property of some object (in a dependency) and provides that value. +// If the object itself is null, or the object doesn't have the listed property, +// the provided dependency will also be null. +export const withPropertyFromObject = templateCompositeFrom({ + annotation: `withPropertyFromObject`, + + inputs: { + object: input({type: 'object', acceptsNull: true}), + property: input({type: 'string'}), + }, + + outputs: ({ + [input.staticDependency('object')]: object, + [input.staticValue('property')]: property, + }) => + (object && property + ? (object.startsWith('#') + ? [`${object}.${property}`] + : [`#${object}.${property}`]) + : ['#value']), + + steps: () => [ + { + dependencies: [ + input.staticDependency('object'), + input.staticValue('property'), + ], + + compute: (continuation, { + [input.staticDependency('object')]: object, + [input.staticValue('property')]: property, + }) => continuation({ + '#output': + (object && property + ? (object.startsWith('#') + ? `${object}.${property}` + : `#${object}.${property}`) + : '#value'), + }), + }, + + { + dependencies: [ + '#output', + input('object'), + input('property'), + ], + + compute: (continuation, { + ['#output']: output, + [input('object')]: object, + [input('property')]: property, + }) => continuation({ + [output]: + (object === null + ? null + : object[property] ?? null), + }), + }, + ], +}); + +// Gets the listed properties from some object, providing each property's value +// as a dependency prefixed with the same name as the object (by default). +// If the object itself is null, all provided dependencies will be null; +// if it's missing only select properties, those will be provided as null. +export const withPropertiesFromObject = templateCompositeFrom({ + annotation: `withPropertiesFromObject`, + + inputs: { + object: input({type: 'object', acceptsNull: true}), + + properties: input({ + type: 'array', + validate: validateArrayItems(isString), + }), + + prefix: input.staticValue({type: 'string', defaultValue: null}), + }, + + outputs: ({ + [input.staticDependency('object')]: object, + [input.staticValue('properties')]: properties, + [input.staticValue('prefix')]: prefix, + }) => + (properties + ? properties.map(property => + (prefix + ? `${prefix}.${property}` + : object + ? `${object}.${property}` + : `#object.${property}`)) + : ['#object']), + + steps: () => [ + { + dependencies: [input('object'), input('properties')], + compute: (continuation, { + [input('object')]: object, + [input('properties')]: properties, + }) => continuation({ + ['#entries']: + (object === null + ? properties.map(property => [property, null]) + : properties.map(property => [property, object[property]])), + }), + }, + + { + dependencies: [ + input.staticDependency('object'), + input.staticValue('properties'), + input.staticValue('prefix'), + '#entries', + ], + + compute: (continuation, { + [input.staticDependency('object')]: object, + [input.staticValue('properties')]: properties, + [input.staticValue('prefix')]: prefix, + ['#entries']: entries, + }) => + (properties + ? continuation( + Object.fromEntries( + entries.map(([property, value]) => [ + (prefix + ? `${prefix}.${property}` + : object + ? `${object}.${property}` + : `#object.${property}`), + value ?? null, + ]))) + : continuation({ + ['#object']: + Object.fromEntries(entries), + })), + }, + ], +}); + +// Gets a property from each of a list of objects (in a dependency) and +// provides the results. This doesn't alter any list indices, so positions +// which were null in the original list are kept null here. Objects which don't +// have the specified property are retained in-place as null. +export function withPropertyFromList({ + list, + property, + into = null, +}) { + into ??= + (list.startsWith('#') + ? `${list}.${property}` + : `#${list}.${property}`); + + return { + annotation: `withPropertyFromList`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {list}, + mapContinuation: {into}, + options: {property}, + + compute(continuation, {list, '#options': {property}}) { + if (list === undefined || empty(list)) { + return continuation({into: []}); + } + + return continuation({ + into: + list.map(item => + (item === null || item === undefined + ? null + : item[property] ?? null)), + }); + }, + }, + }; +} + +// Gets the listed properties from each of a list of objects, providing lists +// of property values each into a dependency prefixed with the same name as the +// list (by default). Like withPropertyFromList, this doesn't alter indices. +export const withPropertiesFromList = templateCompositeFrom({ + annotation: `withPropertiesFromList`, + + inputs: { + list: input({type: 'array'}), + + properties: input({ + validate: validateArrayItems(isString), + }), + + prefix: input.staticValue({type: 'string', defaultValue: null}), + }, + + outputs: ({ + [input.staticDependency('list')]: list, + [input.staticValue('properties')]: properties, + [input.staticValue('prefix')]: prefix, + }) => + (properties + ? properties.map(property => + (prefix + ? `${prefix}.${property}` + : list + ? `${list}.${property}` + : `#list.${property}`)) + : ['#lists']), + + steps: () => [ + { + dependencies: [input('list'), input('properties')], + compute: (continuation, { + [input('list')]: list, + [input('properties')]: properties, + }) => continuation({ + ['#lists']: + Object.fromEntries( + properties.map(property => [ + property, + list.map(item => item[property] ?? null), + ])), + }), + }, + + { + dependencies: [ + input.staticDependency('list'), + input.staticValue('properties'), + input.staticValue('prefix'), + '#lists', + ], + + compute: (continuation, { + [input.staticDependency('list')]: list, + [input.staticValue('properties')]: properties, + [input.staticValue('prefix')]: prefix, + ['#lists']: lists, + }) => + (properties + ? continuation( + Object.fromEntries( + properties.map(property => [ + (prefix + ? `${prefix}.${property}` + : list + ? `${list}.${property}` + : `#list.${property}`), + lists[property], + ]))) + : continuation({'#lists': lists})), + }, + ], +}); + +// Replaces items of a list, which are null or undefined, with some fallback +// value. By default, this replaces the passed dependency. +export const fillMissingListItems = templateCompositeFrom({ + annotation: `fillMissingListItems`, + + inputs: { + list: input({type: 'array'}), + fill: input({acceptsNull: true}), + }, + + outputs: ({ + [input.staticDependency('list')]: list, + }) => [list ?? '#list'], + + steps: () => [ + { + dependencies: [input('list'), input('fill')], + compute: (continuation, { + [input('list')]: list, + [input('fill')]: fill, + }) => continuation({ + ['#filled']: + list.map(item => item ?? fill), + }), + }, + + { + dependencies: [input.staticDependency('list'), '#filled'], + compute: (continuation, { + [input.staticDependency('list')]: list, + ['#filled']: filled, + }) => continuation({ + [list ?? '#list']: + filled, + }), + }, + ], +}); + +// Filters particular values out of a list. Note that this will always +// completely skip over null, but can be used to filter out any other +// primitive or object value. +export const excludeFromList = templateCompositeFrom({ + annotation: `excludeFromList`, + + inputs: { + list: input(), + + item: input({defaultValue: null}), + items: input({type: 'array', defaultValue: null}), + }, + + outputs: ({ + [input.staticDependency('list')]: list, + }) => [list ?? '#list'], + + steps: () => [ + { + dependencies: [ + input.staticDependency('list'), + input('list'), + input('item'), + input('items'), + ], + + compute: (continuation, { + [input.staticDependency('list')]: listName, + [input('list')]: listContents, + [input('item')]: excludeItem, + [input('items')]: excludeItems, + }) => continuation({ + [listName ?? '#list']: + listContents.filter(item => { + if (excludeItem !== null && item === excludeItem) return false; + if (!empty(excludeItems) && excludeItems.includes(item)) return false; + return true; + }), + }), + }, + ], +}); + +// Flattens an array with one level of nested arrays, providing as dependencies +// both the flattened array as well as the original starting indices of each +// successive source array. +export const withFlattenedList = templateCompositeFrom({ + annotation: `withFlattenedList`, + + inputs: { + list: input({type: 'array'}), + }, + + outputs: ['#flattenedList', '#flattenedIndices'], + + steps: () => [ + { + dependencies: [input('list')], + compute(continuation, { + [input('list')]: sourceList, + }) { + const flattenedList = sourceList.flat(); + const indices = []; + let lastEndIndex = 0; + for (const {length} of sourceList) { + indices.push(lastEndIndex); + lastEndIndex += length; + } + + return continuation({ + ['#flattenedList']: flattenedList, + ['#flattenedIndices']: indices, + }); + }, + }, + ], +}); + +// After mapping the contents of a flattened array in-place (being careful to +// retain the original indices by replacing unmatched results with null instead +// of filtering them out), this function allows for recombining them. It will +// filter out null and undefined items by default (pass {filter: false} to +// disable this). +export const withUnflattenedList = templateCompositeFrom({ + annotation: `withUnflattenedList`, + + inputs: { + list: input({ + type: 'array', + defaultDependency: '#flattenedList', + }), + + indices: input({ + validate: validateArrayItems(isWholeNumber), + defaultDependency: '#flattenedIndices', + }), + + filter: input({ + type: 'boolean', + defaultValue: true, + }), + }, + + outputs: ['#unflattenedList'], + + steps: () => [ + { + dependencies: [input('list'), input('indices'), input('filter')], + compute(continuation, { + [input('list')]: list, + [input('indices')]: indices, + [input('filter')]: filter, + }) { + const unflattenedList = []; + + for (let i = 0; i < indices.length; i++) { + const startIndex = indices[i]; + const endIndex = + (i === indices.length - 1 + ? list.length + : indices[i + 1]); + + const values = list.slice(startIndex, endIndex); + unflattenedList.push( + (filter + ? values.filter(value => value !== null && value !== undefined) + : values)); + } + + return continuation({ + ['#unflattenedList']: unflattenedList, + }); + }, + }, + ], +}); |