diff --git a/packages/exo/src/exo-makers.js b/packages/exo/src/exo-makers.js index 94f42706ab..18717bd3f8 100644 --- a/packages/exo/src/exo-makers.js +++ b/packages/exo/src/exo-makers.js @@ -5,7 +5,7 @@ import { objectMap } from '@endo/patterns'; import { defendPrototype, defendPrototypeKit } from './exo-tools.js'; -const { create, seal, freeze, defineProperty } = Object; +const { create, seal, freeze, defineProperty, values } = Object; const { getEnvironmentOption } = makeEnvironmentCaptor(globalThis); const DEBUG = getEnvironmentOption('DEBUG', ''); @@ -62,11 +62,24 @@ export const initEmpty = () => emptyRecord; * Each property is distinct, is checked and changed separately. */ +/** + * @callback Revoker + * @param {any} exo + * @returns {boolean} + */ + +/** + * @callback ReceiveRevoker + * @param {Revoker} revoke + * @returns {void} + */ + /** * @template C * @typedef {object} FarClassOptions * @property {(context: C) => void} [finish] * @property {StateShape} [stateShape] + * @property {ReceiveRevoker} [receiveRevoker] */ /** @@ -79,9 +92,15 @@ export const initEmpty = () => emptyRecord; * @param {FarClassOptions, M>>} [options] * @returns {(...args: Parameters) => (M & import('@endo/eventual-send').RemotableBrand<{}, M>)} */ -export const defineExoClass = (tag, interfaceGuard, init, methods, options) => { +export const defineExoClass = ( + tag, + interfaceGuard, + init, + methods, + options = {}, +) => { harden(methods); - const { finish = undefined } = options || {}; + const { finish = undefined, receiveRevoker = undefined } = options; /** @type {WeakMap, M>>} */ const contextMap = new WeakMap(); const proto = defendPrototype( @@ -113,6 +132,13 @@ export const defineExoClass = (tag, interfaceGuard, init, methods, options) => { self ); }; + + if (receiveRevoker) { + const revoke = self => contextMap.delete(self); + harden(revoke); + receiveRevoker(revoke); + } + return harden(makeInstance); }; harden(defineExoClass); @@ -132,14 +158,14 @@ export const defineExoClassKit = ( interfaceGuardKit, init, methodsKit, - options, + options = {}, ) => { harden(methodsKit); - const { finish = undefined } = options || {}; + const { finish = undefined, receiveRevoker = undefined } = options; const contextMapKit = objectMap(methodsKit, () => new WeakMap()); const getContextKit = objectMap( - methodsKit, - (_v, name) => facet => contextMapKit[name].get(facet), + contextMapKit, + contextMap => facet => contextMap.get(facet), ); const prototypeKit = defendPrototypeKit( tag, @@ -172,6 +198,14 @@ export const defineExoClassKit = ( } return context.facets; }; + + if (receiveRevoker) { + const revoke = aFacet => + values(contextMapKit).some(contextMap => contextMap.delete(aFacet)); + harden(revoke); + receiveRevoker(revoke); + } + return harden(makeInstanceKit); }; harden(defineExoClassKit); diff --git a/packages/exo/test/test-revoke-heap-classes.js b/packages/exo/test/test-revoke-heap-classes.js new file mode 100644 index 0000000000..5f2c81c581 --- /dev/null +++ b/packages/exo/test/test-revoke-heap-classes.js @@ -0,0 +1,127 @@ +// eslint-disable-next-line import/order +import { test } from './prepare-test-env-ava.js'; + +// eslint-disable-next-line import/order +import { M } from '@endo/patterns'; +import { defineExoClass, defineExoClassKit } from '../src/exo-makers.js'; + +const { apply } = Reflect; + +const UpCounterI = M.interface('UpCounter', { + incr: M.call() + // TODO M.number() should not be needed to get a better error message + .optional(M.and(M.number(), M.gte(0))) + .returns(M.number()), +}); + +const DownCounterI = M.interface('DownCounter', { + decr: M.call() + // TODO M.number() should not be needed to get a better error message + .optional(M.and(M.number(), M.gte(0))) + .returns(M.number()), +}); + +test('test revoke defineExoClass', t => { + let revoke; + const makeUpCounter = defineExoClass( + 'UpCounter', + UpCounterI, + /** @param {number} x */ + (x = 0) => ({ x }), + { + incr(y = 1) { + const { state } = this; + state.x += y; + return state.x; + }, + }, + { + receiveRevoker(r) { + revoke = r; + }, + }, + ); + const upCounter = makeUpCounter(3); + t.is(upCounter.incr(5), 8); + t.is(revoke(upCounter), true); + t.throws(() => upCounter.incr(1), { + message: + '"In \\"incr\\" method of (UpCounter)" may only be applied to a valid instance: "[Alleged: UpCounter]"', + }); +}); + +test('test revoke defineExoClassKit', t => { + let revoke; + const makeCounterKit = defineExoClassKit( + 'Counter', + { up: UpCounterI, down: DownCounterI }, + /** @param {number} x */ + (x = 0) => ({ x }), + { + up: { + incr(y = 1) { + const { state } = this; + state.x += y; + return state.x; + }, + }, + down: { + decr(y = 1) { + const { state } = this; + state.x -= y; + return state.x; + }, + }, + }, + { + receiveRevoker(r) { + revoke = r; + }, + }, + ); + const { up: upCounter, down: downCounter } = makeCounterKit(3); + t.is(upCounter.incr(5), 8); + t.is(downCounter.decr(), 7); + t.is(revoke(upCounter), true); + t.is(revoke(upCounter), false); + t.throws(() => upCounter.incr(3), { + message: + '"In \\"incr\\" method of (Counter up)" may only be applied to a valid instance: "[Alleged: Counter up]"', + }); + t.is(revoke(downCounter), true); + t.is(revoke(downCounter), false); + t.throws(() => downCounter.decr(), { + message: + '"In \\"decr\\" method of (Counter down)" may only be applied to a valid instance: "[Alleged: Counter down]"', + }); +}); + +test('test facet cross-talk', t => { + const makeCounterKit = defineExoClassKit( + 'Counter', + { up: UpCounterI, down: DownCounterI }, + /** @param {number} x */ + (x = 0) => ({ x }), + { + up: { + incr(y = 1) { + const { state } = this; + state.x += y; + return state.x; + }, + }, + down: { + decr(y = 1) { + const { state } = this; + state.x -= y; + return state.x; + }, + }, + }, + ); + const { up: upCounter, down: downCounter } = makeCounterKit(3); + t.throws(() => apply(upCounter.incr, downCounter, [2]), { + message: + '"In \\"incr\\" method of (Counter up)" may only be applied to a valid instance: "[Alleged: Counter down]"', + }); +});