diff --git a/packages/cli/demo/cat.js b/packages/cli/demo/cat.js index 782582d137..56622135aa 100644 --- a/packages/cli/demo/cat.js +++ b/packages/cli/demo/cat.js @@ -233,7 +233,7 @@ const inventoryComponent = async ($parent, $end, powers) => { $parent.insertBefore($ul, $end); const $names = new Map(); - for await (const change of makeRefIterator(E(powers).followChanges())) { + for await (const change of makeRefIterator(E(powers).followNameChanges())) { if ('add' in change) { const name = change.add; diff --git a/packages/cli/src/commands/list.js b/packages/cli/src/commands/list.js index 67b9f1fa1d..24835bbeff 100644 --- a/packages/cli/src/commands/list.js +++ b/packages/cli/src/commands/list.js @@ -10,7 +10,7 @@ export const list = async ({ directoryPath, follow, json }) => agent = E(agent).lookup(...directoryPath.split('.')); } if (follow) { - const topic = await E(agent).followNames(); + const topic = await E(agent).followNameChanges(); const iterator = makeRefIterator(topic); if (json) { for await (const change of iterator) { diff --git a/packages/daemon/src/daemon.js b/packages/daemon/src/daemon.js index 79db33e10f..b8b2247e20 100644 --- a/packages/daemon/src/daemon.js +++ b/packages/daemon/src/daemon.js @@ -581,7 +581,7 @@ const makeDaemonCore = async ( has: disallowedFn, identify: disallowedFn, list: disallowedFn, - followChanges: disallowedFn, + followNameChanges: disallowedFn, lookup: disallowedFn, reverseLookup: disallowedFn, write: disallowedFn, diff --git a/packages/daemon/src/directory.js b/packages/daemon/src/directory.js index 521d062991..584eae059e 100644 --- a/packages/daemon/src/directory.js +++ b/packages/daemon/src/directory.js @@ -4,7 +4,7 @@ import { E } from '@endo/far'; import { makeExo } from '@endo/exo'; import { M } from '@endo/patterns'; import { makeIteratorRef } from './reader-ref.js'; -import { formatLocator } from './locator.js'; +import { formatLocator, idFromLocator } from './locator.js'; const { quote: q } = assert; @@ -97,6 +97,30 @@ export const makeDirectoryMaker = ({ return formatLocator(id, formulaType); }; + /** @type {import('./types.js').EndoDirectory['reverseLocate']} */ + const reverseLocate = async locator => { + const id = idFromLocator(locator); + return petStore.reverseIdentify(id); + }; + + /** @type {import('./types.js').EndoDirectory['followLocatorNameChanges']} */ + const followLocatorNameChanges = async function* followLocatorNameChanges( + locator, + ) { + const id = idFromLocator(locator); + for await (const idNameChange of petStore.followIdNameChanges(id)) { + /** @type {any} */ + const locatorNameChange = { + ...idNameChange, + ...(Object.hasOwn(idNameChange, 'add') + ? { add: locator } + : { remove: locator }), + }; + + yield /** @type {import('./types.js').LocatorNameChange} */ locatorNameChange; + } + }; + /** @type {import('./types.js').EndoDirectory['list']} */ const list = async (...petNamePath) => { if (petNamePath.length === 0) { @@ -123,16 +147,18 @@ export const makeDirectoryMaker = ({ return harden(Array.from(identities).sort()); }; - /** @type {import('./types.js').EndoDirectory['followChanges']} */ - const followChanges = async function* followChanges(...petNamePath) { + /** @type {import('./types.js').EndoDirectory['followNameChanges']} */ + const followNameChanges = async function* followNameChanges( + ...petNamePath + ) { if (petNamePath.length === 0) { - yield* petStore.follow(); + yield* petStore.followNameChanges(); return; } const hub = /** @type {import('./types.js').NameHub} */ ( await lookup(...petNamePath) ); - yield* hub.followChanges(); + yield* hub.followNameChanges(); }; /** @type {import('./types.js').EndoDirectory['remove']} */ @@ -206,9 +232,11 @@ export const makeDirectoryMaker = ({ has, identify, locate, + reverseLocate, + followLocatorNameChanges, list, listIdentifiers, - followChanges, + followNameChanges, lookup, reverseLookup, write, @@ -236,7 +264,10 @@ export const makeDirectoryMaker = ({ M.interface('EndoDirectory', {}, { defaultGuards: 'passable' }), { ...directory, - followChanges: () => makeIteratorRef(directory.followChanges()), + /** @param {string} locator */ + followLocatorNameChanges: locator => + makeIteratorRef(directory.followLocatorNameChanges(locator)), + followNameChanges: () => makeIteratorRef(directory.followNameChanges()), }, ); }; diff --git a/packages/daemon/src/guest.js b/packages/daemon/src/guest.js index 70c8ed404a..e672d7c0b7 100644 --- a/packages/daemon/src/guest.js +++ b/packages/daemon/src/guest.js @@ -55,9 +55,11 @@ export const makeGuestMaker = ({ provide, makeMailbox, makeDirectoryNode }) => { has, identify, locate, + reverseLocate, list, listIdentifiers, - followChanges, + followNameChanges, + followLocatorNameChanges, lookup, reverseLookup, write, @@ -85,9 +87,11 @@ export const makeGuestMaker = ({ provide, makeMailbox, makeDirectoryNode }) => { identify, reverseIdentify, locate, + reverseLocate, list, listIdentifiers, - followChanges, + followLocatorNameChanges, + followNameChanges, lookup, reverseLookup, write, @@ -113,8 +117,11 @@ export const makeGuestMaker = ({ provide, makeMailbox, makeDirectoryNode }) => { M.interface('EndoGuest', {}, { defaultGuards: 'passable' }), { ...guest, - followChanges: () => makeIteratorRef(guest.followChanges()), + /** @param {string} locator */ + followLocatorNameChanges: locator => + makeIteratorRef(guest.followLocatorNameChanges(locator)), followMessages: () => makeIteratorRef(guest.followMessages()), + followNameChanges: () => makeIteratorRef(guest.followNameChanges()), }, ); }; diff --git a/packages/daemon/src/host.js b/packages/daemon/src/host.js index 7adc0d8cba..b1d5e37779 100644 --- a/packages/daemon/src/host.js +++ b/packages/daemon/src/host.js @@ -469,9 +469,11 @@ export const makeHostMaker = ({ identify, lookup, locate, + reverseLocate, list, listIdentifiers, - followChanges, + followNameChanges, + followLocatorNameChanges, reverseLookup, write, remove, @@ -498,9 +500,11 @@ export const makeHostMaker = ({ identify, reverseIdentify, locate, + reverseLocate, list, listIdentifiers, - followChanges, + followLocatorNameChanges, + followNameChanges, lookup, reverseLookup, write, @@ -538,8 +542,11 @@ export const makeHostMaker = ({ M.interface('EndoHost', {}, { defaultGuards: 'passable' }), { ...host, - followChanges: () => makeIteratorRef(host.followChanges()), + /** @param {string} locator */ + followLocatorNameChanges: locator => + makeIteratorRef(host.followLocatorNameChanges(locator)), followMessages: () => makeIteratorRef(host.followMessages()), + followNameChanges: () => makeIteratorRef(host.followNameChanges()), }, ); diff --git a/packages/daemon/src/locator.js b/packages/daemon/src/locator.js index 91596cb33c..3ff52dec13 100644 --- a/packages/daemon/src/locator.js +++ b/packages/daemon/src/locator.js @@ -1,6 +1,6 @@ // @ts-check -import { parseId, isValidNumber } from './formula-identifier.js'; +import { formatId, isValidNumber, parseId } from './formula-identifier.js'; import { isValidFormulaType } from './formula-type.js'; const { quote: q } = assert; @@ -28,26 +28,29 @@ const isValidLocatorType = allegedType => */ const assertValidLocatorType = allegedType => { if (!isValidLocatorType(allegedType)) { - assert.Fail`Unrecognized locator type ${q(allegedType)}`; + throw assert.error(`Unrecognized locator type ${q(allegedType)}`); } }; -/** @param {string} allegedLocator */ +/** + * @param {string} allegedLocator + * @returns {{ formulaType: string, node: string, number: string }} + */ export const parseLocator = allegedLocator => { const errorPrefix = `Invalid locator ${q(allegedLocator)}:`; if (!URL.canParse(allegedLocator)) { - assert.Fail`${errorPrefix} Invalid URL.`; + throw assert.error(`${errorPrefix} Invalid URL.`); } const url = new URL(allegedLocator); if (!allegedLocator.startsWith('endo://')) { - assert.Fail`${errorPrefix} Invalid protocol.`; + throw assert.error(`${errorPrefix} Invalid protocol.`); } const node = url.host; if (!isValidNumber(node)) { - assert.Fail`${errorPrefix} Invalid node identifier.`; + throw assert.error(`${errorPrefix} Invalid node identifier.`); } if ( @@ -55,21 +58,20 @@ export const parseLocator = allegedLocator => { !url.searchParams.has('id') || !url.searchParams.has('type') ) { - assert.Fail`${errorPrefix} Invalid search params.`; + throw assert.error(`${errorPrefix} Invalid search params.`); } - const id = url.searchParams.get('id'); - if (id === null || !isValidNumber(id)) { - assert.Fail`${errorPrefix} Invalid id.`; + const number = url.searchParams.get('id'); + if (number === null || !isValidNumber(number)) { + throw assert.error(`${errorPrefix} Invalid id.`); } const formulaType = url.searchParams.get('type'); if (formulaType === null || !isValidLocatorType(formulaType)) { - assert.Fail`${errorPrefix} Invalid type.`; + throw assert.error(`${errorPrefix} Invalid type.`); } - /** @type {{ formulaType: string, node: string, id: string }} */ - return { formulaType, node, id }; + return { formulaType, node, number }; }; /** @param {string} allegedLocator */ @@ -94,3 +96,11 @@ export const formatLocator = (id, formulaType) => { return url.toString(); }; + +/** + * @param {string} locator + */ +export const idFromLocator = locator => { + const { number, node } = parseLocator(locator); + return formatId({ number, node }); +}; diff --git a/packages/daemon/src/multimap.js b/packages/daemon/src/multimap.js index 08e30b2abd..f4e84b9b49 100644 --- a/packages/daemon/src/multimap.js +++ b/packages/daemon/src/multimap.js @@ -36,6 +36,8 @@ const internalMakeMultimap = mapConstructor => { get: key => map.get(key)?.keys().next().value, getAllFor: key => Array.from(map.get(key) ?? []), + + has: key => map.has(key), }; }; @@ -92,6 +94,8 @@ export const makeBidirectionalMultimap = () => { return keyForValues.deleteAll(key); }, + has: key => keyForValues.has(key), + hasValue: value => valueForKey.has(value), get: key => keyForValues.get(key), diff --git a/packages/daemon/src/pet-sitter.js b/packages/daemon/src/pet-sitter.js index 44ddd69592..d90abae29c 100644 --- a/packages/daemon/src/pet-sitter.js +++ b/packages/daemon/src/pet-sitter.js @@ -47,8 +47,8 @@ export const makePetSitter = (petStore, specialNames) => { const list = () => harden([...Object.keys(specialNames).sort(), ...petStore.list()]); - /** @type {import('./types.js').PetStore['follow']} */ - const follow = async function* currentAndSubsequentNames() { + /** @type {import('./types.js').PetStore['followNameChanges']} */ + const followNameChanges = async function* currentAndSubsequentNames() { for (const name of Object.keys(specialNames).sort()) { const idRecord = idRecordForName(name); yield /** @type {{ add: string, value: import('./types.js').IdRecord }} */ ({ @@ -56,7 +56,26 @@ export const makePetSitter = (petStore, specialNames) => { value: idRecord, }); } - yield* petStore.follow(); + yield* petStore.followNameChanges(); + }; + + /** @type {import('./types.js').PetStore['followIdNameChanges']} */ + const followIdNameChanges = async function* currentAndSubsequentIds(id) { + const subscription = petStore.followIdNameChanges(id); + + const idSpecialNames = Object.entries(specialNames) + .filter(([_, specialId]) => specialId === id) + .map(([specialName, _]) => specialName); + + // The first published event contains the existing names for the id, if any. + const { value: existingNames } = await subscription.next(); + existingNames?.names?.unshift(...idSpecialNames); + existingNames?.names?.sort(); + yield /** @type {import('./types.js').PetStoreIdNameChange} */ ( + existingNames + ); + + yield* subscription; }; /** @type {import('./types.js').PetStore['reverseIdentify']} */ @@ -77,7 +96,8 @@ export const makePetSitter = (petStore, specialNames) => { identifyLocal, reverseIdentify, list, - follow, + followIdNameChanges, + followNameChanges, write, remove, rename, diff --git a/packages/daemon/src/pet-store.js b/packages/daemon/src/pet-store.js index 47872fd8be..a8048bd250 100644 --- a/packages/daemon/src/pet-store.js +++ b/packages/daemon/src/pet-store.js @@ -19,9 +19,51 @@ export const makePetStoreMaker = (filePowers, config) => { const makePetStoreAtPath = async (petNameDirectoryPath, assertValidName) => { /** @type {import('./types.js').BidirectionalMultimap} */ const idsToPetNames = makeBidirectionalMultimap(); - /** @type {import('./types.js').Topic<({ add: string, value: import('./types.js').IdRecord } | { remove: string })>} */ + /** @type {import('./types.js').NameChangesTopic} */ const nameChangesTopic = makeChangeTopic(); + /** @returns {import('./types.js').IdChangesTopic} */ + const makeIdChangeTopic = () => makeChangeTopic(); + /** @type {Map>} */ + const idsToTopics = new Map(); + + /** + * Publishes an id change to its subscribers, if any. + * + * @param {string} id - The id to publish a change for. + * @param {import('./types.js').PetStoreIdNameChange} payload - The payload to publish. + */ + const publishIdChangeToSubscribers = (id, payload) => { + const idTopic = idsToTopics.get(id); + if (idTopic !== undefined) { + idTopic.publisher.next(payload); + } + }; + + /** + * @param {string} id - The id receiving a name new name. + * @param {string} petName - The new name. + */ + const publishNameAddition = (id, petName) => { + const idRecord = parseId(id); + nameChangesTopic.publisher.next({ add: petName, value: idRecord }); + publishIdChangeToSubscribers(id, { add: idRecord, names: [petName] }); + }; + + /** + * @param {string} id - The id from which a name is being removed. + * @param {string} petName - The removed name. + */ + const publishNameRemoval = (id, petName) => { + nameChangesTopic.publisher.next({ remove: petName }); + if (id !== undefined) { + publishIdChangeToSubscribers(id, { + remove: parseId(id), + names: [petName], + }); + } + }; + /** @param {string} petName */ const read = async petName => { const petNamePath = filePowers.joinPath(petNameDirectoryPath, petName); @@ -68,7 +110,7 @@ export const makePetStoreMaker = (filePowers, config) => { if (oldFormulaIdentifier !== undefined) { // Perform cleanup on the overwritten pet name. idsToPetNames.delete(oldFormulaIdentifier, petName); - nameChangesTopic.publisher.next({ remove: petName }); + publishNameRemoval(oldFormulaIdentifier, petName); } } @@ -77,39 +119,45 @@ export const makePetStoreMaker = (filePowers, config) => { const petNamePath = filePowers.joinPath(petNameDirectoryPath, petName); const petNameText = `${formulaIdentifier}\n`; await filePowers.writeFileText(petNamePath, petNameText); - const formulaIdentifierRecord = parseId(formulaIdentifier); - nameChangesTopic.publisher.next({ - add: petName, - value: formulaIdentifierRecord, - }); - }; - - /** - * @param {string} petName - * @returns {import('./types.js').IdRecord} - */ - const formulaIdentifierRecordForName = petName => { - const formulaIdentifier = idsToPetNames.getKey(petName); - if (formulaIdentifier === undefined) { - throw new Error(`Formula does not exist for pet name ${q(petName)}`); - } - return parseId(formulaIdentifier); + publishNameAddition(formulaIdentifier, petName); }; /** @type {import('./types.js').PetStore['list']} */ const list = () => harden(idsToPetNames.getAll().sort()); - /** @type {import('./types.js').PetStore['follow']} */ - const follow = async function* currentAndSubsequentNames() { - const changes = nameChangesTopic.subscribe(); + /** @type {import('./types.js').PetStore['followNameChanges']} */ + const followNameChanges = async function* currentAndSubsequentNames() { + const subscription = nameChangesTopic.subscribe(); for (const name of idsToPetNames.getAll().sort()) { - const formulaIdentifierRecord = formulaIdentifierRecordForName(name); - yield /** @type {{ add: string, value: import('./types.js').IdRecord }} */ ({ + const idRecord = parseId( + /** @type {string} */ (idsToPetNames.getKey(name)), + ); + + yield /** @type {import('./types.js').PetStoreNameChange} */ ({ add: name, - value: formulaIdentifierRecord, + value: idRecord, }); } - yield* changes; + yield* subscription; + }; + + /** @type {import('./types.js').PetStore['followIdNameChanges']} */ + const followIdNameChanges = async function* currentAndSubsequentIds(id) { + if (!idsToTopics.has(id)) { + idsToTopics.set(id, makeIdChangeTopic()); + } + const idTopic = /** @type {import('./types.js').IdChangesTopic} */ ( + idsToTopics.get(id) + ); + const subscription = idTopic.subscribe(); + + const existingNames = idsToPetNames.getAllFor(id).sort(); + yield /** @type {import('./types.js').PetStoreIdNameChange} */ ({ + add: parseId(id), + names: existingNames, + }); + + yield* subscription; }; /** @type {import('./types.js').PetStore['remove']} */ @@ -126,7 +174,7 @@ export const makePetStoreMaker = (filePowers, config) => { const petNamePath = filePowers.joinPath(petNameDirectoryPath, petName); await filePowers.removePath(petNamePath); idsToPetNames.delete(formulaIdentifier, petName); - nameChangesTopic.publisher.next({ remove: petName }); + publishNameRemoval(formulaIdentifier, petName); // TODO consider retaining a backlog of deleted names for recovery // TODO consider tracking historical pet names for formulas }; @@ -146,9 +194,6 @@ export const makePetStoreMaker = (filePowers, config) => { ); } assertValidId(formulaIdentifier, fromName); - if (overwrittenId !== undefined) { - assertValidId(overwrittenId, toName); - } const fromPath = filePowers.joinPath(petNameDirectoryPath, fromName); const toPath = filePowers.joinPath(petNameDirectoryPath, toName); @@ -157,17 +202,14 @@ export const makePetStoreMaker = (filePowers, config) => { // Delete the back-reference for the overwritten pet name if it existed. if (overwrittenId !== undefined) { idsToPetNames.delete(overwrittenId, toName); + publishNameRemoval(overwrittenId, toName); } // Update the mapping for the pet name. idsToPetNames.add(formulaIdentifier, toName); - const formulaIdentifierRecord = parseId(formulaIdentifier); - nameChangesTopic.publisher.next({ - add: toName, - value: formulaIdentifierRecord, - }); - nameChangesTopic.publisher.next({ remove: fromName }); + publishNameRemoval(formulaIdentifier, fromName); + publishNameAddition(formulaIdentifier, toName); // TODO consider retaining a backlog of overwritten names for recovery }; @@ -186,7 +228,8 @@ export const makePetStoreMaker = (filePowers, config) => { identifyLocal, reverseIdentify, list, - follow, + followIdNameChanges, + followNameChanges, write, remove, rename, diff --git a/packages/daemon/src/types.d.ts b/packages/daemon/src/types.d.ts index 3b152ea90a..8d8d0b021e 100644 --- a/packages/daemon/src/types.d.ts +++ b/packages/daemon/src/types.d.ts @@ -360,15 +360,35 @@ export interface Handle { export type MakeSha512 = () => Sha512; -export type PetStoreNameDiff = +export type PetStoreNameChange = | { add: string; value: IdRecord } | { remove: string }; +export type PetStoreIdNameChange = + | { add: IdRecord; names: string[] } + | { remove: IdRecord; names?: string[] }; + +export type NameChangesTopic = Topic; + +export type IdChangesTopic = Topic; + export interface PetStore { has(petName: string): boolean; identifyLocal(petName: string): string | undefined; list(): Array; - follow(): AsyncGenerator; + /** + * Subscribe to all name changes. First publishes all existing names in alphabetical order. + * Then publishes diffs as names are added and removed. + */ + followNameChanges(): AsyncGenerator; + /** + * Subscribe to name changes for the specified id. First publishes the existing names for the id. + * Then publishes diffs as names are added and removed, or if the id is itself removed. + * @throws If attempting to follow an id with no names. + */ + followIdNameChanges( + id: string, + ): AsyncGenerator; write(petName: string, id: string): Promise; remove(petName: string): Promise; rename(fromPetName: string, toPetName: string): Promise; @@ -379,15 +399,26 @@ export interface PetStore { reverseIdentify(id: string): Array; } +/** + * `add` and `remove` are locators. + */ +export type LocatorNameChange = + | { add: string; names: string[] } + | { remove: string; names?: string[] }; + export interface NameHub { has(...petNamePath: string[]): Promise; identify(...petNamePath: string[]): Promise; locate(...petNamePath: string[]): Promise; + reverseLocate(locator: string): Promise; + followLocatorNameChanges( + locator: string, + ): AsyncGenerator; list(...petNamePath: string[]): Promise>; listIdentifiers(...petNamePath: string[]): Promise>; - followChanges( + followNameChanges( ...petNamePath: string[] - ): AsyncGenerator; + ): AsyncGenerator; lookup(...petNamePath: string[]): Promise; reverseLookup(value: unknown): Array; write(petNamePath: string[], id: string): Promise; @@ -901,6 +932,12 @@ export type Multimap = { * @returns An array of all values associated with the key. */ getAllFor(key: K): V[]; + + /** + * @param key - The key whose presence to check for. + * @returns `true` if the key is present and `false` otherwise. + */ + has(key: K): boolean; }; /** @@ -929,6 +966,12 @@ export type BidirectionalMultimap = { */ deleteAll(key: K): boolean; + /** + * @param key - The key whose presence to check for. + * @returns `true` if the key is present and `false` otherwise. + */ + has(key: K): boolean; + /** * @param value - The value whose presence to check for. * @returns `true` if the value is present and `false` otherwise. diff --git a/packages/daemon/test/test-endo.js b/packages/daemon/test/test-endo.js index c4a15bf981..a246ced0ca 100644 --- a/packages/daemon/test/test-endo.js +++ b/packages/daemon/test/test-endo.js @@ -24,7 +24,7 @@ import { } from '../index.js'; import { makeCryptoPowers } from '../src/daemon-node-powers.js'; import { formatId } from '../src/formula-identifier.js'; -import { parseLocator } from '../src/locator.js'; +import { idFromLocator, parseLocator } from '../src/locator.js'; const cryptoPowers = makeCryptoPowers(crypto); @@ -33,7 +33,7 @@ const { raw } = String; const dirname = url.fileURLToPath(new URL('..', import.meta.url)).toString(); /** - * @param {ReturnType} asyncIterator - The iterator to take from. + * @param {AsyncIterator} asyncIterator - The iterator to take from. * @param {number} count - The number of values to retrieve. */ const takeCount = async (asyncIterator, count) => { @@ -49,18 +49,33 @@ const takeCount = async (asyncIterator, count) => { }; /** - * Calls `followChanges()`, takes all already-existing names from the iterator, - * and then returns the iterator in order to observe new changes. + * Calls `host.followNameChanges()`, takes all already-existing names from the iterator, + * and returns the iterator. * - * @param {any} host - A Far endo host. + * @param {any} host - An endo host. */ -const prepareFollowChangesIterator = async host => { +const prepareFollowNameChangesIterator = async host => { const existingNames = await E(host).list(); - const changesIterator = makeRefIterator(await E(host).followChanges()); + const changesIterator = makeRefIterator(await E(host).followNameChanges()); await takeCount(changesIterator, existingNames.length); return changesIterator; }; +/** + * Calls `host.followLocatorNameChanges()` for the specified locator, takes the first + * value (i.e. the array of all existing names) from the iterator, and returns the iterator. + * + * @param {any} host - An endo host. + * @param {string} locator + */ +const prepareFollowLocatorNameChangesIterator = async (host, locator) => { + const changesIterator = makeRefIterator( + await E(host).followLocatorNameChanges(locator), + ); + await takeCount(changesIterator, 1); + return changesIterator; +}; + /** @param {Array} root */ const makeConfig = (...root) => { return { @@ -91,11 +106,21 @@ const makeHost = async (config, cancelled) => { }; /** - * @param {ReturnType} config - * @param {Promise} cancelled + * @param {import('ava').ExecutionContext} t + * @returns {Promise & ReturnType>} */ -const makeHostWithTestNetwork = async (config, cancelled) => { +const prepareHost = async t => { + // eslint-disable-next-line no-use-before-define + const { cancel, cancelled, config } = await prepareConfig(t); const { host } = await makeHost(config, cancelled); + return { cancel, cancelled, config, host }; +}; + +/** + * @param {import('ava').ExecutionContext} t + */ +const prepareHostWithTestNetwork = async t => { + const { host } = await prepareHost(t); // Install test network const servicePath = path.join(dirname, 'src', 'networks', 'tcp-netstring.js'); @@ -229,8 +254,7 @@ test('lifecycle', async t => { }); test('spawn and evaluate', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); await E(host).provideWorker('w1'); const ten = await E(host).evaluate('w1', '10', [], []); @@ -238,16 +262,14 @@ test('spawn and evaluate', async t => { }); test('anonymous spawn and evaluate', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); const ten = await E(host).evaluate('MAIN', '10', [], []); t.is(ten, 10); }); test('anonymous spawn and evaluate with new worker', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); const ten = await E(host).evaluate('NEW', '10', [], []); t.is(ten, 10); @@ -255,8 +277,7 @@ test('anonymous spawn and evaluate with new worker', async t => { // Regression test for https://github.com/endojs/endo/issues/2147 test('spawning a worker does not overwrite existing non-worker name', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); const foo = await E(host).evaluate('MAIN', '10', [], [], 'foo'); t.is(foo, 10); @@ -306,8 +327,7 @@ test('persist spawn and evaluation', async t => { }); test('store without name', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); const readerRef = makeReaderRef([new TextEncoder().encode('hello\n')]); const readable = await E(host).store(readerRef); @@ -550,8 +570,7 @@ test('persist confined services and their requests', async t => { }); test('guest facet receives a message for host', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); const guest = E(host).provideGuest('guest'); await E(host).provideWorker('worker'); @@ -599,22 +618,20 @@ test('guest facet receives a message for host', async t => { ); }); -test('name changes subscription first publishes existing names', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); +test('followNamehanges first publishes existing names', async t => { + const { host } = await prepareHost(t); const existingNames = await E(host).list(); - const changesIterator = makeRefIterator(await E(host).followChanges()); + const changesIterator = makeRefIterator(await E(host).followNameChanges()); const values = await takeCount(changesIterator, existingNames.length); t.deepEqual(values.map(value => value.add).sort(), [...existingNames].sort()); }); -test('name changes subscription publishes new names', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); +test('followNameChanges publishes new names', async t => { + const { host } = await prepareHost(t); - const changesIterator = await prepareFollowChangesIterator(host); + const changesIterator = await prepareFollowNameChangesIterator(host); await E(host).evaluate('MAIN', '10', [], [], 'ten'); @@ -622,11 +639,10 @@ test('name changes subscription publishes new names', async t => { t.is(value.add, 'ten'); }); -test('name changes subscription publishes removed names', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); +test('followNameChanges publishes removed names', async t => { + const { host } = await prepareHost(t); - const changesIterator = await prepareFollowChangesIterator(host); + const changesIterator = await prepareFollowNameChangesIterator(host); await E(host).evaluate('MAIN', '10', [], [], 'ten'); await changesIterator.next(); @@ -636,11 +652,10 @@ test('name changes subscription publishes removed names', async t => { t.is(value.remove, 'ten'); }); -test('name changes subscription publishes renamed names', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); +test('followNameChanges publishes renamed names', async t => { + const { host } = await prepareHost(t); - const changesIterator = await prepareFollowChangesIterator(host); + const changesIterator = await prepareFollowNameChangesIterator(host); await E(host).evaluate('MAIN', '10', [], [], 'ten'); await changesIterator.next(); @@ -648,16 +663,35 @@ test('name changes subscription publishes renamed names', async t => { await E(host).move(['ten'], ['zehn']); let { value } = await changesIterator.next(); + t.is(value.remove, 'ten'); + value = (await changesIterator.next()).value; t.is(value.add, 'zehn'); +}); + +test('followNameChanges publishes renamed names (existing mappings for both names)', async t => { + const { host } = await prepareHost(t); + + const changesIterator = await prepareFollowNameChangesIterator(host); + + await E(host).evaluate('MAIN', '10', [], [], 'ten'); + await changesIterator.next(); + await E(host).evaluate('MAIN', '"german 10"', [], [], 'zehn'); + await changesIterator.next(); + + await E(host).move(['ten'], ['zehn']); + + let { value } = await changesIterator.next(); + t.is(value.remove, 'zehn'); value = (await changesIterator.next()).value; t.is(value.remove, 'ten'); + value = (await changesIterator.next()).value; + t.is(value.add, 'zehn'); }); -test('name changes subscription does not notify of redundant pet store writes', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); +test('followNameChanges does not notify of redundant pet store writes', async t => { + const { host } = await prepareHost(t); - const changesIterator = await prepareFollowChangesIterator(host); + const changesIterator = await prepareFollowNameChangesIterator(host); await E(host).evaluate('MAIN', '10', [], [], 'ten'); await changesIterator.next(); @@ -672,9 +706,152 @@ test('name changes subscription does not notify of redundant pet store writes', t.is(value.add, 'eleven'); }); +test('followLocatorNameChanges first publishes existing pet name', async t => { + const { host } = await prepareHost(t); + + await E(host).evaluate('MAIN', '10', [], [], 'ten'); + + const tenLocator = await E(host).locate('ten'); + const tenLocatorSub = makeRefIterator( + await E(host).followLocatorNameChanges(tenLocator), + ); + const { value } = await tenLocatorSub.next(); + t.deepEqual(value, { add: tenLocator, names: ['ten'] }); +}); + +test('followLocatorNameChanges first publishes existing special name', async t => { + const { host } = await prepareHost(t); + + const selfLocator = await E(host).locate('SELF'); + const selfLocatorSub = makeRefIterator( + await E(host).followLocatorNameChanges(selfLocator), + ); + const { value } = await selfLocatorSub.next(); + t.deepEqual(value, { add: selfLocator, names: ['SELF'] }); +}); + +test('followLocatorNameChanges first publishes existing pet and special names', async t => { + const { host } = await prepareHost(t); + + const selfId = await E(host).identify('SELF'); + await E(host).write(['self1'], selfId); + await E(host).write(['self2'], selfId); + + const selfLocator = await E(host).locate('SELF'); + const selfLocatorSub = makeRefIterator( + await E(host).followLocatorNameChanges(selfLocator), + ); + const { value } = await selfLocatorSub.next(); + t.deepEqual(value, { add: selfLocator, names: ['SELF', 'self1', 'self2'] }); +}); + +test('followLocatorNameChanges publishes added names', async t => { + const { host } = await prepareHost(t); + + await E(host).evaluate('MAIN', '10', [], [], 'ten'); + + const tenLocator = await E(host).locate('ten'); + const changesIterator = await prepareFollowLocatorNameChangesIterator( + host, + tenLocator, + ); + + await E(host).write(['zehn'], idFromLocator(tenLocator)); + + const { value } = await changesIterator.next(); + t.deepEqual(value, { add: tenLocator, names: ['zehn'] }); +}); + +test('followLocatorNameChanges publishes removed names', async t => { + const { host } = await prepareHost(t); + + await E(host).evaluate('MAIN', '10', [], [], 'ten'); + + const tenLocator = await E(host).locate('ten'); + await E(host).write(['zehn'], idFromLocator(tenLocator)); + const changesIterator = await prepareFollowLocatorNameChangesIterator( + host, + tenLocator, + ); + + await E(host).remove('zehn'); + + const { value } = await changesIterator.next(); + t.deepEqual(value, { remove: tenLocator, names: ['zehn'] }); +}); + +test('followLocatorNameChanges publishes renamed names', async t => { + const { host } = await prepareHost(t); + + await E(host).evaluate('MAIN', '10', [], [], 'ten'); + + const tenLocator = await E(host).locate('ten'); + const changesIterator = await prepareFollowLocatorNameChangesIterator( + host, + tenLocator, + ); + + await E(host).move(['ten'], ['zehn']); + + let { value } = await changesIterator.next(); + t.deepEqual(value, { remove: tenLocator, names: ['ten'] }); + value = (await changesIterator.next()).value; + t.deepEqual(value, { add: tenLocator, names: ['zehn'] }); +}); + +test('followLocatorNameChanges publishes renamed names (existing mappings for both names)', async t => { + const { host } = await prepareHost(t); + + await E(host).evaluate('MAIN', '10', [], [], 'ten'); + await E(host).evaluate('MAIN', '"german 10"', [], [], 'zehn'); + + const tenLocator = await E(host).locate('ten'); + const zehnLocator = await E(host).locate('zehn'); + const tenChangesIterator = await prepareFollowLocatorNameChangesIterator( + host, + tenLocator, + ); + const zehnChangesIterator = await prepareFollowLocatorNameChangesIterator( + host, + zehnLocator, + ); + + await E(host).move(['ten'], ['zehn']); + + // First, changes for "zehn" + let { value } = await zehnChangesIterator.next(); + t.deepEqual(value, { remove: zehnLocator, names: ['zehn'] }); + + // Then, changes for "ten" + value = (await tenChangesIterator.next()).value; + t.deepEqual(value, { remove: tenLocator, names: ['ten'] }); + value = (await tenChangesIterator.next()).value; + t.deepEqual(value, { add: tenLocator, names: ['zehn'] }); +}); + +test('followLocatorNameChanges does not notify of redundant pet store writes', async t => { + const { host } = await prepareHost(t); + + await E(host).evaluate('MAIN', '10', [], [], 'ten'); + + const tenLocator = await E(host).locate('ten'); + const changesIterator = await prepareFollowLocatorNameChangesIterator( + host, + tenLocator, + ); + + // Rewrite the value's existing name. + await E(host).write(['ten'], idFromLocator(tenLocator)); + // Write an actually different name for the value. + await E(host).write(['zehn'], idFromLocator(tenLocator)); + + // Confirm that the redundant write is not observed. + const { value } = await changesIterator.next(); + t.deepEqual(value, { add: tenLocator, names: ['zehn'] }); +}); + test('direct cancellation', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); await E(host).provideWorker('worker'); @@ -741,8 +918,7 @@ test('direct cancellation', async t => { // Regression test 1 for https://github.com/endojs/endo/issues/2074 test('indirect cancellation via worker', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); await E(host).provideWorker('worker'); @@ -810,8 +986,7 @@ test('indirect cancellation via worker', async t => { // Regression test 2 for https://github.com/endojs/endo/issues/2074 test.failing('indirect cancellation via caplet', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); await E(host).provideWorker('w1'); const counterPath = path.join(dirname, 'test', 'counter.js'); @@ -851,8 +1026,7 @@ test.failing('indirect cancellation via caplet', async t => { }); test('cancel because of requested capability', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); await E(host).provideWorker('worker'); await E(host).provideGuest('guest', { agentName: 'guest-agent' }); @@ -927,8 +1101,7 @@ test('cancel because of requested capability', async t => { }); test('unconfined service can respond to cancellation', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); await E(host).provideWorker('worker'); @@ -952,8 +1125,7 @@ test('unconfined service can respond to cancellation', async t => { }); test('confined service can respond to cancellation', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); await E(host).provideWorker('worker'); @@ -973,8 +1145,7 @@ test('confined service can respond to cancellation', async t => { }); test('make a host', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); const host2 = E(host).provideHost('fellow-host'); await E(host2).provideWorker('w1'); @@ -983,8 +1154,7 @@ test('make a host', async t => { }); test('name and reuse inspector', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); await E(host).provideWorker('worker'); @@ -1011,8 +1181,7 @@ test('name and reuse inspector', async t => { // Regression test for https://github.com/endojs/endo/issues/2021 test('eval-mediated worker name', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); await E(host).provideWorker('worker'); @@ -1053,8 +1222,7 @@ test('eval-mediated worker name', async t => { }); test('lookup with single petname', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); await E(host).provideGuest('guest'); const ten = await E(host).evaluate('MAIN', '10', [], [], 'ten'); @@ -1070,8 +1238,7 @@ test('lookup with single petname', async t => { }); test('lookup with petname path (inspector)', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); await E(host).evaluate('MAIN', '10', [], [], 'ten'); @@ -1085,8 +1252,7 @@ test('lookup with petname path (inspector)', async t => { }); test('lookup with petname path (caplet with lookup method)', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); const lookupPath = path.join(dirname, 'test', 'lookup.js'); await E(host).makeUnconfined('MAIN', lookupPath, 'NONE', 'lookup'); @@ -1101,8 +1267,7 @@ test('lookup with petname path (caplet with lookup method)', async t => { }); test('lookup with petname path (value has no lookup method)', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); await E(host).evaluate('MAIN', '10', [], [], 'ten'); await t.throwsAsync( @@ -1117,8 +1282,7 @@ test('lookup with petname path (value has no lookup method)', async t => { }); test('evaluate name resolved by lookup path', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); await E(host).evaluate('MAIN', '10', [], [], 'ten'); @@ -1132,8 +1296,7 @@ test('evaluate name resolved by lookup path', async t => { }); test('list special names', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); const readerRef = makeReaderRef([new TextEncoder().encode('hello\n')]); await E(host).store(readerRef, 'hello-text'); @@ -1152,8 +1315,7 @@ test('list special names', async t => { }); test('guest cannot access host methods', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); const guest = E(host).provideGuest('guest'); const guestsHost = E(guest).lookup('HOST'); @@ -1165,8 +1327,7 @@ test('guest cannot access host methods', async t => { }); test('read unknown node id', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); // write a bogus value for a bogus nodeId const node = await cryptoPowers.randomHex512(); @@ -1184,10 +1345,8 @@ test('read unknown node id', async t => { }); test('read remote value', async t => { - const { config: configA, cancelled: cancelledA } = await prepareConfig(t); - const { config: configB, cancelled: cancelledB } = await prepareConfig(t); - const hostA = await makeHostWithTestNetwork(configA, cancelledA); - const hostB = await makeHostWithTestNetwork(configB, cancelledB); + const hostA = await prepareHostWithTestNetwork(t); + const hostB = await prepareHostWithTestNetwork(t); // introduce nodes to each other await E(hostA).addPeerInfo(await E(hostB).getPeerInfo()); @@ -1205,8 +1364,7 @@ test('read remote value', async t => { }); test('locate local value', async t => { - const { cancelled, config } = await prepareConfig(t); - const { host } = await makeHost(config, cancelled); + const { host } = await prepareHost(t); const ten = await E(host).evaluate('MAIN', '10', [], [], 'ten'); t.is(ten, 10); @@ -1236,10 +1394,8 @@ test('locate local persisted value', async t => { }); test('locate remote value', async t => { - const { config: configA, cancelled: cancelledA } = await prepareConfig(t); - const { config: configB, cancelled: cancelledB } = await prepareConfig(t); - const hostA = await makeHostWithTestNetwork(configA, cancelledA); - const hostB = await makeHostWithTestNetwork(configB, cancelledB); + const hostA = await prepareHostWithTestNetwork(t); + const hostB = await prepareHostWithTestNetwork(t); // introduce nodes to each other await E(hostA).addPeerInfo(await E(hostB).getPeerInfo()); @@ -1256,3 +1412,53 @@ test('locate remote value', async t => { const parsedGreetingsLocator = parseLocator(greetingsLocator); t.is(parsedGreetingsLocator.formulaType, 'remote'); }); + +test('reverse locate local value', async t => { + const { host } = await prepareHost(t); + + const ten = await E(host).evaluate('MAIN', '10', [], [], 'ten'); + t.is(ten, 10); + + const tenLocator = await E(host).locate('ten'); + const [reverseLocatedName] = await E(host).reverseLocate(tenLocator); + t.is(reverseLocatedName, 'ten'); +}); + +test('reverse locate local persisted value', async t => { + const { cancelled, config } = await prepareConfig(t); + + { + const { host } = await makeHost(config, cancelled); + const ten = await E(host).evaluate('MAIN', '10', [], [], 'ten'); + t.is(ten, 10); + } + + await restart(config); + + { + const { host } = await makeHost(config, cancelled); + const tenLocator = await E(host).locate('ten'); + const [reverseLocatedName] = await E(host).reverseLocate(tenLocator); + t.is(reverseLocatedName, 'ten'); + } +}); + +test('reverse locate remote value', async t => { + const hostA = await prepareHostWithTestNetwork(t); + const hostB = await prepareHostWithTestNetwork(t); + + // introduce nodes to each other + await E(hostA).addPeerInfo(await E(hostB).getPeerInfo()); + await E(hostB).addPeerInfo(await E(hostA).getPeerInfo()); + + // create value to share + await E(hostB).evaluate('MAIN', '"hello, world!"', [], [], 'salutations'); + const hostBValueIdentifier = await E(hostB).identify('salutations'); + + // insert in hostA out of band + await E(hostA).write(['greetings'], hostBValueIdentifier); + + const greetingsLocator = await E(hostA).locate('greetings'); + const [reverseLocatedName] = await E(hostA).reverseLocate(greetingsLocator); + t.is(reverseLocatedName, 'greetings'); +}); diff --git a/packages/daemon/test/test-locator.js b/packages/daemon/test/test-locator.js index aa7b2dfa7f..324c74ae0b 100644 --- a/packages/daemon/test/test-locator.js +++ b/packages/daemon/test/test-locator.js @@ -3,6 +3,7 @@ import test from '@endo/ses-ava/prepare-endo.js'; import { assertValidLocator, formatLocator, + idFromLocator, parseLocator, } from '../src/locator.js'; import { formatId } from '../src/formula-identifier.js'; @@ -58,7 +59,7 @@ test('assertValidLocator - invalid', t => { test('parseLocator', t => { t.deepEqual(parseLocator(makeLocator()), { - id: validId, + number: validId, node: validNode, formulaType: validType, }); @@ -70,3 +71,10 @@ test('formatLocator', t => { makeLocator(), ); }); + +test('idFromLocator', t => { + t.is( + idFromLocator(makeLocator()), + formatId({ number: validId, node: validNode }), + ); +}); diff --git a/packages/daemon/test/test-multimap.js b/packages/daemon/test/test-multimap.js index 2dd75fc781..01cc35ad86 100644 --- a/packages/daemon/test/test-multimap.js +++ b/packages/daemon/test/test-multimap.js @@ -59,6 +59,23 @@ import { t.deepEqual(multimap.getAllFor(key2), []); }); + test(`${mapName}: has`, t => { + const multimap = multimapConstructor(); + const key1 = {}; + const key2 = {}; + const key3 = {}; + const value1 = 'foo'; + const value2 = 'bar'; + + multimap.add(key1, value1); + multimap.add(key1, value2); + multimap.add(key2, value1); + + t.is(multimap.has(key1), true); + t.is(multimap.has(key2), true); + t.is(multimap.has(key3), false); + }); + test(`${mapName}: delete`, t => { const multimap = multimapConstructor(); const key = {}; @@ -190,6 +207,22 @@ test('multi-bimap: getKey', t => { t.is(bimap.getKey(value3), undefined); }); +test('multi-bimap: has', t => { + const bimap = makeBidirectionalMultimap(); + const key1 = 'foo'; + const key2 = 'bar'; + const key3 = 'baz'; + const value1 = {}; + const value2 = {}; + + bimap.add(key1, value1); + bimap.add(key2, value2); + + t.is(bimap.has(key1), true); + t.is(bimap.has(key2), true); + t.is(bimap.has(key3), false); +}); + test('multi-bimap: hasValue', t => { const bimap = makeBidirectionalMultimap(); const key1 = 'foo';