diff --git a/packages/patterns/index.js b/packages/patterns/index.js index 1a3cc1773f..df619d3ed0 100644 --- a/packages/patterns/index.js +++ b/packages/patterns/index.js @@ -73,7 +73,12 @@ export { // ////////////////// Temporary, until these find their proper home //////////// -export { listDifference, objectMap } from './src/utils.js'; +export { + listDifference, + objectMap, + objectMetaMap, + objectMetaAssign, +} from './src/utils.js'; // eslint-disable-next-line import/export export * from './src/types.js'; diff --git a/packages/patterns/src/utils.js b/packages/patterns/src/utils.js index e6f7722df7..fabc2a8744 100644 --- a/packages/patterns/src/utils.js +++ b/packages/patterns/src/utils.js @@ -4,7 +4,13 @@ import { isPromise } from '@endo/promise-kit'; /** @typedef {import('@endo/marshal').Checker} Checker */ -const { fromEntries, entries } = Object; +const { + fromEntries, + entries, + getOwnPropertyDescriptors, + create, + defineProperties, +} = Object; const { ownKeys } = Reflect; const { details: X, quote: q, Fail } = assert; @@ -92,7 +98,7 @@ harden(fromUniqueEntries); * a CopyRecord. * * @template {Record} O - * @template R result + * @template R map result * @param {O} original * @param {(value: O[keyof O], key: keyof O) => R} mapFn * @returns {Record} @@ -106,6 +112,79 @@ export const objectMap = (original, mapFn) => { }; harden(objectMap); +/** + * Like `objectMap`, but at the reflective level of property descriptors + * rather than property values. + * + * Except for hardening, the edge case behavior is mostly the opposite of + * the `objectMap` edge cases. + * * No matter how mutable the original object, the returned object is + * hardened. + * * All own properties of the original are mapped, even if symbol-named + * or non-enumerable. + * * If any of the original properties were accessors, the descriptor + * containing the getter and setter are given to `metaMapFn`. + * * The own properties of the returned are according to the descriptors + * returned by `metaMapFn`. + * * The returned object will always be a plain object whose state is + * only these mapped own properties. It will inherit from the third + * argument if provided, defaulting to `Object.prototype` if omitted. + * + * Because a property descriptor is distinct from `undefined`, we bundle + * mapping and filtering together. When the `metaMapFn` returns `undefined`, + * that property is omitted from the result. + * + * @template {Record} O + * @param {O} original + * @param {( + * desc: TypedPropertyDescriptor, + * key: keyof O + * ) => (PropertyDescriptor | undefined)} metaMapFn + * @param {any} [proto] + * @returns {any} + */ +export const objectMetaMap = ( + original, + metaMapFn, + proto = Object.prototype, +) => { + const descs = getOwnPropertyDescriptors(original); + const keys = ownKeys(original); + + const descEntries = /** @type {[PropertyKey,PropertyDescriptor][]} */ ( + keys + .map(key => [key, metaMapFn(descs[key], key)]) + .filter(([_key, optDesc]) => optDesc !== undefined) + ); + return harden(create(proto, fromUniqueEntries(descEntries))); +}; +harden(objectMetaMap); + +/** + * Like `Object.assign` but at the reflective level of property descriptors + * rather than property values. + * + * Unlike `Object.assign`, this includes all own properties, whether enumerable + * or not. An original accessor property is copied by sharing its getter and + * setter, rather than calling the getter to obtain a value. If an original + * property is non-configurable, a property of the same name on a later original + * that would conflict instead causes the call to `objectMetaAssign` to throw an + * error. + * + * Returns the enhanced `target` after hardening. + * + * @param {any} target + * @param {any[]} originals + * @returns {any} + */ +export const objectMetaAssign = (target, ...originals) => { + for (const original of originals) { + defineProperties(target, getOwnPropertyDescriptors(original)); + } + return harden(target); +}; +harden(objectMetaAssign); + /** * * @param {Array} leftNames