« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/content/dependencies/generateWikiHomeAlbumsRow.js2
-rw-r--r--src/data/things/album.js75
-rw-r--r--src/data/things/artist.js19
-rw-r--r--src/data/things/cacheable-object.js26
-rw-r--r--src/data/things/composite.js15
-rw-r--r--src/data/things/flash.js34
-rw-r--r--src/data/things/group.js27
-rw-r--r--src/data/things/homepage-layout.js60
-rw-r--r--src/data/things/thing.js147
-rw-r--r--src/data/things/track.js132
-rw-r--r--src/data/things/validators.js2
-rw-r--r--src/data/things/wiki-info.js15
-rw-r--r--src/data/yaml.js133
-rw-r--r--src/gen-thumbs.js9
14 files changed, 341 insertions, 355 deletions
diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js
index 99c1be55..cb0860f5 100644
--- a/src/content/dependencies/generateWikiHomeAlbumsRow.js
+++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js
@@ -16,7 +16,7 @@ export default {
   sprawl({albumData}, row) {
     const sprawl = {};
 
-    switch (row.sourceGroupByRef) {
+    switch (row.sourceGroup) {
       case 'new-releases':
         sprawl.albums = getNewReleases(row.countAlbumsFromGroup, {albumData});
         break;
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 9cf58641..88308182 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -7,14 +7,12 @@ import Thing, {
   commentary,
   color,
   commentatorArtists,
-  contribsByRef,
   contribsPresent,
+  contributionList,
   directory,
-  dynamicContribs,
   fileExtension,
   flag,
   name,
-  resolvedReferenceList,
   referenceList,
   simpleDate,
   simpleString,
@@ -43,25 +41,31 @@ export class Album extends Thing {
       update: {validate: isDate},
 
       expose: {
-        dependencies: ['date', 'coverArtistContribsByRef'],
-        transform: (coverArtDate, {
-          coverArtistContribsByRef,
-          date,
-        }) =>
-          (!empty(coverArtistContribsByRef)
+        dependencies: ['date', 'coverArtistContribs'],
+        transform: (coverArtDate, {coverArtistContribs, date}) =>
+          (!empty(coverArtistContribs)
             ? coverArtDate ?? date ?? null
             : null),
       },
     },
 
-    artistContribsByRef: contribsByRef(),
-    coverArtistContribsByRef: contribsByRef(),
-    trackCoverArtistContribsByRef: contribsByRef(),
-    wallpaperArtistContribsByRef: contribsByRef(),
-    bannerArtistContribsByRef: contribsByRef(),
+    artistContribs: contributionList(),
+    coverArtistContribs: contributionList(),
+    trackCoverArtistContribs: contributionList(),
+    wallpaperArtistContribs: contributionList(),
+    bannerArtistContribs: contributionList(),
 
-    groupsByRef: referenceList(Group),
-    artTagsByRef: referenceList(ArtTag),
+    groups: referenceList({
+      class: Group,
+      find: find.group,
+      data: 'groupData',
+    }),
+
+    artTags: referenceList({
+      class: ArtTag,
+      find: find.artTag,
+      data: 'artTagData',
+    }),
 
     trackSections: {
       flags: {update: true, expose: true},
@@ -84,13 +88,12 @@ export class Album extends Thing {
             isDefaultTrackSection: section.isDefaultTrackSection ?? false,
 
             startIndex: (
-              startIndex += section.tracksByRef.length,
-              startIndex - section.tracksByRef.length
+              startIndex += section.tracks.length,
+              startIndex - section.tracks.length
             ),
 
-            tracksByRef: section.tracksByRef ?? [],
             tracks:
-              (trackData && section.tracksByRef
+              (trackData && section.tracks
                 ?.map(ref => find.track(ref, trackData, {mode: 'quiet'}))
                 .filter(Boolean)) ??
               [],
@@ -128,29 +131,11 @@ export class Album extends Thing {
 
     // Expose only
 
-    artistContribs: dynamicContribs('artistContribsByRef'),
-    coverArtistContribs: dynamicContribs('coverArtistContribsByRef'),
-    trackCoverArtistContribs: dynamicContribs('trackCoverArtistContribsByRef'),
-    wallpaperArtistContribs: dynamicContribs('wallpaperArtistContribsByRef'),
-    bannerArtistContribs: dynamicContribs('bannerArtistContribsByRef'),
-
     commentatorArtists: commentatorArtists(),
 
-    groups: resolvedReferenceList({
-      list: 'groupsByRef',
-      data: 'groupData',
-      find: find.group,
-    }),
-
-    artTags: resolvedReferenceList({
-      list: 'artTagsByRef',
-      data: 'artTagData',
-      find: find.artTag,
-    }),
-
-    hasCoverArt: contribsPresent('coverArtistContribsByRef'),
-    hasWallpaperArt: contribsPresent('wallpaperArtistContribsByRef'),
-    hasBannerArt: contribsPresent('bannerArtistContribsByRef'),
+    hasCoverArt: contribsPresent('coverArtistContribs'),
+    hasWallpaperArt: contribsPresent('wallpaperArtistContribs'),
+    hasBannerArt: contribsPresent('bannerArtistContribs'),
 
     tracks: {
       flags: {expose: true},
@@ -158,12 +143,12 @@ export class Album extends Thing {
       expose: {
         dependencies: ['trackSections', 'trackData'],
         compute: ({trackSections, trackData}) =>
-          trackSections && trackData
+          (trackSections && trackData
             ? trackSections
-                .flatMap((section) => section.tracksByRef ?? [])
-                .map((ref) => find.track(ref, trackData, {mode: 'quiet'}))
+                .flatMap(section => section.tracks ?? [])
+                .map(ref => find.track(ref, trackData, {mode: 'quiet'}))
                 .filter(Boolean)
-            : [],
+            : []),
       },
     },
   });
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 2676591a..7a9dbd3c 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -33,7 +33,12 @@ export class Artist extends Thing {
     },
 
     isAlias: flag(),
-    aliasedArtistRef: singleReference(Artist),
+
+    aliasedArtist: singleReference({
+      class: Artist,
+      find: find.artist,
+      data: 'artistData',
+    }),
 
     // Update only
 
@@ -44,18 +49,6 @@ export class Artist extends Thing {
 
     // Expose only
 
-    aliasedArtist: {
-      flags: {expose: true},
-
-      expose: {
-        dependencies: ['artistData', 'aliasedArtistRef'],
-        compute: ({artistData, aliasedArtistRef}) =>
-          aliasedArtistRef && artistData
-            ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'})
-            : null,
-      },
-    },
-
     tracksAsArtist:
       Artist.filterByContrib('trackData', 'artistContribs'),
     tracksAsContributor:
diff --git a/src/data/things/cacheable-object.js b/src/data/things/cacheable-object.js
index 92a46d66..4bc3668d 100644
--- a/src/data/things/cacheable-object.js
+++ b/src/data/things/cacheable-object.js
@@ -86,16 +86,14 @@ export default class CacheableObject {
   #propertyUpdateValues = Object.create(null);
   #propertyUpdateCacheInvalidators = Object.create(null);
 
-  /*
-    // Note the constructor doesn't take an initial data source. Due to a quirk
-    // of JavaScript, private members can't be accessed before the superclass's
-    // constructor is finished processing - so if we call the overridden
-    // update() function from inside this constructor, it will error when
-    // writing to private members. Pretty bad!
-    //
-    // That means initial data must be provided by following up with update()
-    // after constructing the new instance of the Thing (sub)class.
-    */
+  // Note the constructor doesn't take an initial data source. Due to a quirk
+  // of JavaScript, private members can't be accessed before the superclass's
+  // constructor is finished processing - so if we call the overridden
+  // update() function from inside this constructor, it will error when
+  // writing to private members. Pretty bad!
+  //
+  // That means initial data must be provided by following up with update()
+  // after constructing the new instance of the Thing (sub)class.
 
   constructor() {
     this.#defineProperties();
@@ -352,4 +350,12 @@ export default class CacheableObject {
       console.log(` - ${line}`);
     }
   }
+
+  static getUpdateValue(object, key) {
+    if (!Object.hasOwn(object, key)) {
+      return undefined;
+    }
+
+    return object.#propertyUpdateValues[key] ?? null;
+  }
 }
diff --git a/src/data/things/composite.js b/src/data/things/composite.js
index 29f5770c..96abf4af 100644
--- a/src/data/things/composite.js
+++ b/src/data/things/composite.js
@@ -1071,3 +1071,18 @@ export function raiseWithoutUpdateValue({
     },
   ]);
 }
+
+export function withUpdateValueAsDependency({
+  into = '#updateValue',
+} = {}) {
+  return {
+    annotation: `withUpdateValueAsDependency`,
+    flags: {expose: true, compose: true},
+
+    expose: {
+      mapContinuation: {into},
+      transform: (value, continuation) =>
+        continuation(value, {into: value}),
+    },
+  };
+}
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index 4e640dac..eb16d29e 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -9,13 +9,11 @@ import {
 } from '#validators';
 
 import Thing, {
-  dynamicContribs,
   color,
-  contribsByRef,
+  contributionList,
   fileExtension,
   name,
   referenceList,
-  resolvedReferenceList,
   simpleDate,
   simpleString,
   urls,
@@ -60,9 +58,13 @@ export class Flash extends Thing {
 
     coverArtFileExtension: fileExtension('jpg'),
 
-    contributorContribsByRef: contribsByRef(),
+    contributorContribs: contributionList(),
 
-    featuredTracksByRef: referenceList(Track),
+    featuredTracks: referenceList({
+      class: Track,
+      find: find.track,
+      data: 'trackData',
+    }),
 
     urls: urls(),
 
@@ -74,14 +76,6 @@ export class Flash extends Thing {
 
     // Expose only
 
-    contributorContribs: dynamicContribs('contributorContribsByRef'),
-
-    featuredTracks: resolvedReferenceList({
-      list: 'featuredTracksByRef',
-      data: 'trackData',
-      find: find.track,
-    }),
-
     act: {
       flags: {expose: true},
 
@@ -138,18 +132,14 @@ export class FlashAct extends Thing {
       }
     },
 
-    flashesByRef: referenceList(Flash),
+    flashes: referenceList({
+      class: Flash,
+      data: 'flashData',
+      find: find.flash,
+    }),
 
     // Update only
 
     flashData: wikiData(Flash),
-
-    // Expose only
-
-    flashes: resolvedReferenceList({
-      list: 'flashesByRef',
-      data: 'flashData',
-      find: find.flash,
-    }),
   })
 }
diff --git a/src/data/things/group.js b/src/data/things/group.js
index 873c6d88..f53fa48e 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -5,7 +5,6 @@ import Thing, {
   directory,
   name,
   referenceList,
-  resolvedReferenceList,
   simpleString,
   urls,
   wikiData,
@@ -24,7 +23,11 @@ export class Group extends Thing {
 
     urls: urls(),
 
-    featuredAlbumsByRef: referenceList(Album),
+    featuredAlbums: referenceList({
+      class: Album,
+      find: find.album,
+      data: 'albumData',
+    }),
 
     // Update only
 
@@ -33,12 +36,6 @@ export class Group extends Thing {
 
     // Expose only
 
-    featuredAlbums: resolvedReferenceList({
-      list: 'featuredAlbumsByRef',
-      data: 'albumData',
-      find: find.album,
-    }),
-
     descriptionShort: {
       flags: {expose: true},
 
@@ -89,18 +86,14 @@ export class GroupCategory extends Thing {
     name: name('Unnamed Group Category'),
     color: color(),
 
-    groupsByRef: referenceList(Group),
+    groups: referenceList({
+      class: Group,
+      find: find.group,
+      data: 'groupData',
+    }),
 
     // Update only
 
     groupData: wikiData(Group),
-
-    // Expose only
-
-    groups: resolvedReferenceList({
-      list: 'groupsByRef',
-      data: 'groupData',
-      find: find.group,
-    }),
   });
 }
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index ab6f4cff..b509c1e2 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -1,23 +1,29 @@
 import find from '#find';
 
 import {
+  compositeFrom,
+  exposeDependency,
+  withUpdateValueAsDependency,
+} from '#composite';
+
+import {
   is,
   isCountingNumber,
   isString,
   isStringNonEmpty,
+  oneOf,
   validateArrayItems,
   validateInstanceOf,
+  validateReference,
 } from '#validators';
 
 import Thing, {
   color,
   name,
   referenceList,
-  resolvedReference,
-  resolvedReferenceList,
   simpleString,
-  singleReference,
   wikiData,
+  withResolvedReference,
 } from './thing.js';
 
 export class HomepageLayout extends Thing {
@@ -101,8 +107,38 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
       },
     },
 
-    sourceGroupByRef: singleReference(Group),
-    sourceAlbumsByRef: referenceList(Album),
+    sourceGroup: compositeFrom(`HomepageLayoutAlbumsRow.sourceGroup`, [
+      {
+        transform: (value, continuation) =>
+          (value === 'new-releases' || value === 'new-additions'
+            ? value
+            : continuation(value)),
+      },
+
+      withUpdateValueAsDependency(),
+
+      withResolvedReference({
+        ref: '#updateValue',
+        data: 'groupData',
+        find: find.group,
+      }),
+
+      exposeDependency({
+        dependency: '#resolvedReference',
+        update: {
+          validate:
+            oneOf(
+              is('new-releases', 'new-additions'),
+              validateReference(Group[Thing.referenceType])),
+        },
+      }),
+    ]),
+
+    sourceAlbums: referenceList({
+      class: Album,
+      find: find.album,
+      data: 'albumData',
+    }),
 
     countAlbumsFromGroup: {
       flags: {update: true, expose: true},
@@ -113,19 +149,5 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
       flags: {update: true, expose: true},
       update: {validate: validateArrayItems(isString)},
     },
-
-    // Expose only
-
-    sourceGroup: resolvedReference({
-      ref: 'sourceGroupByRef',
-      data: 'groupData',
-      find: find.group,
-    }),
-
-    sourceAlbums: resolvedReferenceList({
-      list: 'sourceAlbumsByRef',
-      data: 'albumData',
-      find: find.album,
-    }),
   });
 }
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
index 9d8b2ea2..91ad96af 100644
--- a/src/data/things/thing.js
+++ b/src/data/things/thing.js
@@ -11,8 +11,11 @@ import {filterMultipleArrays, getKebabCase} from '#wiki-data';
 import {
   compositeFrom,
   exitWithoutDependency,
+  exposeConstant,
   exposeDependency,
+  exposeDependencyOrContinue,
   raiseWithoutDependency,
+  withUpdateValueAsDependency,
 } from '#composite';
 
 import {
@@ -162,22 +165,31 @@ export function externalFunction() {
   };
 }
 
-// Super simple "contributions by reference" list, used for a variety of
-// properties (Artists, Cover Artists, etc). This is the property which is
-// externally provided, in the form:
+// Strong 'n sturdy contribution list, rolling a list of references (provided
+// as this property's update value) and the resolved results (as get exposed)
+// into one property. Update value will look something like this:
 //
-//     [
-//         {who: 'Artist Name', what: 'Viola'},
-//         {who: 'artist:john-cena', what: null},
-//         ...
-//     ]
+//   [
+//     {who: 'Artist Name', what: 'Viola'},
+//     {who: 'artist:john-cena', what: null},
+//     ...
+//   ]
 //
-// ...processed from YAML, spreadsheet, or any other kind of input.
-export function contribsByRef() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isContributionList},
-  };
+// ...typically as processed from YAML, spreadsheet, or elsewhere.
+// Exposes as the same, but with the "who" replaced with matches found in
+// artistData - which means this always depends on an `artistData` property
+// also existing on this object!
+//
+export function contributionList() {
+  return compositeFrom(`contributionList`, [
+    withUpdateValueAsDependency(),
+    withResolvedContribs({from: '#updateValue'}),
+    exposeDependencyOrContinue({dependency: '#resolvedContribs'}),
+    exposeConstant({
+      value: [],
+      update: {validate: isContributionList},
+    }),
+  ]);
 }
 
 // Artist commentary! Generally present on tracks and albums.
@@ -222,88 +234,77 @@ export function additionalFiles() {
 // 'artist' or 'track', but this utility keeps from having to hard-code the
 // string in multiple places by referencing the value saved on the class
 // instead.
-export function referenceList(thingClass) {
-  const {[Thing.referenceType]: referenceType} = thingClass;
-  if (!referenceType) {
-    throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
+export function referenceList({
+  class: thingClass,
+  data,
+  find,
+}) {
+  if (!thingClass) {
+    throw new TypeError(`Expected a Thing class`);
   }
 
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: validateReferenceList(referenceType)},
-  };
-}
-
-// Corresponding function for a single reference.
-export function singleReference(thingClass) {
   const {[Thing.referenceType]: referenceType} = thingClass;
   if (!referenceType) {
     throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
   }
 
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: validateReference(referenceType)},
-  };
-}
+  return compositeFrom(`referenceList`, [
+    withUpdateValueAsDependency(),
 
-// Corresponding dynamic property to referenceList, which takes the values
-// in the provided property and searches the specified wiki data for
-// matching actual Thing-subclass objects.
-export function resolvedReferenceList({list, data, find}) {
-  return compositeFrom(`resolvedReferenceList`, [
     withResolvedReferenceList({
-      list, data, find,
+      data, find,
+      list: '#updateValue',
       notFoundMode: 'filter',
     }),
 
-    exposeDependency({dependency: '#resolvedReferenceList'}),
+    exposeDependency({
+      dependency: '#resolvedReferenceList',
+      update: {
+        validate: validateReferenceList(referenceType),
+      },
+    }),
   ]);
 }
 
 // Corresponding function for a single reference.
-export function resolvedReference({ref, data, find}) {
-  return compositeFrom(`resolvedReference`, [
-    withResolvedReference({ref, data, find}),
-    exposeDependency({dependency: '#resolvedReference'}),
-  ]);
-}
+export function singleReference({
+  class: thingClass,
+  data,
+  find,
+}) {
+  if (!thingClass) {
+    throw new TypeError(`Expected a Thing class`);
+  }
 
-// Corresponding dynamic property to contribsByRef, which takes the values
-// in the provided property and searches the object's artistData for
-// matching actual Artist objects. The computed structure has the same form
-// as contribsByRef, but with Artist objects instead of string references:
-//
-//     [
-//         {who: (an Artist), what: 'Viola'},
-//         {who: (an Artist), what: null},
-//         ...
-//     ]
-//
-// Contributions whose "who" values don't match anything in artistData are
-// filtered out. (So if the list is all empty, chances are that either the
-// reference list is somehow messed up, or artistData isn't being provided
-// properly.)
-export function dynamicContribs(contribsByRefProperty) {
-  return compositeFrom(`dynamicContribs`, [
-    withResolvedContribs({from: contribsByRefProperty}),
-    exposeDependency({dependency: '#resolvedContribs'}),
+  const {[Thing.referenceType]: referenceType} = thingClass;
+  if (!referenceType) {
+    throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
+  }
+
+  return compositeFrom(`singleReference`, [
+    withUpdateValueAsDependency(),
+
+    withResolvedReference({ref: '#updateValue', data, find}),
+
+    exposeDependency({
+      dependency: '#resolvedReference',
+      update: {
+        validate: validateReference(referenceType),
+      },
+    }),
   ]);
 }
 
 // Nice 'n simple shorthand for an exposed-only flag which is true when any
 // contributions are present in the specified property.
-export function contribsPresent(contribsByRefProperty) {
+export function contribsPresent(contribsProperty) {
   return {
     flags: {expose: true},
     expose: {
-      dependencies: [contribsByRefProperty],
-      compute({
-        [contribsByRefProperty]: contribsByRef,
-      }) {
-        return !empty(contribsByRef);
-      },
-    }
+      dependencies: [contribsProperty],
+      compute: ({[contribsProperty]: contribs}) =>
+        !empty(contribs),
+    },
   };
 }
 
@@ -380,13 +381,13 @@ export function withResolvedContribs({
       mapDependencies: {from},
       compute: ({from}, continuation) =>
         continuation({
-          '#whoByRef': from.map(({who}) => who),
+          '#artistRefs': from.map(({who}) => who),
           '#what': from.map(({what}) => what),
         }),
     },
 
     withResolvedReferenceList({
-      list: '#whoByRef',
+      list: '#artistRefs',
       data: 'artistData',
       into: '#who',
       find: find.artist,
diff --git a/src/data/things/track.js b/src/data/things/track.js
index fcfd39c7..8263d399 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -3,7 +3,6 @@ import {inspect} from 'node:util';
 import {colors} from '#cli';
 import find from '#find';
 import {empty} from '#sugar';
-import {isColor, isDate, isDuration, isFileExtension} from '#validators';
 
 import {
   compositeFrom,
@@ -13,20 +12,28 @@ import {
   exposeDependencyOrContinue,
   exposeUpdateValueOrContinue,
   withResultOfAvailabilityCheck,
+  withUpdateValueAsDependency,
 } from '#composite';
 
+import {
+  isColor,
+  isContributionList,
+  isDate,
+  isDuration,
+  isFileExtension,
+} from '#validators';
+
+import CacheableObject from './cacheable-object.js';
+
 import Thing, {
   additionalFiles,
   commentary,
   commentatorArtists,
-  contribsByRef,
+  contributionList,
   directory,
-  dynamicContribs,
   flag,
   name,
   referenceList,
-  resolvedReference,
-  resolvedReferenceList,
   reverseReferenceList,
   simpleDate,
   singleReference,
@@ -55,13 +62,11 @@ export class Track extends Thing {
     urls: urls(),
     dateFirstReleased: simpleDate(),
 
-    artistContribsByRef: contribsByRef(),
-    contributorContribsByRef: contribsByRef(),
-    coverArtistContribsByRef: contribsByRef(),
-
-    referencedTracksByRef: referenceList(Track),
-    sampledTracksByRef: referenceList(Track),
-    artTagsByRef: referenceList(ArtTag),
+    artTags: referenceList({
+      class: ArtTag,
+      find: find.artTag,
+      data: 'artTagData',
+    }),
 
     color: compositeFrom(`Track.color`, [
       exposeUpdateValueOrContinue(),
@@ -134,9 +139,24 @@ export class Track extends Thing {
       }),
     ]),
 
-    originalReleaseTrackByRef: singleReference(Track),
+    originalReleaseTrack: singleReference({
+      class: Track,
+      find: find.track,
+      data: 'trackData',
+    }),
 
-    dataSourceAlbumByRef: singleReference(Album),
+    // Note - this is an internal property used only to help identify a track.
+    // It should not be assumed in general that the album and dataSourceAlbum match
+    // (i.e. a track may dynamically be moved from one album to another, at
+    // which point dataSourceAlbum refers to where it was originally from, and is
+    // not generally relevant information). It's also not guaranteed that
+    // dataSourceAlbum is available (depending on the Track creator to optionally
+    // provide this property's update value).
+    dataSourceAlbum: singleReference({
+      class: Album,
+      find: find.album,
+      data: 'albumData',
+    }),
 
     commentary: commentary(),
     lyrics: simpleString(),
@@ -161,19 +181,6 @@ export class Track extends Thing {
       exposeDependency({dependency: '#album'}),
     ]),
 
-    // Note - this is an internal property used only to help identify a track.
-    // It should not be assumed in general that the album and dataSourceAlbum match
-    // (i.e. a track may dynamically be moved from one album to another, at
-    // which point dataSourceAlbum refers to where it was originally from, and is
-    // not generally relevant information). It's also not guaranteed that
-    // dataSourceAlbum is available (depending on the Track creator to optionally
-    // provide dataSourceAlbumByRef).
-    dataSourceAlbum: resolvedReference({
-      ref: 'dataSourceAlbumByRef',
-      data: 'albumData',
-      find: find.album,
-    }),
-
     date: compositeFrom(`Track.date`, [
       exposeDependencyOrContinue({dependency: 'dateFirstReleased'}),
       withAlbumProperty({property: 'date'}),
@@ -192,11 +199,6 @@ export class Track extends Thing {
       exposeDependency({dependency: '#hasUniqueCoverArt'}),
     ]),
 
-    originalReleaseTrack: compositeFrom(`Track.originalReleaseTrack`, [
-      withOriginalRelease(),
-      exposeDependency({dependency: '#originalRelease'}),
-    ]),
-
     otherReleases: compositeFrom(`Track.otherReleases`, [
       exitWithoutDependency({dependency: 'trackData', mode: 'empty'}),
       withOriginalRelease({selfIfOriginal: true}),
@@ -224,26 +226,20 @@ export class Track extends Thing {
     artistContribs: compositeFrom(`Track.artistContribs`, [
       inheritFromOriginalRelease({property: 'artistContribs'}),
 
-      withResolvedContribs({
-        from: 'artistContribsByRef',
-        into: '#artistContribs',
-      }),
-
-      {
-        dependencies: ['#artistContribs'],
-        compute: ({'#artistContribs': contribsFromTrack}, continuation) =>
-          (empty(contribsFromTrack)
-            ? continuation()
-            : contribsFromTrack),
-      },
+      withUpdateValueAsDependency(),
+      withResolvedContribs({from: '#updateValue', into: '#artistContribs'}),
+      exposeDependencyOrContinue({dependency: '#artistContribs'}),
 
       withAlbumProperty({property: 'artistContribs'}),
-      exposeDependency({dependency: '#album.artistContribs'}),
+      exposeDependency({
+        dependency: '#album.artistContribs',
+        update: {validate: isContributionList},
+      }),
     ]),
 
     contributorContribs: compositeFrom(`Track.contributorContribs`, [
       inheritFromOriginalRelease({property: 'contributorContribs'}),
-      dynamicContribs('contributorContribsByRef'),
+      contributionList(),
     ]),
 
     // Cover artists aren't inherited from the original release, since it
@@ -258,47 +254,35 @@ export class Track extends Thing {
             : continuation()),
       },
 
-      withResolvedContribs({
-        from: 'coverArtistContribsByRef',
-        into: '#coverArtistContribs',
-      }),
-
-      {
-        dependencies: ['#coverArtistContribs'],
-        compute: ({'#coverArtistContribs': contribsFromTrack}, continuation) =>
-          (empty(contribsFromTrack)
-            ? continuation()
-            : contribsFromTrack),
-      },
+      withUpdateValueAsDependency(),
+      withResolvedContribs({from: '#updateValue', into: '#coverArtistContribs'}),
+      exposeDependencyOrContinue({dependency: '#coverArtistContribs'}),
 
       withAlbumProperty({property: 'trackCoverArtistContribs'}),
-      exposeDependency({dependency: '#album.trackCoverArtistContribs'}),
+      exposeDependency({
+        dependency: '#album.trackCoverArtistContribs',
+        update: {validate: isContributionList},
+      }),
     ]),
 
     referencedTracks: compositeFrom(`Track.referencedTracks`, [
       inheritFromOriginalRelease({property: 'referencedTracks'}),
-      resolvedReferenceList({
-        list: 'referencedTracksByRef',
-        data: 'trackData',
+      referenceList({
+        class: Track,
         find: find.track,
+        data: 'trackData',
       }),
     ]),
 
     sampledTracks: compositeFrom(`Track.sampledTracks`, [
       inheritFromOriginalRelease({property: 'sampledTracks'}),
-      resolvedReferenceList({
-        list: 'sampledTracksByRef',
-        data: 'trackData',
+      referenceList({
+        class: Track,
         find: find.track,
+        data: 'trackData',
       }),
     ]),
 
-    artTags: resolvedReferenceList({
-      list: 'artTagsByRef',
-      data: 'artTagData',
-      find: find.artTag,
-    }),
-
     // Specifically exclude re-releases from this list - while it's useful to
     // get from a re-release to the tracks it references, re-releases aren't
     // generally relevant from the perspective of the tracks being referenced.
@@ -327,7 +311,7 @@ export class Track extends Thing {
 
     parts.push(Thing.prototype[inspect.custom].apply(this));
 
-    if (this.originalReleaseTrackByRef) {
+    if (CacheableObject.getUpdateValue(this, 'originalReleaseTrack')) {
       parts.unshift(`${colors.yellow('[rerelease]')} `);
     }
 
@@ -564,7 +548,7 @@ function withOriginalRelease({
 } = {}) {
   return compositeFrom(`withOriginalRelease`, [
     withResolvedReference({
-      ref: 'originalReleaseTrackByRef',
+      ref: 'originalReleaseTrack',
       data: 'trackData',
       into: '#originalRelease',
       find: find.track,
@@ -607,7 +591,7 @@ function withHasUniqueCoverArt({
     },
 
     withResolvedContribs({
-      from: 'coverArtistContribsByRef',
+      from: 'coverArtistContribs',
       into: '#coverArtistContribs',
     }),
 
diff --git a/src/data/things/validators.js b/src/data/things/validators.js
index 4c8f683b..f0d1d9fd 100644
--- a/src/data/things/validators.js
+++ b/src/data/things/validators.js
@@ -308,7 +308,7 @@ export const isTrackSection = validateProperties({
   color: optional(isColor),
   dateOriginallyReleased: optional(isDate),
   isDefaultTrackSection: optional(isBoolean),
-  tracksByRef: optional(validateReferenceList('track')),
+  tracks: optional(validateReferenceList('track')),
 });
 
 export const isTrackSectionList = validateArrayItems(isTrackSection);
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 416b6c4e..7c2de324 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -6,7 +6,6 @@ import Thing, {
   flag,
   name,
   referenceList,
-  resolvedReferenceList,
   simpleString,
   wikiData,
 } from './thing.js';
@@ -45,7 +44,11 @@ export class WikiInfo extends Thing {
       update: {validate: isURL},
     },
 
-    divideTrackListsByGroupsByRef: referenceList(Group),
+    divideTrackListsByGroups: referenceList({
+      class: Group,
+      find: find.group,
+      data: 'groupData',
+    }),
 
     // Feature toggles
     enableFlashesAndGames: flag(false),
@@ -57,13 +60,5 @@ export class WikiInfo extends Thing {
     // Update only
 
     groupData: wikiData(Group),
-
-    // Expose only
-
-    divideTrackListsByGroups: resolvedReferenceList({
-      list: 'divideTrackListsByGroupsByRef',
-      data: 'groupData',
-      find: find.group,
-    }),
   });
 }
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 8aca3299..e1e5803d 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -10,7 +10,7 @@ import yaml from 'js-yaml';
 import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
 import find, {bindFind} from '#find';
 import {traverse} from '#node-utils';
-import T from '#things';
+import T, {CacheableObject, Thing} from '#things';
 
 import {
   conditionallySuppressError,
@@ -278,11 +278,11 @@ export const processAlbumDocument = makeProcessDocument(T.Album, {
     coverArtFileExtension: 'Cover Art File Extension',
     trackCoverArtFileExtension: 'Track Art File Extension',
 
-    wallpaperArtistContribsByRef: 'Wallpaper Artists',
+    wallpaperArtistContribs: 'Wallpaper Artists',
     wallpaperStyle: 'Wallpaper Style',
     wallpaperFileExtension: 'Wallpaper File Extension',
 
-    bannerArtistContribsByRef: 'Banner Artists',
+    bannerArtistContribs: 'Banner Artists',
     bannerStyle: 'Banner Style',
     bannerFileExtension: 'Banner File Extension',
     bannerDimensions: 'Banner Dimensions',
@@ -290,11 +290,11 @@ export const processAlbumDocument = makeProcessDocument(T.Album, {
     commentary: 'Commentary',
     additionalFiles: 'Additional Files',
 
-    artistContribsByRef: 'Artists',
-    coverArtistContribsByRef: 'Cover Artists',
-    trackCoverArtistContribsByRef: 'Default Track Cover Artists',
-    groupsByRef: 'Groups',
-    artTagsByRef: 'Art Tags',
+    artistContribs: 'Artists',
+    coverArtistContribs: 'Cover Artists',
+    trackCoverArtistContribs: 'Default Track Cover Artists',
+    groups: 'Groups',
+    artTags: 'Art Tags',
   },
 });
 
@@ -348,13 +348,13 @@ export const processTrackDocument = makeProcessDocument(T.Track, {
     sheetMusicFiles: 'Sheet Music Files',
     midiProjectFiles: 'MIDI Project Files',
 
-    originalReleaseTrackByRef: 'Originally Released As',
-    referencedTracksByRef: 'Referenced Tracks',
-    sampledTracksByRef: 'Sampled Tracks',
-    artistContribsByRef: 'Artists',
-    contributorContribsByRef: 'Contributors',
-    coverArtistContribsByRef: 'Cover Artists',
-    artTagsByRef: 'Art Tags',
+    originalReleaseTrack: 'Originally Released As',
+    referencedTracks: 'Referenced Tracks',
+    sampledTracks: 'Sampled Tracks',
+    artistContribs: 'Artists',
+    contributorContribs: 'Contributors',
+    coverArtistContribs: 'Cover Artists',
+    artTags: 'Art Tags',
   },
 
   invalidFieldCombinations: [
@@ -424,8 +424,8 @@ export const processFlashDocument = makeProcessDocument(T.Flash, {
     date: 'Date',
     coverArtFileExtension: 'Cover Art File Extension',
 
-    featuredTracksByRef: 'Featured Tracks',
-    contributorContribsByRef: 'Contributors',
+    featuredTracks: 'Featured Tracks',
+    contributorContribs: 'Contributors',
   },
 });
 
@@ -470,7 +470,7 @@ export const processGroupDocument = makeProcessDocument(T.Group, {
     description: 'Description',
     urls: 'URLs',
 
-    featuredAlbumsByRef: 'Featured Albums',
+    featuredAlbums: 'Featured Albums',
   },
 });
 
@@ -501,7 +501,7 @@ export const processWikiInfoDocument = makeProcessDocument(T.WikiInfo, {
     footerContent: 'Footer Content',
     defaultLanguage: 'Default Language',
     canonicalBase: 'Canonical Base',
-    divideTrackListsByGroupsByRef: 'Divide Track Lists By Groups',
+    divideTrackListsByGroups: 'Divide Track Lists By Groups',
     enableFlashesAndGames: 'Enable Flashes & Games',
     enableListings: 'Enable Listings',
     enableNews: 'Enable News',
@@ -536,9 +536,9 @@ export const homepageLayoutRowTypeProcessMapping = {
   albums: makeProcessHomepageLayoutRowDocument(T.HomepageLayoutAlbumsRow, {
     propertyFieldMapping: {
       displayStyle: 'Display Style',
-      sourceGroupByRef: 'Group',
+      sourceGroup: 'Group',
       countAlbumsFromGroup: 'Count',
-      sourceAlbumsByRef: 'Albums',
+      sourceAlbums: 'Albums',
       actionLinks: 'Actions',
     },
   }),
@@ -771,13 +771,13 @@ export const dataSteps = [
         let currentTrackSection = {
           name: `Default Track Section`,
           isDefaultTrackSection: true,
-          tracksByRef: [],
+          tracks: [],
         };
 
-        const albumRef = T.Thing.getReference(album);
+        const albumRef = Thing.getReference(album);
 
         const closeCurrentTrackSection = () => {
-          if (!empty(currentTrackSection.tracksByRef)) {
+          if (!empty(currentTrackSection.tracks)) {
             trackSections.push(currentTrackSection);
           }
         };
@@ -791,7 +791,7 @@ export const dataSteps = [
               color: entry.color,
               dateOriginallyReleased: entry.dateOriginallyReleased,
               isDefaultTrackSection: false,
-              tracksByRef: [],
+              tracks: [],
             };
 
             continue;
@@ -799,9 +799,9 @@ export const dataSteps = [
 
           trackData.push(entry);
 
-          entry.dataSourceAlbumByRef = albumRef;
+          entry.dataSourceAlbum = albumRef;
 
-          currentTrackSection.tracksByRef.push(T.Thing.getReference(entry));
+          currentTrackSection.tracks.push(Thing.getReference(entry));
         }
 
         closeCurrentTrackSection();
@@ -825,12 +825,12 @@ export const dataSteps = [
       const artistData = results;
 
       const artistAliasData = results.flatMap((artist) => {
-        const origRef = T.Thing.getReference(artist);
+        const origRef = Thing.getReference(artist);
         return artist.aliasNames?.map((name) => {
           const alias = new T.Artist();
           alias.name = name;
           alias.isAlias = true;
-          alias.aliasedArtistRef = origRef;
+          alias.aliasedArtist = origRef;
           alias.artistData = artistData;
           return alias;
         }) ?? [];
@@ -854,7 +854,7 @@ export const dataSteps = [
 
     save(results) {
       let flashAct;
-      let flashesByRef = [];
+      let flashRefs = [];
 
       if (results[0] && !(results[0] instanceof T.FlashAct)) {
         throw new Error(`Expected an act at top of flash data file`);
@@ -863,18 +863,18 @@ export const dataSteps = [
       for (const thing of results) {
         if (thing instanceof T.FlashAct) {
           if (flashAct) {
-            Object.assign(flashAct, {flashesByRef});
+            Object.assign(flashAct, {flashes: flashRefs});
           }
 
           flashAct = thing;
-          flashesByRef = [];
+          flashRefs = [];
         } else {
-          flashesByRef.push(T.Thing.getReference(thing));
+          flashRefs.push(Thing.getReference(thing));
         }
       }
 
       if (flashAct) {
-        Object.assign(flashAct, {flashesByRef});
+        Object.assign(flashAct, {flashes: flashRefs});
       }
 
       const flashData = results.filter((x) => x instanceof T.Flash);
@@ -897,7 +897,7 @@ export const dataSteps = [
 
     save(results) {
       let groupCategory;
-      let groupsByRef = [];
+      let groupRefs = [];
 
       if (results[0] && !(results[0] instanceof T.GroupCategory)) {
         throw new Error(`Expected a category at top of group data file`);
@@ -906,18 +906,18 @@ export const dataSteps = [
       for (const thing of results) {
         if (thing instanceof T.GroupCategory) {
           if (groupCategory) {
-            Object.assign(groupCategory, {groupsByRef});
+            Object.assign(groupCategory, {groups: groupRefs});
           }
 
           groupCategory = thing;
-          groupsByRef = [];
+          groupRefs = [];
         } else {
-          groupsByRef.push(T.Thing.getReference(thing));
+          groupRefs.push(Thing.getReference(thing));
         }
       }
 
       if (groupCategory) {
-        Object.assign(groupCategory, {groupsByRef});
+        Object.assign(groupCategory, {groups: groupRefs});
       }
 
       const groupData = results.filter((x) => x instanceof T.Group);
@@ -1462,45 +1462,45 @@ export function filterDuplicateDirectories(wikiData) {
 export function filterReferenceErrors(wikiData) {
   const referenceSpec = [
     ['wikiInfo', processWikiInfoDocument, {
-      divideTrackListsByGroupsByRef: 'group',
+      divideTrackListsByGroups: 'group',
     }],
 
     ['albumData', processAlbumDocument, {
-      artistContribsByRef: '_contrib',
-      coverArtistContribsByRef: '_contrib',
-      trackCoverArtistContribsByRef: '_contrib',
-      wallpaperArtistContribsByRef: '_contrib',
-      bannerArtistContribsByRef: '_contrib',
-      groupsByRef: 'group',
-      artTagsByRef: 'artTag',
+      artistContribs: '_contrib',
+      coverArtistContribs: '_contrib',
+      trackCoverArtistContribs: '_contrib',
+      wallpaperArtistContribs: '_contrib',
+      bannerArtistContribs: '_contrib',
+      groups: 'group',
+      artTags: 'artTag',
     }],
 
     ['trackData', processTrackDocument, {
-      artistContribsByRef: '_contrib',
-      contributorContribsByRef: '_contrib',
-      coverArtistContribsByRef: '_contrib',
-      referencedTracksByRef: '_trackNotRerelease',
-      sampledTracksByRef: '_trackNotRerelease',
-      artTagsByRef: 'artTag',
-      originalReleaseTrackByRef: '_trackNotRerelease',
+      artistContribs: '_contrib',
+      contributorContribs: '_contrib',
+      coverArtistContribs: '_contrib',
+      referencedTracks: '_trackNotRerelease',
+      sampledTracks: '_trackNotRerelease',
+      artTags: 'artTag',
+      originalReleaseTrack: '_trackNotRerelease',
     }],
 
     ['groupCategoryData', processGroupCategoryDocument, {
-      groupsByRef: 'group',
+      groups: 'group',
     }],
 
     ['homepageLayout.rows', undefined, {
-      sourceGroupByRef: '_homepageSourceGroup',
-      sourceAlbumsByRef: 'album',
+      sourceGroup: '_homepageSourceGroup',
+      sourceAlbums: 'album',
     }],
 
     ['flashData', processFlashDocument, {
-      contributorContribsByRef: '_contrib',
-      featuredTracksByRef: 'track',
+      contributorContribs: '_contrib',
+      featuredTracks: 'track',
     }],
 
     ['flashActData', processFlashActDocument, {
-      flashesByRef: 'flash',
+      flashes: 'flash',
     }],
   ];
 
@@ -1532,7 +1532,7 @@ export function filterReferenceErrors(wikiData) {
 
         nest({message: `Reference errors in ${inspect(thing)}`}, ({push, filter}) => {
           for (const [property, findFnKey] of Object.entries(propSpec)) {
-            const value = thing[property];
+            const value = CacheableObject.getUpdateValue(thing, property);
 
             if (value === undefined) {
               push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`));
@@ -1552,7 +1552,7 @@ export function filterReferenceErrors(wikiData) {
                   if (alias) {
                     // No need to check if the original exists here. Aliases are automatically
                     // created from a field on the original, so the original certainly exists.
-                    const original = find.artist(alias.aliasedArtistRef, wikiData.artistData, {mode: 'quiet'});
+                    const original = alias.aliasedArtist;
                     throw new Error(`Reference ${colors.red(contribRef.who)} is to an alias, should be ${colors.green(original.name)}`);
                   }
 
@@ -1573,12 +1573,13 @@ export function filterReferenceErrors(wikiData) {
               case '_trackNotRerelease':
                 findFn = trackRef => {
                   const track = find.track(trackRef, wikiData.trackData, {mode: 'error'});
+                  const originalRef = track && CacheableObject.getUpdateValue(track, 'originalReleaseTrack');
 
-                  if (track?.originalReleaseTrackByRef) {
+                  if (originalRef) {
                     // It's possible for the original to not actually exist, in this case.
                     // It should still be reported since the 'Originally Released As' field
                     // was present.
-                    const original = find.track(track.originalReleaseTrackByRef, wikiData.trackData, {mode: 'quiet'});
+                    const original = find.track(originalRef, wikiData.trackData, {mode: 'quiet'});
 
                     // Prefer references by name, but only if it's unambiguous.
                     const originalByName =
@@ -1591,7 +1592,7 @@ export function filterReferenceErrors(wikiData) {
                         ? colors.green(original.name)
                      : original
                         ? colors.green('track:' + original.directory)
-                        : colors.green(track.originalReleaseTrackByRef));
+                        : colors.green(originalRef));
 
                     throw new Error(`Reference ${colors.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`);
                   }
@@ -1606,7 +1607,7 @@ export function filterReferenceErrors(wikiData) {
             }
 
             const suppress = fn => conditionallySuppressError(error => {
-              if (property === 'sampledTracksByRef') {
+              if (property === 'sampledTracks') {
                 // Suppress "didn't match anything" errors in particular, just for samples.
                 // In hsmusic-data we have a lot of "stub" sample data which don't have
                 // corresponding tracks yet, so it won't be useful to report such reference
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index fafd17f6..4977ade7 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -93,6 +93,9 @@ import * as path from 'node:path';
 
 import dimensionsOf from 'image-size';
 
+import {delay, empty, queue} from '#sugar';
+import {CacheableObject} from '#things';
+
 import {
   colors,
   fileIssue,
@@ -110,8 +113,6 @@ import {
   traverse,
 } from '#node-utils';
 
-import {delay, empty, queue} from '#sugar';
-
 export const defaultMagickThreads = 8;
 
 export function getThumbnailsAvailableForDimensions([width, height]) {
@@ -608,8 +609,8 @@ export function getExpectedImagePaths(mediaPath, {urls, wikiData}) {
     wikiData.albumData
       .flatMap(album => [
         album.hasCoverArt && fromRoot.to('media.albumCover', album.directory, album.coverArtFileExtension),
-        !empty(album.bannerArtistContribsByRef) && fromRoot.to('media.albumBanner', album.directory, album.bannerFileExtension),
-        !empty(album.wallpaperArtistContribsByRef) && fromRoot.to('media.albumWallpaper', album.directory, album.wallpaperFileExtension),
+        !empty(CacheableObject.getUpdateValue(album, 'bannerArtistContribs')) && fromRoot.to('media.albumBanner', album.directory, album.bannerFileExtension),
+        !empty(CacheableObject.getUpdateValue(album, 'wallpaperArtistContribs')) && fromRoot.to('media.albumWallpaper', album.directory, album.wallpaperFileExtension),
       ])
       .filter(Boolean),