Skip to content

Commit ec3d239

Browse files
committed
apply suggestions
1 parent c2ed36f commit ec3d239

3 files changed

Lines changed: 428 additions & 336 deletions

File tree

internal/cmd/db_import_file.go

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
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

Comments
 (0)