remote.go

95 lines
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
package engines

import (
	"database/sql"
	"fmt"
	"log"
	"time"

	"congo.gg/pkg/database"
	"github.com/tursodatabase/go-libsql"
)

// RemoteOption configures a remote database
type RemoteOption func(*remoteConfig)

type remoteConfig struct {
	syncInterval time.Duration
}

// WithSyncInterval sets the automatic sync interval.
// If not set, sync must be called manually.
func WithSyncInterval(d time.Duration) RemoteOption {
	return func(c *remoteConfig) {
		c.syncInterval = d
	}
}

// NewRemote creates a new embedded replica database that syncs with a remote libSQL server.
// It maintains a local copy for fast reads while syncing writes to the primary.
//
// The localPath is where the local replica is stored.
// The primaryURL is the remote libSQL server URL (e.g., "libsql://db-name.turso.io").
// The authToken is the authentication token for the remote server.
//
// Returns an error if the connection cannot be established after retries.
//
// Example:
//
//	db, err := engines.NewRemote(
//	    "./data/replica.db",
//	    os.Getenv("DB_URL"),
//	    os.Getenv("DB_TOKEN"),
//	    engines.WithSyncInterval(time.Minute),
//	)
func NewRemote(localPath, primaryURL, authToken string, opts ...RemoteOption) (*database.Database, error) {
	cfg := &remoteConfig{}
	for _, opt := range opts {
		opt(cfg)
	}

	// Build libsql options
	libsqlOpts := []libsql.Option{
		libsql.WithAuthToken(authToken),
	}

	if cfg.syncInterval > 0 {
		libsqlOpts = append(libsqlOpts, libsql.WithSyncInterval(cfg.syncInterval))
	}

	// Retry with exponential backoff (2s, 4s, 8s)
	var connector *libsql.Connector
	var err error
	for attempt, backoff := range []time.Duration{0, 2 * time.Second, 4 * time.Second, 8 * time.Second} {
		if backoff > 0 {
			log.Printf("database: retrying remote connection (attempt %d/3)...", attempt)
			time.Sleep(backoff)
		}
		connector, err = libsql.NewEmbeddedReplicaConnector(
			localPath,
			primaryURL,
			libsqlOpts...,
		)
		if err == nil {
			break
		}
	}
	if err != nil {
		return nil, fmt.Errorf("failed to create database connector after 3 retries: %w", err)
	}

	db := sql.OpenDB(connector)

	return &database.Database{DB: db, Sync: syncer{connector}}, nil
}

// syncer wraps a libsql.Connector to implement the database.Syncer interface.
type syncer struct {
	connector *libsql.Connector
}

// Sync triggers a manual sync of the local replica with the remote primary.
func (s syncer) Sync() error {
	_, err := s.connector.Sync()
	return err
}