-
Notifications
You must be signed in to change notification settings - Fork 212
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(inter-cli): board client preserving identity from vstorage
- Loading branch information
Showing
2 changed files
with
284 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
// @ts-check | ||
import { Far } from '@endo/far'; | ||
import { makeMarshal } from '@endo/marshal'; | ||
import { assertCapData } from '@agoric/internal/src/lib-chainStorage.js'; | ||
import { BrandShape, DisplayInfoShape, IssuerShape } from '@agoric/ertp'; | ||
import { M, mustMatch } from '@endo/patterns'; | ||
import { deeplyFulfilledObject } from '@agoric/internal'; | ||
import { extractStreamCellValue } from './vstorage.js'; | ||
|
||
const { Fail } = assert; | ||
|
||
export const makeBoardContext = () => { | ||
/** @type {Map<string, {}>} */ | ||
const idToValue = new Map(); | ||
/** @type {Map<unknown, string>} */ | ||
const valueToId = new Map(); | ||
|
||
/** | ||
* Provide a remotable for each slot. | ||
* | ||
* @param {string} slot | ||
* @param {string} [iface] non-empty if present | ||
*/ | ||
const provide = (slot, iface) => { | ||
if (idToValue.has(slot)) { | ||
return idToValue.get(slot) || Fail`cannot happen`; // XXX check this statically? | ||
} | ||
if (!iface) throw Fail`1st occurrence must provide iface`; | ||
const json = { _: iface }; | ||
// XXX ok to leave iface alone? | ||
/** @type {{}} */ | ||
const value = Far(iface, { toJSON: () => json }); | ||
idToValue.set(slot, value); | ||
valueToId.set(value, slot); | ||
return value; | ||
}; | ||
|
||
/** Read-only board */ | ||
const board = { | ||
/** @param {unknown} value */ | ||
getId: value => { | ||
valueToId.has(value) || Fail`unknown value: ${value}`; | ||
return valueToId.get(value) || Fail`cannot happen`; // XXX check this statically? | ||
}, | ||
|
||
/** @param {string} id */ | ||
getValue: id => { | ||
assert.typeof(id, 'string'); | ||
idToValue.has(id) || Fail`unknown id: ${id}`; | ||
return idToValue.get(id) || Fail`cannot happen`; // XXX check this statically? | ||
}, | ||
}; | ||
|
||
const marshaller = makeMarshal(board.getId, provide, { | ||
serializeBodyFormat: 'smallcaps', | ||
}); | ||
|
||
return harden({ | ||
board, | ||
register: provide, | ||
marshaller, | ||
/** | ||
* Unmarshall capData, creating a Remotable for each boardID slot. | ||
* | ||
* @type {(cd: import("@endo/marshal").CapData<string>) => unknown } | ||
*/ | ||
ingest: marshaller.fromCapData, | ||
}); | ||
}; | ||
|
||
/** @param {QueryDataResponseT} queryDataResponse */ | ||
const extractCapData = queryDataResponse => { | ||
const str = extractStreamCellValue(queryDataResponse); | ||
const x = harden(JSON.parse(str)); | ||
assertCapData(x); | ||
return x; | ||
}; | ||
|
||
// XXX where is this originally defined? vat-bank? | ||
/** | ||
* @typedef {{ | ||
* brand: Brand, | ||
* denom: string, | ||
* displayInfo: DisplayInfo, | ||
* issuer: Issuer, | ||
* issuerName: string, | ||
* proposedName: string, | ||
* }} VBankAssetDetail | ||
*/ | ||
const AssetDetailShape = harden({ | ||
brand: BrandShape, | ||
denom: M.string(), | ||
displayInfo: DisplayInfoShape, | ||
issuer: IssuerShape, | ||
issuerName: M.string(), | ||
proposedName: M.string(), | ||
}); | ||
const InstanceShape = M.remotable('Instance'); | ||
const kindInfo = /** @type {const} */ ({ | ||
brand: { | ||
shape: BrandShape, | ||
coerce: x => /** @type {Brand} */ (x), | ||
}, | ||
instance: { | ||
shape: InstanceShape, | ||
coerce: x => /** @type {Instance} */ (x), | ||
}, | ||
vbankAsset: { | ||
shape: AssetDetailShape, | ||
coerce: x => /** @type {VBankAssetDetail} */ (x), | ||
}, | ||
}); | ||
|
||
/** | ||
* @param {ReturnType<typeof makeBoardContext>} boardCtx | ||
* @param {import('@agoric/cosmic-proto/vstorage/query.js').QueryClientImpl} queryService | ||
*/ | ||
const makeAgoricNames = async (boardCtx, queryService) => { | ||
/** | ||
* @template T | ||
* @param {keyof typeof kindInfo} kind | ||
* @param {(x: any) => T} _coerce | ||
*/ | ||
const getKind = async (kind, _coerce) => { | ||
const queryDataResponse = await queryService.Data({ | ||
path: `published.agoricNames.${kind}`, | ||
}); | ||
const capData = extractCapData(queryDataResponse); | ||
const xs = boardCtx.ingest(capData); | ||
mustMatch(xs, M.arrayOf([M.string(), kindInfo[kind].shape])); | ||
/** @type {[string, ReturnType<typeof _coerce>][]} */ | ||
// @ts-expect-error runtime checked | ||
const entries = xs; | ||
const record = harden(Object.fromEntries(entries)); | ||
return record; | ||
}; | ||
const agoricNames = await deeplyFulfilledObject( | ||
harden({ | ||
brand: getKind('brand', kindInfo.brand.coerce), | ||
instance: getKind('instance', kindInfo.instance.coerce), | ||
vbankAsset: getKind('vbankAsset', kindInfo.vbankAsset.coerce), | ||
}), | ||
); | ||
return agoricNames; | ||
}; | ||
|
||
/** | ||
* from @agoric/cosmic-proto/vstorage | ||
* | ||
* XXX import('@agoric/cosmic-proto/vstorage/query').QueryDataResponse doesn't worksomehow | ||
* | ||
* @typedef {Awaited<ReturnType<import('@agoric/cosmic-proto/vstorage/query.js').QueryClientImpl['Data']>>} QueryDataResponseT | ||
*/ | ||
|
||
/** | ||
* A boardClient unmarshals vstorage query responses preserving object identiy. | ||
* | ||
* @param {import('@agoric/cosmic-proto/vstorage/query.js').QueryClientImpl} queryService | ||
*/ | ||
export const makeBoardClient = queryService => { | ||
const boardCtx = makeBoardContext(); | ||
/** @type {Awaited<ReturnType<makeAgoricNames>>} */ | ||
let agoricNames; | ||
|
||
return harden({ | ||
queryService, | ||
provideAgoricNames: async () => { | ||
if (agoricNames) return agoricNames; | ||
agoricNames = await makeAgoricNames(boardCtx, queryService); | ||
return agoricNames; | ||
}, | ||
/** @type {(path: string) => Promise<unknown>} */ | ||
readLatestHead: path => | ||
queryService | ||
.Data({ path }) | ||
.then(response => boardCtx.ingest(extractCapData(response))), | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
// @ts-check | ||
import '@endo/init'; | ||
import { M, matches, keyEQ } from '@endo/patterns'; | ||
import { Far } from '@endo/far'; | ||
import { makeMarshal, makeTagged } from '@endo/marshal'; | ||
|
||
import test from 'ava'; | ||
import { fc } from '@fast-check/ava'; | ||
|
||
import { makeBoardContext } from '../src/lib/boardClient.js'; | ||
|
||
const arbBoardId = fc.integer().map(n => `board0${n}`); | ||
|
||
const arbSlot = arbBoardId.chain(slot => | ||
fc.string({ minLength: 1 }).map(iface => ({ slot, iface })), | ||
); | ||
|
||
const RemotableShape = M.remotable(); | ||
|
||
test('boardProxy.provide() preserves identity', t => { | ||
const bp = makeBoardContext(); | ||
fc.assert( | ||
fc.property( | ||
fc.record({ s1: arbSlot, s3: arbSlot, die: fc.nat(2) }), | ||
({ s1, s3, die }) => { | ||
const v1 = bp.register(s1.slot, s1.iface); | ||
const { slot, iface = undefined } = die > 0 ? s3 : { slot: s1.slot }; | ||
const v2 = bp.register(slot, iface); | ||
t.is(s1.slot === slot, v1 === v2); | ||
t.true(matches(v1, RemotableShape)); | ||
t.true(matches(v2, RemotableShape)); | ||
}, | ||
), | ||
); | ||
}); | ||
|
||
const arbPrim = fc.oneof( | ||
fc.constant(undefined), | ||
fc.constant(null), | ||
fc.boolean(), | ||
fc.float(), | ||
fc.bigInt(), | ||
fc.string(), | ||
fc.string().map(n => Symbol.for(n)), // TODO: well-known symbols | ||
); | ||
|
||
const arbRemotable = fc.string().map(iface => Far(iface)); | ||
// const arbPromise = fc.nat(16).map(_ => harden(new Promise(_resolve => {}))); | ||
// const arbCap = fc.oneof(arbRemotable, arbPromise); | ||
// const arbError = fc.string().map(msg => new Error(msg)); | ||
|
||
// const arbAtom = fc.oneof(arbPrim, arbCap, arbError); | ||
|
||
// const { passable: arbPassable } = fc.letrec(tie => ({ | ||
// passable: fc.oneof(arbAtom, tie('copyArray'), tie('copyRecord')), | ||
// copyArray: fc.array(tie('passable')).map(harden), | ||
// copyRecord: fc.dictionary(fc.string(), tie('passable')).map(harden), | ||
// tagged: fc | ||
// .record({ tag: fc.string(), payload: tie('passable') }) | ||
// .map(({ tag, payload }) => makeTagged(tag, payload)), | ||
// })); | ||
|
||
/** | ||
* "Keys are Passable arbitrarily-nested pass-by-copy containers | ||
* (CopyArray, CopyRecord, CopySet, CopyBag, CopyMap) in which every | ||
* non-container leaf is either a Passable primitive value or a Remotable" | ||
* | ||
* See import('@endo/patterns').Key | ||
*/ | ||
const { passable: arbKey } = fc.letrec(tie => ({ | ||
// NOTE: no published values are copySet, copyMap, or copyBag (yet?) | ||
passable: fc.oneof( | ||
arbPrim, | ||
arbRemotable, | ||
tie('copyArray'), | ||
tie('copyRecord'), | ||
), | ||
copyArray: fc.array(tie('passable')).map(harden), | ||
copyRecord: fc.dictionary(fc.string(), tie('passable')).map(harden), | ||
tagged: fc | ||
.record({ tag: fc.string(), payload: tie('passable') }) | ||
.map(({ tag, payload }) => makeTagged(tag, payload)), | ||
})); | ||
|
||
test('boardCtx ingest() preserves identity for passable keys', t => { | ||
const ctx = makeBoardContext(); | ||
const valToSlot = new Map(); | ||
const m = makeMarshal( | ||
v => { | ||
if (valToSlot.has(v)) return valToSlot.get(v); | ||
const slot = `board0${valToSlot.size}`; | ||
valToSlot.set(v, slot); | ||
return slot; | ||
}, | ||
undefined, | ||
{ serializeBodyFormat: 'smallcaps' }, | ||
); | ||
fc.assert( | ||
fc.property(arbKey, key => { | ||
const { body, slots } = m.toCapData(key); | ||
const ingested = ctx.ingest({ body, slots }); | ||
const reingested = ctx.ingest({ body, slots }); | ||
t.true(keyEQ(ingested, reingested)); | ||
}), | ||
); | ||
}); |