From ab3dbb071ec58e8d456a9af1be742e12a495b0c6 Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Mon, 20 May 2024 11:23:31 -0400 Subject: [PATCH] Migrate MergedRegistry from monorepo (#32) ### Description - Migrate MergedRegistry from the monorepo's CLI package to here - Add required objMerge utility ### Backward compatibility Yes ### Testing New unit test coverage --- .changeset/thick-weeks-approve.md | 5 ++ src/index.ts | 1 + src/registry/IRegistry.ts | 1 + src/registry/MergedRegistry.ts | 114 ++++++++++++++++++++++++++++++ src/utils.ts | 30 ++++++++ test/unit/registry.test.ts | 8 ++- 6 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 .changeset/thick-weeks-approve.md create mode 100644 src/registry/MergedRegistry.ts diff --git a/.changeset/thick-weeks-approve.md b/.changeset/thick-weeks-approve.md new file mode 100644 index 000000000..c1c493bb8 --- /dev/null +++ b/.changeset/thick-weeks-approve.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/registry': minor +--- + +Add MergedRegistry class diff --git a/src/index.ts b/src/index.ts index 8d05f9c64..3ca47f495 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,5 @@ export { DEFAULT_GITHUB_REGISTRY } from './consts.js'; export { BaseRegistry, CHAIN_FILE_REGEX } from './registry/BaseRegistry.js'; export { GithubRegistry, GithubRegistryOptions } from './registry/GithubRegistry.js'; export { ChainFiles, IRegistry, RegistryContent, RegistryType } from './registry/IRegistry.js'; +export { MergedRegistry, MergedRegistryOptions } from './registry/MergedRegistry.js'; export { ChainAddresses, ChainAddressesSchema } from './types.js'; diff --git a/src/registry/IRegistry.ts b/src/registry/IRegistry.ts index bcc630ab9..841cd2ee3 100644 --- a/src/registry/IRegistry.ts +++ b/src/registry/IRegistry.ts @@ -17,6 +17,7 @@ export interface RegistryContent { export enum RegistryType { Github = 'github', Local = 'local', + Merged = 'merged', } export interface IRegistry { diff --git a/src/registry/MergedRegistry.ts b/src/registry/MergedRegistry.ts new file mode 100644 index 000000000..ca2b33472 --- /dev/null +++ b/src/registry/MergedRegistry.ts @@ -0,0 +1,114 @@ +import type { Logger } from 'pino'; + +import type { ChainMap, ChainMetadata, ChainName, WarpCoreConfig } from '@hyperlane-xyz/sdk'; +import { ChainAddresses } from '../types.js'; +import { objMerge } from '../utils.js'; +import { BaseRegistry } from './BaseRegistry.js'; +import { IRegistry, RegistryContent, RegistryType } from './IRegistry.js'; + +export interface MergedRegistryOptions { + registries: Array; + logger?: Logger; +} + +export class MergedRegistry extends BaseRegistry implements IRegistry { + public readonly type = RegistryType.Merged; + public readonly registries: Array; + + constructor({ registries, logger }: MergedRegistryOptions) { + super({ uri: '__merged_registry__', logger }); + if (!registries.length) throw new Error('At least one registry URI is required'); + this.registries = registries; + } + + async listRegistryContent(): Promise { + const results = await this.multiRegistryRead((r) => r.listRegistryContent()); + return results.reduce((acc, content) => objMerge(acc, content), { + chains: {}, + deployments: {}, + }); + } + + async getChains(): Promise> { + return Object.keys(await this.getMetadata()); + } + + async getMetadata(): Promise> { + const results = await this.multiRegistryRead((r) => r.getMetadata()); + return results.reduce((acc, content) => objMerge(acc, content), {}); + } + + async getChainMetadata(chainName: ChainName): Promise { + return (await this.getMetadata())[chainName] || null; + } + + async getAddresses(): Promise> { + const results = await this.multiRegistryRead((r) => r.getAddresses()); + return results.reduce((acc, content) => objMerge(acc, content), {}); + } + + async getChainAddresses(chainName: ChainName): Promise { + return (await this.getAddresses())[chainName] || null; + } + + async addChain(chain: { + chainName: ChainName; + metadata?: ChainMetadata; + addresses?: ChainAddresses; + }): Promise { + return this.multiRegistryWrite( + async (registry) => await registry.addChain(chain), + `adding chain ${chain.chainName}`, + ); + } + + async updateChain(chain: { + chainName: ChainName; + metadata?: ChainMetadata; + addresses?: ChainAddresses; + }): Promise { + return this.multiRegistryWrite( + async (registry) => await registry.updateChain(chain), + `updating chain ${chain.chainName}`, + ); + } + + async removeChain(chain: ChainName): Promise { + return this.multiRegistryWrite( + async (registry) => await registry.removeChain(chain), + `removing chain ${chain}`, + ); + } + + async addWarpRoute(config: WarpCoreConfig): Promise { + return this.multiRegistryWrite( + async (registry) => await registry.addWarpRoute(config), + 'adding warp route', + ); + } + + protected multiRegistryRead(readFn: (registry: IRegistry) => Promise | R) { + return Promise.all(this.registries.map(readFn)); + } + + protected async multiRegistryWrite( + writeFn: (registry: IRegistry) => Promise, + logMsg: string, + ): Promise { + for (const registry of this.registries) { + // TODO remove this when GithubRegistry supports write methods + if (registry.type === RegistryType.Github) { + this.logger.warn(`Skipping ${logMsg} at ${registry.type} registry`); + continue; + } + try { + this.logger.info(`Now ${logMsg} at ${registry.type} registry at ${registry.uri}`); + await writeFn(registry); + this.logger.info(`Done ${logMsg} at ${registry.type} registry`); + } catch (error) { + // To prevent loss of artifacts, MergedRegistry write methods are failure tolerant + this.logger.error(`Failure ${logMsg} at ${registry.type} registry`, error); + } + } + } +} diff --git a/src/utils.ts b/src/utils.ts index b0edb9c45..5280ec0b2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -19,3 +19,33 @@ export async function concurrentMap( } return res; } + +export function isObject(item: any) { + return item && typeof item === 'object' && !Array.isArray(item); +} + +// Recursively merges b into a +// Where there are conflicts, b takes priority over a +export function objMerge(a: Record, b: Record, max_depth = 10): any { + if (max_depth === 0) { + throw new Error('objMerge tried to go too deep'); + } + if (isObject(a) && isObject(b)) { + const ret: Record = {}; + const aKeys = new Set(Object.keys(a)); + const bKeys = new Set(Object.keys(b)); + const allKeys = new Set([...aKeys, ...bKeys]); + for (const key of allKeys.values()) { + if (aKeys.has(key) && bKeys.has(key)) { + ret[key] = objMerge(a[key], b[key], max_depth - 1); + } else if (aKeys.has(key)) { + ret[key] = a[key]; + } else { + ret[key] = b[key]; + } + } + return ret; + } else { + return b ? b : a; + } +} diff --git a/test/unit/registry.test.ts b/test/unit/registry.test.ts index f1a022ae0..9720ae588 100644 --- a/test/unit/registry.test.ts +++ b/test/unit/registry.test.ts @@ -5,7 +5,9 @@ import type { ChainMetadata } from '@hyperlane-xyz/sdk'; import fs from 'fs'; import { CHAIN_FILE_REGEX } from '../../src/registry/BaseRegistry.js'; import { GithubRegistry } from '../../src/registry/GithubRegistry.js'; +import { RegistryType } from '../../src/registry/IRegistry.js'; import { LocalRegistry } from '../../src/registry/LocalRegistry.js'; +import { MergedRegistry } from '../../src/registry/MergedRegistry.js'; import { ChainAddresses } from '../../src/types.js'; const MOCK_CHAIN_NAME = 'mockchain'; @@ -21,7 +23,9 @@ describe('Registry utilities', () => { const localRegistry = new LocalRegistry({ uri: './' }); expect(localRegistry.uri).to.be.a('string'); - for (const registry of [githubRegistry, localRegistry]) { + const mergedRegistry = new MergedRegistry({ registries: [githubRegistry, localRegistry] }); + + for (const registry of [githubRegistry, localRegistry, mergedRegistry]) { it(`Lists all chains for ${registry.type} registry`, async () => { const chains = await registry.getChains(); expect(chains.length).to.be.greaterThan(0); @@ -59,7 +63,7 @@ describe('Registry utilities', () => { }).timeout(250); // TODO remove this once GitHubRegistry methods are implemented - if (registry.type === 'github') continue; + if (registry.type !== RegistryType.Local) continue; it(`Adds a new chain for ${registry.type} registry`, async () => { const mockMetadata: ChainMetadata = {