Last updated 2026-05-28

Store & Conformance

notify treats persistence as a pluggable concern. There is one Store interface, three shipped drivers (memory, postgres, entdb), and a shared conformance suite that every driver runs. Adding a new driver is a matter of implementing the interface and passing the suite.

When to use what

  • memory — tests, demos, ephemeral dev environments. The differential reference for "what does the contract mean".
  • postgres — production deployments using a managed Postgres (RDS, Azure Database for PostgreSQL, Cloud SQL). Most production deployments will start here.
  • entdb — tenant-shard-db deployments. Pick this when you already run EntDB for other services and want notify to share its operational story.

The Store interface

type Store interface {
// Idempotent on (TenantID, UserID, NotificationID). Re-creates set
// n.ID to the existing id and return created=false.
CreateNotification(ctx context.Context, n *Notification) (created bool, err error)
// Tenant + user scoped. Returns ErrNotFound for unknown ids or
// cross-tenant / cross-user attempts (privacy: never leak existence).
GetNotification(ctx context.Context, tenantID, userID, id string) (*Notification, error)
// Sets the delivery status and stamps the matching timestamp field
// (delivered→DeliveredAtMS, acked→AckAtMS, read→ReadAtMS).
UpdateStatus(ctx context.Context, tenantID, id string, status DeliveryStatus, atMS int64) error
// Returns one page newest-first. unreadCount is total-not-yet-read,
// independent of the page window and any UnreadOnly filter.
QueryUserNotifications(ctx context.Context, q Query) (items []*Notification, nextCursor string, unreadCount int, err error)
// Keyed on (TenantID, UserID, DeviceType). Rotates token in place.
UpsertDevice(ctx context.Context, d *Device) (*Device, error)
// Ordered by device_type for deterministic output.
ListDevices(ctx context.Context, tenantID, userID string) ([]*Device, error)
}

Two sentinel errors round out the contract:

var (
ErrNotFound = errors.New("notify: not found")
ErrConflict = errors.New("notify: conflict")
)

Why the contract is this small

Why this design. The realtime registry, the retry tracker, and per-user channel preferences (future) all live outside the Store. The Store holds only what is durable and shared across replicas: notification rows and device registrations. Keeping the interface tight makes new drivers cheap, makes the conformance suite tractable, and prevents "the store" from becoming a leaky god-object.

The conformance suite

store/conformance.RunConformance exercises every driver against the same 24 leaf subtests across six categories. A failure path like TestConformance/postgres/Concurrency/ConcurrentCreate_SameKey_SingleWinner points straight at the driver and the exact semantic that broke.

CategorySubtestsWhat it catches
Core CRUD8create / get / idempotency / status transitions / paging basics / unread filter / device upsert / user isolation
Pagination2full-set traversal across many pages, strict-less-than cursor semantics
FreshTenant3queries against an unseen tenant return empty, not error
RoundTrip3adversarial string values (emoji, unicode, SQL-shaped, leading/trailing whitespace, 10k chars), int64 fidelity, large payload bodies
Concurrency5distinct-keys-no-lost-writes, same-key-single-winner, device-upsert-same-key-single-row, update-status concurrency, read-your-writes
KeyEdge3long notification ids (256 chars), separator-byte collisions in composite keys, case-sensitive device types

All shipped drivers pass 24/24:

Adding a new driver

  1. Create store/<driver>/ with the implementation.
  2. Add store/<driver>/<driver>_test.go that runs the suite:
func TestConformance(t *testing.T) {
conformance.RunConformance(t, conformance.Driver{
Name: "mydriver",
New: func(t *testing.T) notify.Store {
return mydriver.New(...)
},
})
}
  1. Wire the driver into .github/workflows/conformance.yml's matrix.
  2. Run go test ./store/<driver>/... -race -count=1. Any failing subtest gets a one-line root-cause attribution in store/<driver>/CONFORMANCE.md — see the existing two as templates.

The CI check Conformance / <driver> is what branch protection pins. A driver that doesn't pass cannot land.

Related