From 342312a9688bb3a9d291d4ac3278a998bcc671ce Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Fri, 4 Sep 2026 16:55:42 +0530 Subject: [PATCH 1/3] feat(storage): add SereneDB backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SereneDB speaks the PostgreSQL wire protocol, so the SQL provider drives it unchanged except in two places. Rather than branch shared code, the new provider embeds sql.Provider and overrides only those two: - Migration. SereneDB reports varchar(n) as text, so GORM AutoMigrate re-issues ALTER COLUMN ... TYPE on every boot and SereneDB refuses it on an indexed column — the second startup failed. Replaced with a create-only migrator (CREATE TABLE / ADD COLUMN / CREATE INDEX), the DDL SereneDB actually supports. - Upserts with an explicit conflict target. SereneDB resolves ON CONFLICT (cols) only against an inline UNIQUE constraint, never a CREATE UNIQUE INDEX — which is what GORM emits for `uniqueIndex` and what ALTER TABLE cannot retrofit. AddAuthenticator and AddVerificationRequest now insert bare and resolve the conflict off the 23505 the index still raises, so MFA enrollment and repeat verification requests keep their race guarantees. Its MVCC is optimistic: contended writers abort with 40001 instead of blocking, so both upserts retry rather than surfacing the conflict. Not auto-mapped as an FGA store — it is a search-OLAP engine, not an OpenFGA datastore, and needs an explicit --fga-store. Verified against serenedb/serenedb 26.08.2: full storage suite passes on a fresh database and on a second run against the existing schema. Postgres, SQLite and MongoDB suites unchanged. Claude-Session: https://claude.ai/code/session_01JGxmhT63jvFwuTeujfbC45 --- Makefile | 14 +- internal/config/fga.go | 4 +- internal/config/fga_test.go | 8 + internal/constants/db_types.go | 2 + internal/storage/db/serenedb/provider.go | 81 +++++++++ internal/storage/db/serenedb/provider_test.go | 157 ++++++++++++++++++ internal/storage/db/serenedb/upsert.go | 120 +++++++++++++ internal/storage/db/sql/provider.go | 42 ++++- internal/storage/provider.go | 5 + internal/storage/provider_test.go | 3 + scripts/wait-for-test-dbs.sh | 2 +- 11 files changed, 429 insertions(+), 9 deletions(-) create mode 100644 internal/storage/db/serenedb/provider.go create mode 100644 internal/storage/db/serenedb/provider_test.go create mode 100644 internal/storage/db/serenedb/upsert.go diff --git a/Makefile b/Makefile index 206156b83..9e8cfe7c7 100644 --- a/Makefile +++ b/Makefile @@ -123,6 +123,14 @@ test-postgres: test-cleanup-postgres docker rm -vf authorizer_postgres; \ exit $$status +test-serenedb: test-cleanup-serenedb + docker run -d --name authorizer_serenedb -p 7890:7890 -e POSTGRES_PASSWORD=postgres serenedb/serenedb:26.08.2 + sh scripts/wait-for-test-dbs.sh serenedb && \ + { go clean --testcache; TEST_DBS="serenedb" $(GO_TEST_ALL); }; \ + status=$$?; \ + docker rm -vf authorizer_serenedb; \ + exit $$status + test-sqlite: go clean --testcache && TEST_DBS="sqlite" $(GO_TEST_ALL) @@ -203,7 +211,7 @@ test-all-db: test-cleanup test-docker-up @# failed to start them and produced a second, misleading failure. Same @# capture-status-then-clean shape the e2e-playground target already uses. go clean --testcache; \ - TEST_DBS="couchbase,postgres,sqlite,mongodb,arangodb,scylladb,dynamodb" $(GO_TEST_ALL); \ + TEST_DBS="couchbase,postgres,serenedb,sqlite,mongodb,arangodb,scylladb,dynamodb" $(GO_TEST_ALL); \ status=$$?; \ $(MAKE) test-cleanup; \ exit $$status @@ -212,6 +220,7 @@ test-all-db: test-cleanup test-docker-up test-docker-up: docker run -d --name authorizer_redis -p 6380:6379 redis docker run -d --name authorizer_postgres -p 5434:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres postgres + docker run -d --name authorizer_serenedb -p 7890:7890 -e POSTGRES_PASSWORD=postgres serenedb/serenedb:26.08.2 docker run -d --name authorizer_mongodb_db -p 27017:27017 mongo:4.4.15 docker run -d --name authorizer_scylla_db -p 9042:9042 scylladb/scylla docker run -d --name authorizer_arangodb -p 8529:8529 -e ARANGO_NO_AUTH=1 arangodb/arangodb:3.10.3 @@ -223,6 +232,7 @@ test-docker-up: # Remove all test database containers test-cleanup: -docker rm -vf authorizer_postgres + -docker rm -vf authorizer_serenedb -docker rm -vf authorizer_scylla_db -docker rm -vf authorizer_mongodb_db -docker rm -vf authorizer_arangodb @@ -232,6 +242,8 @@ test-cleanup: test-cleanup-postgres: -docker rm -vf authorizer_postgres +test-cleanup-serenedb: + -docker rm -vf authorizer_serenedb test-cleanup-mongodb: -docker rm -vf authorizer_mongodb_db test-cleanup-scylladb: diff --git a/internal/config/fga.go b/internal/config/fga.go index b7899b235..4f2b35d48 100644 --- a/internal/config/fga.go +++ b/internal/config/fga.go @@ -29,8 +29,8 @@ func (c *Config) FGAStoreConfig() (store string, url string, enabled bool) { // Derive from the main database when OpenFGA supports it. Postgres- and // mysql-compatible variants beyond these (cockroachdb, yugabyte, libsql, - // planetscale) are intentionally NOT auto-mapped — they require an explicit - // --fga-store to avoid silent incompatibilities. + // planetscale, serenedb) are intentionally NOT auto-mapped — they require an + // explicit --fga-store to avoid silent incompatibilities. switch c.DatabaseType { case constants.DbTypePostgres: return "postgres", c.DatabaseURL, true diff --git a/internal/config/fga_test.go b/internal/config/fga_test.go index 0df3c6969..05c2065ad 100644 --- a/internal/config/fga_test.go +++ b/internal/config/fga_test.go @@ -127,6 +127,14 @@ func TestFGAStoreConfig(t *testing.T) { cfg: Config{DatabaseType: "cockroachdb", DatabaseURL: "postgres://h/db"}, wantEnabled: false, }, + { + // SereneDB speaks the Postgres wire protocol but is a search-OLAP + // engine, not an OpenFGA datastore. Auto-mapping it would point the + // authorization store at a backend OpenFGA never migrated. + name: "serenedb is NOT auto-mapped (needs explicit store)", + cfg: Config{DatabaseType: "serenedb", DatabaseURL: "postgres://h/db"}, + wantEnabled: false, + }, } for _, tc := range cases { diff --git a/internal/constants/db_types.go b/internal/constants/db_types.go index d6d0777f2..31bbfedcf 100644 --- a/internal/constants/db_types.go +++ b/internal/constants/db_types.go @@ -19,6 +19,8 @@ const ( DbTypePlanetScaleDB = "planetscale" // DbTypeCockroachDB is the cockroach database type DbTypeCockroachDB = "cockroachdb" + // DbTypeSereneDB is the serenedb database type (PostgreSQL wire protocol) + DbTypeSereneDB = "serenedb" // DbTypeArangoDB is the arangodb database type DbTypeArangoDB = "arangodb" diff --git a/internal/storage/db/serenedb/provider.go b/internal/storage/db/serenedb/provider.go new file mode 100644 index 000000000..ef4fd23a7 --- /dev/null +++ b/internal/storage/db/serenedb/provider.go @@ -0,0 +1,81 @@ +// Package serenedb adapts the SQL storage provider to SereneDB, a search-OLAP +// engine that speaks the PostgreSQL wire protocol. It reuses the SQL provider +// wholesale and overrides only the two places where SereneDB's SQL differs from +// PostgreSQL's: +// +// 1. Migration. GORM AutoMigrate reconciles column types on every boot, but +// SereneDB reports varchar(n) as text and refuses ALTER COLUMN ... TYPE on +// an indexed column, so the second startup fails. migrate below creates what +// is missing and never alters what exists. +// 2. Upserts with an explicit conflict target (see upsert.go). +// +// ponytail: SereneDB's MVCC is optimistic, so contended same-row writes abort +// with SQLSTATE 40001 instead of blocking. Only the two upserts here retry it; +// every other write path inherits the SQL provider unchanged and will surface +// 40001 to the caller under the same contention. Move the retry to a GORM +// callback if that shows up in practice. +package serenedb + +import ( + "github.com/rs/zerolog" + "gorm.io/gorm" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/storage/db/sql" +) + +// Dependencies struct for the serenedb data store provider +type Dependencies struct { + Log *zerolog.Logger +} + +type provider struct { + *sql.Provider +} + +// NewProvider returns a new SereneDB provider +func NewProvider(cfg *config.Config, deps *Dependencies) (*provider, error) { + base, err := sql.NewProviderWithMigrate(cfg, &sql.Dependencies{Log: deps.Log}, migrate) + if err != nil { + return nil, err + } + return &provider{Provider: base}, nil +} + +// migrate creates missing tables, columns and indexes, and nothing else. +// SereneDB supports CREATE TABLE, ALTER TABLE ADD COLUMN and CREATE INDEX, but +// not the ALTER COLUMN ... TYPE / SET NOT NULL / SET DEFAULT that GORM's +// AutoMigrate issues when reconciling an existing table. +func migrate(db *gorm.DB) error { + m := db.Migrator() + for _, model := range sql.Models() { + if !m.HasTable(model) { + if err := m.CreateTable(model); err != nil { + return err + } + continue + } + stmt := &gorm.Statement{DB: db} + if err := stmt.Parse(model); err != nil { + return err + } + for _, field := range stmt.Schema.Fields { + if field.DBName == "" || field.IgnoreMigration { + continue + } + if !m.HasColumn(model, field.DBName) { + if err := m.AddColumn(model, field.DBName); err != nil { + return err + } + } + } + for name := range stmt.Schema.ParseIndexes() { + if !m.HasIndex(model, name) { + if err := m.CreateIndex(model, name); err != nil { + return err + } + } + } + } + return nil +} diff --git a/internal/storage/db/serenedb/provider_test.go b/internal/storage/db/serenedb/provider_test.go new file mode 100644 index 000000000..a97e3ef2a --- /dev/null +++ b/internal/storage/db/serenedb/provider_test.go @@ -0,0 +1,157 @@ +package serenedb + +import ( + "context" + "net" + "os" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +const testDBURL = "postgres://postgres:postgres@localhost:7890/postgres" + +// newTestProvider connects to the SereneDB container started by +// `make test-serenedb`. Gated on TEST_DBS first, the way the SQL migration +// tests are, so `make test` (TEST_DBS=sqlite) stays Docker-free even on a +// machine that happens to have something bound to :7890. +func newTestProvider(t *testing.T) *provider { + t.Helper() + if !slices.Contains(strings.Split(os.Getenv("TEST_DBS"), ","), constants.DbTypeSereneDB) { + t.Skip("set TEST_DBS=serenedb (make test-serenedb) to run SereneDB tests") + } + conn, err := net.DialTimeout("tcp", "localhost:7890", 2*time.Second) + if err != nil { + t.Skipf("skipping SereneDB tests: not reachable on localhost:7890: %v", err) + } + _ = conn.Close() + + logger := zerolog.New(zerolog.NewTestWriter(t)).With().Timestamp().Logger() + p, err := NewProvider(&config.Config{ + DatabaseType: constants.DbTypeSereneDB, + DatabaseURL: testDBURL, + DatabaseName: "authorizer_test", + }, &Dependencies{Log: &logger}) + require.NoError(t, err) + require.NotNil(t, p) + return p +} + +// TestMigrateIsRepeatable is the restart path. GORM AutoMigrate fails here on +// the second boot — SereneDB reports varchar(n) as text, so AutoMigrate tries +// ALTER COLUMN ... TYPE and SereneDB refuses it on an indexed column. The +// create-only migrator must be a no-op once the schema exists. +func TestMigrateIsRepeatable(t *testing.T) { + p := newTestProvider(t) + require.NoError(t, p.Close()) + + for i := 0; i < 2; i++ { + p := newTestProvider(t) + require.NoError(t, p.Close()) + } +} + +// TestAddVerificationRequestUpsert covers the ON CONFLICT (email, identifier) +// fallback: SereneDB rejects a unique index as a conflict target, so the second +// request must be turned into an UPDATE off the 23505, not surface as an error +// or leave a duplicate row. +func TestAddVerificationRequestUpsert(t *testing.T) { + p := newTestProvider(t) + defer p.Close() //nolint:errcheck + ctx := context.Background() + + email := uuid.New().String() + "@authorizer.dev" + identifier := "basic_auth_signup" + // Tokens must be unique per run: the table is not truncated between runs + // and GetVerificationRequestByToken looks them up globally. + tokenOne := "token-1-" + uuid.New().String() + tokenTwo := "token-2-" + uuid.New().String() + + first, err := p.AddVerificationRequest(ctx, &schemas.VerificationRequest{ + Email: email, + Identifier: identifier, + Token: tokenOne, + ExpiresAt: time.Now().Add(time.Hour).Unix(), + Nonce: "nonce-1", + }) + require.NoError(t, err) + require.NotNil(t, first) + + second, err := p.AddVerificationRequest(ctx, &schemas.VerificationRequest{ + Email: email, + Identifier: identifier, + Token: tokenTwo, + ExpiresAt: time.Now().Add(2 * time.Hour).Unix(), + Nonce: "nonce-2", + }) + require.NoError(t, err, "re-requesting verification must upsert, not fail on the unique index") + require.NotNil(t, second) + + // The live token is the new one, and the old one is gone — one row, updated. + got, err := p.GetVerificationRequestByToken(ctx, tokenTwo) + require.NoError(t, err) + assert.Equal(t, email, got.Email) + assert.Equal(t, "nonce-2", got.Nonce) + + _, err = p.GetVerificationRequestByToken(ctx, tokenOne) + assert.Error(t, err, "the superseded token must no longer resolve") + + var count int64 + require.NoError(t, p.DB().WithContext(ctx).Model(&schemas.VerificationRequest{}). + Where("email = ? AND identifier = ?", email, identifier).Count(&count).Error) + assert.Equal(t, int64(1), count) +} + +// TestAddAuthenticatorConcurrentEnrollment covers the (user_id, method) +// fallback. The check-then-insert in AddAuthenticator has a race; on PostgreSQL +// ON CONFLICT closes it. Concurrent enrollment must still leave exactly one +// row, or GetAuthenticatorDetailsByUserId's First() returns an arbitrary one +// and MFA fails intermittently. +func TestAddAuthenticatorConcurrentEnrollment(t *testing.T) { + p := newTestProvider(t) + defer p.Close() //nolint:errcheck + ctx := context.Background() + + user, err := p.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(uuid.New().String() + "@authorizer.dev"), + SignupMethods: constants.AuthRecipeMethodBasicAuth, + }) + require.NoError(t, err) + + const goroutines = 8 + var wg sync.WaitGroup + errs := make([]error, goroutines) + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, errs[i] = p.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeyTOTPAuthenticator, + Secret: "secret", + }) + }(i) + } + wg.Wait() + for i, err := range errs { + assert.NoError(t, err, "concurrent enrollment %d must not surface the unique violation", i) + } + + var count int64 + require.NoError(t, p.DB().WithContext(ctx).Model(&schemas.Authenticator{}). + Where("user_id = ? AND method = ?", user.ID, constants.EnvKeyTOTPAuthenticator). + Count(&count).Error) + assert.Equal(t, int64(1), count, "concurrent enrollment must not duplicate the authenticator") +} diff --git a/internal/storage/db/serenedb/upsert.go b/internal/storage/db/serenedb/upsert.go new file mode 100644 index 000000000..787482b4f --- /dev/null +++ b/internal/storage/db/serenedb/upsert.go @@ -0,0 +1,120 @@ +package serenedb + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// sqlState reports whether err carries the given SQLSTATE. +func sqlState(err error, code string) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == code +} + +// uniqueViolation reports whether err is a duplicate-key rejection from the +// unique index. SereneDB returns the standard SQLSTATE 23505 for these. +func uniqueViolation(err error) bool { return sqlState(err, "23505") } + +// retryOnWriteConflict re-runs fn while SereneDB reports a write-write conflict +// (SQLSTATE 40001). Its MVCC is optimistic: two transactions touching the same +// row abort one of them rather than blocking it, so a bounded retry — not an +// error to the caller — is the resolution. Concurrent MFA enrollment and +// repeated verification requests for one address are exactly the contended +// writes that hit this. +func retryOnWriteConflict(fn func() error) error { + var err error + for attempt := 0; attempt < 5; attempt++ { + if err = fn(); err == nil || !sqlState(err, "40001") { + return err + } + time.Sleep(time.Duration(attempt+1) * 5 * time.Millisecond) + } + return err +} + +// AddAuthenticator mirrors the SQL provider's upsert on (user_id, method). +// +// SereneDB resolves an ON CONFLICT target only against an inline UNIQUE table +// constraint, never against a CREATE UNIQUE INDEX — which is what GORM emits +// for the `uniqueIndex` tag, and what ALTER TABLE cannot retrofit here. So the +// insert runs bare and the conflict is handled from the 23505 the index still +// raises: the loser of a concurrent enrollment updates the winner's row instead +// of leaving a duplicate behind. Same end state as ON CONFLICT DO UPDATE, and +// the same protection against the check-then-insert race above it. +func (p *provider) AddAuthenticator(ctx context.Context, authenticators *schemas.Authenticator) (*schemas.Authenticator, error) { + exists, _ := p.GetAuthenticatorDetailsByUserId(ctx, authenticators.UserID, authenticators.Method) + if exists != nil { + return authenticators, nil + } + + if authenticators.ID == "" { + authenticators.ID = uuid.New().String() + } + authenticators.Key = authenticators.ID + authenticators.CreatedAt = time.Now().Unix() + authenticators.UpdatedAt = time.Now().Unix() + + err := retryOnWriteConflict(func() error { + err := p.DB().WithContext(ctx).Create(&authenticators).Error + if err == nil || !uniqueViolation(err) { + return err + } + // Lost the race. Update every column ON CONFLICT ... UPDATE ALL would + // have written — GORM excludes the primary key from UpdateAll, so id is + // left on the winning row. + return p.DB().WithContext(ctx).Model(&schemas.Authenticator{}). + Where("user_id = ? AND method = ?", authenticators.UserID, authenticators.Method). + Updates(map[string]any{ + "key": authenticators.Key, + "user_id": authenticators.UserID, + "method": authenticators.Method, + "secret": authenticators.Secret, + "recovery_codes": authenticators.RecoveryCodes, + "verified_at": authenticators.VerifiedAt, + "updated_at": authenticators.UpdatedAt, + }).Error + }) + if err != nil { + return nil, err + } + return authenticators, nil +} + +// AddVerificationRequest mirrors the SQL provider's upsert on +// (email, identifier). See AddAuthenticator for why ON CONFLICT is unavailable. +func (p *provider) AddVerificationRequest(ctx context.Context, verificationRequest *schemas.VerificationRequest) (*schemas.VerificationRequest, error) { + if verificationRequest.ID == "" { + verificationRequest.ID = uuid.New().String() + } + verificationRequest.Key = verificationRequest.ID + verificationRequest.CreatedAt = time.Now().Unix() + verificationRequest.UpdatedAt = time.Now().Unix() + + err := retryOnWriteConflict(func() error { + err := p.DB().WithContext(ctx).Create(&verificationRequest).Error + if err == nil || !uniqueViolation(err) { + return err + } + // Re-requesting verification for the same (email, identifier) replaces + // the live token, exactly as the DoUpdates column list does on + // PostgreSQL. + return p.DB().WithContext(ctx).Model(&schemas.VerificationRequest{}). + Where("email = ? AND identifier = ?", verificationRequest.Email, verificationRequest.Identifier). + Updates(map[string]any{ + "token": verificationRequest.Token, + "expires_at": verificationRequest.ExpiresAt, + "nonce": verificationRequest.Nonce, + "redirect_uri": verificationRequest.RedirectURI, + }).Error + }) + if err != nil { + return verificationRequest, err + } + return verificationRequest, nil +} diff --git a/internal/storage/db/sql/provider.go b/internal/storage/db/sql/provider.go index 612fd3cff..d7c341de3 100644 --- a/internal/storage/db/sql/provider.go +++ b/internal/storage/db/sql/provider.go @@ -40,11 +40,39 @@ type indexInfo struct { } **/ +// Provider is the SQL storage provider. It is exported so wire-compatible +// engines that need to override a handful of methods (see +// internal/storage/db/serenedb) can embed it instead of duplicating the whole +// backend. +type Provider = provider + +// DB exposes the underlying GORM handle to embedders. +func (p *provider) DB() *gorm.DB { return p.db } + +// Models is the full set of tables this provider owns, in migration order. +// Kept in one place so alternative migrators cannot drift from AutoMigrate. +func Models() []any { + return []any{&schemas.User{}, &schemas.VerificationRequest{}, &schemas.Session{}, &schemas.Env{}, &schemas.Webhook{}, &schemas.WebhookLog{}, &schemas.EmailTemplate{}, &schemas.OTP{}, &schemas.Authenticator{}, &schemas.SessionToken{}, &schemas.MFASession{}, &schemas.OAuthState{}, &schemas.AuditLog{}, &schemas.Client{}, &schemas.TrustedIssuer{}, &schemas.Organization{}, &schemas.OrgMembership{}, &schemas.FederatedIdentity{}, &schemas.ScimEndpoint{}, &schemas.ScimGroup{}, &schemas.WebauthnCredential{}, &schemas.OrgDomain{}, &schemas.SAMLServiceProvider{}, &schemas.SAMLIDPKey{}} +} + // NewProvider returns a new SQL provider func NewProvider( config *config.Config, deps *Dependencies, -) (*provider, error) { +) (*Provider, error) { + return NewProviderWithMigrate(config, deps, nil) +} + +// NewProviderWithMigrate returns a new SQL provider, letting the caller replace +// the schema migration. Pass nil for the default (GORM AutoMigrate). SereneDB +// speaks the Postgres wire protocol but rejects the ALTER COLUMN ... TYPE that +// AutoMigrate re-issues on every boot, so it supplies its own create-only +// migrator. +func NewProviderWithMigrate( + config *config.Config, + deps *Dependencies, + migrate func(*gorm.DB) error, +) (*Provider, error) { var sqlDB *gorm.DB var err error @@ -63,7 +91,7 @@ func NewProvider( dbURL := config.DatabaseURL switch dbType { - case constants.DbTypePostgres, constants.DbTypeYugabyte, constants.DbTypeCockroachDB: + case constants.DbTypePostgres, constants.DbTypeYugabyte, constants.DbTypeCockroachDB, constants.DbTypeSereneDB: sqlDB, err = gorm.Open(postgres.Open(dbURL), ormConfig) case constants.DbTypeSqlite: sqlDB, err = gorm.Open(sqlite.Open(dbURL+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"), ormConfig) @@ -93,10 +121,14 @@ func NewProvider( // or any custom name) — failing with "constraint does not exist" (Postgres // SQLSTATE 42704) and aborting startup. Clear the legacy uniqueness up front, // name-agnostically, before AutoMigrate runs. - clearLegacyColumnUniqueness(sqlDB, deps.Log) + if migrate == nil { + migrate = func(db *gorm.DB) error { + clearLegacyColumnUniqueness(db, deps.Log) + return db.AutoMigrate(Models()...) + } + } - err = sqlDB.AutoMigrate(&schemas.User{}, &schemas.VerificationRequest{}, &schemas.Session{}, &schemas.Env{}, &schemas.Webhook{}, &schemas.WebhookLog{}, &schemas.EmailTemplate{}, &schemas.OTP{}, &schemas.Authenticator{}, &schemas.SessionToken{}, &schemas.MFASession{}, &schemas.OAuthState{}, &schemas.AuditLog{}, &schemas.Client{}, &schemas.TrustedIssuer{}, &schemas.Organization{}, &schemas.OrgMembership{}, &schemas.FederatedIdentity{}, &schemas.ScimEndpoint{}, &schemas.ScimGroup{}, &schemas.WebauthnCredential{}, &schemas.OrgDomain{}, &schemas.SAMLServiceProvider{}, &schemas.SAMLIDPKey{}) - if err != nil { + if err = migrate(sqlDB); err != nil { return nil, err } diff --git a/internal/storage/provider.go b/internal/storage/provider.go index 872c8bf4b..f1f8fc162 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -14,6 +14,7 @@ import ( "github.com/authorizerdev/authorizer/internal/storage/db/couchbase" "github.com/authorizerdev/authorizer/internal/storage/db/dynamodb" "github.com/authorizerdev/authorizer/internal/storage/db/mongodb" + "github.com/authorizerdev/authorizer/internal/storage/db/serenedb" "github.com/authorizerdev/authorizer/internal/storage/db/sql" "github.com/authorizerdev/authorizer/internal/storage/schemas" ) @@ -544,6 +545,10 @@ func New(config *config.Config, deps *Dependencies) (Provider, error) { provider, err = sql.NewProvider(config, &sql.Dependencies{ Log: deps.Log, }) + case constants.DbTypeSereneDB: + provider, err = serenedb.NewProvider(config, &serenedb.Dependencies{ + Log: deps.Log, + }) case constants.DbTypeMongoDB: provider, err = mongodb.NewProvider(config, &mongodb.Dependencies{ Log: deps.Log, diff --git a/internal/storage/provider_test.go b/internal/storage/provider_test.go index 92bc5a849..ab2f32337 100644 --- a/internal/storage/provider_test.go +++ b/internal/storage/provider_test.go @@ -24,6 +24,7 @@ import ( // allDBTypes is the full list of database types supported for storage tests. var allDBTypes = []string{ constants.DbTypePostgres, + constants.DbTypeSereneDB, constants.DbTypeSqlite, constants.DbTypeMongoDB, constants.DbTypeArangoDB, @@ -62,6 +63,8 @@ func getTestDBConfig(dbType string) *config.Config { switch dbType { case constants.DbTypePostgres: cfg.DatabaseURL = "postgres://postgres:postgres@localhost:5434/postgres" + case constants.DbTypeSereneDB: + cfg.DatabaseURL = "postgres://postgres:postgres@localhost:7890/postgres" case constants.DbTypeSqlite: cfg.DatabaseURL = "test.db" case constants.DbTypeMongoDB: diff --git a/scripts/wait-for-test-dbs.sh b/scripts/wait-for-test-dbs.sh index 1dc8d7bf8..929c7a8b1 100755 --- a/scripts/wait-for-test-dbs.sh +++ b/scripts/wait-for-test-dbs.sh @@ -15,7 +15,7 @@ TIMEOUT_SECONDS="${TEST_DB_WAIT_TIMEOUT:-120}" # name:port pairs. Couchbase is absent on purpose — scripts/couchbase-test.sh # already provisions and waits for it. -ALL_SERVICES="redis:6380 postgres:5434 mongodb:27017 scylladb:9042 arangodb:8529 dynamodb:8000" +ALL_SERVICES="redis:6380 postgres:5434 serenedb:7890 mongodb:27017 scylladb:9042 arangodb:8529 dynamodb:8000" # With no arguments, wait for everything (make test-all-db). With arguments, # wait only for the named services, so a single-backend target does not block on From a1604878a775b777d0b37278569859c50a133184 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Fri, 4 Sep 2026 17:43:54 +0530 Subject: [PATCH 2/3] test(serenedb): assert the unique index the migrator creates AddAuthenticator's conflict fallback depends on the (user_id, method) index raising 23505. Without a direct assertion, a migrator that stopped creating it would make the concurrency test flaky rather than red. Claude-Session: https://claude.ai/code/session_01JGxmhT63jvFwuTeujfbC45 --- internal/storage/db/serenedb/provider_test.go | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/storage/db/serenedb/provider_test.go b/internal/storage/db/serenedb/provider_test.go index a97e3ef2a..437d3f4bf 100644 --- a/internal/storage/db/serenedb/provider_test.go +++ b/internal/storage/db/serenedb/provider_test.go @@ -155,3 +155,30 @@ func TestAddAuthenticatorConcurrentEnrollment(t *testing.T) { Count(&count).Error) assert.Equal(t, int64(1), count, "concurrent enrollment must not duplicate the authenticator") } + +// TestAuthenticatorUniqueIndexEnforced is the backstop for the create-only +// migrator. AddAuthenticator's conflict fallback only works because the +// (user_id, method) unique index exists and raises 23505 — if the migrator ever +// stopped creating it, the concurrency test above would go quietly flaky +// instead of failing. This writes the duplicate row directly, bypassing the +// pre-check, and asserts the index rejects it. +func TestAuthenticatorUniqueIndexEnforced(t *testing.T) { + p := newTestProvider(t) + defer p.Close() //nolint:errcheck + ctx := context.Background() + + userID := uuid.New().String() + first := &schemas.Authenticator{ + ID: uuid.New().String(), Key: uuid.New().String(), + UserID: userID, Method: constants.EnvKeyTOTPAuthenticator, Secret: "one", + } + require.NoError(t, p.DB().WithContext(ctx).Create(first).Error) + + second := &schemas.Authenticator{ + ID: uuid.New().String(), Key: uuid.New().String(), + UserID: userID, Method: constants.EnvKeyTOTPAuthenticator, Secret: "two", + } + err := p.DB().WithContext(ctx).Create(second).Error + require.Error(t, err, "a second enrollment for the same (user_id, method) must be rejected") + assert.True(t, uniqueViolation(err), "expected SQLSTATE 23505, got %v", err) +} From bac24d7cae54212df3eb78e51237f2a79648c772 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Fri, 4 Sep 2026 19:21:29 +0530 Subject: [PATCH 3/3] fix(serenedb): retry write conflicts at the pool, not per method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing the backend against the real binary found the documented ceiling was not theoretical: 20 concurrent update_profile calls for one user produced 17 failures, each leaking `ERROR: Conflict on tuple deletion! (SQLSTATE 40001)` to the API caller. The per-method retry covered only the two overridden upserts; every other write path had none. Moved the retry to a gorm.ConnPool wrapper so every autocommit statement gets it. Two consequences worth stating: - SkipDefaultTransaction is required. GORM otherwise wraps each single Create/Update in a transaction, routing all writes through BeginTx and past the retry — which is why the first attempt at this changed nothing. These schemas carry no GORM associations, so one write is one statement and that wrapper bought nothing here anyway. - Explicit Transaction() blocks are deliberately not retried: replaying one statement inside an aborted transaction is wrong, and GORM offers no hook to replay the whole block. The four cascade-delete call sites still surface 40001 under sustained same-row contention. NewProviderWithMigrate becomes NewProviderWithOptions; both hooks are nil for every other database type, so their path is unchanged. After: 20/20 succeed, 0 conflicts in the server log; at 50 concurrent the app's own rate limiter trips first and still nothing leaks. New test asserts the cascade delete stays atomic under SkipDefaultTransaction. Verified: full suite on SereneDB (946 pass), make test (SQLite), Postgres storage suite, 5x flakiness loop. Claude-Session: https://claude.ai/code/session_01JGxmhT63jvFwuTeujfbC45 --- internal/storage/db/serenedb/connpool.go | 103 ++++++++++++++++++ internal/storage/db/serenedb/provider.go | 17 ++- internal/storage/db/serenedb/provider_test.go | 46 ++++++++ internal/storage/db/serenedb/upsert.go | 37 +------ internal/storage/db/sql/provider.go | 51 +++++++-- 5 files changed, 206 insertions(+), 48 deletions(-) create mode 100644 internal/storage/db/serenedb/connpool.go diff --git a/internal/storage/db/serenedb/connpool.go b/internal/storage/db/serenedb/connpool.go new file mode 100644 index 000000000..1ddd888ca --- /dev/null +++ b/internal/storage/db/serenedb/connpool.go @@ -0,0 +1,103 @@ +package serenedb + +import ( + "context" + stdsql "database/sql" + "errors" + "time" + + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" +) + +// maxWriteConflictRetries bounds the retry loop below. SereneDB resolves a +// conflict by aborting one writer immediately rather than making it wait, so +// the retries are cheap and a contended row converges in a few rounds. +const maxWriteConflictRetries = 8 + +// sqlState reports whether err carries the given SQLSTATE. +func sqlState(err error, code string) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == code +} + +// uniqueViolation reports whether err is a duplicate-key rejection from a +// unique index. SereneDB returns the standard SQLSTATE 23505 for these. +func uniqueViolation(err error) bool { return sqlState(err, "23505") } + +// retryPool retries statements SereneDB aborts with a write conflict. +// +// Its MVCC is optimistic: two transactions touching the same row abort one of +// them (SQLSTATE 40001, "Conflict on tuple deletion") rather than blocking it, +// so retrying — not failing — is the resolution. Without this, concurrent +// writes to one row fail most of the time and leak the driver's message to API +// callers: 20 simultaneous profile updates for one user produced 17 errors. +// +// The retry sits at the pool so every write path gets it, not just the ones +// this package overrides. It applies only to autocommit statements: BeginTx +// hands back the raw *sql.Tx, because replaying one statement inside an already +// aborted transaction is wrong — the whole transaction has to be replayed, and +// GORM's Transaction() offers no hook for that. This is also why the provider +// sets SkipDefaultTransaction: GORM otherwise wraps every single Create/Update +// in a transaction, which would route all writes through BeginTx and past this +// retry. The four explicit Transaction() call sites in the SQL provider +// (user/client/organization/webhook cascade deletes) still surface 40001 under +// contention. +type retryPool struct { + db *stdsql.DB +} + +// newRetryPool adapts a database/sql handle for gorm. +func newRetryPool(db *stdsql.DB) gorm.ConnPool { return &retryPool{db: db} } + +func (p *retryPool) retry(fn func() error) error { + var err error + for attempt := 0; attempt < maxWriteConflictRetries; attempt++ { + if err = fn(); err == nil || !sqlState(err, "40001") { + return err + } + time.Sleep(time.Duration(attempt+1) * 5 * time.Millisecond) + } + return err +} + +func (p *retryPool) PrepareContext(ctx context.Context, query string) (*stdsql.Stmt, error) { + return p.db.PrepareContext(ctx, query) +} + +func (p *retryPool) ExecContext(ctx context.Context, query string, args ...any) (stdsql.Result, error) { + var res stdsql.Result + err := p.retry(func() error { + var err error + res, err = p.db.ExecContext(ctx, query, args...) + return err + }) + return res, err +} + +func (p *retryPool) QueryContext(ctx context.Context, query string, args ...any) (*stdsql.Rows, error) { + var rows *stdsql.Rows + err := p.retry(func() error { + var err error + rows, err = p.db.QueryContext(ctx, query, args...) + return err + }) + return rows, err +} + +// QueryRowContext cannot retry: *sql.Row defers its error to Scan, so the +// conflict is not visible here. Single-row reads do not raise 40001 — it is a +// write conflict — so this is a read-path no-op rather than a gap. +func (p *retryPool) QueryRowContext(ctx context.Context, query string, args ...any) *stdsql.Row { + return p.db.QueryRowContext(ctx, query, args...) +} + +// BeginTx implements gorm.ConnPoolBeginner. The transaction is deliberately +// unwrapped — see the type comment. +func (p *retryPool) BeginTx(ctx context.Context, opts *stdsql.TxOptions) (gorm.ConnPool, error) { + return p.db.BeginTx(ctx, opts) +} + +// GetDBConn implements gorm.GetDBConnector so gorm.DB.DB() keeps working — +// HealthCheck and Close both go through it. +func (p *retryPool) GetDBConn() (*stdsql.DB, error) { return p.db, nil } diff --git a/internal/storage/db/serenedb/provider.go b/internal/storage/db/serenedb/provider.go index ef4fd23a7..2274be86a 100644 --- a/internal/storage/db/serenedb/provider.go +++ b/internal/storage/db/serenedb/provider.go @@ -9,11 +9,12 @@ // is missing and never alters what exists. // 2. Upserts with an explicit conflict target (see upsert.go). // -// ponytail: SereneDB's MVCC is optimistic, so contended same-row writes abort -// with SQLSTATE 40001 instead of blocking. Only the two upserts here retry it; -// every other write path inherits the SQL provider unchanged and will surface -// 40001 to the caller under the same contention. Move the retry to a GORM -// callback if that shows up in practice. +// SereneDB's MVCC is optimistic, so contended same-row writes abort with +// SQLSTATE 40001 instead of blocking. That is handled at the connection pool +// (see connpool.go) so every write path gets it, not just the two overridden +// here — but only for autocommit statements. The SQL provider's four explicit +// Transaction() call sites still surface 40001 under sustained same-row +// contention. package serenedb import ( @@ -35,7 +36,11 @@ type provider struct { // NewProvider returns a new SereneDB provider func NewProvider(cfg *config.Config, deps *Dependencies) (*provider, error) { - base, err := sql.NewProviderWithMigrate(cfg, &sql.Dependencies{Log: deps.Log}, migrate) + base, err := sql.NewProviderWithOptions(cfg, &sql.Dependencies{Log: deps.Log}, sql.Options{ + Migrate: migrate, + WrapPool: newRetryPool, + SkipDefaultTransaction: true, + }) if err != nil { return nil, err } diff --git a/internal/storage/db/serenedb/provider_test.go b/internal/storage/db/serenedb/provider_test.go index 437d3f4bf..0c6473cb2 100644 --- a/internal/storage/db/serenedb/provider_test.go +++ b/internal/storage/db/serenedb/provider_test.go @@ -182,3 +182,49 @@ func TestAuthenticatorUniqueIndexEnforced(t *testing.T) { require.Error(t, err, "a second enrollment for the same (user_id, method) must be rejected") assert.True(t, uniqueViolation(err), "expected SQLSTATE 23505, got %v", err) } + +// TestDeleteUserCascadeStaysAtomic guards the one place this provider's GORM +// config differs from the SQL one. SkipDefaultTransaction removes the implicit +// transaction GORM wraps around each single write — required, because that +// transaction routes every write through BeginTx and past the pool's conflict +// retry. Explicit Transaction() blocks are supposed to be unaffected, and +// DeleteUser's cascade is one of them. This asserts that rather than inferring +// it: every user-keyed table must be empty afterwards. +func TestDeleteUserCascadeStaysAtomic(t *testing.T) { + p := newTestProvider(t) + defer p.Close() //nolint:errcheck + ctx := context.Background() + + user, err := p.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(uuid.New().String() + "@authorizer.dev"), + SignupMethods: constants.AuthRecipeMethodBasicAuth, + }) + require.NoError(t, err) + + orgID := uuid.New().String() + require.NoError(t, p.AddSession(ctx, &schemas.Session{UserID: user.ID})) + _, err = p.AddFederatedIdentity(ctx, &schemas.FederatedIdentity{ + OrgID: orgID, Issuer: "https://idp.example.com", Subject: uuid.New().String(), UserID: user.ID, + }) + require.NoError(t, err) + _, err = p.AddOrgMembership(ctx, &schemas.OrgMembership{OrgID: orgID, UserID: user.ID, Roles: "member"}) + require.NoError(t, err) + _, err = p.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, Method: constants.EnvKeyTOTPAuthenticator, Secret: "s3cret", + }) + require.NoError(t, err) + _, err = p.AddWebauthnCredential(ctx, &schemas.WebauthnCredential{ + UserID: user.ID, CredentialID: uuid.New().String(), PublicKey: "pk", Name: "laptop", + }) + require.NoError(t, err) + require.NoError(t, p.AddSessionToken(ctx, &schemas.SessionToken{UserID: user.ID, KeyName: "access", Token: "t"})) + require.NoError(t, p.AddMFASession(ctx, &schemas.MFASession{UserID: user.ID, KeyName: "mfa"})) + + require.NoError(t, p.DeleteUser(ctx, user)) + + for _, table := range schemas.UserOwnedCollections { + var count int64 + require.NoError(t, p.DB().WithContext(ctx).Table(table).Where("user_id = ?", user.ID).Count(&count).Error) + assert.Zero(t, count, "%s still holds rows for the deleted user", table) + } +} diff --git a/internal/storage/db/serenedb/upsert.go b/internal/storage/db/serenedb/upsert.go index 787482b4f..6c2c39cb1 100644 --- a/internal/storage/db/serenedb/upsert.go +++ b/internal/storage/db/serenedb/upsert.go @@ -2,42 +2,13 @@ package serenedb import ( "context" - "errors" "time" "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgconn" "github.com/authorizerdev/authorizer/internal/storage/schemas" ) -// sqlState reports whether err carries the given SQLSTATE. -func sqlState(err error, code string) bool { - var pgErr *pgconn.PgError - return errors.As(err, &pgErr) && pgErr.Code == code -} - -// uniqueViolation reports whether err is a duplicate-key rejection from the -// unique index. SereneDB returns the standard SQLSTATE 23505 for these. -func uniqueViolation(err error) bool { return sqlState(err, "23505") } - -// retryOnWriteConflict re-runs fn while SereneDB reports a write-write conflict -// (SQLSTATE 40001). Its MVCC is optimistic: two transactions touching the same -// row abort one of them rather than blocking it, so a bounded retry — not an -// error to the caller — is the resolution. Concurrent MFA enrollment and -// repeated verification requests for one address are exactly the contended -// writes that hit this. -func retryOnWriteConflict(fn func() error) error { - var err error - for attempt := 0; attempt < 5; attempt++ { - if err = fn(); err == nil || !sqlState(err, "40001") { - return err - } - time.Sleep(time.Duration(attempt+1) * 5 * time.Millisecond) - } - return err -} - // AddAuthenticator mirrors the SQL provider's upsert on (user_id, method). // // SereneDB resolves an ON CONFLICT target only against an inline UNIQUE table @@ -60,7 +31,7 @@ func (p *provider) AddAuthenticator(ctx context.Context, authenticators *schemas authenticators.CreatedAt = time.Now().Unix() authenticators.UpdatedAt = time.Now().Unix() - err := retryOnWriteConflict(func() error { + err := func() error { err := p.DB().WithContext(ctx).Create(&authenticators).Error if err == nil || !uniqueViolation(err) { return err @@ -79,7 +50,7 @@ func (p *provider) AddAuthenticator(ctx context.Context, authenticators *schemas "verified_at": authenticators.VerifiedAt, "updated_at": authenticators.UpdatedAt, }).Error - }) + }() if err != nil { return nil, err } @@ -96,7 +67,7 @@ func (p *provider) AddVerificationRequest(ctx context.Context, verificationReque verificationRequest.CreatedAt = time.Now().Unix() verificationRequest.UpdatedAt = time.Now().Unix() - err := retryOnWriteConflict(func() error { + err := func() error { err := p.DB().WithContext(ctx).Create(&verificationRequest).Error if err == nil || !uniqueViolation(err) { return err @@ -112,7 +83,7 @@ func (p *provider) AddVerificationRequest(ctx context.Context, verificationReque "nonce": verificationRequest.Nonce, "redirect_uri": verificationRequest.RedirectURI, }).Error - }) + }() if err != nil { return verificationRequest, err } diff --git a/internal/storage/db/sql/provider.go b/internal/storage/db/sql/provider.go index d7c341de3..07f852b98 100644 --- a/internal/storage/db/sql/provider.go +++ b/internal/storage/db/sql/provider.go @@ -1,7 +1,10 @@ package sql import ( + stdsql "database/sql" + libsql "github.com/ekristen/gorm-libsql" + _ "github.com/jackc/pgx/v5/stdlib" // database/sql driver for the pool seam below "github.com/rs/zerolog" "gorm.io/driver/mysql" "gorm.io/driver/postgres" @@ -55,28 +58,49 @@ func Models() []any { return []any{&schemas.User{}, &schemas.VerificationRequest{}, &schemas.Session{}, &schemas.Env{}, &schemas.Webhook{}, &schemas.WebhookLog{}, &schemas.EmailTemplate{}, &schemas.OTP{}, &schemas.Authenticator{}, &schemas.SessionToken{}, &schemas.MFASession{}, &schemas.OAuthState{}, &schemas.AuditLog{}, &schemas.Client{}, &schemas.TrustedIssuer{}, &schemas.Organization{}, &schemas.OrgMembership{}, &schemas.FederatedIdentity{}, &schemas.ScimEndpoint{}, &schemas.ScimGroup{}, &schemas.WebauthnCredential{}, &schemas.OrgDomain{}, &schemas.SAMLServiceProvider{}, &schemas.SAMLIDPKey{}} } +// Options customise how the provider is built. Both hooks exist for engines +// that speak a supported wire protocol but differ underneath; both are nil for +// every database type this package handles directly, so the default path is +// unchanged. +type Options struct { + // Migrate replaces GORM AutoMigrate. SereneDB rejects the + // ALTER COLUMN ... TYPE that AutoMigrate re-issues on every boot, so it + // supplies a create-only migrator instead. + Migrate func(*gorm.DB) error + // WrapPool wraps the connection pool before GORM sees it. SereneDB uses it + // to retry write conflicts its optimistic MVCC raises. PostgreSQL family + // only. + WrapPool func(*stdsql.DB) gorm.ConnPool + // SkipDefaultTransaction turns off the transaction GORM wraps around every + // single Create/Update/Delete. Only SereneDB sets it: that implicit + // transaction routes every write through BeginTx, where a conflict aborts + // the whole transaction and cannot be retried statement-by-statement. The + // schemas here carry no GORM associations, so one write is one statement + // and the wrapper buys nothing to begin with. Explicit Transaction() blocks + // are unaffected. + SkipDefaultTransaction bool +} + // NewProvider returns a new SQL provider func NewProvider( config *config.Config, deps *Dependencies, ) (*Provider, error) { - return NewProviderWithMigrate(config, deps, nil) + return NewProviderWithOptions(config, deps, Options{}) } -// NewProviderWithMigrate returns a new SQL provider, letting the caller replace -// the schema migration. Pass nil for the default (GORM AutoMigrate). SereneDB -// speaks the Postgres wire protocol but rejects the ALTER COLUMN ... TYPE that -// AutoMigrate re-issues on every boot, so it supplies its own create-only -// migrator. -func NewProviderWithMigrate( +// NewProviderWithOptions returns a new SQL provider with the given hooks +// applied. See Options. +func NewProviderWithOptions( config *config.Config, deps *Dependencies, - migrate func(*gorm.DB) error, + opts Options, ) (*Provider, error) { var sqlDB *gorm.DB var err error ormConfig := &gorm.Config{ + SkipDefaultTransaction: opts.SkipDefaultTransaction, NamingStrategy: schema.NamingStrategy{ TablePrefix: schemas.Prefix, }, @@ -92,7 +116,15 @@ func NewProviderWithMigrate( switch dbType { case constants.DbTypePostgres, constants.DbTypeYugabyte, constants.DbTypeCockroachDB, constants.DbTypeSereneDB: - sqlDB, err = gorm.Open(postgres.Open(dbURL), ormConfig) + if opts.WrapPool != nil { + var rawDB *stdsql.DB + if rawDB, err = stdsql.Open("pgx", dbURL); err != nil { + return nil, err + } + sqlDB, err = gorm.Open(postgres.New(postgres.Config{Conn: opts.WrapPool(rawDB)}), ormConfig) + } else { + sqlDB, err = gorm.Open(postgres.Open(dbURL), ormConfig) + } case constants.DbTypeSqlite: sqlDB, err = gorm.Open(sqlite.Open(dbURL+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"), ormConfig) case constants.DbTypeLibSQL: @@ -121,6 +153,7 @@ func NewProviderWithMigrate( // or any custom name) — failing with "constraint does not exist" (Postgres // SQLSTATE 42704) and aborting startup. Clear the legacy uniqueness up front, // name-agnostically, before AutoMigrate runs. + migrate := opts.Migrate if migrate == nil { migrate = func(db *gorm.DB) error { clearLegacyColumnUniqueness(db, deps.Log)