Skip to main content

Storage

O2ID's storage layer is built around a single Provider interface. At startup, the CLI looks up a driver by name in a registry and passes the resulting provider to the server. Every read and write goes through that provider. Swapping the backend means registering a different implementation — no changes to the API or business logic are required.

Choosing a driver

Storage is configured through a TOML config file.

By default o2id serve looks for o2id.toml in the working directory it's run from. Point it elsewhere with --config:

o2id serve --config /etc/o2id/config.toml

All three drivers ship as generic RDBMS drivers: they share one query engine (internal/store/rdbms) and differ only in a small set of dialect-specific facts (placeholder syntax, key column type, row locking, and unique-constraint detection).

Driverstore.driver valuePersistentUse case
SQLitesqlite (default)YesZero-config local/single-node use
PostgreSQLpostgresYesMulti-node production
MySQLmysqlYesMulti-node production

SQLite (default)

[store]
driver = "sqlite"
dsn = "/var/lib/o2id/o2id.db"

The SQLite driver uses WAL journal mode, is restricted to a single connection (SQLite only supports one writer at a time), and runs schema migrations on every startup, so it is safe to restart the server against an existing database.

PostgreSQL

[store]
driver = "postgres"
dsn = "postgres://user:pass@host:5432/dbname?sslmode=disable"

MySQL

[store]
driver = "mysql"
dsn = "user:pass@tcp(host:3306)/dbname"

Provider interface

The store.Provider interface lives in internal/store/store.go and exposes seven stores:

StoreWhat it holds
Users()User identities
Applications()Registered OAuth 2.0 clients
AccessTokens()Issued Bearer access tokens
RefreshTokens()Single-use refresh tokens
Sessions()Browser session cookies
AuthorizationCodes()Short-lived OAuth authorization codes
LoginTransactions()Pending login flow state

Users() and Applications() return the domain repository interfaces defined alongside their respective services (user.Repository, application.Repository). The remaining five return store-level interfaces defined in internal/store/store.go.

Driver registry

Drivers register themselves by name via store.Register(name, factory), typically from an init() function, and the CLI resolves a driver at startup with store.Open(name, dsn). There is no hardcoded switch to edit — adding a driver anywhere in the import graph (including outside this repo) makes it available.

func init() {
store.Register("mydriver", func(dsn string) (store.Provider, error) {
return NewProvider(dsn)
})
}

internal/cli/cli.go blank-imports the built-in driver packages (internal/store/sqlite, internal/store/postgres, internal/store/mysql) purely to trigger their init() registration.

Adding a new RDBMS driver

Because all three built-in drivers share one generic engine (internal/store/rdbms), adding another SQL database does not mean reimplementing every repository and store — it means implementing the small rdbms.Dialect interface:

type Dialect interface {
DriverName() string // database/sql driver name
Rebind(query string) string // rewrite `?` placeholders to native syntax
KeyType() string // column type for PK/UNIQUE string columns
LockClause() string // appended to consume-then-delete SELECTs (e.g. " FOR UPDATE")
ConfigurePool(db *sql.DB) // connection pool sizing
PreludeStatements() []string // dialect-only bootstrap SQL run before the shared schema
IsUniqueViolation(err error) bool // detect a PK/UNIQUE constraint violation
UpsertSQL(table, pk string, cols []string) string // insert-or-overwrite-by-pk statement
}

See internal/store/sqlite/dialect.go, internal/store/postgres/dialect.go, and internal/store/mysql/dialect.go for three complete, contrasting implementations. A new driver package needs only:

internal/store/<driver>/
dialect.go Dialect implementation + blank import of the database/sql driver
provider.go NewProvider (delegates to rdbms.Open) + init() registering the driver
provider_test.go a few lines calling the shared conformance suite
package mydriver

import (
_ "example.com/mydriver-sql-driver"

"o2id/internal/store"
"o2id/internal/store/rdbms"
)

func init() {
store.Register("mydriver", func(dsn string) (store.Provider, error) {
return NewProvider(dsn)
})
}

func NewProvider(dsn string) (*rdbms.Provider, error) {
return rdbms.Open(dsn, dialect{})
}

Then wire the new package into internal/cli/cli.go's blank imports so its init() runs.

Testing a new dialect

internal/store/storetest provides a shared conformance suite (storetest.RunConformanceTests) covering every store, including a concurrency test that consumes the same refresh token/authorization code from many goroutines at once and asserts exactly one succeeds — this is exactly the kind of race a naive select-then-delete implementation gets wrong once a dialect uses a real connection pool instead of SQLite's forced single connection, so don't skip it.

PostgreSQL and MySQL tests run against a real database and are skipped unless a DSN is provided via an environment variable:

docker run --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres
O2ID_TEST_POSTGRES_DSN="postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" \
go test ./internal/store/postgres/...

docker run --rm -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -p 3306:3306 mysql:8
O2ID_TEST_MYSQL_DSN="root@tcp(localhost:3306)/mysql" \
go test ./internal/store/mysql/...

Implementing a fully custom (non-relational) provider

The registry isn't limited to rdbms.Dialect implementations — any package can implement store.Provider from scratch and register itself, for example to back O2ID with Redis, DynamoDB, or another non-relational store:

package redisstore

import "o2id/internal/store"

func init() {
store.Register("redis", func(dsn string) (store.Provider, error) {
return NewProvider(dsn)
})
}

type Provider struct{ /* ... */ }

func (p *Provider) Users() user.Repository { /* ... */ }
func (p *Provider) Applications() application.Repository { /* ... */ }
func (p *Provider) AccessTokens() store.AccessTokenStore { /* ... */ }
func (p *Provider) RefreshTokens() store.RefreshTokenStore { /* ... */ }
func (p *Provider) Sessions() store.SessionStore { /* ... */ }
func (p *Provider) AuthorizationCodes() store.AuthorizationCodeStore { /* ... */ }
func (p *Provider) LoginTransactions() store.LoginTransactionStore { /* ... */ }

If the provider holds a resource that needs releasing on shutdown, implement Close() errorinternal/cli/cli.go closes any provider that satisfies interface{ Close() error } via a type assertion, since store.Provider itself doesn't require one (a provider with nothing to release, e.g. a pure-Go client library that manages its own connection lifecycle internally, doesn't need to implement it).

Token lifetimes

The lifetimes below are fixed in the current release. They apply regardless of which storage driver is in use.

StoreLifetime
Access tokens1 hour
Refresh tokens30 days
Authorization codes10 minutes
Sessions12 hours
Login transactions10 minutes