waitlists

package
v13.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: AGPL-3.0 Imports: 23 Imported by: 0

Documentation

Overview

Package waitlists stores the queue people join before the thing they are queueing for exists: named lists with a closing time, and the signups against them with a lifecycle of their own.

list, err := store.CreateList(ctx, tenancy.Global(), &waitlists.List{
	Name:        "Launch",
	Description: "early access to the beta",
	ClosesAt:    launchDay,
})

// On the request path, from a form somebody filled in.
signup, err := store.Join(ctx, tenancy.Global(), list.ID, &waitlists.Signup{
	Contact: "Ada@example.com",
})

// When it is their turn.
err = store.Invite(ctx, tenancy.Global(), list.ID, signup.ID)

// When they ask to come off it.
err = store.Withdraw(ctx, tenancy.Global(), list.ID, signup.ID)

Why this is in the platform at all

A waitlist is the next thing every pre-launch product writes, and it sits in the github.com/primandproper/platform-go/v13/authentication/passwordreset class: short enough to look like it needs no library, repeated often enough that the copies drift. What drifts is never the CRUD. It is the three things below.

The contact is stored twice, and the second copy is the point

A signup holds an address, which means two obligations that pull in opposite directions. The list exists to write to that address, so it cannot be stored as a digest — a digest cannot be emailed. And a person who asks to come off the list has to stay off it, which means remembering them after their address is gone.

So the row carries both. Signup.Contact is the address as it was given, and Signup.ContactDigest is SQLStore.Digest of Normalize of it: the column the row is found by, the column the uniqueness is on, and the column a withdrawal leaves behind. SignupStore.Withdraw blanks the contact, the notes and the subject reference, keeps the digest, and marks the row StatusWithdrawn. A later SignupStore.Join from the same address finds that row and is refused with ErrContactWithdrawn.

That is the whole design, and it is what a hand-rolled signups table gets wrong. The two ways it is usually written both fail: deleting the row frees the key, so the next form submission re-subscribes somebody who asked to be left alone, and keeping the row intact means holding an address you have promised not to use. A digest is how a table remembers somebody it no longer holds.

The digest is unsalted and the hash is fast, which is deliberate and is a weaker claim than the one passwordreset makes. What it digests is an address somebody chose rather than 256 bits from a CSPRNG, so anyone willing to hash a list of addresses can find out whether one is on a suppression list. It is not there to make a withdrawal secret. It is there so the suppression does not require the address, which is the difference between a suppression list and a mailing list you have promised not to use — see WithHasher.

Normalization is case folding and a trim, and no more than that. Plus-addressing and dots in a Gmail local part are each a provider's own policy about which addresses are the same mailbox, and a library that guessed would merge two people's signups at some providers and split one person's at others.

A transition is a guarded write, not a read and a write

SignupStore.Invite and SignupStore.Convert each run one UPDATE whose WHERE names the status the row must already hold, and it is the affected-row count — not a read before it — that decides whether this caller is the one that moved the signup. Two requests inviting the same person both find them waiting; one of the updates reports a row and the other is told ErrWrongStatus, so one email goes out between them.

Deciding on the read instead leaves a window exactly as wide as whatever the caller does next, which for an invitation is an email — and a waitlist that emails the same person twice is the failure everybody has seen.

Because a statement that matched nothing cannot say which of two things went wrong, the losing path makes one more read to find out: a signup that is gone reports ErrSignupNotFound, one in another status reports ErrWrongStatus naming it, and a second withdrawal reports ErrAlreadyWithdrawn. That read costs a round trip nobody is waiting on.

Archiving is not withdrawing

Both hide a signup, and they are not interchangeable, so the store offers both under names that say which is which.

SignupStore.ArchiveSignup is administrative. It is the soft delete every table in this module has: the row stops appearing in reads, and nothing about what it holds changes. The contact is still stored, nothing is suppressed, and the uniqueness still covers the row — so the next signup from that address gets ErrAlreadySignedUp.

SignupStore.Withdraw is the person's own request. It erases what the row said about them and keeps the suppression. Somebody clicking "unsubscribe" wants this one, and a consumer that reaches for the archive instead has written the bug this package exists to prevent.

ClosesAt is required, and the column is NOT NULL

A list names the instant it stops taking signups, and there is no way to say "never". That is the one shape here worth arguing about, so the argument is written down.

A nullable closing time reads as "this list never closes", and honoring it means `closes_at IS NULL OR closes_at > now` — a disjunction over the column the read pages by, on the read this package exists to serve, on three dialects. What the NOT NULL column buys instead is one comparison against a bound instant, which is what makes ListStore.ListOpenLists a keyset page rather than a filter applied after the fact.

The state the nullable column was for is still expressible and is the state the row already had. A list whose end is not yet decided names a far horizon and is brought in by ListStore.UpdateList when the date is known; a list that should stop taking signups this instant is archived, which is the retirement this schema already has. See waitlists/migrations.

The comparison is against the store's clock rather than the server's, bound as an argument. closes_at is stamped by the application, so comparing it against CURRENT_TIMESTAMP would be two clocks deciding one row — and under a test clock that only moves when a test moves it, the two are years apart. List.OpenAt and ListOpenLists therefore agree by construction, and WithClock moves both.

Scope

Every method takes a tenancy.Scope, and there is no unscoped read of anything here. A deployment with one catalog of lists passes tenancy.Global() everywhere and behaves exactly as it would have without the column.

A list and the signups against it share a scope, and the signup carries its own copy of it rather than reaching the list's through the reference — a scope predicate that had to join to find its column is a predicate a read can omit, and every read here is one that must not.

Every signup-side method also takes the list. The list is half of what addresses a signup, and a read that omitted it would be a read that could hand one list's row to a caller holding another list's id.

What this package does not do

It does not send anything. SignupStore.Invite records that somebody was let in; what reaches them is github.com/primandproper/platform-go/v13/email's to deliver, off the Signup.StatusChangedAt this stamps.

It does not number the queue. "You are 4,102nd in line" is a count that changes under whoever is reading it — every withdrawal ahead of somebody renumbers them — and the paged reads here already carry the counts a caller needs to render a position they are willing to stand behind.

It does not decide who may administer a list. github.com/primandproper/platform-go/v13/authorization is where that lives, and a store that pretended to know who was calling would be an authorization check in the wrong layer.

Where the SQL comes from

Nothing in this package composes SQL. The statements are rendered from the column lists in waitlists/internal/queries through database/querygen, committed as one .sql per dialect, checked against the schema by sqlc, and executed through the querier sqlc-gen-unison generates into waitlists/internal/waitlistsdb. A column renamed in waitlists/migrations is a failed generate rather than a runtime scan error, on both tables, in all three dialects.

The tables are waitlists/migrations' to create, at whatever prefix a consumer chooses. The platform ships no numbered migration file — see that package.

Example

Example shows the flow this package exists for: a list is opened, somebody joins it from a form, and they are invited when it is their turn.

package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/primandproper/platform-go/v13/database/dialect"
	"github.com/primandproper/platform-go/v13/database/sqlite"
	"github.com/primandproper/platform-go/v13/tenancy"
	"github.com/primandproper/platform-go/v13/waitlists"
	"github.com/primandproper/platform-go/v13/waitlists/migrations"
)

func main() {
	ctx := context.Background()
	store := exampleWiring()

	// One catalog, so the scope is global — the shape a single-tenant
	// application has, behaving exactly as it would without the column.
	scope := tenancy.Global()

	// The administrative half. Written once, by whoever decides the launch is
	// happening, rather than on a request path.
	list, err := store.CreateList(ctx, scope, &waitlists.List{
		Name:        "Launch",
		Description: "early access to the beta",
		ClosesAt:    time.Now().Add(30 * 24 * time.Hour),
	})
	if err != nil {
		panic(err)
	}

	// The request path. The contact is stored as it was given and digested as
	// Normalize renders it, so two capitalizations of one address are one
	// person.
	signup, err := store.Join(ctx, scope, list.ID, &waitlists.Signup{
		Contact: "Ada@Example.com",
		Notes:   "asked about the API",
	})
	if err != nil {
		panic(err)
	}

	fmt.Println("joined as:", signup.Status)

	if _, err = store.Join(ctx, scope, list.ID, &waitlists.Signup{Contact: "ada@example.com"}); err != nil {
		fmt.Println("already on the list:", errors.Is(err, waitlists.ErrAlreadySignedUp))
	}

	// When it is their turn. The move is a guarded write, so a second
	// invitation loses rather than sending a second email.
	if err = store.Invite(ctx, scope, list.ID, signup.ID); err != nil {
		panic(err)
	}

	if err = store.Invite(ctx, scope, list.ID, signup.ID); err != nil {
		fmt.Println("invited once:", errors.Is(err, waitlists.ErrWrongStatus))
	}

	invited, err := store.GetSignup(ctx, scope, list.ID, signup.ID)
	if err != nil {
		panic(err)
	}

	fmt.Println("now:", invited.Status)

}

// exampleWiring builds a throwaway SQLite-backed store. A real application hands
// migrations.SQL to its own migration run and builds the store over the database
// it already has.
func exampleWiring() waitlists.Store {
	ctx := context.Background()

	dir, err := os.MkdirTemp("", "waitlists-example")
	if err != nil {
		panic(err)
	}

	client, err := sqlite.NewDatabaseClient(ctx, &exampleClientConfig{
		connectionString: filepath.Join(dir, "waitlists.db"),
	})
	if err != nil {
		panic(err)
	}

	stmts, err := migrations.Statements(dialect.SQLite, waitlists.DefaultTablePrefix)
	if err != nil {
		panic(err)
	}

	for _, stmt := range stmts {
		if _, err = client.Writer().ExecContext(ctx, stmt); err != nil {
			panic(err)
		}
	}

	store, err := waitlists.NewSQLStore(client)
	if err != nil {
		panic(err)
	}

	return store
}

// exampleClientConfig is the minimum database.ClientConfig a SQLite client
// needs.
type exampleClientConfig struct {
	connectionString string
}

func (c *exampleClientConfig) GetReadConnectionString() string   { return c.connectionString }
func (c *exampleClientConfig) GetWriteConnectionString() string  { return c.connectionString }
func (c *exampleClientConfig) GetMaxPingAttempts() uint64        { return 1 }
func (c *exampleClientConfig) GetPingWaitPeriod() time.Duration  { return time.Millisecond }
func (c *exampleClientConfig) GetMaxIdleConns() int              { return 2 }
func (c *exampleClientConfig) GetMaxOpenConns() int              { return 1 }
func (c *exampleClientConfig) GetConnMaxLifetime() time.Duration { return time.Minute }
Output:
joined as: waiting
already on the list: true
invited once: true
now: invited
Example (Withdrawal)

Example_withdrawal shows the obligation this package is shaped around: an address that asks to come off a list stays off it, and the row that remembers that no longer holds the address.

package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/primandproper/platform-go/v13/database/dialect"
	"github.com/primandproper/platform-go/v13/database/sqlite"
	"github.com/primandproper/platform-go/v13/tenancy"
	"github.com/primandproper/platform-go/v13/waitlists"
	"github.com/primandproper/platform-go/v13/waitlists/migrations"
)

func main() {
	ctx := context.Background()
	store := exampleWiring()
	scope := tenancy.Global()

	list, err := store.CreateList(ctx, scope, &waitlists.List{
		Name:     "Launch",
		ClosesAt: time.Now().Add(30 * 24 * time.Hour),
	})
	if err != nil {
		panic(err)
	}

	signup, err := store.Join(ctx, scope, list.ID, &waitlists.Signup{Contact: "ada@example.com"})
	if err != nil {
		panic(err)
	}

	if err = store.Withdraw(ctx, scope, list.ID, signup.ID); err != nil {
		panic(err)
	}

	// What is left is the digest and the fact of the withdrawal. The address,
	// the notes and the subject reference are gone.
	withdrawn, err := store.GetSignupByContact(ctx, scope, list.ID, "ada@example.com")
	if err != nil {
		panic(err)
	}

	fmt.Printf("status %s, contact %q\n", withdrawn.Status, withdrawn.Contact)

	// And a later signup from the same address is refused rather than quietly
	// re-subscribing somebody who asked to be left alone.
	if _, err = store.Join(ctx, scope, list.ID, &waitlists.Signup{Contact: "ADA@example.com"}); err != nil {
		fmt.Println("stays off the list:", errors.Is(err, waitlists.ErrContactWithdrawn))
	}

}

// exampleWiring builds a throwaway SQLite-backed store. A real application hands
// migrations.SQL to its own migration run and builds the store over the database
// it already has.
func exampleWiring() waitlists.Store {
	ctx := context.Background()

	dir, err := os.MkdirTemp("", "waitlists-example")
	if err != nil {
		panic(err)
	}

	client, err := sqlite.NewDatabaseClient(ctx, &exampleClientConfig{
		connectionString: filepath.Join(dir, "waitlists.db"),
	})
	if err != nil {
		panic(err)
	}

	stmts, err := migrations.Statements(dialect.SQLite, waitlists.DefaultTablePrefix)
	if err != nil {
		panic(err)
	}

	for _, stmt := range stmts {
		if _, err = client.Writer().ExecContext(ctx, stmt); err != nil {
			panic(err)
		}
	}

	store, err := waitlists.NewSQLStore(client)
	if err != nil {
		panic(err)
	}

	return store
}

// exampleClientConfig is the minimum database.ClientConfig a SQLite client
// needs.
type exampleClientConfig struct {
	connectionString string
}

func (c *exampleClientConfig) GetReadConnectionString() string   { return c.connectionString }
func (c *exampleClientConfig) GetWriteConnectionString() string  { return c.connectionString }
func (c *exampleClientConfig) GetMaxPingAttempts() uint64        { return 1 }
func (c *exampleClientConfig) GetPingWaitPeriod() time.Duration  { return time.Millisecond }
func (c *exampleClientConfig) GetMaxIdleConns() int              { return 2 }
func (c *exampleClientConfig) GetMaxOpenConns() int              { return 1 }
func (c *exampleClientConfig) GetConnMaxLifetime() time.Duration { return time.Minute }
Output:
status withdrawn, contact ""
stays off the list: true

Index

Examples

Constants

View Source
const DefaultTablePrefix = ""

DefaultTablePrefix is the namespace the waitlist tables carry when none is configured, which is none — rendering waitlists and waitlist_signups.

The waitlist_ segment is the schema's, not the caller's: a table always says which package created it. Setting a namespace of "ddb" renders ddb_waitlists, for a database shared between applications. A namespace must not end in '_'; database/ddl supplies the separator.

Variables

View Source
var (
	// ErrNilDatabaseClient indicates a nil database.Client. It wraps
	// errors.ErrNilInputParameter, so a caller may check either.
	ErrNilDatabaseClient = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil database client")

	// ErrNilList indicates a nil *List where one was required.
	ErrNilList = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil waitlist")

	// ErrNilSignup indicates a nil *Signup where one was required.
	ErrNilSignup = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil waitlist signup")

	// ErrEmptyListName indicates a list with no name. A list nobody can name is
	// a list nobody can administer, since the id is minted by the store.
	ErrEmptyListName = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty waitlist name")

	// ErrEmptyClosesAt indicates a list with no closing time.
	//
	// It is refused rather than defaulted, because every default available is a
	// policy: an hour is too short for anything, a decade is a list nobody will
	// ever close, and "never" is the state the column deliberately cannot hold.
	// See waitlists/migrations.
	ErrEmptyClosesAt = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "waitlist has no closing time")

	// ErrEmptyContact indicates a signup with no contact, or one that is
	// nothing but whitespace. The address is what the list exists to hold.
	ErrEmptyContact = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty waitlist signup contact")

	// ErrEmptySubjectType indicates a Subject naming an id and no type.
	ErrEmptySubjectType = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty subject type")

	// ErrEmptySubjectID indicates a Subject naming a type and no id.
	ErrEmptySubjectID = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty subject id")

	// ErrListNotFound indicates no live list by that id in this scope. Every
	// signup-side call can return it, because a signup is only meaningful
	// against a list.
	ErrListNotFound = platformerrors.New("waitlist not found")

	// ErrSignupNotFound indicates no live signup by that id on that list in
	// this scope.
	ErrSignupNotFound = platformerrors.New("waitlist signup not found")

	// ErrListClosed indicates a signup for a list that has stopped taking them:
	// past its closing time, or archived.
	//
	// It is a distinct error rather than a not-found, because the two lead a
	// caller somewhere different — a closed list is a page that says "we are no
	// longer taking signups", and a missing one is a broken link.
	ErrListClosed = platformerrors.New("waitlist is closed")

	// ErrAlreadySignedUp indicates a contact that is already on this list.
	//
	// It is a distinct error rather than a raw constraint violation because the
	// difference between "your input collides" and "the database is unwell"
	// decides whether the caller reports to a person or retries. It covers
	// archived signups too: the uniqueness does, so a name freed by archiving is
	// a name that stays taken.
	ErrAlreadySignedUp = platformerrors.New("contact is already on this waitlist")

	// ErrContactWithdrawn indicates a signup from a contact that has asked to
	// come off this list.
	//
	// It is the reason the digest outlives the address. A withdrawal that let
	// the next signup through would be an unsubscribe that lasted until somebody
	// filled the form in again, which is not an unsubscribe — so the row stays,
	// keyed on a digest of an address the table no longer holds, and this is
	// what a later attempt gets. A person who genuinely wants back on is put
	// back by whoever administers the list, which is a deliberate act rather
	// than a form submission.
	ErrContactWithdrawn = platformerrors.New("contact has withdrawn from this waitlist")

	// ErrWrongStatus indicates a transition from a status the signup is not in:
	// converting somebody who was never invited, inviting somebody twice.
	//
	// It is the affected-row count of a guarded update rather than a decision
	// made on a read, which is what makes a transition happen exactly once. Two
	// requests inviting the same signup both find it waiting; only one of their
	// updates reports a row, and the other is told this.
	ErrWrongStatus = platformerrors.New("waitlist signup is not in the status the transition requires")

	// ErrAlreadyWithdrawn indicates a withdrawal of a signup that has already
	// been withdrawn.
	//
	// A replay reports it rather than restamping, because the moment somebody
	// asked to come off a list is a fact about them and a second request should
	// not move it.
	ErrAlreadyWithdrawn = platformerrors.New("waitlist signup has already been withdrawn")
)

The sentinels this package returns. They live together because a caller deciding what to do next is choosing between them, and a set spread across the files that happen to return each one cannot be read as the set it is.

Functions

func Normalize

func Normalize(contact string) string

Normalize renders a contact as it is digested: trimmed of surrounding space and folded to lower case.

It is exported because it is half of what a caller needs to reproduce a digest, and because it is the answer to "why did my signup collide". Two people typing Ada@Example.com and ada@example.com are one person, and a suppression that missed the second would be a suppression that did not work.

It goes no further than that on purpose. Plus-addressing, dots in a Gmail local part and unicode normalization are each a provider's own policy about which addresses are the same mailbox, and a library that guessed would be merging two people's signups at some providers and splitting one person's at others. What is stored in Contact is what the caller passed, so the address the list writes to is the address somebody gave it.

Types

type List

type List struct {

	// CreatedAt is when the list was opened. It is the database's clock rather
	// than the application's, read back by the write — see waitlists/migrations.
	CreatedAt time.Time `json:"createdAt"`

	// ClosesAt is when the list stops taking signups. Required.
	//
	// It is a value rather than a pointer, and the column behind it is NOT NULL,
	// which is the one shape in this package worth arguing about — see the
	// package documentation. A list whose end is not yet decided names a far
	// horizon and is brought in by an update; a list that should stop this
	// instant is archived.
	ClosesAt time.Time `json:"closesAt"`

	// LastUpdatedAt is when the list last changed, or nil for one that has not
	// been edited.
	LastUpdatedAt *time.Time `json:"lastUpdatedAt"`

	// ArchivedAt is when the list was retired. An archived list is excluded from
	// every read that does not ask for archived rows, and takes no further
	// signups; the signups already against it are left alone, because archiving
	// is not erasure.
	ArchivedAt *time.Time `json:"archivedAt"`

	// ID identifies the list. Minted on write when empty.
	ID string `json:"id"`

	// Name is what the list is called, for whoever administers it and for
	// whatever renders the signup form. Required.
	//
	// It is not unique and is not a handle: a list is addressed by its id
	// everywhere in this package, so two lists may share a name and neither is
	// reachable by it.
	Name string `json:"name"`

	// Description is prose about what people are queueing for.
	Description string `json:"description"`

	// Scope is whose list this is. See the tenancy package.
	Scope tenancy.Scope `json:"scope"`
	// contains filtered or unexported fields
}

List is a waitlist: a named queue people join before the thing they are queueing for exists.

Lists are administrative rows. Nothing on a public request path creates one — which lists a deployment runs is a decision, in the same sense that a product launch is — and what a public path does is read one and add a signup to it.

func (*List) OpenAt

func (l *List) OpenAt(t time.Time) bool

OpenAt reports whether the list was taking signups at t: not archived, and not yet closed.

It is the same question SignupStore.Join asks before it writes and the same one ListOpenLists pages by, spelled once here so a caller rendering a form and the store refusing a write cannot disagree about the boundary. The boundary is exclusive on the open side — a list whose closing instant is exactly t is closed — which is the reading that leaves no instant at which a list is neither open nor closed.

type ListStore

type ListStore interface {
	// CreateList opens a waitlist and returns it as stored, with the id it was
	// minted under and the creation time the database assigned.
	//
	// It refuses a list with no name and one with no closing time. There is no
	// default for the latter — see ErrEmptyClosesAt.
	CreateList(ctx context.Context, scope tenancy.Scope, list *List) (*List, error)

	// GetList reads one live list by id.
	GetList(ctx context.Context, scope tenancy.Scope, listID string) (*List, error)

	// ListLists pages the scope's catalog, open and closed alike. It is the
	// administrative read.
	ListLists(ctx context.Context, scope tenancy.Scope, filter *filtering.QueryFilter) (*filtering.QueryFilteredResult[List], error)

	// ListOpenLists pages the lists still taking signups: live, and not yet
	// past their closing time as of the store's clock.
	//
	// It is the public read — what a "join the waitlist" page offers — and it
	// is a separate statement rather than a filter applied to ListLists'
	// results, because a page filtered after the fact is a page whose size the
	// caller cannot rely on.
	ListOpenLists(ctx context.Context, scope tenancy.Scope, filter *filtering.QueryFilter) (*filtering.QueryFilteredResult[List], error)

	// UpdateList rewrites a list's name, description and closing time.
	//
	// Moving the closing time is how a list is extended or brought in, and it
	// is not guarded against the signups already on it: a list closed early
	// keeps everybody who joined while it was open, and reopening one lets the
	// next person through. What it will not do is revive an archived list.
	UpdateList(ctx context.Context, scope tenancy.Scope, list *List) error

	// ArchiveList retires a list.
	//
	// The signups against it are left alone and stay readable, because archiving
	// is not erasure. What it does do is close the list to new signups
	// immediately, whatever its closing time says — see List.OpenAt.
	ArchiveList(ctx context.Context, scope tenancy.Scope, listID string) error
}

ListStore is the catalog: what lists exist, what they are for, and when each stops taking signups.

Every method takes a tenancy.Scope, and a deployment with one catalog passes tenancy.Global() to all of them. There is deliberately no unscoped variant of any of these — see the tenancy package.

type SQLStore

type SQLStore struct {
	// contains filtered or unexported fields
}

SQLStore is the SQL-backed Store, against the schema waitlists/migrations renders.

It is exported, and returned by NewSQLStore, so a caller who has chosen SQL storage can depend on that choice rather than on the Store seam every backing shares.

func NewSQLStore

func NewSQLStore(client database.Client, opts ...SQLStoreOption) (*SQLStore, error)

NewSQLStore builds a Store over the given database.

The dialect comes from the client, so the two cannot disagree. The prefix must still match the one the migrations were rendered with — nothing here can check that, and a mismatch surfaces as a missing table on the first query rather than at construction.

Observability is optional and defaults to nothing: an unconfigured store logs to a noop logger and traces to a noop provider.

func (*SQLStore) ArchiveList

func (s *SQLStore) ArchiveList(ctx context.Context, scope tenancy.Scope, listID string) error

ArchiveList retires one of the scope's lists.

func (*SQLStore) ArchiveSignup

func (s *SQLStore) ArchiveSignup(ctx context.Context, scope tenancy.Scope, listID, signupID string) error

ArchiveSignup retires a signup administratively.

func (*SQLStore) Convert

func (s *SQLStore) Convert(ctx context.Context, scope tenancy.Scope, listID, signupID string) error

Convert moves an invited signup to converted.

func (*SQLStore) CreateList

func (s *SQLStore) CreateList(ctx context.Context, scope tenancy.Scope, list *List) (*List, error)

CreateList opens a waitlist in the scope's catalog.

The insert and the read-back of the creation time share one transaction. The column is the database's — see waitlists/internal/queries — so the alternative is a caller whose struct says 0001-01-01 for a row written a moment ago.

func (*SQLStore) Digest

func (s *SQLStore) Digest(contact string) string

Digest renders what the contact_digest column holds for a contact.

It is exported for the one caller the Store seam cannot serve: a deployment migrating off a hand-written table, which has to write the new column from the addresses it is holding. It normalizes first, so it is the digest a signup made with any capitalization of that address would be found under.

It is not a verification and it is not reversible. What it protects is narrower than passwordreset's digest of a random secret — see WithHasher.

func (*SQLStore) GetList

func (s *SQLStore) GetList(ctx context.Context, scope tenancy.Scope, listID string) (*List, error)

GetList reads one of the scope's live lists by id.

func (*SQLStore) GetSignup

func (s *SQLStore) GetSignup(
	ctx context.Context,
	scope tenancy.Scope,
	listID, signupID string,
) (*Signup, error)

GetSignup reads one live signup by id, on the list it belongs to.

func (*SQLStore) GetSignupByContact

func (s *SQLStore) GetSignupByContact(
	ctx context.Context,
	scope tenancy.Scope,
	listID, contact string,
) (*Signup, error)

GetSignupByContact reads one live signup by the address it was made with.

The statement behind it sees archived rows, because it is the same statement Join's suppression check runs — see refuseTakenContact. An archived signup is not a live one, so this reports ErrSignupNotFound for it; a withdrawn signup is live and comes back, which is what lets an unsubscribe page say "you are already off this list" rather than "we have never heard of you".

func (*SQLStore) Invite

func (s *SQLStore) Invite(ctx context.Context, scope tenancy.Scope, listID, signupID string) error

Invite moves a waiting signup to invited.

func (*SQLStore) Join

func (s *SQLStore) Join(
	ctx context.Context,
	scope tenancy.Scope,
	listID string,
	signup *Signup,
) (*Signup, error)

Join adds somebody to a list.

The list read, the suppression check and the insert share one transaction. Without it a signup could be written against a list that closed between the check and the write, or past a withdrawal that landed in the same instant — and the second of those is the obligation this package exists to keep.

The suppression check is a read rather than a reliance on the unique index, for the reason settings' name check is: a constraint violation reaches a caller as a driver error naming an index, which they cannot tell apart from the database being unwell and cannot show to a person. The index is still what makes it true under a concurrent write.

func (*SQLStore) ListLists

func (s *SQLStore) ListLists(
	ctx context.Context,
	scope tenancy.Scope,
	filter *filtering.QueryFilter,
) (*filtering.QueryFilteredResult[List], error)

ListLists pages the scope's catalog, in the direction the filter names.

The direction is a choice between two generated statements rather than an argument either of them binds — see sortedRows — so what this method does with filter.SortBy is pick the one whose ORDER BY and cursor comparison agree with it.

func (*SQLStore) ListOpenLists

func (s *SQLStore) ListOpenLists(
	ctx context.Context,
	scope tenancy.Scope,
	filter *filtering.QueryFilter,
) (*filtering.QueryFilteredResult[List], error)

ListOpenLists pages the lists still taking signups.

The horizon is the store's clock rather than the server's, bound as an argument — see queries.OpenAsOfArg. That is what puts this read and List.OpenAt on the same clock: a test that moves the store's clock past a list's closing time sees the list leave this page, which it would not if the statement read CURRENT_TIMESTAMP.

func (*SQLStore) ListSignups

func (s *SQLStore) ListSignups(
	ctx context.Context,
	scope tenancy.Scope,
	listID string,
	filter *filtering.QueryFilter,
) (*filtering.QueryFilteredResult[Signup], error)

ListSignups pages one list's live signups.

func (*SQLStore) ListSignupsForSubject

func (s *SQLStore) ListSignupsForSubject(
	ctx context.Context,
	scope tenancy.Scope,
	subject Subject,
	filter *filtering.QueryFilter,
) (*filtering.QueryFilteredResult[Signup], error)

ListSignupsForSubject pages the signups belonging to one principal across every list in the scope.

func (*SQLStore) TablePrefix

func (s *SQLStore) TablePrefix() string

TablePrefix returns the namespace this store's tables carry, for a caller that needs the rendered names — a maintenance TRUNCATE, a schema audit. Pass it to migrations.Tables.

func (*SQLStore) UpdateList

func (s *SQLStore) UpdateList(ctx context.Context, scope tenancy.Scope, list *List) error

UpdateList rewrites a list's name, description and closing time.

func (*SQLStore) UpdateSignupNotes

func (s *SQLStore) UpdateSignupNotes(
	ctx context.Context,
	scope tenancy.Scope,
	listID, signupID, notes string,
) error

UpdateSignupNotes rewrites the operator's note against a signup.

func (*SQLStore) Withdraw

func (s *SQLStore) Withdraw(ctx context.Context, scope tenancy.Scope, listID, signupID string) error

Withdraw takes somebody off the list at their own request.

type SQLStoreOption

type SQLStoreOption func(*SQLStore)

SQLStoreOption configures a SQLStore.

The observability dependencies are options rather than parameters because every one of them is genuinely optional: an absent logger logs nowhere, an absent tracer provider traces nowhere, and an absent metrics provider records nothing. A caller wanting none of the three names none of them.

func WithClock

func WithClock(c clock.Clock) SQLStoreOption

WithClock swaps the clock that decides whether a list is still open and stamps every lifecycle transition.

It is one clock rather than two on purpose. A list's closing time is compared against whatever this returns — the comparison is a bound instant rather than the server's CURRENT_TIMESTAMP, for the reason querygen.AtMostArgument gives — so a test clock that only moves when a test moves it decides both halves consistently.

func WithHasher

func WithHasher(hasher hashing.Hasher) SQLStoreOption

WithHasher swaps what the contact_digest column holds.

The default is SHA-256, and a replacement must be a cryptographic hash — hashing.Hasher also has adler32, crc64 and fnv implementations, and a checksum here is a column whose collisions are somebody else's signup.

What it protects is narrower than passwordreset's digest and worth being honest about. A contact is an address somebody chose, not 256 bits from a CSPRNG, so the digest of a withdrawn signup is guessable by anyone willing to hash a list of addresses — it is not there to make the withdrawal secret. It is there so the row that remembers a withdrawal does not have to keep the address it is about, which is the difference between a suppression list and a mailing list you promised not to use.

Changing it on a deployed store orphans every digest already written: existing signups stop being found by their contacts, and a withdrawn contact stops being suppressed. A deployment that swaps it rewrites the column first.

func WithStoreLogger

func WithStoreLogger(logger logging.Logger) SQLStoreOption

WithStoreLogger attaches a logger. An absent logger logs nowhere.

func WithStoreMetricsProvider

func WithStoreMetricsProvider(metricsProvider metrics.Provider) SQLStoreOption

WithStoreMetricsProvider attaches a metrics provider. An absent provider records nothing.

func WithStorePillars

func WithStorePillars(p *observability.Pillars) SQLStoreOption

WithStorePillars attaches a logger, tracer provider, and metrics provider in one go. A nil Pillars attaches nothing.

Options apply in order, so a caller can hand over its pillars and then override one of them.

func WithStoreTracerProvider

func WithStoreTracerProvider(tracerProvider tracing.Provider) SQLStoreOption

WithStoreTracerProvider attaches a tracer provider, enabling spans on every read and write. An absent provider traces nowhere.

It takes a provider rather than a ready-made tracer so that the spans this package emits carry this package's instrumentation scope. A caller-supplied tracer would attribute them to whoever built it.

func WithTablePrefix

func WithTablePrefix(prefix string) SQLStoreOption

WithTablePrefix namespaces the two waitlist tables. It must match the prefix the migrations were rendered with; nothing here can check that, and a mismatch surfaces as a missing table on the first query rather than at construction.

type Signup

type Signup struct {

	// CreatedAt is when they joined, which is also the order they joined in —
	// the id sorts by creation time, and every page here walks it.
	CreatedAt time.Time `json:"createdAt"`

	// LastUpdatedAt is when the row last changed, or nil for one nobody has
	// touched since it was written.
	LastUpdatedAt *time.Time `json:"lastUpdatedAt"`

	// StatusChangedAt is when the signup last moved through the lifecycle, or
	// nil for one that is still where it started.
	//
	// It is not LastUpdatedAt, and the difference is the point: an administrator
	// fixing a typo in Notes changes the row without moving anybody, and the
	// reminder that goes out three days after an invitation is scheduled off
	// this column.
	StatusChangedAt *time.Time `json:"statusChangedAt"`

	// ArchivedAt is when the signup was retired administratively. It is not a
	// withdrawal — see [StatusWithdrawn], which is what somebody asking to come
	// off the list gets.
	ArchivedAt *time.Time `json:"archivedAt"`

	// Subject is who the signup belongs to, where anybody does. The zero value
	// is a signup that names nobody, which is the ordinary case.
	Subject Subject `json:"subject"`

	// ID identifies the signup. Minted on write when empty.
	ID string `json:"id"`

	// ListID is the list this signup is for.
	ListID string `json:"listID"`

	// Contact is the address the list exists to write to, as it was given —
	// [Normalize] is what the digest is taken of, not what this holds, so a
	// mail client renders the capitalization somebody typed.
	//
	// It is empty for a withdrawn signup, which is the whole of what a
	// withdrawal erases from this column.
	Contact string `json:"contact"`

	// ContactDigest is what the row is found by and what survives a withdrawal.
	//
	// It is [SQLStore.Digest] of [Normalize] of the contact, and it is
	// exported because a deployment migrating off a hand-written table has to
	// write the column from the addresses it is holding. It is not reversible,
	// so a caller holding one holds nothing.
	ContactDigest string `json:"contactDigest"`

	// Notes is whatever whoever administers the list wrote about this signup.
	// It is empty for a withdrawn signup.
	Notes string `json:"notes"`

	// Status is where the signup stands.
	Status Status `json:"status"`

	// Scope is whose list this signup is on.
	Scope tenancy.Scope `json:"scope"`
	// contains filtered or unexported fields
}

Signup is one person's place on one list.

func (*Signup) Withdrawn

func (s *Signup) Withdrawn() bool

Withdrawn reports whether the signup has been taken off the list.

type SignupStore

type SignupStore interface {
	// Join adds somebody to a list.
	//
	// It refuses a list that is closed or missing (ErrListClosed,
	// ErrListNotFound), a contact already on the list (ErrAlreadySignedUp), and
	// a contact that has withdrawn from it (ErrContactWithdrawn) — the last of
	// which is the obligation this package is shaped around and outlives the
	// address it is about.
	//
	// The signup's Contact is stored as it was given and digested as
	// [Normalize] renders it, so two capitalizations of one address are one
	// person. Status and the timestamps are the store's; whatever the caller
	// set on them is ignored.
	Join(ctx context.Context, scope tenancy.Scope, listID string, signup *Signup) (*Signup, error)

	// GetSignup reads one live signup by id, on the list it belongs to.
	GetSignup(ctx context.Context, scope tenancy.Scope, listID, signupID string) (*Signup, error)

	// GetSignupByContact reads one live signup by the address it was made with,
	// which is the read behind "am I already on this list" and the read an
	// unsubscribe link resolves.
	//
	// The contact is normalized and digested, so it is found by whichever
	// capitalization the caller has. A withdrawn signup is still a live row and
	// comes back as one, with its contact blank and its status saying why —
	// which is what lets an unsubscribe page tell somebody they are already off
	// the list rather than that they were never on it.
	GetSignupByContact(ctx context.Context, scope tenancy.Scope, listID, contact string) (*Signup, error)

	// ListSignups pages one list's live signups, oldest first by default, which
	// is the order they joined in.
	ListSignups(ctx context.Context, scope tenancy.Scope, listID string, filter *filtering.QueryFilter) (*filtering.QueryFilteredResult[Signup], error)

	// ListSignupsForSubject pages the signups belonging to one principal across
	// every list in the scope. It is the read a profile page makes and the one a
	// data privacy export walks.
	//
	// A withdrawn signup is not among them: a withdrawal blanks the subject
	// reference along with the contact, so the row that remembers a suppression
	// no longer says whose it was.
	ListSignupsForSubject(ctx context.Context, scope tenancy.Scope, subject Subject, filter *filtering.QueryFilter) (*filtering.QueryFilteredResult[Signup], error)

	// UpdateSignupNotes rewrites the operator's note against a signup.
	//
	// It is the one write that touches a signup without moving it, and it
	// deliberately leaves StatusChangedAt alone — a typo fixed in a note must
	// not reschedule the reminder somebody's invitation started.
	UpdateSignupNotes(ctx context.Context, scope tenancy.Scope, listID, signupID, notes string) error

	// Invite moves a waiting signup to invited and stamps the moment.
	//
	// It refuses anything that is not waiting with ErrWrongStatus, and the
	// refusal is the affected-row count of a guarded update rather than a
	// decision made on a read — so two requests inviting the same person send
	// one email between them.
	Invite(ctx context.Context, scope tenancy.Scope, listID, signupID string) error

	// Convert moves an invited signup to converted and stamps the moment. It
	// refuses anything that is not invited with ErrWrongStatus, guarded the same
	// way Invite is.
	Convert(ctx context.Context, scope tenancy.Scope, listID, signupID string) error

	// Withdraw takes somebody off the list at their own request, and erases what
	// the row said about them.
	//
	// It blanks the contact, the notes and the subject reference and keeps the
	// contact digest, which is what lets a later signup from the same address be
	// refused with ErrContactWithdrawn instead of quietly re-subscribing
	// somebody who asked to be left alone. It moves a signup in any status; a
	// second call reports ErrAlreadyWithdrawn rather than restamping the moment
	// they left.
	Withdraw(ctx context.Context, scope tenancy.Scope, listID, signupID string) error

	// ArchiveSignup retires a signup administratively.
	//
	// It is not a withdrawal and must not be used as one: it hides the row from
	// every read that does not ask for archived rows and changes nothing about
	// what the row holds, so the contact is still stored and nothing suppresses
	// a re-signup — the uniqueness covers archived rows, so what the next
	// attempt gets is ErrAlreadySignedUp. Somebody asking to come off a list
	// wants Withdraw.
	ArchiveSignup(ctx context.Context, scope tenancy.Scope, listID, signupID string) error
}

SignupStore is the queue: who is on a list, where they stand, and what happens when they ask to come off it.

Every method takes both the scope and the list. The list is half of what addresses a signup, and a read that omitted it would be a read that could hand one list's row to a caller holding another list's id.

type Status

type Status string

Status is where one signup stands.

It is a closed set, unlike SubjectType, and the two differ for a reason worth stating: a subject type names a kind of principal, which is the application's to invent, while a status decides which transitions the store will make and what a withdrawal means. A status this package does not implement is a row nothing can move.

const (
	// StatusWaiting is somebody who has joined and not yet been invited. It is
	// where every signup starts.
	StatusWaiting Status = "waiting"
	// StatusInvited is somebody who has been let in and has not yet taken it
	// up. [SignupStore.Invite] is what puts them here, and
	// [Signup.StatusChangedAt] is when — which is the column a reminder is
	// scheduled off.
	StatusInvited Status = "invited"
	// StatusConverted is somebody who took the invitation up. The waitlist has
	// done its job and whatever they became is somebody else's row.
	StatusConverted Status = "converted"
	// StatusWithdrawn is somebody who asked to come off the list, and it is the
	// status this package is shaped around.
	//
	// It is the one status nothing moves out of, and it is a suppression rather
	// than a deletion. The row keeps its contact digest and loses everything
	// else that identifies a person, so a later signup from the same address is
	// recognized and refused instead of quietly re-subscribing whoever asked to
	// be left alone. See the package documentation.
	StatusWithdrawn Status = "withdrawn"
)

func (Status) String

func (s Status) String() string

String renders the status as it is stored.

func (Status) Valid

func (s Status) Valid() bool

Valid reports whether s is one of the four statuses.

It is exported for the caller decoding one out of a request or a stored document, which is the only place a status this package does not implement can come from: the store writes the column itself and every transition names both ends of its move.

type Store

type Store interface {
	ListStore
	SignupStore
}

Store is the whole of what this package persists: the lists, and the people queueing on them.

It is two interfaces because they have two callers. A ListStore is reached by whatever administers a deployment — an admin console, a seeding job, whoever decides a launch is happening — and a SignupStore is reached on the request path, by the form somebody fills in and by the operator working through the queue. Splitting them is what lets a component depend on the half it uses; Store is here for the wiring that provides both.

type Subject

type Subject struct {
	// Type says what kind of principal this is.
	Type SubjectType `json:"type"`
	// ID identifies the principal within that type.
	ID string `json:"id"`
}

Subject is who a signup belongs to, where anybody does.

The zero value is the ordinary case: a pre-launch list has an address and nothing else, and a signup that names nobody is stored with both columns empty. It is two fields rather than one composite string for the reason the tenancy doctrine gives for the scope: a key spelling "user:abc123" carries two facts in a column that can only be indexed as one.

func (Subject) Anonymous

func (s Subject) Anonymous() bool

Anonymous reports whether the subject names nobody, which is what a signup carrying only a contact looks like.

func (Subject) Validate

func (s Subject) Validate() error

Validate reports whether the subject is one this package can store: either wholly absent, or naming both halves.

Half a subject is refused rather than stored, because the read that finds one binds both columns — a signup with a type and no id is a row nothing will ever list, and a signup with an id and no type is a row the wrong list would find.

type SubjectType

type SubjectType string

SubjectType distinguishes the kinds of thing a signup can belong to.

Like settings.SubjectType and audit.ActorType this is a bare string with suggested constants rather than a closed set: an application whose signups hang off a third kind of principal — a device, a workspace, an API client — should say so rather than misfile it as one of these.

const (
	// SubjectUser is a signup made by somebody who already has an account.
	SubjectUser SubjectType = "user"
	// SubjectAccount is a signup made on an account's behalf, for a list a
	// whole organization is queueing for.
	SubjectAccount SubjectType = "account"
)

func (SubjectType) String

func (t SubjectType) String() string

String renders the subject type as it is stored.

Directories

Path Synopsis
Package waitlistscfg assembles a waitlists Store from environment configuration.
Package waitlistscfg assembles a waitlists Store from environment configuration.
internal
queries
Package queries is the waitlist schema described as data: the canonical table names, each table's columns in the order every read projects them, and the subsets a write may assign.
Package queries is the waitlist schema described as data: the canonical table names, each table's columns in the order every read projects them, and the subsets a write may assign.
queriesgen command
Command queriesgen writes the canonical sqlc input for the waitlist schema, one file per dialect, from waitlists/internal/queries.
Command queriesgen writes the canonical sqlc input for the waitlist schema, one file per dialect, from waitlists/internal/queries.
Package migrations supplies the waitlist tables' DDL, rendered for a dialect and table prefix.
Package migrations supplies the waitlist tables' DDL, rendered for a dialect and table prefix.
Package waitlistsmock provides moq-generated mock implementations of interfaces in the waitlists package.
Package waitlistsmock provides moq-generated mock implementations of interfaces in the waitlists package.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL