OpenNHP/opennhp · error

keystore: migrate .

Error message

keystore: migrate %s.%s: %w

What it means

migrate() executes an ALTER TABLE-style DDL statement to add a missing column; a failure here is wrapped as 'keystore: migrate <table>.<column>'. Common SQLite causes are duplicates during ALTER, disk I/O errors, or a locked database.

Solutions

  1. Read the wrapped SQLite error: 'duplicate column name' means a race — re-check pragma and treat as success or serialize migrations.
  2. Free disk space / check filesystem writability if the error is disk I/O.
  3. Use an exclusive lock or a single-migrator pattern (e.g. file lock) when multiple daemons share the file.
  4. Re-run startup after fixing; migrations are guarded by the pragma existence check and are idempotent.

Example fix

// before
if _, err := s.db.Exec(m.ddl); err != nil {
    return fmt.Errorf("keystore: migrate %s.%s: %w", m.table, m.column, err)
}
// after
if _, err := s.db.Exec(m.ddl); err != nil {
    if strings.Contains(err.Error(), "duplicate column name") {
        continue // raced migration; column already added
    }
    return fmt.Errorf("keystore: migrate %s.%s: %w", m.table, m.column, err)
}
Defensive patterns

Strategy: retry

Try / catch

if err != nil && strings.Contains(err.Error(), "keystore: migrate") {
    if strings.Contains(err.Error(), "duplicate column") {
        // raced migration; treat as success
    } else {
        return err
    }
}

Prevention

When it happens

Trigger: Running Exec on migration DDL like ALTER TABLE agent_keys ADD COLUMN ... when the DB is locked by another writer, the disk is full, or the table is corrupted.

Common situations: Two replicas racing to add the same column (one gets 'duplicate column name' if the pragma check raced), embedded storage full on small VMs/containers.

Related errors


AI-assisted analysis of OpenNHP/opennhp@6e04ca5ff0 (2026-09-07). Data as JSON: /api/errors/2e9737c865299cf8. Report an issue: GitHub.

Appendix: source

Thrown at endpoints/server/keystore.go:122

		table  string
		column string
		ddl    string
	}{
		{"otp_records", "pub_key", "ALTER TABLE otp_records ADD COLUMN pub_key TEXT NOT NULL DEFAULT ''"},
		{"otp_records", "attempts", "ALTER TABLE otp_records ADD COLUMN attempts INTEGER DEFAULT 0"},
	}
	for _, m := range migrations {
		var exists int
		err := s.db.QueryRow(
			`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`,
			m.table, m.column,
		).Scan(&exists)
		if err != nil {
			return fmt.Errorf("keystore: check column %s.%s: %w", m.table, m.column, err)
		}
		if exists == 0 {
			if _, err := s.db.Exec(m.ddl); err != nil {
				return fmt.Errorf("keystore: migrate %s.%s: %w", m.table, m.column, err)
			}
		}
	}

	return nil
}

// ── OTP operations ────────────────────────────────────────────────────────

// OTPCooldownSeconds is the minimum interval between successive OTP
// generations for the same user+device. A request that arrives before
// the cooldown elapses is rejected with ErrOTPCooldown.
const OTPCooldownSeconds int64 = 60

// MaxOTPPerUserPerWindow caps the total number of OTP generations for a
// single userId across all deviceIds within the cooldown window. This
// closes the deviceId-rotation bypass: without it, an attacker can vary
// the (unauthenticated, attacker-controlled) deviceId on each request to

View on GitHub (pinned to 6e04ca5ff0)