Skip to content

Commit

Permalink
feat: New version of ua-utils, aligned with below-zero's model of con…
Browse files Browse the repository at this point in the history
…figurables
  • Loading branch information
janjakubnanista committed Nov 21, 2023
1 parent 32a99f4 commit 69a6e07
Show file tree
Hide file tree
Showing 12 changed files with 424 additions and 1 deletion.
2 changes: 2 additions & 0 deletions packages/ua-utils/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
node_modules
5 changes: 5 additions & 0 deletions packages/ua-utils/.mocharc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extensions": ["ts"],
"spec": ["**/*.test.*"],
"loader": "ts-node/esm"
}
2 changes: 2 additions & 0 deletions packages/ua-utils/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/
node_modules/
129 changes: 129 additions & 0 deletions packages/ua-utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<p align="center">
<a href="https://layerzero.network">
<img alt="LayerZero" style="max-width: 500px" src="https://d3a2dpnnrypp5h.cloudfront.net/bridge-app/lz.png"/>
</a>
</p>

<h1 align="center">@layerzerolabs/ua-utils</h1>

<!-- The badges section -->
<p align="center">
<!-- Shields.io NPM published package version -->
<a href="https://www.npmjs.com/package/@layerzerolabs/ua-utils"><img alt="NPM Version" src="https://img.shields.io/npm/v/@layerzerolabs/ua-utils"/></a>
<!-- Shields.io NPM downloads -->
<a href="https://www.npmjs.com/package/@layerzerolabs/ua-utils"><img alt="Downloads" src="https://img.shields.io/npm/dm/@layerzerolabs/ua-utils"/></a>
<!-- Shields.io license badge -->
<a href="https://www.npmjs.com/package/@layerzerolabs/ua-utils"><img alt="NPM License" src="https://img.shields.io/npm/l/@layerzerolabs/ua-utils"/></a>
</p>

## Installation

```bash
yarn add @layerzerolabs/ua-utils

pnpm add @layerzerolabs/ua-utils

npm install @layerzerolabs/ua-utils
```

## Usage

### `createProperty`

Creates a `Property` object - an abstract wrapper around a getter and setter of a (typically contract) property.

`Property` objects can be evaluated - this evaluation results in a `PropertyState` object which comes in two varieties:

- `Configured` object represents a property whose desired value matches the current, actual value
- `Misconfigured` object represents a property whose desired value does not match the current value. It can be further executed using its `configure` method to set the desired property value

```typescript
import type { Contract } from "@etherspropject/contracts"
import { createProperty, isMisconfigured } from "@layerzerolabs/ua-utils"

// In this example we'll creating a property that is executed within a context of a contract
//
// What this means is that the property requires a contract to be evaluated. This context
// is then passed to the getter, setter and the desired value getter
const myContractProperty = createProperty({
get: (contract: Contract) => contract.getFavouriteAddress(),
set: (contract: Contract, favouriteAddress: string) => contract.setFavouriteAddress(favouriteAddress),
desired: (contract: Contract) => "0x00000000219ab540356cbb839cbe05303d7705fa",
})

// Let's pretend we have a contract at hand
declare const myContract: Contract

// We'll evaluate this property by passing myContract in. This contract will then be passed
// to the getter, setter and desired value getter
//
// The result of this evaluation is a PropertyState object
const state = await myContractProperty(myContract)

// This package comes with two type narrowing utilities for working with PropertyState objects:
//
// - isConfigured
// - isMisconfigured
//
// Using these we can discern between the two varieties of PropertyState
if (isMisconfigured(state)) {
await state.configure()
}
```

In the example above we used a simple `Contract` object as our context. We can use arbitrary context as the following example shows:

```typescript
import { createProperty, isMisconfigured } from "@layerzerolabs/ua-utils"

const myContractPropertyWithParams = createProperty({
get: (contract: Contract, when: number, where: string) => contract.getFavouriteAddress(when, where),
set: (contract: Contract, when: number, where: string, favouriteAddress: string) =>
contract.setFavouriteAddress(when, where, favouriteAddress),
desired: (contract: Contract, when: number, where: string) => "0x00000000219ab540356cbb839cbe05303d7705fa",
})

// We'll again pretend we have a contract at hand
declare const myContract: Contract
const when = Date.now()
const where = "Antractica"

// We'll evaluate this property by passing the required context in - in this case
// the context consists of a contract, a numeric value and a string value
//
// The result of this evaluation is a PropertyState object
const state = await myContractPropertyWithParams(myContract, when, where)

// The execution goes just like before
if (isMisconfigured(state)) {
await state.configure()
}
```

### `isConfigured`

Helper type assertion utility that narrows down the `PropertyState` type to `Configured`:

```typescript
import { PropertyState, isConfigured } from "@layerzerolabs/ua-utils"

declare const state: PropertyState

if (isConfigured(state)) {
// state is now Configured, no action is needed as the property is in its desired state
}
```

### `isMisconfigured`

Helper type assertion utility that narrows down the `PropertyState` type to `Misconfigured`:

```typescript
import { PropertyState, isMisconfigured } from "@layerzerolabs/ua-utils"

declare const state: PropertyState

if (isMisconfigured(state)) {
// state is now Misconfigured, we can e.g. call .configure
}
```
41 changes: 41 additions & 0 deletions packages/ua-utils/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "@layerzerolabs/ua-utils",
"description": "Utilities for working with LayerZero projects",
"version": "0.1.0",
"license": "MIT",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"module": "./dist/index.mjs",
"exports": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
"files": [
"./dist/index.*"
],
"scripts": {
"build": "npx tsup",
"clean": "rm -rf dist",
"dev": "npx tsup --watch",
"lint": "npx eslint '**/*.{js,ts,json}'",
"prebuild": "npx tsc --noEmit -p tsconfig.build.json",
"test": "mocha --parallel"
},
"repository": {
"type": "git",
"url": "git+https://github.com/LayerZero-Labs/lz-utils.git",
"directory": "packages/ua-utils"
},
"devDependencies": {
"@types/mocha": "^10.0.1",
"@types/sinon": "^17.0.2",
"chai": "^4.3.10",
"mocha": "^10.2.0",
"sinon": "^17.0.1",
"ts-node": "^10.9.1",
"tsup": "~7.2.0",
"typescript": "^5.2.2"
}
}
1 change: 1 addition & 0 deletions packages/ua-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./property"
110 changes: 110 additions & 0 deletions packages/ua-utils/src/property.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import assert from "assert"

/**
* The central concept of this module - a structure that can be evaluated into a `PropertyState`
*
* `Property` has three basic functions:
*
* - Property getter that gets the current value of the property
* - Property setter that sets value of the property
* - Property desired value getter that grabs the value this property should be set to
*/
export type Property<TContext extends unknown[], TValue = unknown, TResult = unknown> = (
...context: TContext
) => Promise<PropertyState<TValue, TResult>>

/**
* Type encapsulating two states of a configurable property: `Configured` and `Misconfigured`
*
* Property property is understood as anything that has a getter and setter
* and its value needs to match a desired value (coming from some sort of a configuration).
*/
export type PropertyState<TValue = unknown, TResult = unknown> = Configured<TValue> | Misconfigured<TValue, TResult>

/**
* Interface for configured state of a configurable property.
*
* In configured state, the current value of the property matches its desired state
* and no action is necessary.
*/
export interface Configured<TValue = unknown> {
value: TValue
desiredValue?: never
configure?: never
}

/**
* Interface for misconfigured state of a configurable property.
*
* In misconfigured state, the current value of the property does not match its desired state
* and an action needs to be taken to synchronize these two.
*/
export interface Misconfigured<TValue = unknown, TResult = unknown> {
value: TValue
desiredValue: TValue
configure: () => TResult | Promise<TResult>
}

export type GetPropertyValue<TContext extends unknown[], TValue = unknown> = (...context: TContext) => TValue | Promise<TValue>

export type SetPropertyValue<TContext extends unknown[], TValue = unknown, TResult = unknown> = (
...params: [...TContext, TValue]
) => TResult | Promise<TResult>

export interface PropertyOptions<TContext extends unknown[], TValue, TResult> {
desired: GetPropertyValue<TContext, TValue>
get: GetPropertyValue<TContext, TValue>
set: SetPropertyValue<TContext, TValue, TResult>
}

/**
* Property factory, the central functional piece of this module.
*
* @param `PropertyOptions<TContext extends unknown[], TValue = unknown, TResult = unknown>`
*
* @returns `Property<TContext, TValue, TResult>`
*/
export const createProperty =
<TContext extends unknown[], TValue = unknown, TResult = unknown>({
get,
set,
desired,
}: PropertyOptions<TContext, TValue, TResult>): Property<TContext, TValue, TResult> =>
async (...context) => {
// First we grab the current & desired states of the property
const [value, desiredValue] = await Promise.all([get(...context), desired(...context)])

// Now we compare the current & desired states using value equality (i.e. values
// with the same shape will be considered equal)
//
// We'll use the native deep equality function that throws an AssertionError
// when things don't match so we need to try/catch and understand the catch branch
// as inequality
try {
assert.deepStrictEqual(value, desiredValue)

// The values matched, we return a Configured
return { value }
} catch {
// The values did not match, we'll return a Misconfigured
return { value, desiredValue, configure: async () => set(...context, desiredValue) }
}
}

/**
* Type assertion utility for narrowing the `PropertyState` type to `Misconfigured` type
*
* @param value `PropertyState<TValue, TResult>`
* @returns `value is Misconfigured<TValue, TResult>`
*/
export const isMisconfigured = <TValue = unknown, TResult = unknown>(
value: PropertyState<TValue, TResult>
): value is Misconfigured<TValue, TResult> => "configure" in value && "desiredValue" in value && typeof value.configure === "function"

/**
* Type assertion utility for narrowing the `PropertyState` type to `Configured` type
*
* @param value `PropertyState<TValue, TResult>`
* @returns `value is Configured<TValue, TResult>`
*/
export const isConfigured = <TValue = unknown>(value: PropertyState<TValue>): value is Configured<TValue> => !isMisconfigured(value)
96 changes: 96 additions & 0 deletions packages/ua-utils/test/property.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { expect } from "chai"
import { describe } from "mocha"
import sinon from "sinon"
import { createProperty, isConfigured, isMisconfigured } from "../src/property"

describe("property", () => {
describe("isMisconfigured", () => {
it("should return true if value is Misconfigured", () => {
expect(isMisconfigured({ value: false, desiredValue: true, configure: () => {} })).to.be.true
expect(isMisconfigured({ value: null, desiredValue: null, configure: () => {} })).to.be.true
expect(isMisconfigured({ value: 0, desiredValue: 0, configure: () => {} })).to.be.true
})

it("should return false if value is Configured", () => {
expect(isMisconfigured({ value: false })).to.be.false
expect(isMisconfigured({ value: true })).to.be.false
expect(isMisconfigured({ value: 1 })).to.be.false
})
})

describe("isConfigured", () => {
it("should return false if value is Configured", () => {
expect(isConfigured({ value: false, desiredValue: true, configure: () => {} })).to.be.false
expect(isConfigured({ value: null, desiredValue: null, configure: () => {} })).to.be.false
expect(isConfigured({ value: 0, desiredValue: 0, configure: () => {} })).to.be.false
})

it("should return true if value is Misconfigured", () => {
expect(isConfigured({ value: false })).to.be.true
expect(isConfigured({ value: true })).to.be.true
expect(isConfigured({ value: 1 })).to.be.true
})
})

describe("createProperty", () => {
it("should return Configured if the current and desired values match", async () => {
const currentValue = [1, "two", { three: true }]
const desiredValue = [1, "two", { three: true }]

const property = createProperty({
desired: async () => desiredValue,
get: () => currentValue,
set: () => {},
})

expect(isMisconfigured(await property())).to.be.false
})

it("should return Misconfigured if the current and desired don't match", async () => {
const currentValue = [1, "two", { three: true }]
const desiredValue = [1, "two", { three: false }]

const property = createProperty({
desired: async () => desiredValue,
get: () => currentValue,
set: () => {},
})

expect(isMisconfigured(await property())).to.be.true
})

it("should call the setter with desired value when executed", async () => {
const currentValue = [1, "two", { three: true }]
const desiredValue = [1, "two", { three: false }]

const set = sinon.spy()
const property = createProperty({
desired: () => desiredValue,
get: () => currentValue,
set,
})

const state = await property()
await state.configure?.()

expect(set.calledOnceWith(desiredValue)).to.be.true
})

it("should call the setter with context when executed", async () => {
const currentValue = [1, "two", { three: true }]
const desiredValue = [1, "two", { three: false }]

const set = sinon.spy()
const property = createProperty({
desired: (context: string) => desiredValue,
get: (context: string) => currentValue,
set,
})

const state = await property("context")
await state.configure?.()

expect(set.calledOnceWith("context", desiredValue)).to.be.true
})
})
})
Loading

0 comments on commit 69a6e07

Please sign in to comment.