sqlite

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package sqlite is the SQLite reference implementation of every store interface sulis defines: sulis.UserStore, sulis.SessionStore, sulis.TokenStore, passkey.Store, passkey.ChallengeStore, totp.Store, and recovery.Store.

It lives in its own module, github.com/borfast/sulis/store/sql, so that a SQL driver never enters the dependency graph of an application that brings its own store. Depending on this package is opting in to modernc.org/sqlite; depending on sulis is not.

What it is for

memstore shows what the contracts mean with a mutex. This package shows what they mean in SQL, which is where most adopters will actually have to satisfy them: every atomicity requirement the interfaces document is expressed here as one conditional statement or one transaction, and the comment on each method names which. Both packages are held to the same bar — the whole storetest conformance suite, unmodified, passes against both.

It is also a usable store. modernc.org/sqlite is pure Go, so this builds and runs without cgo, and a single-file SQLite database is a reasonable answer for a single-process application. It is not an answer for several application processes sharing one database file over a network filesystem; see T602's Postgres store for that shape.

Concurrency

Open configures the pool with exactly one connection. SQLite allows one writer at a time whatever the pool says, so a second connection buys concurrent reads and costs SQLITE_BUSY handling on every write path; one connection makes every statement and every transaction here serial by construction, which is the honest way to satisfy contracts whose entire point is that a check and a mutation cannot be split. The cost is throughput, and it is stated rather than hidden: this store serializes.

Every multi-statement transaction in this package issues a write as its first statement, so SQLite takes the write lock at BEGIN-plus-one rather than trying to upgrade a read transaction later — the one deadlock shape busy_timeout famously does not rescue you from. That property is worth preserving if you adapt this code to a pool with more connections.

Aliasing

The interfaces forbid a store from sharing mutable state with its callers in either direction. A store that reconstructs rows from a database read gets this for free, and this one does: nothing a caller passes in is retained, and every value handed back is built from column values.

Index

Constants

View Source
const DefaultChallengeTTL = 5 * time.Minute

DefaultChallengeTTL is how long a challenge stays consumable when ChallengeStore.TTL is left at zero, matching the lifetime of a WebAuthn ceremony.

View Source
const DriverName = "sqlite"

DriverName is the database/sql driver name modernc.org/sqlite registers. Importing this package registers it, since the driver is imported here.

Variables

View Source
var ErrChallengeNotFound = errors.New("sulis/sqlite: challenge not found")

ErrChallengeNotFound is returned by ChallengeStore.ConsumeChallenge when no live challenge is stored under the key — already consumed, expired, or never saved. passkey.ChallengeStore leaves this error implementation-defined; the caller normalizes whatever comes back to passkey.ErrChallengeExpired.

View Source
var ErrTOTPCounterRegressed = errors.New("sulis/sqlite: TOTP counter would regress")

ErrTOTPCounterRegressed is returned by TOTPStore.SaveTOTP when a save would lower LastUsedCounter for the active credential with the same ID. totp.Store requires such a save to be rejected but does not name the error; failing closed is the point, since a counter that can regress is a code that can be replayed.

View Source
var Schema string

Schema is the DDL every store in this package expects, exported so an adopter can hand it to their own migration tool instead of calling Migrate. It is written to be read: each constraint carries the contract it exists to enforce.

Functions

func FileDSN

func FileDSN(path string) string

FileDSN returns a DSN for the database file at path, with the pragmas a durable single-process store wants: WAL journaling (readers do not block the writer), synchronous=NORMAL (the WAL-safe setting — a crash can lose the last committed transaction but cannot corrupt the database), and a 10-second busy timeout as a backstop for another process holding the file.

The path is percent-escaped into the URI. Concatenating it raw would be a quiet data-loss bug rather than an error: a path containing "?" or "#" terminates the URI early, and SQLite would happily create and use a different file than the one asked for.

func MemoryDSN

func MemoryDSN() string

MemoryDSN returns a DSN for a private in-memory database, useful for tests and examples. The database exists only as long as the connection does, which is why Open pins the pool to a single connection that is never retired.

func Migrate

func Migrate(ctx context.Context, db *sql.DB) error

Migrate applies Schema. Every statement runs in one transaction, so a database is either fully migrated or untouched.

Types

type ChallengeStore

type ChallengeStore struct {

	// TTL is how long a saved challenge stays consumable. Zero — or any
	// non-positive value, since "expire before you were saved" is not a
	// coherent request — means DefaultChallengeTTL. The interface asks
	// implementations to expire entries after roughly the lifetime of a
	// WebAuthn ceremony; nothing above the store enforces that, so it is
	// enforced here.
	TTL time.Duration
	// contains filtered or unexported fields
}

ChallengeStore is the SQLite passkey.ChallengeStore.

ConsumeChallenge is one DELETE ... RETURNING, so two concurrent finishes of the same ceremony cannot both receive the challenge — a replayed WebAuthn response must not be verifiable twice against one challenge.

func (*ChallengeStore) ConsumeChallenge

func (s *ChallengeStore) ConsumeChallenge(ctx context.Context, key string) ([]byte, error)

ConsumeChallenge fetches and deletes the challenge stored under key in one statement, so only one caller can ever receive it.

An expired challenge is deleted and then refused, rather than filtered out of the DELETE: burning the row either way matches the "failures burn the token" direction the rest of this module takes, and leaves nothing behind for a second attempt to find.

func (*ChallengeStore) DeleteExpiredChallenges

func (s *ChallengeStore) DeleteExpiredChallenges(ctx context.Context) error

DeleteExpiredChallenges removes challenges nobody finished in time. The ChallengeStore interface has no sweep method — ConsumeChallenge already removes the row it reads, and an abandoned ceremony is the only way one is ever left behind — so this is offered for a caller that wants to run it on a timer rather than required by the contract.

func (*ChallengeStore) SaveChallenge

func (s *ChallengeStore) SaveChallenge(ctx context.Context, key string, sessionData []byte) error

SaveChallenge stores sessionData under key, replacing anything already there. The bytes are copied into the database, so a caller reusing its buffer afterwards cannot rewrite a challenge it already handed over.

type DB

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

DB owns a database handle and hands out the seven store implementations. All of them read and write through the same handle, so one DB is one database.

func New

func New(db *sql.DB) *DB

New wraps an already-configured *sql.DB. The caller keeps ownership: New neither applies Schema (call Migrate, or run the DDL through your own migration tool) nor closes the handle, and DB.Close on a DB built this way closes the handle the caller passed in.

Configure the pool with SetMaxOpenConns(1) unless you have read the package documentation's concurrency section and decided otherwise.

func Open

func Open(ctx context.Context, dsn string) (*DB, error)

Open opens the database named by dsn, configures the connection pool, and applies Schema. Build dsn with FileDSN or MemoryDSN, or write your own.

Neither error below names dsn, matching the Postgres sibling: a DSN never reaches an error string, not even wrapped. A SQLite DSN carries a file path rather than a password, but the rule is absolute on purpose — a path is deployment topology, an encrypted build's key pragma would be a credential outright, and a rule applied case by case is a rule somebody eventually decides wrong. Nothing is lost: the caller passed dsn in and still has it.

The pool is pinned to a single connection, never retired: see the package documentation for why one connection is the honest configuration for a store whose contracts are about atomicity, and note that an in-memory database would not survive its connection being recycled anyway.

The caller owns the returned DB and must Close it.

func (*DB) ChallengeStore

func (d *DB) ChallengeStore() *ChallengeStore

ChallengeStore returns the passkey.ChallengeStore backed by this database, with the default challenge lifetime. Set its TTL field to change it.

func (*DB) Close

func (d *DB) Close() error

Close closes the underlying handle.

func (*DB) PasskeyStore

func (d *DB) PasskeyStore() *PasskeyStore

PasskeyStore returns the passkey.Store backed by this database.

func (*DB) RecoveryStore

func (d *DB) RecoveryStore() *RecoveryStore

RecoveryStore returns the recovery.Store backed by this database.

func (*DB) SQL

func (d *DB) SQL() *sql.DB

SQL returns the underlying handle, for callers that need to run their own statements against the same database (a health check, a report, a migration of their own tables).

func (*DB) SessionStore

func (d *DB) SessionStore() *SessionStore

SessionStore returns the sulis.SessionStore backed by this database.

func (*DB) TOTPStore

func (d *DB) TOTPStore() *TOTPStore

TOTPStore returns the totp.Store backed by this database.

func (*DB) TokenStore

func (d *DB) TokenStore() *TokenStore

TokenStore returns the sulis.TokenStore backed by this database.

func (*DB) UserStore

func (d *DB) UserStore() *UserStore

UserStore returns the sulis.UserStore backed by this database.

type PasskeyStore

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

PasskeyStore is the SQLite passkey.Store.

DeleteCredential is where the danger lives, and it is the only method here that needs more than one statement. The membership check, the remaining-count check, and the removal are expressed as a single DELETE whose WHERE clause carries all three; the follow-up query exists only to tell the two zero-row outcomes apart for the caller, and runs inside the same transaction. Split the guard from the delete and two goroutines each removing one of a user's last two credentials both observe count == 2, both pass the allowLast == false guard, and both succeed — the exact lockout the guard exists to prevent, reached through the guarded path.

func (*PasskeyStore) DeleteCredential

func (s *PasskeyStore) DeleteCredential(ctx context.Context, userID, id string, allowLast bool) error

DeleteCredential removes the credential identified by id if it belongs to userID, refusing to remove userID's last one unless allowLast is set:

DELETE FROM passkey_credentials
 WHERE id = ? AND user_id = ?
   AND (? = 1 OR (SELECT COUNT(*) FROM passkey_credentials WHERE user_id = ?) > 1)

One statement carries the ownership check, the count check, and the removal, so no concurrent caller can slip between them. Zero rows affected means either that the guard refused or that (id, userID) names nothing, and those are different errors to the caller — passkey.ErrLastCredential versus passkey.ErrPasskeyNotFound — so a follow-up existence check inside the same transaction tells them apart.

func (*PasskeyStore) DeleteCredentialsByUserID

func (s *PasskeyStore) DeleteCredentialsByUserID(ctx context.Context, userID string) error

DeleteCredentialsByUserID removes every credential owned by userID, with no last-credential guard: deleting a whole account is a stronger action the caller has already gated, and leaving one credential behind because it happened to be last would be surprising here.

func (*PasskeyStore) GetCredentialByID

func (s *PasskeyStore) GetCredentialByID(ctx context.Context, credentialID []byte) (*passkey.Credential, error)

GetCredentialByID returns the credential whose raw WebAuthn credential ID is credentialID, or passkey.ErrPasskeyNotFound.

func (*PasskeyStore) GetCredentialsByUserID

func (s *PasskeyStore) GetCredentialsByUserID(ctx context.Context, userID string) ([]passkey.Credential, error)

GetCredentialsByUserID returns every credential owned by userID. A user with no credentials is not an error: it is how a caller learns the user has no passkey enrolled.

func (*PasskeyStore) RenameCredential

func (s *PasskeyStore) RenameCredential(ctx context.Context, id, name string) error

RenameCredential sets a credential's display name — caller-supplied metadata passkey itself never generates or validates. Zero rows affected is passkey.ErrPasskeyNotFound.

func (*PasskeyStore) SaveCredential

func (s *PasskeyStore) SaveCredential(ctx context.Context, cred *passkey.Credential) error

SaveCredential persists cred, replacing any credential already stored under the same ID. Registration is the caller; the bookkeeping that changes on every later login goes through UpdateCredentialAfterLogin instead.

func (*PasskeyStore) UpdateCredentialAfterLogin

func (s *PasskeyStore) UpdateCredentialAfterLogin(ctx context.Context, credentialID []byte, signCount uint32, backupState bool, lastUsedAt time.Time) error

UpdateCredentialAfterLogin persists the three fields that must change together after every successful assertion: SignCount (clone detection), BackupState (which can flip independently of BackupEligible), and LastUsedAt. go-webauthn re-reads all three on the next ceremony, so a store that persists only some of them breaks the credential's NEXT login rather than this one — which is why they are one statement and one method.

Zero rows affected is passkey.ErrPasskeyNotFound.

type RecoveryStore

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

RecoveryStore is the SQLite recovery.Store.

A recovery code is a single-use bypass of every other factor, so ConsumeCode's lookup and delete are one statement: a store that looks a code up and then deletes it lets two concurrent presentations of the same code both succeed — one code, two authentications. The composite primary key on (user_id, code_hash) also scopes a code to its owner, so presenting someone else's code hash matches no row at all.

func (*RecoveryStore) ConsumeCode

func (s *RecoveryStore) ConsumeCode(ctx context.Context, userID, hash string) error

ConsumeCode deletes the code matching userID and hash, in one statement. Zero rows affected — spent, never issued, or issued to somebody else — is recovery.ErrCodeNotFound.

func (*RecoveryStore) CountCodes

func (s *RecoveryStore) CountCodes(ctx context.Context, userID string) (int, error)

CountCodes returns how many unused codes the user has left. A user with none is not an error: zero is the answer, and it is the answer an application shows on a "you have no recovery codes left" screen.

func (*RecoveryStore) DeleteCodes

func (s *RecoveryStore) DeleteCodes(ctx context.Context, userID string) error

DeleteCodes removes every code for the user. Removing nothing is not an error.

func (*RecoveryStore) ReplaceCodes

func (s *RecoveryStore) ReplaceCodes(ctx context.Context, userID string, hashes []string) error

ReplaceCodes replaces the user's whole code set inside one transaction, so no reader ever sees the old set gone and the new set not yet there. Regenerating replaces, never adds: an empty or nil hashes simply clears the set.

type SessionStore

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

SessionStore is the SQLite sulis.SessionStore.

The requirement worth the most here is DeleteSession's scoping: the membership check and the removal are one statement keyed on both columns, and zero affected rows is an error rather than a silent success. That is what makes cross-user revocation impossible through Sulis.RevokeSession, which passes the caller's own user ID — a store that ignored the user ID, or reported success when it deleted nothing, would hand anyone who learned a session ID the power to sign other people out.

func (*SessionStore) CleanExpired

func (s *SessionStore) CleanExpired(ctx context.Context) error

CleanExpired removes every session whose absolute expiry has passed. Idle expiry is deliberately not swept here: IdleExpiresAt moves forward on every throttled touch, ValidateSession enforces it on read, and a session left behind by an idle timeout is unusable rather than dangerous.

func (*SessionStore) CreateSession

func (s *SessionStore) CreateSession(ctx context.Context, session *sulis.Session) error

CreateSession inserts session.

func (*SessionStore) DeleteSession

func (s *SessionStore) DeleteSession(ctx context.Context, userID, id string) error

DeleteSession removes the session identified by id if it belongs to userID:

DELETE FROM sessions WHERE id = ? AND user_id = ?

Zero rows affected — whether id names nothing at all or names someone else's session — is sulis.ErrSessionNotFound. The two cases are deliberately not distinguished: telling a caller "that session exists but is not yours" would answer a question they have no business asking.

func (*SessionStore) DeleteUserSessions

func (s *SessionStore) DeleteUserSessions(ctx context.Context, userID string) error

DeleteUserSessions removes every session belonging to userID. Matching nothing is not an error.

func (*SessionStore) DeleteUserSessionsExcept

func (s *SessionStore) DeleteUserSessionsExcept(ctx context.Context, userID, keepSessionID string) error

DeleteUserSessionsExcept removes every session belonging to userID except keepSessionID — "sign out everywhere else". keepSessionID naming nothing, or naming another user's session, is not an error: every OTHER session for userID goes regardless.

func (*SessionStore) GetSessionByTokenHash

func (s *SessionStore) GetSessionByTokenHash(ctx context.Context, tokenHash string) (*sulis.Session, error)

GetSessionByTokenHash returns the session with the given token hash, or sulis.ErrSessionNotFound. Sessions are only ever looked up by hash; the raw token is never stored.

func (*SessionStore) ListUserSessions

func (s *SessionStore) ListUserSessions(ctx context.Context, userID string) ([]sulis.Session, error)

ListUserSessions returns every session belonging to userID, in no promised order. Matching nothing is not an error.

TokenHash comes back exactly as stored, the same as GetSessionByTokenHash: blanking it before an application sees it is Sulis.ListUserSessions's job, done once there rather than depended on here.

func (*SessionStore) TouchSession

func (s *SessionStore) TouchSession(ctx context.Context, id string, lastSeen time.Time, idleExpires *time.Time) error

TouchSession stamps the session identified by id with a fresh lastSeen and idleExpires, touching no other column. Zero rows affected is sulis.ErrSessionNotFound.

A nil idleExpires is written as SQL NULL, clearing whatever was there: an application that enables idle expiry and later disables it must not have the old deadline linger and quietly start enforcing itself again.

func (*SessionStore) UpdateAuthenticatedAt

func (s *SessionStore) UpdateAuthenticatedAt(ctx context.Context, id string, at time.Time) error

UpdateAuthenticatedAt stamps the session identified by id with at, touching no other column. Zero rows affected is sulis.ErrSessionNotFound.

This is ReAuthenticate's write path: it refreshes how recently the session's owner proved their credential without minting a new session or rotating its token.

type TOTPStore

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

TOTPStore is the SQLite totp.Store.

The active and pending slots are two tables, both keyed by user_id, so "at most one of each per user" is a primary key rather than a convention. Every transition between them is one statement or one transaction:

  • EnrollPending is an INSERT ... SELECT ... WHERE NOT EXISTS upsert, so the no-active-credential check and the write cannot be split.
  • ConfirmEnrollment is a DELETE ... RETURNING on the pending row feeding an upsert of the active one, in a transaction — the compare-and-swap that closes the clobber race.
  • SaveTOTP is a conditional upsert that refuses to lower the counter.
  • DeleteTOTP empties both slots in one transaction, so no promotion can land between the two removals and resurrect a factor the caller believed it had removed.

func (*TOTPStore) ConfirmEnrollment

func (s *TOTPStore) ConfirmEnrollment(ctx context.Context, userID, pendingID string, counter uint64) (*totp.Credential, error)

ConfirmEnrollment promotes userID's pending enrollment to active, but only while it is still the one named by pendingID — the enrollment whose secret the caller just matched a code against.

The comparison and the promotion are one transaction whose first statement is the DELETE that consumes the pending row:

DELETE FROM totp_pending WHERE user_id = ? AND id = ? RETURNING ...

Returning no row means pendingID no longer matches — already promoted, superseded by a racing enrollment, or never there — so nothing is touched and totp.ErrTOTPNotEnrolled comes back, which the caller treats exactly like "nothing to confirm".

counter is the time step the code was matched at. When an active credential already exists (this is a replacement, not a first enrollment) the promoted credential keeps whichever counter is higher, so swapping factors can never roll the user's replay-protection clock backwards.

func (*TOTPStore) DeleteTOTP

func (s *TOTPStore) DeleteTOTP(ctx context.Context, userID string) error

DeleteTOTP removes userID's active credential and any pending enrollment, both inside one transaction. Removing them one after the other would let a concurrent ConfirmEnrollment promotion land in the gap, leaving the just-promoted credential behind as an active factor the caller believed it had removed entirely. Removing nothing is not an error.

func (*TOTPStore) EnrollPending

func (s *TOTPStore) EnrollPending(ctx context.Context, cred *totp.Credential) error

EnrollPending stores cred as userID's pending enrollment, but only if the user has no active credential:

INSERT INTO totp_pending (user_id, id, secret, created_at)
SELECT ?, ?, ?, ?
 WHERE NOT EXISTS (SELECT 1 FROM totp_active WHERE user_id = ?)
    ON CONFLICT(user_id) DO UPDATE SET ...

The guard is the statement's own WHERE clause, so it cannot be split from the write: a ConfirmEnrollment landing between a separate check and a separate write would promote some other enrollment to active and this write would then land undetected, silently replacing a factor the user relies on. Zero rows affected therefore means an active credential exists — totp.ErrTOTPAlreadyEnrolled, which Service.Enroll surfaces unchanged.

Any pending enrollment already on file is superseded either way: at most one exists per user, and an unconfirmed one has nothing worth protecting.

func (*TOTPStore) GetActiveTOTP

func (s *TOTPStore) GetActiveTOTP(ctx context.Context, userID string) (*totp.Credential, error)

GetActiveTOTP returns userID's active (verified) credential — the one Validate checks codes against — or totp.ErrTOTPNotEnrolled, whether or not a pending enrollment exists.

func (*TOTPStore) GetPendingTOTP

func (s *TOTPStore) GetPendingTOTP(ctx context.Context, userID string) (*totp.Credential, error)

GetPendingTOTP returns userID's pending (unverified) enrollment awaiting ConfirmEnrollment, or totp.ErrTOTPNotEnrolled.

func (*TOTPStore) ReplacePending

func (s *TOTPStore) ReplacePending(ctx context.Context, cred *totp.Credential) error

ReplacePending is EnrollPending without the active-credential guard: it stores cred as userID's pending enrollment whatever else is on file, and leaves any active credential completely alone, so codes keep validating against the old factor until a later ConfirmEnrollment promotes this one.

func (*TOTPStore) SaveTOTP

func (s *TOTPStore) SaveTOTP(ctx context.Context, cred *totp.Credential) error

SaveTOTP persists an update to an existing active credential — in practice Validate's post-check LastUsedCounter bump — as a single conditional upsert:

INSERT INTO totp_active (...) VALUES (...)
    ON CONFLICT(user_id) DO UPDATE SET ...
 WHERE totp_active.id <> excluded.id
    OR excluded.last_used_counter >= totp_active.last_used_counter

The WHERE on DO UPDATE is the monotonicity guard: a save that would lower the counter for the active credential with the same ID matches nothing, changes nothing, and is reported as ErrTOTPCounterRegressed rather than applied. Of two racing validations only one advances the clock, and the loser cannot rewind it.

type TokenStore

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

TokenStore is the SQLite sulis.TokenStore.

Everything else rests on ConsumeToken's atomicity, which is why that method is the only one here with a transaction: a store that reads the row, checks used, and then writes hands two callers presenting the same password-reset link at the same instant one working reset each.

func (*TokenStore) ConsumeToken

func (s *TokenStore) ConsumeToken(ctx context.Context, hash string, purpose sulis.TokenPurpose) (*sulis.Token, error)

ConsumeToken finds the unused token matching hash AND purpose and marks it used in one statement, returning it:

UPDATE tokens SET used = 1
 WHERE token_hash = ? AND purpose = ? AND used = 0
RETURNING ...

Purpose is part of the lookup key rather than something checked afterwards, so a two-factor token presented to the password-reset flow matches nothing at all — and, because the statement never touches it, that mismatched attempt consumes nothing either.

The statement returning no row means one of two things the caller maps to different outcomes, so a follow-up existence check in the same transaction tells them apart: sulis.ErrTokenAlreadyUsed when the token is there but spent, sulis.ErrTokenNotFound when hash+purpose matches nothing.

Expiry is deliberately not part of the predicate. The contract makes this method's job "find the unused token and mark it used"; sulis compares ExpiresAt itself, on the token this returns, and wants the token burned either way.

func (*TokenStore) CreateToken

func (s *TokenStore) CreateToken(ctx context.Context, token *sulis.Token) error

CreateToken inserts token.

func (*TokenStore) DeleteExpiredTokens

func (s *TokenStore) DeleteExpiredTokens(ctx context.Context) error

DeleteExpiredTokens removes every token whose expiry has passed, spent or not.

func (*TokenStore) DeleteUserTokens

func (s *TokenStore) DeleteUserTokens(ctx context.Context, userID string, purpose sulis.TokenPurpose) error

DeleteUserTokens removes every token for the given user and purpose — the "invalidate the outstanding reset links" primitive. Deleting zero tokens is not an error.

type UserStore

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

UserStore is the SQLite sulis.UserStore.

The two requirements the interface places on a real implementation are both in the schema rather than in this file: the UNIQUE index on users.email is what makes two accounts racing to claim one address resolve to exactly one winner, and the version column is what lets UpdateUser express its compare-and-swap as a single statement. Neither can be moved up into Go code without reintroducing the race it exists to close.

func (*UserStore) CreateUser

func (s *UserStore) CreateUser(ctx context.Context, user *sulis.User) error

CreateUser inserts user. An address another user already holds — or an ID already in use, which would lose an account if it silently overwrote — comes back from SQLite as a UNIQUE violation and is reported as sulis.ErrUserAlreadyExists.

The row starts at version 0 whatever user.Version says: the version column is the store's, set on read and passed back unchanged to UpdateUser, and a new row has no prior write to be stale against.

func (*UserStore) DeleteUser

func (s *UserStore) DeleteUser(ctx context.Context, id string) error

DeleteUser removes the user with the given ID. Deleting a user who is not there is not an error: the caller's intent is already satisfied.

func (*UserStore) GetUserByEmail

func (s *UserStore) GetUserByEmail(ctx context.Context, email string) (*sulis.User, error)

GetUserByEmail returns the user whose live address is email, or sulis.ErrUserNotFound. The comparison is the column's own, which is case-insensitive (see the schema): sulis normalizes an address to lowercase long before a store sees it, so this only ever differs from an exact match for a caller that skipped normalization, and there it fails safe.

func (*UserStore) GetUserByID

func (s *UserStore) GetUserByID(ctx context.Context, id string) (*sulis.User, error)

GetUserByID returns the user with the given ID, or sulis.ErrUserNotFound.

func (*UserStore) UpdateUser

func (s *UserStore) UpdateUser(ctx context.Context, user *sulis.User) error

UpdateUser applies user only while the stored row's version still matches the one the caller read, and only if the address it carries is not another user's:

UPDATE users SET ..., version = version + 1
 WHERE id = ? AND version = ?

Zero rows affected means either that no such user exists or that another writer already advanced the version, and the two are different errors to the caller, so a follow-up existence check inside the same transaction tells them apart: sulis.ErrUserNotFound or sulis.ErrConcurrentUpdate. A UNIQUE violation on the address is sulis.ErrUserAlreadyExists.

The version predicate is checked first by construction. A write that is both stale and colliding therefore reports the staleness, which is the more useful of the two: the caller must re-read either way, and the collision it would have hit was computed from a row it no longer has.

Jump to

Keyboard shortcuts

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