« get me outta code hell

data: composition docs, annotations, nesting - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data/things
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-08-22 22:36:20 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-09-05 21:02:49 -0300
commit6483809c6d9c67f1311a64f2572b4fe5881d3a0d (patch)
tree01a42c056ce8a7f05d2e38533aa77bdaa8ffac49 /src/data/things
parent1481db921e645ab09aad3a57b4ce308e2c57d738 (diff)
data: composition docs, annotations, nesting
Diffstat (limited to 'src/data/things')
-rw-r--r--src/data/things/thing.js316
-rw-r--r--src/data/things/track.js81
2 files changed, 359 insertions, 38 deletions
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
index f1ae6c71..1186c389 100644
--- a/src/data/things/thing.js
+++ b/src/data/things/thing.js
@@ -426,14 +426,222 @@ export default class Thing extends CacheableObject {
   }
 
   static composite = {
-    from(composition) {
+    // 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.
+    //
+    // 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:
+    //
+    //   static Thing.composite.withResolvedContribs = ({
+    //     from: contribsByRefDependency,
+    //     to: 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:
+    //
+    //   static Track[Thing.getPropertyDescriptors].coverArtists =
+    //     Thing.composite.from([
+    //       Track.composite.doSomethingWhichMightEarlyExit(),
+    //       Thing.composite.withResolvedContribs({
+    //         from: 'coverArtistContribsByRef',
+    //         to: '#coverArtistContribs',
+    //       }),
+    //
+    //       {
+    //         flags: {expose: true},
+    //         expose: {
+    //           dependencies: ['#coverArtistContribs'],
+    //           compute({'#coverArtistContribs': coverArtistContribs}) {
+    //             return coverArtistContribs.map(({who}) => who);
+    //           },
+    //         },
+    //       },
+    //     ]);
+    //
+    // == 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 Thing.composite.from() 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 internal, 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!)
+    //
+    from(firstArg, secondArg) {
+      let annotation, composition;
+      if (typeof firstArg === 'string') {
+        [annotation, composition] = [firstArg, secondArg];
+      } else {
+        [annotation, composition] = [null, firstArg];
+      }
+
       const base = composition.at(-1);
       const steps = composition.slice(0, -1);
 
-      const aggregate = openAggregate({message: `Errors preparing Thing.composite.from() composition`});
+      const aggregate = openAggregate({
+        message:
+          `Errors preparing Thing.composite.from() composition` +
+          (annotation ? ` (${annotation})` : ''),
+      });
 
-      if (base.flags.compose) {
-        aggregate.push(new TypeError(`Base (bottom item) must not be {compose: true}`));
+      if (base.flags.compose && base.flags.compute) {
+        push(new TypeError(`Base which composes can't also update yet`));
       }
 
       const exposeFunctionOrder = [];
@@ -500,14 +708,18 @@ export default class Thing extends CacheableObject {
 
       const constructedDescriptor = {};
 
+      if (annotation) {
+        constructedDescriptor.annotation = annotation;
+      }
+
       constructedDescriptor.flags = {
         update: !!base.flags.update,
         expose: !!base.flags.expose,
-        compose: false,
+        compose: !!base.flags.compose,
       };
 
       if (base.flags.update) {
-        constructedDescriptor.update = base.flags.update;
+        constructedDescriptor.update = base.update;
       }
 
       if (base.flags.expose) {
@@ -547,6 +759,9 @@ export default class Thing extends CacheableObject {
             const filteredDependencies =
               filterProperties(dependencies, base.expose.dependencies);
 
+            // Note: base.flags.compose is not compatible with base.flags.update,
+            // so the base.flags.compose case is not handled here.
+
             if (base.expose.transform) {
               return base.expose.transform(valueSoFar, filteredDependencies);
             } else {
@@ -554,7 +769,7 @@ export default class Thing extends CacheableObject {
             }
           };
         } else {
-          expose.compute = (initialDependencies) => {
+          expose.compute = (initialDependencies, continuationIfApplicable) => {
             const dependencies = {...initialDependencies};
 
             for (const {fn} of exposeFunctionOrder) {
@@ -569,7 +784,23 @@ export default class Thing extends CacheableObject {
               }
             }
 
-            return base.expose.compute(dependencies);
+            if (base.flags.compose) {
+              let exportDependencies;
+
+              const result =
+                base.expose.compute(dependencies, providedDependencies => {
+                  exportDependencies = providedDependencies;
+                  return continuationSymbol;
+                });
+
+              if (result !== continuationSymbol) {
+                return result;
+              }
+
+              return exportDependencies;
+            } else {
+              return base.expose.compute(dependencies);
+            }
           };
         }
       }
@@ -577,11 +808,48 @@ export default class Thing extends CacheableObject {
       return constructedDescriptor;
     },
 
+    // Provides dependencies exactly as they are (or null if not defined) to the
+    // continuation. Although this can *technically* be used to alias existing
+    // dependencies to some other name within the middle of a composition, it's
+    // intended to be used only as a composition's base - doing so makes the
+    // composition as a whole suitable as a step in some other composition,
+    // providing the listed (internal) dependencies to later steps just like
+    // other compositional steps.
+    export(mapping) {
+      const mappingEntries = Object.entries(mapping);
+
+      return {
+        annotation: `Thing.composite.export`,
+        flags: {expose: true, compose: true},
+
+        expose: {
+          dependencies: Object.values(mapping),
+
+          compute(dependencies, continuation) {
+            const exports = {};
+
+            // Note: This is slightly different behavior from filterProperties,
+            // as defined in sugar.js, which doesn't fall back to null for
+            // properties which don't exist on the original object.
+            for (const [exportKey, dependencyKey] of mappingEntries) {
+              exports[exportKey] =
+                (Object.hasOwn(dependencies, dependencyKey)
+                  ? dependencies[dependencyKey]
+                  : null);
+            }
+
+            return continuation(exports);
+          }
+        },
+      };
+    },
+
     // Resolves the contribsByRef contained in the provided dependency,
     // providing (named by the second argument) the result. "Resolving"
     // means mapping the "who" reference of each contribution to an artist
     // object, and filtering out those whose "who" doesn't match any artist.
     withResolvedContribs: ({from: contribsByRefDependency, to: outputDependency}) => ({
+      annotation: `Thing.composite.withResolvedContribs`,
       flags: {expose: true, compose: true},
 
       expose: {
@@ -593,5 +861,37 @@ export default class Thing extends CacheableObject {
           }),
       },
     }),
+
+    // Resolves a reference by using the provided find function to match it
+    // within the provided thingData dependency. This will early exit if the
+    // data dependency is null, or, if earlyExitIfNotFound is set to true,
+    // if the find function doesn't match anything for the reference.
+    // Otherwise, the data object (or null, if not found) is provided on
+    // the output dependency.
+    withResolvedReference({
+      ref: refDependency,
+      data: dataDependency,
+      to: outputDependency,
+      find: findFunction,
+      earlyExitIfNotFound = false,
+    }) {
+      return {
+        annotation: `Thing.composite.withResolvedReference`,
+        flags: {expose: true, compose: true},
+
+        expose: {
+          dependencies: [refDependency, dataDependency],
+
+          compute({[refDependency]: ref, [dataDependency]: data}, continuation) {
+            if (data === null) return null;
+
+            const match = findFunction(ref, data, {mode: 'quiet'});
+            if (match === null && earlyExitIfNotFound) return null;
+
+            return continuation({[outputDependency]: match});
+          },
+        },
+      };
+    }
   };
 }
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 985de594..718eb07e 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -44,7 +44,7 @@ export class Track extends Thing {
     sampledTracksByRef: Thing.common.referenceList(Track),
     artTagsByRef: Thing.common.referenceList(ArtTag),
 
-    color: Thing.composite.from([
+    color: Thing.composite.from(`Track.color`, [
       {
         flags: {expose: true, compose: true},
         expose: {
@@ -77,7 +77,7 @@ export class Track extends Thing {
     // track's unique cover artwork, if any, and does not inherit the cover's
     // main artwork. (It does inherit `trackCoverArtFileExtension` if present
     // on the album.)
-    coverArtFileExtension: Thing.composite.from([
+    coverArtFileExtension: Thing.composite.from(`Track.coverArtFileExtension`, [
       Track.composite.withAlbumProperties({
         properties: [
           'trackCoverArtistContribsByRef',
@@ -114,7 +114,7 @@ export class Track extends Thing {
     // only the track's own unique cover artwork, if any. This exposes only as
     // the track's own coverArtDate or its album's trackArtDate, so if neither
     // is specified, this value is null.
-    coverArtDate: Thing.composite.from([
+    coverArtDate: Thing.composite.from(`Track.coverArtDate`, [
       Track.composite.withAlbumProperties({
         properties: [
           'trackArtDate',
@@ -192,7 +192,7 @@ export class Track extends Thing {
       find.album
     ),
 
-    date: Thing.composite.from([
+    date: Thing.composite.from(`Track.date`, [
       {
         flags: {expose: true, compose: true},
         expose: {
@@ -222,7 +222,7 @@ export class Track extends Thing {
     // or a placeholder. (This property is named hasUniqueCoverArt instead of
     // the usual hasCoverArt to emphasize that it does not inherit from the
     // album.)
-    hasUniqueCoverArt: Thing.composite.from([
+    hasUniqueCoverArt: Thing.composite.from(`Track.hasUniqueCoverArt`, [
       Track.composite.withAlbumProperties({
         properties: ['trackCoverArtistContribsByRef'],
       }),
@@ -284,7 +284,7 @@ export class Track extends Thing {
       },
     },
 
-    artistContribs: Thing.composite.from([
+    artistContribs: Thing.composite.from(`Track.artistContribs`, [
       Track.composite.inheritFromOriginalRelease('artistContribs'),
 
       Track.composite.withAlbumProperties({
@@ -311,7 +311,7 @@ export class Track extends Thing {
       },
     ]),
 
-    contributorContribs: Thing.composite.from([
+    contributorContribs: Thing.composite.from(`Track.contributorContribs`, [
       Track.composite.inheritFromOriginalRelease('contributorContribs'),
       Thing.common.dynamicContribs('contributorContribsByRef'),
     ]),
@@ -319,7 +319,7 @@ export class Track extends Thing {
     // Cover artists aren't inherited from the original release, since it
     // typically varies by release and isn't defined by the musical qualities
     // of the track.
-    coverArtistContribs: Thing.composite.from([
+    coverArtistContribs: Thing.composite.from(`Track.coverArtistContribs`, [
       {
         flags: {expose: true, compose: true},
         expose: {
@@ -355,12 +355,12 @@ export class Track extends Thing {
       },
     ]),
 
-    referencedTracks: Thing.composite.from([
+    referencedTracks: Thing.composite.from(`Track.referencedTracks`, [
       Track.composite.inheritFromOriginalRelease('referencedTracks'),
       Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track),
     ]),
 
-    sampledTracks: Thing.composite.from([
+    sampledTracks: Thing.composite.from(`Track.sampledTracks`, [
       Track.composite.inheritFromOriginalRelease('sampledTracks'),
       Thing.common.dynamicThingsFromReferenceList('sampledTracksByRef', 'trackData', find.track),
     ]),
@@ -417,37 +417,39 @@ export class Track extends Thing {
   });
 
   static composite = {
-    // Returns a value inherited from the original release, if this track
-    // is a rerelease, and otherwise continues with no further provided
-    // dependencies. If the second argument is provided true, then the
-    // continuation will also be called if the original release exposed
-    // the requested property as null.
-    inheritFromOriginalRelease: (originalProperty, allowOverride = false) => ({
-      flags: {expose: true, compose: true},
+    // Early exits with a value inherited from the original release, if
+    // this track is a rerelease, and otherwise continues with no further
+    // dependencies provided. If allowOverride is true, then the continuation
+    // will also be called if the original release exposed the requested
+    // property as null.
+    inheritFromOriginalRelease: ({property: originalProperty, allowOverride = false}) =>
+      Thing.composite.from(`Track.composite.inheritFromOriginalRelease`, [
+        Track.composite.withOriginalRelease({to: '#originalRelease'}),
 
-      expose: {
-        dependencies: ['originalReleaseTrackByRef', 'trackData'],
+        {
+          flags: {expose: true, compose: true},
 
-        compute({originalReleaseTrackByRef, trackData}, continuation) {
-          if (!originalReleaseTrackByRef) return continuation();
+          expose: {
+            dependencies: ['#originalRelease'],
 
-          if (!trackData) return null;
-          const original = find.track(originalReleaseTrackByRef, trackData, {mode: 'quiet'});
-          if (!original) return null;
+            compute({'#originalRelease': originalRelease}, continuation) {
+              if (!originalRelease) return continuation();
 
-          const value = original[originalProperty];
-          if (allowOverride && value === null) return continuation();
+              const value = originalRelease[originalProperty];
+              if (allowOverride && value === null) return continuation();
 
-          return value;
-        },
-      },
-    }),
+              return value;
+            },
+          },
+        }
+      ]),
 
     // Gets the listed properties from this track's album, providing them as
     // dependencies (by default) with '#album.' prefixed before each property
     // name. If the track's album isn't available, the same dependency names
     // will each be provided as null.
     withAlbumProperties: ({properties, prefix = '#album'}) => ({
+      annotation: `Track.composite.withAlbumProperties`,
       flags: {expose: true, compose: true},
 
       expose: {
@@ -468,6 +470,25 @@ export class Track extends Thing {
         },
       },
     }),
+
+    // Just includes the original release of this track as a dependency, or
+    // null, if it's not a rerelease. Note that this will early exit if the
+    // original release is specified by reference and that reference doesn't
+    // resolve to anything.
+    withOriginalRelease: ({to: outputDependency = '#originalRelease'}) =>
+      Thing.composite.from(`Track.composite.withOriginalRelease`, [
+        Thing.composite.withResolvedReference({
+          ref: 'originalReleaseTrackByRef',
+          data: 'trackData',
+          to: '#originalRelease',
+          find: find.track,
+          earlyExitIfNotFound: true,
+        }),
+
+        Thing.composite.export({
+          [outputDependency]: '#originalRelease',
+        }),
+      ]),
   };
 
   [inspect.custom]() {