diff --git a/packages/ERTP/src/paymentLedger.js b/packages/ERTP/src/paymentLedger.js index 50ba2bffb06f..f0981d578076 100644 --- a/packages/ERTP/src/paymentLedger.js +++ b/packages/ERTP/src/paymentLedger.js @@ -32,6 +32,7 @@ const amountShapeFromElementShape = (brand, assetKind, elementShape) => { if (elementShape === undefined) { valueShape = M.arrayOf(M.key()); } else { + // M.and compresses only according to its last conjunct valueShape = M.arrayOf(M.and(M.key(), elementShape)); } break; @@ -137,6 +138,7 @@ export const vivifyPaymentLedger = ( const paymentLedger = provideDurableWeakMapStore( issuerBaggage, 'paymentLedger', + { valueShape: amountShape }, ); /** diff --git a/packages/ERTP/src/purse.js b/packages/ERTP/src/purse.js index 6f68b5051a66..ffe4d0229e69 100644 --- a/packages/ERTP/src/purse.js +++ b/packages/ERTP/src/purse.js @@ -1,3 +1,4 @@ +import { M } from '@agoric/store'; import { vivifyFarClassKit, makeScalarBigSetStore } from '@agoric/vat-data'; import { AmountMath } from './amountMath.js'; import { makeTransientNotifierKit } from './transientNotifier.js'; @@ -12,6 +13,8 @@ export const vivifyPurseKind = ( PurseIKit, purseMethods, ) => { + const amountShape = brand.getAmountShape(); + // Note: Virtual for high cardinality, but *not* durable, and so // broken across an upgrade. const { provideNotifier, update: updateBalance } = makeTransientNotifierKit(); @@ -111,6 +114,12 @@ export const vivifyPurseKind = ( }, }, }, + { + stateShape: { + currentBalance: amountShape, + recoverySet: M.remotable('recoverySet'), + }, + }, ); return () => makePurseKit().purse; }; diff --git a/packages/ERTP/test/unitTests/test-inputValidation.js b/packages/ERTP/test/unitTests/test-inputValidation.js index c192eb598939..d6e271247297 100644 --- a/packages/ERTP/test/unitTests/test-inputValidation.js +++ b/packages/ERTP/test/unitTests/test-inputValidation.js @@ -33,8 +33,7 @@ test('makeIssuerKit bad displayInfo.decimalPlaces', async t => { harden({ decimalPlaces: 'hello' }), ), { - message: - 'displayInfo: optional: decimalPlaces: "hello" - Must be >= -100', + message: 'displayInfo: decimalPlaces?: "hello" - Must be >= -100', }, ); @@ -62,7 +61,7 @@ test('makeIssuerKit bad displayInfo.decimalPlaces', async t => { () => makeIssuerKit('myTokens', AssetKind.NAT, harden({ decimalPlaces: 101 })), { - message: 'displayInfo: optional: decimalPlaces: 101 - Must be <= 100', + message: 'displayInfo: decimalPlaces?: 101 - Must be <= 100', }, ); @@ -70,7 +69,7 @@ test('makeIssuerKit bad displayInfo.decimalPlaces', async t => { () => makeIssuerKit('myTokens', AssetKind.NAT, harden({ decimalPlaces: -101 })), { - message: 'displayInfo: optional: decimalPlaces: -101 - Must be >= -100', + message: 'displayInfo: decimalPlaces?: -101 - Must be >= -100', }, ); }); @@ -88,7 +87,7 @@ test('makeIssuerKit bad displayInfo.assetKind', async t => { ), { message: - 'displayInfo: optional: assetKind: "something" - Must match one of ["nat","set","copySet","copyBag"]', + 'displayInfo: assetKind?: "something" - Must match one of ["nat","set","copySet","copyBag"]', }, ); }); @@ -105,7 +104,7 @@ test('makeIssuerKit bad displayInfo.whatever', async t => { }), ), { - message: 'displayInfo: rest: {"whatever":"something"} - Must be: {}', + message: 'displayInfo: ...rest: {"whatever":"something"} - Must be: {}', }, ); }); @@ -142,7 +141,7 @@ test('brand.isMyIssuer bad issuer', async t => { // @ts-expect-error Intentional wrong type for testing t.throwsAsync(() => brand.isMyIssuer('not an issuer'), { message: - 'In "isMyIssuer" method of (myTokens brand): args: [0]: string "not an issuer" - Must be a remotable (Issuer)', + 'In "isMyIssuer" method of (myTokens brand): arg 0: string "not an issuer" - Must be a remotable (Issuer)', }); const fakeIssuer = /** @type {Issuer} */ ( /** @type {unknown} */ (Far('myTokens issuer', {})) @@ -195,7 +194,7 @@ test('issuer.combine bad payments array', async t => { // @ts-expect-error Intentional wrong type for testing await t.throwsAsync(() => E(issuer).combine(notAnArray2), { message: - 'In "combine" method of (fungible issuer): args: [0]: remotable "[Alleged: notAnArray2]" - Must be a copyArray', + 'In "combine" method of (fungible issuer): arg 0: remotable "[Alleged: notAnArray2]" - Must be a copyArray', }); }); diff --git a/packages/ERTP/test/unitTests/test-issuerObj.js b/packages/ERTP/test/unitTests/test-issuerObj.js index f5e924f3d8d1..ec255b60fa4a 100644 --- a/packages/ERTP/test/unitTests/test-issuerObj.js +++ b/packages/ERTP/test/unitTests/test-issuerObj.js @@ -44,7 +44,7 @@ test('bad display info', t => { const displayInfo = harden({ somethingUnexpected: 3 }); // @ts-expect-error deliberate invalid arguments for testing t.throws(() => makeIssuerKit('fungible', AssetKind.NAT, displayInfo), { - message: 'displayInfo: rest: {"somethingUnexpected":3} - Must be: {}', + message: 'displayInfo: ...rest: {"somethingUnexpected":3} - Must be: {}', }); }); @@ -200,7 +200,7 @@ test('purse.deposit promise', async t => { () => E(purse).deposit(exclusivePaymentP, fungible25), { message: - 'In "deposit" method of (fungible Purse purse): args: [0]: promise "[Promise]" - Must be a remotable (Payment)', + 'In "deposit" method of (fungible Purse purse): arg 0: promise "[Promise]" - Must be a remotable (Payment)', }, 'failed to reject a promise for a payment', ); @@ -335,7 +335,7 @@ test('issuer.split bad amount', async t => { _ => E(issuer).split(payment, AmountMath.make(otherBrand, 10n)), { message: - 'In "split" method of (fungible issuer): args: [1]: brand: "[Alleged: other fungible brand]" - Must be: "[Alleged: fungible brand]"', + 'In "split" method of (fungible issuer): arg 1: brand: "[Alleged: other fungible brand]" - Must be: "[Alleged: fungible brand]"', }, 'throws for bad amount', ); diff --git a/packages/ERTP/test/unitTests/test-mintObj.js b/packages/ERTP/test/unitTests/test-mintObj.js index 2ad45a334433..d4c91684e129 100644 --- a/packages/ERTP/test/unitTests/test-mintObj.js +++ b/packages/ERTP/test/unitTests/test-mintObj.js @@ -46,7 +46,7 @@ test('mint.mintPayment set w strings AssetKind', async t => { const badAmount = AmountMath.make(brand, harden([['badElement']])); t.throws(() => mint.mintPayment(badAmount), { message: - 'In "mintPayment" method of (items mint): args: [0]: value: [0]: copyArray ["badElement"] - Must be a string', + 'In "mintPayment" method of (items mint): arg 0: value: [0]: copyArray ["badElement"] - Must be a string', }); }); diff --git a/packages/SwingSet/src/liveslots/collectionManager.js b/packages/SwingSet/src/liveslots/collectionManager.js index b30e1ec4a56a..9542a5899667 100644 --- a/packages/SwingSet/src/liveslots/collectionManager.js +++ b/packages/SwingSet/src/liveslots/collectionManager.js @@ -5,6 +5,7 @@ import { assertKeyPattern, assertPattern, matches, + fit, compareRank, M, zeroPad, @@ -13,6 +14,8 @@ import { isEncodedRemotable, makeCopySet, makeCopyMap, + mustCompress, + decompress, } from '@agoric/store'; import { Far, passStyleOf } from '@endo/marshal'; import { decodeToJustin } from '@endo/marshal/src/marshal-justin.js'; @@ -213,7 +216,7 @@ export function makeCollectionManager( return storeKindInfo[kindName].kindID; } - // Not that it's only used for this purpose, what should it be called? + // Now that it's only used for this purpose, what should it be called? // TODO Should we be using the new encodeBigInt scheme instead, anyway? const BIGINT_TAG_LEN = 10; @@ -257,6 +260,23 @@ export function makeCollectionManager( const dbKeyPrefix = `vc.${collectionID}.`; let currentGenerationNumber = 0; + const keyLabel = `invalid key type for collection ${q(label)}`; + const valueLabel = `invalid value type for collection ${q(label)}`; + + const serializeValue = value => { + if (valueShape === undefined) { + return serialize(value); + } + return serialize(mustCompress(value, valueShape, valueLabel)); + }; + + const unserializeValue = data => { + if (valueShape === undefined) { + return unserialize(data); + } + return decompress(unserialize(data), valueShape); + }; + function prefix(dbEntryKey) { return `${dbKeyPrefix}${dbEntryKey}`; } @@ -331,11 +351,10 @@ export function makeCollectionManager( } function get(key) { - matches(key, keyShape) || - assert.fail(X`invalid key type for collection ${q(label)}`); + fit(key, keyShape, keyLabel); const result = syscall.vatstoreGet(keyToDBKey(key)); if (result) { - return unserialize(JSON.parse(result)); + return unserializeValue(JSON.parse(result)); } assert.fail(X`key ${key} not found in collection ${q(label)}`); } @@ -351,16 +370,11 @@ export function makeCollectionManager( } function init(key, value) { - matches(key, keyShape) || - assert.fail(X`invalid key type for collection ${q(label)}`); + fit(key, keyShape, keyLabel); !has(key) || assert.fail(X`key ${key} already registered in collection ${q(label)}`); - if (valueShape) { - matches(value, valueShape) || - assert.fail(X`invalid value type for collection ${q(label)}`); - } + const serializedValue = serializeValue(value); currentGenerationNumber += 1; - const serializedValue = serialize(value); assertAcceptableSyscallCapdataSize([serializedValue]); if (durable) { serializedValue.slots.forEach((vref, slotIndex) => { @@ -388,13 +402,8 @@ export function makeCollectionManager( } function set(key, value) { - matches(key, keyShape) || - assert.fail(X`invalid key type for collection ${q(label)}`); - if (valueShape) { - matches(value, valueShape) || - assert.fail(X`invalid value type for collection ${q(label)}`); - } - const after = serialize(harden(value)); + fit(key, keyShape, keyLabel); + const after = serializeValue(harden(value)); assertAcceptableSyscallCapdataSize([after]); if (durable) { after.slots.forEach((vref, i) => { @@ -412,8 +421,7 @@ export function makeCollectionManager( } function deleteInternal(key) { - matches(key, keyShape) || - assert.fail(X`invalid key type for collection ${q(label)}`); + fit(key, keyShape, keyLabel); const dbKey = keyToDBKey(key); const rawValue = syscall.vatstoreGet(dbKey); assert(rawValue, X`key ${key} not found in collection ${q(label)}`); @@ -472,7 +480,7 @@ export function makeCollectionManager( if (dbKey < end) { priorDBKey = dbKey; if (ignoreKeys) { - const value = unserialize(JSON.parse(dbValue)); + const value = unserializeValue(JSON.parse(dbValue)); if (matches(value, valuePatt)) { yield [undefined, value]; } @@ -484,7 +492,7 @@ export function makeCollectionManager( } else { const key = dbKeyToKey(dbKey); if (matches(key, keyPatt)) { - const value = unserialize(JSON.parse(dbValue)); + const value = unserializeValue(JSON.parse(dbValue)); if (matches(value, valuePatt)) { yield [key, value]; } diff --git a/packages/SwingSet/src/liveslots/virtualObjectManager.js b/packages/SwingSet/src/liveslots/virtualObjectManager.js index 9bf0c75896c6..27089d27a7db 100644 --- a/packages/SwingSet/src/liveslots/virtualObjectManager.js +++ b/packages/SwingSet/src/liveslots/virtualObjectManager.js @@ -1,13 +1,20 @@ // @ts-check -/* eslint-disable no-use-before-define, jsdoc/require-returns-type */ +/* eslint-disable no-use-before-define */ -import { assert, details as X, q } from '@agoric/assert'; -import { defendPrototype } from '@agoric/store'; -import { Far } from '@endo/marshal'; +import { + assertPattern, + decompress, + defendPrototype, + mustCompress, +} from '@agoric/store'; +import { Far, hasOwnPropertyOf, passStyleOf } from '@endo/marshal'; import { parseVatSlot } from '../lib/parseVatSlots.js'; /** @template T @typedef {import('@agoric/vat-data').DefineKindOptions} DefineKindOptions */ +const { ownKeys } = Reflect; +const { details: X, quote: q } = assert; + // import { kdebug } from './kdebug.js'; // Marker associated to flag objects that should be held onto strongly if @@ -28,7 +35,7 @@ const unweakable = new WeakSet(); * @param {(baseRef: string, rawState: object) => void} store Function to * store raw object state by its baseRef * - * @returns An LRU cache of (up to) the given size + * @returns {object} An LRU cache of (up to) the given size * * This cache is part of the virtual object manager and is not intended to be * used independently; it is exported only for the benefit of test code. @@ -146,24 +153,28 @@ export function makeCache(size, fetch, store) { /** * Create a new virtual object manager. There is one of these for each vat. * - * @param {*} syscall Vat's syscall object, used to access the vatstore operations. - * @param {*} vrm Virtual reference manager, to handle reference counting and GC - * of virtual references. - * @param {() => number} allocateExportID Function to allocate the next object - * export ID for the enclosing vat. - * @param {(val: object) => string} _getSlotForVal A function that returns the - * object ID (vref) for a given object, if any. their corresponding export - * IDs - * @param {*} registerValue Function to register a new slot+value in liveSlot's - * various tables - * @param {import('@endo/marshal').Serialize} serialize Serializer for this vat - * @param {import('@endo/marshal').Unserialize} unserialize Unserializer for this vat - * @param {number} cacheSize How many virtual objects this manager should cache - * in memory. - * @param {*} assertAcceptableSyscallCapdataSize Function to check for oversized - * syscall params + * @param {*} syscall + * Vat's syscall object, used to access the vatstore operations. + * @param {*} vrm + * Virtual reference manager, to handle reference counting and GC + * of virtual references. + * @param {() => number} allocateExportID + * Function to allocate the next object export ID for the enclosing vat. + * @param {(val: object) => string} _getSlotForVal + * A function that returns the object ID (vref) for a given object, if any. + * their corresponding export IDs + * @param {*} registerValue + * Function to register a new slot+value in liveSlot's various tables + * @param {import('@endo/marshal').Serialize} serialize + * Serializer for this vat + * @param {import('@endo/marshal').Unserialize} unserialize + * Unserializer for this vat + * @param {number} cacheSize + * How many virtual objects this manager should cache in memory. + * @param {*} assertAcceptableSyscallCapdataSize + * Function to check for oversized syscall params * - * @returns a new virtual object manager. + * @returns {object} a new virtual object manager. * * The virtual object manager allows the creation of persistent objects that do * not need to occupy memory when they are not in use. It provides five @@ -585,6 +596,7 @@ export function makeVirtualObjectManager( ) { const { finish, + stateShape = undefined, thisfulMethods = false, interfaceGuard = undefined, } = options; @@ -592,6 +604,38 @@ export function makeVirtualObjectManager( let contextMapTemplate; let prototypeTemplate; + harden(stateShape); + stateShape === undefined || + passStyleOf(stateShape) === 'copyRecord' || + assert.fail(X`A stateShape must be a copyRecord: ${q(stateShape)}`); + assertPattern(stateShape); + + const serializeSlot = (slotState, prop) => { + if (stateShape === undefined) { + return serialize(slotState); + } + hasOwnPropertyOf(stateShape, prop) || + assert.fail( + X`State must only have fields described by stateShape: ${q( + ownKeys(stateShape), + )}`, + ); + return serialize(mustCompress(slotState, stateShape[prop], prop)); + }; + + const unserializeSlot = (slotData, prop) => { + if (stateShape === undefined) { + return unserialize(slotData); + } + hasOwnPropertyOf(stateShape, prop) || + assert.fail( + X`State only has fields described by stateShape: ${q( + ownKeys(stateShape), + )}`, + ); + return decompress(unserialize(slotData), stateShape[prop]); + }; + const facetiousness = assessFacetiousness(behavior); switch (facetiousness) { case 'one': { @@ -695,12 +739,12 @@ export function makeVirtualObjectManager( Object.defineProperty(state, prop, { get: () => { ensureState(); - return unserialize(innerSelf.rawState[prop]); + return unserializeSlot(innerSelf.rawState[prop], prop); }, set: value => { ensureState(); const before = innerSelf.rawState[prop]; - const after = serialize(value); + const after = serializeSlot(value, prop); assertAcceptableSyscallCapdataSize([after]); if (isDurable) { after.slots.forEach((vref, index) => { @@ -793,11 +837,12 @@ export function makeVirtualObjectManager( const initialData = init ? init(...args) : {}; const rawState = {}; for (const prop of Object.getOwnPropertyNames(initialData)) { - const data = serialize(initialData[prop]); + const data = serializeSlot(initialData[prop], prop); assertAcceptableSyscallCapdataSize([data]); if (isDurable) { data.slots.forEach(vref => { - assert(vrm.isDurable(vref), X`value for ${q(prop)} is not durable`); + vrm.isDurable(vref) || + assert.fail(X`value for ${q(prop)} is not durable`); }); } data.slots.forEach(vrm.addReachableVref); diff --git a/packages/SwingSet/test/stores/test-collections.js b/packages/SwingSet/test/stores/test-collections.js index 0750dc99a3bb..c70604412404 100644 --- a/packages/SwingSet/test/stores/test-collections.js +++ b/packages/SwingSet/test/stores/test-collections.js @@ -174,7 +174,9 @@ test('constrain map key shape', t => { t.is(stringsOnly.get('skey'), 'this should work'); t.throws( () => stringsOnly.init(29, 'this should not work'), - m('invalid key type for collection "map key strings only"'), + m( + 'invalid key type for collection "map key strings only": number 29 - Must be a string', + ), ); const noStrings = makeScalarBigMapStore('map key no strings', { @@ -184,27 +186,31 @@ test('constrain map key shape', t => { noStrings.init(true, 'boolean ok'); t.throws( () => noStrings.init('foo', 'string not ok?'), - m('invalid key type for collection "map key no strings"'), + m( + 'invalid key type for collection "map key no strings": "foo" - Must fail negated pattern: "[match:string]"', + ), ); t.is(noStrings.get(47), 'number ok'); t.is(noStrings.get(true), 'boolean ok'); t.falsy(noStrings.has('foo')); t.throws( () => noStrings.get('foo'), - m('invalid key type for collection "map key no strings"'), + m( + 'invalid key type for collection "map key no strings": "foo" - Must fail negated pattern: "[match:string]"', + ), ); const only47 = makeScalarBigMapStore('map key only 47', { keyShape: 47 }); only47.init(47, 'this number ok'); t.throws( () => only47.init(29, 'this number not ok?'), - m('invalid key type for collection "map key only 47"'), + m('invalid key type for collection "map key only 47": 29 - Must be: 47'), ); t.is(only47.get(47), 'this number ok'); t.falsy(only47.has(29)); t.throws( () => only47.get(29), - m('invalid key type for collection "map key only 47"'), + m('invalid key type for collection "map key only 47": 29 - Must be: 47'), ); const lt47 = makeScalarBigMapStore('map key less than 47', { @@ -213,13 +219,17 @@ test('constrain map key shape', t => { lt47.init(29, 'this number ok'); t.throws( () => lt47.init(53, 'this number not ok?'), - m('invalid key type for collection "map key less than 47"'), + m( + 'invalid key type for collection "map key less than 47": 53 - Must be < 47', + ), ); t.is(lt47.get(29), 'this number ok'); t.falsy(lt47.has(53)); t.throws( () => lt47.get(53), - m('invalid key type for collection "map key less than 47"'), + m( + 'invalid key type for collection "map key less than 47": 53 - Must be < 47', + ), ); lt47.init(11, 'lower value'); lt47.init(46, 'higher value'); @@ -235,7 +245,9 @@ test('constrain map value shape', t => { t.is(stringsOnly.get('sval'), 'string value'); t.throws( () => stringsOnly.init('nval', 29), - m('invalid value type for collection "map value strings only"'), + m( + 'invalid value type for collection "map value strings only": number 29 - Must be a string', + ), ); const noStrings = makeScalarBigMapStore('map value no strings', { @@ -245,7 +257,9 @@ test('constrain map value shape', t => { noStrings.init('bkey', true); t.throws( () => noStrings.init('skey', 'string not ok?'), - m('invalid value type for collection "map value no strings"'), + m( + 'invalid value type for collection "map value no strings": "string not ok?" - Must fail negated pattern: "[match:string]"', + ), ); t.is(noStrings.get('nkey'), 47); t.is(noStrings.get('bkey'), true); @@ -257,7 +271,9 @@ test('constrain map value shape', t => { only47.init('47key', 47); t.throws( () => only47.init('29key', 29), - m('invalid value type for collection "map value only 47"'), + m( + 'invalid value type for collection "map value only 47": 29 - Must be: 47', + ), ); t.is(only47.get('47key'), 47); t.falsy(only47.has('29key')); @@ -268,7 +284,9 @@ test('constrain map value shape', t => { lt47.init('29key', 29); t.throws( () => lt47.init('53key', 53), - m('invalid value type for collection "map value less than 47"'), + m( + 'invalid value type for collection "map value less than 47": 53 - Must be < 47', + ), ); t.is(lt47.get('29key'), 29); t.falsy(lt47.has('53key')); @@ -288,7 +306,9 @@ test('constrain set key shape', t => { t.truthy(stringsOnly.has('skey')); t.throws( () => stringsOnly.add(29), - m('invalid key type for collection "strings only set"'), + m( + 'invalid key type for collection "strings only set": number 29 - Must be a string', + ), ); const noStrings = makeScalarBigSetStore('no strings set', { @@ -298,7 +318,9 @@ test('constrain set key shape', t => { noStrings.add(true); t.throws( () => noStrings.add('foo?'), - m('invalid key type for collection "no strings set"'), + m( + 'invalid key type for collection "no strings set": "foo?" - Must fail negated pattern: "[match:string]"', + ), ); t.truthy(noStrings.has(47)); t.truthy(noStrings.has(true)); @@ -311,7 +333,7 @@ test('constrain set key shape', t => { t.falsy(only47.has(29)); t.throws( () => only47.add(29), - m('invalid key type for collection "only 47 set"'), + m('invalid key type for collection "only 47 set": 29 - Must be: 47'), ); const lt47 = makeScalarBigSetStore('less than 47 set', { @@ -320,7 +342,7 @@ test('constrain set key shape', t => { lt47.add(29); t.throws( () => lt47.add(53), - m('invalid key type for collection "less than 47 set"'), + m('invalid key type for collection "less than 47 set": 53 - Must be < 47'), ); t.truthy(lt47.has(29)); t.falsy(lt47.has(53)); diff --git a/packages/governance/test/unitTests/test-paramGovernance.js b/packages/governance/test/unitTests/test-paramGovernance.js index 38744faaa52d..2c2f6b3c5341 100644 --- a/packages/governance/test/unitTests/test-paramGovernance.js +++ b/packages/governance/test/unitTests/test-paramGovernance.js @@ -220,7 +220,7 @@ test('multiple params bad change', async t => { ), { message: - 'In "getAmountOf" method of (Zoe Invitation issuer): args: [0]: bigint "[13n]" - Must be a remotable (Payment)', + 'In "getAmountOf" method of (Zoe Invitation issuer): arg 0: bigint "[13n]" - Must be a remotable (Payment)', }, ); }); diff --git a/packages/store/src/index.js b/packages/store/src/index.js index bf5b306f4bd3..ec9cd6f8223d 100755 --- a/packages/store/src/index.js +++ b/packages/store/src/index.js @@ -56,6 +56,8 @@ export { fit, } from './patterns/patternMatchers.js'; +export { compress, mustCompress, decompress } from './patterns/compress.js'; + export { defendPrototype, initEmpty, diff --git a/packages/store/src/patterns/compress.js b/packages/store/src/patterns/compress.js new file mode 100644 index 000000000000..75cff87c8dde --- /dev/null +++ b/packages/store/src/patterns/compress.js @@ -0,0 +1,250 @@ +// @ts-check +import { assertChecker, makeTagged, passStyleOf } from '@endo/marshal'; + +import { recordParts } from './rankOrder.js'; +import { + kindOf, + assertPattern, + maybeMatchHelper, + matches, + checkMatches, +} from './patternMatchers.js'; +import { isKey } from '../keys/checkKey.js'; +import { keyEQ } from '../keys/compareKeys.js'; + +const { fromEntries } = Object; +const { details: X, quote: q } = assert; + +/** + * When, for example, all the specimens in a given store match a + * specific pattern, then each of those specimens must contain the same + * literal superstructure as their one shared pattern. Therefore, storing + * that literal superstructure would be redumdant. If `specimen` does + * match `pattern`, then `compress(specimen, pattern)` will return a bindings + * array which is hopefully more compact than `specimen` as a whole, but + * carries all the information from specimen that cannot be derived just + * from knowledge that it matches this `pattern`. + * + * @type {Compress} + */ +export const compress = (specimen, pattern) => { + // Not yet frozen! Used to accumulate bindings + const bindings = []; + const emitBinding = binding => { + bindings.push(binding); + }; + harden(emitBinding); + + /** + * @param {Passable} innerSpecimen + * @param {Pattern} innerPattern + * @returns {boolean} + */ + const compressRecur = (innerSpecimen, innerPattern) => { + assertPattern(innerPattern); + if (isKey(innerPattern)) { + return keyEQ(innerSpecimen, innerPattern); + } + const patternKind = kindOf(innerPattern); + const specimenKind = kindOf(innerSpecimen); + switch (patternKind) { + case undefined: { + return false; + } + case 'copyArray': { + if ( + specimenKind !== 'copyArray' || + innerSpecimen.length !== innerPattern.length + ) { + return false; + } + return innerPattern.every((p, i) => compressRecur(innerSpecimen[i], p)); + } + case 'copyRecord': { + if (specimenKind !== 'copyRecord') { + return false; + } + const [specimenNames, specimenValues] = recordParts(innerSpecimen); + const [pattNames, pattValues] = recordParts(innerPattern); + if (specimenNames.length !== pattNames.length) { + return false; + } + return pattNames.every( + (name, i) => + specimenNames[i] === name && + compressRecur(specimenValues[i], pattValues[i]), + ); + } + case 'copyMap': { + if (specimenKind !== 'copyMap') { + return false; + } + const { + payload: { keys: pattKeys, values: valuePatts }, + } = innerPattern; + const { + payload: { keys: specimenKeys, values: specimenValues }, + } = innerSpecimen; + // TODO BUG: this assumes that the keys appear in the + // same order, so we can compare values in that order. + // However, we're only guaranteed that they appear in + // the same rankOrder. Thus we must search one of these + // in the other's rankOrder. + if (!keyEQ(specimenKeys, pattKeys)) { + return false; + } + return compressRecur(specimenValues, valuePatts); + } + default: + { + const matchHelper = maybeMatchHelper(patternKind); + if (matchHelper) { + if (matchHelper.compress) { + const subBindings = matchHelper.compress( + innerSpecimen, + innerPattern.payload, + compress, + ); + if (subBindings === undefined) { + return false; + } else { + // Note that we're not flattening the subBindings + // Note that as long as we allow this kind of nested compression, + // we cannot feasibly preserve sort order anyway. + emitBinding(subBindings); + return true; + } + } else if (matches(innerSpecimen, innerPattern)) { + emitBinding(innerSpecimen); + return true; + } else { + return false; + } + } + } + assert.fail(X`unrecognized kind: ${q(patternKind)}`); + } + }; + + if (compressRecur(specimen, pattern)) { + return harden(bindings); + } else { + return undefined; + } +}; +harden(compress); + +/** + * `mustCompress` is to `compress` approximately as `fit` is to `matches`. + * Where `compress` indicates pattern match failure by returning `undefined`, + * `mustCompress` indicates pattern match failure by throwing an error + * with a good pattern-match-failure diagnostic. Thus, like `fit`, + * `mustCompress` has an additional optional `label` parameter to be used on + * the outside of that diagnostic if needed. If `mustCompress` does return + * normally, then the pattern match succeeded and `mustCompress` returns a + * valid bindings array. + * + * @type {MustCompress} + */ +export const mustCompress = (specimen, pattern, label = undefined) => { + const bindings = compress(specimen, pattern); + if (bindings !== undefined) { + return bindings; + } + // should only throw + checkMatches(specimen, pattern, assertChecker, label); + assert.fail(X`internal: ${label}: inconsistent pattern match: ${q(pattern)}`); +}; +harden(mustCompress); + +/** + * `decompress` reverses the compression performed by `compress` + * or `mustCompress`, in order to recover the equivalent + * of the original specimen from the `bindings` array and the `pattern`. + * + * @type {Decompress} + */ +export const decompress = (bindings, pattern) => { + passStyleOf(bindings) === 'copyArray' || + assert.fail(X`Pattern ${pattern} expected bindings array: ${bindings}`); + let i = 0; + const takeBinding = () => { + i < bindings.length || + assert.fail( + X`Pattern ${q(pattern)} expects more than ${q( + bindings.length, + )} bindings: ${bindings}`, + ); + const binding = bindings[i]; + i += 1; + return binding; + }; + harden(takeBinding); + + const decompressRecur = innerPattern => { + assertPattern(innerPattern); + if (isKey(innerPattern)) { + return innerPattern; + } + const patternKind = kindOf(innerPattern); + switch (patternKind) { + case undefined: { + assert.fail(X`decompress expected a pattern: ${q(innerPattern)}`); + } + case 'copyArray': { + return harden(innerPattern.map(p => decompressRecur(p))); + } + case 'copyRecord': { + const [pattNames, pattValues] = recordParts(innerPattern); + const entries = pattNames.map((name, j) => [ + name, + decompressRecur(pattValues[j]), + ]); + // Reverse so printed form looks less surprising, + // with ascenting rather than descending property names. + return harden(fromEntries(entries.reverse())); + } + case 'copyMap': { + const { + payload: { keys: pattKeys, values: valuePatts }, + } = innerPattern; + return makeTagged( + 'copyMap', + harden({ + keys: pattKeys, + values: valuePatts.map(p => decompressRecur(p)), + }), + ); + } + default: + { + const matchHelper = maybeMatchHelper(patternKind); + if (matchHelper) { + if (matchHelper.decompress) { + const subBindings = takeBinding(); + passStyleOf(subBindings) === 'copyArray' || + assert.fail( + X`Pattern ${q( + innerPattern, + )} expected nested bindings array: ${subBindings}`, + ); + + return matchHelper.decompress( + subBindings, + innerPattern.payload, + decompress, + ); + } else { + return takeBinding(); + } + } + } + assert.fail( + X`unrecognized pattern kind: ${q(patternKind)} ${q(innerPattern)}`, + ); + } + }; + + return decompressRecur(pattern); +}; +harden(decompress); diff --git a/packages/store/src/patterns/interface-tools.js b/packages/store/src/patterns/interface-tools.js index f6ffc8fa688c..83b276d1b563 100644 --- a/packages/store/src/patterns/interface-tools.js +++ b/packages/store/src/patterns/interface-tools.js @@ -250,6 +250,27 @@ export const initEmpty = () => emptyRecord; * @property {T} self */ +/** + * @template [S = any] + * @template [F = any] + * @typedef {object} KitContext + * @property {S} state + * @property {F} facets + */ + +/** + * @typedef {{[name: string]: Pattern}} StateShape + * It looks like a copyRecord pattern, but the interpretation is different. + * Each property is distinct, is checked and changed separately. + */ + +/** + * @template C + * @typedef {object} FarClassOptions + * @property {(context: C) => void} [finish] + * @property {StateShape} [stateShape] + */ + /** * @template A * @template S @@ -258,7 +279,7 @@ export const initEmpty = () => emptyRecord; * @param {any} interfaceGuard * @param {(...args: A[]) => S} init * @param {T} methods - * @param {object} [options] + * @param {FarClassOptions>} [options] * @returns {(...args: A[]) => (T & import('@endo/eventual-send').RemotableBrand<{}, T>)} */ export const defineHeapFarClass = ( @@ -266,7 +287,7 @@ export const defineHeapFarClass = ( interfaceGuard, init, methods, - options = undefined, + { finish = undefined } = {}, ) => { /** @type {WeakMap>} */ const contextMap = new WeakMap(); @@ -287,11 +308,8 @@ export const defineHeapFarClass = ( /** @type {Context} */ const context = freeze({ state, self }); contextMap.set(self, context); - if (options) { - const { finish = undefined } = options; - if (finish) { - finish(context); - } + if (finish) { + finish(context); } return self; }; @@ -308,7 +326,7 @@ harden(defineHeapFarClass); * @param {any} interfaceGuardKit * @param {(...args: A[]) => S} init * @param {F} methodsKit - * @param {object} [options] + * @param {FarClassOptions>} [options] * @returns {(...args: A[]) => F} */ export const defineHeapFarClassKit = ( @@ -316,7 +334,7 @@ export const defineHeapFarClassKit = ( interfaceGuardKit, init, methodsKit, - options = undefined, + { finish = undefined } = {}, ) => { const facetNames = ownKeys(methodsKit); const interfaceNames = ownKeys(interfaceGuardKit); @@ -356,11 +374,9 @@ export const defineHeapFarClassKit = ( context.facets = facets; // Be careful not to freeze the state record freeze(context); - if (options) { - const { finish = undefined } = options; - if (finish) { - finish(context); - } + if (finish) { + // @ts-expect-error `facets` was added + finish(context); } return facets; }; @@ -375,7 +391,7 @@ harden(defineHeapFarClassKit); * @param {string} tag * @param {InterfaceGuard | undefined} interfaceGuard CAVEAT: static typing does not yet support `callWhen` transformation * @param {T} methods - * @param {object} [options] + * @param {FarClassOptions>} [options] * @returns {T & import('@endo/eventual-send').RemotableBrand<{}, T>} */ export const makeHeapFarInstance = ( diff --git a/packages/store/src/patterns/patternMatchers.js b/packages/store/src/patterns/patternMatchers.js index 99fb6fcf7114..fd8302f707ec 100644 --- a/packages/store/src/patterns/patternMatchers.js +++ b/packages/store/src/patterns/patternMatchers.js @@ -35,6 +35,8 @@ import { checkCopyMap, copyMapKeySet, checkCopyBag, + makeCopySet, + makeCopyBag, } from '../keys/checkKey.js'; /// @@ -141,6 +143,15 @@ const checkDecimalDigitsLimit = (specimen, decimalDigitsLimit, check) => { ); }; +/** + * @typedef {string} Kind + * It is either a PassStyle other than 'tagged', or, if the underlying + * PassStyle is 'tagged', then the `getTag` value for tags that are + * recognized at the store level of abstraction. For each of those + * tags, a tagged record only has that kind if it satisfies the invariants + * that the store level associates with that kind. + */ + /** * @returns {PatternKit} */ @@ -156,15 +167,6 @@ const makePatternKit = () => { // eslint-disable-next-line no-use-before-define HelpersByMatchTag[tag]; - /** - * @typedef {string} Kind - * It is either a PassStyle other than 'tagged', or, if the underlying - * PassStyle is 'tagged', then the `getTag` value for tags that are - * recognized at the store level of abstraction. For each of those - * tags, a tagged record only has that kind if it satisfies the invariants - * that the store level associates with that kind. - */ - /** * @type {WeakMap} * Only for tagged records of recognized kinds whose store-level invariants @@ -261,6 +263,16 @@ const makePatternKit = () => { return false; }; + /** + * Checks only recognized kinds, and only if the specimen + * passes the invariants associated with that recognition. + * + * @param {Passable} specimen + * @param {Kind} kind + * @returns {boolean} + */ + const isKind = (specimen, kind) => checkKind(specimen, kind, identChecker); + /** * @param {Passable} specimen * @param {Key} keyAsPattern @@ -559,6 +571,11 @@ const makePatternKit = () => { const pattValues = pattPayload.values; const specimenValues = specimenPayload.values; // compare values as copyArrays + // TODO BUG: this assumes that the keys appear in the + // same order, so we can compare values in that order. + // However, we're only guaranteed that they appear in + // the same rankOrder. Thus we must search one of these + // in the other's rankOrder. return checkMatches(specimenValues, pattValues, check); } default: { @@ -708,8 +725,15 @@ const makePatternKit = () => { return getPassStyleCover(passStyle); }; + /** + * @param {Passable[]} array + * @param {Pattern} patt + * @param {Checker} check + * @param {string} [labelPrefix] + * @returns {boolean} + */ const arrayEveryMatchPattern = (array, patt, check, labelPrefix = '') => { - if (checkKind(patt, 'match:any', identChecker)) { + if (isKind(patt, 'match:any')) { // if the pattern is M.any(), we know its true return true; } @@ -718,6 +742,42 @@ const makePatternKit = () => { ); }; + /** + * @param { Passable[] } array + * @param { Pattern } patt + * @param {Compress} compress + * @returns {Passable[] | undefined} + */ + const arrayCompressMatchPattern = (array, patt, compress) => { + if (isKind(patt, 'match:any')) { + return array; + } + const bindings = []; + for (const el of array) { + const subBindings = compress(el, patt); + if (subBindings) { + // Note: not flattened + bindings.push(subBindings); + } else { + return undefined; + } + } + return harden(bindings); + }; + + /** + * @param {Passable[]} bindings + * @param {Pattern} patt + * @param {Decompress} decompress + * @returns {Passable[]} + */ + const arrayDecompressMatchPattern = (bindings, patt, decompress) => { + if (isKind(patt, 'match:any')) { + return bindings; + } + return harden(bindings.map(subBindings => decompress(subBindings, patt))); + }; + // /////////////////////// Match Helpers ///////////////////////////////////// /** @type {MatchHelper} */ @@ -739,11 +799,32 @@ const makePatternKit = () => { return patts.every(patt => checkMatches(specimen, patt, check)); }, + // Compress only according to the last conjunct + compress: (specimen, patts, compress) => { + const { length } = patts; + // We know there are at least two patts + const lastPatt = patts[length - 1]; + const allButLast = patts.slice(0, length - 1); + if ( + !allButLast.every(patt => checkMatches(specimen, patt, identChecker)) + ) { + return undefined; + } + return compress(specimen, lastPatt); + }, + + decompress: (bindings, patts, decompress) => { + const lastPatt = patts[patts.length - 1]; + return decompress(bindings, lastPatt); + }, + checkIsWellFormed: (allegedPatts, check) => { const checkIt = patt => checkPattern(patt, check); return ( (passStyleOf(allegedPatts) === 'copyArray' || check(false, X`Needs array of sub-patterns: ${allegedPatts}`)) && + (allegedPatts.length >= 2 || + check(false, X`Must have at least two sub-patterns`)) && allegedPatts.every(checkIt) ); }, @@ -762,13 +843,6 @@ const makePatternKit = () => { /** @type {MatchHelper} */ const matchOrHelper = Far('match:or helper', { checkMatches: (specimen, patts, check) => { - const { length } = patts; - if (length === 0) { - return check( - false, - X`${specimen} - no pattern disjuncts to match: ${patts}`, - ); - } if ( patts.length === 2 && !matches(specimen, patts[0]) && @@ -785,6 +859,26 @@ const makePatternKit = () => { return check(false, X`${specimen} - Must match one of ${patts}`); }, + // Compress to a bindings array that starts with the index of the + // first disjunct that succeeded, followed by the bindings according to + // that disjunct. + compress: (specimen, patts, compress) => { + const { length } = patts; + if (length === 0) { + return undefined; + } + for (let i = 0; i < length; i += 1) { + const subBindings = compress(specimen, patts[i]); + if (subBindings !== undefined) { + return harden([i, ...subBindings]); + } + } + return undefined; + }, + + decompress: ([i, ...subBindings], patts, decompress) => + decompress(harden(subBindings), patts[i]), + checkIsWellFormed: matchAndHelper.checkIsWellFormed, getRankCover: (patts, encodePassable) => @@ -1016,7 +1110,7 @@ const makePatternKit = () => { const matchRemotableHelper = Far('match:remotable helper', { checkMatches: (specimen, remotableDesc, check) => { // Unfortunate duplication of checkKind logic, but no better choices. - if (checkKind(specimen, 'remotable', identChecker)) { + if (isKind(specimen, 'remotable')) { return true; } if (check === identChecker) { @@ -1195,6 +1289,21 @@ const makePatternKit = () => { ); }, + // Compress to an array of corresponding bindings arrays + compress: (specimen, [subPatt, limits = undefined], compress) => { + const { arrayLengthLimit } = limit(limits); + if ( + isKind(specimen, 'copyArray') && + specimen.length <= arrayLengthLimit + ) { + return arrayCompressMatchPattern(specimen, subPatt, compress); + } + return undefined; + }, + + decompress: (bindings, [subPatt, _limits = undefined], decompress) => + arrayDecompressMatchPattern(bindings, subPatt, decompress), + checkIsWellFormed: (payload, check) => checkIsWellFormedWithLimit( payload, @@ -1225,6 +1334,21 @@ const makePatternKit = () => { ); }, + // Compress to an array of corresponding bindings arrays + compress: (specimen, [keyPatt, limits = undefined], compress) => { + const { numSetElementsLimit } = limit(limits); + if ( + isKind(specimen, 'copySet') && + specimen.payload.length <= numSetElementsLimit + ) { + return arrayCompressMatchPattern(specimen.payload, keyPatt, compress); + } + return undefined; + }, + + decompress: (bindings, [keyPatt, _limits = undefined], decompress) => + makeCopySet(arrayDecompressMatchPattern(bindings, keyPatt, decompress)), + checkIsWellFormed: (payload, check) => checkIsWellFormedWithLimit( payload, @@ -1268,6 +1392,42 @@ const makePatternKit = () => { ); }, + // Compress to an array of corresponding bindings arrays + compress: ( + specimen, + [keyPatt, countPatt, limits = undefined], + compress, + ) => { + const { numUniqueBagElementsLimit, decimalDigitsLimit } = limit(limits); + if ( + isKind(specimen, 'copyBag') && + specimen.payload.length <= numUniqueBagElementsLimit && + specimen.payload.every(([_key, count]) => + checkDecimalDigitsLimit(count, decimalDigitsLimit, identChecker), + ) + ) { + return arrayCompressMatchPattern( + specimen.payload, + harden([keyPatt, countPatt]), + compress, + ); + } + return undefined; + }, + + decompress: ( + bindings, + [keyPatt, countPatt, _limits = undefined], + decompress, + ) => + makeCopyBag( + arrayDecompressMatchPattern( + bindings, + harden([keyPatt, countPatt]), + decompress, + ), + ), + checkIsWellFormed: (payload, check) => checkIsWellFormedWithLimit( payload, @@ -1313,6 +1473,48 @@ const makePatternKit = () => { ); }, + // Compress to a pair of bindings arrays, one for the keys + // and a matching one for the values. + compress: ( + specimen, + [keyPatt, valuePatt, limits = undefined], + compress, + ) => { + const { numMapEntriesLimit } = limit(limits); + if ( + isKind(specimen, 'copyMap') && + specimen.payload.keys.length <= numMapEntriesLimit + ) { + return harden([ + arrayCompressMatchPattern(specimen.payload.keys, keyPatt, compress), + arrayCompressMatchPattern( + specimen.payload.values, + valuePatt, + compress, + ), + ]); + } + return undefined; + }, + + decompress: ( + [keyBindings, valueBindings], + [keyPatt, valuePatt, _limits = undefined], + decompress, + ) => { + return makeTagged( + 'copyMap', + harden({ + keys: arrayDecompressMatchPattern(keyBindings, keyPatt, decompress), + values: arrayDecompressMatchPattern( + valueBindings, + valuePatt, + decompress, + ), + }), + ); + }, + checkIsWellFormed: (payload, check) => checkIsWellFormedWithLimit( payload, @@ -1377,10 +1579,24 @@ const makePatternKit = () => { optionalPatt, optionalSpecimen.length, ); + let argNum = 0; return ( - checkMatches(requiredSpecimen, requiredPatt, check, 'args') && - checkMatches(optionalSpecimen, partialPatt, check, 'optional args') && - checkMatches(restSpecimen, restPatt, check, 'rest args') + (requiredSpecimen.length === requiredPatt.length || + check( + false, + X`Expected at least ${q( + requiredPatt.length, + )} arguments: ${specimen}`, + )) && + requiredPatt.every((p, i) => + // eslint-disable-next-line no-plusplus + checkMatches(requiredSpecimen[i], p, check, `arg ${argNum++}`), + ) && + partialPatt.every((p, i) => + // eslint-disable-next-line no-plusplus + checkMatches(optionalSpecimen[i], p, check, `arg ${argNum++}?`), + ) && + checkMatches(restSpecimen, restPatt, check, '...rest') ); }, @@ -1526,8 +1742,15 @@ const makePatternKit = () => { const partialPatt = adaptRecordPattern(optionalPatt, partialNames); return ( checkMatches(requiredSpecimen, requiredPatt, check) && - checkMatches(optionalSpecimen, partialPatt, check, 'optional') && - checkMatches(restSpecimen, restPatt, check, 'rest') + partialNames.every(name => + checkMatches( + optionalSpecimen[name], + partialPatt[name], + check, + `${name}?`, + ), + ) && + checkMatches(restSpecimen, restPatt, check, '...rest') ); }, @@ -1536,7 +1759,7 @@ const makePatternKit = () => { [requiredPatt, optionalPatt = {}, restPatt = MM.any()], compress, ) => { - if (!checkKind(specimen, 'copyArray', identChecker)) { + if (!checkKind(specimen, 'copyRecord', identChecker)) { return undefined; } const { requiredSpecimen, optionalSpecimen, restSpecimen } = @@ -1747,8 +1970,20 @@ const makePatternKit = () => { /** @type {MatcherNamespace} */ const M = harden({ any: () => AnyShape, - and: (...patts) => makeMatcher('match:and', patts), - or: (...patts) => makeMatcher('match:or', patts), + and: (...patts) => + // eslint-disable-next-line no-nested-ternary + patts.length === 0 + ? M.any() + : patts.length === 1 + ? patts[0] + : makeMatcher('match:and', patts), + or: (...patts) => + // eslint-disable-next-line no-nested-ternary + patts.length === 0 + ? M.not(M.any()) + : patts.length === 1 + ? patts[0] + : makeMatcher('match:or', patts), not: subPatt => makeMatcher('match:not', subPatt), scalar: () => ScalarShape, @@ -1871,6 +2106,8 @@ const makePatternKit = () => { assertKeyPattern, isKeyPattern, getRankCover, + kindOf, + maybeMatchHelper, M, }); }; @@ -1890,6 +2127,8 @@ export const { assertKeyPattern, isKeyPattern, getRankCover, + kindOf, + maybeMatchHelper, M, } = makePatternKit(); diff --git a/packages/store/src/types.js b/packages/store/src/types.js index 216e48925ea9..a1fc26830e0f 100644 --- a/packages/store/src/types.js +++ b/packages/store/src/types.js @@ -8,6 +8,7 @@ /** @template T @typedef {import('@endo/marshal').CopyRecord} CopyRecord */ /** @template T @typedef {import('@endo/marshal').CopyArray} CopyArray */ /** @typedef {import('@endo/marshal').Checker} Checker */ +/** @typedef {import('./patterns/patternMatchers').Kind} Kind */ /** * @typedef {Passable} Key @@ -662,11 +663,35 @@ * @property {(patt: Pattern) => void} assertKeyPattern * @property {(patt: Passable) => boolean} isKeyPattern * @property {GetRankCover} getRankCover + * @property {(passable: Passable, check?: Checker) => (Kind | undefined)} kindOf + * @property {(tag: string) => (MatchHelper | undefined)} maybeMatchHelper * @property {MatcherNamespace} M */ // ///////////////////////////////////////////////////////////////////////////// +/** + * @callback Compress + * @param {Passable} specimen + * @param {Pattern} pattern + * @returns {Passable[] | undefined} + */ + +/** + * @callback MustCompress + * @param {Passable} specimen + * @param {Pattern} pattern + * @param {string|number} [label] + * @returns {Passable[]} + */ + +/** + * @callback Decompress + * @param {Passable[]} bindings + * @param {Pattern} pattern + * @returns {Passable} + */ + // TODO // The following type should be in internal-types.js, since the // `MatchHelper` type is purely internal to this package. However, @@ -693,6 +718,27 @@ * Assuming a valid Matcher of this type with `matcherPayload` as its * payload, does this specimen match that Matcher? * + * @property {(specimen: Passable, + * matcherPayload: Passable, + * compress: Compress + * ) => (Passable[] | undefined)} [compress] + * Assuming a valid Matcher of this type with `matcherPayload` as its + * payload, if this specimen matches this matcher, then return a + * "bindings" array of passables that represents this specimen, + * perhaps more compactly, given the knowledge that it matches this matcher. + * If the specimen does not match the matcher, return undefined. + * If this matcher has a `compress` method, then it must have a matching + * `decompress` method. + * + * @property {(bindings: Passable[], + * matcherPayload: Passable, + * decompress: Decompress + * ) => Passable} [decompress] + * If `bindings` is the result of a successful `compress` with this matcher, + * then `decompress` must return a Passable equivalent to the original specimen. + * If this matcher has an `decompress` method, then it must have a matching + * `compress` method. + * * @property {( * payload: Passable, * encodePassable: KeyToDBKey diff --git a/packages/store/test/test-compress.js b/packages/store/test/test-compress.js new file mode 100644 index 000000000000..20bef674ef6e --- /dev/null +++ b/packages/store/test/test-compress.js @@ -0,0 +1,166 @@ +// @ts-check + +import { test } from '@agoric/swingset-vat/tools/prepare-test-env-ava.js'; +import { Far } from '@endo/marshal'; +import { + makeCopyBagFromElements, + makeCopyMap, + makeCopySet, +} from '../src/keys/checkKey.js'; +import { + compress, + decompress, + mustCompress, +} from '../src/patterns/compress.js'; +import { M } from '../src/patterns/patternMatchers.js'; + +const runTests = testTriple => { + const brand = Far('simoleans', {}); + const moolaBrand = Far('moola', {}); + const timer = Far('timer', {}); + + testTriple({ brand, value: 37n }, { brand, value: M.bigint() }, [37n]); + testTriple( + { brand, value: 37n }, + { brand: M.remotable(), value: M.bigint() }, + [37n, brand], + ); + testTriple( + { brand, value: 37n }, + { brand: M.bigint(), value: M.bigint() }, + undefined, + 'test mustCompress: brand: remotable "[Alleged: simoleans]" - Must be a bigint', + ); + testTriple({ brand, value: 37n }, M.any(), [{ brand, value: 37n }]); + testTriple({ brand, value: 37n }, M.recordOf(M.string(), M.scalar()), [ + { brand, value: 37n }, + ]); + testTriple( + [{ foo: 'a' }, { foo: 'b' }, { foo: 'c' }], + M.arrayOf(harden({ foo: M.string() })), + [[['a'], ['b'], ['c']]], + ); + testTriple( + makeCopySet([{ foo: 'a' }, { foo: 'b' }, { foo: 'c' }]), + M.setOf(harden({ foo: M.string() })), + [[['c'], ['b'], ['a']]], + ); + testTriple( + makeCopyBagFromElements([{ foo: 'a' }, { foo: 'a' }, { foo: 'c' }]), + M.bagOf(harden({ foo: M.string() })), + [ + [ + ['c', 1n], + ['a', 2n], + ], + ], + ); + testTriple( + makeCopyMap([ + [{ foo: 'a' }, { bar: 1 }], + [{ foo: 'b' }, { bar: 2 }], + [{ foo: 'c' }, { bar: 3 }], + ]), + M.mapOf(harden({ foo: M.string() }), harden({ bar: M.number() })), + [ + [ + [['c'], ['b'], ['a']], + [[3], [2], [1]], + ], + ], + ); + testTriple( + makeCopyMap([ + [{ foo: 'c' }, { bar: 3 }], + [{ foo: 'b' }, { bar: 2 }], + [{ foo: 'a' }, { bar: 1 }], + ]), + // TODO Add a test case where the keys are in the same rankOrder but not + // the same order. + makeCopyMap([ + [{ foo: 'c' }, M.any()], + // @ts-expect-error The array need not be generic + [{ foo: 'b' }, { bar: M.number() }], + [{ foo: 'a' }, { bar: 1 }], + ]), + [{ bar: 3 }, 2], + ); + testTriple( + { + want: { Winnings: { brand: moolaBrand, value: ['x', 'y'] } }, + give: { Bid: { brand, value: 37n } }, + exit: { afterDeadline: { deadline: 11n, timer } }, + }, + { + want: { Winnings: { brand: moolaBrand, value: M.array() } }, + give: { Bid: { brand, value: M.nat() } }, + exit: { afterDeadline: { deadline: M.gte(10n), timer } }, + }, + [['x', 'y'], 37n, 11n], + ); + testTriple( + 'orange', + M.or('red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'), + [[1]], + ); + testTriple( + { x: 3, y: 5 }, + M.or(harden({ x: M.number(), y: M.number() }), M.bigint(), M.record()), + [[0, 5, 3]], + ); + testTriple( + [5n], + M.or(harden({ x: M.number(), y: M.number() }), [M.bigint()], M.record()), + [[1, 5n]], + ); + testTriple( + { x: 3, y: 5, z: 9 }, + M.or(harden({ x: M.number(), y: M.number() }), M.bigint(), M.record()), + [[2, { x: 3, y: 5, z: 9 }]], + ); + testTriple( + { + brand, + value: [{ bar: 2 }, { bar: 1 }], + }, + { + brand, + value: M.arrayOf(M.and(M.key(), { bar: M.number() })), + }, + [[[[2]], [[1]]]], + ); +}; + +test('compression', t => { + const testCompress = (specimen, pattern, bindings) => + t.deepEqual(compress(harden(specimen), harden(pattern)), harden(bindings)); + runTests(testCompress); +}); + +test('test mustCompress', t => { + const testCompress = (specimen, pattern, bindings, message) => { + if (bindings === undefined) { + t.throws( + () => + mustCompress(harden(specimen), harden(pattern), 'test mustCompress'), + { message }, + ); + } else { + t.deepEqual( + mustCompress(harden(specimen), harden(pattern), 'test mustCompress'), + harden(bindings), + ); + } + }; + runTests(testCompress); +}); + +test('decompression', t => { + const testDecompress = (specimen, pattern, bindings) => + bindings === undefined || + t.deepEqual( + decompress(harden(bindings), harden(pattern)), + harden(specimen), + ); + runTests(testDecompress); +}); diff --git a/packages/store/test/test-heap-classes.js b/packages/store/test/test-heap-classes.js index 4bffd29cc0de..c019843463cb 100644 --- a/packages/store/test/test-heap-classes.js +++ b/packages/store/test/test-heap-classes.js @@ -40,13 +40,12 @@ test('test defineHeapFarClass', t => { t.is(upCounter.incr(5), 8); t.is(upCounter.incr(1), 9); t.throws(() => upCounter.incr(-3), { - message: - 'In "incr" method of (UpCounter): optional args: [0]: -3 - Must be >= 0', + message: 'In "incr" method of (UpCounter): arg 0?: -3 - Must be >= 0', }); // @ts-expect-error bad arg t.throws(() => upCounter.incr('foo'), { message: - 'In "incr" method of (UpCounter): optional args: [0]: string "foo" - Must be a number', + 'In "incr" method of (UpCounter): arg 0?: string "foo" - Must be a number', }); }); @@ -79,13 +78,12 @@ test('test defineHeapFarClassKit', t => { t.is(downCounter.decr(), 7); t.is(upCounter.incr(3), 10); t.throws(() => upCounter.incr(-3), { - message: - 'In "incr" method of (Counter up): optional args: [0]: -3 - Must be >= 0', + message: 'In "incr" method of (Counter up): arg 0?: -3 - Must be >= 0', }); // @ts-expect-error the type violation is what we're testing t.throws(() => downCounter.decr('foo'), { message: - 'In "decr" method of (Counter down): optional args: [0]: string "foo" - Must be a number', + 'In "decr" method of (Counter down): arg 0?: string "foo" - Must be a number', }); // @ts-expect-error bad arg t.throws(() => upCounter.decr(3), { @@ -104,12 +102,11 @@ test('test makeHeapFarInstance', t => { t.is(upCounter.incr(5), 8); t.is(upCounter.incr(1), 9); t.throws(() => upCounter.incr(-3), { - message: - 'In "incr" method of (upCounter): optional args: [0]: -3 - Must be >= 0', + message: 'In "incr" method of (upCounter): arg 0?: -3 - Must be >= 0', }); t.throws(() => upCounter.incr('foo'), { message: - 'In "incr" method of (upCounter): optional args: [0]: string "foo" - Must be a number', + 'In "incr" method of (upCounter): arg 0?: string "foo" - Must be a number', }); }); diff --git a/packages/store/test/test-patterns.js b/packages/store/test/test-patterns.js index 43686b520a64..be4eff2cff18 100644 --- a/packages/store/test/test-patterns.js +++ b/packages/store/test/test-patterns.js @@ -41,7 +41,7 @@ const runTests = (successCase, failCase) => { failCase(specimen, M.gte('x'), '3 - Must be >= "x"'); failCase(specimen, M.and(3, 4), '3 - Must be: 4'); failCase(specimen, M.or(4, 4), '3 - Must match one of [4,4]'); - failCase(specimen, M.or(), '3 - no pattern disjuncts to match: []'); + failCase(specimen, M.or(), '3 - Must fail negated pattern: "[match:any]"'); } { const specimen = [3, 4]; @@ -84,13 +84,13 @@ const runTests = (successCase, failCase) => { failCase( specimen, M.split([3, 4, 5, 6]), - 'args: [3,4] - Must be: [3,4,5,6]', + 'Expected at least 4 arguments: [3,4]', ); - failCase(specimen, M.split([5]), 'args: [3] - Must be: [5]'); + failCase(specimen, M.split([5]), 'arg 0: 3 - Must be: 5'); failCase(specimen, M.split({}), 'copyArray [3,4] - Must be a copyRecord'); - failCase(specimen, M.split([3], 'x'), 'rest args: [4] - Must be: "x"'); + failCase(specimen, M.split([3], 'x'), '...rest: [4] - Must be: "x"'); - failCase(specimen, M.partial([5]), 'optional args: [0]: 3 - Must be: 5'); + failCase(specimen, M.partial([5]), 'arg 0?: 3 - Must be: 5'); failCase( specimen, @@ -199,7 +199,7 @@ const runTests = (successCase, failCase) => { failCase( specimen, M.splitRecord({ foo: M.number() }, { bar: M.string(), baz: M.number() }), - 'optional: bar: number 4 - Must be a string', + 'bar?: number 4 - Must be a string', ); failCase( @@ -208,7 +208,7 @@ const runTests = (successCase, failCase) => { { foo: M.number() }, M.and(M.partial({ bar: M.string() }), M.partial({ baz: M.number() })), ), - 'rest: optional: bar: number 4 - Must be a string', + '...rest: bar?: number 4 - Must be a string', ); failCase( @@ -224,17 +224,17 @@ const runTests = (successCase, failCase) => { failCase( specimen, M.split({ foo: 3 }, { foo: 3, bar: 4 }), - 'rest: {"bar":4} - Must be: {"foo":3,"bar":4}', + '...rest: {"bar":4} - Must be: {"foo":3,"bar":4}', ); failCase( specimen, M.split({ foo: 3 }, { foo: M.any(), bar: 4 }), - 'rest: {"bar":4} - Must have missing properties ["foo"]', + '...rest: {"bar":4} - Must have missing properties ["foo"]', ); failCase( specimen, M.partial({ foo: 7, zip: 5 }, { bar: 4 }), - 'optional: foo: 3 - Must be: 7', + 'foo?: 3 - Must be: 7', ); failCase( diff --git a/packages/vat-data/src/types.d.ts b/packages/vat-data/src/types.d.ts index ec9291de68c9..414b250c5d30 100644 --- a/packages/vat-data/src/types.d.ts +++ b/packages/vat-data/src/types.d.ts @@ -69,6 +69,12 @@ type DefineKindOptions = { */ durable?: boolean; + /** + * If provided, it describes the shape of all state records of instances + * of this kind. + */ + stateShape?: { [name: string]: Pattern }; + /** * Intended for internal use only. * Should the raw methods receive their `context` argument as their first diff --git a/packages/vat-data/src/vat-data-bindings.js b/packages/vat-data/src/vat-data-bindings.js index 85c16f941bf3..11c7d4fa7258 100644 --- a/packages/vat-data/src/vat-data-bindings.js +++ b/packages/vat-data/src/vat-data-bindings.js @@ -122,16 +122,20 @@ harden(partialAssign); */ export const provide = provideLazy; -export const provideDurableMapStore = (baggage, name) => - provide(baggage, name, () => makeScalarBigMapStore(name, { durable: true })); +export const provideDurableMapStore = (baggage, name, options = {}) => + provide(baggage, name, () => + makeScalarBigMapStore(name, { durable: true, ...options }), + ); harden(provideDurableMapStore); -export const provideDurableWeakMapStore = (baggage, name) => +export const provideDurableWeakMapStore = (baggage, name, options = {}) => provide(baggage, name, () => - makeScalarBigWeakMapStore(name, { durable: true }), + makeScalarBigWeakMapStore(name, { durable: true, ...options }), ); harden(provideDurableWeakMapStore); -export const provideDurableSetStore = (baggage, name) => - provide(baggage, name, () => makeScalarBigSetStore(name, { durable: true })); +export const provideDurableSetStore = (baggage, name, options = {}) => + provide(baggage, name, () => + makeScalarBigSetStore(name, { durable: true, ...options }), + ); harden(provideDurableSetStore); diff --git a/packages/vat-data/test/test-durable-classes.js b/packages/vat-data/test/test-durable-classes.js index fe734ab6d1bb..e080e3764431 100644 --- a/packages/vat-data/test/test-durable-classes.js +++ b/packages/vat-data/test/test-durable-classes.js @@ -42,13 +42,12 @@ test('test defineDurableFarClass', t => { t.is(upCounter.incr(5), 8); t.is(upCounter.incr(1), 9); t.throws(() => upCounter.incr(-3), { - message: - 'In "incr" method of (UpCounter): optional args: [0]: -3 - Must be >= 0', + message: 'In "incr" method of (UpCounter): arg 0?: -3 - Must be >= 0', }); // @ts-expect-error the type violation is what we're testing t.throws(() => upCounter.incr('foo'), { message: - 'In "incr" method of (UpCounter): optional args: [0]: string "foo" - Must be a number', + 'In "incr" method of (UpCounter): arg 0?: string "foo" - Must be a number', }); }); @@ -81,13 +80,12 @@ test('test defineDurableFarClassKit', t => { t.is(downCounter.decr(), 7); t.is(upCounter.incr(3), 10); t.throws(() => upCounter.incr(-3), { - message: - 'In "incr" method of (Counter up): optional args: [0]: -3 - Must be >= 0', + message: 'In "incr" method of (Counter up): arg 0?: -3 - Must be >= 0', }); // @ts-expect-error the type violation is what we're testing t.throws(() => downCounter.decr('foo'), { message: - 'In "decr" method of (Counter down): optional args: [0]: string "foo" - Must be a number', + 'In "decr" method of (Counter down): arg 0?: string "foo" - Must be a number', }); t.throws(() => upCounter.decr(3), { message: 'upCounter.decr is not a function', diff --git a/packages/vat-data/test/test-virtual-classes.js b/packages/vat-data/test/test-virtual-classes.js index 44c954d1129c..0089fe796990 100644 --- a/packages/vat-data/test/test-virtual-classes.js +++ b/packages/vat-data/test/test-virtual-classes.js @@ -39,13 +39,12 @@ test('test defineVirtualFarClass', t => { t.is(upCounter.incr(5), 8); t.is(upCounter.incr(1), 9); t.throws(() => upCounter.incr(-3), { - message: - 'In "incr" method of (UpCounter): optional args: [0]: -3 - Must be >= 0', + message: 'In "incr" method of (UpCounter): arg 0?: -3 - Must be >= 0', }); // @ts-expect-error the type violation is what we're testing t.throws(() => upCounter.incr('foo'), { message: - 'In "incr" method of (UpCounter): optional args: [0]: string "foo" - Must be a number', + 'In "incr" method of (UpCounter): arg 0?: string "foo" - Must be a number', }); }); @@ -76,13 +75,12 @@ test('test defineVirtualFarClassKit', t => { t.is(downCounter.decr(), 7); t.is(upCounter.incr(3), 10); t.throws(() => upCounter.incr(-3), { - message: - 'In "incr" method of (Counter up): optional args: [0]: -3 - Must be >= 0', + message: 'In "incr" method of (Counter up): arg 0?: -3 - Must be >= 0', }); // @ts-expect-error the type violation is what we're testing t.throws(() => downCounter.decr('foo'), { message: - 'In "decr" method of (Counter down): optional args: [0]: string "foo" - Must be a number', + 'In "decr" method of (Counter down): arg 0?: string "foo" - Must be a number', }); t.throws(() => upCounter.decr(3), { message: 'upCounter.decr is not a function', diff --git a/packages/vat-data/test/test-vivify.js b/packages/vat-data/test/test-vivify.js index 5ac4168ec115..07e33fa76b46 100644 --- a/packages/vat-data/test/test-vivify.js +++ b/packages/vat-data/test/test-vivify.js @@ -44,13 +44,12 @@ test('test vivifyFarClass', t => { t.is(upCounter.incr(5), 8); t.is(upCounter.incr(1), 9); t.throws(() => upCounter.incr(-3), { - message: - 'In "incr" method of (UpCounter): optional args: [0]: -3 - Must be >= 0', + message: 'In "incr" method of (UpCounter): arg 0?: -3 - Must be >= 0', }); // @ts-expect-error the type violation is what we're testing t.throws(() => upCounter.incr('foo'), { message: - 'In "incr" method of (UpCounter): optional args: [0]: string "foo" - Must be a number', + 'In "incr" method of (UpCounter): arg 0?: string "foo" - Must be a number', }); }); @@ -84,13 +83,12 @@ test('test vivifyFarClassKit', t => { t.is(downCounter.decr(), 7); t.is(upCounter.incr(3), 10); t.throws(() => upCounter.incr(-3), { - message: - 'In "incr" method of (Counter up): optional args: [0]: -3 - Must be >= 0', + message: 'In "incr" method of (Counter up): arg 0?: -3 - Must be >= 0', }); // @ts-expect-error the type violation is what we're testing t.throws(() => downCounter.decr('foo'), { message: - 'In "decr" method of (Counter down): optional args: [0]: string "foo" - Must be a number', + 'In "decr" method of (Counter down): arg 0?: string "foo" - Must be a number', }); t.throws(() => upCounter.decr(3), { message: 'upCounter.decr is not a function', @@ -110,12 +108,11 @@ test('test vivifyFarInstance', t => { t.is(upCounter.incr(5), 8); t.is(upCounter.incr(1), 9); t.throws(() => upCounter.incr(-3), { - message: - 'In "incr" method of (upCounter): optional args: [0]: -3 - Must be >= 0', + message: 'In "incr" method of (upCounter): arg 0?: -3 - Must be >= 0', }); t.throws(() => upCounter.incr('foo'), { message: - 'In "incr" method of (upCounter): optional args: [0]: string "foo" - Must be a number', + 'In "incr" method of (upCounter): arg 0?: string "foo" - Must be a number', }); }); diff --git a/packages/zoe/test/unitTests/test-cleanProposal.js b/packages/zoe/test/unitTests/test-cleanProposal.js index 843e1b679030..3daf18978a33 100644 --- a/packages/zoe/test/unitTests/test-cleanProposal.js +++ b/packages/zoe/test/unitTests/test-cleanProposal.js @@ -228,7 +228,7 @@ test('cleanProposal - other wrong stuff', t => { t, { exit: { onDemand: 'foo' } }, 'nat', - 'proposal: exit: optional: onDemand: "foo" - Must be: null', + 'proposal: exit: onDemand?: "foo" - Must be: null', ); proposeBad( t, @@ -240,43 +240,43 @@ test('cleanProposal - other wrong stuff', t => { t, { exit: { afterDeadline: { timer: 'foo', deadline: 3n } } }, 'nat', - 'proposal: exit: optional: afterDeadline: timer: "foo" - Must match one of ["[match:remotable]","[match:kind]"]', + 'proposal: exit: afterDeadline?: timer: "foo" - Must match one of ["[match:remotable]","[match:kind]"]', ); proposeBad( t, { exit: { afterDeadline: { timer, deadline: 'foo' } } }, 'nat', - 'proposal: exit: optional: afterDeadline: deadline: string "foo" - Must be a bigint', + 'proposal: exit: afterDeadline?: deadline: string "foo" - Must be a bigint', ); proposeBad( t, { exit: { afterDeadline: { timer, deadline: 3n, extra: 'foo' } } }, 'nat', - 'proposal: exit: optional: afterDeadline: {"timer":"[Alleged: ManualTimer]","deadline":"[3n]","extra":"foo"} - Must not have unexpected properties: ["extra"]', + 'proposal: exit: afterDeadline?: {"timer":"[Alleged: ManualTimer]","deadline":"[3n]","extra":"foo"} - Must not have unexpected properties: ["extra"]', ); proposeBad( t, { exit: { afterDeadline: { timer } } }, 'nat', - 'proposal: exit: optional: afterDeadline: {"timer":"[Alleged: ManualTimer]"} - Must have missing properties ["deadline"]', + 'proposal: exit: afterDeadline?: {"timer":"[Alleged: ManualTimer]"} - Must have missing properties ["deadline"]', ); proposeBad( t, { exit: { afterDeadline: { deadline: 3n } } }, 'nat', - 'proposal: exit: optional: afterDeadline: {"deadline":"[3n]"} - Must have missing properties ["timer"]', + 'proposal: exit: afterDeadline?: {"deadline":"[3n]"} - Must have missing properties ["timer"]', ); proposeBad( t, { exit: { afterDeadline: { timer, deadline: 3 } } }, 'nat', - 'proposal: exit: optional: afterDeadline: deadline: number 3 - Must be a bigint', + 'proposal: exit: afterDeadline?: deadline: number 3 - Must be a bigint', ); proposeBad( t, { exit: { afterDeadline: { timer, deadline: -3n } } }, 'nat', - 'proposal: exit: optional: afterDeadline: deadline: "[-3n]" - Must be non-negative', + 'proposal: exit: afterDeadline?: deadline: "[-3n]" - Must be non-negative', ); proposeBad(t, { exit: {} }, 'nat', /exit {} should only have one key/); proposeBad(