Add browsers API (#1523)

Add browsers API
This commit is contained in:
Dmitry Shulyak 2019-07-17 08:28:37 +03:00 committed by GitHub
parent 40b6b3da13
commit 9dbf5a0c86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 691 additions and 31 deletions

View File

@ -486,6 +486,20 @@ func (b *StatusBackend) Logout() error {
return err
}
}
if b.statusNode.Config().BrowsersConfig.Enabled {
svc, err := b.statusNode.BrowsersService()
switch err {
case node.ErrServiceUnknown:
case nil:
err = svc.StopDatabase()
if err != nil {
return err
}
default:
return err
}
}
b.AccountManager().Logout()
return nil
@ -553,7 +567,11 @@ func (b *StatusBackend) SelectAccount(walletAddress, chatAddress, password strin
return err
}
}
return b.startWallet(password)
err = b.startWallet(password)
if err != nil {
return err
}
return b.startBrowsers(password)
}
func (b *StatusBackend) startWallet(password string) error {
@ -575,6 +593,22 @@ func (b *StatusBackend) startWallet(password string) error {
new(big.Int).SetUint64(b.statusNode.Config().NetworkID))
}
func (b *StatusBackend) startBrowsers(password string) error {
if !b.statusNode.Config().BrowsersConfig.Enabled {
return nil
}
svc, err := b.statusNode.BrowsersService()
if err != nil {
return err
}
account, err := b.accountManager.SelectedWalletAccount()
if err != nil {
return err
}
path := path.Join(b.statusNode.Config().DataDir, fmt.Sprintf("browsers-%x.sql", account.Address))
return svc.StartDatabase(path, password)
}
// SendDataNotification sends data push notifications to users.
// dataPayloadJSON is a JSON string that looks like this:
// {

View File

@ -24,6 +24,7 @@ import (
"github.com/ethereum/go-ethereum/p2p/nat"
"github.com/status-im/status-go/mailserver"
"github.com/status-im/status-go/params"
"github.com/status-im/status-go/services/browsers"
"github.com/status-im/status-go/services/incentivisation"
"github.com/status-im/status-go/services/peer"
"github.com/status-im/status-go/services/personal"
@ -47,6 +48,7 @@ var (
ErrPeerServiceRegistrationFailure = errors.New("failed to register the Peer service")
ErrIncentivisationServiceRegistrationFailure = errors.New("failed to register the Incentivisation service")
ErrWalletServiceRegistrationFailure = errors.New("failed to register the Wallet service")
ErrBrowsersServiceRegistrationFailure = errors.New("failed to register the Browsers service")
)
// All general log messages in this package should be routed through this logger.
@ -124,6 +126,10 @@ func MakeNode(config *params.NodeConfig, db *leveldb.DB) (*node.Node, error) {
return nil, fmt.Errorf("%v: %v", ErrWalletServiceRegistrationFailure, err)
}
if err := activateBrowsersService(stack, config.BrowsersConfig); err != nil {
return nil, fmt.Errorf("%v: %v", ErrBrowsersServiceRegistrationFailure, err)
}
return stack, nil
}
@ -277,7 +283,7 @@ func activatePeerService(stack *node.Node) error {
func activateWalletService(stack *node.Node, config params.WalletConfig) error {
if !config.Enabled {
logger.Info("service.Wallet is disabled")
logger.Info("wallet service is disabled")
return nil
}
return stack.Register(func(*node.ServiceContext) (node.Service, error) {
@ -285,6 +291,16 @@ func activateWalletService(stack *node.Node, config params.WalletConfig) error {
})
}
func activateBrowsersService(stack *node.Node, config params.BrowsersConfig) error {
if !config.Enabled {
logger.Info("browsers service is disabled")
return nil
}
return stack.Register(func(*node.ServiceContext) (node.Service, error) {
return browsers.NewService(), nil
})
}
func registerMailServer(whisperService *whisper.Whisper, config *params.WhisperConfig) (err error) {
var mailServer mailserver.WMailServer
whisperService.RegisterServer(&mailServer)

View File

@ -29,6 +29,7 @@ import (
"github.com/status-im/status-go/params"
"github.com/status-im/status-go/peers"
"github.com/status-im/status-go/rpc"
"github.com/status-im/status-go/services/browsers"
"github.com/status-im/status-go/services/peer"
"github.com/status-im/status-go/services/shhext"
"github.com/status-im/status-go/services/status"
@ -591,7 +592,7 @@ func (n *StatusNode) ShhExtService() (s *shhext.Service, err error) {
return
}
// WalletService returns wallet.Service instance if it is started.
// WalletService returns wallet.Service instance if it was started.
func (n *StatusNode) WalletService() (s *wallet.Service, err error) {
n.mu.RLock()
defer n.mu.RUnlock()
@ -602,6 +603,17 @@ func (n *StatusNode) WalletService() (s *wallet.Service, err error) {
return
}
// BrowsersService returns browsers.Service instance if it was started.
func (n *StatusNode) BrowsersService() (s *browsers.Service, err error) {
n.mu.RLock()
defer n.mu.RUnlock()
err = n.gethService(&s)
if err == node.ErrServiceUnknown {
err = ErrServiceUnknown
}
return
}
// AccountManager exposes reference to node's accounts manager
func (n *StatusNode) AccountManager() (*accounts.Manager, error) {
n.mu.RLock()

View File

@ -346,6 +346,9 @@ type NodeConfig struct {
// WalletConfig extra configuration for wallet.Service.
WalletConfig WalletConfig
// BrowsersConfig extra configuration for browsers.Service.
BrowsersConfig BrowsersConfig
// SwarmConfig extra configuration for Swarm and ENS
SwarmConfig SwarmConfig `json:"SwarmConfig," validate:"structonly"`
@ -366,6 +369,11 @@ type WalletConfig struct {
Enabled bool
}
// BrowsersConfig extra configuration for browsers.Service.
type BrowsersConfig struct {
Enabled bool
}
// ShhextConfig defines options used by shhext service.
type ShhextConfig struct {
PFSEnabled bool

View File

@ -0,0 +1,48 @@
Browsers Service
================
Browser service provides read/write API for browser object.
To enable include browsers config part and add `browsers` to APIModules:
```json
{
"BrowsersConfig": {
"Enabled": true,
},
APIModules: "browsers"
}
```
API
---
Enabling service will expose three additional methods:
#### browsers_addBrowser
Stores browser in the database.
All fields are specified below:
```json
{
"browser-id": "1",
"name": "first",
"timestamp": 10,
"dapp?": true,
"history-index": 1,
"history": [
"one",
"two"
]
}
```
#### browsers_getBrowsers
Reads all browsers, returns in the format specified above. List is sorted by timestamp.
#### browsers_deleteBrowser
Delete browser from database. Accepts browser `id`.

41
services/browsers/api.go Normal file
View File

@ -0,0 +1,41 @@
package browsers
import (
"context"
"errors"
)
var (
// ErrServiceNotInitialized returned when wallet is not initialized/started,.
ErrServiceNotInitialized = errors.New("browsers service is not initialized")
)
func NewAPI(s *Service) *API {
return &API{s}
}
// API is class with methods available over RPC.
type API struct {
s *Service
}
func (api *API) AddBrowser(ctx context.Context, browser Browser) error {
if api.s.db == nil {
return ErrServiceNotInitialized
}
return api.s.db.InsertBrowser(browser)
}
func (api *API) GetBrowsers(ctx context.Context) ([]*Browser, error) {
if api.s.db == nil {
return nil, ErrServiceNotInitialized
}
return api.s.db.GetBrowsers()
}
func (api *API) DeleteBrowser(ctx context.Context, id string) error {
if api.s.db == nil {
return ErrServiceNotInitialized
}
return api.s.db.DeleteBrowser(id)
}

View File

@ -0,0 +1,128 @@
package browsers
import (
"context"
"io/ioutil"
"os"
"sort"
"testing"
"github.com/stretchr/testify/require"
)
func setupTestDB(t *testing.T) (*Database, func()) {
tmpfile, err := ioutil.TempFile("", "browsers-tests-")
require.NoError(t, err)
db, err := InitializeDB(tmpfile.Name(), "browsers-tests")
require.NoError(t, err)
return db, func() {
require.NoError(t, db.Close())
require.NoError(t, os.Remove(tmpfile.Name()))
}
}
func setupTestAPI(t *testing.T) (*API, func()) {
db, cancel := setupTestDB(t)
return &API{s: &Service{db: db}}, cancel
}
func TestBrowsersOrderedNewestFirst(t *testing.T) {
api, cancel := setupTestAPI(t)
defer cancel()
browsers := []*Browser{
{
ID: "1",
Name: "first",
Dapp: true,
Timestamp: 10,
},
{
ID: "2",
Name: "second",
Dapp: true,
Timestamp: 50,
},
{
ID: "3",
Name: "third",
Dapp: true,
Timestamp: 100,
},
}
for i := 0; i < len(browsers); i++ {
require.NoError(t, api.AddBrowser(context.TODO(), *browsers[i]))
}
sort.Slice(browsers, func(i, j int) bool {
return browsers[i].Timestamp > browsers[j].Timestamp
})
rst, err := api.GetBrowsers(context.TODO())
require.NoError(t, err)
require.Equal(t, browsers, rst)
}
func TestBrowsersHistoryIncluded(t *testing.T) {
api, cancel := setupTestAPI(t)
defer cancel()
browser := &Browser{
ID: "1",
Name: "first",
Dapp: true,
Timestamp: 10,
HistoryIndex: 1,
History: []string{"one", "two"},
}
require.NoError(t, api.AddBrowser(context.TODO(), *browser))
rst, err := api.GetBrowsers(context.TODO())
require.NoError(t, err)
require.Len(t, rst, 1)
require.Equal(t, browser, rst[0])
}
func TestBrowsersReplaceOnUpdate(t *testing.T) {
api, cancel := setupTestAPI(t)
defer cancel()
browser := &Browser{
ID: "1",
Name: "first",
Dapp: true,
Timestamp: 10,
History: []string{"one", "two"},
}
require.NoError(t, api.AddBrowser(context.TODO(), *browser))
browser.Dapp = false
browser.History = []string{"one", "three"}
browser.Timestamp = 107
require.NoError(t, api.AddBrowser(context.TODO(), *browser))
rst, err := api.GetBrowsers(context.TODO())
require.NoError(t, err)
require.Len(t, rst, 1)
require.Equal(t, browser, rst[0])
}
func TestDeleteBrowser(t *testing.T) {
api, cancel := setupTestAPI(t)
defer cancel()
browser := &Browser{
ID: "1",
Name: "first",
Dapp: true,
Timestamp: 10,
History: []string{"one", "two"},
}
require.NoError(t, api.AddBrowser(context.TODO(), *browser))
rst, err := api.GetBrowsers(context.TODO())
require.NoError(t, err)
require.Len(t, rst, 1)
require.NoError(t, api.DeleteBrowser(context.TODO(), browser.ID))
rst, err = api.GetBrowsers(context.TODO())
require.NoError(t, err)
require.Len(t, rst, 0)
}

View File

@ -0,0 +1,136 @@
package browsers
import (
"database/sql"
"github.com/status-im/status-go/services/browsers/migrations"
"github.com/status-im/status-go/sqlite"
)
// Database sql wrapper for operations with browser objects.
type Database struct {
db *sql.DB
}
// Close closes database.
func (db Database) Close() error {
return db.db.Close()
}
// InitializeDB creates db file at a given path and applies migrations.
func InitializeDB(path, password string) (*Database, error) {
db, err := sqlite.OpenDB(path, password)
if err != nil {
return nil, err
}
err = migrations.Migrate(db)
if err != nil {
return nil, err
}
return &Database{db: db}, nil
}
type Browser struct {
ID string `json:"browser-id"`
Name string `json:"name"`
Timestamp uint64 `json:"timestamp"`
Dapp bool `json:"dapp?"`
HistoryIndex int `json:"history-index,omitempty"`
History []string `json:"history,omitempty"`
}
func (db *Database) InsertBrowser(browser Browser) (err error) {
var (
tx *sql.Tx
insert *sql.Stmt
)
tx, err = db.db.Begin()
if err != nil {
return
}
defer func() {
if err == nil {
err = tx.Commit()
return
}
_ = tx.Rollback()
}()
insert, err = tx.Prepare("INSERT OR REPLACE INTO browsers(id, name, timestamp, dapp, historyIndex) VALUES(?, ?, ?, ?, ?)")
if err != nil {
return
}
_, err = insert.Exec(browser.ID, browser.Name, browser.Timestamp, browser.Dapp, browser.HistoryIndex)
insert.Close()
if err != nil {
return
}
if len(browser.History) == 0 {
return
}
insert, err = tx.Prepare("INSERT INTO browsers_history(browser_id, history) VALUES(?, ?)")
if err != nil {
return
}
defer insert.Close()
for _, history := range browser.History {
_, err = insert.Exec(browser.ID, history)
if err != nil {
return
}
}
return
}
func (db *Database) GetBrowsers() (rst []*Browser, err error) {
var (
tx *sql.Tx
rows *sql.Rows
)
tx, err = db.db.Begin()
if err != nil {
return
}
defer func() {
if err == nil {
err = tx.Commit()
return
}
_ = tx.Rollback()
}()
// FULL and RIGHT joins are not supported
rows, err = tx.Query("SELECT id, name, timestamp, dapp, historyIndex FROM browsers ORDER BY timestamp DESC")
if err != nil {
return
}
browsers := map[string]*Browser{}
for rows.Next() {
browser := Browser{}
err = rows.Scan(&browser.ID, &browser.Name, &browser.Timestamp, &browser.Dapp, &browser.HistoryIndex)
if err != nil {
return nil, err
}
browsers[browser.ID] = &browser
rst = append(rst, &browser)
}
rows, err = tx.Query("SELECT browser_id, history from browsers_history")
if err != nil {
return
}
var (
id string
history string
)
for rows.Next() {
err = rows.Scan(&id, &history)
if err != nil {
return
}
browsers[id].History = append(browsers[id].History, history)
}
return rst, nil
}
func (db *Database) DeleteBrowser(id string) error {
_, err := db.db.Exec("DELETE from browsers WHERE id = ?", id)
return err
}

View File

@ -0,0 +1,127 @@
package migrations
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"strings"
)
func bindata_read(data []byte, name string) ([]byte, error) {
gz, err := gzip.NewReader(bytes.NewBuffer(data))
if err != nil {
return nil, fmt.Errorf("Read %q: %v", name, err)
}
var buf bytes.Buffer
_, err = io.Copy(&buf, gz)
gz.Close()
if err != nil {
return nil, fmt.Errorf("Read %q: %v", name, err)
}
return buf.Bytes(), nil
}
var __0001_browsers_down_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x09\xf2\x0f\x50\x08\x71\x74\xf2\x71\x55\x48\x2a\xca\x2f\x2f\x4e\x2d\x2a\xb6\xe6\xc2\x22\x18\x9f\x91\x59\x5c\x92\x5f\x54\x69\xcd\x05\x08\x00\x00\xff\xff\x5b\xe2\x78\xbe\x32\x00\x00\x00")
func _0001_browsers_down_sql() ([]byte, error) {
return bindata_read(
__0001_browsers_down_sql,
"0001_browsers.down.sql",
)
}
var __0001_browsers_up_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x8c\x8f\xc1\x6a\x83\x40\x10\x86\xef\xf3\x14\xff\x51\xc1\x37\xe8\x69\xd5\xd1\x0e\xdd\xee\x96\x75\x24\xc9\x29\x58\xb4\x54\xa8\x89\xa8\xd0\xf6\xed\xcb\x92\x04\xaf\xbd\x7e\xcc\x37\xfc\x5f\x11\xd8\x28\x43\x4d\x6e\x19\x52\xc1\x79\x05\x1f\xa5\xd1\x06\xef\xcb\xf5\x7b\x1d\x96\x15\x09\x8d\x3d\x94\x8f\x8a\xb7\x20\xaf\x26\x9c\xf0\xc2\xa7\x8c\x2e\xdd\x34\xdc\x70\x94\x5c\x6b\x6d\x46\xdb\x38\x0d\xeb\xd6\x4d\x33\xda\xa6\x96\xda\x71\x89\x5c\x6a\x71\x9a\x51\xdf\xcd\x33\x72\xef\x2d\x1b\x87\x92\x2b\xd3\x5a\xc5\x47\xf7\xb5\x0e\x19\x7d\x8e\xeb\x76\x5d\x7e\xe5\xd2\x0f\x3f\x68\x5d\x73\x33\xc5\x29\xa5\x38\x88\x3e\xfb\x56\x11\xfc\x41\xca\x27\xa2\x7f\x2c\x3e\xdf\xff\x21\xa1\x3b\x3a\x3f\x0a\xf6\xa9\x8f\x9b\x88\x33\xaa\x7c\x60\xa9\x5d\x2c\x4b\x76\x27\x45\xe0\x8a\x03\xbb\x82\xf7\xef\x49\xe4\x3e\x36\x58\x56\x46\x61\x9a\xc2\x94\x4c\x29\xfd\x05\x00\x00\xff\xff\x69\xa0\xeb\xdb\x4c\x01\x00\x00")
func _0001_browsers_up_sql() ([]byte, error) {
return bindata_read(
__0001_browsers_up_sql,
"0001_browsers.up.sql",
)
}
var _doc_go = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x2c\xc9\xb1\x0d\xc4\x20\x0c\x05\xd0\x9e\x29\xfe\x02\xd8\xfd\x6d\xe3\x4b\xac\x2f\x44\x82\x09\x78\x7f\xa5\x49\xfd\xa6\x1d\xdd\xe8\xd8\xcf\x55\x8a\x2a\xe3\x47\x1f\xbe\x2c\x1d\x8c\xfa\x6f\xe3\xb4\x34\xd4\xd9\x89\xbb\x71\x59\xb6\x18\x1b\x35\x20\xa2\x9f\x0a\x03\xa2\xe5\x0d\x00\x00\xff\xff\x60\xcd\x06\xbe\x4a\x00\x00\x00")
func doc_go() ([]byte, error) {
return bindata_read(
_doc_go,
"doc.go",
)
}
// Asset loads and returns the asset for the given name.
// It returns an error if the asset could not be found or
// could not be loaded.
func Asset(name string) ([]byte, error) {
cannonicalName := strings.Replace(name, "\\", "/", -1)
if f, ok := _bindata[cannonicalName]; ok {
return f()
}
return nil, fmt.Errorf("Asset %s not found", name)
}
// AssetNames returns the names of the assets.
func AssetNames() []string {
names := make([]string, 0, len(_bindata))
for name := range _bindata {
names = append(names, name)
}
return names
}
// _bindata is a table, holding each asset generator, mapped to its name.
var _bindata = map[string]func() ([]byte, error){
"0001_browsers.down.sql": _0001_browsers_down_sql,
"0001_browsers.up.sql": _0001_browsers_up_sql,
"doc.go": doc_go,
}
// AssetDir returns the file names below a certain
// directory embedded in the file by go-bindata.
// For example if you run go-bindata on data/... and data contains the
// following hierarchy:
// data/
// foo.txt
// img/
// a.png
// b.png
// then AssetDir("data") would return []string{"foo.txt", "img"}
// AssetDir("data/img") would return []string{"a.png", "b.png"}
// AssetDir("foo.txt") and AssetDir("notexist") would return an error
// AssetDir("") will return []string{"data"}.
func AssetDir(name string) ([]string, error) {
node := _bintree
if len(name) != 0 {
cannonicalName := strings.Replace(name, "\\", "/", -1)
pathList := strings.Split(cannonicalName, "/")
for _, p := range pathList {
node = node.Children[p]
if node == nil {
return nil, fmt.Errorf("Asset %s not found", name)
}
}
}
if node.Func != nil {
return nil, fmt.Errorf("Asset %s not found", name)
}
rv := make([]string, 0, len(node.Children))
for name := range node.Children {
rv = append(rv, name)
}
return rv, nil
}
type _bintree_t struct {
Func func() ([]byte, error)
Children map[string]*_bintree_t
}
var _bintree = &_bintree_t{nil, map[string]*_bintree_t{
"0001_browsers.down.sql": &_bintree_t{_0001_browsers_down_sql, map[string]*_bintree_t{
}},
"0001_browsers.up.sql": &_bintree_t{_0001_browsers_up_sql, map[string]*_bintree_t{
}},
"doc.go": &_bintree_t{doc_go, map[string]*_bintree_t{
}},
}}

View File

@ -0,0 +1,18 @@
package migrations
import (
"database/sql"
bindata "github.com/status-im/migrate/v4/source/go_bindata"
"github.com/status-im/status-go/sqlite"
)
// Migrate applies migrations.
func Migrate(db *sql.DB) error {
return sqlite.Migrate(db, bindata.Resource(
AssetNames(),
func(name string) ([]byte, error) {
return Asset(name)
},
))
}

View File

@ -0,0 +1,2 @@
DROP TABLE browsers;
DROP TABLE browsers_history;

View File

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS browsers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
timestamp USGIGNED BIGINT,
dapp BOOLEAN DEFAULT false,
historyIndex UNSIGNED INT
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS browsers_history (
browser_id TEXT NOT NULL,
history TEXT,
FOREIGN KEY(browser_id) REFERENCES browsers(id) ON DELETE CASCADE
)

View File

@ -0,0 +1,3 @@
package sql
//go:generate go-bindata -pkg migrations -o ../bindata.go ./

View File

@ -0,0 +1,63 @@
package browsers
import (
"sync"
"github.com/ethereum/go-ethereum/p2p"
"github.com/ethereum/go-ethereum/rpc"
)
// NewService initializes service instance.
func NewService() *Service {
return &Service{}
}
// Service is a browsers service.
type Service struct {
mu sync.Mutex
db *Database
}
// Start a service.
func (s *Service) Start(*p2p.Server) error {
return nil
}
// Start database after dbpath and password will become known.
func (s *Service) StartDatabase(dbpath, password string) (err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.db, err = InitializeDB(dbpath, password)
return err
}
func (s *Service) StopDatabase() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.db != nil {
return s.db.Close()
}
return nil
}
// Stop a service.
func (s *Service) Stop() error {
return s.StopDatabase()
}
// APIs returns list of available RPC APIs.
func (s *Service) APIs() []rpc.API {
return []rpc.API{
{
Namespace: "browsers",
Version: "0.1.0",
Service: NewAPI(s),
Public: true,
},
}
}
// Protocols returns list of p2p protocols.
func (s *Service) Protocols() []p2p.Protocol {
return nil
}

View File

@ -3,41 +3,16 @@ package migrations
import (
"database/sql"
"github.com/status-im/migrate/v4"
"github.com/status-im/migrate/v4/database/sqlcipher"
bindata "github.com/status-im/migrate/v4/source/go_bindata"
"github.com/status-im/status-go/sqlite"
)
// Migrate applies migrations.
func Migrate(db *sql.DB) error {
resources := bindata.Resource(
return sqlite.Migrate(db, bindata.Resource(
AssetNames(),
func(name string) ([]byte, error) {
return Asset(name)
},
)
source, err := bindata.WithInstance(resources)
if err != nil {
return err
}
driver, err := sqlcipher.WithInstance(db, &sqlcipher.Config{})
if err != nil {
return err
}
m, err := migrate.NewWithInstance(
"go-bindata",
source,
"sqlcipher",
driver)
if err != nil {
return err
}
if err = m.Up(); err != migrate.ErrNoChange {
return err
}
return nil
))
}

36
sqlite/migrate.go Normal file
View File

@ -0,0 +1,36 @@
package sqlite
import (
"database/sql"
"github.com/status-im/migrate/v4"
"github.com/status-im/migrate/v4/database/sqlcipher"
bindata "github.com/status-im/migrate/v4/source/go_bindata"
)
// Migrate database using provided resources.
func Migrate(db *sql.DB, resources *bindata.AssetSource) error {
source, err := bindata.WithInstance(resources)
if err != nil {
return err
}
driver, err := sqlcipher.WithInstance(db, &sqlcipher.Config{})
if err != nil {
return err
}
m, err := migrate.NewWithInstance(
"go-bindata",
source,
"sqlcipher",
driver)
if err != nil {
return err
}
if err = m.Up(); err != migrate.ErrNoChange {
return err
}
return nil
}