diff --git a/.changeset/add_support_for_rhp4_accounts.md b/.changeset/add_support_for_rhp4_accounts.md new file mode 100644 index 00000000..dde3ef98 --- /dev/null +++ b/.changeset/add_support_for_rhp4_accounts.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# Add support for RHP4 accounts diff --git a/host/contracts/accounts.go b/host/contracts/accounts.go new file mode 100644 index 00000000..d5999e04 --- /dev/null +++ b/host/contracts/accounts.go @@ -0,0 +1,22 @@ +package contracts + +import ( + proto4 "go.sia.tech/core/rhp/v4" + "go.sia.tech/core/types" +) + +// AccountBalance returns the balance of an account. +func (cm *Manager) AccountBalance(account proto4.Account) (types.Currency, error) { + return cm.store.RHP4AccountBalance(account) +} + +// CreditAccountsWithContract atomically revises a contract and credits the accounts +// returning the new balance of each account. +func (cm *Manager) CreditAccountsWithContract(deposits []proto4.AccountDeposit, contractID types.FileContractID, revision types.V2FileContract) ([]types.Currency, error) { + return cm.store.RHP4CreditAccounts(deposits, contractID, revision) +} + +// DebitAccount debits an account. +func (cm *Manager) DebitAccount(account proto4.Account, usage proto4.Usage) error { + return cm.store.RHP4DebitAccount(account, usage) +} diff --git a/host/contracts/persist.go b/host/contracts/persist.go index 13faacb7..590f602f 100644 --- a/host/contracts/persist.go +++ b/host/contracts/persist.go @@ -55,5 +55,12 @@ type ( // ExpireV2ContractSectors removes sector roots for any v2 contracts that are // rejected or past their proof window. ExpireV2ContractSectors(height uint64) error + + // RHP4AccountBalance returns the balance of an account. + RHP4AccountBalance(proto4.Account) (types.Currency, error) + // RHP4CreditAccounts atomically revises a contract and credits the accounts + RHP4CreditAccounts([]proto4.AccountDeposit, types.FileContractID, types.V2FileContract) (balances []types.Currency, err error) + // RHP4DebitAccount debits an account. + RHP4DebitAccount(proto4.Account, proto4.Usage) error } ) diff --git a/persist/sqlite/accounts.go b/persist/sqlite/accounts.go index 0a3e354c..971fcdd5 100644 --- a/persist/sqlite/accounts.go +++ b/persist/sqlite/accounts.go @@ -7,12 +7,128 @@ import ( "time" rhp3 "go.sia.tech/core/rhp/v3" + proto4 "go.sia.tech/core/rhp/v4" "go.sia.tech/core/types" "go.sia.tech/hostd/host/accounts" "go.sia.tech/hostd/host/contracts" "go.uber.org/zap" ) +// RHP4AccountBalance returns the balance of the account with the given ID. +func (s *Store) RHP4AccountBalance(account proto4.Account) (balance types.Currency, err error) { + err = s.transaction(func(tx *txn) error { + return tx.QueryRow(`SELECT balance FROM accounts WHERE account_id=$1`, encode(account)).Scan(decode(&balance)) + }) + return +} + +// RHP4DebitAccount debits the account with the given ID. +func (s *Store) RHP4DebitAccount(account proto4.Account, usage proto4.Usage) error { + return s.transaction(func(tx *txn) error { + var dbID int64 + var balance types.Currency + err := tx.QueryRow(`SELECT id, balance FROM accounts WHERE account_id=$1`, encode(account)).Scan(&dbID, decode(&balance)) + if err != nil { + return fmt.Errorf("failed to query balance: %w", err) + } + + total := usage.RenterCost() + balance, underflow := balance.SubWithUnderflow(total) + if underflow { + return fmt.Errorf("insufficient balance") + } + + _, err = tx.Exec(`UPDATE accounts SET balance=$1 WHERE id=$2`, encode(balance), dbID) + if err != nil { + return fmt.Errorf("failed to update balance: %w", err) + } else if err := updateV2ContractFunding(tx, dbID, usage); err != nil { + return fmt.Errorf("failed to update contract funding: %w", err) + } + return nil + }) +} + +// RHP4CreditAccounts credits the accounts with the given deposits and revises +// the contract. +func (s *Store) RHP4CreditAccounts(deposits []proto4.AccountDeposit, contractID types.FileContractID, revision types.V2FileContract) (balances []types.Currency, err error) { + err = s.transaction(func(tx *txn) error { + getBalanceStmt, err := tx.Prepare(`SELECT balance FROM accounts WHERE account_id=$1`) + if err != nil { + return fmt.Errorf("failed to prepare get balance statement: %w", err) + } + defer getBalanceStmt.Close() + + updateBalanceStmt, err := tx.Prepare(`INSERT INTO accounts (account_id, balance, expiration_timestamp) VALUES ($1, $2, $3) ON CONFLICT (account_id) DO UPDATE SET balance=EXCLUDED.balance, expiration_timestamp=EXCLUDED.expiration_timestamp RETURNING id`) + if err != nil { + return fmt.Errorf("failed to prepare update balance statement: %w", err) + } + defer updateBalanceStmt.Close() + + getFundingAmountStmt, err := tx.Prepare(`SELECT amount FROM contract_v2_account_funding WHERE contract_id=$1 AND account_id=$2`) + if err != nil { + return fmt.Errorf("failed to prepare get funding amount statement: %w", err) + } + defer getFundingAmountStmt.Close() + + updateFundingAmountStmt, err := tx.Prepare(`INSERT INTO contract_v2_account_funding (contract_id, account_id, amount) VALUES ($1, $2, $3) ON CONFLICT (contract_id, account_id) DO UPDATE SET amount=EXCLUDED.amount`) + if err != nil { + return fmt.Errorf("failed to prepare update funding amount statement: %w", err) + } + defer updateFundingAmountStmt.Close() + + var contractDBID int64 + err = tx.QueryRow(`SELECT id FROM contracts_v2 WHERE contract_id=$1`, encode(contractID)).Scan(&contractDBID) + if err != nil { + return fmt.Errorf("failed to get contract ID: %w", err) + } + + var usage proto4.Usage + var createdAccounts int + for _, deposit := range deposits { + var balance types.Currency + err := getBalanceStmt.QueryRow(encode(deposit.Account)).Scan(decode(&balance)) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("failed to get balance: %w", err) + } else if errors.Is(err, sql.ErrNoRows) { + createdAccounts++ + } + + balance = balance.Add(deposit.Amount) + + var accountDBID int64 + err = updateBalanceStmt.QueryRow(encode(deposit.Account), encode(balance), encode(time.Now().Add(90*24*time.Hour))).Scan(&accountDBID) + if err != nil { + return fmt.Errorf("failed to update balance: %w", err) + } + balances = append(balances, balance) + + var fundAmount types.Currency + if err := getFundingAmountStmt.QueryRow(contractDBID, accountDBID).Scan(decode(&fundAmount)); err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("failed to get funding amount: %w", err) + } + fundAmount = fundAmount.Add(deposit.Amount) + if _, err := updateFundingAmountStmt.Exec(contractDBID, accountDBID, encode(fundAmount)); err != nil { + return fmt.Errorf("failed to update funding amount: %w", err) + } + usage.AccountFunding = usage.AccountFunding.Add(deposit.Amount) + } + + _, err = reviseV2Contract(tx, contractID, revision, usage) + if err != nil { + return fmt.Errorf("failed to revise contract: %w", err) + } + + if err := incrementCurrencyStat(tx, metricAccountBalance, usage.AccountFunding, false, time.Now()); err != nil { + return fmt.Errorf("failed to increment balance metric: %w", err) + } else if err := incrementNumericStat(tx, metricActiveAccounts, createdAccounts, time.Now()); err != nil { + return fmt.Errorf("failed to increment active accounts metric: %w", err) + } + + return nil + }) + return +} + // AccountBalance returns the balance of the account with the given ID. func (s *Store) AccountBalance(accountID rhp3.Account) (balance types.Currency, err error) { err = s.transaction(func(tx *txn) error { @@ -26,20 +142,6 @@ func (s *Store) AccountBalance(accountID rhp3.Account) (balance types.Currency, return } -func incrementContractAccountFunding(tx *txn, accountID, contractID int64, amount types.Currency) error { - var fundingValue types.Currency - err := tx.QueryRow(`SELECT amount FROM contract_account_funding WHERE contract_id=$1 AND account_id=$2`, contractID, accountID).Scan(decode(&fundingValue)) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("failed to get fund amount: %w", err) - } - fundingValue = fundingValue.Add(amount) - _, err = tx.Exec(`INSERT INTO contract_account_funding (contract_id, account_id, amount) VALUES ($1, $2, $3) ON CONFLICT (contract_id, account_id) DO UPDATE SET amount=EXCLUDED.amount`, contractID, accountID, encode(fundingValue)) - if err != nil { - return fmt.Errorf("failed to update funding source: %w", err) - } - return nil -} - // CreditAccountWithContract adds the specified amount to the account with the given ID. func (s *Store) CreditAccountWithContract(fund accounts.FundAccountWithContract) error { return s.transaction(func(tx *txn) error { @@ -181,6 +283,20 @@ func (s *Store) PruneAccounts(height uint64) error { }) } +func incrementContractAccountFunding(tx *txn, accountID, contractID int64, amount types.Currency) error { + var fundingValue types.Currency + err := tx.QueryRow(`SELECT amount FROM contract_account_funding WHERE contract_id=$1 AND account_id=$2`, contractID, accountID).Scan(decode(&fundingValue)) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("failed to get fund amount: %w", err) + } + fundingValue = fundingValue.Add(amount) + _, err = tx.Exec(`INSERT INTO contract_account_funding (contract_id, account_id, amount) VALUES ($1, $2, $3) ON CONFLICT (contract_id, account_id) DO UPDATE SET amount=EXCLUDED.amount`, contractID, accountID, encode(fundingValue)) + if err != nil { + return fmt.Errorf("failed to update funding source: %w", err) + } + return nil +} + func accountBalance(tx *txn, accountID rhp3.Account) (dbID int64, balance types.Currency, err error) { err = tx.QueryRow(`SELECT id, balance FROM accounts WHERE account_id=$1`, encode(accountID)).Scan(&dbID, decode(&balance)) return @@ -192,6 +308,26 @@ type fundAmount struct { Amount types.Currency } +// contractV2Funding returns all contracts that were used to fund the account. +func contractV2Funding(tx *txn, accountID int64) (fund []fundAmount, err error) { + rows, err := tx.Query(`SELECT id, contract_id, amount FROM contract_v2_account_funding WHERE account_id=$1`, accountID) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var f fundAmount + if err := rows.Scan(&f.ID, &f.ContractID, decode(&f.Amount)); err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } else if f.Amount.IsZero() { + continue + } + fund = append(fund, f) + } + return +} + // contractFunding returns all contracts that were used to fund the account. func contractFunding(tx *txn, accountID int64) (fund []fundAmount, err error) { rows, err := tx.Query(`SELECT id, contract_id, amount FROM contract_account_funding WHERE account_id=$1`, accountID) @@ -212,6 +348,65 @@ func contractFunding(tx *txn, accountID int64) (fund []fundAmount, err error) { return } +// updateV2ContractFunding distributes account usage to the contracts that funded +// the account. +func updateV2ContractFunding(tx *txn, accountID int64, usage proto4.Usage) error { + funding, err := contractV2Funding(tx, accountID) + if err != nil { + return fmt.Errorf("failed to get contract funding: %w", err) + } + + distributeFunds := func(usage, additional, remainder *types.Currency) { + if remainder.IsZero() || usage.IsZero() { + return + } + + v := *usage + if usage.Cmp(*remainder) > 0 { + v = *remainder + } + *usage = usage.Sub(v) + *remainder = remainder.Sub(v) + *additional = additional.Add(v) + } + + // distribute account usage to the funding contracts + for _, f := range funding { + remainder := f.Amount + + var additionalUsage proto4.Usage + distributeFunds(&usage.Storage, &additionalUsage.Storage, &remainder) + distributeFunds(&usage.Ingress, &additionalUsage.Ingress, &remainder) + distributeFunds(&usage.Egress, &additionalUsage.Egress, &remainder) + distributeFunds(&usage.RPC, &additionalUsage.RPC, &remainder) + + if remainder.IsZero() { + if _, err := tx.Exec(`DELETE FROM contract_v2_account_funding WHERE id=$1`, f.ID); err != nil { + return fmt.Errorf("failed to delete account funding: %w", err) + } + } else { + _, err := tx.Exec(`UPDATE contract_v2_account_funding SET amount=$1 WHERE id=$2`, encode(remainder), f.ID) + if err != nil { + return fmt.Errorf("failed to update account funding: %w", err) + } + } + + var contractExistingFunding types.Currency + if err := tx.QueryRow(`SELECT account_funding FROM contracts_v2 WHERE id=$1`, f.ContractID).Scan(decode(&contractExistingFunding)); err != nil { + return fmt.Errorf("failed to get contract usage: %w", err) + } + contractExistingFunding = contractExistingFunding.Sub(f.Amount.Sub(remainder)) + if _, err := tx.Exec(`UPDATE contracts_v2 SET account_funding=$1 WHERE id=$2`, encode(contractExistingFunding), f.ContractID); err != nil { + return fmt.Errorf("failed to update contract account funding: %w", err) + } + + if err := updateV2ContractUsage(tx, f.ContractID, additionalUsage); err != nil { + return fmt.Errorf("failed to update contract usage: %w", err) + } + } + return nil +} + // updateContractUsage distributes account usage to the contracts that funded // the account. func updateContractUsage(tx *txn, accountID int64, usage accounts.Usage, log *zap.Logger) error { @@ -265,7 +460,7 @@ func updateContractUsage(tx *txn, accountID int64, usage accounts.Usage, log *za return fmt.Errorf("failed to decrement account funding: %w", err) } - if contract.Status == contracts.ContractStatusActive || contract.Status == contracts.ContractStatusPending { + if contract.Status == contracts.ContractStatusActive { // increment potential revenue if err := incrementPotentialRevenueMetrics(tx, additionalUsage, false); err != nil { return fmt.Errorf("failed to increment contract potential revenue: %w", err) diff --git a/persist/sqlite/accounts_test.go b/persist/sqlite/accounts_test.go new file mode 100644 index 00000000..9eb1ef73 --- /dev/null +++ b/persist/sqlite/accounts_test.go @@ -0,0 +1,165 @@ +package sqlite + +import ( + "path/filepath" + "testing" + "time" + + proto4 "go.sia.tech/core/rhp/v4" + "go.sia.tech/core/types" + rhp4 "go.sia.tech/coreutils/rhp/v4" + "go.sia.tech/hostd/host/contracts" + "go.sia.tech/hostd/index" + "go.uber.org/zap/zaptest" + "lukechampine.com/frand" +) + +func TestRHP4Accounts(t *testing.T) { + log := zaptest.NewLogger(t) + db, err := OpenDatabase(filepath.Join(t.TempDir(), "test.db"), log) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + renterKey := types.NewPrivateKeyFromSeed(frand.Bytes(32)) + hostKey := types.NewPrivateKeyFromSeed(frand.Bytes(32)) + + c, count, err := db.V2Contracts(contracts.V2ContractFilter{}) + if err != nil { + t.Fatal(err) + } else if len(c) != 0 { + t.Fatal("expected no contracts") + } else if count != 0 { + t.Fatal("expected no contracts") + } + + // add a contract to the database + contract := contracts.V2Contract{ + ID: frand.Entropy256(), + V2FileContract: types.V2FileContract{ + RenterPublicKey: renterKey.PublicKey(), + HostPublicKey: hostKey.PublicKey(), + ProofHeight: 100, + ExpirationHeight: 200, + }, + } + + if err := db.AddV2Contract(contract, rhp4.TransactionSet{}); err != nil { + t.Fatal(err) + } + + checkMetricConsistency := func(t *testing.T, potential, earned proto4.Usage) { + m, err := db.Metrics(time.Now()) + if err != nil { + t.Fatal(err) + } + switch { + case m.Revenue.Potential.Ingress != potential.Ingress: + t.Fatalf("expected potential ingress %v, got %v", potential.Ingress, m.Revenue.Potential.Ingress) + case m.Revenue.Potential.Egress != potential.Egress: + t.Fatalf("expected potential egress %v, got %v", potential.Egress, m.Revenue.Potential.Egress) + case m.Revenue.Potential.Storage != potential.Storage: + t.Fatalf("expected storage %v, got %v", potential.Storage, m.Revenue.Potential.Storage) + case m.Revenue.Potential.RPC != potential.RPC: + t.Fatalf("expect RPC %v, got %v", potential.RPC, m.Revenue.Potential.RPC) + case m.Revenue.Earned.Ingress != earned.Ingress: + t.Fatalf("expected ingress %v, got %v", earned.Ingress, m.Revenue.Earned.Ingress) + case m.Revenue.Earned.Egress != earned.Egress: + t.Fatalf("expected egree %v, got %v", earned.Egress, m.Revenue.Earned.Egress) + case m.Revenue.Earned.Storage != earned.Storage: + t.Fatalf("expected storage %v, got %v", earned.Storage, m.Revenue.Earned.Storage) + case m.Revenue.Earned.RPC != earned.RPC: + t.Fatalf("expected RPC %v, got %v", earned.RPC, m.Revenue.Earned.RPC) + } + } + + sk := types.GeneratePrivateKey() + account := proto4.Account(sk.PublicKey()) + + // deposit funds + balances, err := db.RHP4CreditAccounts([]proto4.AccountDeposit{ + {Account: account, Amount: types.Siacoins(10)}, + }, contract.ID, contract.V2FileContract) + if err != nil { + t.Fatal(err) + } else if len(balances) != 1 { + t.Fatalf("expected %d balances, got %d", 1, len(balances)) + } else if !balances[0].Equals(types.Siacoins(10)) { + t.Fatalf("expected balance %v, got %v", types.Siacoins(10), balances[0]) + } + + // try to spend more than the account balance + expectedUsage := proto4.Usage{ + Ingress: types.Siacoins(15), + } + if err := db.RHP4DebitAccount(account, expectedUsage); err == nil { + t.Fatalf("expected insufficient funds error") + } + + // spend some funds + expectedUsage = proto4.Usage{ + Storage: types.Siacoins(3), + Ingress: types.Siacoins(2), + Egress: types.Siacoins(1), + } + if err := db.RHP4DebitAccount(account, expectedUsage); err != nil { + t.Fatal(err) + } + + // pending accounts do not affect metrics + checkMetricConsistency(t, proto4.Usage{}, proto4.Usage{}) + + // confirm the contract + err = db.UpdateChainState(func(itx index.UpdateTx) error { + return itx.ApplyContracts(types.ChainIndex{}, contracts.StateChanges{ + ConfirmedV2: []types.V2FileContractElement{ + { + ID: contract.ID, + }, + }, + }) + }) + if err != nil { + t.Fatal(err) + } + + // check that the metrics now reflect the spending + checkMetricConsistency(t, expectedUsage, proto4.Usage{}) + + // try to spend more than the account balance + err = db.RHP4DebitAccount(account, proto4.Usage{ + Ingress: types.Siacoins(15), + }) + if err == nil { + t.Fatalf("expected insufficient funds error") + } + + // check that the metrics did not change + checkMetricConsistency(t, expectedUsage, proto4.Usage{}) + + // spend the rest of the balance + err = db.RHP4DebitAccount(account, proto4.Usage{ + RPC: types.Siacoins(4), + }) + if err != nil { + t.Fatal(err) + } + expectedUsage.RPC = types.Siacoins(4) + // check that the metrics did not change + checkMetricConsistency(t, expectedUsage, proto4.Usage{}) + + err = db.UpdateChainState(func(itx index.UpdateTx) error { + return itx.ApplyContracts(types.ChainIndex{}, contracts.StateChanges{ + SuccessfulV2: []types.FileContractID{ + contract.ID, + }, + }) + }) + if err != nil { + t.Fatal(err) + } + + // check that the metrics were confirmed + checkMetricConsistency(t, proto4.Usage{}, expectedUsage) +} diff --git a/persist/sqlite/contracts.go b/persist/sqlite/contracts.go index 6bf6fc26..34531207 100644 --- a/persist/sqlite/contracts.go +++ b/persist/sqlite/contracts.go @@ -1106,9 +1106,6 @@ func updateV2ContractUsage(tx *txn, contractDBID int64, usage proto4.Usage) erro return fmt.Errorf("failed to get contract status: %w", err) } - // only increment metrics if the contract is active. - // If the contract is pending or some variant of successful, the metrics - // will already be handled. if status == contracts.V2ContractStatusActive { incrementCurrencyStat, done, err := incrementCurrencyStatStmt(tx) if err != nil { @@ -1121,10 +1118,32 @@ func updateV2ContractUsage(tx *txn, contractDBID int64, usage proto4.Usage) erro } else if err := updateCollateralMetrics(types.ZeroCurrency, usage.RiskedCollateral, false, incrementCurrencyStat); err != nil { return fmt.Errorf("failed to update collateral metrics: %w", err) } + } else if status == contracts.V2ContractStatusSuccessful || status == contracts.V2ContractStatusRenewed { + incrementCurrencyStat, done, err := incrementCurrencyStatStmt(tx) + if err != nil { + return fmt.Errorf("failed to prepare increment currency stat statement: %w", err) + } + defer done() + + if err := updateV2PotentialRevenueMetrics(usage, false, incrementCurrencyStat); err != nil { + return fmt.Errorf("failed to update potential revenue: %w", err) + } } return nil } +func getV2Contract(tx *txn, dbID int64) (contracts.V2Contract, error) { + const query = `SELECT c.contract_id, rt.contract_id AS renewed_to, rf.contract_id AS renewed_from, c.contract_status, c.negotiation_height, c.confirmation_index, +COALESCE(c.revision_number=cs.revision_number, false) AS revision_confirmed, c.resolution_index, c.rpc_revenue, +c.storage_revenue, c.ingress_revenue, c.egress_revenue, c.account_funding, c.risked_collateral, c.raw_revision +FROM contracts_v2 c +LEFT JOIN contract_v2_state_elements cs ON (c.id = cs.contract_id) +LEFT JOIN contracts_v2 rt ON (c.renewed_to = rt.id) +LEFT JOIN contracts_v2 rf ON (c.renewed_from = rf.id) +WHERE c.id=$1;` + return scanV2Contract(tx.QueryRow(query, dbID)) +} + func reviseV2Contract(tx *txn, id types.FileContractID, revision types.V2FileContract, usage proto4.Usage) (int64, error) { const updateQuery = `UPDATE contracts_v2 SET raw_revision=?, revision_number=? WHERE contract_id=? RETURNING id` diff --git a/persist/sqlite/init.sql b/persist/sqlite/init.sql index 946b13e7..4b347250 100644 --- a/persist/sqlite/init.sql +++ b/persist/sqlite/init.sql @@ -220,6 +220,14 @@ CREATE TABLE contract_account_funding ( UNIQUE (contract_id, account_id) ); +CREATE TABLE contract_v2_account_funding ( + id INTEGER PRIMARY KEY, + contract_id INTEGER NOT NULL REFERENCES contracts_v2(id), + account_id INTEGER NOT NULL REFERENCES accounts(id), + amount BLOB NOT NULL, + UNIQUE (contract_id, account_id) +); + CREATE TABLE host_stats ( date_created INTEGER NOT NULL, stat TEXT NOT NULL, diff --git a/persist/sqlite/migrations.go b/persist/sqlite/migrations.go index 9788c83d..df73bca5 100644 --- a/persist/sqlite/migrations.go +++ b/persist/sqlite/migrations.go @@ -10,6 +10,18 @@ import ( "go.uber.org/zap" ) +// migrateVersion33 adds the contract_v2_account_funding table. +func migrateVersion33(tx *txn, _ *zap.Logger) error { + _, err := tx.Exec(`CREATE TABLE contract_v2_account_funding ( + id INTEGER PRIMARY KEY, + contract_id INTEGER NOT NULL REFERENCES contracts_v2(id), + account_id INTEGER NOT NULL REFERENCES accounts(id), + amount BLOB NOT NULL, + UNIQUE (contract_id, account_id) +);`) + return err +} + // migrateVersion32 adds the proof height and expiration_height columns to the contracts_v2 table. func migrateVersion32(tx *txn, _ *zap.Logger) error { _, err := tx.Exec(` @@ -961,4 +973,5 @@ var migrations = []func(tx *txn, log *zap.Logger) error{ migrateVersion30, migrateVersion31, migrateVersion32, + migrateVersion33, }