diff options
-rw-r--r-- | src/thing/album.js | 43 | ||||
-rw-r--r-- | src/thing/structures.js | 17 | ||||
-rw-r--r-- | src/thing/thing.js | 66 | ||||
-rw-r--r-- | src/util/sugar.js | 112 |
4 files changed, 238 insertions, 0 deletions
diff --git a/src/thing/album.js b/src/thing/album.js new file mode 100644 index 00000000..1915ab85 --- /dev/null +++ b/src/thing/album.js @@ -0,0 +1,43 @@ +import Thing from './thing.js'; + +import { + validateReference +} from './structures.js'; + +import { + showAggregate, + withAggregate +} from '../util/sugar.js'; + +export default class Album extends Thing { + #tracks; + + static updateError = { + tracks: Thing.extendPropertyError('tracks') + }; + + update(source) { + withAggregate(({ wrap, call, map }) => { + if (source.tracks) { + this.#tracks = map(source.tracks, validateReference('track'), { + errorClass: this.constructor.updateError.tracks + }); + } + }); + } +} + +const album = new Album(); + +try { + album.update({ + tracks: [ + 'lol', + 123, + 'track:oh-yeah', + 'group:what-am-i-doing-here' + ] + }); +} catch (error) { + showAggregate(error); +} diff --git a/src/thing/structures.js b/src/thing/structures.js new file mode 100644 index 00000000..e6b9fd4b --- /dev/null +++ b/src/thing/structures.js @@ -0,0 +1,17 @@ +// Generic structure utilities common across various Thing types. + +export function validateReference(type = '') { + return ref => { + if (typeof ref !== 'string') + throw new TypeError(`Expected a string, got ${ref}`); + + if (type) { + if (!ref.includes(':')) + throw new TypeError(`Expected ref to begin with "${type}:", but no type specified (ref: ${ref})`); + + const typePart = ref.split(':')[0]; + if (typePart !== type) + throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:" (ref: ${ref})`); + } + }; +} diff --git a/src/thing/thing.js b/src/thing/thing.js new file mode 100644 index 00000000..c2465e32 --- /dev/null +++ b/src/thing/thing.js @@ -0,0 +1,66 @@ +// Base class for Things. No, we will not come up with a better name. +// Sorry not sorry! :) +// +// NB: Since these methods all involve processing a variety of input data, some +// of which will pass and some of which may fail, any failures should be thrown +// together as an AggregateError. See util/sugar.js for utility functions to +// make writing code around this easier! + +export default class Thing { + constructor(source, { + wikiData + } = {}) { + if (source) { + this.update(source); + } + + if (wikiData && this.checkComplete()) { + this.postprocess({wikiData}); + } + } + + static PropertyError = class extends AggregateError { + #key = this.constructor.key; + get key() { return this.#key; } + + constructor(errors) { + super(errors, ''); + this.message = `${errors.length} error(s) in property "${this.#key}"`; + } + }; + + static extendPropertyError(key) { + const cls = class extends this.PropertyError { + static #key = key; + static get key() { return this.#key; } + }; + + Object.defineProperty(cls, 'name', {value: `PropertyError:${key}`}); + return cls; + } + + // Called when instantiating a thing, and when its data is updated for any + // reason. (Which currently includes no reasons, but hey, future-proofing!) + // + // Don't expect source to be a complete object, even on the first call - the + // method checkComplete() will prevent incomplete resources from being mixed + // with the rest. + update(source) {} + + // Called when collecting the full list of available things of that type + // for wiki data; this method determine whether or not to include it. + // + // This should return whether or not the object is complete enough to be + // used across the wiki - not whether every optional attribute is provided! + // (That is, attributes required for postprocessing & basic page generation + // are all present.) + checkComplete() {} + + // Called when adding the thing to the wiki data list, and when its source + // data is updated (provided checkComplete() passes). + // + // This should generate any cached object references, across other wiki + // data; for example, building an array of actual track objects + // corresponding to an album's track list ('track:cool-track' strings). + postprocess({wikiData}) {} +} diff --git a/src/util/sugar.js b/src/util/sugar.js index 79a271bf..54b2df0a 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -87,3 +87,115 @@ export function bindOpts(fn, bind) { } bindOpts.bindIndex = Symbol(); + +// Utility function for providing useful interfaces to the JS AggregateError +// class. +// +// Generally, this works by returning a set of interfaces which operate on +// functions: wrap() takes a function and returns a new function which passes +// its arguments through and appends any resulting error to the internal error +// list; call() simplifies this process by wrapping the provided function and +// then calling it immediately. Once the process for which errors should be +// aggregated is complete, close() constructs and throws an AggregateError +// object containing all caught errors (or doesn't throw anything if there were +// no errors). +export function openAggregate({ + // Constructor to use, defaulting to the builtin AggregateError class. + // Anything passed here should probably extend from that! May be used for + // letting callers programatically distinguish between multiple aggregate + // errors. + errorClass = AggregateError, + + // Optional human-readable message to describe the aggregate error, if + // constructed. + message = '', + + // Value to return when a provided function throws an error. (This is + // primarily useful when wrapping a function and then providing it to + // another utility, e.g. array.map().) + returnOnFail = null +} = {}) { + const errors = []; + + const aggregate = {}; + + aggregate.wrap = fn => (...args) => { + try { + return fn(...args); + } catch (error) { + errors.push(error); + return returnOnFail; + } + }; + + aggregate.call = (fn, ...args) => { + return aggregate.wrap(fn)(...args); + }; + + aggregate.map = (...args) => { + const parent = aggregate; + const { result, aggregate: child } = mapAggregate(...args); + parent.call(child.close); + return result; + }; + + aggregate.close = () => { + if (errors.length) { + throw Reflect.construct(errorClass, [errors, message]); + } + }; + + return aggregate; +} + +// Performs an ordinary array map with the given function, collating into a +// results array (with errored inputs filtered out) and an error aggregate. +// +// Note the aggregate property is the result of openAggregate(), still unclosed; +// use aggregate.close() to throw the error. (This aggregate may be passed to a +// parent aggregate: `parent.call(aggregate.close)`!) +export function mapAggregate(array, fn, aggregateOpts) { + const failureSymbol = Symbol(); + + const aggregate = openAggregate({ + ...aggregateOpts, + returnOnFail: failureSymbol + }); + + const result = array.map(aggregate.wrap(fn)) + .filter(value => value !== failureSymbol); + + return {result, aggregate}; +} + +// Totally sugar function for opening an aggregate, running the provided +// function with it, then closing the function and returning the result (if +// there's no throw). +export function withAggregate(aggregateOpts, fn) { + if (typeof aggregateOpts === 'function') { + fn = aggregateOpts; + aggregateOpts = {}; + } + + const aggregate = openAggregate(aggregateOpts); + const result = fn(aggregate); + aggregate.close(); + return result; +} + +export function showAggregate(topError) { + const recursive = error => { + const header = `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'}`; + if (error instanceof AggregateError) { + return header + '\n' + (error.errors + .map(recursive) + .flatMap(str => str.split('\n')) + .map(line => ` | ` + line) + .join('\n')); + } else { + return header; + } + }; + + console.log(recursive(topError)); +} |