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).
| Driver | store.driver value | Persistent | Use case |
|---|---|---|---|
| SQLite | sqlite (default) | Yes | Zero-config local/single-node use |
| PostgreSQL | postgres | Yes | Multi-node production |
| MySQL | mysql | Yes | Multi-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:
| Store | What 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() error — internal/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.
| Store | Lifetime |
|---|---|
| Access tokens | 1 hour |
| Refresh tokens | 30 days |
| Authorization codes | 10 minutes |
| Sessions | 12 hours |
| Login transactions | 10 minutes |