diff --git a/go.mod b/go.mod index c9743f90..be576413 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,25 @@ module sub2api-cn-relay-manager go 1.22.2 + +require modernc.org/sqlite v1.18.1 + +require ( + github.com/google/uuid v1.3.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-isatty v0.0.12 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect + golang.org/x/mod v0.3.0 // indirect + golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac // indirect + golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + lukechampine.com/uint128 v1.1.1 // indirect + modernc.org/cc/v3 v3.36.0 // indirect + modernc.org/ccgo/v3 v3.16.8 // indirect + modernc.org/libc v1.16.19 // indirect + modernc.org/mathutil v1.4.1 // indirect + modernc.org/memory v1.1.1 // indirect + modernc.org/opt v0.1.1 // indirect + modernc.org/strutil v1.1.1 // indirect + modernc.org/token v1.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..39780c60 --- /dev/null +++ b/go.sum @@ -0,0 +1,80 @@ +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/google/go-cmp v0.5.3 h1:x95R7cp+rSeeqAMI2knLtQ0DKlaBhv2NrtrOvafPHRo= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw= +github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac h1:oN6lz7iLW/YC7un8pq+9bOLyXrprv2+DKfkJY+2LJJw= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs= +golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU= +lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.36.0 h1:0kmRkTmqNidmu3c7BNDSdVHCxXCkWLmWmCIVX4LUboo= +modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc= +modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw= +modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= +modernc.org/ccgo/v3 v3.16.8 h1:G0QNlTqI5uVgczBWfGKs7B++EPwCfXPWGD2MdeKloDs= +modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws= +modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= +modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= +modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= +modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= +modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= +modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A= +modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU= +modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU= +modernc.org/libc v1.16.19 h1:S8flPn5ZeXx6iw/8yNa986hwTQDrY8RXU7tObZuAozo= +modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= +modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8= +modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.1.1 h1:bDOL0DIDLQv7bWhP3gMvIrnoFw+Eo6F7a2QK9HPDiFU= +modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= +modernc.org/opt v0.1.1 h1:/0RX92k9vwVeDXj+Xn23DKp2VJubL7k8qNffND6qn3A= +modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8= +modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= +modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs= +modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= +modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao= +modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw= +modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk= +modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM= +modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= diff --git a/internal/store/migrations/0001_init.sql b/internal/store/migrations/0001_init.sql new file mode 100644 index 00000000..69fd4efe --- /dev/null +++ b/internal/store/migrations/0001_init.sql @@ -0,0 +1,32 @@ +CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id TEXT NOT NULL UNIQUE, + base_url TEXT NOT NULL, + host_version TEXT NOT NULL, + capability_probe_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE packs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pack_id TEXT NOT NULL UNIQUE, + version TEXT NOT NULL, + checksum TEXT NOT NULL, + installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pack_id INTEGER NOT NULL, + provider_id TEXT NOT NULL, + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + platform TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_providers_pack + FOREIGN KEY (pack_id) + REFERENCES packs(id) + ON DELETE CASCADE, + CONSTRAINT uq_providers_pack_provider + UNIQUE (pack_id, provider_id) +); diff --git a/internal/store/migrations/embed.go b/internal/store/migrations/embed.go new file mode 100644 index 00000000..518dc758 --- /dev/null +++ b/internal/store/migrations/embed.go @@ -0,0 +1,8 @@ +package migrations + +import "embed" + +// Files embeds SQL migration assets for release builds. +// +//go:embed *.sql +var Files embed.FS diff --git a/internal/store/sqlite/db.go b/internal/store/sqlite/db.go new file mode 100644 index 00000000..1c6f972f --- /dev/null +++ b/internal/store/sqlite/db.go @@ -0,0 +1,340 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io/fs" + "sort" + "strings" + + _ "modernc.org/sqlite" + "sub2api-cn-relay-manager/internal/store/migrations" +) + +type execQuerier interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +type Queries struct { + Hosts *HostsRepo + Packs *PacksRepo + Providers *ProvidersRepo +} + +type DB struct { + sqlDB *sql.DB + queries *Queries +} + +func Open(ctx context.Context, dsn string) (*DB, error) { + sqlDB, err := sql.Open("sqlite", withForeignKeysEnabled(dsn)) + if err != nil { + return nil, fmt.Errorf("open sqlite database: %w", err) + } + + if err := sqlDB.PingContext(ctx); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("ping sqlite database: %w", err) + } + + if err := ensureForeignKeys(ctx, sqlDB); err != nil { + _ = sqlDB.Close() + return nil, err + } + + if err := migrate(ctx, sqlDB); err != nil { + _ = sqlDB.Close() + return nil, err + } + + return &DB{ + sqlDB: sqlDB, + queries: newQueries(sqlDB), + }, nil +} + +func (db *DB) Close() error { + return db.sqlDB.Close() +} + +func (db *DB) SQLDB() *sql.DB { + return db.sqlDB +} + +func (db *DB) Hosts() *HostsRepo { + return db.queries.Hosts +} + +func (db *DB) Packs() *PacksRepo { + return db.queries.Packs +} + +func (db *DB) Providers() *ProvidersRepo { + return db.queries.Providers +} + +func (db *DB) WithTx(ctx context.Context, fn func(*Queries) error) error { + tx, err := db.sqlDB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin sqlite transaction: %w", err) + } + + queries := newQueries(tx) + if err := fn(queries); err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + return errors.Join(err, fmt.Errorf("rollback sqlite transaction: %w", rollbackErr)) + } + + return err + } + + if err := tx.Commit(); err != nil { + _ = tx.Rollback() + return fmt.Errorf("commit sqlite transaction: %w", err) + } + + return nil +} + +func newQueries(db execQuerier) *Queries { + return &Queries{ + Hosts: newHostsRepo(db), + Packs: newPacksRepo(db), + Providers: newProvidersRepo(db), + } +} + +func migrate(ctx context.Context, db *sql.DB) error { + migrationNames, err := migrationFileNames() + if err != nil { + return err + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin sqlite migration transaction: %w", err) + } + + if err := ensureMigrationLedger(ctx, tx); err != nil { + return rollbackMigration(tx, err) + } + + appliedMigrations, err := loadAppliedMigrations(ctx, tx) + if err != nil { + return rollbackMigration(tx, err) + } + + if err := backfillLegacySchemaIfNeeded(ctx, tx, migrationNames, appliedMigrations); err != nil { + return rollbackMigration(tx, err) + } + + for _, name := range migrationNames { + if appliedMigrations[name] { + continue + } + + migrationSQL, err := readMigration(name) + if err != nil { + return rollbackMigration(tx, err) + } + + if _, err := tx.ExecContext(ctx, migrationSQL); err != nil { + return rollbackMigration(tx, fmt.Errorf("apply sqlite migration %s: %w", name, err)) + } + + if _, err := tx.ExecContext( + ctx, + "INSERT INTO schema_migrations (version) VALUES (?)", + name, + ); err != nil { + return rollbackMigration(tx, fmt.Errorf("record sqlite migration %s: %w", name, err)) + } + } + + if err := tx.Commit(); err != nil { + _ = tx.Rollback() + return fmt.Errorf("commit sqlite migration transaction: %w", err) + } + + return nil +} + +func withForeignKeysEnabled(dsn string) string { + const pragma = "_pragma=foreign_keys(1)" + + if strings.Contains(dsn, "?") { + return dsn + "&" + pragma + } + + return dsn + "?" + pragma +} + +func ensureForeignKeys(ctx context.Context, db *sql.DB) error { + if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { + return fmt.Errorf("enable sqlite foreign keys: %w", err) + } + + var enabled int + if err := db.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&enabled); err != nil { + return fmt.Errorf("verify sqlite foreign keys: %w", err) + } + if enabled != 1 { + return errors.New("sqlite foreign keys are disabled") + } + + return nil +} + +func ensureMigrationLedger(ctx context.Context, tx *sql.Tx) error { + const createLedgerSQL = ` +CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +)` + + if _, err := tx.ExecContext(ctx, createLedgerSQL); err != nil { + return fmt.Errorf("create schema_migrations table: %w", err) + } + + return nil +} + +func loadAppliedMigrations(ctx context.Context, tx *sql.Tx) (map[string]bool, error) { + rows, err := tx.QueryContext(ctx, "SELECT version FROM schema_migrations") + if err != nil { + return nil, fmt.Errorf("query applied sqlite migrations: %w", err) + } + defer rows.Close() + + applied := make(map[string]bool) + for rows.Next() { + var version string + if err := rows.Scan(&version); err != nil { + return nil, fmt.Errorf("scan applied sqlite migration: %w", err) + } + + applied[version] = true + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate applied sqlite migrations: %w", err) + } + + return applied, nil +} + +func migrationFileNames() ([]string, error) { + entries, err := fs.ReadDir(migrations.Files, ".") + if err != nil { + return nil, fmt.Errorf("read embedded sqlite migrations: %w", err) + } + + var names []string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + + names = append(names, entry.Name()) + } + + sort.Strings(names) + return names, nil +} + +func readMigration(name string) (string, error) { + data, err := fs.ReadFile(migrations.Files, name) + if err != nil { + return "", fmt.Errorf("read embedded migration %s: %w", name, err) + } + + return string(data), nil +} + +func rollbackMigration(tx *sql.Tx, err error) error { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + return errors.Join(err, fmt.Errorf("rollback sqlite migration transaction: %w", rollbackErr)) + } + + return err +} + +func backfillLegacySchemaIfNeeded(ctx context.Context, tx *sql.Tx, migrationNames []string, appliedMigrations map[string]bool) error { + if len(migrationNames) == 0 { + return nil + } + if len(appliedMigrations) != 0 { + return nil + } + + firstMigration := migrationNames[0] + if firstMigration != "0001_init.sql" { + return nil + } + + complete, partial, err := detectLegacy0001Schema(ctx, tx) + if err != nil { + return err + } + if partial { + return errors.New("legacy sqlite schema is partially applied without schema_migrations") + } + if !complete { + return nil + } + + if _, err := tx.ExecContext( + ctx, + "INSERT INTO schema_migrations (version) VALUES (?)", + firstMigration, + ); err != nil { + return fmt.Errorf("backfill sqlite migration %s: %w", firstMigration, err) + } + + appliedMigrations[firstMigration] = true + return nil +} + +func detectLegacy0001Schema(ctx context.Context, tx *sql.Tx) (complete bool, partial bool, err error) { + legacyTables := []string{"hosts", "packs", "providers"} + + existing := 0 + for _, table := range legacyTables { + found, err := tableExists(ctx, tx, table) + if err != nil { + return false, false, err + } + if found { + existing++ + } + } + + switch existing { + case 0: + return false, false, nil + case len(legacyTables): + return true, false, nil + default: + return false, true, nil + } +} + +func tableExists(ctx context.Context, db execQuerier, table string) (bool, error) { + var name string + err := db.QueryRowContext( + ctx, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + table, + ).Scan(&name) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("check sqlite table %s: %w", table, err) + } + + return name == table, nil +} diff --git a/internal/store/sqlite/hosts_repo.go b/internal/store/sqlite/hosts_repo.go new file mode 100644 index 00000000..44ec57a3 --- /dev/null +++ b/internal/store/sqlite/hosts_repo.go @@ -0,0 +1,60 @@ +package sqlite + +import ( + "context" + "fmt" + "strings" +) + +type Host struct { + HostID string + BaseURL string + HostVersion string + CapabilityProbeJSON string +} + +type HostsRepo struct { + db execQuerier +} + +func newHostsRepo(db execQuerier) *HostsRepo { + return &HostsRepo{db: db} +} + +func (r *HostsRepo) Create(ctx context.Context, host Host) (int64, error) { + hostID := strings.TrimSpace(host.HostID) + baseURL := strings.TrimSpace(host.BaseURL) + hostVersion := strings.TrimSpace(host.HostVersion) + capabilityProbeJSON := strings.TrimSpace(host.CapabilityProbeJSON) + + switch { + case hostID == "": + return 0, fmt.Errorf("host_id is required") + case baseURL == "": + return 0, fmt.Errorf("base_url is required") + case hostVersion == "": + return 0, fmt.Errorf("host_version is required") + case capabilityProbeJSON == "": + capabilityProbeJSON = "{}" + } + + result, err := r.db.ExecContext( + ctx, + `INSERT INTO hosts (host_id, base_url, host_version, capability_probe_json) + VALUES (?, ?, ?, ?)`, + hostID, + baseURL, + hostVersion, + capabilityProbeJSON, + ) + if err != nil { + return 0, fmt.Errorf("insert host %q: %w", hostID, err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("read inserted host id for %q: %w", hostID, err) + } + + return id, nil +} diff --git a/internal/store/sqlite/packs_repo.go b/internal/store/sqlite/packs_repo.go new file mode 100644 index 00000000..cab58c0b --- /dev/null +++ b/internal/store/sqlite/packs_repo.go @@ -0,0 +1,55 @@ +package sqlite + +import ( + "context" + "fmt" + "strings" +) + +type Pack struct { + PackID string + Version string + Checksum string +} + +type PacksRepo struct { + db execQuerier +} + +func newPacksRepo(db execQuerier) *PacksRepo { + return &PacksRepo{db: db} +} + +func (r *PacksRepo) Create(ctx context.Context, pack Pack) (int64, error) { + packID := strings.TrimSpace(pack.PackID) + version := strings.TrimSpace(pack.Version) + checksum := strings.TrimSpace(pack.Checksum) + + switch { + case packID == "": + return 0, fmt.Errorf("pack_id is required") + case version == "": + return 0, fmt.Errorf("version is required") + case checksum == "": + return 0, fmt.Errorf("checksum is required") + } + + result, err := r.db.ExecContext( + ctx, + `INSERT INTO packs (pack_id, version, checksum) + VALUES (?, ?, ?)`, + packID, + version, + checksum, + ) + if err != nil { + return 0, fmt.Errorf("insert pack %q: %w", packID, err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("read inserted pack id for %q: %w", packID, err) + } + + return id, nil +} diff --git a/internal/store/sqlite/providers_repo.go b/internal/store/sqlite/providers_repo.go new file mode 100644 index 00000000..382a0512 --- /dev/null +++ b/internal/store/sqlite/providers_repo.go @@ -0,0 +1,64 @@ +package sqlite + +import ( + "context" + "fmt" + "strings" +) + +type Provider struct { + PackID int64 + ProviderID string + DisplayName string + BaseURL string + Platform string +} + +type ProvidersRepo struct { + db execQuerier +} + +func newProvidersRepo(db execQuerier) *ProvidersRepo { + return &ProvidersRepo{db: db} +} + +func (r *ProvidersRepo) Create(ctx context.Context, provider Provider) (int64, error) { + providerID := strings.TrimSpace(provider.ProviderID) + displayName := strings.TrimSpace(provider.DisplayName) + baseURL := strings.TrimSpace(provider.BaseURL) + platform := strings.TrimSpace(provider.Platform) + + switch { + case provider.PackID <= 0: + return 0, fmt.Errorf("pack_id is required") + case providerID == "": + return 0, fmt.Errorf("provider_id is required") + case displayName == "": + return 0, fmt.Errorf("display_name is required") + case baseURL == "": + return 0, fmt.Errorf("base_url is required") + case platform == "": + return 0, fmt.Errorf("platform is required") + } + + result, err := r.db.ExecContext( + ctx, + `INSERT INTO providers (pack_id, provider_id, display_name, base_url, platform) + VALUES (?, ?, ?, ?, ?)`, + provider.PackID, + providerID, + displayName, + baseURL, + platform, + ) + if err != nil { + return 0, fmt.Errorf("insert provider %q: %w", providerID, err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("read inserted provider id for %q: %w", providerID, err) + } + + return id, nil +} diff --git a/tests/integration/store_init_test.go b/tests/integration/store_init_test.go new file mode 100644 index 00000000..46eb3a41 --- /dev/null +++ b/tests/integration/store_init_test.go @@ -0,0 +1,291 @@ +package integration_test + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" + "sub2api-cn-relay-manager/internal/store/sqlite" +) + +func TestStoreInitCreatesRequiredTables(t *testing.T) { + store := openTestStore(t) + defer closeTestStore(t, store) + + for _, table := range []string{"hosts", "packs", "providers"} { + if !tableExists(t, store.SQLDB(), table) { + t.Fatalf("table %q does not exist after store initialization", table) + } + } +} + +func TestStoreInitEnforcesUniqueConstraints(t *testing.T) { + ctx := context.Background() + store := openTestStore(t) + defer closeTestStore(t, store) + + packID, err := store.Packs().Create(ctx, sqlite.Pack{ + PackID: "openai-cn-pack", + Version: "1.0.0", + Checksum: "checksum-1", + }) + if err != nil { + t.Fatalf("Packs().Create() error = %v", err) + } + + provider := sqlite.Provider{ + PackID: packID, + ProviderID: "deepseek", + DisplayName: "DeepSeek", + BaseURL: "https://api.deepseek.com", + Platform: "openai", + } + + if _, err := store.Providers().Create(ctx, provider); err != nil { + t.Fatalf("Providers().Create() first call error = %v", err) + } + + if _, err := store.Providers().Create(ctx, provider); err == nil { + t.Fatal("Providers().Create() second call error = nil, want unique constraint failure") + } +} + +func TestStoreInitEnforcesProviderForeignKey(t *testing.T) { + ctx := context.Background() + store := openTestStore(t) + defer closeTestStore(t, store) + + _, err := store.Providers().Create(ctx, sqlite.Provider{ + PackID: 9999, + ProviderID: "ghost", + DisplayName: "Ghost", + BaseURL: "https://ghost.example.com", + Platform: "openai", + }) + if err == nil { + t.Fatal("Providers().Create() error = nil, want foreign key failure") + } +} + +func TestStoreInitRollsBackTransaction(t *testing.T) { + ctx := context.Background() + store := openTestStore(t) + defer closeTestStore(t, store) + + wantErr := errors.New("force rollback") + + err := store.WithTx(ctx, func(queries *sqlite.Queries) error { + _, err := queries.Hosts.Create(ctx, sqlite.Host{ + HostID: "host-1", + BaseURL: "https://host.example.com", + HostVersion: "0.1.126", + CapabilityProbeJSON: `{"supports_batch_accounts":true}`, + }) + if err != nil { + return err + } + + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("WithTx() error = %v, want %v", err, wantErr) + } + + if got := countRows(t, store.SQLDB(), "hosts"); got != 0 { + t.Fatalf("hosts row count after rollback = %d, want 0", got) + } +} + +func TestStoreInitRecordsMigrationLedgerOnce(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "state.db") + dsn := fmt.Sprintf("file:%s?_busy_timeout=5000", filepath.ToSlash(dbPath)) + + store1, err := sqlite.Open(context.Background(), dsn) + if err != nil { + t.Fatalf("first sqlite.Open() error = %v", err) + } + if got := countRows(t, store1.SQLDB(), "schema_migrations"); got != 1 { + t.Fatalf("schema_migrations row count after first open = %d, want 1", got) + } + if err := store1.Close(); err != nil { + t.Fatalf("first store.Close() error = %v", err) + } + + store2, err := sqlite.Open(context.Background(), dsn) + if err != nil { + t.Fatalf("second sqlite.Open() error = %v", err) + } + defer closeTestStore(t, store2) + + if got := countRows(t, store2.SQLDB(), "schema_migrations"); got != 1 { + t.Fatalf("schema_migrations row count after second open = %d, want 1", got) + } +} + +func TestStoreInitBackfillsLedgerForCompletePreLedgerSchema(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "state.db") + dsn := fmt.Sprintf("file:%s?_busy_timeout=5000", filepath.ToSlash(dbPath)) + + rawDB := openRawSQLiteDB(t, dsn) + createLegacy0001Schema(t, rawDB) + closeRawSQLiteDB(t, rawDB) + + store, err := sqlite.Open(context.Background(), dsn) + if err != nil { + t.Fatalf("sqlite.Open() on complete pre-ledger schema error = %v", err) + } + defer closeTestStore(t, store) + + if got := countRows(t, store.SQLDB(), "schema_migrations"); got != 1 { + t.Fatalf("schema_migrations row count after backfill = %d, want 1", got) + } +} + +func TestStoreInitFailsWhenPreLedgerSchemaIsPartial(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "state.db") + dsn := fmt.Sprintf("file:%s?_busy_timeout=5000", filepath.ToSlash(dbPath)) + + rawDB := openRawSQLiteDB(t, dsn) + mustExec(t, rawDB, ` +CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id TEXT NOT NULL UNIQUE, + base_url TEXT NOT NULL, + host_version TEXT NOT NULL, + capability_probe_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +)`) + closeRawSQLiteDB(t, rawDB) + + store, err := sqlite.Open(context.Background(), dsn) + if err == nil { + closeTestStore(t, store) + t.Fatal("sqlite.Open() error = nil, want partial pre-ledger schema failure") + } +} + +func openTestStore(t *testing.T) *sqlite.DB { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "state.db") + dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_pragma=foreign_keys(0)", filepath.ToSlash(dbPath)) + + store, err := sqlite.Open(context.Background(), dsn) + if err != nil { + t.Fatalf("sqlite.Open() error = %v", err) + } + + return store +} + +func openRawSQLiteDB(t *testing.T, dsn string) *sql.DB { + t.Helper() + + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + + if err := db.PingContext(context.Background()); err != nil { + t.Fatalf("raw db PingContext() error = %v", err) + } + + return db +} + +func closeRawSQLiteDB(t *testing.T, db *sql.DB) { + t.Helper() + + if err := db.Close(); err != nil { + t.Fatalf("raw db Close() error = %v", err) + } +} + +func createLegacy0001Schema(t *testing.T, db *sql.DB) { + t.Helper() + + mustExec(t, db, ` +CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id TEXT NOT NULL UNIQUE, + base_url TEXT NOT NULL, + host_version TEXT NOT NULL, + capability_probe_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +)`) + mustExec(t, db, ` +CREATE TABLE packs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pack_id TEXT NOT NULL UNIQUE, + version TEXT NOT NULL, + checksum TEXT NOT NULL, + installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +)`) + mustExec(t, db, ` +CREATE TABLE providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pack_id INTEGER NOT NULL, + provider_id TEXT NOT NULL, + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + platform TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_providers_pack + FOREIGN KEY (pack_id) + REFERENCES packs(id) + ON DELETE CASCADE, + CONSTRAINT uq_providers_pack_provider + UNIQUE (pack_id, provider_id) +)`) +} + +func mustExec(t *testing.T, db *sql.DB, statement string) { + t.Helper() + + if _, err := db.ExecContext(context.Background(), statement); err != nil { + t.Fatalf("ExecContext() error = %v", err) + } +} + +func closeTestStore(t *testing.T, store *sqlite.DB) { + t.Helper() + + if err := store.Close(); err != nil { + t.Fatalf("store.Close() error = %v", err) + } +} + +func tableExists(t *testing.T, db *sql.DB, table string) bool { + t.Helper() + + var name string + err := db.QueryRowContext( + context.Background(), + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + table, + ).Scan(&name) + if errors.Is(err, sql.ErrNoRows) { + return false + } + if err != nil { + t.Fatalf("tableExists(%q) query error = %v", table, err) + } + + return name == table +} + +func countRows(t *testing.T, db *sql.DB, table string) int { + t.Helper() + + var count int + query := fmt.Sprintf("SELECT COUNT(*) FROM %s", table) + if err := db.QueryRowContext(context.Background(), query).Scan(&count); err != nil { + t.Fatalf("countRows(%q) query error = %v", table, err) + } + + return count +}