v0.13.3 account delete api, web client and toolchain update

This commit is contained in:
lixen committed 2026-10-12 21:34:06 -04:00
1 parent cc03dc0294
commit ffbf603bc8
16 files changed
+672 -57

No files matched your search

+31
View File
@@ -284,6 +284,37 @@ func (s *Store) enqueue(operation, gameID string, fn func(context.Context, *sql.
}
}
// enqueueWait queues fn on the ordered writer and waits for its own outcome,
// for a write that must be ordered after queued gameplay writes and must also
// report success only once it is durable.
func (s *Store) enqueueWait(ctx context.Context, operation string, fn func(context.Context, *sql.Tx) error) error {
s.enqueueMu.RLock()
if s.closed.Load() {
s.enqueueMu.RUnlock()
return ErrStoreClosed
}
if !s.healthStatus.Load() {
s.enqueueMu.RUnlock()
return ErrStorageDegraded
}
done := make(chan error, 1)
select {
case s.writeChan <- writeRequest{operation: operation, run: fn, barrier: done}:
s.enqueueMu.RUnlock()
case <-ctx.Done():
s.enqueueMu.RUnlock()
return ctx.Err()
}
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
// Flush waits until every write queued before this call has completed. Replay
// reads use this barrier to provide read-after-write consistency while normal
// gameplay retains the low-latency async write path.
+69
View File
@@ -837,6 +837,75 @@ func TestClaimsSnapshotPlayerNames(t *testing.T) {
}
}
func TestDeleteAccountClearsClaimsAndNames(t *testing.T) {
store := openStore(t, pgtest.DSN(t))
now := time.Now().UTC()
alice, bob := uuid.NewString(), uuid.NewString()
for id, name := range map[string]string{alice: "alice", bob: "bob"} {
if err := store.CreateUser(UserRecord{UserID: id, Username: name, PasswordHash: "hash", CreatedAt: now}); err != nil {
t.Fatal(err)
}
}
if err := store.CreateSession(SessionRecord{
SessionID: uuid.NewString(), UserID: alice, CreatedAt: now, ExpiresAt: now.Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
// Alice against Bob, and Alice alone against the computer.
shared, solo := uuid.NewString(), uuid.NewString()
for _, record := range []GameRecord{
{GameID: shared, InitialFEN: "initial",
WhitePlayerID: alice, WhiteType: 1, WhiteClaimedBy: alice,
BlackPlayerID: bob, BlackType: 1, BlackClaimedBy: bob, StartTimeUTC: now},
{GameID: solo, InitialFEN: "initial",
WhitePlayerID: uuid.NewString(), WhiteType: 2,
BlackPlayerID: alice, BlackType: 1, BlackClaimedBy: alice, StartTimeUTC: now},
} {
if err := store.RecordNewGame(record); err != nil {
t.Fatal(err)
}
}
if err := store.DeleteAccount(alice); err != nil {
t.Fatal(err)
}
if _, err := store.GetUserByID(alice); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("account still present: %v", err)
}
var sessions int
if err := store.db.QueryRow(`SELECT count(*) FROM sessions WHERE user_id = $1`, alice).Scan(&sessions); err != nil || sessions != 0 {
t.Fatalf("sessions left: %d, %v", sessions, err)
}
record, _, err := store.GetGameHistory(shared)
if err != nil || record.WhiteClaimedBy != "" || record.WhiteName != "" ||
record.BlackClaimedBy != bob || record.BlackName != "bob" {
t.Fatalf("shared game after deletion: white %q/%q black %q/%q, %v",
record.WhiteClaimedBy, record.WhiteName, record.BlackClaimedBy, record.BlackName, err)
}
// Nobody claims the solo game now, so the inactivity purge takes it.
if err := store.DeleteAnonymousGames(now.Add(time.Minute), nil); err != nil {
t.Fatal(err)
}
if err := store.Flush(context.Background()); err != nil {
t.Fatal(err)
}
if _, _, err := store.GetGameHistory(solo); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("solo game survived the anonymous purge: %v", err)
}
if _, _, err := store.GetGameHistory(shared); err != nil {
t.Fatalf("a game another user claims must stay: %v", err)
}
if err := store.DeleteAccount(alice); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("second deletion: %v, want sql.ErrNoRows", err)
}
if !store.IsHealthy() {
t.Fatal("deleting a missing account degraded the writer")
}
}
func timePointer(value time.Time) *time.Time {
return &value
}
+47
View File
@@ -1,6 +1,7 @@
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
@@ -124,6 +125,52 @@ func (s *Store) DeleteUser(userID string) error {
return nil
}
// DeleteAccount removes an account at its owner's request. Unlike DeleteUser,
// which keeps the claim IDs and name snapshots for the record, it first clears
// the user's claims and names from every stored game, so nothing in the
// database still names the person, and a game nobody else claimed becomes
// anonymous and falls to the inactivity purge. Sessions cascade.
//
// It runs on the ordered game writer, behind every gameplay write already
// queued, so a queued claim cannot land on a game after it was cleared. It
// returns sql.ErrNoRows when no such user exists.
func (s *Store) DeleteAccount(userID string) error {
if !validUUID(userID) {
return sql.ErrNoRows
}
// Set inside the transaction, read after the writer reports back: the
// barrier channel orders the two. A missing row is an answer, not a
// failed write, so it must not degrade the writer.
var deleted int64
// Behind the queue, then its own transaction.
ctx, cancel := context.WithTimeout(context.Background(), flushTimeout+writeTimeout)
defer cancel()
err := s.enqueueWait(ctx, "delete_account", func(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `UPDATE games SET
white_claimed_by = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_claimed_by END,
white_name = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_name END,
black_claimed_by = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_claimed_by END,
black_name = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_name END
WHERE white_claimed_by = $1 OR black_claimed_by = $1`, userID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `DELETE FROM users WHERE user_id = $1`, userID)
if err != nil {
return err
}
deleted, err = result.RowsAffected()
return err
})
if err != nil {
return err
}
if deleted == 0 {
return sql.ErrNoRows
}
slog.Debug("storage account deleted", "user_id", userID)
return nil
}
// UpdateUserPassword updates user password hash
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
return s.execUserUpdate(`UPDATE users SET password_hash = $2 WHERE user_id = $1`,