Skip to content

Keyv-Compatible Persistence

Overview

Keyv is a JavaScript key-value abstraction. It exposes asynchronous get, set, delete, and clear operations and delegates persistence to a driver such as Redis, SQLite, PostgreSQL, or MongoDB. It is not a Go package, so a Go bot cannot import Keyv directly. The equivalent integration point in this repository is storage.Store.

This guide adapts the Keyv shape into that interface:

  • values are JSON-marshaled by the adapter, just as Keyv supports JSON values;
  • missing values become storage.ErrNotFound instead of JavaScript undefined;
  • every backend operation receives a context.Context;
  • namespaces are represented by a key prefix;
  • Keys is implemented by a driver-level prefix scan, not by pretending that Keyv's clear method is a key iterator.

The result is a backend-independent store that can be attached with bot.WithStore and used for guild settings, feature flags, moderation state, or other application-owned data. It should not be used as the Discord gateway cache; bot.WithCache and gateway.ShardManager.SetCache serve that separate purpose.

Architecture

text
command or service
        |
        v
storage.Store
        |
        v
KeyvStore: namespace + JSON encode/decode + error mapping
        |
        v
Keyv-like driver: get / set / delete / prefix keys
        |
        v
Redis, SQL, document store, or a service that owns a Keyv instance

The adapter contract is deliberately small. A real driver can be backed by a Go Redis or SQL client, or it can call a separate Node service that owns Keyv. The application code does not change in either case.

storage.Store methodKeyv-style operationAdapter behavior
Get(ctx, key, &target)get(key)Decode JSON into target; map an absent key to storage.ErrNotFound.
Set(ctx, key, value)set(key, value)Encode value as JSON before writing.
Delete(ctx, key)delete(key)Make deletion idempotent, matching MemoryStore.
Keys(ctx, prefix)driver scan or indexReturn sorted, namespace-relative keys.

Keyv's clear operation has no direct Store equivalent. If an application needs to clear one namespace, enumerate Keys(ctx, "") and delete the returned keys, or add a backend-specific administrative method outside the Store interface. Do not implement a broad clear operation behind an ordinary user command.

Prerequisites

  • Go 1.26.4 or a compatible newer toolchain, as declared by go.mod.
  • A checkout of this module, because the example imports discord.go/storage and discord.go/bot.
  • A Discord application and bot token if the bot portion of the example is run. Keep DISCORD_TOKEN out of source control.
  • The Guilds intent enabled for the slash-command example.
  • A real Keyv driver or a Go implementation of the KeyvBackend contract for production. The runnable example uses a concurrency-safe in-memory driver so it has no additional dependency.

Keyv itself is appropriate when the storage owner is a Node process. If the Go bot and Node service are separate processes, define the backend contract at that service boundary and preserve the same not-found, JSON, timeout, and namespace rules.

Quick Start

The following complete program adapts a Keyv-like backend, attaches it to bot.Bot, stores a guild setting, and exposes /storage-check. The included backend is only a runnable stand-in for a Keyv driver; replace memoryKeyv with a real implementation without changing KeyvStore or the bot handler.

Run it from the repository root after placing the program in a Go package:

bash
export DISCORD_TOKEN='replace-with-a-bot-token'
go run keyv-example.go
go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"os"
	"sort"
	"strings"
	"sync"
	"time"

	"github.com/discord-go/discord.go/bot"
	"github.com/discord-go/discord.go/intents"
	"github.com/discord-go/discord.go/storage"
)

// KeyvBackend is the small driver boundary needed by this adapter. A real
// implementation can wrap Redis, SQL, or an RPC client for a Node Keyv service.
type KeyvBackend interface {
	Get(context.Context, string) ([]byte, error)
	Set(context.Context, string, []byte) error
	Delete(context.Context, string) error
	Keys(context.Context, string) ([]string, error)
}

type KeyvStore struct {
	backend  KeyvBackend
	namespace string
}

var _ storage.Store = (*KeyvStore)(nil)

func NewKeyvStore(backend KeyvBackend, namespace string) *KeyvStore {
	return &KeyvStore{backend: backend, namespace: strings.Trim(namespace, ":")}
}

func (s *KeyvStore) scoped(key string) string {
	if s.namespace == "" {
		return key
	}
	return s.namespace + ":" + key
}

func (s *KeyvStore) unscoped(key string) string {
	if s.namespace == "" {
		return key
	}
	return strings.TrimPrefix(key, s.namespace+":")
}

func (s *KeyvStore) Get(ctx context.Context, key string, target any) error {
	if err := contextErr(ctx); err != nil {
		return err
	}
	data, err := s.backend.Get(ctx, s.scoped(key))
	if err != nil {
		return err
	}
	if len(data) == 0 {
		return storage.ErrNotFound
	}
	return json.Unmarshal(data, target)
}

func (s *KeyvStore) Set(ctx context.Context, key string, value any) error {
	if err := contextErr(ctx); err != nil {
		return err
	}
	data, err := json.Marshal(value)
	if err != nil {
		return err
	}
	return s.backend.Set(ctx, s.scoped(key), data)
}

func (s *KeyvStore) Delete(ctx context.Context, key string) error {
	if err := contextErr(ctx); err != nil {
		return err
	}
	return s.backend.Delete(ctx, s.scoped(key))
}

func (s *KeyvStore) Keys(ctx context.Context, prefix string) ([]string, error) {
	if err := contextErr(ctx); err != nil {
		return nil, err
	}
	keys, err := s.backend.Keys(ctx, s.scoped(prefix))
	if err != nil {
		return nil, err
	}
	for i := range keys {
		keys[i] = s.unscoped(keys[i])
	}
	sort.Strings(keys)
	return keys, nil
}

type memoryKeyv struct {
	mu     sync.RWMutex
	values map[string][]byte
}

func newMemoryKeyv() *memoryKeyv {
	return &memoryKeyv{values: make(map[string][]byte)}
}

func (m *memoryKeyv) Get(ctx context.Context, key string) ([]byte, error) {
	if err := contextErr(ctx); err != nil {
		return nil, err
	}
	m.mu.RLock()
	value, ok := m.values[key]
	m.mu.RUnlock()
	if !ok {
		return nil, storage.ErrNotFound
	}
	return append([]byte(nil), value...), nil
}

func (m *memoryKeyv) Set(ctx context.Context, key string, value []byte) error {
	if err := contextErr(ctx); err != nil {
		return err
	}
	m.mu.Lock()
	m.values[key] = append([]byte(nil), value...)
	m.mu.Unlock()
	return nil
}

func (m *memoryKeyv) Delete(ctx context.Context, key string) error {
	if err := contextErr(ctx); err != nil {
		return err
	}
	m.mu.Lock()
	delete(m.values, key)
	m.mu.Unlock()
	return nil
}

func (m *memoryKeyv) Keys(ctx context.Context, prefix string) ([]string, error) {
	if err := contextErr(ctx); err != nil {
		return nil, err
	}
	m.mu.RLock()
	keys := make([]string, 0)
	for key := range m.values {
		if strings.HasPrefix(key, prefix) {
			keys = append(keys, key)
		}
	}
	m.mu.RUnlock()
	sort.Strings(keys)
	return keys, nil
}

func contextErr(ctx context.Context) error {
	if ctx == nil {
		return nil
	}
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
		return nil
	}
}

type guildSettings struct {
	Prefix string `json:"prefix"`
}

func main() {
	token := os.Getenv("DISCORD_TOKEN")
	if token == "" {
		log.Fatal("DISCORD_TOKEN is required")
	}

	store := NewKeyvStore(newMemoryKeyv(), "discord")
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	if err := store.Set(ctx, "guild:demo", guildSettings{Prefix: "?"}); err != nil {
		log.Fatal(err)
	}

	router := bot.NewRouter()
	router.Command("storage-check", "Read a setting through storage.Store", func(ctx *bot.InteractionContext) {
		var settings guildSettings
		if err := store.Get(ctx.Context(), "guild:demo", &settings); err != nil {
			if err := ctx.ReplyEphemeral("storage read failed"); err != nil {
				log.Printf("reply: %v", err)
			}
			log.Printf("storage: %v", err)
			return
		}
		if err := ctx.Reply(fmt.Sprintf("Stored prefix: %s", settings.Prefix)); err != nil {
			log.Printf("reply: %v", err)
		}
	})

	client := bot.New(token,
		bot.WithIntents(intents.Guilds),
		bot.WithRouter(router),
		bot.WithStore(store),
	)
	client.OnReady(func(ctx *bot.ReadyContext) {
		log.Printf("ready as %s", ctx.User.Username)
	})
	if err := client.Run(); err != nil {
		log.Fatal(err)
	}
}

Practical Levels

Basic: one namespace and JSON settings

Use one Store instance and stable keys such as guild:<guild-id>. Keep the value a versioned struct rather than a map when the shape is part of your application contract.

go
type Settings struct {
	Version int    `json:"version"`
	Prefix  string `json:"prefix"`
}

var settings Settings
err := store.Get(ctx, "guild:"+guildID, &settings)
if err != nil && !errors.Is(err, storage.ErrNotFound) {
		return err
}

Intermediate: TTL and cache policy

Keyv drivers commonly offer TTL. storage.Store has no TTL parameter, so choose one of these designs explicitly:

  • configure a TTL on the backend for data that is safe to recompute;
  • store expires_at in the JSON value and reject expired records in the application;
  • expose a separate typed cache for ephemeral data and keep Store durable.

Do not silently add a TTL to settings or moderation records. A namespace also does not provide a transaction or a distributed lock.

Advanced: shared storage across shards and replicas

Use a shared backend when more than one process needs the same state. Include a schema or version field, make writes idempotent, and avoid read-modify-write updates unless the backend provides an atomic operation. Use a separate key prefix for each data family, for example guild:, user:, and oauth:.

If the driver has no reliable Keys operation, maintain an explicit index or use a domain-specific repository instead of implementing an expensive full scan.

Best Practices

  • Keep KeyvStore behind storage.Store; handlers should not know the driver.
  • Pass the interaction or request context into storage calls so cancellation reaches the backend.
  • Use errors.Is(err, storage.ErrNotFound) for misses; do not compare error strings.
  • Namespace keys and validate IDs before concatenating them.
  • Marshal only JSON-safe values and use pointers when Get must populate a target.
  • Make Delete idempotent, matching storage.MemoryStore behavior.
  • Sort keys before returning them so callers receive deterministic results.
  • Treat backend errors as operational failures and report them without logging user data or credentials.
  • Add metrics for latency, errors, hit/miss counts, and key families.
  • Test TTL, serialization failures, cancellation, driver outages, and two concurrent writers before selecting a production backend.

Common Mistakes

Treating a missing value as a zero value

Wrong:

go
var settings Settings
_ = store.Get(ctx, key, &settings)
use(settings) // may use defaults after silently losing a storage error

Correct:

go
var settings Settings
if err := store.Get(ctx, key, &settings); err != nil {
	if errors.Is(err, storage.ErrNotFound) {
		settings = Settings{Version: 1, Prefix: "!"}
	} else {
		return err
	}
}
use(settings)

Assuming every Keyv provider can list keys

get, set, and delete are the common Keyv operations. storage.Store.Keys requires a prefix scan, which may be unavailable or expensive in a driver. Implement it with a bounded backend query or maintain an index. Never expose a prefix supplied by an untrusted user as an unrestricted administrative scan.

Sharing a process-local backend between shards

An in-memory Keyv driver is local to one process. It does not synchronize two bot replicas and is lost at restart. Use a shared durable backend for state that must survive either event.

Confusing namespaces with authorization

discord:guild:123 prevents accidental key collisions; it does not decide whether the caller may edit guild 123. Apply bot.GuildOnly and permission middleware before changing settings.

API Walkthrough

The adapter's Get must unmarshal into the caller's target, not return a json.RawMessage and force every handler to know the backend format. The adapter's Set must marshal once at the boundary so all backends share the same JSON semantics.

Examples

A guild settings repository can centralize key construction and defaults:

go
func guildKey(id snowflake.ID) string { return "guild:" + id.String() }

func loadSettings(ctx context.Context, store storage.Store, id snowflake.ID) (Settings, error) {
	var value Settings
	if err := store.Get(ctx, guildKey(id), &value); err != nil {
		if errors.Is(err, storage.ErrNotFound) {
			return Settings{Version: 1, Prefix: "!"}, nil
		}
		return Settings{}, err
	}
	return value, nil
}

For a Node-owned Keyv service, implement KeyvBackend with an authenticated RPC client. Keep the service response distinction explicit: a 404 maps to storage.ErrNotFound, invalid JSON maps to a decode error, and transport timeouts return the original context or network error.

Released under the Apache License 2.0.