|
| 1 | +package cmd |
| 2 | + |
| 3 | +import ( |
| 4 | + "bufio" |
| 5 | + "errors" |
| 6 | + "fmt" |
| 7 | + "log" |
| 8 | + "os" |
| 9 | + "os/exec" |
| 10 | + "path/filepath" |
| 11 | + "strconv" |
| 12 | + "strings" |
| 13 | + |
| 14 | + "github.com/tursodatabase/turso-cli/internal/flags" |
| 15 | + "github.com/tursodatabase/turso-cli/internal/prompt" |
| 16 | + "github.com/tursodatabase/turso-cli/internal/turso" |
| 17 | +) |
| 18 | + |
| 19 | +const MaxAWSDBSizeBytes = 1024 * 1024 * 1024 * 20 // 20 GB |
| 20 | + |
| 21 | +const databaseSettingsQuery = "select journal_mode as j, page_size as p, auto_vacuum as a, encoding as e from pragma_journal_mode, pragma_page_size, pragma_auto_vacuum, pragma_encoding;" |
| 22 | + |
| 23 | +type databaseFileChecker struct { |
| 24 | + name string |
| 25 | + binary string |
| 26 | + journalMode string |
| 27 | + settings func(string) (databaseSettings, error) |
| 28 | + quickCheck func(string) error |
| 29 | +} |
| 30 | + |
| 31 | +type databaseSettings struct { |
| 32 | + journalMode string |
| 33 | + pageSize string |
| 34 | + autoVacuum string |
| 35 | + encoding string |
| 36 | +} |
| 37 | + |
| 38 | +func humanReadableSize(bytes int64) string { |
| 39 | + const unit = 1024 |
| 40 | + if bytes < unit { |
| 41 | + return fmt.Sprintf("%d B", bytes) |
| 42 | + } |
| 43 | + div, exp := int64(unit), 0 |
| 44 | + for n := bytes / unit; n >= unit; n /= unit { |
| 45 | + div *= unit |
| 46 | + exp++ |
| 47 | + } |
| 48 | + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) |
| 49 | +} |
| 50 | + |
| 51 | +func checkIfDump(filename string) (bool, error) { |
| 52 | + file, err := os.Open(filename) |
| 53 | + if err != nil { |
| 54 | + return false, err |
| 55 | + } |
| 56 | + defer file.Close() |
| 57 | + scanner := bufio.NewScanner(file) |
| 58 | + if scanner.Scan() { |
| 59 | + return strings.TrimSpace(scanner.Text()) == "PRAGMA foreign_keys=OFF;", nil |
| 60 | + } |
| 61 | + return false, scanner.Err() |
| 62 | +} |
| 63 | + |
| 64 | +func validateDatabaseFileSize(file string) error { |
| 65 | + if flags.Debug() { |
| 66 | + log.Printf("Checking file size...") |
| 67 | + } |
| 68 | + fileInfo, err := os.Stat(file) |
| 69 | + if err != nil { |
| 70 | + return fmt.Errorf("failed to get file info: %w", err) |
| 71 | + } |
| 72 | + if fileInfo.Size() > MaxAWSDBSizeBytes { |
| 73 | + return errors.New("database file size exceeds maximum allowed size of 20 GB") |
| 74 | + } |
| 75 | + return nil |
| 76 | +} |
| 77 | + |
| 78 | +func sqliteFileIntegrityChecks(file string, cipher string) error { |
| 79 | + return databaseFileIntegrityChecks(file, cipher, databaseFileChecker{ |
| 80 | + name: "SQLite", |
| 81 | + binary: "sqlite3", |
| 82 | + journalMode: "wal", |
| 83 | + settings: sqliteDatabaseSettings, |
| 84 | + quickCheck: runQuickCheck, |
| 85 | + }) |
| 86 | +} |
| 87 | + |
| 88 | +func tursoDBFileIntegrityChecks(file string) error { |
| 89 | + return databaseFileIntegrityChecks(file, "", databaseFileChecker{ |
| 90 | + name: "TursoDB", |
| 91 | + binary: "tursodb", |
| 92 | + journalMode: "mvcc", |
| 93 | + settings: tursoDBDatabaseSettings, |
| 94 | + quickCheck: runTursoDBQuickCheck, |
| 95 | + }) |
| 96 | +} |
| 97 | + |
| 98 | +func databaseFileIntegrityChecks(file, cipher string, checker databaseFileChecker) error { |
| 99 | + if flags.Debug() { |
| 100 | + log.Printf("Running %s integrity checks on database file %s", checker.name, file) |
| 101 | + log.Printf("Checking database settings...") |
| 102 | + } |
| 103 | + |
| 104 | + settings, err := checker.settings(file) |
| 105 | + if err != nil { |
| 106 | + return err |
| 107 | + } |
| 108 | + if err := validateDatabaseSettings(file, settings, checker); err != nil { |
| 109 | + return err |
| 110 | + } |
| 111 | + |
| 112 | + fileInfo, err := os.Stat(file) |
| 113 | + if err != nil { |
| 114 | + return fmt.Errorf("failed to get file info: %w", err) |
| 115 | + } |
| 116 | + if flags.Debug() { |
| 117 | + log.Printf("Running integrity check...") |
| 118 | + } |
| 119 | + spinner := prompt.Spinner(fmt.Sprintf("Validating database file (%s)...", humanReadableSize(fileInfo.Size()))) |
| 120 | + err = checker.quickCheck(file) |
| 121 | + spinner.Stop() |
| 122 | + if err != nil { |
| 123 | + return err |
| 124 | + } |
| 125 | + |
| 126 | + if cipher != "" { |
| 127 | + if flags.Debug() { |
| 128 | + log.Printf("Checking reserved bytes for cipher %s...", cipher) |
| 129 | + } |
| 130 | + return validateReservedBytes(file, cipher) |
| 131 | + } |
| 132 | + |
| 133 | + return nil |
| 134 | +} |
| 135 | + |
| 136 | +func validateDatabaseSettings(file string, settings databaseSettings, checker databaseFileChecker) error { |
| 137 | + if !strings.EqualFold(settings.journalMode, checker.journalMode) { |
| 138 | + return fmt.Errorf("database is not in %s mode. Set it with '%s %s \"PRAGMA journal_mode = %s;\"'", strings.ToUpper(checker.journalMode), checker.binary, file, strings.ToUpper(checker.journalMode)) |
| 139 | + } |
| 140 | + if settings.pageSize != "4096" { |
| 141 | + return fmt.Errorf("database must use 4KB page size. You can set it with '%s %s \"PRAGMA page_size = 4096; VACUUM;\"'", checker.binary, file) |
| 142 | + } |
| 143 | + if settings.autoVacuum != "0" { |
| 144 | + return fmt.Errorf("database must have autovacuum disabled. You can set it with '%s %s \"PRAGMA auto_vacuum = 0;\"'", checker.binary, file) |
| 145 | + } |
| 146 | + if !strings.EqualFold(settings.encoding, "UTF-8") { |
| 147 | + return fmt.Errorf("database must use UTF-8 encoding. You can set it with '%s %s \"PRAGMA encoding = 'UTF-8';\"'", checker.binary, file) |
| 148 | + } |
| 149 | + return nil |
| 150 | +} |
| 151 | + |
| 152 | +func parseDatabaseSettings(output string) (databaseSettings, error) { |
| 153 | + values := strings.Split(strings.TrimSpace(output), "|") |
| 154 | + if len(values) != 4 { |
| 155 | + return databaseSettings{}, fmt.Errorf("unexpected database settings output: %s", strings.TrimSpace(output)) |
| 156 | + } |
| 157 | + return databaseSettings{ |
| 158 | + journalMode: strings.TrimSpace(values[0]), |
| 159 | + pageSize: strings.TrimSpace(values[1]), |
| 160 | + autoVacuum: strings.TrimSpace(values[2]), |
| 161 | + encoding: strings.TrimSpace(values[3]), |
| 162 | + }, nil |
| 163 | +} |
| 164 | + |
| 165 | +func sqliteDatabaseSettings(file string) (databaseSettings, error) { |
| 166 | + output, err := exec.Command("sqlite3", "-list", file, databaseSettingsQuery).CombinedOutput() |
| 167 | + if err != nil { |
| 168 | + return databaseSettings{}, fmt.Errorf("failed to check database settings with sqlite3: %w: %s", err, strings.TrimSpace(string(output))) |
| 169 | + } |
| 170 | + settings, err := parseDatabaseSettings(string(output)) |
| 171 | + if err != nil { |
| 172 | + return databaseSettings{}, fmt.Errorf("failed to parse database settings from sqlite3: %w", err) |
| 173 | + } |
| 174 | + return settings, nil |
| 175 | +} |
| 176 | + |
| 177 | +func tursoDBDatabaseSettings(file string) (databaseSettings, error) { |
| 178 | + output, err := exec.Command("tursodb", "-q", "-m", "list", file, databaseSettingsQuery).CombinedOutput() |
| 179 | + if err != nil { |
| 180 | + return databaseSettings{}, fmt.Errorf("failed to check database settings with TursoDB: %w: %s", err, strings.TrimSpace(string(output))) |
| 181 | + } |
| 182 | + settings, err := parseDatabaseSettings(string(output)) |
| 183 | + if err != nil { |
| 184 | + return databaseSettings{}, fmt.Errorf("failed to parse database settings from TursoDB: %w", err) |
| 185 | + } |
| 186 | + return settings, nil |
| 187 | +} |
| 188 | + |
| 189 | +func runQuickCheck(file string) error { |
| 190 | + cmd := exec.Command("sqlite3", "-list", file, "pragma quick_check;") |
| 191 | + if err := cmd.Run(); err != nil { |
| 192 | + return fmt.Errorf("integrity check failed: %w", err) |
| 193 | + } |
| 194 | + return nil |
| 195 | +} |
| 196 | + |
| 197 | +func runTursoDBQuickCheck(file string) error { |
| 198 | + output, err := exec.Command("tursodb", "-q", "-m", "list", file, "PRAGMA quick_check;").CombinedOutput() |
| 199 | + if err != nil { |
| 200 | + return fmt.Errorf("TursoDB integrity check failed for %s: %w: %s", file, err, strings.TrimSpace(string(output))) |
| 201 | + } |
| 202 | + fields := strings.Fields(string(output)) |
| 203 | + if len(fields) == 0 || fields[len(fields)-1] != "ok" { |
| 204 | + return fmt.Errorf("TursoDB integrity check failed for %s: %s", file, strings.TrimSpace(string(output))) |
| 205 | + } |
| 206 | + return nil |
| 207 | +} |
| 208 | + |
| 209 | +func checkpointWALBeforeUpload(file string) error { |
| 210 | + if out, err := exec.Command("sqlite3", "-list", file, "PRAGMA wal_checkpoint(TRUNCATE);").CombinedOutput(); err != nil { |
| 211 | + return fmt.Errorf("could not checkpoint database %s: %w: %s", file, err, out) |
| 212 | + } |
| 213 | + for _, sidecar := range []struct{ suffix, hint string }{ |
| 214 | + {"-wal", "close all connections to the database and retry the import"}, |
| 215 | + {"-journal", "the database has a leftover rollback journal; open and cleanly close it with sqlite3 first"}, |
| 216 | + } { |
| 217 | + if err := checkSidecarEmpty(file+sidecar.suffix, sidecar.hint); err != nil { |
| 218 | + return err |
| 219 | + } |
| 220 | + } |
| 221 | + return nil |
| 222 | +} |
| 223 | + |
| 224 | +func prepareTursoDBFile(file string) error { |
| 225 | + output, err := exec.Command("tursodb", "-q", "-m", "list", file, |
| 226 | + "PRAGMA journal_mode = mvcc; PRAGMA wal_checkpoint(TRUNCATE);").CombinedOutput() |
| 227 | + if err != nil { |
| 228 | + return fmt.Errorf("could not prepare %s for TursoDB import: %w: %s", file, err, strings.TrimSpace(string(output))) |
| 229 | + } |
| 230 | + |
| 231 | + format, err := sniffSQLiteFileFormat(file) |
| 232 | + if err != nil { |
| 233 | + return err |
| 234 | + } |
| 235 | + if format != fileFormatMVCC { |
| 236 | + return fmt.Errorf("TursoDB did not convert %s to MVCC format", file) |
| 237 | + } |
| 238 | + return nil |
| 239 | +} |
| 240 | + |
| 241 | +func checkTursoDBSidecars(file string) error { |
| 242 | + for _, sidecar := range []struct{ path, hint string }{ |
| 243 | + {file + "-wal", "close all connections to the database and retry the import"}, |
| 244 | + {file + "-journal", "the database has a leftover rollback journal; close it cleanly and retry the import"}, |
| 245 | + {tursodbLogPath(file), "close all TursoDB connections and retry the import"}, |
| 246 | + } { |
| 247 | + if err := checkSidecarEmpty(sidecar.path, sidecar.hint); err != nil { |
| 248 | + return err |
| 249 | + } |
| 250 | + } |
| 251 | + return nil |
| 252 | +} |
| 253 | + |
| 254 | +func checkSidecarEmpty(sidecarPath, hint string) error { |
| 255 | + info, err := os.Stat(sidecarPath) |
| 256 | + if errors.Is(err, os.ErrNotExist) { |
| 257 | + return nil |
| 258 | + } |
| 259 | + if err != nil { |
| 260 | + return fmt.Errorf("could not check %s: %w", sidecarPath, err) |
| 261 | + } |
| 262 | + if info.Size() > 0 { |
| 263 | + return fmt.Errorf("%s is not empty, importing would lose the data it holds: %s", sidecarPath, hint) |
| 264 | + } |
| 265 | + return nil |
| 266 | +} |
| 267 | + |
| 268 | +func tursodbLogPath(file string) string { |
| 269 | + return strings.TrimSuffix(file, filepath.Ext(file)) + ".db-log" |
| 270 | +} |
| 271 | + |
| 272 | +func handleDBFileAWS(file string, cipher string) (*turso.DBSeed, error) { |
| 273 | + format, err := sniffSQLiteFileFormat(file) |
| 274 | + if err != nil { |
| 275 | + return nil, err |
| 276 | + } |
| 277 | + |
| 278 | + if format == fileFormatRollback { |
| 279 | + if err := checkSQLiteAvailable(); err != nil { |
| 280 | + return nil, err |
| 281 | + } |
| 282 | + fmt.Printf("File %s uses a rollback journal; converting it to WAL mode for import.\n", file) |
| 283 | + if out, err := exec.Command("sqlite3", file, "PRAGMA journal_mode=WAL;").CombinedOutput(); err != nil { |
| 284 | + return nil, fmt.Errorf("could not convert %s to WAL mode: %w: %s", file, err, out) |
| 285 | + } |
| 286 | + if format, err = sniffSQLiteFileFormat(file); err != nil { |
| 287 | + return nil, err |
| 288 | + } |
| 289 | + } |
| 290 | + |
| 291 | + switch format { |
| 292 | + case fileFormatWAL: |
| 293 | + if err := checkSQLiteAvailable(); err != nil { |
| 294 | + return nil, err |
| 295 | + } |
| 296 | + if err := checkpointWALBeforeUpload(file); err != nil { |
| 297 | + return nil, err |
| 298 | + } |
| 299 | + if err := sqliteFileIntegrityChecks(file, cipher); err != nil { |
| 300 | + return nil, err |
| 301 | + } |
| 302 | + case fileFormatMVCC: |
| 303 | + if !tursoDBFlag { |
| 304 | + return nil, fmt.Errorf("%s is in tursodb (MVCC) format and can only be imported into a tursodb database", file) |
| 305 | + } |
| 306 | + if cipher != "" { |
| 307 | + return nil, errors.New("remote encryption is not supported when importing tursodb (MVCC) format files") |
| 308 | + } |
| 309 | + case fileFormatNotSQLite: |
| 310 | + isDump, err := checkIfDump(file) |
| 311 | + if err != nil { |
| 312 | + return nil, fmt.Errorf("failed to get file header: %w", err) |
| 313 | + } |
| 314 | + if isDump { |
| 315 | + return nil, fmt.Errorf("%s is a sqlite3 dump, not a sqlite3 database. Please import a sqlite database", file) |
| 316 | + } |
| 317 | + return nil, fmt.Errorf("file %s is not a valid SQLite database file", file) |
| 318 | + case fileFormatUnknown: |
| 319 | + return nil, fmt.Errorf("file %s has unsupported SQLite read/write format versions", file) |
| 320 | + default: |
| 321 | + return nil, fmt.Errorf("file %s has an unsupported SQLite file format", file) |
| 322 | + } |
| 323 | + |
| 324 | + if tursoDBFlag { |
| 325 | + if err := checkTursoDBAvailable(); err != nil { |
| 326 | + return nil, err |
| 327 | + } |
| 328 | + if format != fileFormatMVCC { |
| 329 | + fmt.Printf("Converting %s to TursoDB (MVCC) format for import.\n", file) |
| 330 | + } |
| 331 | + if err := prepareTursoDBFile(file); err != nil { |
| 332 | + return nil, err |
| 333 | + } |
| 334 | + if err := tursoDBFileIntegrityChecks(file); err != nil { |
| 335 | + return nil, err |
| 336 | + } |
| 337 | + if err := checkTursoDBSidecars(file); err != nil { |
| 338 | + return nil, err |
| 339 | + } |
| 340 | + } |
| 341 | + |
| 342 | + if err := validateDatabaseFileSize(file); err != nil { |
| 343 | + return nil, err |
| 344 | + } |
| 345 | + |
| 346 | + return &turso.DBSeed{ |
| 347 | + Type: "database_upload", |
| 348 | + Filepath: file, |
| 349 | + }, nil |
| 350 | +} |
| 351 | + |
| 352 | +func getReservedBytes(dbPath string) (int, error) { |
| 353 | + output, err := exec.Command("sqlite3", "-list", dbPath, ".filectrl reserve_bytes").CombinedOutput() |
| 354 | + if err != nil { |
| 355 | + return 0, fmt.Errorf("failed to get reserved bytes: %w", err) |
| 356 | + } |
| 357 | + outputStr := strings.TrimSpace(string(output)) |
| 358 | + if strings.Contains(outputStr, ":") { |
| 359 | + parts := strings.Split(outputStr, ":") |
| 360 | + if len(parts) >= 2 { |
| 361 | + outputStr = strings.TrimSpace(parts[1]) |
| 362 | + } |
| 363 | + } |
| 364 | + |
| 365 | + reservedBytes, err := strconv.Atoi(outputStr) |
| 366 | + if err != nil { |
| 367 | + return 0, fmt.Errorf("failed to parse reserved bytes from output '%s': %w", string(output), err) |
| 368 | + } |
| 369 | + return reservedBytes, nil |
| 370 | +} |
| 371 | + |
| 372 | +func validateReservedBytes(dbPath string, cipher string) error { |
| 373 | + requiredBytes, ok := getRequiredReservedBytes(cipher) |
| 374 | + if !ok { |
| 375 | + return nil |
| 376 | + } |
| 377 | + |
| 378 | + currentBytes, err := getReservedBytes(dbPath) |
| 379 | + if err != nil { |
| 380 | + return err |
| 381 | + } |
| 382 | + if currentBytes != requiredBytes { |
| 383 | + return fmt.Errorf("database reserved bytes mismatch: found %d, but cipher '%s' requires %d reserved bytes.\nTo fix this, run:\n\n $ sqlite3 %s\n sqlite> .filectrl reserve_bytes %d\n sqlite> VACUUM;", |
| 384 | + currentBytes, cipher, requiredBytes, dbPath, requiredBytes) |
| 385 | + } |
| 386 | + return nil |
| 387 | +} |
| 388 | + |
| 389 | +func checkTursoDBAvailable() error { |
| 390 | + _, err := exec.LookPath("tursodb") |
| 391 | + if errors.Is(err, exec.ErrNotFound) { |
| 392 | + return errors.New("could not find tursodb on your system. Please install it to import into a TursoDB database") |
| 393 | + } |
| 394 | + return err |
| 395 | +} |
0 commit comments