Skip to content

Commit 231f7a6

Browse files
committed
support TursoDB database imports
1 parent f10150c commit 231f7a6

9 files changed

Lines changed: 420 additions & 8 deletions

File tree

internal/cmd/db_create.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,12 @@ func createDatabase(client *turso.Client, name, location, groupName string, seed
155155
if sizeLimitFlag != "" {
156156
return createDatabaseV2(client, name, location, groupName, seed, spinner)
157157
}
158-
if seed != nil && seed.Type != "database" && seed.Type != "upload" {
158+
// Only fork seeds ("database") can go through the v3 API. File seeds
159+
// ("database_upload") must use the v2 flow: the v3 branch never uploads
160+
// the file after creating the database. (This used to compare against
161+
// "upload", a seed type that doesn't exist, which routed uploads to v2
162+
// by accident.)
163+
if seed != nil && seed.Type != "database" {
159164
return createDatabaseV2(client, name, location, groupName, seed, spinner)
160165
}
161166
orgID, err := tryResolveOrgID(client)

internal/cmd/db_import.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,19 @@ import (
99
"github.com/spf13/cobra"
1010
)
1111

12+
// Import defaults to TursoDB (MVCC) databases — the only mode the cloud
13+
// supports for new databases. The flag is separate from createCmd's
14+
// --tursodb (which shares the tursoDBFlag global with a false default) so
15+
// the two commands can have different defaults; RunE copies it over before
16+
// delegating to CreateDatabase.
17+
var importTursoDBFlag bool
18+
1219
func init() {
1320
dbCmd.AddCommand(importCmd)
1421
addGroupFlag(importCmd)
1522
addRemoteEncryptionKeyFlag(importCmd)
1623
addRemoteEncryptionCipherFlag(importCmd)
24+
importCmd.Flags().BoolVar(&importTursoDBFlag, "tursodb", true, "Import into a TursoDB (MVCC) database.")
1725
}
1826

1927
var importCmd = &cobra.Command{
@@ -43,6 +51,7 @@ var importCmd = &cobra.Command{
4351
}
4452

4553
fromFileFlag = filename
54+
tursoDBFlag = importTursoDBFlag
4655
name := sanitizeDatabaseName(filename)
4756
return CreateDatabase(name)
4857
},

internal/cmd/group_flag.go

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"log"
99
"os"
1010
"os/exec"
11+
"path/filepath"
1112
"strconv"
1213
"strings"
1314
"time"
@@ -340,11 +341,110 @@ func runQuickCheck(file string) error {
340341
return nil
341342
}
342343

344+
// checkpointWALBeforeUpload folds any pending WAL frames into the main
345+
// database file and verifies no data is left behind in sidecar files. The
346+
// upload ships only the main database file, so frames still sitting in
347+
// <file>-wal (or a hot rollback journal) would silently be missing from the
348+
// imported database.
349+
func checkpointWALBeforeUpload(file string) error {
350+
if out, err := exec.Command("sqlite3", "-list", file, "PRAGMA wal_checkpoint(TRUNCATE);").CombinedOutput(); err != nil {
351+
return fmt.Errorf("could not checkpoint database %s: %w: %s", file, err, out)
352+
}
353+
for _, sidecar := range []struct{ suffix, hint string }{
354+
{"-wal", "close all connections to the database and retry the import"},
355+
{"-journal", "the database has a leftover rollback journal; open and cleanly close it with sqlite3 first"},
356+
} {
357+
if err := checkSidecarEmpty(file+sidecar.suffix, sidecar.hint); err != nil {
358+
return err
359+
}
360+
}
361+
return nil
362+
}
363+
364+
func checkSidecarEmpty(sidecarPath, hint string) error {
365+
info, err := os.Stat(sidecarPath)
366+
if errors.Is(err, os.ErrNotExist) {
367+
return nil
368+
}
369+
if err != nil {
370+
return fmt.Errorf("could not check %s: %w", sidecarPath, err)
371+
}
372+
if info.Size() > 0 {
373+
return fmt.Errorf("%s is not empty, importing would lose the data it holds: %s", sidecarPath, hint)
374+
}
375+
return nil
376+
}
377+
378+
// tursodbLogPath returns the logical-log sidecar path tursodb uses for a
379+
// database file, mirroring turso_core's `with_extension("db-log")`: the
380+
// file's extension (if any) is replaced with "db-log".
381+
func tursodbLogPath(file string) string {
382+
return strings.TrimSuffix(file, filepath.Ext(file)) + ".db-log"
383+
}
384+
343385
func handleDBFileAWS(file string, cipher string) (*turso.DBSeed, error) {
344-
if err := sqliteFileIntegrityChecks(file, cipher); err != nil {
386+
format, err := sniffSQLiteFileFormat(file)
387+
if err != nil {
345388
return nil, err
346389
}
347390

391+
if format == fileFormatRollback {
392+
// The server only accepts WAL or MVCC format files. Converting to WAL
393+
// is exactly the remediation the error message used to instruct users
394+
// to run themselves, and sqlite3 is already a hard requirement here.
395+
fmt.Printf("File %s uses a rollback journal; converting it to WAL mode for import.\n", file)
396+
if out, err := exec.Command("sqlite3", file, "PRAGMA journal_mode=WAL;").CombinedOutput(); err != nil {
397+
return nil, fmt.Errorf("could not convert %s to WAL mode: %w: %s", file, err, out)
398+
}
399+
if format, err = sniffSQLiteFileFormat(file); err != nil {
400+
return nil, err
401+
}
402+
}
403+
404+
switch format {
405+
case fileFormatWAL:
406+
if err := checkpointWALBeforeUpload(file); err != nil {
407+
return nil, err
408+
}
409+
if err := sqliteFileIntegrityChecks(file, cipher); err != nil {
410+
return nil, err
411+
}
412+
case fileFormatMVCC:
413+
// sqlite3-based checks can't run on MVCC (tursodb format) files: the
414+
// sqlite3 binary reports them as not-a-database. The server verifies
415+
// the file with the tursodb engine after upload.
416+
if !tursoDBFlag {
417+
return nil, fmt.Errorf("%s is in tursodb (MVCC) format and can only be imported into a tursodb database", file)
418+
}
419+
if cipher != "" {
420+
return nil, errors.New("remote encryption is not supported when importing tursodb (MVCC) format files")
421+
}
422+
// The upload ships only the main database file: a non-empty tursodb
423+
// logical log next to it means the file is not a fully checkpointed
424+
// snapshot and importing it would lose the log's data.
425+
if err := checkSidecarEmpty(tursodbLogPath(file), "checkpoint the database with tursodb before importing"); err != nil {
426+
return nil, err
427+
}
428+
fileInfo, err := os.Stat(file)
429+
if err != nil {
430+
return nil, fmt.Errorf("failed to get file info: %w", err)
431+
}
432+
if fileInfo.Size() > MaxAWSDBSizeBytes {
433+
return nil, errors.New("database file size exceeds maximum allowed size of 20 GB")
434+
}
435+
case fileFormatNotSQLite:
436+
isDump, err := checkIfDump(file)
437+
if err != nil {
438+
return nil, fmt.Errorf("failed to get file header: %w", err)
439+
}
440+
if isDump {
441+
return nil, fmt.Errorf("%s is a sqlite3 dump, not a sqlite3 database. Please import a sqlite database", file)
442+
}
443+
return nil, fmt.Errorf("file %s is not a valid SQLite database file", file)
444+
default:
445+
return nil, fmt.Errorf("file %s has an unsupported SQLite file format", file)
446+
}
447+
348448
seed := &turso.DBSeed{
349449
Type: "database_upload",
350450
Filepath: file,
@@ -365,6 +465,16 @@ func handleDBFile(client *turso.Client, file string, isAWS bool, cipher string)
365465
return handleDBFileAWS(file, cipher)
366466
}
367467

468+
format, err := sniffSQLiteFileFormat(file)
469+
if err != nil {
470+
return nil, err
471+
}
472+
if format == fileFormatMVCC {
473+
// non-AWS groups are seeded by replaying a .dump, which sqlite3 cannot
474+
// produce from an MVCC (tursodb format) file
475+
return nil, fmt.Errorf("%s is in tursodb (MVCC) format and can only be imported into AWS groups", file)
476+
}
477+
368478
if err := checkSQLiteFile(file); err != nil {
369479
return nil, err
370480
}

internal/cmd/group_flag_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,49 @@ func TestRunQuickCheck(t *testing.T) {
7474
require.Error(t, err)
7575
})
7676
}
77+
78+
func TestTursodbLogPath(t *testing.T) {
79+
require.Equal(t, "data.db-log", tursodbLogPath("data.db"))
80+
require.Equal(t, "data.db-log", tursodbLogPath("data.sqlite"))
81+
require.Equal(t, "/some/dir/mydb.db-log", tursodbLogPath("/some/dir/mydb.db"))
82+
require.Equal(t, "noext.db-log", tursodbLogPath("noext"))
83+
}
84+
85+
func TestCheckSidecarEmpty(t *testing.T) {
86+
dir := t.TempDir()
87+
88+
t.Run("missing sidecar is fine", func(t *testing.T) {
89+
require.NoError(t, checkSidecarEmpty(filepath.Join(dir, "missing-wal"), "hint"))
90+
})
91+
92+
t.Run("empty sidecar is fine", func(t *testing.T) {
93+
path := filepath.Join(dir, "empty-wal")
94+
require.NoError(t, os.WriteFile(path, nil, 0644))
95+
require.NoError(t, checkSidecarEmpty(path, "hint"))
96+
})
97+
98+
t.Run("non-empty sidecar errors with hint", func(t *testing.T) {
99+
path := filepath.Join(dir, "full-wal")
100+
require.NoError(t, os.WriteFile(path, []byte("frames"), 0644))
101+
err := checkSidecarEmpty(path, "close all connections")
102+
require.Error(t, err)
103+
require.Contains(t, err.Error(), "importing would lose the data it holds")
104+
require.Contains(t, err.Error(), "close all connections")
105+
})
106+
}
107+
108+
func TestCheckpointWALBeforeUpload(t *testing.T) {
109+
if _, err := exec.LookPath("sqlite3"); err != nil {
110+
t.Skip("sqlite3 not available, skipping test")
111+
}
112+
113+
dbPath := createTestDatabase(t, 10*1024)
114+
require.NoError(t, checkpointWALBeforeUpload(dbPath))
115+
116+
// after the checkpoint no data-bearing sidecars may remain
117+
for _, suffix := range []string{"-wal", "-journal"} {
118+
if info, err := os.Stat(dbPath + suffix); err == nil {
119+
require.Zero(t, info.Size(), "%s must be empty after checkpoint", dbPath+suffix)
120+
}
121+
}
122+
}

internal/cmd/sqlite_header.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package cmd
2+
3+
import (
4+
"io"
5+
"os"
6+
)
7+
8+
type sqliteFileFormat int
9+
10+
const (
11+
fileFormatNotSQLite sqliteFileFormat = iota
12+
fileFormatRollback
13+
fileFormatWAL
14+
fileFormatMVCC
15+
fileFormatUnknown
16+
)
17+
18+
const sqliteMagic = "SQLite format 3\x00"
19+
20+
// sniffSQLiteFileFormat classifies a database file by its SQLite header: the
21+
// 16-byte magic string plus the read/write format version bytes at offsets
22+
// 18/19 (1 = rollback journal, 2 = WAL, 255 = MVCC, i.e. tursodb format).
23+
//
24+
// This must run before any sqlite3 shellout: the sqlite3 binary reports
25+
// MVCC-format files as not-a-database, so they have to be routed around the
26+
// sqlite3-based checks entirely.
27+
func sniffSQLiteFileFormat(path string) (sqliteFileFormat, error) {
28+
file, err := os.Open(path)
29+
if err != nil {
30+
return fileFormatNotSQLite, err
31+
}
32+
defer file.Close()
33+
34+
header := make([]byte, 20)
35+
if _, err := io.ReadFull(file, header); err != nil {
36+
// too short to hold a SQLite header
37+
return fileFormatNotSQLite, nil
38+
}
39+
if string(header[:len(sqliteMagic)]) != sqliteMagic {
40+
return fileFormatNotSQLite, nil
41+
}
42+
readVersion, writeVersion := header[18], header[19]
43+
switch {
44+
case readVersion == 1 && writeVersion == 1:
45+
return fileFormatRollback, nil
46+
case readVersion == 2 && writeVersion == 2:
47+
return fileFormatWAL, nil
48+
case readVersion == 255 && writeVersion == 255:
49+
return fileFormatMVCC, nil
50+
}
51+
return fileFormatUnknown, nil
52+
}

internal/cmd/sqlite_header_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func writeHeaderFile(t *testing.T, magic string, readVersion, writeVersion byte) string {
13+
t.Helper()
14+
header := make([]byte, 100)
15+
copy(header, magic)
16+
header[18] = readVersion
17+
header[19] = writeVersion
18+
path := filepath.Join(t.TempDir(), "test.db")
19+
require.NoError(t, os.WriteFile(path, header, 0644))
20+
return path
21+
}
22+
23+
func TestSniffSQLiteFileFormat(t *testing.T) {
24+
for _, tc := range []struct {
25+
name string
26+
magic string
27+
readVersion, writeVersion byte
28+
expected sqliteFileFormat
29+
}{
30+
{"rollback journal", sqliteMagic, 1, 1, fileFormatRollback},
31+
{"wal", sqliteMagic, 2, 2, fileFormatWAL},
32+
{"mvcc", sqliteMagic, 255, 255, fileFormatMVCC},
33+
{"mixed version bytes", sqliteMagic, 2, 255, fileFormatUnknown},
34+
{"unknown version bytes", sqliteMagic, 42, 42, fileFormatUnknown},
35+
{"wrong magic", "Not a SQLite db\x00", 2, 2, fileFormatNotSQLite},
36+
} {
37+
t.Run(tc.name, func(t *testing.T) {
38+
path := writeHeaderFile(t, tc.magic, tc.readVersion, tc.writeVersion)
39+
format, err := sniffSQLiteFileFormat(path)
40+
require.NoError(t, err)
41+
require.Equal(t, tc.expected, format)
42+
})
43+
}
44+
45+
t.Run("file shorter than header", func(t *testing.T) {
46+
path := filepath.Join(t.TempDir(), "short.db")
47+
require.NoError(t, os.WriteFile(path, []byte("SQLite"), 0644))
48+
format, err := sniffSQLiteFileFormat(path)
49+
require.NoError(t, err)
50+
require.Equal(t, fileFormatNotSQLite, format)
51+
})
52+
53+
t.Run("missing file", func(t *testing.T) {
54+
_, err := sniffSQLiteFileFormat(filepath.Join(t.TempDir(), "missing.db"))
55+
require.Error(t, err)
56+
})
57+
}
58+
59+
func TestSniffSQLiteFileFormatOnRealDatabases(t *testing.T) {
60+
if _, err := exec.LookPath("sqlite3"); err != nil {
61+
t.Skip("sqlite3 not available, skipping test")
62+
}
63+
64+
t.Run("wal database", func(t *testing.T) {
65+
dbPath := createTestDatabase(t, 10*1024)
66+
format, err := sniffSQLiteFileFormat(dbPath)
67+
require.NoError(t, err)
68+
require.Equal(t, fileFormatWAL, format)
69+
})
70+
71+
t.Run("rollback database converts to wal", func(t *testing.T) {
72+
dbPath := filepath.Join(t.TempDir(), "rollback.db")
73+
cmd := exec.Command("sqlite3", "-list", dbPath,
74+
"PRAGMA page_size=4096;",
75+
"CREATE TABLE data (id INTEGER PRIMARY KEY);")
76+
require.NoError(t, cmd.Run(), "failed to create test database")
77+
78+
format, err := sniffSQLiteFileFormat(dbPath)
79+
require.NoError(t, err)
80+
require.Equal(t, fileFormatRollback, format)
81+
82+
// the conversion handleDBFileAWS performs for rollback files
83+
out, err := exec.Command("sqlite3", dbPath, "PRAGMA journal_mode=WAL;").CombinedOutput()
84+
require.NoError(t, err, "convert to WAL: %s", out)
85+
86+
format, err = sniffSQLiteFileFormat(dbPath)
87+
require.NoError(t, err)
88+
require.Equal(t, fileFormatWAL, format)
89+
})
90+
}

0 commit comments

Comments
 (0)