Skip to content

Commit 189f917

Browse files
authored
Add turso db export command (#973)
This adds a `turso db export` command, which exports a Turso database to a local SQLite file. The export command does not generate a WAL file so the database is a snapshot of time at the beginning of the current generation, not the latest database version. You need to, therefore, sync the database using Turso SDK if you need the latest data. Example usage: 1. Export the `hello` database as `hello.db`: ``` turso db export hello ``` 2. Overwrite an existing database file: ``` turso db export hello --overwrite ``` 3. Generate metadata with the export: ``` turso db export hello --with-metadata ``` 4. Output `hello` database to the `world.db` file: ``` turso db export hello --output-file world.db ```
2 parents 6774d85 + b489086 commit 189f917

File tree

3 files changed

+167
-7
lines changed

3 files changed

+167
-7
lines changed

internal/cmd/db_export.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/spf13/cobra"
8+
)
9+
10+
var withMetadata bool
11+
var overwriteExport bool
12+
var outputFile string
13+
14+
var exportCmd = &cobra.Command{
15+
Use: "export <database>",
16+
Short: "Export a database snapshot from Turso to SQLite file.",
17+
Long: `Export a database snapshot from Turso to SQLite file.
18+
19+
This command exports a snapshot of the current generation of a Turso database
20+
to a local SQLite file. Note that the exported file may not contain the
21+
latest changes. Use SDK to sync the database after exporting to ensure you
22+
have the most recent version.`,
23+
Args: cobra.ExactArgs(1),
24+
RunE: func(cmd *cobra.Command, args []string) error {
25+
cmd.SilenceUsage = true
26+
dbName := args[0]
27+
if outputFile == "" {
28+
outputFile = dbName + ".db"
29+
}
30+
err := ExportDatabase(dbName, outputFile, withMetadata, overwriteExport)
31+
if err != nil {
32+
return fmt.Errorf("failed to export database: %w", err)
33+
}
34+
fmt.Printf("Exported database to %s\n", outputFile)
35+
return nil
36+
},
37+
}
38+
39+
func ExportDatabase(dbName, outputFile string, withMetadata bool, overwrite bool) error {
40+
if !overwrite {
41+
if _, err := os.Stat(outputFile); err == nil {
42+
return fmt.Errorf("file %s already exists, use `--overwrite` flag to overwrite it", outputFile)
43+
}
44+
}
45+
client, err := authedTursoClient()
46+
if err != nil {
47+
return err
48+
}
49+
db, err := getDatabase(client, dbName)
50+
if err != nil {
51+
return fmt.Errorf("failed to find database: %w", err)
52+
}
53+
dbUrl := getDatabaseHttpUrl(&db)
54+
err = client.Databases.Export(dbName, dbUrl, outputFile, withMetadata, overwrite)
55+
if err != nil {
56+
return err
57+
}
58+
return nil
59+
}
60+
61+
func init() {
62+
exportCmd.Flags().BoolVar(&withMetadata, "with-metadata", false, "Include metadata in the export.")
63+
exportCmd.Flags().BoolVar(&overwriteExport, "overwrite", false, "Overwrite output file if it exists.")
64+
exportCmd.Flags().StringVar(&outputFile, "output-file", "", "Specify the output file name (default: <database>.db)")
65+
dbCmd.AddCommand(exportCmd)
66+
}

internal/turso/databases.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,11 @@ func (d *DatabasesClient) UploadDatabaseAWS(resp *CreateDatabaseResponse, group
222222
return nil, fmt.Errorf("could not create database token: %w", err)
223223
}
224224

225-
hostname := resp.Database.Hostname
226-
tursoServerClient, err := NewTursoServerClient(hostname, token, d.client.cliVersion, d.client.Org)
225+
baseURL, err := url.Parse(fmt.Sprintf("https://%s", resp.Database.Hostname))
226+
if err != nil {
227+
return nil, fmt.Errorf("unable to create TursoServerClient: %v", err)
228+
}
229+
tursoServerClient, err := NewTursoServerClient(baseURL, token, d.client.cliVersion, d.client.Org)
227230
if err != nil {
228231
return nil, fmt.Errorf("could not create Turso server client: %w", err)
229232
}
@@ -262,6 +265,27 @@ func (d *DatabasesClient) UploadDatabaseAWS(resp *CreateDatabaseResponse, group
262265
return resp, nil
263266
}
264267

268+
func (d *DatabasesClient) Export(dbName, dbUrl, outputFile string, withMetadata bool, overwrite bool) error {
269+
if !overwrite {
270+
if _, err := os.Stat(outputFile); err == nil {
271+
return fmt.Errorf("file %s already exists, use `--overwrite` flag to overwrite it", outputFile)
272+
}
273+
}
274+
token, err := d.Token(dbName, "1h", false, nil)
275+
if err != nil {
276+
return fmt.Errorf("could not create database token: %w", err)
277+
}
278+
baseURL, err := url.Parse(dbUrl)
279+
if err != nil {
280+
return fmt.Errorf("could not parse database URL: %w", err)
281+
}
282+
tursoServerClient, err := NewTursoServerClient(baseURL, token, d.client.cliVersion, d.client.Org)
283+
if err != nil {
284+
return fmt.Errorf("could not create Turso server client: %w", err)
285+
}
286+
return tursoServerClient.Export(outputFile, withMetadata)
287+
}
288+
265289
func (d *DatabasesClient) Seed(name string, dbFile *os.File) error {
266290
url := d.URL(fmt.Sprintf("/%s/seed", name))
267291
res, err := d.client.Upload(url, dbFile)

internal/turso/tursoServer.go

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package turso
22

33
import (
4+
"encoding/binary"
5+
"encoding/json"
46
"errors"
57
"fmt"
8+
"hash/crc32"
69
"io"
710
"net/http"
811
"net/url"
@@ -42,11 +45,7 @@ type TursoServerClient struct {
4245
client *Client
4346
}
4447

45-
func NewTursoServerClient(tenantHostname string, token string, cliVersion string, org string) (TursoServerClient, error) {
46-
baseURL, err := url.Parse(fmt.Sprintf("https://%s", tenantHostname))
47-
if err != nil {
48-
return TursoServerClient{}, fmt.Errorf("unable to create TursoServerClient: %v", err)
49-
}
48+
func NewTursoServerClient(baseURL *url.URL, token string, cliVersion string, org string) (TursoServerClient, error) {
5049
newClient := New(baseURL, token, cliVersion, org)
5150

5251
return TursoServerClient{
@@ -98,3 +97,74 @@ func (i *TursoServerClient) UploadFile(filepath string, onUploadProgress func(pr
9897

9998
return nil
10099
}
100+
101+
func (i *TursoServerClient) Export(outputFile string, withMetadata bool) error {
102+
res, err := i.client.Get("/info", nil)
103+
if err != nil {
104+
return fmt.Errorf("failed to fetch database info: %w", err)
105+
}
106+
defer res.Body.Close()
107+
if res.StatusCode != http.StatusOK {
108+
return parseResponseError(res)
109+
}
110+
var info struct {
111+
CurrentGeneration int `json:"current_generation"`
112+
}
113+
if err := json.NewDecoder(res.Body).Decode(&info); err != nil {
114+
return fmt.Errorf("failed to decode /info response: %w", err)
115+
}
116+
117+
exportRes, err := i.client.Get(fmt.Sprintf("/export/%d", info.CurrentGeneration), nil)
118+
if err != nil {
119+
return fmt.Errorf("failed to fetch export: %w", err)
120+
}
121+
defer exportRes.Body.Close()
122+
if exportRes.StatusCode != http.StatusOK {
123+
return parseResponseError(exportRes)
124+
}
125+
126+
out, err := os.Create(outputFile)
127+
if err != nil {
128+
return fmt.Errorf("failed to create output file: %w", err)
129+
}
130+
defer out.Close()
131+
if _, err := io.Copy(out, exportRes.Body); err != nil {
132+
return fmt.Errorf("failed to write export to file: %w", err)
133+
}
134+
135+
if withMetadata {
136+
out, err := os.Create(outputFile + "-info")
137+
if err != nil {
138+
return fmt.Errorf("failed to create info file: %w", err)
139+
}
140+
defer out.Close()
141+
142+
hasher := crc32.New(crc32.MakeTable(crc32.IEEE))
143+
var versionBytes [4]byte
144+
var durableFrameNumBytes [4]byte
145+
var generationBytes [4]byte
146+
binary.LittleEndian.PutUint32(versionBytes[:], 0)
147+
binary.LittleEndian.PutUint32(durableFrameNumBytes[:], 0)
148+
binary.LittleEndian.PutUint32(generationBytes[:], uint32(info.CurrentGeneration))
149+
hasher.Write(versionBytes[:])
150+
hasher.Write(durableFrameNumBytes[:])
151+
hasher.Write(generationBytes[:])
152+
hash := int(hasher.Sum32())
153+
154+
metadata := struct {
155+
Hash int `json:"hash"`
156+
Version int `json:"version"`
157+
DurableFrameNum int `json:"durable_frame_num"`
158+
Generation int `json:"generation"`
159+
}{
160+
Hash: hash,
161+
Version: 0,
162+
DurableFrameNum: 0,
163+
Generation: info.CurrentGeneration,
164+
}
165+
if err := json.NewEncoder(out).Encode(metadata); err != nil {
166+
return fmt.Errorf("failed to write metadata to file: %w", err)
167+
}
168+
}
169+
return nil
170+
}

0 commit comments

Comments
 (0)