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.
| Category | Subtests | What it catches |
|---|---|---|
| Core CRUD | 8 | create / get / idempotency / status transitions / paging basics / unread filter / device upsert / user isolation |
| Pagination | 2 | full-set traversal across many pages, strict-less-than cursor semantics |
| FreshTenant | 3 | queries against an unseen tenant return empty, not error |
| RoundTrip | 3 | adversarial string values (emoji, unicode, SQL-shaped, leading/trailing whitespace, 10k chars), int64 fidelity, large payload bodies |
| Concurrency | 5 | distinct-keys-no-lost-writes, same-key-single-winner, device-upsert-same-key-single-row, update-status concurrency, read-your-writes |
| KeyEdge | 3 | long notification ids (256 chars), separator-byte collisions in composite keys, case-sensitive device types |
All shipped drivers pass 24/24:
- memory — differential reference
- postgres — see store/postgres/CONFORMANCE.md
- entdb — see store/entdb/CONFORMANCE.md
Adding a new driver
- Create
store/<driver>/with the implementation. - Add
store/<driver>/<driver>_test.gothat 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(...) }, })}- Wire the driver into
.github/workflows/conformance.yml's matrix. - Run
go test ./store/<driver>/... -race -count=1. Any failing subtest gets a one-line root-cause attribution instore/<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
- Conformance Suite — every subtest, what it catches
- Memory driver
- Postgres driver
- EntDB driver