feat: add state store migrations and repositories

This commit is contained in:
phamnazage-jpg
2026-05-12 23:25:02 +08:00
parent 9d52b22b8d
commit a1d7007397
9 changed files with 952 additions and 0 deletions

22
go.mod
View File

@@ -1,3 +1,25 @@
module sub2api-cn-relay-manager module sub2api-cn-relay-manager
go 1.22.2 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
)

80
go.sum Normal file
View File

@@ -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=

View File

@@ -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)
);

View File

@@ -0,0 +1,8 @@
package migrations
import "embed"
// Files embeds SQL migration assets for release builds.
//
//go:embed *.sql
var Files embed.FS

340
internal/store/sqlite/db.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}