Skip to content

Writing a storage adapter ​

This page is for developers who want to persist Wicket's state in some database (PostgreSQL, MySQL, Redis, etc.): where the adapter should live, how to prove it meets the contract, and the requirements for schema and logging. For the interface definitions themselves, see Storage ports.

Put it in a separate Go module ​

An adapter should be a separate repository (a separate Go module) that does require github.com/gonewx/wicket and depends only on the public port packages:

text
github.com/you/wicket-mydb
├── go.mod            // require github.com/gonewx/wicket v0.1.4
├── LICENSE
├── code_store.go     // storage.AuthorizationCodeStore
├── refresh_store.go  // storage.RefreshTokenStore
├── session_store.go  // session.Store
├── key_store.go      // keymgmt.RecordStore
├── migrations/       // this repo's own table-creation scripts
└── *_test.go         // conformance suite

The benefits: Wicket itself pulls in no database drivers; the adapter can release on its own schedule; and ownership of the table structure clearly belongs to the adapter repository.

Production wiring must inject all 11 storage seams listed in ServerConfig (otherwise Freeze() panics). An adapter doesn't have to cover every interface at once—one repository can implement only authorization codes and refresh tokens, with the rest provided by another adapter (for example Redis).

Pass the conformance suite ​

Wicket provides an executable conformance suite for each of the three port families:

Suite packageEntry functions
github.com/gonewx/wicket/storage/storagetestRunAuthorizationCodeStoreSuite, RunRefreshTokenStoreSuite, RunReferenceTokenStoreSuite, RunUserConsentStoreSuite, RunPersistedGrantStoreSuite, RunDeviceFlowStoreSuite, RunBackchannelAuthenticationRequestStoreSuite
github.com/gonewx/wicket/session/sessiontestRunSessionStoreSuite
github.com/gonewx/wicket/keymgmt/keymgmttestRunRecordStoreSuite

All entry points have a signature of the form:

go
func RunAuthorizationCodeStoreSuite(t *testing.T, newStore func() storage.AuthorizationCodeStore, opts ...Option)

newStore must return a brand-new, empty store every time: the suite calls it before each test case, and test cases must not share state. The factory may open connections, create a temporary schema, or truncate tables—the suite never inspects the underlying medium directly and observes behavior only through the interface.

The tests below have been compiled and actually run successfully (the demo adapter delegates internally to Wicket's built-in in-memory implementations; replace that with real database access in your implementation):

go
package mydb

import (
	"testing"
	"time"

	"github.com/gonewx/wicket/keymgmt"
	"github.com/gonewx/wicket/keymgmt/keymgmttest"
	"github.com/gonewx/wicket/session"
	"github.com/gonewx/wicket/session/sessiontest"
	"github.com/gonewx/wicket/storage"
	"github.com/gonewx/wicket/storage/storagetest"
)

func TestAuthorizationCodeStoreConformance(t *testing.T) {
	storagetest.RunAuthorizationCodeStoreSuite(t, func() storage.AuthorizationCodeStore {
		return NewAuthorizationCodeStore() // a brand-new empty store each time
	}, storagetest.WithMay(true))
}

func TestRecordStoreConformance(t *testing.T) {
	keymgmttest.RunRecordStoreSuite(t, func() keymgmt.RecordStore {
		return NewRecordStore()
	}, keymgmttest.WithMay(true))
}

type fixedClock struct{}

func (fixedClock) Now() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }

func TestSessionStoreConformance(t *testing.T) {
	sessiontest.RunSessionStoreSuite(t, func() session.Store {
		return NewSessionStore(fixedClock{})
	})
}

MUST and MAY ​

Test cases are graded per RFC 2119, and subtest names start with MUST/ or MAY/:

  • MUST cases always run. If you skip or fail any MUST case, you can't claim conformance. They cover: returning sentinels on a miss, insert-only conflicts, version conflicts and write-back, expired records still being readable, RemoveExpired reclaiming exactly the records older than the cutoff, read/write behavior under an already-canceled ctx, not spawning background goroutines, and more.
  • MAY cases are skipped by default and run only when you pass WithMay(true); they cover enhanced behavior (concurrency stress, contract version declaration checks, etc.).

Always pass WithMay(true) in CI, and run with go test -race.

Already-canceled ctx ​

The suites call read and write methods with an already-canceled ctx, and the three suites' requirements differ somewhat:

  • Writes (same in all three): either return context.Canceled, or actually complete and be readable afterwards—"returning success but writing nothing" fails.
  • Read misses: storagetest only requires a non-nil error (either the sentinel or context.Canceled); sessiontest and keymgmttest require errors.Is(err, session.ErrSessionNotFound) / errors.Is(err, keymgmt.ErrRecordNotFound) to hold.

The latter is a trap for database adapters: passing an already-canceled ctx to the driver yields context.Canceled, and the suite fails. One approach is to wrap both errors so that both checks hold for the caller:

go
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
	return nil, fmt.Errorf("%w: %w", session.ErrSessionNotFound, err)
}

(Illustrative: evaluate whether to do this based on your driver's behavior; it makes "canceled" also look like "not found" to upper layers.)

Declare the contract version: ConformsTo ​

Each suite package exports a constant SuiteVersion ("1.0.0" for all three in v0.1.4) identifying the contract version it checks. The convention: a conforming adapter implements, on each store type,

go
func (s *AuthorizationCodeStore) ConformsTo() string { return storagetest.SuiteVersion }

returning the suite version it passes. With WithMay(true), the suite checks that this value matches the current SuiteVersion.

The Wicket core does not check ConformsTo() automatically during wiring; whether to perform this step is up to the host. Below is a compilable check function the host can use before calling cfg.Set*:

go
func requireConformance(name string, store any) error {
	c, ok := store.(interface{ ConformsTo() string })
	if !ok {
		return fmt.Errorf("%s: adapter does not declare ConformsTo()", name)
	}
	if got := c.ConformsTo(); got != storagetest.SuiteVersion {
		return fmt.Errorf("%s: contract version %s, current wicket requires %s", name, got, storagetest.SuiteVersion)
	}
	return nil
}

Session and key record stores are compared against sessiontest.SuiteVersion and keymgmttest.SuiteVersion respectively. If SuiteVersion changes after a Wicket upgrade, first rerun the new suite in the adapter repository and release, then upgrade the host.

Ship your own migrations ​

  • Table-creation scripts live in the adapter repository and are released with the adapter version; the adapter owns the table structure.
  • Migrations are invoked explicitly by the host in its startup sequence (for example by exporting migrations.Up(ctx, db)). Don't run them silently in a constructor or on the first query, and don't start background tasks.
  • Migrations must be idempotent and safe to run repeatedly; providing a rollback entry point is even better.
  • Wicket isn't responsible for any migrations and doesn't know your table names.

Derive the schema from access patterns ​

Don't map the Go structs in storage/models to columns field by field. These models gain and lose fields as Wicket evolves, and a one-to-one mapping turns every upgrade into a schema change. A more robust approach is to work backwards from how the port methods read and write:

Access patternWhich methodsHow it shows up in the schema
Exact read and delete by handleGet* / Remove* (handle, key, device code, request id)Handle as primary key or unique index
Insert-onlyStore*Rely on primary key conflicts to return ErrDuplicateHandle; don't use ON CONFLICT DO UPDATE
Bulk query or revocation by subject / clientRemoveReferenceTokens, GetAll/RemoveAll, GetSessionsBySubjectIDIndex subject and client
Reclamation by expiry timeRemoveExpired / DeleteExpiredStore expiry time in its own indexed column, used only for reclamation; don't filter on it when reading
Version-based concurrencyUpdateRefreshToken, RecordStore.UpdateAn integer version column; UPDATE ... WHERE handle = ? AND version = ?, then check the affected row count

Put the few fields that need to be queried or indexed in their own columns, and serialize the rest into a single column (JSON or binary) stored and loaded as a whole. Reads must reconstruct the complete object and return an independent copy.

Keep a short design note in the adapter repository that states which port method each table and index serves, to make review and tuning easier for whoever comes next.

Logging and credentials ​

  • Use only the *slog.Logger injected by the host (passed in through a constructor parameter); don't create your own logger, and don't write directly to stdout/stderr.
  • Logs at any level must not contain token values, authorization codes, refresh token handles, private key material, client secrets, or passwords from database connection strings. For troubleshooting, log only "present or not" and short prefixes of identifiers.
  • The same goes for error messages: when wrapping driver errors, make sure they don't carry out SQL parameters or DSNs containing credentials.

License ​

An adapter is a separate module; put its own LICENSE file at the repository root and declare its own module path in go.mod. Wicket itself is released under the Apache License 2.0, and the license you choose for the adapter must be compatible with it. For more on licensing, see License and acknowledgments.

Released under the Apache License 2.0 · Documentation © 2026 Wicket authors