diff options
Diffstat (limited to 'src/data/cacheable-object.js')
-rw-r--r-- | src/data/cacheable-object.js | 420 |
1 files changed, 240 insertions, 180 deletions
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js index 4afb036..1e7c7aa 100644 --- a/src/data/cacheable-object.js +++ b/src/data/cacheable-object.js @@ -74,236 +74,296 @@ // function, which provides a mapping of exposed property names to whether // or not their dependencies are yet met. -import { color, ENABLE_COLOR } from '../util/cli.js'; +import {inspect as nodeInspect} from 'node:util'; -import { inspect as nodeInspect } from 'util'; +import {colors, ENABLE_COLOR} from '#cli'; function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); + return nodeInspect(value, {colors: ENABLE_COLOR}); } export default class CacheableObject { - static instance = Symbol('CacheableObject `this` instance'); - - #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. - */ - - constructor() { - this.#defineProperties(); - this.#initializeUpdatingPropertyValues(); - - if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { - return new Proxy(this, { - get: (obj, key) => { - if (!Object.hasOwn(obj, key)) { - if (key !== 'constructor') { - CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`); - } - } - return obj[key]; - } - }); - } + #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. + + constructor() { + this.#defineProperties(); + this.#initializeUpdatingPropertyValues(); + + if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { + return new Proxy(this, { + get: (obj, key) => { + if (!Object.hasOwn(obj, key)) { + if (key !== 'constructor') { + CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`); + } + } + return obj[key]; + }, + }); } + } - #initializeUpdatingPropertyValues() { - for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) { - const { flags, update } = descriptor; + #initializeUpdatingPropertyValues() { + for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) { + const {flags, update} = descriptor; - if (!flags.update) { - continue; - } + if (!flags.update) { + continue; + } - if (update?.default) { - this[property] = update?.default; - } else { - this[property] = null; - } - } + if (update?.default) { + this[property] = update?.default; + } else { + this[property] = null; + } } + } - #defineProperties() { - if (!this.constructor.propertyDescriptors) { - throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`); - } + #defineProperties() { + if (!this.constructor.propertyDescriptors) { + throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`); + } - for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) { - const { flags } = descriptor; + for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) { + const {flags} = descriptor; - const definition = { - configurable: false, - enumerable: true - }; + const definition = { + configurable: false, + enumerable: flags.expose, + }; - if (flags.update) { - definition.set = this.#getUpdateObjectDefinitionSetterFunction(property); - } + if (flags.update) { + definition.set = this.#getUpdateObjectDefinitionSetterFunction(property); + } - if (flags.expose) { - definition.get = this.#getExposeObjectDefinitionGetterFunction(property); - } + if (flags.expose) { + definition.get = this.#getExposeObjectDefinitionGetterFunction(property); + } + + Object.defineProperty(this, property, definition); + } - Object.defineProperty(this, property, definition); + Object.seal(this); + } + + #getUpdateObjectDefinitionSetterFunction(property) { + const {update} = this.#getPropertyDescriptor(property); + const validate = update?.validate; + + return (newValue) => { + const oldValue = this.#propertyUpdateValues[property]; + + if (newValue === undefined) { + throw new TypeError(`Properties cannot be set to undefined`); + } + + if (newValue === oldValue) { + return; + } + + if (newValue !== null && validate) { + try { + const result = validate(newValue); + if (result === undefined) { + throw new TypeError(`Validate function returned undefined`); + } else if (result !== true) { + throw new TypeError(`Validation failed for value ${newValue}`); + } + } catch (caughtError) { + throw new CacheableObjectPropertyValueError( + property, oldValue, newValue, {cause: caughtError}); } + } - Object.seal(this); - } + this.#propertyUpdateValues[property] = newValue; + this.#invalidateCachesDependentUpon(property); + }; + } - #getUpdateObjectDefinitionSetterFunction(property) { - const { update } = this.#getPropertyDescriptor(property); - const validate = update?.validate; - const allowNull = update?.allowNull; + #getPropertyDescriptor(property) { + return this.constructor.propertyDescriptors[property]; + } - return (newValue) => { - const oldValue = this.#propertyUpdateValues[property]; + #invalidateCachesDependentUpon(property) { + const invalidators = this.#propertyUpdateCacheInvalidators[property]; + if (!invalidators) { + return; + } - if (newValue === undefined) { - throw new TypeError(`Properties cannot be set to undefined`); - } + for (const invalidate of invalidators) { + invalidate(); + } + } + + #getExposeObjectDefinitionGetterFunction(property) { + const {flags} = this.#getPropertyDescriptor(property); + const compute = this.#getExposeComputeFunction(property); + + if (compute) { + let cachedValue; + const checkCacheValid = this.#getExposeCheckCacheValidFunction(property); + return () => { + if (checkCacheValid()) { + return cachedValue; + } else { + return (cachedValue = compute()); + } + }; + } else if (!flags.update && !compute) { + throw new Error(`Exposed property ${property} does not update and is missing compute function`); + } else { + return () => this.#propertyUpdateValues[property]; + } + } - if (newValue === oldValue) { - return; - } + #getExposeComputeFunction(property) { + const {flags, expose} = this.#getPropertyDescriptor(property); - if (newValue !== null && validate) { - try { - const result = validate(newValue); - if (result === undefined) { - throw new TypeError(`Validate function returned undefined`); - } else if (result !== true) { - throw new TypeError(`Validation failed for value ${newValue}`); - } - } catch (error) { - error.message = `Property ${color.green(property)} (${inspect(this[property])} -> ${inspect(newValue)}): ${error.message}`; - throw error; - } - } + const compute = expose?.compute; + const transform = expose?.transform; - this.#propertyUpdateValues[property] = newValue; - this.#invalidateCachesDependentUpon(property); - }; + if (flags.update && !transform) { + return null; + } else if (flags.update && compute) { + throw new Error(`Updating property ${property} has compute function, should be formatted as transform`); + } else if (!flags.update && !compute) { + throw new Error(`Exposed property ${property} does not update and is missing compute function`); } - #getUpdatePropertyValidateFunction(property) { - const descriptor = this.#getPropertyDescriptor(property); - } + let getAllDependencies; - #getPropertyDescriptor(property) { - return this.constructor.propertyDescriptors[property]; - } + if (expose.dependencies?.length > 0) { + const dependencyKeys = expose.dependencies.slice(); + const shouldReflect = dependencyKeys.includes('this'); + + getAllDependencies = () => { + const dependencies = Object.create(null); - #invalidateCachesDependentUpon(property) { - for (const invalidate of this.#propertyUpdateCacheInvalidators[property] || []) { - invalidate(); + for (const key of dependencyKeys) { + dependencies[key] = this.#propertyUpdateValues[key]; } - } - #getExposeObjectDefinitionGetterFunction(property) { - const { flags } = this.#getPropertyDescriptor(property); - const compute = this.#getExposeComputeFunction(property); - - if (compute) { - let cachedValue; - const checkCacheValid = this.#getExposeCheckCacheValidFunction(property); - return () => { - if (checkCacheValid()) { - return cachedValue; - } else { - return (cachedValue = compute()); - } - }; - } else if (!flags.update && !compute) { - throw new Error(`Exposed property ${property} does not update and is missing compute function`); - } else { - return () => this.#propertyUpdateValues[property]; + if (shouldReflect) { + dependencies.this = this; } + + return dependencies; + }; + } else { + const dependencies = Object.create(null); + Object.freeze(dependencies); + getAllDependencies = () => dependencies; } - #getExposeComputeFunction(property) { - const { flags, expose } = this.#getPropertyDescriptor(property); + if (flags.update) { + return () => transform(this.#propertyUpdateValues[property], getAllDependencies()); + } else { + return () => compute(getAllDependencies()); + } + } - const compute = expose?.compute; - const transform = expose?.transform; + #getExposeCheckCacheValidFunction(property) { + const {flags, expose} = this.#getPropertyDescriptor(property); - if (flags.update && !transform) { - return null; - } else if (flags.update && compute) { - throw new Error(`Updating property ${property} has compute function, should be formatted as transform`); - } else if (!flags.update && !compute) { - throw new Error(`Exposed property ${property} does not update and is missing compute function`); - } + let valid = false; - const dependencyKeys = expose.dependencies || []; - const dependencyGetters = dependencyKeys.map(key => () => [key, this.#propertyUpdateValues[key]]); - const getAllDependencies = () => Object.fromEntries(dependencyGetters.map(f => f()) - .concat([[this.constructor.instance, this]])); + const invalidate = () => { + valid = false; + }; - if (flags.update) { - return () => transform(this.#propertyUpdateValues[property], getAllDependencies()); - } else { - return () => compute(getAllDependencies()); - } + const dependencyKeys = new Set(expose?.dependencies); + + if (flags.update) { + dependencyKeys.add(property); } - #getExposeCheckCacheValidFunction(property) { - const { flags, expose } = this.#getPropertyDescriptor(property); + for (const key of dependencyKeys) { + if (this.#propertyUpdateCacheInvalidators[key]) { + this.#propertyUpdateCacheInvalidators[key].push(invalidate); + } else { + this.#propertyUpdateCacheInvalidators[key] = [invalidate]; + } + } - let valid = false; + return () => { + if (!valid) { + valid = true; + return false; + } else { + return true; + } + }; + } + + static cacheAllExposedProperties(obj) { + if (!(obj instanceof CacheableObject)) { + console.warn('Not a CacheableObject:', obj); + return; + } - const invalidate = () => { - valid = false; - }; + const {propertyDescriptors} = obj.constructor; - const dependencyKeys = new Set(expose?.dependencies); + if (!propertyDescriptors) { + console.warn('Missing property descriptors:', obj); + return; + } - if (flags.update) { - dependencyKeys.add(property); - } + for (const [property, descriptor] of Object.entries(propertyDescriptors)) { + const {flags} = descriptor; - for (const key of dependencyKeys) { - if (this.#propertyUpdateCacheInvalidators[key]) { - this.#propertyUpdateCacheInvalidators[key].push(invalidate); - } else { - this.#propertyUpdateCacheInvalidators[key] = [invalidate]; - } - } + if (!flags.expose) { + continue; + } - return () => { - if (!valid) { - valid = true; - return false; - } else { - return true; - } - }; + obj[property]; } + } - static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false; - static _invalidAccesses = new Set(); + static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false; + static _invalidAccesses = new Set(); - static showInvalidAccesses() { - if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { - return; - } + static showInvalidAccesses() { + if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { + return; + } - if (!this._invalidAccesses.size) { - return; - } + if (!this._invalidAccesses.size) { + return; + } - console.log(`${this._invalidAccesses.size} unique invalid accesses:`); - for (const line of this._invalidAccesses) { - console.log(` - ${line}`); - } + console.log(`${this._invalidAccesses.size} unique invalid accesses:`); + for (const line of this._invalidAccesses) { + console.log(` - ${line}`); } + } + + static getUpdateValue(object, key) { + if (!Object.hasOwn(object, key)) { + return undefined; + } + + return object.#propertyUpdateValues[key] ?? null; + } +} + +export class CacheableObjectPropertyValueError extends Error { + [Symbol.for('hsmusic.aggregate.translucent')] = true; + + constructor(property, oldValue, newValue, options) { + super( + `Error setting ${colors.green(property)} (${inspect(oldValue)} -> ${inspect(newValue)})`, + options); + + this.property = property; + } } |